Files

859 lines
42 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 } 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<number, ProductRow>();
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<PackageName[]>([]);
const [selectedPackageName, setSelectedPackageName] = useState<string>('');
const [products, setProducts] = useState<Product[]>([]);
const [selectedProductId, setSelectedProductId] = useState<string>('');
// Date range — defaults to today so the daily account is the first thing shown
const [dateFrom, setDateFrom] = useState<string>(todayValue);
const [dateTo, setDateTo] = useState<string>(todayValue);
const [preset, setPreset] = useState<DatePreset | null>('today');
// Time range
const [timeFrom, setTimeFrom] = useState<string>('');
const [timeTo, setTimeTo] = useState<string>('');
// Status filter — both paid statuses selected by default
const [statuses, setStatuses] = useState<number[]>([...PAID_STATUSES]);
// درصد سهم توسعه‌دهنده (R در فرمول) — مایکت و کافه‌بازار به‌طور پیش‌فرض ۸۵٪
const [myketRate, setMyketRate] = useState<string>('85');
const [cafeRate, setCafeRate] = useState<string>('85');
// کارمزد تراکنش به ریال (T در فرمول سهم توسعه‌دهنده)
const [transactionFee, setTransactionFee] = useState<string>('1200');
const [allPurchases, setAllPurchases] = useState<Purchase[] | null>(null);
const [result, setResult] = useState<AccountingResult | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div className="p-4 sm:p-8">
<div className="max-w-7xl mx-auto">
<PageHeader
title="حسابداری"
subtitle="تحلیل فروش محصولات و محاسبه کارمزد فروشگاه‌ها"
action={
allPurchases ? (
<button
onClick={handleAnalyze}
disabled={isLoading}
className="inline-flex items-center gap-2 px-4 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 disabled:opacity-50 transition-colors shadow-sm"
>
<ArrowPathIcon className="h-4 w-4" />
بارگذاری مجدد
</button>
) : null
}
/>
{/* Filters */}
<form
onSubmit={(e) => {
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"
>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-6">
<div>
<label htmlFor="package_name" className={labelClass}>پکیج</label>
<select
id="package_name"
value={selectedPackageName}
onChange={(e) => setSelectedPackageName(e.target.value)}
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="product" className={labelClass}>محصول</label>
<select
id="product"
value={selectedProductId}
onChange={(e) => setSelectedProductId(e.target.value)}
className={inputClass}
>
<option value="">همه محصولات</option>
{products.map((prod) => (
<option key={prod.id} value={String(prod.id)}>
{prod.title || `#${prod.id}`}
</option>
))}
</select>
</div>
<div>
<PersianDatePicker
id="date_from"
label="از تاریخ"
placeholder="انتخاب تاریخ شروع"
value={dateFrom}
onChange={handleDateFromChange}
/>
</div>
<div>
<label htmlFor="time_from" className={labelClass}>از ساعت</label>
<input
type="time"
id="time_from"
value={timeFrom}
onChange={(e) => setTimeFrom(e.target.value)}
className={inputClass}
placeholder="۰۰:۰۰"
/>
</div>
<div>
<PersianDatePicker
id="date_to"
label="تا تاریخ"
placeholder="انتخاب تاریخ پایان"
value={dateTo}
onChange={handleDateToChange}
/>
</div>
<div>
<label htmlFor="time_to" className={labelClass}>تا ساعت</label>
<input
type="time"
id="time_to"
value={timeTo}
onChange={(e) => setTimeTo(e.target.value)}
className={inputClass}
placeholder="۲۳:۵۹"
/>
</div>
</div>
{/* Quick date-range presets */}
<div className="mt-4">
<label className={labelClass}>بازه زمانی</label>
<div className="flex flex-wrap gap-2">
{PRESET_LABELS.map(({ key, label }) => {
const active = preset === key;
return (
<button
key={key}
type="button"
onClick={() => applyPreset(key)}
className={`px-3.5 py-1.5 rounded-xl text-sm font-medium transition-colors ${
active
? 'bg-indigo-600 text-white shadow-sm shadow-indigo-500/20'
: 'bg-gray-50 dark:bg-gray-800 text-gray-600 dark:text-gray-300 ring-1 ring-gray-200 dark:ring-gray-700 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
>
{label}
</button>
);
})}
</div>
</div>
{/* Status checkboxes */}
<div className="mt-4">
<label className={labelClass}>وضعیت</label>
<div className="flex flex-wrap gap-3">
{[
{ code: 2, label: 'موفق' },
{ code: 3, label: 'مصرف‌شده' },
{ code: 1, label: 'در انتظار پرداخت' },
].map(({ code, label }) => (
<label key={code} className="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
<input
type="checkbox"
checked={statuses.includes(code)}
onChange={() => toggleStatus(code)}
className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
{label}
</label>
))}
</div>
</div>
{/* Commission settings */}
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div>
<label htmlFor="myket_rate" className={labelClass}>
سهم توسعه‌دهنده مایکت (٪)
</label>
<input
type="text"
id="myket_rate"
inputMode="numeric"
value={myketRate}
onChange={(e) => setMyketRate(e.target.value)}
className={inputClass}
placeholder="۸۵"
/>
</div>
<div>
<label htmlFor="cafe_rate" className={labelClass}>
سهم توسعه‌دهنده کافه بازار (٪)
</label>
<input
type="text"
id="cafe_rate"
inputMode="numeric"
value={cafeRate}
onChange={(e) => setCafeRate(e.target.value)}
className={inputClass}
placeholder="۸۵"
/>
</div>
<div>
<label htmlFor="transaction_fee" className={labelClass}>
کارمزد تراکنش (ریال)
</label>
<input
type="text"
id="transaction_fee"
inputMode="numeric"
value={transactionFee}
onChange={(e) => setTransactionFee(e.target.value)}
className={inputClass}
placeholder="۱۲۰۰"
/>
</div>
</div>
<p className="mt-2 text-xs text-gray-400 dark:text-gray-500">
سهم توسعه‌دهنده = (قیمت ÷ ۱٫۱ کارمزد تراکنش) × درصد سهم. پیش‌فرض مایکت و
کافه‌بازار ۸۵٪ (فروش زیر ۱۰ میلیارد ریال) و ۷۰٪ (بالای آن) است.
</p>
<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>
)}
{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>
)}
{!isLoading && allPurchases && result && (
<>
{/* Summary stat cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<StatCard label="تعداد فروش" value={formatPrice(result.totals.count)} Icon={WalletIcon} tone="indigo" />
<StatCard label="فروش ناخالص" value={formatPrice(result.totals.gross)} unit="تومان" Icon={BanknotesIcon} tone="sky" />
<StatCard label="کارمزد فروشگاه‌ها" value={formatPrice(result.totals.commission)} unit="تومان" Icon={ChartPieIcon} tone="amber" />
<StatCard label="سود خالص" value={formatPrice(result.totals.net)} unit="تومان" Icon={CalculatorIcon} tone="emerald" />
</div>
{/* Per-store breakdown */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
<StoreCard title="مایکت" summary={result.myket} />
<StoreCard title="کافه بازار" summary={result.cafe} />
<StoreCard title="سایر درگاه‌ها" summary={result.other} />
</div>
{/* Per-product table */}
<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>
{result.rows.length === 0 ? (
<div className="text-center py-16">
<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">
{result.rows.map((row) => (
<li key={row.productId} className="px-5 py-4">
<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">
{row.title}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 truncate">
{row.packageName}
</p>
</div>
<span className="flex-shrink-0 px-2.5 py-1 text-xs font-semibold rounded-lg bg-indigo-50 text-indigo-700 dark:bg-indigo-500/10 dark:text-indigo-400">
{formatPrice(row.count)} فروش
</span>
</div>
<div className="grid grid-cols-3 gap-2 mt-3 pt-3 border-t border-gray-100 dark:border-gray-800/60">
<div>
<p className="text-[11px] text-gray-400 dark:text-gray-500">فروش ناخالص</p>
<p className="text-xs font-bold text-gray-900 dark:text-gray-100 mt-0.5">
{formatPrice(row.gross)}
</p>
</div>
<div>
<p className="text-[11px] text-gray-400 dark:text-gray-500">کارمزد</p>
<p className="text-xs font-bold text-amber-600 dark:text-amber-400 mt-0.5">
{formatPrice(row.commission)}
</p>
</div>
<div>
<p className="text-[11px] text-gray-400 dark:text-gray-500">سود خالص</p>
<p className="text-xs font-bold text-emerald-600 dark:text-emerald-400 mt-0.5">
{formatPrice(row.net)}
</p>
</div>
</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">کارمزد فروشگاه</th>
<th className="px-4 py-3">سود خالص</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-800">
{result.rows.map((row) => (
<tr key={row.productId} className="hover:bg-gray-50 dark:hover:bg-gray-800/40 transition-colors">
<td className="px-4 py-3.5 text-sm font-medium text-gray-900 dark:text-gray-100 whitespace-nowrap">
{row.title}
</td>
<td className="px-4 py-3.5 text-sm text-gray-700 dark:text-gray-300 whitespace-nowrap">
{row.packageName}
</td>
<td className="px-4 py-3.5 text-sm text-gray-900 dark:text-gray-100 whitespace-nowrap">
{formatPrice(row.count)}
</td>
<td className="px-4 py-3.5 text-sm text-gray-900 dark:text-gray-100 whitespace-nowrap font-medium">
{formatPrice(row.gross)} <span className="text-xs text-gray-400">تومان</span>
</td>
<td className="px-4 py-3.5 text-sm text-amber-600 dark:text-amber-400 whitespace-nowrap">
{formatPrice(row.commission)} <span className="text-xs text-gray-400">تومان</span>
</td>
<td className="px-4 py-3.5 text-sm text-emerald-600 dark:text-emerald-400 whitespace-nowrap font-medium">
{formatPrice(row.net)} <span className="text-xs text-gray-400">تومان</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</div>
</>
)}
{!isLoading && !allPurchases && (
<div className="text-center py-16 bg-white dark:bg-gray-900 ring-1 ring-gray-200 dark:ring-gray-800 rounded-2xl shadow-sm">
<div className="mx-auto h-12 w-12 rounded-xl bg-indigo-50 dark:bg-indigo-500/15 flex items-center justify-center mb-3">
<CalculatorIcon className="h-6 w-6 text-indigo-600 dark:text-indigo-400" />
</div>
<p className="text-gray-500 dark:text-gray-400 text-sm">
برای تحلیل فروش، فیلترها را تنظیم و روی «تحلیل» کلیک کنید
</p>
</div>
)}
</div>
</div>
);
}
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 (
<div className="rounded-2xl bg-white dark:bg-gray-900 ring-1 ring-gray-200 dark:ring-gray-800 p-5 shadow-sm">
<div className="flex items-center gap-2.5">
<span className={`h-9 w-9 rounded-xl flex items-center justify-center flex-shrink-0 ${STAT_TONES[tone]}`}>
<Icon className="h-5 w-5" />
</span>
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">{label}</span>
</div>
<div className={`text-2xl font-bold mt-2.5 ${valueColor}`}>
{value} {unit && <span className="text-sm font-normal text-gray-400">{unit}</span>}
</div>
</div>
);
}
function StoreCard({ title, summary }: { title: string; summary: StoreSummary }) {
return (
<div className="bg-white dark:bg-gray-900 ring-1 ring-gray-200 dark:ring-gray-800 rounded-2xl p-5 shadow-sm">
<div className="flex items-center justify-between">
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100">{title}</h4>
{summary.rate > 0 && (
<span className="px-2 py-0.5 text-xs font-medium rounded-lg bg-indigo-50 text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-400">
سهم توسعه‌دهنده {summary.rate}٪
</span>
)}
</div>
<div className="mt-3 space-y-1.5 text-sm">
<div className="flex justify-between text-gray-500 dark:text-gray-400">
<span>تعداد فروش</span>
<span className="text-gray-900 dark:text-gray-100 font-medium">{formatPrice(summary.count)}</span>
</div>
<div className="flex justify-between text-gray-500 dark:text-gray-400">
<span>فروش ناخالص</span>
<span className="text-gray-900 dark:text-gray-100 font-medium">{formatPrice(summary.gross)} تومان</span>
</div>
<div className="flex justify-between text-gray-500 dark:text-gray-400">
<span>سهم فروشگاه‌ها</span>
<span className="text-amber-600 dark:text-amber-400 font-medium">{formatPrice(summary.fee)} تومان</span>
</div>
<div className="flex justify-between pt-1.5 border-t border-gray-100 dark:border-gray-800 text-gray-500 dark:text-gray-400">
<span>سود خالص</span>
<span className="text-emerald-600 dark:text-emerald-400 font-bold">{formatPrice(summary.net)} تومان</span>
</div>
</div>
</div>
);
}