From dd5b6d4616599f542bb486bbc1102c62e0165c5d Mon Sep 17 00:00:00 2001 From: Amirmahdi Nourkazemi Date: Sun, 2 Aug 2026 19:37:22 +0330 Subject: [PATCH] feat: accounting added --- app/admin/accounting/page.tsx | 593 +++++++++++++++++++++++++ app/admin/dashboard/page.tsx | 11 +- app/admin/purchases/page.tsx | 22 +- components/admin/PersianDatePicker.tsx | 154 +++++++ components/admin/Sidebar.tsx | 2 + lib/api/purchases.ts | 61 ++- lib/utils.ts | 30 ++ package-lock.json | 118 ++++- package.json | 4 +- types/purchase.ts | 1 + 10 files changed, 945 insertions(+), 51 deletions(-) create mode 100644 app/admin/accounting/page.tsx create mode 100644 components/admin/PersianDatePicker.tsx diff --git a/app/admin/accounting/page.tsx b/app/admin/accounting/page.tsx new file mode 100644 index 0000000..86caef4 --- /dev/null +++ b/app/admin/accounting/page.tsx @@ -0,0 +1,593 @@ +'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 } 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; + commission: 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 +): AccountingResult => { + const rows = new Map(); + let totalCount = 0; + let totalGross = 0; + let totalCommission = 0; + + const makeStore = (rate: number): StoreSummary => ({ count: 0, gross: 0, rate, commission: 0, net: 0 }); + const myket: StoreSummary = makeStore(myketRate); + const cafe: StoreSummary = makeStore(cafeRate); + const other: StoreSummary = makeStore(0); + + const collect = (store: StoreSummary, price: number, rate: number) => { + const commission = (price * rate) / 100; + store.count += 1; + store.gross += price; + store.commission += commission; + store.net += price - commission; + }; + + for (const purchase of purchases) { + const price = getPurchaseFinalPrice(purchase); + const gateway = purchase.gateway; + + let store: StoreSummary; + let rate: number; + if (gateway === PAYMENT_GATEWAYS.cafe.code) { + store = cafe; + rate = cafeRate; + } else if (gateway === PAYMENT_GATEWAYS.myket.code) { + store = myket; + rate = myketRate; + } else { + store = other; + rate = 0; + } + + collect(store, price, rate); + + totalCount += 1; + totalGross += price; + totalCommission += (price * rate) / 100; + + 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 * rate) / 100; + row.net += price - (price * rate) / 100; + } + + 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 + const [dateFrom, setDateFrom] = useState(''); + const [dateTo, setDateTo] = useState(''); + + // Status filter — both paid statuses selected by default + const [statuses, setStatuses] = useState([...PAID_STATUSES]); + + // Store commission rates (editable percentages) + const [myketRate, setMyketRate] = useState('15'); + const [cafeRate, setCafeRate] = useState('15'); + + const [allPurchases, setAllPurchases] = useState(null); + const [result, setResult] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(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))); + } catch (err) { + setError(err instanceof Error ? err.message : 'خطا در دریافت داده‌ها'); + } finally { + setIsLoading(false); + } + }, [token, myketRate, cafeRate]); + + // 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 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)); + }, [allPurchases, statuses, selectedProductId, selectedPackageName, dateFrom, dateTo, myketRate, cafeRate]); + + // 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('15'); + setCafeRate('15'); + }; + + 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 ( +
+
+ + + بارگذاری مجدد + + ) : 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" + > +
+
+ + +
+ +
+ + +
+ +
+ +
+ +
+ +
+
+ + {/* Status checkboxes */} +
+ +
+ {[ + { code: 2, label: 'موفق' }, + { code: 3, label: 'مصرف‌شده' }, + { code: 1, label: 'در انتظار پرداخت' }, + ].map(({ code, label }) => ( + + ))} +
+
+ + {/* Commission rates */} +
+
+ + setMyketRate(e.target.value)} + className={inputClass} + placeholder="۱۵" + /> +
+
+ + setCafeRate(e.target.value)} + className={inputClass} + placeholder="۱۵" + /> +
+
+

+ مایکت معمولاً ۱۵٪ یا ۳۰٪ و کافه بازار ۱۵٪ (زیر یک میلیارد تومان) یا ۳۰٪ کارمزد می‌گیرد. +

