Files
2026-08-19 12:38:22 +03:30

571 lines
32 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useAuth } from '@/contexts/AuthContext';
import { authLogsApi } from '@/lib/api/authLogs';
import { packagesApi } from '@/lib/api/packages';
import PageHeader from '@/components/admin/PageHeader';
import PersianDatePicker from '@/components/admin/PersianDatePicker';
import { PackageName } from '@/types/package';
import {
AuthLog,
AuthLogFilters,
AuthStats,
AUTH_EVENTS,
getAuthEventLabel,
PaginatedAuthLogs,
} from '@/types/auth-log';
import { formatPrice, formatDate, formatTime } from '@/lib/utils';
import {
MagnifyingGlassIcon,
ArrowPathIcon,
DevicePhoneMobileIcon,
ArrowRightOnRectangleIcon,
CheckBadgeIcon,
BanknotesIcon,
UserGroupIcon,
ExclamationTriangleIcon,
} from '@heroicons/react/24/outline';
// Default price of one Kavenegar verify SMS, in Toman. Only used to price the
// SMS total in the panel — the admin can change it and the choice is remembered.
const DEFAULT_SMS_UNIT_COST = 2000;
const SMS_COST_STORAGE_KEY = 'approagency:sms_unit_cost';
// Today as the Gregorian YYYY-MM-DD the API and PersianDatePicker exchange.
const todayValue = (): string => {
const now = new Date();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
return `${now.getFullYear()}-${month}-${day}`;
};
// The section opens on today's activity; widen the range with the date pickers.
const createDefaultFilters = (): AuthLogFilters => ({
event: '',
mobile: '',
package_name: '',
date_from: todayValue(),
date_to: todayValue(),
});
const eventBadgeClass = (event: string, success: boolean): string => {
if (!success) return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300';
if (event === 'otp_sent' || event === 'mobile_otp_sent') {
return 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300';
}
return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300';
};
export default function AuthAnalyticsPage() {
const { token } = useAuth();
const [packages, setPackages] = useState<PackageName[]>([]);
const [filters, setFilters] = useState<AuthLogFilters>(createDefaultFilters);
const [smsUnitCost, setSmsUnitCost] = useState<number>(DEFAULT_SMS_UNIT_COST);
const [initialized, setInitialized] = useState(false);
const [stats, setStats] = useState<AuthStats | null>(null);
const [logs, setLogs] = useState<PaginatedAuthLogs | null>(null);
const [page, setPage] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async (
targetPage: number,
activeFilters: AuthLogFilters,
unitCost: number
) => {
if (!token) return;
setIsLoading(true);
setError(null);
try {
const [statsData, logsData] = await Promise.all([
authLogsApi.getStats({ ...activeFilters, sms_unit_cost: unitCost }, token),
authLogsApi.getLogs({ ...activeFilters, page: targetPage, per_page: 30 }, token),
]);
setStats(statsData);
setLogs(logsData);
} catch (err) {
setError(err instanceof Error ? err.message : 'خطا در دریافت آمار ورود و پیامک');
} finally {
setIsLoading(false);
}
}, [token]);
useEffect(() => {
if (!token) return;
packagesApi.getAllPackages(token).then(setPackages).catch(() => setPackages([]));
}, [token]);
// First load: restore the last SMS unit price the admin entered, then fetch
// today's activity with it (so the cost card is right on the very first render).
useEffect(() => {
if (!token || initialized) return;
const saved = window.localStorage.getItem(SMS_COST_STORAGE_KEY);
const parsed = saved ? parseInt(saved, 10) : NaN;
const unitCost = !Number.isNaN(parsed) && parsed >= 0 ? parsed : DEFAULT_SMS_UNIT_COST;
setSmsUnitCost(unitCost);
setInitialized(true);
load(1, createDefaultFilters(), unitCost);
}, [token, initialized, load]);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value } = e.target;
setFilters((prev) => ({ ...prev, [name]: value }));
};
const handleUnitCostChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const digits = e.target.value.replace(/[^\d]/g, '');
const parsed = digits ? parseInt(digits, 10) : 0;
setSmsUnitCost(parsed);
window.localStorage.setItem(SMS_COST_STORAGE_KEY, String(parsed));
};
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setPage(1);
load(1, filters, smsUnitCost);
};
const handleReset = () => {
const defaults = createDefaultFilters();
setFilters(defaults);
setSmsUnitCost(DEFAULT_SMS_UNIT_COST);
window.localStorage.setItem(SMS_COST_STORAGE_KEY, String(DEFAULT_SMS_UNIT_COST));
setPage(1);
load(1, defaults, DEFAULT_SMS_UNIT_COST);
};
const goToPage = (targetPage: number) => {
setPage(targetPage);
load(targetPage, filters, smsUnitCost);
};
// Tallest bar in the daily chart, used to scale the rest
const dailyMax = useMemo(
() => Math.max(1, ...(stats?.daily || []).map((d) => Math.max(d.otp_sent, d.otp_verified))),
[stats]
);
const inputClass =
'block w-full px-3 py-2.5 text-gray-900 dark:text-gray-100 border border-gray-200 dark:border-gray-700 rounded-xl shadow-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent sm:text-sm dark:bg-gray-800/80 transition-colors';
const labelClass = 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5';
const cards = stats ? [
{
title: 'پیامک ارسال‌شده',
value: formatPrice(stats.totals.sms_sent),
hint: 'مجموع کدهای ارسالی (قابل محاسبه برای هزینه)',
Icon: DevicePhoneMobileIcon,
iconBg: 'bg-amber-100 dark:bg-amber-500/15',
iconColor: 'text-amber-600 dark:text-amber-400',
},
{
title: 'هزینه تخمینی پیامک',
value: `${formatPrice(stats.totals.sms_cost)} تومان`,
hint: `بر پایه ${formatPrice(stats.totals.sms_unit_cost)} تومان برای هر پیامک`,
Icon: BanknotesIcon,
iconBg: 'bg-cyan-100 dark:bg-cyan-500/15',
iconColor: 'text-cyan-600 dark:text-cyan-400',
},
{
title: 'ورود موفق',
value: formatPrice(stats.totals.logins),
hint: 'تأیید کد + رمز عبور + گوگل',
Icon: ArrowRightOnRectangleIcon,
iconBg: 'bg-green-100 dark:bg-green-500/15',
iconColor: 'text-green-600 dark:text-green-400',
},
{
title: 'نرخ تأیید کد',
value: `${formatPrice(stats.totals.otp_conversion_rate)}٪`,
hint: `${formatPrice(stats.totals.otp_verified)} تأیید از ${formatPrice(stats.totals.otp_sent)} ارسال`,
Icon: CheckBadgeIcon,
iconBg: 'bg-indigo-100 dark:bg-indigo-500/15',
iconColor: 'text-indigo-600 dark:text-indigo-400',
},
{
title: 'کاربران یکتا',
value: formatPrice(stats.totals.unique_mobiles),
hint: 'شماره‌های یکتا در این بازه',
Icon: UserGroupIcon,
iconBg: 'bg-purple-100 dark:bg-purple-500/15',
iconColor: 'text-purple-600 dark:text-purple-400',
},
{
title: 'کد نامعتبر',
value: formatPrice(stats.totals.otp_failed),
hint: 'پیامک‌هایی که به ورود نرسیدند',
Icon: ExclamationTriangleIcon,
iconBg: 'bg-red-100 dark:bg-red-500/15',
iconColor: 'text-red-600 dark:text-red-400',
},
] : [];
return (
<div className="p-4 sm:p-8">
<div className="max-w-7xl mx-auto">
<PageHeader
title="آمار ورود و پیامک"
subtitle="تعداد کدهای ارسالی، تأییدها و ورودها برای محاسبه هزینه پیامک"
/>
{/* Filters */}
<form
onSubmit={handleSearch}
className="bg-white dark:bg-gray-900 ring-1 ring-gray-200 dark:ring-gray-800 rounded-2xl p-5 sm:p-6 mb-6 shadow-sm"
>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div>
<PersianDatePicker
id="date_from"
label="از تاریخ"
placeholder="انتخاب تاریخ شروع"
value={filters.date_from || ''}
onChange={(value) => setFilters((prev) => ({ ...prev, date_from: value }))}
/>
</div>
<div>
<PersianDatePicker
id="date_to"
label="تا تاریخ"
placeholder="انتخاب تاریخ پایان"
value={filters.date_to || ''}
onChange={(value) => setFilters((prev) => ({ ...prev, date_to: value }))}
/>
</div>
<div>
<label htmlFor="package_name" className={labelClass}>پکیج</label>
<select
id="package_name"
name="package_name"
value={filters.package_name || ''}
onChange={handleInputChange}
className={inputClass}
>
<option value="">همه پکیج‌ها</option>
{packages.map((pkg) => (
<option key={pkg.id} value={pkg.name || ''}>
{pkg.title || pkg.name}
</option>
))}
</select>
</div>
<div>
<label htmlFor="event" className={labelClass}>نوع رویداد</label>
<select
id="event"
name="event"
value={filters.event || ''}
onChange={handleInputChange}
className={inputClass}
>
<option value="">همه رویدادها</option>
{Object.entries(AUTH_EVENTS).map(([key, e]) => (
<option key={key} value={key}>{e.label}</option>
))}
</select>
</div>
<div>
<label htmlFor="mobile" className={labelClass}>شماره موبایل</label>
<input
type="text"
id="mobile"
name="mobile"
value={filters.mobile || ''}
onChange={handleInputChange}
className={inputClass}
placeholder="0912xxxxxxx"
dir="ltr"
/>
</div>
<div>
<label htmlFor="sms_unit_cost" className={labelClass}>هزینه هر پیامک (تومان)</label>
<input
type="text"
id="sms_unit_cost"
value={smsUnitCost ? formatPrice(smsUnitCost) : ''}
onChange={handleUnitCostChange}
className={inputClass}
placeholder="۲۰۰۰"
/>
</div>
</div>
<div className="flex flex-wrap items-center gap-3 mt-5">
<button
type="submit"
disabled={isLoading}
className="inline-flex items-center gap-2 px-5 py-2.5 bg-indigo-600 text-white rounded-xl text-sm font-medium hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-gray-900 focus:ring-indigo-500 disabled:opacity-50 transition-colors shadow-sm shadow-indigo-500/20"
>
<MagnifyingGlassIcon className="h-4 w-4" />
اعمال فیلتر
</button>
<button
type="button"
onClick={handleReset}
disabled={isLoading}
className="inline-flex items-center gap-2 px-5 py-2.5 border border-gray-200 dark:border-gray-700 rounded-xl text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-gray-900 focus:ring-indigo-500 disabled:opacity-50 transition-colors shadow-sm"
>
<ArrowPathIcon className="h-4 w-4" />
پاک کردن فیلترها
</button>
</div>
</form>
{error && (
<div className="mb-4 rounded-xl bg-red-50 dark:bg-red-950/40 ring-1 ring-red-200 dark:ring-red-800/60 p-4 flex items-center gap-3">
<p className="text-sm text-red-700 dark:text-red-300">{error}</p>
<button onClick={() => setError(null)} className="mr-auto text-red-400 hover:text-red-600">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</div>
)}
{/* Summary cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
{cards.map(({ title, value, hint, Icon, iconBg, iconColor }) => (
<div
key={title}
className="bg-white dark:bg-gray-900 rounded-2xl p-5 ring-1 ring-gray-200 dark:ring-gray-800 shadow-sm"
>
<div className="flex items-start gap-3">
<div className={`inline-flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-xl ${iconBg}`}>
<Icon className={`h-5 w-5 ${iconColor}`} />
</div>
<div className="min-w-0">
<p className="text-sm text-gray-500 dark:text-gray-400">{title}</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100 mt-0.5">{value}</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1 leading-relaxed">{hint}</p>
</div>
</div>
</div>
))}
</div>
{/* Daily chart: sent vs verified OTPs */}
{stats && stats.daily.length > 0 && (
<div className="bg-white dark:bg-gray-900 ring-1 ring-gray-200 dark:ring-gray-800 rounded-2xl p-5 sm:p-6 mb-6 shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-3 mb-5">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">روند روزانه</h3>
<div className="flex items-center gap-4 text-xs text-gray-500 dark:text-gray-400">
<span className="flex items-center gap-1.5">
<span className="h-2.5 w-2.5 rounded-sm bg-amber-500" />
کد ارسالی
</span>
<span className="flex items-center gap-1.5">
<span className="h-2.5 w-2.5 rounded-sm bg-green-500" />
کد تأییدشده
</span>
</div>
</div>
<div className="overflow-x-auto">
<div className="flex items-end gap-3 min-w-full h-48" style={{ minWidth: `${stats.daily.length * 44}px` }}>
{stats.daily.map((day) => (
<div key={day.date} className="flex-1 flex flex-col items-center gap-2 min-w-[32px]">
<div className="w-full flex items-end justify-center gap-0.5 h-40">
<div
className="w-1/2 bg-amber-500/80 rounded-t-md transition-all"
style={{ height: `${(day.otp_sent / dailyMax) * 100}%` }}
title={`${day.otp_sent} کد ارسالی`}
/>
<div
className="w-1/2 bg-green-500/80 rounded-t-md transition-all"
style={{ height: `${(day.otp_verified / dailyMax) * 100}%` }}
title={`${day.otp_verified} کد تأییدشده`}
/>
</div>
<span className="text-[10px] text-gray-400 dark:text-gray-500 whitespace-nowrap">
{formatDate(day.date)}
</span>
</div>
))}
</div>
</div>
</div>
)}
{/* Breakdown by event and package */}
{stats && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-6">
<div className="bg-white dark:bg-gray-900 ring-1 ring-gray-200 dark:ring-gray-800 rounded-2xl overflow-hidden shadow-sm">
<div className="px-5 py-4 border-b border-gray-200 dark:border-gray-800">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">تفکیک بر اساس رویداد</h3>
</div>
<ul className="divide-y divide-gray-200 dark:divide-gray-800">
{Object.entries(AUTH_EVENTS).map(([key, e]) => (
<li key={key} className="px-5 py-3 flex items-center justify-between gap-3">
<span className="text-sm text-gray-700 dark:text-gray-300">
{e.label}
</span>
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">
{formatPrice(stats.by_event[key] ?? 0)}
</span>
</li>
))}
</ul>
</div>
<div className="bg-white dark:bg-gray-900 ring-1 ring-gray-200 dark:ring-gray-800 rounded-2xl overflow-hidden shadow-sm">
<div className="px-5 py-4 border-b border-gray-200 dark:border-gray-800">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">تفکیک بر اساس پکیج</h3>
</div>
{stats.by_package.length === 0 ? (
<p className="px-5 py-8 text-sm text-center text-gray-500 dark:text-gray-400">
داده‌ای برای نمایش نیست
</p>
) : (
<ul className="divide-y divide-gray-200 dark:divide-gray-800">
{stats.by_package.map((row) => (
<li key={row.package_name} className="px-5 py-3 flex items-center justify-between gap-3">
<span className="text-sm text-gray-700 dark:text-gray-300 truncate" dir="ltr">
{packages.find((p) => p.name === row.package_name)?.title || row.package_name}
</span>
<span className="flex items-center gap-3 flex-shrink-0 text-xs">
<span className="px-2 py-0.5 rounded-md bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300">
{formatPrice(row.sms)} پیامک
</span>
<span className="px-2 py-0.5 rounded-md bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
{formatPrice(row.logins)} ورود
</span>
</span>
</li>
))}
</ul>
)}
</div>
</div>
)}
{/* Raw event log */}
<div className="bg-white dark:bg-gray-900 ring-1 ring-gray-200 dark:ring-gray-800 rounded-2xl overflow-hidden shadow-sm">
<div className="px-5 py-4 flex items-center justify-between border-b border-gray-200 dark:border-gray-800">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
{logs ? `${formatPrice(logs.total)} رویداد یافت شد` : 'در حال بارگذاری...'}
</h3>
</div>
{isLoading ? (
<div className="flex justify-center items-center py-16">
<div className="h-10 w-10 border-4 border-indigo-200 dark:border-indigo-800 border-t-indigo-600 rounded-full animate-spin" />
</div>
) : !logs || logs.data.length === 0 ? (
<div className="text-center py-16">
<div className="mx-auto h-12 w-12 rounded-xl bg-gray-100 dark:bg-gray-800 flex items-center justify-center mb-3">
<DevicePhoneMobileIcon className="h-6 w-6 text-gray-400" />
</div>
<p className="text-gray-500 dark:text-gray-400 text-sm">
هیچ رویدادی با این فیلترها یافت نشد
</p>
</div>
) : (
<>
{/* Mobile: stacked cards */}
<ul className="divide-y divide-gray-200 dark:divide-gray-800 md:hidden">
{logs.data.map((log: AuthLog) => (
<li key={log.id} className="px-5 py-4 space-y-2">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate" dir="ltr">
{log.mobile || log.email || '—'}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 truncate">
{log.package_name || '—'}
</p>
</div>
<span className={`flex-shrink-0 px-2.5 py-1 inline-flex text-xs leading-5 font-semibold rounded-lg ${eventBadgeClass(log.event, log.success)}`}>
{getAuthEventLabel(log.event)}
</span>
</div>
<div className="flex items-center justify-between gap-3 text-xs text-gray-400 dark:text-gray-500">
<span dir="ltr">{log.ip || '—'}</span>
<span>{formatDate(log.created_at)} {formatTime(log.created_at)}</span>
</div>
</li>
))}
</ul>
{/* Desktop: table */}
<div className="hidden md:block overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-800">
<thead className="bg-gray-50 dark:bg-gray-800/60">
<tr className="text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
<th className="px-4 py-3">کاربر</th>
<th className="px-4 py-3">رویداد</th>
<th className="px-4 py-3">پیامک</th>
<th className="px-4 py-3">پکیج</th>
<th className="px-4 py-3">IP</th>
<th className="px-4 py-3">تاریخ و ساعت</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-800">
{logs.data.map((log: AuthLog) => (
<tr key={log.id} className="hover:bg-gray-50 dark:hover:bg-gray-800/40 transition-colors">
<td className="px-4 py-3.5">
<div className="text-sm text-gray-900 dark:text-gray-100" dir="ltr">
{log.mobile || '—'}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400" dir="ltr">
{log.email || '—'}
</div>
</td>
<td className="px-4 py-3.5 whitespace-nowrap">
<span className={`px-2.5 py-1 inline-flex text-xs leading-5 font-semibold rounded-lg ${eventBadgeClass(log.event, log.success)}`}>
{getAuthEventLabel(log.event)}
</span>
</td>
<td className="px-4 py-3.5 text-sm whitespace-nowrap">
{log.sms_sent ? (
<span className="text-amber-600 dark:text-amber-400 font-medium">۱</span>
) : (
<span className="text-gray-400 dark:text-gray-500"></span>
)}
</td>
<td className="px-4 py-3.5 text-sm text-gray-700 dark:text-gray-300 whitespace-nowrap">
{log.package_name || '—'}
</td>
<td className="px-4 py-3.5 text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap" dir="ltr">
{log.ip || '—'}
</td>
<td className="px-4 py-3.5 text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">
{formatDate(log.created_at)} {formatTime(log.created_at)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
{/* Pagination */}
{logs && logs.last_page > 1 && (
<div className="flex items-center justify-between px-5 py-3.5 border-t border-gray-200 dark:border-gray-800">
<button
onClick={() => goToPage(page - 1)}
disabled={page <= 1 || isLoading}
className="px-4 py-2 text-sm border border-gray-200 dark:border-gray-700 rounded-xl text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
قبلی
</button>
<span className="text-sm text-gray-600 dark:text-gray-400">
صفحه {logs.current_page} از {logs.last_page}
</span>
<button
onClick={() => goToPage(page + 1)}
disabled={page >= logs.last_page || isLoading}
className="px-4 py-2 text-sm border border-gray-200 dark:border-gray-700 rounded-xl text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
بعدی
</button>
</div>
)}
</div>
</div>
</div>
);
}