Files
approagency-admin-panel/app/admin/accounting/page.tsx
T
2026-08-02 22:32:08 +03:30

642 lines
32 KiB
TypeScript
Raw 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 { 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];
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
const [dateFrom, setDateFrom] = useState<string>('');
const [dateTo, setDateTo] = 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);
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
useEffect(() => {
if (!token) return;
packagesApi
.getAllPackages(token)
.then((data) => {
setPackages(data);
if (data.length > 0) setSelectedPackageName(data[0].name || '');
})
.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]);
const handleAnalyze = useCallback(async () => {
if (!token) return;
setIsLoading(true);
setError(null);
try {
// Fetch all purchases once, then filter/aggregate in-memory.
const purchases = await purchasesApi.getAllPurchases({ per_page: 500 }, token);
setAllPurchases(purchases);
setResult(
computeAccounting(
purchases,
parseStoreRate(myketRate),
parseStoreRate(cafeRate),
parseFee(transactionFee)
)
);
} catch (err) {
setError(err instanceof Error ? err.message : 'خطا در دریافت داده‌ها');
} finally {
setIsLoading(false);
}
}, [token, myketRate, cafeRate, transactionFee]);
// 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")
if (dateFrom && p.created_at && p.created_at.slice(0, 10) < dateFrom) return false;
if (dateTo && p.created_at && p.created_at.slice(0, 10) > dateTo) return false;
return true;
});
setResult(computeAccounting(filtered, myket, cafe, fee));
}, [allPurchases, statuses, selectedProductId, selectedPackageName, dateFrom, dateTo, myketRate, cafeRate, transactionFee]);
// Recompute on filter/rate changes (no refetch needed once data is cached)
useEffect(() => {
if (allPurchases) runAnalysis();
}, [runAnalysis, allPurchases]);
const handleReset = () => {
setSelectedPackageName(packages[0]?.name || '');
setSelectedProductId('');
setDateFrom('');
setDateTo('');
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]
);
};
const statCardClass =
'rounded-2xl bg-white dark:bg-gray-900 ring-1 ring-gray-200 dark:ring-gray-800 p-5 shadow-sm';
const statIconClass = 'h-5 w-5';
const statValueClass = 'text-2xl font-bold text-gray-900 dark:text-gray-100 mt-1.5';
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-4">
<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={setDateFrom}
/>
</div>
<div>
<PersianDatePicker
id="date_to"
label="تا تاریخ"
placeholder="انتخاب تاریخ پایان"
value={dateTo}
onChange={setDateTo}
/>
</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 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">
<div className={statCardClass}>
<div className="flex items-center gap-2 text-sm font-medium text-gray-500 dark:text-gray-400">
<WalletIcon className={statIconClass} />
تعداد فروش
</div>
<div className={statValueClass}>{formatPrice(result.totals.count)}</div>
</div>
<div className={statCardClass}>
<div className="flex items-center gap-2 text-sm font-medium text-gray-500 dark:text-gray-400">
<BanknotesIcon className={statIconClass} />
فروش ناخالص
</div>
<div className={statValueClass}>{formatPrice(result.totals.gross)} <span className="text-sm font-normal text-gray-400">تومان</span></div>
</div>
<div className={statCardClass}>
<div className="flex items-center gap-2 text-sm font-medium text-gray-500 dark:text-gray-400">
<ChartPieIcon className={statIconClass} />
کارمزد فروشگاه‌ها
</div>
<div className={`${statValueClass} text-amber-600 dark:text-amber-400`}>
{formatPrice(result.totals.commission)} <span className="text-sm font-normal text-gray-400">تومان</span>
</div>
</div>
<div className={statCardClass}>
<div className="flex items-center gap-2 text-sm font-medium text-gray-500 dark:text-gray-400">
<CalculatorIcon className={statIconClass} />
سود خالص
</div>
<div className={`${statValueClass} text-emerald-600 dark:text-emerald-400`}>
{formatPrice(result.totals.net)} <span className="text-sm font-normal text-gray-400">تومان</span>
</div>
</div>
</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>
) : (
<div className="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>
);
}
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>
);
}