'use client'; import { useState, useEffect, useCallback } from 'react'; import type { ComponentType } from 'react'; import { useAuth } from '@/contexts/AuthContext'; import { purchasesApi } from '@/lib/api/purchases'; import { packagesApi } from '@/lib/api/packages'; import { productsApi } from '@/lib/api/products'; import PageHeader from '@/components/admin/PageHeader'; import PersianDatePicker from '@/components/admin/PersianDatePicker'; import { Purchase, PAYMENT_GATEWAYS } from '@/types/purchase'; import { PackageName } from '@/types/package'; import { Product } from '@/types/product'; import { formatPrice, getPurchaseFinalPrice, parseStoreRate, parseFee } from '@/lib/utils'; import { MagnifyingGlassIcon, ArrowPathIcon, CalculatorIcon, BanknotesIcon, WalletIcon, ChartPieIcon, } from '@heroicons/react/24/outline'; // A single paid sale = success (موفق) or consumed (مصرف‌شده). // مایکت/کافه‌بازار تراکنش را مستقیماً با وضعیت «مصرف‌شده» ثبت می‌کنند (نه «موفق»)، // بنابراین هر دو وضعیت باید به‌عنوان فروش محسوب شوند. const PAID_STATUSES = [2, 3]; // Local-time Gregorian helpers. The "YYYY-MM-DD" format matches what // PersianDatePicker exchanges and what the created_at comparison expects. const toGregorianValue = (date: Date): string => { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); return `${year}-${month}-${day}`; }; const addDays = (date: Date, days: number): Date => { const copy = new Date(date); copy.setDate(copy.getDate() + days); return copy; }; const todayValue = (): string => toGregorianValue(new Date()); type DatePreset = 'today' | 'yesterday' | 'last7' | 'thisMonth'; const presetRange = (preset: DatePreset): { from: string; to: string } => { const now = new Date(); switch (preset) { case 'today': return { from: toGregorianValue(now), to: toGregorianValue(now) }; case 'yesterday': { const yesterday = addDays(now, -1); return { from: toGregorianValue(yesterday), to: toGregorianValue(yesterday) }; } case 'last7': return { from: toGregorianValue(addDays(now, -6)), to: toGregorianValue(now) }; case 'thisMonth': return { from: toGregorianValue(new Date(now.getFullYear(), now.getMonth(), 1)), to: toGregorianValue(now), }; } }; const PRESET_LABELS: { key: DatePreset; label: string }[] = [ { key: 'today', label: 'امروز' }, { key: 'yesterday', label: 'دیروز' }, { key: 'last7', label: '۷ روز اخیر' }, { key: 'thisMonth', label: 'این ماه' }, ]; interface StoreSummary { count: number; gross: number; rate: number; fee: number; net: number; } interface ProductRow { productId: number | null; title: string; packageName: string; count: number; gross: number; commission: number; net: number; } interface AccountingResult { rows: ProductRow[]; totals: { count: number; gross: number; commission: number; net: number }; myket: StoreSummary; cafe: StoreSummary; other: StoreSummary; } // خلاصه فروش هر محصول و هر فروشگاه با احتساب کارمزد const computeAccounting = ( purchases: Purchase[], myketRate: number, cafeRate: number, transactionFee: number, ): AccountingResult => { const rows = new Map(); let totalCount = 0; let totalGross = 0; let totalCommission = 0; // درصد سهم توسعه‌دهنده به‌صورت کسر (0.85) و کارمزد تراکنش به ریال const makeStore = (rate: number): StoreSummary => ({ count: 0, gross: 0, rate, fee: 0, net: 0 }); const myket: StoreSummary = makeStore(myketRate); const cafe: StoreSummary = makeStore(cafeRate); const other: StoreSummary = makeStore(0); // سهم توسعه‌دهنده برای مایکت/کافه‌بازار: // (P / 1.1 - T) × R // P = قیمت فروش (با احتساب ۱۰٪ مالیات بر ارزش افزوده) // /1.1 = حذف ارزش افزوده، T = کارمزد تراکنش به ریال، R = درصد سهم const developerShare = (price: number, rate: number, feePerTransaction: number) => { const beforeVat = price / 1.1; const afterVatAndFee = beforeVat - feePerTransaction; if (afterVatAndFee <= 0) return 0; return afterVatAndFee * (rate / 100); }; const collect = (store: StoreSummary, price: number, rate: number, feePerTransaction: number) => { let net: number; if (rate > 0) { net = developerShare(price, rate, feePerTransaction); } else { net = price; // سایر درگاه‌ها: بدون کارمزد فروشگاه } store.count += 1; store.gross += price; store.fee += price - net; store.net += net; }; for (const purchase of purchases) { const price = getPurchaseFinalPrice(purchase); const gateway = purchase.gateway; let store: StoreSummary; let rate: number; let feePerTransaction: number; if (gateway === PAYMENT_GATEWAYS.cafe.code) { store = cafe; rate = cafeRate; feePerTransaction = transactionFee; } else if (gateway === PAYMENT_GATEWAYS.myket.code) { store = myket; rate = myketRate; feePerTransaction = transactionFee; } else { store = other; rate = 0; feePerTransaction = 0; } collect(store, price, rate, feePerTransaction); totalCount += 1; totalGross += price; totalCommission += price - developerShare(price, rate, feePerTransaction); const productId = purchase.product_id ?? purchase.product?.id ?? -1; let row = rows.get(productId); if (!row) { row = { productId, title: purchase.product?.title || 'نامشخص', packageName: purchase.product?.package_name?.title || purchase.product?.package_name?.name || '—', count: 0, gross: 0, commission: 0, net: 0, }; rows.set(productId, row); } row.count += 1; row.gross += price; row.commission += price - developerShare(price, rate, feePerTransaction); row.net += developerShare(price, rate, feePerTransaction); } return { rows: Array.from(rows.values()), totals: { count: totalCount, gross: totalGross, commission: totalCommission, net: totalGross - totalCommission, }, myket, cafe, other, }; }; export default function AccountingPage() { const { token } = useAuth(); // Package / product filters const [packages, setPackages] = useState([]); const [selectedPackageName, setSelectedPackageName] = useState(''); const [products, setProducts] = useState([]); const [selectedProductId, setSelectedProductId] = useState(''); // Date range — defaults to today so the daily account is the first thing shown const [dateFrom, setDateFrom] = useState(todayValue); const [dateTo, setDateTo] = useState(todayValue); const [preset, setPreset] = useState('today'); // Time range const [timeFrom, setTimeFrom] = useState(''); const [timeTo, setTimeTo] = useState(''); // Status filter — both paid statuses selected by default const [statuses, setStatuses] = useState([...PAID_STATUSES]); // درصد سهم توسعه‌دهنده (R در فرمول) — مایکت و کافه‌بازار به‌طور پیش‌فرض ۸۵٪ const [myketRate, setMyketRate] = useState('85'); const [cafeRate, setCafeRate] = useState('85'); // کارمزد تراکنش به ریال (T در فرمول سهم توسعه‌دهنده) const [transactionFee, setTransactionFee] = useState('1200'); const [allPurchases, setAllPurchases] = useState(null); const [result, setResult] = useState(null); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); // Bumped by the «تحلیل» button / «بارگذاری مجدد» to force a refetch const [refreshTick, setRefreshTick] = useState(0); 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'; // Load packages once on mount — "همه پکیج‌ها" stays selected by default so // the initial view covers the whole business. useEffect(() => { if (!token) return; packagesApi .getAllPackages(token) .then(setPackages) .catch(() => setError('خطا در دریافت لیست پکیج‌ها')); }, [token]); // Load products when a package is selected useEffect(() => { if (!token || !selectedPackageName) { setProducts([]); setSelectedProductId(''); return; } setSelectedProductId(''); productsApi .getProducts(selectedPackageName, token) .then(setProducts) .catch(() => setProducts([])); }, [selectedPackageName, token]); // The server-side query window. Fetching only the selected window keeps the // payload small; the backend filters created_at by these dates. Runs // automatically on mount (today's account) and whenever the window, // package or product selection changes. const windowKey = [dateFrom, dateTo, timeFrom, timeTo, selectedPackageName, selectedProductId].join('|'); useEffect(() => { if (!token) return; let cancelled = false; setIsLoading(true); setError(null); purchasesApi .getAllPurchases( { per_page: 500, ...(dateFrom && { date_from: dateFrom }), ...(dateTo && { date_to: dateTo }), ...(timeFrom && { time_from: timeFrom }), ...(timeTo && { time_to: timeTo }), ...(selectedPackageName && { package_name: selectedPackageName }), ...(selectedProductId && { product_id: Number(selectedProductId) }), }, token ) .then((purchases) => { if (cancelled) return; setAllPurchases(purchases); setResult( computeAccounting( purchases, parseStoreRate(myketRate), parseStoreRate(cafeRate), parseFee(transactionFee) ) ); }) .catch((err) => { if (!cancelled) { setError(err instanceof Error ? err.message : 'خطا در دریافت داده‌ها'); } }) .finally(() => { if (!cancelled) setIsLoading(false); }); return () => { cancelled = true; }; // Commission rates are read at fetch time only; changing them recomputes // locally (see runAnalysis) without refetching. // eslint-disable-next-line react-hooks/exhaustive-deps }, [token, windowKey, refreshTick]); const handleAnalyze = () => setRefreshTick((t) => t + 1); // Recompute whenever filters or commission rates change (uses cached data) const runAnalysis = useCallback(() => { if (!allPurchases) return; const myket = parseStoreRate(myketRate); const cafe = parseStoreRate(cafeRate); const fee = parseFee(transactionFee); const filtered = allPurchases.filter((p) => { // Status if (statuses.length > 0 && !statuses.includes(p.status)) return false; // Product if (selectedProductId !== '' && String(p.product_id) !== selectedProductId) return false; // Package (via nested product) if (selectedPackageName) { const pkgName = p.product?.package_name?.name || p.product?.package_name?.title || ''; if (pkgName && pkgName !== selectedPackageName) return false; } // Date range (created_at is "Y-m-d H:i:s") // When time is also selected, compare full datetime strings; // otherwise compare only the date portion (YYYY-MM-DD). const created = p.created_at ?? ''; if (dateFrom) { const datePart = created.slice(0, 10); if (timeFrom) { // Full datetime comparison: "YYYY-MM-DD HH:mm:ss" if (created < `${dateFrom} ${timeFrom}`) return false; } else if (datePart < dateFrom) return false; } if (dateTo) { const datePart = created.slice(0, 10); if (timeTo) { if (created > `${dateTo} ${timeTo}`) return false; } else if (datePart > dateTo) return false; } return true; }); setResult(computeAccounting(filtered, myket, cafe, fee)); }, [allPurchases, statuses, selectedProductId, selectedPackageName, dateFrom, dateTo, timeFrom, timeTo, myketRate, cafeRate, transactionFee]); // Recompute on filter/rate changes (no refetch needed once data is cached) useEffect(() => { if (allPurchases) runAnalysis(); }, [runAnalysis, allPurchases]); const applyPreset = (key: DatePreset) => { const range = presetRange(key); setPreset(key); setDateFrom(range.from); setDateTo(range.to); setTimeFrom(''); setTimeTo(''); }; const handleDateFromChange = (value: string) => { setPreset(null); setDateFrom(value); }; const handleDateToChange = (value: string) => { setPreset(null); setDateTo(value); }; const handleReset = () => { setSelectedPackageName(''); setSelectedProductId(''); applyPreset('today'); setStatuses([...PAID_STATUSES]); setMyketRate('85'); setCafeRate('85'); setTransactionFee('1200'); }; const toggleStatus = (code: number) => { setStatuses((prev) => prev.includes(code) ? prev.filter((c) => c !== code) : [...prev, code] ); }; return (
بارگذاری مجدد ) : null } /> {/* Filters */}
{ e.preventDefault(); handleAnalyze(); }} 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" >
setTimeFrom(e.target.value)} className={inputClass} placeholder="۰۰:۰۰" />
setTimeTo(e.target.value)} className={inputClass} placeholder="۲۳:۵۹" />
{/* Quick date-range presets */}
{PRESET_LABELS.map(({ key, label }) => { const active = preset === key; return ( ); })}
{/* Status checkboxes */}
{[ { code: 2, label: 'موفق' }, { code: 3, label: 'مصرف‌شده' }, { code: 1, label: 'در انتظار پرداخت' }, ].map(({ code, label }) => ( ))}
{/* Commission settings */}
setMyketRate(e.target.value)} className={inputClass} placeholder="۸۵" />
setCafeRate(e.target.value)} className={inputClass} placeholder="۸۵" />
setTransactionFee(e.target.value)} className={inputClass} placeholder="۱۲۰۰" />