+ +
+ + +
+
+ + {error && ( +
+

{error}

+ +
+ )} + + {isLoading && ( +
+
+
+ )} + + {!isLoading && allPurchases && result && ( + <> + {/* Summary stat cards */} +
+
+
+ + تعداد فروش +
+
{formatPrice(result.totals.count)}
+
+
+
+ + فروش ناخالص +
+
{formatPrice(result.totals.gross)} تومان
+
+
+
+ + کارمزد فروشگاه‌ها +
+
+ {formatPrice(result.totals.commission)} تومان +
+
+
+
+ + سود خالص +
+
+ {formatPrice(result.totals.net)} تومان +
+
+
+ + {/* Per-store breakdown */} +
+ + + +
+ + {/* Per-product table */} +
+
+

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

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

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

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

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

+
+ )} +
+
+ ); +} + +function StoreCard({ title, summary }: { title: string; summary: StoreSummary }) { + return ( +
+
+

{title}

+ {summary.rate > 0 && ( + + کارمزد {summary.rate}٪ + + )} +
+
+
+ تعداد فروش + {formatPrice(summary.count)} +
+
+ فروش ناخالص + {formatPrice(summary.gross)} تومان +
+
+ کارمزد + {formatPrice(summary.commission)} تومان +
+
+ سود خالص + {formatPrice(summary.net)} تومان +
+
+
+ ); +} diff --git a/app/admin/dashboard/page.tsx b/app/admin/dashboard/page.tsx index 8231569..2e678c0 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 } from '@heroicons/react/24/outline'; +import { UsersIcon, CubeIcon, TagIcon, BellIcon, GiftIcon, ShoppingBagIcon, CalculatorIcon } from '@heroicons/react/24/outline'; import { MegaphoneIcon } from 'lucide-react'; export default function Dashboard() { @@ -27,6 +27,15 @@ export default function Dashboard() { iconColor: 'text-orange-600 dark:text-orange-400', hoverBorder: 'hover:border-orange-300 dark:hover:border-orange-700', }, + { + href: '/admin/accounting', + title: 'حسابداری', + desc: 'تحلیل فروش محصولات و محاسبه کارمزد مایکت و کافه بازار', + Icon: CalculatorIcon, + iconBg: 'bg-cyan-100 dark:bg-cyan-500/15', + iconColor: 'text-cyan-600 dark:text-cyan-400', + hoverBorder: 'hover:border-cyan-300 dark:hover:border-cyan-700', + }, { href: '/admin/packages', title: 'مدیریت پکیج‌ها', diff --git a/app/admin/purchases/page.tsx b/app/admin/purchases/page.tsx index d9c5d35..c00356f 100644 --- a/app/admin/purchases/page.tsx +++ b/app/admin/purchases/page.tsx @@ -15,7 +15,7 @@ import { getPurchaseStatusLabel, } from '@/types/purchase'; import { PackageName } from '@/types/package'; -import { formatPrice, formatDate, getProductTypeLabel } from '@/lib/utils'; +import { formatPrice, formatDate, getProductTypeLabel, getPurchaseFinalPrice } from '@/lib/utils'; import { MagnifyingGlassIcon, ArrowPathIcon } from '@heroicons/react/24/outline'; const statusBadgeClass = (status: number): string => { @@ -37,26 +37,6 @@ const emptyFilters: PurchaseFilters = { status: undefined, }; -// قیمت نهایی خرید (مبلغ قابل‌نمایش در جدول). -// وقتی محصول تخفیف دارد، مقدار نهایی همان «قیمت کلی تخفیف‌خورده» است؛ -// اما در پکیج‌هایی که تخفیف ندارند، «قیمت» خود محصول مقدار نهایی است. -const getPurchaseFinalPrice = (purchase: Purchase): number => { - const product = purchase.product; - - // اگر محصول تخفیف دارد، قیمت نهایی همان قیمت تخفیف‌خورده است - const discountedPrice = product?.discounted_price; - if (discountedPrice) { - const parsed = parseInt(discountedPrice.replace(/[^\d]/g, ''), 10); - if (!Number.isNaN(parsed) && parsed > 0) return parsed; - } - - // بدون تخفیف → قیمت اصلی محصول (فقط وقتی discount نداشته باشد) - if (product?.price) return product.price; - - // برگشت به مبلغ ثبت‌شدهٔ تراکنش وقتی محصول در دسترس نیست - return purchase.amount || 0; -}; - export default function PurchasesPage() { const { token } = useAuth(); const [packages, setPackages] = useState([]); diff --git a/components/admin/PersianDatePicker.tsx b/components/admin/PersianDatePicker.tsx new file mode 100644 index 0000000..b682f81 --- /dev/null +++ b/components/admin/PersianDatePicker.tsx @@ -0,0 +1,154 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { DayPicker } from '@daypicker/persian'; +import '@daypicker/react/style.css'; +import { CalendarDaysIcon, XMarkIcon } from '@heroicons/react/24/outline'; + +interface PersianDatePickerProps { + /** Gregorian YYYY-MM-DD, or '' when empty. */ + value: string; + /** Called with the selected Gregorian YYYY-MM-DD, or '' when cleared. */ + onChange: (value: string) => void; + label?: string; + placeholder?: string; + id?: string; +} + +// Parses a Gregorian "YYYY-MM-DD" into a local Date (pinned at noon to avoid +// any date shift from timezone offsets). +const parseGregorian = (value: string): Date | null => { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); + if (!match) return null; + const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]), 12, 0, 0); + return Number.isNaN(date.getTime()) ? null : date; +}; + +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}`; +}; + +// Renders the given Gregorian date as a Persian (Jalali) string with Persian +// digits, e.g. "۱۴۰۴/۰۵/۱۱". +const jalaliFormat = new Intl.DateTimeFormat('fa-IR-u-ca-persian', { + year: 'numeric', + month: '2-digit', + day: '2-digit', +}); + +const formatJalali = (date: Date): string => jalaliFormat.format(date); + +/** + * A Persian (Jalali) date input built on @daypicker/persian. + * + * The calendar operates in the Jalali calendar, but the public value is a + * Gregorian "YYYY-MM-DD" string — the same format the native produced — so the accounting filters keep working unchanged. + * + * Note: with the default dateLib (no `timeZone` prop), @daypicker/persian + * works with plain JS `Date` objects whose native getters already return the + * Gregorian date; the Jalali conversion happens inside the calendar library. + * That's why we can read `getFullYear()/getMonth()/getDate()` straight off the + * picked date. + */ +export default function PersianDatePicker({ + value, + onChange, + label, + placeholder, + id, +}: PersianDatePickerProps) { + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + + const selectedDate = value ? parseGregorian(value) : null; + + // Controlled month so the popover opens on the selected month (or now). + const [viewMonth, setViewMonth] = useState(() => selectedDate ?? new Date()); + + // Close on outside click or Escape. + useEffect(() => { + if (!open) return; + const handlePointerDown = (event: MouseEvent) => { + if (rootRef.current && !rootRef.current.contains(event.target as Node)) { + setOpen(false); + } + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setOpen(false); + }; + document.addEventListener('mousedown', handlePointerDown); + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('mousedown', handlePointerDown); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [open]); + + const handleSelect = (date: Date | undefined) => { + if (date) onChange(toGregorianValue(date)); + setOpen(false); + }; + + const handleClear = () => { + onChange(''); + setOpen(false); + }; + + const toggle = () => { + setViewMonth(selectedDate ?? new Date()); + setOpen((prev) => !prev); + }; + + return ( +
+ {label && ( + + )} + + + + {open && ( +
+ +
+ )} +
+ ); +} diff --git a/components/admin/Sidebar.tsx b/components/admin/Sidebar.tsx index 668f047..8b851a5 100644 --- a/components/admin/Sidebar.tsx +++ b/components/admin/Sidebar.tsx @@ -13,6 +13,7 @@ import { PowerIcon, ShoppingBagIcon, HomeIcon, + CalculatorIcon, Bars3Icon, XMarkIcon, } from '@heroicons/react/24/outline'; @@ -23,6 +24,7 @@ const navItems = [ { href: '/admin/dashboard', label: 'داشبورد', Icon: HomeIcon }, { href: '/admin/users', label: 'کاربران', Icon: UsersIcon }, { href: '/admin/purchases', label: 'خریدها', Icon: ShoppingBagIcon }, + { href: '/admin/accounting', label: 'حسابداری', Icon: CalculatorIcon }, { href: '/admin/packages', label: 'پکیج‌ها', Icon: CubeIcon }, { href: '/admin/products', label: 'محصولات', Icon: TagIcon }, { href: '/admin/reminders', label: 'یادآوری‌ها', Icon: BellIcon }, diff --git a/lib/api/purchases.ts b/lib/api/purchases.ts index 1d41dc3..f9aee9a 100644 --- a/lib/api/purchases.ts +++ b/lib/api/purchases.ts @@ -1,22 +1,57 @@ import { apiClient } from './client'; import { Purchase, Paginated, PurchaseFilters } from '@/types/purchase'; +const buildPurchaseQuery = (filters: PurchaseFilters): string => { + const params = new URLSearchParams(); + + if (filters.email) params.append('email', filters.email); + if (filters.mobile) params.append('mobile', filters.mobile); + if (filters.package_name) params.append('package_name', filters.package_name); + if (filters.gateway) params.append('gateway', filters.gateway); + if (filters.status !== undefined && filters.status !== null) { + params.append('status', String(filters.status)); + } + if (filters.product_id !== undefined && filters.product_id !== null) { + params.append('product_id', String(filters.product_id)); + } + 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 purchasesApi = { // List all subscription purchases with optional filters getPurchases: async (filters: PurchaseFilters, token: string): Promise> => { - const params = new URLSearchParams(); - - if (filters.email) params.append('email', filters.email); - if (filters.mobile) params.append('mobile', filters.mobile); - if (filters.package_name) params.append('package_name', filters.package_name); - if (filters.gateway) params.append('gateway', filters.gateway); - if (filters.status !== undefined && filters.status !== null) { - params.append('status', String(filters.status)); - } - if (filters.per_page) params.append('per_page', String(filters.per_page)); - if (filters.page) params.append('page', String(filters.page)); - - const qs = params.toString(); + const qs = buildPurchaseQuery(filters); return apiClient.get>(`/admin/purchases${qs ? `?${qs}` : ''}`, token); }, + + // Fetch ALL purchases matching the filters (used by the accounting section). + // Learns the total from page 1, then fetches the remaining pages concurrently. + getAllPurchases: async (filters: PurchaseFilters, token: string): Promise => { + const perPage = filters.per_page || 500; + const first = await purchasesApi.getPurchases({ ...filters, per_page: perPage, page: 1 }, token); + + const all = new Map(); + first.data.forEach((p) => all.set(p.id, p)); + + const lastPage = first.last_page ?? 1; + if (lastPage > 1) { + const pages: number[] = []; + for (let page = 2; page <= lastPage; page++) pages.push(page); + + // Fetch in small concurrent batches to avoid hammering the API + const batchSize = 4; + for (let i = 0; i < pages.length; i += batchSize) { + const batch = pages.slice(i, i + batchSize); + const results = await Promise.all( + batch.map((page) => purchasesApi.getPurchases({ ...filters, per_page: perPage, page }, token)) + ); + results.forEach((res) => res.data.forEach((p) => all.set(p.id, p))); + } + } + + return Array.from(all.values()); + }, }; diff --git a/lib/utils.ts b/lib/utils.ts index ac72bbb..95d4c79 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -1,10 +1,40 @@ import { User } from "@/types/user"; +import { Purchase } from "@/types/purchase"; // Single source of truth for product type labels lives in types/product.ts // (kept in sync with the backend Product::TYPES). Re-exported here so existing // `@/lib/utils` imports keep working without a divergent (previously wrong) map. export { getProductTypeLabel } from "@/types/product"; +// قیمت نهایی خرید — shared between purchases table and accounting page. +// اگر محصول تخفیف داشته باشد قیمت تخفیف‌خورده، در غیر این صورت قیمت اصلی، و در نهایت مبلغ تراکنش. +export const getPurchaseFinalPrice = (purchase: Purchase): number => { + const product = purchase.product; + + // اگر محصول تخفیف دارد، قیمت نهایی همان قیمت تخفیف‌خورده است + const discountedPrice = product?.discounted_price; + if (discountedPrice) { + const parsed = parseInt(discountedPrice.replace(/[^\d]/g, ''), 10); + if (!Number.isNaN(parsed) && parsed > 0) return parsed; + } + + // بدون تخفیف → قیمت اصلی محصول + if (product?.price) return product.price; + + // برگشت به مبلغ ثبت‌شدهٔ تراکنش وقتی محصول در دسترس نیست + return purchase.amount || 0; +}; + +// Parse a percentage input (e.g. "۱۵%" / "15") and clamp to 0–100. +export const parseStoreRate = (value: string | number): number => { + if (value === null || value === undefined || value === '') return 0; + const str = String(value).replace(/[^\d]/g, ''); + if (!str) return 0; + const n = parseInt(str, 10); + if (Number.isNaN(n)) return 0; + return Math.min(100, Math.max(0, n)); +}; + export const formatDate = (dateString: string | null): string => { if (!dateString) return 'نامشخص'; diff --git a/package-lock.json b/package-lock.json index fafcb2e..ced79a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,9 @@ "name": "approagency admin pannel", "version": "0.1.0", "dependencies": { + "@daypicker/persian": "^10.0.1", "@heroicons/react": "^2.2.0", + "date-fns-jalali": "^4.4.0-0", "framer-motion": "^12.23.24", "lucide-react": "^0.552.0", "next": "16.0.1", @@ -72,7 +74,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -282,6 +283,63 @@ "node": ">=6.9.0" } }, + "node_modules/@date-fns/tz": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.5.0.tgz", + "integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==", + "license": "MIT" + }, + "node_modules/@daypicker/persian": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@daypicker/persian/-/persian-10.0.1.tgz", + "integrity": "sha512-dRgANykUb+QOMaHNlYx1Vz9G4HXT2RC2SLQ6RBdIkGYImBHteFJ72zvISSEQ7EepcYpS4CGXL2D+P9f5ddbAZg==", + "license": "MIT", + "dependencies": { + "@date-fns/tz": "^1.4.1", + "@daypicker/react": "10.0.1", + "date-fns-jalali": "4.1.0-0" + }, + "peerDependencies": { + "@types/react": ">=16.8.0", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@daypicker/persian/node_modules/date-fns-jalali": { + "version": "4.1.0-0", + "resolved": "https://registry.npmjs.org/date-fns-jalali/-/date-fns-jalali-4.1.0-0.tgz", + "integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==", + "license": "MIT" + }, + "node_modules/@daypicker/react": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@daypicker/react/-/react-10.0.1.tgz", + "integrity": "sha512-lH4YQz4iMBWP8hsI1bD9Eg0T7t503IkSUR/WDGGkV5mKZvwVv+ukCkJz7yN+uVFBv7vHTK+ww7a5EvlkeFwPYQ==", + "license": "MIT", + "dependencies": { + "react-day-picker": "10.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/gpbl" + }, + "peerDependencies": { + "@types/react": ">=16.8.0", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@emnapi/core": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.0.tgz", @@ -1550,9 +1608,8 @@ "version": "19.2.2", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", - "dev": true, + "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -1613,7 +1670,6 @@ "integrity": "sha512-6m1I5RmHBGTnUGS113G04DMu3CpSdxCAU/UvtjNWL4Nuf3MW9tQhiJqRlHzChIkhy6kZSAQmc+I1bcGjE3yNKg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.3", "@typescript-eslint/types": "8.46.3", @@ -2144,7 +2200,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2523,7 +2578,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2694,7 +2748,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/damerau-levenshtein": { @@ -2758,6 +2812,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz", + "integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/date-fns-jalali": { + "version": "4.4.0-0", + "resolved": "https://registry.npmjs.org/date-fns-jalali/-/date-fns-jalali-4.4.0-0.tgz", + "integrity": "sha512-4kei2k9Hr/fFvxNL7C6k+WeEYVehe5jO8zDcpREDDZ4ILa9af8tNmqNUJKAGJyPqKAHsdMmwY0jX5gTCiRWw4A==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -3091,7 +3161,6 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3277,7 +3346,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -5454,7 +5522,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -5529,17 +5596,41 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } }, + "node_modules/react-day-picker": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-10.0.1.tgz", + "integrity": "sha512-eNh6BlwcYInWaJtRv18mXQ06Ys/H6rdTZAnTaSdOYJuTpwP1JMCHNd1FDRadA+gbeinq+psdULN5Xnowy9mV8w==", + "license": "MIT", + "dependencies": { + "@date-fns/tz": "^1.4.1", + "date-fns": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/gpbl" + }, + "peerDependencies": { + "@types/react": ">=16.8.0", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/react-dom": { "version": "19.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -6226,7 +6317,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -6389,7 +6479,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6665,7 +6754,6 @@ "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 2e1d17f..3316f2d 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,9 @@ "prepare": "git config core.hooksPath .githooks || true" }, "dependencies": { + "@daypicker/persian": "^10.0.1", "@heroicons/react": "^2.2.0", + "date-fns-jalali": "^4.4.0-0", "framer-motion": "^12.23.24", "lucide-react": "^0.552.0", "next": "16.0.1", @@ -29,4 +31,4 @@ "tailwindcss": "^4.2.0", "typescript": "^5" } -} \ No newline at end of file +} diff --git a/types/purchase.ts b/types/purchase.ts index aa1b570..aa09d49 100644 --- a/types/purchase.ts +++ b/types/purchase.ts @@ -89,6 +89,7 @@ export interface PurchaseFilters { package_name?: string; gateway?: string; // gateway name key (e.g. "zarinpal") status?: number; + product_id?: number; // filter by a specific product per_page?: number; page?: number; }