diff --git a/app/admin/auth-analytics/page.tsx b/app/admin/auth-analytics/page.tsx new file mode 100644 index 0000000..9b0c072 --- /dev/null +++ b/app/admin/auth-analytics/page.tsx @@ -0,0 +1,559 @@ +'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 = 800; +const SMS_COST_STORAGE_KEY = 'approagency:sms_unit_cost'; + +const emptyFilters: AuthLogFilters = { + event: '', + mobile: '', + package_name: '', + date_from: '', + date_to: '', +}; + +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([]); + const [filters, setFilters] = useState(emptyFilters); + const [smsUnitCost, setSmsUnitCost] = useState(DEFAULT_SMS_UNIT_COST); + const [stats, setStats] = useState(null); + const [logs, setLogs] = useState(null); + const [page, setPage] = useState(1); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + // Restore the last SMS unit price the admin entered + useEffect(() => { + const saved = window.localStorage.getItem(SMS_COST_STORAGE_KEY); + if (saved) { + const parsed = parseInt(saved, 10); + if (!Number.isNaN(parsed) && parsed >= 0) setSmsUnitCost(parsed); + } + }, []); + + 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]); + + useEffect(() => { + if (!token) return; + load(1, emptyFilters, DEFAULT_SMS_UNIT_COST); + }, [token, load]); + + const handleInputChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFilters((prev) => ({ ...prev, [name]: value })); + }; + + const handleUnitCostChange = (e: React.ChangeEvent) => { + 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 = () => { + setFilters(emptyFilters); + setSmsUnitCost(DEFAULT_SMS_UNIT_COST); + window.localStorage.setItem(SMS_COST_STORAGE_KEY, String(DEFAULT_SMS_UNIT_COST)); + setPage(1); + load(1, emptyFilters, 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 ( +
+
+ + + {/* Filters */} +
+
+
+ setFilters((prev) => ({ ...prev, date_from: value }))} + /> +
+
+ setFilters((prev) => ({ ...prev, date_to: value }))} + /> +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+ + {error && ( +
+

{error}

+ +
+ )} + + {/* Summary cards */} +
+ {cards.map(({ title, value, hint, Icon, iconBg, iconColor }) => ( +
+
+
+ +
+
+

{title}

+

{value}

+

{hint}

+
+
+
+ ))} +
+ + {/* Daily chart: sent vs verified OTPs */} + {stats && stats.daily.length > 0 && ( +
+
+

روند روزانه

+
+ + + کد ارسالی + + + + کد تأییدشده + +
+
+
+
+ {stats.daily.map((day) => ( +
+
+
+
+
+ + {formatDate(day.date)} + +
+ ))} +
+
+
+ )} + + {/* Breakdown by event and package */} + {stats && ( +
+
+
+

تفکیک بر اساس رویداد

+
+
    + {Object.entries(AUTH_EVENTS).map(([key, e]) => ( +
  • + + {e.label} + + + {formatPrice(stats.by_event[key] ?? 0)} + +
  • + ))} +
+
+ +
+
+

تفکیک بر اساس پکیج

+
+ {stats.by_package.length === 0 ? ( +

+ داده‌ای برای نمایش نیست +

+ ) : ( +
    + {stats.by_package.map((row) => ( +
  • + + {packages.find((p) => p.name === row.package_name)?.title || row.package_name} + + + + {formatPrice(row.sms)} پیامک + + + {formatPrice(row.logins)} ورود + + +
  • + ))} +
+ )} +
+
+ )} + + {/* Raw event log */} +
+
+

+ {logs ? `${formatPrice(logs.total)} رویداد یافت شد` : 'در حال بارگذاری...'} +