سهم توسعه‌دهنده = (قیمت ÷ ۱٫۱ − کارمزد تراکنش) × درصد سهم. پیش‌فرض مایکت و کافه‌بازار ۸۵٪ (فروش زیر ۱۰ میلیارد ریال) و ۷۰٪ (بالای آن) است.

{error && (

{error}

)} {isLoading && (
)} {!isLoading && allPurchases && result && ( <> {/* Summary stat cards */}
{/* Per-store breakdown */}
{/* Per-product table */}

تحلیل به تفکیک محصول

{result.rows.length === 0 ? (

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

) : ( <> {/* Mobile: stacked cards */}
    {result.rows.map((row) => (
  • {row.title}

    {row.packageName}

    {formatPrice(row.count)} فروش

    فروش ناخالص

    {formatPrice(row.gross)}

    کارمزد

    {formatPrice(row.commission)}

    سود خالص

    {formatPrice(row.net)}

  • ))}
{/* Desktop: table */}
{result.rows.map((row) => ( ))}
محصول پکیج تعداد فروش فروش ناخالص کارمزد فروشگاه سود خالص
{row.title} {row.packageName} {formatPrice(row.count)} {formatPrice(row.gross)} تومان {formatPrice(row.commission)} تومان {formatPrice(row.net)} تومان
)}
)} {!isLoading && !allPurchases && (

برای تحلیل فروش، فیلترها را تنظیم و روی «تحلیل» کلیک کنید

)}
); } const STAT_TONES = { indigo: 'bg-indigo-50 text-indigo-600 dark:bg-indigo-500/10 dark:text-indigo-400', sky: 'bg-sky-50 text-sky-600 dark:bg-sky-500/10 dark:text-sky-400', amber: 'bg-amber-50 text-amber-600 dark:bg-amber-500/10 dark:text-amber-400', emerald: 'bg-emerald-50 text-emerald-600 dark:bg-emerald-500/10 dark:text-emerald-400', } as const; function StatCard({ label, value, unit, Icon, tone, }: { label: string; value: string; unit?: string; Icon: ComponentType<{ className?: string }>; tone: keyof typeof STAT_TONES; }) { const valueColor = tone === 'amber' ? 'text-amber-600 dark:text-amber-400' : tone === 'emerald' ? 'text-emerald-600 dark:text-emerald-400' : 'text-gray-900 dark:text-gray-100'; return (
{label}
{value} {unit && {unit}}
); } function StoreCard({ title, summary }: { title: string; summary: StoreSummary }) { return (

{title}

{summary.rate > 0 && ( سهم توسعه‌دهنده {summary.rate}٪ )}
تعداد فروش {formatPrice(summary.count)}
فروش ناخالص {formatPrice(summary.gross)} تومان
سهم فروشگاه‌ها {formatPrice(summary.fee)} تومان
سود خالص {formatPrice(summary.net)} تومان
); }