access logs + audio
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
DocumentText,
|
||||
DocumentDownload,
|
||||
Refresh,
|
||||
Activity,
|
||||
InfoCircle
|
||||
} from "iconsax-react";
|
||||
import Button from "../../components/Button";
|
||||
import Pagination from "../../components/Pagination";
|
||||
import TitleLine from "../../components/TitleLine";
|
||||
import PageLoading from "../../components/PageLoading";
|
||||
// import Error from "../../components/Error"; // Using custom error display
|
||||
import FilterPanel from "./components/FilterPanel";
|
||||
import AccessLogsTable from "./components/AccessLogsTable";
|
||||
import LogDetailsModal from "./components/LogDetailsModal";
|
||||
import { useAccessLogs, useAccessLogDetail } from "./hooks/useAccessLogs";
|
||||
import { AccessLog } from "./types/AccessLogTypes";
|
||||
|
||||
const AccessLogsList: React.FC = () => {
|
||||
const {
|
||||
logs,
|
||||
loading,
|
||||
error,
|
||||
total,
|
||||
filters,
|
||||
updateFilters,
|
||||
resetFilters,
|
||||
exportLogs,
|
||||
refetch,
|
||||
} = useAccessLogs();
|
||||
|
||||
const { log: selectedLog, fetchLog, clearLog } = useAccessLogDetail();
|
||||
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
|
||||
|
||||
const handleViewDetails = async (log: AccessLog) => {
|
||||
await fetchLog(log.id);
|
||||
setIsDetailsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseDetailsModal = () => {
|
||||
setIsDetailsModalOpen(false);
|
||||
clearLog();
|
||||
};
|
||||
|
||||
const handleSort = (field: string, order: "ASC" | "DESC") => {
|
||||
updateFilters({ sortBy: field, sortOrder: order });
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
updateFilters({ page });
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
await exportLogs();
|
||||
} catch (error) {
|
||||
console.error("خطا در خروجی گرفتن:", error);
|
||||
}
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<TitleLine title="لاگهای دسترسی" />
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-red-800">{error}</p>
|
||||
<Button
|
||||
onClick={refetch}
|
||||
className="bg-red-600 text-white hover:bg-red-700"
|
||||
>
|
||||
تلاش مجدد
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<TitleLine title="لاگهای دسترسی" />
|
||||
<p className="text-gray-600 mt-2">
|
||||
مشاهده و مدیریت تمام فعالیتهای سیستم
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
onClick={refetch}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 border border-gray-300 bg-white text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
<Refresh size={16} className={loading ? "animate-spin" : ""} />
|
||||
بروزرسانی
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={handleExport}
|
||||
disabled={loading || !logs || logs.length === 0}
|
||||
className="flex items-center gap-2 border border-gray-300 bg-white text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
<DocumentDownload size={16} />
|
||||
خروجی CSV
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Summary */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-shrink-0 p-2 bg-blue-100 rounded-lg">
|
||||
<DocumentText size={20} className="text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">کل لاگها</p>
|
||||
<p className="text-xl font-bold text-gray-900">
|
||||
{(total || 0).toLocaleString('fa-IR')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-shrink-0 p-2 bg-green-100 rounded-lg">
|
||||
<Activity size={20} className="text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">صفحه فعلی</p>
|
||||
<p className="text-xl font-bold text-gray-900">
|
||||
{filters.page || 1}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-shrink-0 p-2 bg-purple-100 rounded-lg">
|
||||
<InfoCircle size={20} className="text-purple-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">نمایش در صفحه</p>
|
||||
<p className="text-xl font-bold text-gray-900">
|
||||
{filters.limit || 20}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter Panel */}
|
||||
<FilterPanel
|
||||
filters={filters}
|
||||
onFiltersChange={updateFilters}
|
||||
onReset={resetFilters}
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
{/* Loading State */}
|
||||
{loading && <PageLoading />}
|
||||
|
||||
{/* Table */}
|
||||
{!loading && (
|
||||
<>
|
||||
<AccessLogsTable
|
||||
logs={logs}
|
||||
loading={loading}
|
||||
onViewDetails={handleViewDetails}
|
||||
onSort={handleSort}
|
||||
sortBy={filters.sortBy}
|
||||
sortOrder={filters.sortOrder}
|
||||
/>
|
||||
|
||||
{/* Pagination */}
|
||||
{total > (filters.limit || 20) && (
|
||||
<div className="flex justify-center">
|
||||
<Pagination
|
||||
currentPage={filters.page || 1}
|
||||
totalPages={Math.ceil(total / (filters.limit || 20))}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Details Modal */}
|
||||
<LogDetailsModal
|
||||
log={selectedLog}
|
||||
isOpen={isDetailsModalOpen}
|
||||
onClose={handleCloseDetailsModal}
|
||||
/>
|
||||
|
||||
{/* Empty State Info */}
|
||||
{!loading && (!logs || logs.length === 0) && !error && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<InfoCircle size={20} className="text-blue-600" />
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-blue-900">راهنما</h3>
|
||||
<p className="text-sm text-blue-700 mt-1">
|
||||
از فیلترهای بالا برای جستجوی دقیقتر استفاده کنید. میتوانید بر اساس نوع عملیات، کاربر، تاریخ و سایر معیارها جستجو کنید.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccessLogsList;
|
||||
@@ -0,0 +1,279 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Activity,
|
||||
Calendar,
|
||||
Refresh,
|
||||
TrendUp,
|
||||
InfoCircle
|
||||
} from "iconsax-react";
|
||||
import Button from "../../components/Button";
|
||||
import DatePicker from "../../components/DatePicker";
|
||||
import TitleLine from "../../components/TitleLine";
|
||||
import PageLoading from "../../components/PageLoading";
|
||||
// import Error from "../../components/Error"; // Using custom error display
|
||||
import StatsDashboard from "./components/StatsDashboard";
|
||||
import { useAccessLogStats } from "./hooks/useAccessLogs";
|
||||
import { AccessLogStatsQueryDto } from "./types/AccessLogTypes";
|
||||
|
||||
const AccessLogsStats: React.FC = () => {
|
||||
const { stats, topUsers, loading, error, fetchStats } = useAccessLogStats();
|
||||
|
||||
const [filters, setFilters] = useState<AccessLogStatsQueryDto>({
|
||||
startDate: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0], // 30 days ago
|
||||
endDate: new Date().toISOString().split('T')[0], // today
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
const handleFilterChange = (newFilters: Partial<AccessLogStatsQueryDto>) => {
|
||||
const updatedFilters = { ...filters, ...newFilters };
|
||||
setFilters(updatedFilters);
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
fetchStats(filters);
|
||||
};
|
||||
|
||||
const setPresetDateRange = (days: number) => {
|
||||
const endDate = new Date().toISOString().split('T')[0];
|
||||
const startDate = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
||||
|
||||
handleFilterChange({ startDate, endDate });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchStats(filters);
|
||||
}, [filters, fetchStats]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<TitleLine title="آمار لاگهای دسترسی" />
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-red-800">{error}</p>
|
||||
<Button
|
||||
onClick={handleRefresh}
|
||||
className="bg-red-600 text-white hover:bg-red-700"
|
||||
>
|
||||
تلاش مجدد
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<TitleLine title="آمار لاگهای دسترسی" />
|
||||
<p className="text-gray-600 mt-2">
|
||||
تحلیل و بررسی آماری فعالیتهای سیستم
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleRefresh}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 border border-gray-300 bg-white text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
<Refresh size={16} className={loading ? "animate-spin" : ""} />
|
||||
بروزرسانی
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Date Range Filter */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar size={20} className="text-gray-600" />
|
||||
<h3 className="text-lg font-semibold text-gray-900">بازه زمانی</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
از تاریخ
|
||||
</label>
|
||||
<DatePicker
|
||||
defaulValue={filters.startDate || ""}
|
||||
onChange={(value: string) => handleFilterChange({ startDate: value })}
|
||||
placeholder="انتخاب تاریخ شروع"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
تا تاریخ
|
||||
</label>
|
||||
<DatePicker
|
||||
defaulValue={filters.endDate || ""}
|
||||
onChange={(value: string) => handleFilterChange({ endDate: value })}
|
||||
placeholder="انتخاب تاریخ پایان"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
حداکثر نتایج
|
||||
</label>
|
||||
<select
|
||||
value={filters.limit || 10}
|
||||
onChange={(e) => handleFilterChange({ limit: parseInt(e.target.value) })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
disabled={loading}
|
||||
>
|
||||
<option value={5}>5</option>
|
||||
<option value={10}>10</option>
|
||||
<option value={20}>20</option>
|
||||
<option value={50}>50</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Date Range Buttons */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
onClick={() => setPresetDateRange(1)}
|
||||
disabled={loading}
|
||||
className="text-sm px-3 py-1 border border-gray-300 bg-white text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
امروز
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setPresetDateRange(7)}
|
||||
disabled={loading}
|
||||
className="text-sm px-3 py-1 border border-gray-300 bg-white text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
7 روز گذشته
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setPresetDateRange(30)}
|
||||
disabled={loading}
|
||||
className="text-sm px-3 py-1 border border-gray-300 bg-white text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
30 روز گذشته
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setPresetDateRange(90)}
|
||||
disabled={loading}
|
||||
className="text-sm px-3 py-1 border border-gray-300 bg-white text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
3 ماه گذشته
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setPresetDateRange(365)}
|
||||
disabled={loading}
|
||||
className="text-sm px-3 py-1 border border-gray-300 bg-white text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
سال گذشته
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{loading && <PageLoading />}
|
||||
|
||||
{/* Stats Dashboard */}
|
||||
{!loading && (
|
||||
<StatsDashboard
|
||||
stats={stats}
|
||||
topUsers={topUsers}
|
||||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Info Panel */}
|
||||
{!loading && (!stats?.operationStats.length || !topUsers?.topUsers.length) && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<InfoCircle size={24} className="text-blue-600 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-blue-900 mb-2">اطلاعات</h3>
|
||||
<div className="text-blue-700 space-y-2">
|
||||
<p>• آمار بر اساس بازه زمانی انتخاب شده محاسبه میشود</p>
|
||||
<p>• برای مشاهده آمار دقیقتر، بازه زمانی مناسب انتخاب کنید</p>
|
||||
<p>• امکان خروجی گرفتن از آمار در قالب CSV موجود است</p>
|
||||
<p>• آمار به صورت لحظهای بروزرسانی میشود</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Additional Stats Info */}
|
||||
{!loading && stats && topUsers && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Activity size={20} className="text-blue-600" />
|
||||
<h3 className="text-lg font-semibold text-gray-900">خلاصه عملیات</h3>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-600">کل نوع عملیات:</span>
|
||||
<span className="font-semibold text-gray-900">
|
||||
{(stats?.operationStats?.length || 0).toLocaleString('fa-IR')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-600">کل فعالیت:</span>
|
||||
<span className="font-semibold text-gray-900">
|
||||
{(stats?.operationStats?.reduce((sum: number, stat: any) => sum + stat.count, 0) || 0).toLocaleString('fa-IR')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-600">میانگین فعالیت روزانه:</span>
|
||||
<span className="font-semibold text-gray-900">
|
||||
{filters.startDate && filters.endDate ?
|
||||
Math.round(stats.operationStats.reduce((sum: number, stat: any) => sum + stat.count, 0) /
|
||||
Math.max(1, Math.ceil((new Date(filters.endDate).getTime() - new Date(filters.startDate).getTime()) / (1000 * 60 * 60 * 24)))).toLocaleString('fa-IR')
|
||||
: '-'
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<TrendUp size={20} className="text-green-600" />
|
||||
<h3 className="text-lg font-semibold text-gray-900">خلاصه کاربران</h3>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-600">کل کاربران فعال:</span>
|
||||
<span className="font-semibold text-gray-900">
|
||||
{(topUsers?.topUsers?.length || 0).toLocaleString('fa-IR')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-600">بیشترین فعالیت:</span>
|
||||
<span className="font-semibold text-gray-900">
|
||||
{topUsers.topUsers.length > 0 ?
|
||||
(topUsers?.topUsers?.[0]?.activityCount || 0).toLocaleString('fa-IR') :
|
||||
'0'
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-600">میانگین فعالیت کاربر:</span>
|
||||
<span className="font-semibold text-gray-900">
|
||||
{topUsers.topUsers.length > 0 ?
|
||||
Math.round((topUsers?.topUsers?.reduce((sum: number, user: any) => sum + user.activityCount, 0) || 0) / (topUsers?.topUsers?.length || 1)).toLocaleString('fa-IR') :
|
||||
'0'
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccessLogsStats;
|
||||
@@ -0,0 +1,303 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Warning2,
|
||||
Refresh,
|
||||
InfoCircle,
|
||||
CloseCircle
|
||||
} from "iconsax-react";
|
||||
import Button from "../../components/Button";
|
||||
import Pagination from "../../components/Pagination";
|
||||
import TitleLine from "../../components/TitleLine";
|
||||
import PageLoading from "../../components/PageLoading";
|
||||
// import Error from "../../components/Error"; // Using custom error display
|
||||
import FilterPanel from "./components/FilterPanel";
|
||||
import AccessLogsTable from "./components/AccessLogsTable";
|
||||
import LogDetailsModal from "./components/LogDetailsModal";
|
||||
import { useErrorLogs, useAccessLogDetail } from "./hooks/useAccessLogs";
|
||||
import { AccessLog } from "./types/AccessLogTypes";
|
||||
|
||||
const ErrorLogs: React.FC = () => {
|
||||
const {
|
||||
errorLogs,
|
||||
loading,
|
||||
error,
|
||||
total,
|
||||
filters,
|
||||
updateFilters,
|
||||
refetch,
|
||||
} = useErrorLogs();
|
||||
|
||||
const { log: selectedLog, fetchLog, clearLog } = useAccessLogDetail();
|
||||
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
|
||||
|
||||
const handleViewDetails = async (log: AccessLog) => {
|
||||
await fetchLog(log.id);
|
||||
setIsDetailsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseDetailsModal = () => {
|
||||
setIsDetailsModalOpen(false);
|
||||
clearLog();
|
||||
};
|
||||
|
||||
const handleSort = (field: string, order: "ASC" | "DESC") => {
|
||||
updateFilters({ sortBy: field, sortOrder: order });
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
updateFilters({ page });
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
updateFilters({
|
||||
page: 1,
|
||||
limit: 20,
|
||||
sortBy: "createdAt",
|
||||
sortOrder: "DESC",
|
||||
operationType: undefined,
|
||||
userType: undefined,
|
||||
entityName: "",
|
||||
entityId: "",
|
||||
userId: "",
|
||||
ipAddress: "",
|
||||
endpoint: "",
|
||||
httpMethod: undefined,
|
||||
statusCode: undefined,
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
search: "",
|
||||
minExecutionTime: undefined,
|
||||
maxExecutionTime: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
// Count errors by status code
|
||||
const errorsByStatusCode = (errorLogs || []).reduce((acc: Record<string, number>, log: AccessLog) => {
|
||||
const statusCode = log.statusCode || 0;
|
||||
const range = statusCode >= 500 ? "5xx" : statusCode >= 400 ? "4xx" : "other";
|
||||
acc[range] = (acc[range] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
|
||||
// Count recent errors (last 24 hours)
|
||||
const recentErrors = (errorLogs || []).filter((log: AccessLog) => {
|
||||
const logDate = new Date(log.createdAt);
|
||||
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
return logDate > oneDayAgo;
|
||||
}).length;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<TitleLine title="لاگهای خطا" />
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-red-800">{error}</p>
|
||||
<Button
|
||||
onClick={refetch}
|
||||
className="bg-red-600 text-white hover:bg-red-700"
|
||||
>
|
||||
تلاش مجدد
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<TitleLine title="لاگهای خطا" />
|
||||
<p className="text-gray-600 mt-2">
|
||||
مشاهده و تحلیل خطاهای سیستم
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
onClick={refetch}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 border border-gray-300 bg-white text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
<Refresh size={16} className={loading ? "animate-spin" : ""} />
|
||||
بروزرسانی
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-shrink-0 p-2 bg-red-100 rounded-lg">
|
||||
<Warning2 size={20} className="text-red-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">کل خطاها</p>
|
||||
<p className="text-xl font-bold text-gray-900">
|
||||
{(total || 0).toLocaleString('fa-IR')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-shrink-0 p-2 bg-orange-100 rounded-lg">
|
||||
<CloseCircle size={20} className="text-orange-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">خطاهای 4xx</p>
|
||||
<p className="text-xl font-bold text-gray-900">
|
||||
{(errorsByStatusCode["4xx"] || 0).toLocaleString('fa-IR')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-shrink-0 p-2 bg-red-100 rounded-lg">
|
||||
<Warning2 size={20} className="text-red-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">خطاهای 5xx</p>
|
||||
<p className="text-xl font-bold text-gray-900">
|
||||
{(errorsByStatusCode["5xx"] || 0).toLocaleString('fa-IR')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-shrink-0 p-2 bg-yellow-100 rounded-lg">
|
||||
<InfoCircle size={20} className="text-yellow-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">24 ساعت گذشته</p>
|
||||
<p className="text-xl font-bold text-gray-900">
|
||||
{recentErrors.toLocaleString('fa-IR')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Severity Alert */}
|
||||
{errorsByStatusCode["5xx"] > 0 && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Warning2 size={20} className="text-red-600" />
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-red-900">هشدار خطاهای سرور</h3>
|
||||
<p className="text-sm text-red-700 mt-1">
|
||||
{errorsByStatusCode["5xx"]} خطای سرور (5xx) شناسایی شده است.
|
||||
لطفاً این خطاها را بررسی و رفع کنید.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent Errors Alert */}
|
||||
{recentErrors > 10 && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<InfoCircle size={20} className="text-yellow-600" />
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-yellow-900">افزایش خطاها</h3>
|
||||
<p className="text-sm text-yellow-700 mt-1">
|
||||
{recentErrors} خطا در 24 ساعت گذشته رخ داده است.
|
||||
ممکن است نیاز به بررسی بیشتر باشد.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter Panel */}
|
||||
<FilterPanel
|
||||
filters={filters}
|
||||
onFiltersChange={updateFilters}
|
||||
onReset={resetFilters}
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
{/* Loading State */}
|
||||
{loading && <PageLoading />}
|
||||
|
||||
{/* Table */}
|
||||
{!loading && (
|
||||
<>
|
||||
<AccessLogsTable
|
||||
logs={errorLogs}
|
||||
loading={loading}
|
||||
onViewDetails={handleViewDetails}
|
||||
onSort={handleSort}
|
||||
sortBy={filters.sortBy}
|
||||
sortOrder={filters.sortOrder}
|
||||
/>
|
||||
|
||||
{/* Pagination */}
|
||||
{total > (filters.limit || 20) && (
|
||||
<div className="flex justify-center">
|
||||
<Pagination
|
||||
currentPage={filters.page || 1}
|
||||
totalPages={Math.ceil(total / (filters.limit || 20))}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Details Modal */}
|
||||
<LogDetailsModal
|
||||
log={selectedLog}
|
||||
isOpen={isDetailsModalOpen}
|
||||
onClose={handleCloseDetailsModal}
|
||||
/>
|
||||
|
||||
{/* Empty State Info */}
|
||||
{!loading && (!errorLogs || errorLogs.length === 0) && !error && (
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<InfoCircle size={24} className="text-green-600" />
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-green-900">عالی! هیچ خطایی یافت نشد</h3>
|
||||
<p className="text-green-700 mt-1">
|
||||
در بازه زمانی و فیلترهای انتخاب شده، هیچ خطایی ثبت نشده است.
|
||||
این نشاندهنده عملکرد مناسب سیستم است.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Analysis Tips */}
|
||||
{!loading && errorLogs && errorLogs.length > 0 && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<InfoCircle size={24} className="text-blue-600 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-blue-900 mb-2">راهنمای تحلیل خطاها</h3>
|
||||
<div className="text-blue-700 space-y-2">
|
||||
<p>• <strong>خطاهای 4xx:</strong> مربوط به درخواستهای نامعتبر کاربران</p>
|
||||
<p>• <strong>خطاهای 5xx:</strong> مربوط به مشکلات سرور که نیاز به رفع فوری دارند</p>
|
||||
<p>• <strong>الگوهای تکراری:</strong> خطاهای مشابه ممکن است نشاندهنده مشکل سیستمی باشند</p>
|
||||
<p>• <strong>زمانبندی:</strong> توجه به زمان وقوع خطاها برای شناسایی الگوهای زمانی</p>
|
||||
<p>• <strong>IP و کاربران:</strong> شناسایی منابع خطاها برای بهبود تجربه کاربری</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ErrorLogs;
|
||||
@@ -0,0 +1,293 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Shield,
|
||||
Refresh,
|
||||
InfoCircle,
|
||||
TickCircle,
|
||||
CloseCircle
|
||||
} from "iconsax-react";
|
||||
import Button from "../../components/Button";
|
||||
import Pagination from "../../components/Pagination";
|
||||
import TitleLine from "../../components/TitleLine";
|
||||
import PageLoading from "../../components/PageLoading";
|
||||
import FilterPanel from "./components/FilterPanel";
|
||||
import AccessLogsTable from "./components/AccessLogsTable";
|
||||
import LogDetailsModal from "./components/LogDetailsModal";
|
||||
import { useErrorLogs, useAccessLogDetail } from "./hooks/useAccessLogs";
|
||||
import { AccessLog } from "./types/AccessLogTypes";
|
||||
|
||||
const PermissionLogs: React.FC = () => {
|
||||
const {
|
||||
errorLogs,
|
||||
loading,
|
||||
error,
|
||||
total,
|
||||
filters,
|
||||
updateFilters,
|
||||
refetch,
|
||||
} = useErrorLogs();
|
||||
|
||||
const { log: selectedLog, fetchLog, clearLog } = useAccessLogDetail();
|
||||
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
|
||||
|
||||
const handleViewDetails = async (log: AccessLog) => {
|
||||
await fetchLog(log.id);
|
||||
setIsDetailsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseDetailsModal = () => {
|
||||
setIsDetailsModalOpen(false);
|
||||
clearLog();
|
||||
};
|
||||
|
||||
const handleSort = (field: string, order: "ASC" | "DESC") => {
|
||||
updateFilters({ sortBy: field, sortOrder: order });
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
updateFilters({ page });
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
updateFilters({
|
||||
page: 1,
|
||||
limit: 20,
|
||||
sortBy: "createdAt",
|
||||
sortOrder: "DESC",
|
||||
operationType: undefined,
|
||||
userType: undefined,
|
||||
entityName: "",
|
||||
entityId: "",
|
||||
userId: "",
|
||||
ipAddress: "",
|
||||
endpoint: "",
|
||||
httpMethod: undefined,
|
||||
statusCode: undefined,
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
search: "",
|
||||
minExecutionTime: undefined,
|
||||
maxExecutionTime: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
// Filter permission-related logs (assuming they have specific operation types or endpoints)
|
||||
const permissionLogs = (errorLogs || []).filter((log: AccessLog) => {
|
||||
return log.operationType?.includes('permission') ||
|
||||
log.endpoint?.includes('permission') ||
|
||||
log.entityName?.includes('role') ||
|
||||
log.entityName?.includes('permission');
|
||||
});
|
||||
|
||||
// Count permission actions
|
||||
const permissionStats = permissionLogs.reduce((acc: Record<string, number>, log: AccessLog) => {
|
||||
const action = log.operationType || 'unknown';
|
||||
acc[action] = (acc[action] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
|
||||
// Count recent permission changes (last 24 hours)
|
||||
const recentPermissions = permissionLogs.filter((log: AccessLog) => {
|
||||
const logDate = new Date(log.createdAt);
|
||||
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
return logDate > oneDayAgo;
|
||||
}).length;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<TitleLine title="لاگهای مجوز" />
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-red-800">{error}</p>
|
||||
<Button
|
||||
onClick={refetch}
|
||||
className="bg-red-600 text-white hover:bg-red-700"
|
||||
>
|
||||
تلاش مجدد
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<TitleLine title="لاگهای مجوز" />
|
||||
<p className="text-gray-600 mt-2">
|
||||
مشاهده و تحلیل تغییرات مجوزها و نقشهای کاربری
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
onClick={refetch}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 border border-gray-300 bg-white text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
<Refresh size={16} className={loading ? "animate-spin" : ""} />
|
||||
بروزرسانی
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Permission Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-shrink-0 p-2 bg-blue-100 rounded-lg">
|
||||
<Shield size={20} className="text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">کل تغییرات مجوز</p>
|
||||
<p className="text-xl font-bold text-gray-900">
|
||||
{permissionLogs.length.toLocaleString('fa-IR')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-shrink-0 p-2 bg-green-100 rounded-lg">
|
||||
<TickCircle size={20} className="text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">مجوزهای اعطا شده</p>
|
||||
<p className="text-xl font-bold text-gray-900">
|
||||
{(permissionStats['grant'] || 0).toLocaleString('fa-IR')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-shrink-0 p-2 bg-red-100 rounded-lg">
|
||||
<CloseCircle size={20} className="text-red-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">مجوزهای لغو شده</p>
|
||||
<p className="text-xl font-bold text-gray-900">
|
||||
{(permissionStats['revoke'] || 0).toLocaleString('fa-IR')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-shrink-0 p-2 bg-yellow-100 rounded-lg">
|
||||
<InfoCircle size={20} className="text-yellow-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">24 ساعت گذشته</p>
|
||||
<p className="text-xl font-bold text-gray-900">
|
||||
{recentPermissions.toLocaleString('fa-IR')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Security Alert */}
|
||||
{recentPermissions > 5 && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Shield size={20} className="text-yellow-600" />
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-yellow-900">تغییرات زیاد مجوز</h3>
|
||||
<p className="text-sm text-yellow-700 mt-1">
|
||||
{recentPermissions} تغییر مجوز در 24 ساعت گذشته رخ داده است.
|
||||
لطفاً این تغییرات را بررسی کنید.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter Panel */}
|
||||
<FilterPanel
|
||||
filters={filters}
|
||||
onFiltersChange={updateFilters}
|
||||
onReset={resetFilters}
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
{/* Loading State */}
|
||||
{loading && <PageLoading />}
|
||||
|
||||
{/* Table */}
|
||||
{!loading && (
|
||||
<>
|
||||
<AccessLogsTable
|
||||
logs={permissionLogs}
|
||||
loading={loading}
|
||||
onViewDetails={handleViewDetails}
|
||||
onSort={handleSort}
|
||||
sortBy={filters.sortBy}
|
||||
sortOrder={filters.sortOrder}
|
||||
/>
|
||||
|
||||
{/* Pagination */}
|
||||
{total > (filters.limit || 20) && (
|
||||
<div className="flex justify-center">
|
||||
<Pagination
|
||||
currentPage={filters.page || 1}
|
||||
totalPages={Math.ceil(total / (filters.limit || 20))}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Details Modal */}
|
||||
<LogDetailsModal
|
||||
log={selectedLog}
|
||||
isOpen={isDetailsModalOpen}
|
||||
onClose={handleCloseDetailsModal}
|
||||
/>
|
||||
|
||||
{/* Empty State Info */}
|
||||
{!loading && (!permissionLogs || permissionLogs.length === 0) && !error && (
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<InfoCircle size={24} className="text-green-600" />
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-green-900">هیچ تغییر مجوزی یافت نشد</h3>
|
||||
<p className="text-green-700 mt-1">
|
||||
در بازه زمانی و فیلترهای انتخاب شده، هیچ تغییر مجوزی ثبت نشده است.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Permission Analysis Tips */}
|
||||
{!loading && permissionLogs && permissionLogs.length > 0 && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<InfoCircle size={24} className="text-blue-600 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-blue-900 mb-2">راهنمای تحلیل مجوزها</h3>
|
||||
<div className="text-blue-700 space-y-2">
|
||||
<p>• <strong>تغییرات نقش:</strong> بررسی تغییرات نقشهای کاربری و مجوزهای مربوطه</p>
|
||||
<p>• <strong>دسترسیهای حساس:</strong> نظارت بر دسترسیهای مهم و حساس سیستم</p>
|
||||
<p>• <strong>الگوهای دسترسی:</strong> شناسایی الگوهای غیرعادی در دسترسیها</p>
|
||||
<p>• <strong>زمانبندی:</strong> توجه به زمان تغییرات مجوزها</p>
|
||||
<p>• <strong>کاربران:</strong> شناسایی کاربرانی که تغییرات مجوز زیادی دارند</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PermissionLogs;
|
||||
@@ -0,0 +1,241 @@
|
||||
import React from "react";
|
||||
import { Eye, ArrowUp, ArrowDown, Clock, User, DocumentText } from "iconsax-react";
|
||||
import Button from "../../../components/Button";
|
||||
import StatusCircle from "../../../components/StatusCircle";
|
||||
import Td from "../../../components/Td";
|
||||
import {
|
||||
AccessLog,
|
||||
OperationType,
|
||||
UserType,
|
||||
OperationTypeLabels,
|
||||
UserTypeLabels,
|
||||
} from "../types/AccessLogTypes";
|
||||
|
||||
interface AccessLogsTableProps {
|
||||
logs: AccessLog[];
|
||||
loading: boolean;
|
||||
onViewDetails: (log: AccessLog) => void;
|
||||
onSort: (field: string, order: "ASC" | "DESC") => void;
|
||||
sortBy?: string;
|
||||
sortOrder?: "ASC" | "DESC";
|
||||
}
|
||||
|
||||
const AccessLogsTable: React.FC<AccessLogsTableProps> = ({
|
||||
logs,
|
||||
loading,
|
||||
onViewDetails,
|
||||
onSort,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
}) => {
|
||||
const getOperationTypeColor = (type: OperationType): string => {
|
||||
const colors = {
|
||||
CREATE: "green",
|
||||
UPDATE: "blue",
|
||||
DELETE: "red",
|
||||
READ: "gray",
|
||||
LOGIN: "purple",
|
||||
LOGOUT: "orange",
|
||||
APPROVE: "green",
|
||||
REJECT: "red",
|
||||
ACTIVATE: "green",
|
||||
DEACTIVATE: "red",
|
||||
EXPORT: "blue",
|
||||
IMPORT: "blue",
|
||||
BULK_UPDATE: "blue",
|
||||
BULK_DELETE: "red",
|
||||
};
|
||||
return colors[type as keyof typeof colors] || "gray";
|
||||
};
|
||||
|
||||
const getUserTypeBadgeColor = (type: UserType): string => {
|
||||
const colors = {
|
||||
SYSTEM: "bg-gray-100 text-gray-800",
|
||||
ADMIN: "bg-red-100 text-red-800",
|
||||
USER: "bg-blue-100 text-blue-800",
|
||||
};
|
||||
return colors[type as keyof typeof colors] || "bg-gray-100 text-gray-800";
|
||||
};
|
||||
|
||||
const getStatusColor = (statusCode?: number): string => {
|
||||
if (!statusCode) return "bg-gray-100 text-gray-800";
|
||||
if (statusCode >= 200 && statusCode < 300) return "bg-green-100 text-green-800";
|
||||
if (statusCode >= 400 && statusCode < 500) return "bg-yellow-100 text-yellow-800";
|
||||
if (statusCode >= 500) return "bg-red-100 text-red-800";
|
||||
return "bg-gray-100 text-gray-800";
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string): string => {
|
||||
const date = new Date(dateString);
|
||||
return new Intl.DateTimeFormat("fa-IR", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
const formatExecutionTime = (time?: number): string => {
|
||||
if (!time) return "-";
|
||||
if (time < 1000) return `${time}ms`;
|
||||
return `${(time / 1000).toFixed(2)}s`;
|
||||
};
|
||||
|
||||
const renderSortButton = (field: string, label: string) => (
|
||||
<button
|
||||
className="flex items-center gap-1 text-xs font-medium text-gray-500 hover:text-gray-700"
|
||||
onClick={() => {
|
||||
const newOrder = sortBy === field && sortOrder === "ASC" ? "DESC" : "ASC";
|
||||
onSort(field, newOrder);
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
{sortBy === field ? (
|
||||
sortOrder === "ASC" ? (
|
||||
<ArrowUp size={14} />
|
||||
) : (
|
||||
<ArrowDown size={14} />
|
||||
)
|
||||
) : (
|
||||
<div className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className='relative overflow-x-auto rounded-3xl mt-9 w-full'>
|
||||
<div className="p-8 text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">در حال بارگذاری...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!logs || logs.length === 0) {
|
||||
return (
|
||||
<div className='relative overflow-x-auto rounded-3xl mt-9 w-full'>
|
||||
<div className="p-8 text-center">
|
||||
<DocumentText size={48} className="text-gray-400 mx-auto mb-4" />
|
||||
<p className="text-gray-600 text-lg mb-2">هیچ لاگی یافت نشد</p>
|
||||
<p className="text-gray-500">فیلترهای خود را تغییر دهید یا دوباره تلاش کنید</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='relative overflow-x-auto rounded-3xl mt-9 w-full'>
|
||||
<table className='w-full text-sm'>
|
||||
<thead className='thead'>
|
||||
<tr>
|
||||
<Td text="">
|
||||
{renderSortButton("createdAt", "تاریخ و زمان")}
|
||||
</Td>
|
||||
<Td text="نوع عملیات" />
|
||||
<Td text="نوع کاربر" />
|
||||
<Td text="موجودیت" />
|
||||
<Td text="توضیحات" />
|
||||
<Td text="کاربر" />
|
||||
<Td text="IP" />
|
||||
<Td text="وضعیت" />
|
||||
<Td text="زمان اجرا" />
|
||||
<Td text="عملیات" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logs.map((log) => (
|
||||
<tr key={log.id} className='tr'>
|
||||
<Td text="">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock size={16} className="text-gray-400" />
|
||||
{formatDate(log.createdAt)}
|
||||
</div>
|
||||
</Td>
|
||||
|
||||
<Td text="">
|
||||
<StatusCircle
|
||||
color={getOperationTypeColor(log.operationType)}
|
||||
text={OperationTypeLabels[log.operationType]}
|
||||
/>
|
||||
</Td>
|
||||
|
||||
<Td text="">
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getUserTypeBadgeColor(log.userType)}`}>
|
||||
{UserTypeLabels[log.userType]}
|
||||
</span>
|
||||
</Td>
|
||||
|
||||
<Td text="">
|
||||
<div>
|
||||
<div className="font-medium">{log.entityName}</div>
|
||||
{log.entityId && (
|
||||
<div className="text-xs text-gray-500 truncate max-w-[100px]">
|
||||
{log.entityId}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Td>
|
||||
|
||||
<Td text="">
|
||||
<div className="truncate max-w-xs" title={log.actionDescription}>
|
||||
{log.actionDescription || "-"}
|
||||
</div>
|
||||
</Td>
|
||||
|
||||
<Td text="">
|
||||
{log.user ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<User size={16} className="text-gray-400" />
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{log.user.firstName} {log.user.lastName}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{log.user.email}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-500">سیستم</span>
|
||||
)}
|
||||
</Td>
|
||||
|
||||
<Td text={log.ipAddress || "-"} />
|
||||
|
||||
<Td text="">
|
||||
<div>
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusColor(log.statusCode)}`}>
|
||||
{log.statusCode || "N/A"}
|
||||
</span>
|
||||
{log.errorMessage && (
|
||||
<div className="text-xs text-red-600 mt-1 truncate max-w-[100px]" title={log.errorMessage}>
|
||||
{log.errorMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Td>
|
||||
|
||||
<Td text={formatExecutionTime(log.executionTime)} />
|
||||
|
||||
<Td text="">
|
||||
<Button
|
||||
onClick={() => onViewDetails(log)}
|
||||
className="text-sm px-3 py-1 bg-transparent text-blue-600 hover:text-blue-800 hover:bg-blue-50"
|
||||
>
|
||||
<Eye size={16} className="ml-1" />
|
||||
جزئیات
|
||||
</Button>
|
||||
</Td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccessLogsTable;
|
||||
@@ -0,0 +1,253 @@
|
||||
import React from "react";
|
||||
import { Filter, CloseSquare } from "iconsax-react";
|
||||
import Input from "../../../components/Input";
|
||||
import Select from "../../../components/Select";
|
||||
import DatePicker from "../../../components/DatePicker";
|
||||
import Button from "../../../components/Button";
|
||||
import {
|
||||
AccessLogQuery,
|
||||
OperationType,
|
||||
UserType,
|
||||
HttpMethod,
|
||||
OperationTypeLabels,
|
||||
UserTypeLabels,
|
||||
HttpMethodLabels,
|
||||
} from "../types/AccessLogTypes";
|
||||
|
||||
interface FilterPanelProps {
|
||||
filters: AccessLogQuery;
|
||||
onFiltersChange: (filters: Partial<AccessLogQuery>) => void;
|
||||
onReset: () => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const FilterPanel: React.FC<FilterPanelProps> = ({
|
||||
filters,
|
||||
onFiltersChange,
|
||||
onReset,
|
||||
loading = false,
|
||||
}) => {
|
||||
const operationTypeOptions = Object.values(OperationType).map((type) => ({
|
||||
value: type,
|
||||
label: OperationTypeLabels[type],
|
||||
}));
|
||||
|
||||
const userTypeOptions = Object.values(UserType).map((type) => ({
|
||||
value: type,
|
||||
label: UserTypeLabels[type],
|
||||
}));
|
||||
|
||||
const httpMethodOptions = Object.values(HttpMethod).map((method) => ({
|
||||
value: method,
|
||||
label: HttpMethodLabels[method],
|
||||
}));
|
||||
|
||||
const statusCodeOptions = [
|
||||
{ value: "200", label: "200 - موفق" },
|
||||
{ value: "201", label: "201 - ایجاد شده" },
|
||||
{ value: "400", label: "400 - درخواست نامعتبر" },
|
||||
{ value: "401", label: "401 - عدم احراز هویت" },
|
||||
{ value: "403", label: "403 - عدم دسترسی" },
|
||||
{ value: "404", label: "404 - یافت نشد" },
|
||||
{ value: "500", label: "500 - خطای سرور" },
|
||||
];
|
||||
|
||||
const hasActiveFilters = () => {
|
||||
return Object.entries(filters).some(([key, value]) => {
|
||||
if (key === "page" || key === "limit" || key === "sortBy" || key === "sortOrder") {
|
||||
return false;
|
||||
}
|
||||
return value !== undefined && value !== null && value !== "";
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter size={20} className="text-gray-600" />
|
||||
<h3 className="text-lg font-semibold text-gray-900">فیلترها</h3>
|
||||
{hasActiveFilters() && (
|
||||
<span className="bg-blue-100 text-blue-800 text-xs font-medium px-2 py-1 rounded-full">
|
||||
فعال
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{hasActiveFilters() && (
|
||||
<Button
|
||||
onClick={onReset}
|
||||
disabled={loading}
|
||||
className="text-sm px-3 py-1 border border-gray-300 bg-white text-gray-600 hover:text-gray-800"
|
||||
>
|
||||
<CloseSquare size={16} className="ml-1" />
|
||||
پاک کردن فیلترها
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{/* Search */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
جستجو
|
||||
</label>
|
||||
<Input
|
||||
placeholder="جستجو در توضیحات و نام موجودیت..."
|
||||
value={filters.search || ""}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onFiltersChange({ search: e.target.value })}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Operation Type */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
نوع عملیات
|
||||
</label>
|
||||
<Select
|
||||
items={[{ value: "", label: "همه" }, ...operationTypeOptions]}
|
||||
value={filters.operationType || ""}
|
||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => onFiltersChange({ operationType: e.target.value as OperationType })}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* User Type */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
نوع کاربر
|
||||
</label>
|
||||
<Select
|
||||
items={[{ value: "", label: "همه" }, ...userTypeOptions]}
|
||||
value={filters.userType || ""}
|
||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => onFiltersChange({ userType: e.target.value as UserType })}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Entity Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
نام موجودیت
|
||||
</label>
|
||||
<Input
|
||||
placeholder="مثال: User, Blog, Service"
|
||||
value={filters.entityName || ""}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onFiltersChange({ entityName: e.target.value })}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* HTTP Method */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
متد HTTP
|
||||
</label>
|
||||
<Select
|
||||
items={[{ value: "", label: "همه" }, ...httpMethodOptions]}
|
||||
value={filters.httpMethod || ""}
|
||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => onFiltersChange({ httpMethod: e.target.value as HttpMethod })}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status Code */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
کد وضعیت
|
||||
</label>
|
||||
<Select
|
||||
items={[{ value: "", label: "همه" }, ...statusCodeOptions]}
|
||||
value={filters.statusCode?.toString() || ""}
|
||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => onFiltersChange({ statusCode: e.target.value ? parseInt(e.target.value) : undefined })}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* IP Address */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
آدرس IP
|
||||
</label>
|
||||
<Input
|
||||
placeholder="192.168.1.1"
|
||||
value={filters.ipAddress || ""}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onFiltersChange({ ipAddress: e.target.value })}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* User ID */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
شناسه کاربر
|
||||
</label>
|
||||
<Input
|
||||
placeholder="شناسه کاربر"
|
||||
value={filters.userId || ""}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onFiltersChange({ userId: e.target.value })}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Start Date */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
از تاریخ
|
||||
</label>
|
||||
<DatePicker
|
||||
defaulValue={filters.startDate || ""}
|
||||
onChange={(value: string) => onFiltersChange({ startDate: value })}
|
||||
placeholder="انتخاب تاریخ شروع"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* End Date */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
تا تاریخ
|
||||
</label>
|
||||
<DatePicker
|
||||
defaulValue={filters.endDate || ""}
|
||||
onChange={(value: string) => onFiltersChange({ endDate: value })}
|
||||
placeholder="انتخاب تاریخ پایان"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Min Execution Time */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
حداقل زمان اجرا (ms)
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="0"
|
||||
value={filters.minExecutionTime?.toString() || ""}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onFiltersChange({
|
||||
minExecutionTime: e.target.value ? parseInt(e.target.value) : undefined
|
||||
})}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Max Execution Time */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
حداکثر زمان اجرا (ms)
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="1000"
|
||||
value={filters.maxExecutionTime?.toString() || ""}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onFiltersChange({
|
||||
maxExecutionTime: e.target.value ? parseInt(e.target.value) : undefined
|
||||
})}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilterPanel;
|
||||
@@ -0,0 +1,365 @@
|
||||
import React from "react";
|
||||
import {
|
||||
CloseSquare,
|
||||
InfoCircle,
|
||||
User,
|
||||
Clock,
|
||||
Monitor,
|
||||
Code,
|
||||
Warning2,
|
||||
DocumentText,
|
||||
Activity
|
||||
} from "iconsax-react";
|
||||
import DefaulModal from "../../../components/DefaulModal";
|
||||
import Button from "../../../components/Button";
|
||||
import StatusCircle from "../../../components/StatusCircle";
|
||||
import {
|
||||
AccessLog,
|
||||
OperationType,
|
||||
UserType,
|
||||
OperationTypeLabels,
|
||||
UserTypeLabels,
|
||||
HttpMethodLabels,
|
||||
} from "../types/AccessLogTypes";
|
||||
|
||||
interface LogDetailsModalProps {
|
||||
log: AccessLog | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const LogDetailsModal: React.FC<LogDetailsModalProps> = ({
|
||||
log,
|
||||
isOpen,
|
||||
onClose,
|
||||
}) => {
|
||||
if (!log) return null;
|
||||
|
||||
const getOperationTypeColor = (type: OperationType): string => {
|
||||
const colors = {
|
||||
CREATE: "green",
|
||||
UPDATE: "blue",
|
||||
DELETE: "red",
|
||||
READ: "gray",
|
||||
LOGIN: "purple",
|
||||
LOGOUT: "orange",
|
||||
APPROVE: "green",
|
||||
REJECT: "red",
|
||||
ACTIVATE: "green",
|
||||
DEACTIVATE: "red",
|
||||
EXPORT: "blue",
|
||||
IMPORT: "blue",
|
||||
BULK_UPDATE: "blue",
|
||||
BULK_DELETE: "red",
|
||||
};
|
||||
return colors[type as keyof typeof colors] || "gray";
|
||||
};
|
||||
|
||||
const getUserTypeBadgeColor = (type: UserType): string => {
|
||||
const colors = {
|
||||
SYSTEM: "bg-gray-100 text-gray-800",
|
||||
ADMIN: "bg-red-100 text-red-800",
|
||||
USER: "bg-blue-100 text-blue-800",
|
||||
};
|
||||
return colors[type as keyof typeof colors] || "bg-gray-100 text-gray-800";
|
||||
};
|
||||
|
||||
const getStatusColor = (statusCode?: number): string => {
|
||||
if (!statusCode) return "bg-gray-100 text-gray-800";
|
||||
if (statusCode >= 200 && statusCode < 300) return "bg-green-100 text-green-800";
|
||||
if (statusCode >= 400 && statusCode < 500) return "bg-yellow-100 text-yellow-800";
|
||||
if (statusCode >= 500) return "bg-red-100 text-red-800";
|
||||
return "bg-gray-100 text-gray-800";
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string): string => {
|
||||
const date = new Date(dateString);
|
||||
return new Intl.DateTimeFormat("fa-IR", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
const formatExecutionTime = (time?: number): string => {
|
||||
if (!time) return "-";
|
||||
if (time < 1000) return `${time} میلیثانیه`;
|
||||
return `${(time / 1000).toFixed(2)} ثانیه`;
|
||||
};
|
||||
|
||||
const formatJson = (jsonString?: string): string => {
|
||||
if (!jsonString) return "";
|
||||
try {
|
||||
const parsed = JSON.parse(jsonString);
|
||||
return JSON.stringify(parsed, null, 2);
|
||||
} catch {
|
||||
return jsonString;
|
||||
}
|
||||
};
|
||||
|
||||
const InfoRow: React.FC<{
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
fullWidth?: boolean;
|
||||
}> = ({ icon, label, value, fullWidth = false }) => (
|
||||
<div className={`${fullWidth ? "col-span-2" : ""}`}>
|
||||
<div className="flex items-start gap-3 p-3 bg-gray-50 rounded-lg">
|
||||
<div className="flex-shrink-0 mt-0.5 text-gray-600">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 mb-1">{label}</p>
|
||||
<div className="text-sm text-gray-700">{value}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const CodeBlock: React.FC<{ title: string; content?: string }> = ({ title, content }) => {
|
||||
if (!content) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-3 flex items-center gap-2">
|
||||
<Code size={16} />
|
||||
{title}
|
||||
</h4>
|
||||
<div className="bg-gray-900 rounded-lg p-4 overflow-x-auto">
|
||||
<pre className="text-sm text-green-400 whitespace-pre-wrap font-mono">
|
||||
{formatJson(content)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<DefaulModal open={isOpen} close={onClose} width={800}>
|
||||
<div className="p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<InfoCircle size={24} className="text-blue-600" />
|
||||
<h2 className="text-xl font-bold text-gray-900">جزئیات لاگ</h2>
|
||||
</div>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
className="text-sm px-3 py-1 bg-transparent text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<CloseSquare size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Basic Information */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
<InfoRow
|
||||
icon={<Activity size={18} />}
|
||||
label="نوع عملیات"
|
||||
value={
|
||||
<StatusCircle
|
||||
color={getOperationTypeColor(log.operationType)}
|
||||
text={OperationTypeLabels[log.operationType]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<InfoRow
|
||||
icon={<User size={18} />}
|
||||
label="نوع کاربر"
|
||||
value={
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getUserTypeBadgeColor(log.userType)}`}>
|
||||
{UserTypeLabels[log.userType]}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
||||
<InfoRow
|
||||
icon={<DocumentText size={18} />}
|
||||
label="موجودیت"
|
||||
value={
|
||||
<div>
|
||||
<div className="font-medium">{log.entityName}</div>
|
||||
{log.entityId && (
|
||||
<div className="text-xs text-gray-500 mt-1">{log.entityId}</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<InfoRow
|
||||
icon={<Clock size={18} />}
|
||||
label="تاریخ و زمان"
|
||||
value={formatDate(log.createdAt)}
|
||||
/>
|
||||
|
||||
{log.actionDescription && (
|
||||
<InfoRow
|
||||
icon={<InfoCircle size={18} />}
|
||||
label="توضیحات عملیات"
|
||||
value={log.actionDescription}
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* User Information */}
|
||||
{log.user && (
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-3 flex items-center gap-2">
|
||||
<User size={18} />
|
||||
اطلاعات کاربر
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<InfoRow
|
||||
icon={<User size={18} />}
|
||||
label="نام و نام خانوادگی"
|
||||
value={`${log.user.firstName} ${log.user.lastName}`}
|
||||
/>
|
||||
<InfoRow
|
||||
icon={<InfoCircle size={18} />}
|
||||
label="ایمیل"
|
||||
value={log.user.email}
|
||||
/>
|
||||
{log.user.phone && (
|
||||
<InfoRow
|
||||
icon={<InfoCircle size={18} />}
|
||||
label="شماره تماس"
|
||||
value={log.user.phone}
|
||||
/>
|
||||
)}
|
||||
<InfoRow
|
||||
icon={<InfoCircle size={18} />}
|
||||
label="شناسه کاربر"
|
||||
value={log.user.id}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Technical Details */}
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-3 flex items-center gap-2">
|
||||
<Monitor size={18} />
|
||||
جزئیات فنی
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{log.ipAddress && (
|
||||
<InfoRow
|
||||
icon={<Monitor size={18} />}
|
||||
label="آدرس IP"
|
||||
value={log.ipAddress}
|
||||
/>
|
||||
)}
|
||||
|
||||
{log.endpoint && (
|
||||
<InfoRow
|
||||
icon={<Code size={18} />}
|
||||
label="Endpoint"
|
||||
value={log.endpoint}
|
||||
/>
|
||||
)}
|
||||
|
||||
{log.httpMethod && (
|
||||
<InfoRow
|
||||
icon={<Code size={18} />}
|
||||
label="متد HTTP"
|
||||
value={HttpMethodLabels[log.httpMethod]}
|
||||
/>
|
||||
)}
|
||||
|
||||
<InfoRow
|
||||
icon={<InfoCircle size={18} />}
|
||||
label="کد وضعیت"
|
||||
value={
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusColor(log.statusCode)}`}>
|
||||
{log.statusCode || "N/A"}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
||||
{log.executionTime && (
|
||||
<InfoRow
|
||||
icon={<Clock size={18} />}
|
||||
label="زمان اجرا"
|
||||
value={formatExecutionTime(log.executionTime)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{log.sessionId && (
|
||||
<InfoRow
|
||||
icon={<InfoCircle size={18} />}
|
||||
label="شناسه جلسه"
|
||||
value={log.sessionId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{log.requestId && (
|
||||
<InfoRow
|
||||
icon={<InfoCircle size={18} />}
|
||||
label="شناسه درخواست"
|
||||
value={log.requestId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Information */}
|
||||
{log.errorMessage && (
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-semibold text-red-600 mb-3 flex items-center gap-2">
|
||||
<Warning2 size={18} />
|
||||
اطلاعات خطا
|
||||
</h3>
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<p className="text-red-800">{log.errorMessage}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User Agent */}
|
||||
{log.userAgent && (
|
||||
<InfoRow
|
||||
icon={<Monitor size={18} />}
|
||||
label="User Agent"
|
||||
value={
|
||||
<div className="text-xs font-mono bg-gray-100 p-2 rounded break-all">
|
||||
{log.userAgent}
|
||||
</div>
|
||||
}
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Data Changes */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<CodeBlock title="مقادیر قبلی" content={log.oldValues} />
|
||||
<CodeBlock title="مقادیر جدید" content={log.newValues} />
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
{log.metadata && Object.keys(log.metadata).length > 0 && (
|
||||
<CodeBlock
|
||||
title="متادیتا"
|
||||
content={JSON.stringify(log.metadata, null, 2)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end mt-8 pt-6 border-t border-gray-200">
|
||||
<Button
|
||||
onClick={onClose}
|
||||
className="bg-blue-600 text-white hover:bg-blue-700"
|
||||
>
|
||||
بستن
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DefaulModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default LogDetailsModal;
|
||||
@@ -0,0 +1,392 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Activity,
|
||||
User,
|
||||
TrendUp,
|
||||
DocumentText,
|
||||
Warning2
|
||||
} from "iconsax-react";
|
||||
import StatusCircle from "../../../components/StatusCircle";
|
||||
import {
|
||||
OperationStatsResponse,
|
||||
TopUsersResponse,
|
||||
OperationType,
|
||||
UserType,
|
||||
OperationTypeLabels,
|
||||
UserTypeLabels,
|
||||
} from "../types/AccessLogTypes";
|
||||
|
||||
interface StatsDashboardProps {
|
||||
stats: OperationStatsResponse | null;
|
||||
topUsers: TopUsersResponse | null;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
const StatsDashboard: React.FC<StatsDashboardProps> = ({
|
||||
stats,
|
||||
topUsers,
|
||||
loading,
|
||||
}) => {
|
||||
const getOperationTypeColor = (type: OperationType): string => {
|
||||
const colors = {
|
||||
CREATE: "green",
|
||||
UPDATE: "blue",
|
||||
DELETE: "red",
|
||||
READ: "gray",
|
||||
LOGIN: "purple",
|
||||
LOGOUT: "orange",
|
||||
APPROVE: "green",
|
||||
REJECT: "red",
|
||||
ACTIVATE: "green",
|
||||
DEACTIVATE: "red",
|
||||
EXPORT: "blue",
|
||||
IMPORT: "blue",
|
||||
BULK_UPDATE: "blue",
|
||||
BULK_DELETE: "red",
|
||||
};
|
||||
return colors[type as keyof typeof colors] || "gray";
|
||||
};
|
||||
|
||||
const getUserTypeBadgeColor = (type: UserType): string => {
|
||||
const colors = {
|
||||
SYSTEM: "bg-gray-100 text-gray-800",
|
||||
ADMIN: "bg-red-100 text-red-800",
|
||||
USER: "bg-blue-100 text-blue-800",
|
||||
};
|
||||
return colors[type as keyof typeof colors] || "bg-gray-100 text-gray-800";
|
||||
};
|
||||
|
||||
// Calculate totals
|
||||
const totalOperations = stats?.operationStats.reduce((sum: number, stat: any) => sum + stat.count, 0) || 0;
|
||||
const totalUsers = topUsers?.topUsers.length || 0;
|
||||
|
||||
// Group stats by operation type
|
||||
const operationTypeTotals = stats?.operationStats.reduce((acc: Record<OperationType, number>, stat: any) => {
|
||||
acc[stat.operationType as OperationType] = (acc[stat.operationType as OperationType] || 0) + stat.count;
|
||||
return acc;
|
||||
}, {} as Record<OperationType, number>) || {};
|
||||
|
||||
// Group stats by user type
|
||||
const userTypeTotals = stats?.operationStats.reduce((acc: Record<UserType, number>, stat: any) => {
|
||||
acc[stat.userType as UserType] = (acc[stat.userType as UserType] || 0) + stat.count;
|
||||
return acc;
|
||||
}, {} as Record<UserType, number>) || {};
|
||||
|
||||
const StatCard: React.FC<{
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
value: string | number;
|
||||
color: string;
|
||||
subtitle?: string;
|
||||
}> = ({ icon, title, value, color, subtitle }) => (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center">
|
||||
<div className={`flex-shrink-0 p-3 rounded-lg bg-${color}-100`}>
|
||||
<div className={`text-${color}-600`}>
|
||||
{icon}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mr-4 flex-1">
|
||||
<p className="text-sm font-medium text-gray-600">{title}</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{(value || 0).toLocaleString('fa-IR')}</p>
|
||||
{subtitle && (
|
||||
<p className="text-xs text-gray-500 mt-1">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 animate-pulse">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0 p-3 rounded-lg bg-gray-200 w-12 h-12"></div>
|
||||
<div className="mr-4 flex-1">
|
||||
<div className="h-4 bg-gray-200 rounded w-20 mb-2"></div>
|
||||
<div className="h-8 bg-gray-200 rounded w-16"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<StatCard
|
||||
icon={<Activity size={24} />}
|
||||
title="کل عملیات"
|
||||
value={totalOperations}
|
||||
color="blue"
|
||||
subtitle="در بازه زمانی انتخاب شده"
|
||||
/>
|
||||
|
||||
<StatCard
|
||||
icon={<User size={24} />}
|
||||
title="کاربران فعال"
|
||||
value={totalUsers}
|
||||
color="green"
|
||||
subtitle="کاربران با فعالیت"
|
||||
/>
|
||||
|
||||
<StatCard
|
||||
icon={<Activity size={24} />}
|
||||
title="عملیات ایجاد"
|
||||
value={(operationTypeTotals as any)[OperationType.CREATE] || 0}
|
||||
color="green"
|
||||
subtitle="عملیات جدید"
|
||||
/>
|
||||
|
||||
<StatCard
|
||||
icon={<Warning2 size={24} />}
|
||||
title="عملیات حذف"
|
||||
value={(operationTypeTotals as any)[OperationType.DELETE] || 0}
|
||||
color="red"
|
||||
subtitle="عملیات حذف شده"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Operation Types Chart */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||
<Activity size={20} />
|
||||
توزیع نوع عملیات
|
||||
</h3>
|
||||
<span className="text-sm text-gray-500">
|
||||
{totalOperations.toLocaleString('fa-IR')} کل
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{Object.entries(operationTypeTotals)
|
||||
.sort(([, a], [, b]) => (b as number) - (a as number))
|
||||
.slice(0, 8)
|
||||
.map(([type, count]) => {
|
||||
const percentage = totalOperations > 0 ? ((count as number) / totalOperations) * 100 : 0;
|
||||
return (
|
||||
<div key={type} className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<StatusCircle
|
||||
color={getOperationTypeColor(type as OperationType)}
|
||||
text=""
|
||||
size="sm"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
{OperationTypeLabels[type as OperationType]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-24 bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full bg-${getOperationTypeColor(type as OperationType)}-500`}
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-gray-900 min-w-[3rem] text-left">
|
||||
{(count as number).toLocaleString('fa-IR')}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 min-w-[3rem] text-left">
|
||||
{percentage.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User Types Distribution */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||
<User size={20} />
|
||||
توزیع نوع کاربر
|
||||
</h3>
|
||||
<span className="text-sm text-gray-500">
|
||||
{totalOperations.toLocaleString('fa-IR')} کل
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{Object.entries(userTypeTotals)
|
||||
.sort(([, a], [, b]) => (b as number) - (a as number))
|
||||
.map(([type, count]) => {
|
||||
const percentage = totalOperations > 0 ? ((count as number) / totalOperations) * 100 : 0;
|
||||
return (
|
||||
<div key={type} className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getUserTypeBadgeColor(type as UserType)}`}>
|
||||
{UserTypeLabels[type as UserType]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-32 bg-gray-200 rounded-full h-3">
|
||||
<div
|
||||
className="h-3 rounded-full bg-blue-500"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-gray-900 min-w-[3rem] text-left">
|
||||
{(count as number).toLocaleString('fa-IR')}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 min-w-[3rem] text-left">
|
||||
{percentage.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Users Table */}
|
||||
{topUsers && topUsers.topUsers.length > 0 && (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||
<TrendUp size={20} />
|
||||
فعالترین کاربران
|
||||
</h3>
|
||||
<span className="text-sm text-gray-500">
|
||||
{topUsers.topUsers.length} کاربر
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
رتبه
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
کاربر
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
ایمیل
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
تعداد فعالیت
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
درصد
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{topUsers.topUsers.map((user, index) => {
|
||||
const percentage = totalOperations > 0 ? (user.activityCount / totalOperations) * 100 : 0;
|
||||
return (
|
||||
<tr key={user.userId} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-blue-100 text-blue-800 font-bold">
|
||||
{index + 1}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-shrink-0 w-10 h-10 bg-gray-200 rounded-full flex items-center justify-center">
|
||||
<User size={20} className="text-gray-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{user.firstName} {user.lastName}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{user.email}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-semibold text-gray-900">
|
||||
{(user.activityCount || 0).toLocaleString('fa-IR')}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-16 bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="h-2 rounded-full bg-blue-500"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">
|
||||
{percentage.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Entity Activity */}
|
||||
{stats && stats.operationStats.length > 0 && (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||
<DocumentText size={20} />
|
||||
فعالیت موجودیتها
|
||||
</h3>
|
||||
<span className="text-sm text-gray-500">
|
||||
{stats.operationStats.length} نوع موجودیت
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{stats.operationStats
|
||||
.reduce((acc: Array<{ entityName: string; count: number }>, stat: any) => {
|
||||
const existing = acc.find((item: any) => item.entityName === stat.entityName);
|
||||
if (existing) {
|
||||
existing.count += stat.count;
|
||||
} else {
|
||||
acc.push({ entityName: stat.entityName, count: stat.count });
|
||||
}
|
||||
return acc;
|
||||
}, [] as { entityName: string; count: number }[])
|
||||
.sort((a: any, b: any) => b.count - a.count)
|
||||
.slice(0, 9)
|
||||
.map((entity: any) => {
|
||||
const percentage = totalOperations > 0 ? (entity.count / totalOperations) * 100 : 0;
|
||||
return (
|
||||
<div key={entity.entityName} className="bg-gray-50 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="font-medium text-gray-900">{entity.entityName}</h4>
|
||||
<span className="text-sm font-semibold text-gray-700">
|
||||
{(entity.count || 0).toLocaleString('fa-IR')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="h-2 rounded-full bg-green-500"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{percentage.toFixed(1)}% از کل فعالیتها
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatsDashboard;
|
||||
@@ -0,0 +1,232 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
AccessLog,
|
||||
AccessLogQuery,
|
||||
AccessLogStatsQueryDto,
|
||||
AccessLogsResponse,
|
||||
OperationStatsResponse,
|
||||
TopUsersResponse,
|
||||
} from "../types/AccessLogTypes";
|
||||
import { accessLogsService } from "../service/AccessLogService";
|
||||
|
||||
export const useAccessLogs = () => {
|
||||
const [logs, setLogs] = useState<AccessLog[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [filters, setFilters] = useState<AccessLogQuery>({
|
||||
page: 1,
|
||||
limit: 20,
|
||||
sortBy: "createdAt",
|
||||
sortOrder: "DESC",
|
||||
});
|
||||
|
||||
const fetchLogs = useCallback(
|
||||
async (newFilters?: AccessLogQuery) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const currentFilters = newFilters || filters;
|
||||
const response: AccessLogsResponse = await accessLogsService.getLogs(
|
||||
currentFilters
|
||||
);
|
||||
setLogs(response.accessLogs || []);
|
||||
setTotal(response.count || 0);
|
||||
} catch (err) {
|
||||
setError((err as Error)?.message || "خطا در دریافت لاگها");
|
||||
setLogs([]);
|
||||
setTotal(0);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[filters]
|
||||
);
|
||||
|
||||
const updateFilters = useCallback(
|
||||
(newFilters: Partial<AccessLogQuery>) => {
|
||||
const updatedFilters = { ...filters, ...newFilters };
|
||||
if (newFilters.page === undefined && Object.keys(newFilters).length > 0) {
|
||||
updatedFilters.page = 1;
|
||||
}
|
||||
setFilters(updatedFilters);
|
||||
},
|
||||
[filters]
|
||||
);
|
||||
|
||||
const resetFilters = useCallback(() => {
|
||||
setFilters({
|
||||
page: 1,
|
||||
limit: 20,
|
||||
sortBy: "createdAt",
|
||||
sortOrder: "DESC",
|
||||
});
|
||||
}, []);
|
||||
|
||||
const exportLogs = useCallback(async () => {
|
||||
try {
|
||||
const blob = await accessLogsService.exportLogs(filters);
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `access-logs-${new Date().toISOString().split("T")[0]}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
} catch (err) {
|
||||
setError((err as Error)?.message || "خطا در خروجی گرفتن از لاگها");
|
||||
}
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogs();
|
||||
}, [fetchLogs]);
|
||||
|
||||
return {
|
||||
logs,
|
||||
loading,
|
||||
error,
|
||||
total,
|
||||
filters,
|
||||
updateFilters,
|
||||
resetFilters,
|
||||
fetchLogs,
|
||||
exportLogs,
|
||||
refetch: () => fetchLogs(),
|
||||
};
|
||||
};
|
||||
|
||||
export const useAccessLogStats = () => {
|
||||
const [stats, setStats] = useState<OperationStatsResponse | null>(null);
|
||||
const [topUsers, setTopUsers] = useState<TopUsersResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchStats = useCallback(
|
||||
async (filters: AccessLogStatsQueryDto = {}) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const [statsResponse, topUsersResponse] = await Promise.all([
|
||||
accessLogsService.getStats(filters),
|
||||
accessLogsService.getTopUsers(filters),
|
||||
]);
|
||||
|
||||
setStats(statsResponse);
|
||||
setTopUsers(topUsersResponse);
|
||||
} catch (err) {
|
||||
setError((err as Error)?.message || "خطا در دریافت آمار");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return {
|
||||
stats,
|
||||
topUsers,
|
||||
loading,
|
||||
error,
|
||||
fetchStats,
|
||||
};
|
||||
};
|
||||
|
||||
export const useAccessLogDetail = () => {
|
||||
const [log, setLog] = useState<AccessLog | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchLog = useCallback(async (id: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await accessLogsService.getLogById(id);
|
||||
setLog(response.accessLog);
|
||||
} catch (err) {
|
||||
setError((err as Error)?.message || "خطا در دریافت جزئیات لاگ");
|
||||
setLog(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearLog = useCallback(() => {
|
||||
setLog(null);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
log,
|
||||
loading,
|
||||
error,
|
||||
fetchLog,
|
||||
clearLog,
|
||||
};
|
||||
};
|
||||
|
||||
export const useErrorLogs = () => {
|
||||
const [errorLogs, setErrorLogs] = useState<AccessLog[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [filters, setFilters] = useState<AccessLogQuery>({
|
||||
page: 1,
|
||||
limit: 20,
|
||||
sortBy: "createdAt",
|
||||
sortOrder: "DESC",
|
||||
});
|
||||
|
||||
const fetchErrorLogs = useCallback(
|
||||
async (newFilters?: AccessLogQuery) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const currentFilters = newFilters || filters;
|
||||
const response: AccessLogsResponse = await accessLogsService.getErrorLogs(
|
||||
currentFilters
|
||||
);
|
||||
setErrorLogs(response.accessLogs || []);
|
||||
setTotal(response.count || 0);
|
||||
} catch (err) {
|
||||
setError((err as Error)?.message || "خطا در دریافت لاگهای خطا");
|
||||
setErrorLogs([]);
|
||||
setTotal(0);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[filters]
|
||||
);
|
||||
|
||||
const updateFilters = useCallback(
|
||||
(newFilters: Partial<AccessLogQuery>) => {
|
||||
const updatedFilters = { ...filters, ...newFilters };
|
||||
if (newFilters.page === undefined && Object.keys(newFilters).length > 0) {
|
||||
updatedFilters.page = 1;
|
||||
}
|
||||
setFilters(updatedFilters);
|
||||
},
|
||||
[filters]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchErrorLogs();
|
||||
}, [fetchErrorLogs]);
|
||||
|
||||
return {
|
||||
errorLogs,
|
||||
loading,
|
||||
error,
|
||||
total,
|
||||
filters,
|
||||
updateFilters,
|
||||
fetchErrorLogs,
|
||||
refetch: () => fetchErrorLogs(),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,144 @@
|
||||
import axios from "../../../config/axios";
|
||||
import {
|
||||
AccessLog,
|
||||
AccessLogQuery,
|
||||
AccessLogStatsQueryDto,
|
||||
EntityIdQueryDto,
|
||||
AccessLogsResponse,
|
||||
OperationStatsResponse,
|
||||
TopUsersResponse,
|
||||
} from "../types/AccessLogTypes";
|
||||
|
||||
type QueryParams = Record<string, string | number | boolean | undefined | null>;
|
||||
|
||||
class AccessLogsService {
|
||||
private baseUrl = "/access-logs";
|
||||
|
||||
private buildQueryParams(params: QueryParams): URLSearchParams {
|
||||
const queryParams = new URLSearchParams();
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== "") {
|
||||
queryParams.append(key, value.toString());
|
||||
}
|
||||
});
|
||||
return queryParams;
|
||||
}
|
||||
|
||||
// Get all access logs with filtering
|
||||
async getLogs(filters: AccessLogQuery = {}): Promise<AccessLogsResponse> {
|
||||
const params = this.buildQueryParams(filters as QueryParams);
|
||||
const response = await axios.get(`${this.baseUrl}?${params}`);
|
||||
// Handle both response structures
|
||||
if (response.data.data) {
|
||||
return {
|
||||
accessLogs: response.data.data.accessLogs,
|
||||
count: response.data.data.pager.totalItems,
|
||||
paginate: true,
|
||||
data: response.data.data,
|
||||
};
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// Get operation statistics
|
||||
async getStats(
|
||||
filters: AccessLogStatsQueryDto = {}
|
||||
): Promise<OperationStatsResponse> {
|
||||
const params = this.buildQueryParams(filters as QueryParams);
|
||||
const response = await axios.get(`${this.baseUrl}/stats?${params}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// Get top users by activity
|
||||
async getTopUsers(
|
||||
filters: AccessLogStatsQueryDto = {}
|
||||
): Promise<TopUsersResponse> {
|
||||
const params = this.buildQueryParams(filters as QueryParams);
|
||||
const response = await axios.get(`${this.baseUrl}/top-users?${params}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// Get error logs only
|
||||
async getErrorLogs(
|
||||
filters: AccessLogQuery = {}
|
||||
): Promise<AccessLogsResponse> {
|
||||
const params = this.buildQueryParams(filters as QueryParams);
|
||||
const response = await axios.get(`${this.baseUrl}/errors?${params}`);
|
||||
// Handle both response structures
|
||||
if (response.data.data) {
|
||||
return {
|
||||
accessLogs: response.data.data.accessLogs,
|
||||
count: response.data.data.pager.totalItems,
|
||||
paginate: true,
|
||||
data: response.data.data,
|
||||
};
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// Get specific log by ID
|
||||
async getLogById(id: string): Promise<{ accessLog: AccessLog }> {
|
||||
const response = await axios.get(`${this.baseUrl}/${id}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// Get logs for specific user
|
||||
async getUserLogs(
|
||||
userId: string,
|
||||
filters: AccessLogQuery = {}
|
||||
): Promise<AccessLogsResponse> {
|
||||
const params = this.buildQueryParams(filters as QueryParams);
|
||||
const response = await axios.get(
|
||||
`${this.baseUrl}/user/${userId}?${params}`
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// Get logs for specific entity
|
||||
async getEntityLogs(
|
||||
entityName: string,
|
||||
filters: EntityIdQueryDto = {}
|
||||
): Promise<AccessLogsResponse> {
|
||||
const params = this.buildQueryParams(filters as QueryParams);
|
||||
const response = await axios.get(
|
||||
`${this.baseUrl}/entity/${entityName}?${params}`
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// Export logs to CSV
|
||||
async exportLogs(filters: AccessLogQuery = {}): Promise<Blob> {
|
||||
const params = this.buildQueryParams({
|
||||
...filters,
|
||||
format: "csv",
|
||||
} as QueryParams);
|
||||
const response = await axios.get(`${this.baseUrl}/export?${params}`, {
|
||||
responseType: "blob",
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// Get logs count for dashboard
|
||||
async getLogsCount(filters: AccessLogQuery = {}): Promise<{ count: number }> {
|
||||
const params = this.buildQueryParams({
|
||||
...filters,
|
||||
countOnly: true,
|
||||
} as QueryParams);
|
||||
const response = await axios.get(`${this.baseUrl}/count?${params}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// Get recent logs (for real-time updates)
|
||||
async getRecentLogs(lastId?: string): Promise<AccessLogsResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (lastId) params.append("after", lastId);
|
||||
params.append("limit", "20");
|
||||
params.append("sortBy", "createdAt");
|
||||
params.append("sortOrder", "DESC");
|
||||
|
||||
const response = await axios.get(`${this.baseUrl}?${params}`);
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
export const accessLogsService = new AccessLogsService();
|
||||
@@ -0,0 +1,197 @@
|
||||
// Enums
|
||||
export enum OperationType {
|
||||
CREATE = "CREATE",
|
||||
UPDATE = "UPDATE",
|
||||
DELETE = "DELETE",
|
||||
READ = "READ",
|
||||
LOGIN = "LOGIN",
|
||||
LOGOUT = "LOGOUT",
|
||||
APPROVE = "APPROVE",
|
||||
REJECT = "REJECT",
|
||||
ACTIVATE = "ACTIVATE",
|
||||
DEACTIVATE = "DEACTIVATE",
|
||||
EXPORT = "EXPORT",
|
||||
IMPORT = "IMPORT",
|
||||
BULK_UPDATE = "BULK_UPDATE",
|
||||
BULK_DELETE = "BULK_DELETE",
|
||||
}
|
||||
|
||||
export enum UserType {
|
||||
SYSTEM = "SYSTEM",
|
||||
ADMIN = "ADMIN",
|
||||
USER = "USER",
|
||||
}
|
||||
|
||||
export enum HttpMethod {
|
||||
GET = "GET",
|
||||
POST = "POST",
|
||||
PUT = "PUT",
|
||||
PATCH = "PATCH",
|
||||
DELETE = "DELETE",
|
||||
}
|
||||
|
||||
// Main interfaces
|
||||
export interface AccessLog {
|
||||
id: string;
|
||||
operationType: OperationType;
|
||||
userType: UserType;
|
||||
entityName: string;
|
||||
entityId?: string;
|
||||
actionDescription?: string;
|
||||
oldValues?: string; // JSON string
|
||||
newValues?: string; // JSON string
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
endpoint?: string;
|
||||
httpMethod?: HttpMethod;
|
||||
statusCode?: number;
|
||||
errorMessage?: string;
|
||||
metadata?: Record<string, any>; // JSON object
|
||||
userId?: string;
|
||||
sessionId?: string;
|
||||
requestId?: string;
|
||||
executionTime?: number; // in milliseconds
|
||||
createdAt: string; // ISO date string
|
||||
updatedAt: string; // ISO date string
|
||||
user?: {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Query interfaces
|
||||
export interface AccessLogQuery {
|
||||
// Pagination
|
||||
page?: number;
|
||||
limit?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: "ASC" | "DESC";
|
||||
|
||||
// Filters
|
||||
operationType?: OperationType;
|
||||
userType?: UserType;
|
||||
entityName?: string;
|
||||
entityId?: string;
|
||||
userId?: string;
|
||||
ipAddress?: string;
|
||||
endpoint?: string;
|
||||
httpMethod?: HttpMethod;
|
||||
statusCode?: number;
|
||||
|
||||
// Date range
|
||||
startDate?: string; // ISO date string
|
||||
endDate?: string; // ISO date string
|
||||
|
||||
// Search
|
||||
search?: string; // Searches in actionDescription and entityName
|
||||
|
||||
// Performance
|
||||
minExecutionTime?: number; // milliseconds
|
||||
maxExecutionTime?: number; // milliseconds
|
||||
}
|
||||
|
||||
export interface AccessLogStatsQueryDto {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface EntityIdQueryDto {
|
||||
entityId?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
// Response interfaces
|
||||
export interface AccessLogsResponse {
|
||||
accessLogs: AccessLog[];
|
||||
count: number;
|
||||
paginate: boolean;
|
||||
data?: {
|
||||
accessLogs: AccessLog[];
|
||||
pager: {
|
||||
page: number;
|
||||
limit: number;
|
||||
totalItems: number;
|
||||
totalPages: number;
|
||||
prevPage: boolean;
|
||||
nextPage: boolean;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface OperationStatsResponse {
|
||||
operationStats: Array<{
|
||||
operationType: OperationType;
|
||||
userType: UserType;
|
||||
entityName: string;
|
||||
count: number;
|
||||
}>;
|
||||
count: number;
|
||||
paginate: boolean;
|
||||
}
|
||||
|
||||
export interface TopUsersResponse {
|
||||
topUsers: Array<{
|
||||
userId: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
activityCount: number;
|
||||
}>;
|
||||
count: number;
|
||||
paginate: boolean;
|
||||
}
|
||||
|
||||
// UI State interfaces
|
||||
export interface AccessLogsState {
|
||||
logs: AccessLog[];
|
||||
stats: OperationStatsResponse | null;
|
||||
topUsers: TopUsersResponse | null;
|
||||
errorLogs: AccessLog[];
|
||||
currentLog: AccessLog | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
filters: AccessLogQuery;
|
||||
pagination: {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Persian translations
|
||||
export const OperationTypeLabels: Record<OperationType, string> = {
|
||||
[OperationType.CREATE]: "ایجاد",
|
||||
[OperationType.UPDATE]: "بهروزرسانی",
|
||||
[OperationType.DELETE]: "حذف",
|
||||
[OperationType.READ]: "مشاهده",
|
||||
[OperationType.LOGIN]: "ورود",
|
||||
[OperationType.LOGOUT]: "خروج",
|
||||
[OperationType.APPROVE]: "تایید",
|
||||
[OperationType.REJECT]: "رد",
|
||||
[OperationType.ACTIVATE]: "فعالسازی",
|
||||
[OperationType.DEACTIVATE]: "غیرفعالسازی",
|
||||
[OperationType.EXPORT]: "خروجی",
|
||||
[OperationType.IMPORT]: "ورودی",
|
||||
[OperationType.BULK_UPDATE]: "بهروزرسانی گروهی",
|
||||
[OperationType.BULK_DELETE]: "حذف گروهی",
|
||||
};
|
||||
|
||||
export const UserTypeLabels: Record<UserType, string> = {
|
||||
[UserType.SYSTEM]: "سیستم",
|
||||
[UserType.ADMIN]: "مدیر",
|
||||
[UserType.USER]: "کاربر",
|
||||
};
|
||||
|
||||
export const HttpMethodLabels: Record<HttpMethod, string> = {
|
||||
[HttpMethod.GET]: "GET",
|
||||
[HttpMethod.POST]: "POST",
|
||||
[HttpMethod.PUT]: "PUT",
|
||||
[HttpMethod.PATCH]: "PATCH",
|
||||
[HttpMethod.DELETE]: "DELETE",
|
||||
};
|
||||
@@ -1,18 +1,16 @@
|
||||
import { FC, useEffect, useRef, useState } from 'react'
|
||||
import { FC, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import 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
@@ -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
|
||||
|
||||
@@ -3,6 +3,7 @@ export type CreateBlogType = {
|
||||
previewContent: string;
|
||||
content: string;
|
||||
imageUrl: string;
|
||||
audioUrl?: string;
|
||||
metaTitle: string;
|
||||
metaDescription: string;
|
||||
tags: string[];
|
||||
|
||||
Reference in New Issue
Block a user