+
+ + {isLoading ? ( +
+
+
+ ) : !logs || logs.data.length === 0 ? ( +
+
+ +
+

+ هیچ رویدادی با این فیلترها یافت نشد +

+
+ ) : ( + <> + {/* Mobile: stacked cards */} +
    + {logs.data.map((log: AuthLog) => ( +
  • +
    +
    +

    + {log.mobile || log.email || '—'} +

    +

    + {log.package_name || '—'} +

    +
    + + {getAuthEventLabel(log.event)} + +
    +
    + {log.ip || '—'} + {formatDate(log.created_at)} {formatTime(log.created_at)} +
    +
  • + ))} +
+ + {/* Desktop: table */} +
+ + + + + + + + + + + + + {logs.data.map((log: AuthLog) => ( + + + + + + + + + ))} + +
کاربررویدادپیامکپکیجIPتاریخ و ساعت
+
+ {log.mobile || '—'} +
+
+ {log.email || '—'} +
+
+ + {getAuthEventLabel(log.event)} + + + {log.sms_sent ? ( + ۱ + ) : ( + + )} + + {log.package_name || '—'} + + {log.ip || '—'} + + {formatDate(log.created_at)} {formatTime(log.created_at)} +
+
+ + )} + + {/* Pagination */} + {logs && logs.last_page > 1 && ( +
+ + + صفحه {logs.current_page} از {logs.last_page} + + +
+ )} +
+
+
+ ); +} diff --git a/app/admin/dashboard/page.tsx b/app/admin/dashboard/page.tsx index cce1a7b..4893639 100644 --- a/app/admin/dashboard/page.tsx +++ b/app/admin/dashboard/page.tsx @@ -2,7 +2,7 @@ import { useAuth } from '@/contexts/AuthContext'; import Link from 'next/link'; -import { UsersIcon, CubeIcon, TagIcon, BellIcon, GiftIcon, ShoppingBagIcon, CalculatorIcon } from '@heroicons/react/24/outline'; +import { UsersIcon, CubeIcon, TagIcon, BellIcon, GiftIcon, ShoppingBagIcon, CalculatorIcon, ChartBarIcon } from '@heroicons/react/24/outline'; import { MegaphoneIcon } from 'lucide-react'; export default function Dashboard() { @@ -36,6 +36,15 @@ export default function Dashboard() { iconColor: 'text-cyan-600 dark:text-cyan-400', hoverBorder: 'hover:border-cyan-300 dark:hover:border-cyan-700', }, + { + href: '/admin/auth-analytics', + title: 'آمار ورود و پیامک', + desc: 'تعداد کدهای ارسالی، تأییدها و ورودها برای محاسبه هزینه پیامک', + Icon: ChartBarIcon, + iconBg: 'bg-amber-100 dark:bg-amber-500/15', + iconColor: 'text-amber-600 dark:text-amber-400', + hoverBorder: 'hover:border-amber-300 dark:hover:border-amber-700', + }, { href: '/admin/packages', title: 'مدیریت پکیج‌ها', diff --git a/components/admin/Sidebar.tsx b/components/admin/Sidebar.tsx index 8b851a5..3928a33 100644 --- a/components/admin/Sidebar.tsx +++ b/components/admin/Sidebar.tsx @@ -14,6 +14,7 @@ import { ShoppingBagIcon, HomeIcon, CalculatorIcon, + ChartBarIcon, Bars3Icon, XMarkIcon, } from '@heroicons/react/24/outline'; @@ -25,6 +26,7 @@ const navItems = [ { href: '/admin/users', label: 'کاربران', Icon: UsersIcon }, { href: '/admin/purchases', label: 'خریدها', Icon: ShoppingBagIcon }, { href: '/admin/accounting', label: 'حسابداری', Icon: CalculatorIcon }, + { href: '/admin/auth-analytics', label: 'ورود و پیامک', Icon: ChartBarIcon }, { href: '/admin/packages', label: 'پکیج‌ها', Icon: CubeIcon }, { href: '/admin/products', label: 'محصولات', Icon: TagIcon }, { href: '/admin/reminders', label: 'یادآوری‌ها', Icon: BellIcon }, diff --git a/lib/api/authLogs.ts b/lib/api/authLogs.ts new file mode 100644 index 0000000..595b1ac --- /dev/null +++ b/lib/api/authLogs.ts @@ -0,0 +1,36 @@ +import { apiClient } from './client'; +import { AuthLogFilters, AuthStats, PaginatedAuthLogs } from '@/types/auth-log'; + +const buildAuthLogQuery = (filters: AuthLogFilters): string => { + const params = new URLSearchParams(); + + if (filters.event) params.append('event', filters.event); + if (filters.mobile) params.append('mobile', filters.mobile); + if (filters.email) params.append('email', filters.email); + if (filters.package_name) params.append('package_name', filters.package_name); + if (filters.success !== undefined) params.append('success', filters.success ? '1' : '0'); + if (filters.sms_only) params.append('sms_only', '1'); + if (filters.date_from) params.append('date_from', filters.date_from); + if (filters.date_to) params.append('date_to', filters.date_to); + if (filters.time_from) params.append('time_from', filters.time_from); + if (filters.time_to) params.append('time_to', filters.time_to); + if (filters.sms_unit_cost !== undefined) params.append('sms_unit_cost', String(filters.sms_unit_cost)); + if (filters.per_page) params.append('per_page', String(filters.per_page)); + if (filters.page) params.append('page', String(filters.page)); + + return params.toString(); +}; + +export const authLogsApi = { + // Aggregated OTP/SMS and login stats for the analytics cards and chart + getStats: async (filters: AuthLogFilters, token: string): Promise => { + const qs = buildAuthLogQuery(filters); + return apiClient.get(`/admin/auth-stats${qs ? `?${qs}` : ''}`, token); + }, + + // Raw paginated event log + getLogs: async (filters: AuthLogFilters, token: string): Promise => { + const qs = buildAuthLogQuery(filters); + return apiClient.get(`/admin/auth-logs${qs ? `?${qs}` : ''}`, token); + }, +}; diff --git a/types/auth-log.ts b/types/auth-log.ts new file mode 100644 index 0000000..ac57d2e --- /dev/null +++ b/types/auth-log.ts @@ -0,0 +1,110 @@ +import { Paginated } from './purchase'; + +// Nested user on an auth log row (subset of the full User model) +export interface AuthLogUser { + id: number; + uuid: string; + first_name: string | null; + last_name: string | null; + email: string | null; + mobile: string | null; +} + +// One authentication event — matches backend App\Models\AuthLog +export interface AuthLog { + id: number; + user_id: number | null; + mobile: string | null; + email: string | null; + event: AuthEvent; + package_name: string | null; + source: number | null; + success: boolean; + sms_sent: boolean; + ip: string | null; + user_agent: string | null; + created_at: string; + updated_at: string; + user: AuthLogUser | null; +} + +// Event keys — must stay in sync with AuthLog::EVENTS on the backend +export const AUTH_EVENTS = { + otp_sent: { label: 'ارسال کد ورود (پیامک)', sms: true }, + otp_verified: { label: 'تأیید کد ورود', sms: false }, + otp_failed: { label: 'کد ورود نامعتبر', sms: false }, + mobile_otp_sent: { label: 'ارسال کد تأیید شماره (پیامک)', sms: true }, + mobile_verified: { label: 'تأیید شماره موبایل', sms: false }, + mobile_verify_failed: { label: 'تأیید شماره ناموفق', sms: false }, + login: { label: 'ورود با رمز عبور', sms: false }, + login_failed: { label: 'ورود ناموفق', sms: false }, + register: { label: 'ثبت‌نام', sms: false }, + google_login: { label: 'ورود با گوگل', sms: false }, +} as const; + +export type AuthEvent = keyof typeof AUTH_EVENTS; + +export const getAuthEventLabel = (event: string): string => + (AUTH_EVENTS as Record)[event]?.label || event; + +export interface AuthStatsDaily { + date: string; + total: number; + sms: number; + otp_sent: number; + otp_verified: number; + otp_failed: number; +} + +export interface AuthStatsPackage { + package_name: string; + sms: number; + logins: number; +} + +export interface AuthStats { + filters: { + date_from: string | null; + date_to: string | null; + event: string | null; + package_name: string | null; + }; + totals: { + events: number; + unique_mobiles: number; + unique_users: number; + sms_sent: number; + sms_cost: number; + sms_unit_cost: number; + otp_sent: number; + otp_verified: number; + otp_failed: number; + otp_conversion_rate: number; + logins: number; + registers: number; + }; + by_event: Record; + by_package: AuthStatsPackage[]; + daily: AuthStatsDaily[]; +} + +// Filters shared by the auth-logs and auth-stats endpoints +export interface AuthLogFilters { + event?: string; + mobile?: string; + email?: string; + package_name?: string; + success?: boolean; + sms_only?: boolean; + // Date/time window on created_at (Y-m-d dates, H:i(:s) times, Tehran-local) + date_from?: string; + date_to?: string; + time_from?: string; + time_to?: string; + // Only used by the stats endpoint, to price the SMS total + sms_unit_cost?: number; + per_page?: number; + page?: number; +} + +export type PaginatedAuthLogs = Paginated;