2473 lines
75 KiB
TypeScript
2473 lines
75 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
Activity,
|
||
ArrowDownRight,
|
||
ArrowUpRight,
|
||
BarChart3,
|
||
Bell,
|
||
Building2,
|
||
CheckCircle2,
|
||
ChevronLeft,
|
||
ChevronRight,
|
||
ChevronsLeft,
|
||
ChevronsRight,
|
||
ChevronsUpDown,
|
||
CircleDollarSign,
|
||
Clock,
|
||
Copy,
|
||
CreditCard,
|
||
Download,
|
||
Eye,
|
||
FileText,
|
||
Filter,
|
||
LayoutDashboard,
|
||
LogOut,
|
||
MoreHorizontal,
|
||
Pencil,
|
||
Plus,
|
||
Receipt,
|
||
RotateCcw,
|
||
Search,
|
||
Settings,
|
||
ShoppingCart,
|
||
User,
|
||
UserPlus,
|
||
Users,
|
||
Wallet,
|
||
X,
|
||
XCircle,
|
||
Zap,
|
||
} from "lucide-react";
|
||
import * as React from "react";
|
||
import type { TooltipProps } from "recharts";
|
||
import {
|
||
Area,
|
||
AreaChart,
|
||
Bar,
|
||
BarChart,
|
||
CartesianGrid,
|
||
Tooltip,
|
||
XAxis,
|
||
YAxis,
|
||
} from "recharts";
|
||
|
||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||
import { Button } from "@/components/ui/button";
|
||
import { type ChartConfig, ChartContainer } from "@/components/ui/chart";
|
||
import {
|
||
Collapsible,
|
||
CollapsibleContent,
|
||
CollapsibleTrigger,
|
||
} from "@/components/ui/collapsible";
|
||
import {
|
||
DropdownMenu,
|
||
DropdownMenuCheckboxItem,
|
||
DropdownMenuContent,
|
||
DropdownMenuItem,
|
||
DropdownMenuLabel,
|
||
DropdownMenuSeparator,
|
||
DropdownMenuTrigger,
|
||
} from "@/components/ui/dropdown-menu";
|
||
import {
|
||
Dialog,
|
||
DialogClose,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
DialogTrigger,
|
||
} from "@/components/ui/dialog";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Label } from "@/components/ui/label";
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select";
|
||
import {
|
||
Sidebar,
|
||
SidebarContent,
|
||
SidebarFooter,
|
||
SidebarGroup,
|
||
SidebarGroupContent,
|
||
SidebarGroupLabel,
|
||
SidebarHeader,
|
||
SidebarInset,
|
||
SidebarMenu,
|
||
SidebarMenuButton,
|
||
SidebarMenuItem,
|
||
SidebarMenuSub,
|
||
SidebarMenuSubButton,
|
||
SidebarMenuSubItem,
|
||
SidebarProvider,
|
||
SidebarRail,
|
||
SidebarTrigger,
|
||
} from "@/components/ui/sidebar";
|
||
import {
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableHead,
|
||
TableHeader,
|
||
TableRow,
|
||
} from "@/components/ui/table";
|
||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||
import { cn } from "@/lib/utils";
|
||
|
||
type NavItem = {
|
||
label: string;
|
||
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
||
href: string;
|
||
isActive?: boolean;
|
||
children?: NavItem[];
|
||
};
|
||
|
||
type NavGroup = {
|
||
title: string;
|
||
items: NavItem[];
|
||
defaultOpen?: boolean;
|
||
};
|
||
|
||
type UserData = {
|
||
name: string;
|
||
email: string;
|
||
avatar: string;
|
||
};
|
||
|
||
type SidebarData = {
|
||
logo: {
|
||
src: string;
|
||
alt: string;
|
||
title: string;
|
||
description: string;
|
||
href: string;
|
||
};
|
||
navGroups: NavGroup[];
|
||
footerGroup: NavGroup;
|
||
user?: UserData;
|
||
};
|
||
|
||
type TransactionStatus = "completed" | "pending" | "failed" | "refunded";
|
||
|
||
type TransactionType = "Sale" | "Refund" | "Subscription";
|
||
|
||
type Transaction = {
|
||
id: string;
|
||
txnId: string;
|
||
amount: number;
|
||
customerName: string;
|
||
customerInitials: string;
|
||
type: TransactionType;
|
||
paymentMethod: "Credit Card" | "Debit Card" | "PayPal" | "Bank Transfer";
|
||
date: string;
|
||
status: TransactionStatus;
|
||
};
|
||
|
||
type PeriodKey = "30days" | "3months" | "1year";
|
||
|
||
type RevenueDataPoint = {
|
||
month: string;
|
||
revenue: number;
|
||
};
|
||
|
||
type CostDataPoint = {
|
||
month: string;
|
||
cogs: number;
|
||
operatingExpenses: number;
|
||
};
|
||
|
||
type PeriodSummary = {
|
||
total: number;
|
||
change: number;
|
||
isPositive: boolean;
|
||
};
|
||
|
||
type StatItem = {
|
||
title: string;
|
||
previousValue: number;
|
||
value: number;
|
||
changePercent: number;
|
||
isPositive: boolean;
|
||
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
||
format: "currency" | "percentage";
|
||
};
|
||
|
||
const currencyFormatter = new Intl.NumberFormat("fa-IR", {
|
||
style: "currency",
|
||
currency: "IRR",
|
||
});
|
||
|
||
const compactCurrencyFormatter = new Intl.NumberFormat("fa-IR", {
|
||
style: "currency",
|
||
currency: "IRR",
|
||
notation: "compact",
|
||
maximumFractionDigits: 0,
|
||
});
|
||
|
||
const percentFormatter = new Intl.NumberFormat("fa-IR", {
|
||
style: "percent",
|
||
minimumFractionDigits: 1,
|
||
maximumFractionDigits: 1,
|
||
});
|
||
|
||
const monthLabel = (monthIndex: number) =>
|
||
new Intl.DateTimeFormat("fa-IR", { month: "short" }).format(
|
||
new Date(2025, monthIndex, 1),
|
||
);
|
||
|
||
const formatTransactionDate = (date: string): string => {
|
||
if (date === "Today") return "امروز";
|
||
const dayAgoMatch = date.match(/^(\d+) day ago$/);
|
||
if (dayAgoMatch) {
|
||
return `${Number(dayAgoMatch[1]).toLocaleString("fa-IR")} روز پیش`;
|
||
}
|
||
const daysAgoMatch = date.match(/^(\d+) days ago$/);
|
||
if (daysAgoMatch) {
|
||
return `${Number(daysAgoMatch[1]).toLocaleString("fa-IR")} روز پیش`;
|
||
}
|
||
return date;
|
||
};
|
||
|
||
const transactionTypeLabels: Record<TransactionType, string> = {
|
||
Sale: "فروش",
|
||
Refund: "بازپرداخت",
|
||
Subscription: "اشتراک",
|
||
};
|
||
|
||
const paymentMethodLabels: Record<Transaction["paymentMethod"], string> = {
|
||
"Credit Card": "کارت اعتباری",
|
||
"Debit Card": "کارت نقدی",
|
||
PayPal: "پیپال",
|
||
"Bank Transfer": "انتقال بانکی",
|
||
};
|
||
|
||
const mixBase = "var(--background)";
|
||
|
||
const palette = {
|
||
primary: "var(--primary)",
|
||
secondary: {
|
||
light: `color-mix(in oklch, var(--primary) 75%, ${mixBase})`,
|
||
dark: `color-mix(in oklch, var(--primary) 85%, ${mixBase})`,
|
||
},
|
||
};
|
||
|
||
const revenueChartConfig = {
|
||
revenue: { label: "درآمد", color: palette.primary },
|
||
} satisfies ChartConfig;
|
||
|
||
const costsChartConfig = {
|
||
cogs: { label: "بهای تمامشده", color: palette.primary },
|
||
operatingExpenses: {
|
||
label: "هزینههای عملیاتی",
|
||
theme: palette.secondary,
|
||
},
|
||
} satisfies ChartConfig;
|
||
|
||
const sidebarData: SidebarData = {
|
||
logo: {
|
||
src: "/block-media/shadcnblocks/block/logos/NovaSales-logo.svg",
|
||
alt: "نوافروش",
|
||
title: "NovaSales",
|
||
description: "پلتفرم فروش ابری",
|
||
href: "https://www.NovaSales",
|
||
},
|
||
navGroups: [
|
||
{
|
||
title: "نمای کلی",
|
||
defaultOpen: true,
|
||
items: [
|
||
{
|
||
label: "داشبورد",
|
||
icon: LayoutDashboard,
|
||
href: "#",
|
||
isActive: true,
|
||
},
|
||
{ label: "تحلیل فروش", icon: BarChart3, href: "#" },
|
||
{ label: "فعالیت تیم", icon: Activity, href: "#" },
|
||
],
|
||
},
|
||
{
|
||
title: "اعضای تیم",
|
||
defaultOpen: true,
|
||
items: [
|
||
{
|
||
label: "فهرست معاملات",
|
||
icon: Users,
|
||
href: "#",
|
||
children: [
|
||
{ label: "همه اعضای تیم", icon: User, href: "#" },
|
||
{ label: "فضاهای کاری", icon: Building2, href: "#" },
|
||
],
|
||
},
|
||
{ label: "دعوتها", icon: UserPlus, href: "#" },
|
||
],
|
||
},
|
||
{
|
||
title: "صورتحساب",
|
||
defaultOpen: false,
|
||
items: [
|
||
{ label: "اشتراکها", icon: CreditCard, href: "#" },
|
||
{ label: "مصرف", icon: Zap, href: "#" },
|
||
{ label: "فاکتورها", icon: Receipt, href: "#" },
|
||
],
|
||
},
|
||
{
|
||
title: "گزارشها",
|
||
defaultOpen: false,
|
||
items: [
|
||
{ label: "گزارشهای ذخیرهشده", icon: FileText, href: "#" },
|
||
{ label: "خروجیها", icon: Download, href: "#" },
|
||
],
|
||
},
|
||
],
|
||
footerGroup: {
|
||
title: "تنظیمات",
|
||
items: [{ label: "تنظیمات", icon: Settings, href: "#" }],
|
||
},
|
||
user: {
|
||
name: "مریم الیس",
|
||
email: "morgan.ellis@northwind.app",
|
||
avatar:
|
||
"/block-media/shadcnblocks/block/avatar-1.webp",
|
||
},
|
||
};
|
||
|
||
const headerNotificationItems = [
|
||
{
|
||
id: "1",
|
||
title: "فید بانکی همگام شد",
|
||
body: "۴۲ تراکنش جدید از حساب جاری نورثویند وارد شد.",
|
||
time: "۴ دقیقه پیش",
|
||
},
|
||
{
|
||
id: "2",
|
||
title: "آستانه بودجه",
|
||
body: "هزینه بازاریابی به ۸۸٪ سقف فصلی رسیده است.",
|
||
time: "۱ ساعت پیش",
|
||
},
|
||
{
|
||
id: "3",
|
||
title: "یادآوری تطبیق",
|
||
body: "۳ حساب برای بستن پایان ماه نیاز به بررسی دارند.",
|
||
time: "۳ ساعت پیش",
|
||
},
|
||
{
|
||
id: "4",
|
||
title: "گزارش CFO آماده است",
|
||
body: "صورت سود و زیان یکپارچه ماه گذشته برای خروجی در دسترس است.",
|
||
time: "دیروز",
|
||
},
|
||
] as const;
|
||
|
||
const unreadNotificationCount = headerNotificationItems.length;
|
||
|
||
const revenueData: Record<PeriodKey, RevenueDataPoint[]> = {
|
||
"30days": [
|
||
{ month: "هفته ۱", revenue: 87500 },
|
||
{ month: "هفته ۲", revenue: 95200 },
|
||
{ month: "هفته ۳", revenue: 102800 },
|
||
{ month: "هفته ۴", revenue: 91400 },
|
||
{ month: "هفته ۵", revenue: 110600 },
|
||
],
|
||
"3months": [
|
||
{ month: monthLabel(9), revenue: 342000 },
|
||
{ month: monthLabel(10), revenue: 378000 },
|
||
{ month: monthLabel(11), revenue: 456000 },
|
||
{ month: monthLabel(0), revenue: 398000 },
|
||
{ month: monthLabel(1), revenue: 412000 },
|
||
{ month: monthLabel(2), revenue: 445000 },
|
||
],
|
||
"1year": [
|
||
{ month: monthLabel(0), revenue: 285000 },
|
||
{ month: monthLabel(1), revenue: 312000 },
|
||
{ month: monthLabel(2), revenue: 338000 },
|
||
{ month: monthLabel(3), revenue: 356000 },
|
||
{ month: monthLabel(4), revenue: 342000 },
|
||
{ month: monthLabel(5), revenue: 378000 },
|
||
{ month: monthLabel(6), revenue: 395000 },
|
||
{ month: monthLabel(7), revenue: 418000 },
|
||
{ month: monthLabel(8), revenue: 432000 },
|
||
{ month: monthLabel(9), revenue: 456000 },
|
||
{ month: monthLabel(10), revenue: 478000 },
|
||
{ month: monthLabel(11), revenue: 512000 },
|
||
],
|
||
};
|
||
|
||
const costsData: Record<PeriodKey, CostDataPoint[]> = {
|
||
"30days": [
|
||
{ month: "هفته ۱", cogs: 31500, operatingExpenses: 18200 },
|
||
{ month: "هفته ۲", cogs: 34100, operatingExpenses: 19800 },
|
||
{ month: "هفته ۳", cogs: 37000, operatingExpenses: 20500 },
|
||
{ month: "هفته ۴", cogs: 32800, operatingExpenses: 19100 },
|
||
{ month: "هفته ۵", cogs: 39800, operatingExpenses: 21600 },
|
||
],
|
||
"3months": [
|
||
{ month: monthLabel(9), cogs: 123100, operatingExpenses: 68400 },
|
||
{ month: monthLabel(10), cogs: 136100, operatingExpenses: 75600 },
|
||
{ month: monthLabel(11), cogs: 164200, operatingExpenses: 91200 },
|
||
{ month: monthLabel(0), cogs: 143300, operatingExpenses: 79600 },
|
||
{ month: monthLabel(1), cogs: 148300, operatingExpenses: 82400 },
|
||
{ month: monthLabel(2), cogs: 160200, operatingExpenses: 89000 },
|
||
],
|
||
"1year": [
|
||
{ month: monthLabel(0), cogs: 102600, operatingExpenses: 57000 },
|
||
{ month: monthLabel(1), cogs: 112300, operatingExpenses: 62400 },
|
||
{ month: monthLabel(2), cogs: 121700, operatingExpenses: 67600 },
|
||
{ month: monthLabel(3), cogs: 128200, operatingExpenses: 71200 },
|
||
{ month: monthLabel(4), cogs: 123100, operatingExpenses: 68400 },
|
||
{ month: monthLabel(5), cogs: 136100, operatingExpenses: 75600 },
|
||
{ month: monthLabel(6), cogs: 142200, operatingExpenses: 79000 },
|
||
{ month: monthLabel(7), cogs: 150500, operatingExpenses: 83600 },
|
||
{ month: monthLabel(8), cogs: 155500, operatingExpenses: 86400 },
|
||
{ month: monthLabel(9), cogs: 164200, operatingExpenses: 91200 },
|
||
{ month: monthLabel(10), cogs: 172100, operatingExpenses: 95600 },
|
||
{ month: monthLabel(11), cogs: 184300, operatingExpenses: 102400 },
|
||
],
|
||
};
|
||
|
||
const revenueSummary: Record<PeriodKey, PeriodSummary> = {
|
||
"30days": { total: 487500, change: 14.2, isPositive: true },
|
||
"3months": { total: 2431000, change: 11.8, isPositive: true },
|
||
"1year": { total: 4702000, change: 18.5, isPositive: true },
|
||
};
|
||
|
||
const costsSummary: Record<PeriodKey, PeriodSummary> = {
|
||
"30days": { total: 274400, change: 8.6, isPositive: false },
|
||
"3months": { total: 1361400, change: 9.3, isPositive: false },
|
||
"1year": { total: 2705800, change: 12.1, isPositive: false },
|
||
};
|
||
|
||
const statsData: StatItem[] = [
|
||
{
|
||
title: "درآمد ناخالص",
|
||
previousValue: 426800,
|
||
value: 487500,
|
||
changePercent: 14.2,
|
||
isPositive: true,
|
||
icon: CircleDollarSign,
|
||
format: "currency",
|
||
},
|
||
{
|
||
title: "سود خالص",
|
||
previousValue: 168200,
|
||
value: 213100,
|
||
changePercent: 26.7,
|
||
isPositive: true,
|
||
icon: Wallet,
|
||
format: "currency",
|
||
},
|
||
{
|
||
title: "نرخ بازپرداخت",
|
||
previousValue: 0.042,
|
||
value: 0.031,
|
||
changePercent: 26.2,
|
||
isPositive: true,
|
||
icon: RotateCcw,
|
||
format: "percentage",
|
||
},
|
||
{
|
||
title: "میانگین تراکنش",
|
||
previousValue: 68.4,
|
||
value: 74.2,
|
||
changePercent: 8.5,
|
||
isPositive: true,
|
||
icon: CreditCard,
|
||
format: "currency",
|
||
},
|
||
];
|
||
|
||
const transactionRecords: Transaction[] = [
|
||
{
|
||
id: "1",
|
||
txnId: "TXN-100201",
|
||
amount: 129.99,
|
||
customerName: "Sarah Johnson",
|
||
customerInitials: "SJ",
|
||
type: "Sale",
|
||
paymentMethod: "Credit Card",
|
||
date: "Today",
|
||
status: "completed",
|
||
},
|
||
{
|
||
id: "2",
|
||
txnId: "TXN-100202",
|
||
amount: 84.5,
|
||
customerName: "Michael Chen",
|
||
customerInitials: "MC",
|
||
type: "Sale",
|
||
paymentMethod: "PayPal",
|
||
date: "Today",
|
||
status: "completed",
|
||
},
|
||
{
|
||
id: "3",
|
||
txnId: "TXN-100203",
|
||
amount: 249.0,
|
||
customerName: "Emma Wilson",
|
||
customerInitials: "EW",
|
||
type: "Subscription",
|
||
paymentMethod: "Credit Card",
|
||
date: "Today",
|
||
status: "pending",
|
||
},
|
||
{
|
||
id: "4",
|
||
txnId: "TXN-100204",
|
||
amount: 45.99,
|
||
customerName: "James Rodriguez",
|
||
customerInitials: "JR",
|
||
type: "Refund",
|
||
paymentMethod: "Debit Card",
|
||
date: "Today",
|
||
status: "refunded",
|
||
},
|
||
{
|
||
id: "5",
|
||
txnId: "TXN-100205",
|
||
amount: 312.0,
|
||
customerName: "Lisa Park",
|
||
customerInitials: "LP",
|
||
type: "Sale",
|
||
paymentMethod: "Bank Transfer",
|
||
date: "Today",
|
||
status: "failed",
|
||
},
|
||
{
|
||
id: "6",
|
||
txnId: "TXN-100206",
|
||
amount: 67.25,
|
||
customerName: "David Kim",
|
||
customerInitials: "DK",
|
||
type: "Sale",
|
||
paymentMethod: "Credit Card",
|
||
date: "1 day ago",
|
||
status: "completed",
|
||
},
|
||
{
|
||
id: "7",
|
||
txnId: "TXN-100207",
|
||
amount: 189.99,
|
||
customerName: "Anna Martinez",
|
||
customerInitials: "AM",
|
||
type: "Subscription",
|
||
paymentMethod: "PayPal",
|
||
date: "1 day ago",
|
||
status: "completed",
|
||
},
|
||
{
|
||
id: "8",
|
||
txnId: "TXN-100208",
|
||
amount: 95.0,
|
||
customerName: "Robert Taylor",
|
||
customerInitials: "RT",
|
||
type: "Sale",
|
||
paymentMethod: "Debit Card",
|
||
date: "1 day ago",
|
||
status: "pending",
|
||
},
|
||
{
|
||
id: "9",
|
||
txnId: "TXN-100209",
|
||
amount: 156.5,
|
||
customerName: "Jennifer Lee",
|
||
customerInitials: "JL",
|
||
type: "Refund",
|
||
paymentMethod: "Credit Card",
|
||
date: "1 day ago",
|
||
status: "refunded",
|
||
},
|
||
{
|
||
id: "10",
|
||
txnId: "TXN-100210",
|
||
amount: 78.99,
|
||
customerName: "William Brown",
|
||
customerInitials: "WB",
|
||
type: "Sale",
|
||
paymentMethod: "PayPal",
|
||
date: "2 days ago",
|
||
status: "completed",
|
||
},
|
||
{
|
||
id: "11",
|
||
txnId: "TXN-100211",
|
||
amount: 425.0,
|
||
customerName: "Sophia Davis",
|
||
customerInitials: "SD",
|
||
type: "Subscription",
|
||
paymentMethod: "Credit Card",
|
||
date: "2 days ago",
|
||
status: "completed",
|
||
},
|
||
{
|
||
id: "12",
|
||
txnId: "TXN-100212",
|
||
amount: 34.99,
|
||
customerName: "Daniel Garcia",
|
||
customerInitials: "DG",
|
||
type: "Sale",
|
||
paymentMethod: "Debit Card",
|
||
date: "2 days ago",
|
||
status: "failed",
|
||
},
|
||
{
|
||
id: "13",
|
||
txnId: "TXN-100213",
|
||
amount: 210.0,
|
||
customerName: "Olivia White",
|
||
customerInitials: "OW",
|
||
type: "Sale",
|
||
paymentMethod: "Bank Transfer",
|
||
date: "3 days ago",
|
||
status: "completed",
|
||
},
|
||
{
|
||
id: "14",
|
||
txnId: "TXN-100214",
|
||
amount: 149.99,
|
||
customerName: "Thomas Anderson",
|
||
customerInitials: "TA",
|
||
type: "Sale",
|
||
paymentMethod: "Credit Card",
|
||
date: "3 days ago",
|
||
status: "completed",
|
||
},
|
||
{
|
||
id: "15",
|
||
txnId: "TXN-100215",
|
||
amount: 62.5,
|
||
customerName: "Emily Thompson",
|
||
customerInitials: "ET",
|
||
type: "Refund",
|
||
paymentMethod: "PayPal",
|
||
date: "3 days ago",
|
||
status: "refunded",
|
||
},
|
||
{
|
||
id: "16",
|
||
txnId: "TXN-100216",
|
||
amount: 299.0,
|
||
customerName: "Christopher Moore",
|
||
customerInitials: "CM",
|
||
type: "Subscription",
|
||
paymentMethod: "Credit Card",
|
||
date: "4 days ago",
|
||
status: "completed",
|
||
},
|
||
{
|
||
id: "17",
|
||
txnId: "TXN-100217",
|
||
amount: 88.75,
|
||
customerName: "Isabella Jackson",
|
||
customerInitials: "IJ",
|
||
type: "Sale",
|
||
paymentMethod: "Debit Card",
|
||
date: "4 days ago",
|
||
status: "pending",
|
||
},
|
||
{
|
||
id: "18",
|
||
txnId: "TXN-100218",
|
||
amount: 175.0,
|
||
customerName: "Matthew Harris",
|
||
customerInitials: "MH",
|
||
type: "Sale",
|
||
paymentMethod: "Bank Transfer",
|
||
date: "5 days ago",
|
||
status: "completed",
|
||
},
|
||
{
|
||
id: "19",
|
||
txnId: "TXN-100219",
|
||
amount: 42.99,
|
||
customerName: "Ava Clark",
|
||
customerInitials: "AC",
|
||
type: "Sale",
|
||
paymentMethod: "PayPal",
|
||
date: "5 days ago",
|
||
status: "failed",
|
||
},
|
||
{
|
||
id: "20",
|
||
txnId: "TXN-100220",
|
||
amount: 520.0,
|
||
customerName: "Joshua Lewis",
|
||
customerInitials: "JL",
|
||
type: "Subscription",
|
||
paymentMethod: "Credit Card",
|
||
date: "5 days ago",
|
||
status: "completed",
|
||
},
|
||
{
|
||
id: "21",
|
||
txnId: "TXN-100221",
|
||
amount: 37.5,
|
||
customerName: "Mia Robinson",
|
||
customerInitials: "MR",
|
||
type: "Refund",
|
||
paymentMethod: "Debit Card",
|
||
date: "6 days ago",
|
||
status: "refunded",
|
||
},
|
||
{
|
||
id: "22",
|
||
txnId: "TXN-100222",
|
||
amount: 165.0,
|
||
customerName: "Ethan Walker",
|
||
customerInitials: "EW",
|
||
type: "Sale",
|
||
paymentMethod: "Credit Card",
|
||
date: "6 days ago",
|
||
status: "completed",
|
||
},
|
||
{
|
||
id: "23",
|
||
txnId: "TXN-100223",
|
||
amount: 99.99,
|
||
customerName: "Charlotte Hall",
|
||
customerInitials: "CH",
|
||
type: "Sale",
|
||
paymentMethod: "PayPal",
|
||
date: "7 days ago",
|
||
status: "pending",
|
||
},
|
||
{
|
||
id: "24",
|
||
txnId: "TXN-100224",
|
||
amount: 284.0,
|
||
customerName: "Alexander Young",
|
||
customerInitials: "AY",
|
||
type: "Sale",
|
||
paymentMethod: "Bank Transfer",
|
||
date: "7 days ago",
|
||
status: "completed",
|
||
},
|
||
{
|
||
id: "25",
|
||
txnId: "TXN-100225",
|
||
amount: 55.99,
|
||
customerName: "Amelia King",
|
||
customerInitials: "AK",
|
||
type: "Sale",
|
||
paymentMethod: "Debit Card",
|
||
date: "8 days ago",
|
||
status: "completed",
|
||
},
|
||
{
|
||
id: "26",
|
||
txnId: "TXN-100226",
|
||
amount: 399.0,
|
||
customerName: "Benjamin Wright",
|
||
customerInitials: "BW",
|
||
type: "Subscription",
|
||
paymentMethod: "Credit Card",
|
||
date: "8 days ago",
|
||
status: "completed",
|
||
},
|
||
{
|
||
id: "27",
|
||
txnId: "TXN-100227",
|
||
amount: 72.25,
|
||
customerName: "Harper Lopez",
|
||
customerInitials: "HL",
|
||
type: "Refund",
|
||
paymentMethod: "PayPal",
|
||
date: "9 days ago",
|
||
status: "refunded",
|
||
},
|
||
{
|
||
id: "28",
|
||
txnId: "TXN-100228",
|
||
amount: 198.0,
|
||
customerName: "Lucas Hill",
|
||
customerInitials: "LH",
|
||
type: "Sale",
|
||
paymentMethod: "Credit Card",
|
||
date: "10 days ago",
|
||
status: "failed",
|
||
},
|
||
{
|
||
id: "29",
|
||
txnId: "TXN-100229",
|
||
amount: 145.5,
|
||
customerName: "Evelyn Scott",
|
||
customerInitials: "ES",
|
||
type: "Sale",
|
||
paymentMethod: "Debit Card",
|
||
date: "10 days ago",
|
||
status: "completed",
|
||
},
|
||
{
|
||
id: "30",
|
||
txnId: "TXN-100230",
|
||
amount: 89.99,
|
||
customerName: "Henry Green",
|
||
customerInitials: "HG",
|
||
type: "Sale",
|
||
paymentMethod: "Bank Transfer",
|
||
date: "12 days ago",
|
||
status: "completed",
|
||
},
|
||
{
|
||
id: "31",
|
||
txnId: "TXN-100231",
|
||
amount: 349.0,
|
||
customerName: "Abigail Adams",
|
||
customerInitials: "AA",
|
||
type: "Subscription",
|
||
paymentMethod: "Credit Card",
|
||
date: "12 days ago",
|
||
status: "completed",
|
||
},
|
||
{
|
||
id: "32",
|
||
txnId: "TXN-100232",
|
||
amount: 27.99,
|
||
customerName: "Sebastian Baker",
|
||
customerInitials: "SB",
|
||
type: "Sale",
|
||
paymentMethod: "PayPal",
|
||
date: "14 days ago",
|
||
status: "pending",
|
||
},
|
||
{
|
||
id: "33",
|
||
txnId: "TXN-100233",
|
||
amount: 462.0,
|
||
customerName: "Ella Nelson",
|
||
customerInitials: "EN",
|
||
type: "Sale",
|
||
paymentMethod: "Credit Card",
|
||
date: "14 days ago",
|
||
status: "completed",
|
||
},
|
||
{
|
||
id: "34",
|
||
txnId: "TXN-100234",
|
||
amount: 58.5,
|
||
customerName: "Jack Carter",
|
||
customerInitials: "JC",
|
||
type: "Refund",
|
||
paymentMethod: "Debit Card",
|
||
date: "15 days ago",
|
||
status: "refunded",
|
||
},
|
||
{
|
||
id: "35",
|
||
txnId: "TXN-100235",
|
||
amount: 225.0,
|
||
customerName: "Scarlett Mitchell",
|
||
customerInitials: "SM",
|
||
type: "Sale",
|
||
paymentMethod: "Bank Transfer",
|
||
date: "16 days ago",
|
||
status: "completed",
|
||
},
|
||
{
|
||
id: "36",
|
||
txnId: "TXN-100236",
|
||
amount: 134.75,
|
||
customerName: "Owen Perez",
|
||
customerInitials: "OP",
|
||
type: "Sale",
|
||
paymentMethod: "Credit Card",
|
||
date: "16 days ago",
|
||
status: "completed",
|
||
},
|
||
{
|
||
id: "37",
|
||
txnId: "TXN-100237",
|
||
amount: 499.0,
|
||
customerName: "Grace Roberts",
|
||
customerInitials: "GR",
|
||
type: "Subscription",
|
||
paymentMethod: "PayPal",
|
||
date: "18 days ago",
|
||
status: "completed",
|
||
},
|
||
{
|
||
id: "38",
|
||
txnId: "TXN-100238",
|
||
amount: 82.0,
|
||
customerName: "Liam Turner",
|
||
customerInitials: "LT",
|
||
type: "Sale",
|
||
paymentMethod: "Debit Card",
|
||
date: "18 days ago",
|
||
status: "failed",
|
||
},
|
||
{
|
||
id: "39",
|
||
txnId: "TXN-100239",
|
||
amount: 178.5,
|
||
customerName: "Chloe Phillips",
|
||
customerInitials: "CP",
|
||
type: "Sale",
|
||
paymentMethod: "Credit Card",
|
||
date: "20 days ago",
|
||
status: "completed",
|
||
},
|
||
{
|
||
id: "40",
|
||
txnId: "TXN-100240",
|
||
amount: 93.99,
|
||
customerName: "Noah Campbell",
|
||
customerInitials: "NC",
|
||
type: "Refund",
|
||
paymentMethod: "Bank Transfer",
|
||
date: "20 days ago",
|
||
status: "refunded",
|
||
},
|
||
{
|
||
id: "41",
|
||
txnId: "TXN-100241",
|
||
amount: 267.0,
|
||
customerName: "Lily Parker",
|
||
customerInitials: "LP",
|
||
type: "Sale",
|
||
paymentMethod: "Credit Card",
|
||
date: "22 days ago",
|
||
status: "completed",
|
||
},
|
||
{
|
||
id: "42",
|
||
txnId: "TXN-100242",
|
||
amount: 149.0,
|
||
customerName: "Mason Evans",
|
||
customerInitials: "ME",
|
||
type: "Subscription",
|
||
paymentMethod: "PayPal",
|
||
date: "22 days ago",
|
||
status: "completed",
|
||
},
|
||
{
|
||
id: "43",
|
||
txnId: "TXN-100243",
|
||
amount: 41.25,
|
||
customerName: "Zoe Edwards",
|
||
customerInitials: "ZE",
|
||
type: "Sale",
|
||
paymentMethod: "Debit Card",
|
||
date: "25 days ago",
|
||
status: "pending",
|
||
},
|
||
{
|
||
id: "44",
|
||
txnId: "TXN-100244",
|
||
amount: 315.0,
|
||
customerName: "Aiden Collins",
|
||
customerInitials: "AC",
|
||
type: "Sale",
|
||
paymentMethod: "Credit Card",
|
||
date: "25 days ago",
|
||
status: "completed",
|
||
},
|
||
{
|
||
id: "45",
|
||
txnId: "TXN-100245",
|
||
amount: 68.99,
|
||
customerName: "Nora Stewart",
|
||
customerInitials: "NS",
|
||
type: "Sale",
|
||
paymentMethod: "Bank Transfer",
|
||
date: "28 days ago",
|
||
status: "completed",
|
||
},
|
||
{
|
||
id: "46",
|
||
txnId: "TXN-100246",
|
||
amount: 599.0,
|
||
customerName: "Logan Sanchez",
|
||
customerInitials: "LS",
|
||
type: "Subscription",
|
||
paymentMethod: "Credit Card",
|
||
date: "28 days ago",
|
||
status: "completed",
|
||
},
|
||
{
|
||
id: "47",
|
||
txnId: "TXN-100247",
|
||
amount: 112.5,
|
||
customerName: "Aria Morris",
|
||
customerInitials: "AM",
|
||
type: "Refund",
|
||
paymentMethod: "PayPal",
|
||
date: "30 days ago",
|
||
status: "refunded",
|
||
},
|
||
{
|
||
id: "48",
|
||
txnId: "TXN-100248",
|
||
amount: 246.0,
|
||
customerName: "Carter Rogers",
|
||
customerInitials: "CR",
|
||
type: "Sale",
|
||
paymentMethod: "Debit Card",
|
||
date: "30 days ago",
|
||
status: "failed",
|
||
},
|
||
{
|
||
id: "49",
|
||
txnId: "TXN-100249",
|
||
amount: 79.99,
|
||
customerName: "Penelope Reed",
|
||
customerInitials: "PR",
|
||
type: "Sale",
|
||
paymentMethod: "Credit Card",
|
||
date: "33 days ago",
|
||
status: "completed",
|
||
},
|
||
{
|
||
id: "50",
|
||
txnId: "TXN-100250",
|
||
amount: 189.0,
|
||
customerName: "Caleb Cook",
|
||
customerInitials: "CC",
|
||
type: "Sale",
|
||
paymentMethod: "Bank Transfer",
|
||
date: "36 days ago",
|
||
status: "completed",
|
||
},
|
||
];
|
||
|
||
const PAGE_SIZE_OPTIONS = [5, 10, 20];
|
||
|
||
const SidebarLogo = ({ logo }: { logo: SidebarData["logo"] }) => {
|
||
return (
|
||
<SidebarMenu>
|
||
<SidebarMenuItem>
|
||
<SidebarMenuButton size="lg" tooltip={logo.title} asChild>
|
||
<a
|
||
href={logo.href}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="min-w-0"
|
||
>
|
||
<div className="flex aspect-square size-8 items-center justify-center rounded-sm bg-primary">
|
||
<img
|
||
src={logo.src}
|
||
alt={logo.alt}
|
||
width={24}
|
||
height={24}
|
||
className="size-6 text-primary-foreground invert dark:invert-0"
|
||
/>
|
||
</div>
|
||
<div className="flex min-w-0 flex-col gap-0.5 leading-none">
|
||
<span className="truncate font-medium">{logo.title}</span>
|
||
<span className="truncate text-xs text-muted-foreground">
|
||
{logo.description}
|
||
</span>
|
||
</div>
|
||
</a>
|
||
</SidebarMenuButton>
|
||
</SidebarMenuItem>
|
||
</SidebarMenu>
|
||
);
|
||
};
|
||
|
||
const NavMenuItem = ({ item }: { item: NavItem }) => {
|
||
const Icon = item.icon;
|
||
const hasChildren = item.children && item.children.length > 0;
|
||
|
||
if (!hasChildren) {
|
||
return (
|
||
<SidebarMenuItem>
|
||
<SidebarMenuButton
|
||
asChild
|
||
isActive={item.isActive}
|
||
tooltip={item.label}
|
||
>
|
||
<a href={item.href}>
|
||
<Icon className="size-4" aria-hidden="true" />
|
||
<span>{item.label}</span>
|
||
</a>
|
||
</SidebarMenuButton>
|
||
</SidebarMenuItem>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<Collapsible asChild defaultOpen className="group/collapsible">
|
||
<SidebarMenuItem>
|
||
<CollapsibleTrigger asChild>
|
||
<SidebarMenuButton isActive={item.isActive} tooltip={item.label}>
|
||
<Icon className="size-4" aria-hidden="true" />
|
||
<span>{item.label}</span>
|
||
<ChevronRight
|
||
aria-hidden="true"
|
||
className="ms-auto size-4 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90 rtl:rotate-180"
|
||
/>
|
||
</SidebarMenuButton>
|
||
</CollapsibleTrigger>
|
||
<CollapsibleContent>
|
||
<SidebarMenuSub>
|
||
{item.children!.map((child) => (
|
||
<SidebarMenuSubItem key={child.label}>
|
||
<SidebarMenuSubButton asChild isActive={child.isActive}>
|
||
<a href={child.href}>{child.label}</a>
|
||
</SidebarMenuSubButton>
|
||
</SidebarMenuSubItem>
|
||
))}
|
||
</SidebarMenuSub>
|
||
</CollapsibleContent>
|
||
</SidebarMenuItem>
|
||
</Collapsible>
|
||
);
|
||
};
|
||
|
||
const NavUser = ({ user }: { user: UserData }) => {
|
||
return (
|
||
<SidebarMenu>
|
||
<SidebarMenuItem>
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<SidebarMenuButton
|
||
size="lg"
|
||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||
>
|
||
<Avatar className="size-8 rounded-lg">
|
||
<AvatarImage src={user.avatar} alt={user.name} />
|
||
<AvatarFallback className="rounded-lg">
|
||
{user.name
|
||
.split(" ")
|
||
.map((n) => n[0])
|
||
.join("")}
|
||
</AvatarFallback>
|
||
</Avatar>
|
||
<div className="grid flex-1 text-start text-sm leading-tight">
|
||
<span className="truncate font-medium">{user.name}</span>
|
||
<span className="truncate text-xs text-muted-foreground">
|
||
{user.email}
|
||
</span>
|
||
</div>
|
||
<ChevronsUpDown className="ms-auto size-4" aria-hidden="true" />
|
||
</SidebarMenuButton>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent
|
||
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
|
||
side="bottom"
|
||
align="end"
|
||
sideOffset={4}
|
||
>
|
||
<DropdownMenuLabel className="p-0 font-normal">
|
||
<div className="flex items-center gap-2 px-1 py-1.5 text-start text-sm">
|
||
<Avatar className="size-8 rounded-lg">
|
||
<AvatarImage src={user.avatar} alt={user.name} />
|
||
<AvatarFallback className="rounded-lg">
|
||
{user.name
|
||
.split(" ")
|
||
.map((n) => n[0])
|
||
.join("")}
|
||
</AvatarFallback>
|
||
</Avatar>
|
||
<div className="grid flex-1 text-start text-sm leading-tight">
|
||
<span className="truncate font-medium">{user.name}</span>
|
||
<span className="truncate text-xs text-muted-foreground">
|
||
{user.email}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</DropdownMenuLabel>
|
||
<DropdownMenuSeparator />
|
||
<DropdownMenuItem>
|
||
<User className="me-2 size-4" aria-hidden="true" />
|
||
حساب کاربری
|
||
</DropdownMenuItem>
|
||
<DropdownMenuSeparator />
|
||
<DropdownMenuItem>
|
||
<LogOut className="me-2 size-4" aria-hidden="true" />
|
||
خروج
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
</SidebarMenuItem>
|
||
</SidebarMenu>
|
||
);
|
||
};
|
||
|
||
const AppSidebar = ({ ...props }: React.ComponentProps<typeof Sidebar>) => {
|
||
return (
|
||
<Sidebar side="right" variant="inset" collapsible="icon" {...props}>
|
||
<SidebarHeader>
|
||
<div className="flex items-center gap-2 group-data-[collapsible=icon]:flex-col">
|
||
<SidebarLogo logo={sidebarData.logo} />
|
||
<SidebarTrigger className="ms-auto group-data-[collapsible=icon]:ms-0" />
|
||
</div>
|
||
</SidebarHeader>
|
||
<SidebarContent>
|
||
{sidebarData.navGroups.map((group) => (
|
||
<SidebarGroup key={group.title}>
|
||
<SidebarGroupLabel>{group.title}</SidebarGroupLabel>
|
||
<SidebarGroupContent>
|
||
<SidebarMenu>
|
||
{group.items.map((item) => (
|
||
<NavMenuItem key={item.label} item={item} />
|
||
))}
|
||
</SidebarMenu>
|
||
</SidebarGroupContent>
|
||
</SidebarGroup>
|
||
))}
|
||
</SidebarContent>
|
||
<SidebarFooter>
|
||
{sidebarData.user && <NavUser user={sidebarData.user} />}
|
||
</SidebarFooter>
|
||
<SidebarRail />
|
||
</Sidebar>
|
||
);
|
||
};
|
||
|
||
const DashboardHeader = () => {
|
||
return (
|
||
<header className="flex w-full items-center gap-3 border-b bg-background px-4 py-4 sm:px-6">
|
||
<LayoutDashboard className="size-5" aria-hidden="true" />
|
||
<h1 className="text-base font-medium">داشبورد</h1>
|
||
|
||
<div className="ms-auto flex items-center gap-2">
|
||
<div className="relative w-full max-w-[220px] sm:max-w-[260px]">
|
||
<Search
|
||
className="absolute top-1/2 start-3 size-4 -translate-y-1/2 text-muted-foreground"
|
||
aria-hidden="true"
|
||
/>
|
||
<Input
|
||
type="search"
|
||
name="header-search"
|
||
inputMode="search"
|
||
autoComplete="off"
|
||
aria-label="جستجو در داشبورد"
|
||
placeholder="جستجوی اعضای تیم، فضاهای کاری…"
|
||
className="h-9 w-full pe-14 ps-9 text-sm"
|
||
/>
|
||
<kbd className="pointer-events-none absolute top-1/2 end-2 hidden -translate-y-1/2 rounded-md border bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground sm:inline-flex">
|
||
{"\u2318"}
|
||
{"\u00a0"}K
|
||
</kbd>
|
||
</div>
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button
|
||
variant="outline"
|
||
size="icon"
|
||
className="relative size-9"
|
||
aria-label={`اعلانها، ${unreadNotificationCount.toLocaleString("fa-IR")} خواندهنشده`}
|
||
>
|
||
<Bell className="size-4" aria-hidden="true" />
|
||
<span className="absolute -top-1 -end-1 flex size-[18px] items-center justify-center rounded-full bg-red-600 text-[10px] leading-none font-semibold text-white ring-2 ring-background">
|
||
{unreadNotificationCount.toLocaleString("fa-IR")}
|
||
</span>
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end" className="w-80 sm:w-96">
|
||
<DropdownMenuLabel className="text-base font-medium">
|
||
اعلانها
|
||
</DropdownMenuLabel>
|
||
<DropdownMenuSeparator />
|
||
{headerNotificationItems.map((item) => (
|
||
<DropdownMenuItem
|
||
key={item.id}
|
||
className="cursor-default flex-col items-start gap-1 py-3"
|
||
onSelect={(event: Event) => event.preventDefault()}
|
||
>
|
||
<span className="font-medium text-foreground">
|
||
{item.title}
|
||
</span>
|
||
<span className="text-xs leading-snug font-normal text-muted-foreground">
|
||
{item.body}
|
||
</span>
|
||
<span className="text-[10px] text-muted-foreground/80">
|
||
{item.time}
|
||
</span>
|
||
</DropdownMenuItem>
|
||
))}
|
||
<DropdownMenuSeparator />
|
||
<DropdownMenuItem className="justify-center text-center font-medium">
|
||
علامتگذاری همه بهعنوان خواندهشده
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
</div>
|
||
</header>
|
||
);
|
||
};
|
||
|
||
const periodLabels: Record<PeriodKey, string> = {
|
||
"1year": "۱ سال",
|
||
"3months": "۳ ماه",
|
||
"30days": "۳۰ روز",
|
||
};
|
||
|
||
const PeriodTabs = ({
|
||
activePeriod,
|
||
onPeriodChange,
|
||
}: {
|
||
activePeriod: PeriodKey;
|
||
onPeriodChange: (period: PeriodKey) => void;
|
||
}) => {
|
||
return (
|
||
<div className="flex items-center gap-1 rounded-lg bg-muted p-1">
|
||
{(Object.keys(periodLabels) as PeriodKey[]).map((key) => (
|
||
<button
|
||
key={key}
|
||
onClick={() => onPeriodChange(key)}
|
||
className={cn(
|
||
"rounded-md px-3 py-1.5 text-xs font-medium transition-all sm:text-sm",
|
||
activePeriod === key
|
||
? "bg-card text-foreground shadow-sm"
|
||
: "text-muted-foreground hover:text-foreground",
|
||
)}
|
||
>
|
||
{periodLabels[key]}
|
||
</button>
|
||
))}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
function RevenueTooltip({
|
||
active,
|
||
payload,
|
||
label,
|
||
}: any) {
|
||
if (!active || !payload?.length) return null;
|
||
|
||
const value = payload[0]?.value || 0;
|
||
|
||
return (
|
||
<div className="rounded-lg border border-border bg-popover p-2 shadow-lg sm:p-3">
|
||
<p className="mb-1.5 text-xs font-medium text-foreground sm:mb-2 sm:text-sm">
|
||
{label}
|
||
</p>
|
||
<div className="flex items-center gap-1.5 sm:gap-2">
|
||
<div className="size-2 rounded-full bg-foreground sm:size-2.5" />
|
||
<span className="text-[10px] text-muted-foreground sm:text-sm">
|
||
{revenueChartConfig.revenue.label}:
|
||
</span>
|
||
<span className="text-[10px] font-medium text-foreground sm:text-sm">
|
||
{currencyFormatter.format(Number(value))}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const RevenueChart = ({ period }: { period: PeriodKey }) => {
|
||
const data = revenueData[period];
|
||
const summary = revenueSummary[period];
|
||
|
||
return (
|
||
<div className="flex min-w-0 flex-1 flex-col gap-4 rounded-xl border bg-card p-4 sm:gap-5 sm:p-5">
|
||
<div className="flex flex-col gap-1">
|
||
<p className="text-xs text-muted-foreground sm:text-sm">
|
||
نمای کلی درآمد
|
||
</p>
|
||
<div className="flex items-center gap-2">
|
||
<p className="text-xl leading-tight font-semibold tracking-tight sm:text-xl">
|
||
{currencyFormatter.format(summary.total)}
|
||
</p>
|
||
<div className="flex items-center gap-0.5">
|
||
{summary.isPositive ? (
|
||
<ArrowUpRight
|
||
className="size-3.5 text-emerald-600"
|
||
aria-hidden="true"
|
||
/>
|
||
) : (
|
||
<ArrowDownRight
|
||
className="size-3.5 text-red-600"
|
||
aria-hidden="true"
|
||
/>
|
||
)}
|
||
<span
|
||
className={cn(
|
||
"text-xs font-medium",
|
||
summary.isPositive ? "text-emerald-600" : "text-red-600",
|
||
)}
|
||
>
|
||
{summary.isPositive ? "+" : "-"}
|
||
{summary.change.toLocaleString("fa-IR")}٪
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="h-[180px] w-full min-w-0 sm:h-[220px]">
|
||
<ChartContainer config={revenueChartConfig} className="h-full w-full">
|
||
<AreaChart data={data}>
|
||
<defs>
|
||
<linearGradient id="revenueGradient" x1="0" y1="0" x2="0" y2="1">
|
||
<stop
|
||
offset="0%"
|
||
stopColor="var(--color-revenue)"
|
||
stopOpacity={0.15}
|
||
/>
|
||
<stop
|
||
offset="100%"
|
||
stopColor="var(--color-revenue)"
|
||
stopOpacity={0.02}
|
||
/>
|
||
</linearGradient>
|
||
</defs>
|
||
<CartesianGrid strokeDasharray="0" vertical={false} />
|
||
<XAxis
|
||
dataKey="month"
|
||
axisLine={false}
|
||
tickLine={false}
|
||
tick={{ fontSize: 10 }}
|
||
dy={8}
|
||
/>
|
||
<YAxis
|
||
axisLine={false}
|
||
tickLine={false}
|
||
tick={{ fontSize: 10 }}
|
||
dx={-5}
|
||
tickFormatter={(value) => compactCurrencyFormatter.format(value)}
|
||
width={40}
|
||
/>
|
||
<Tooltip
|
||
content={<RevenueTooltip />}
|
||
cursor={{ strokeOpacity: 0.2 }}
|
||
/>
|
||
<Area
|
||
type="monotone"
|
||
dataKey="revenue"
|
||
stroke="var(--color-revenue)"
|
||
strokeWidth={2}
|
||
fill="url(#revenueGradient)"
|
||
/>
|
||
</AreaChart>
|
||
</ChartContainer>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
function CostsTooltip({
|
||
active,
|
||
payload,
|
||
label,
|
||
colors,
|
||
}: any & {
|
||
colors: { primary: string; secondary: string };
|
||
}) {
|
||
if (!active || !payload?.length) return null;
|
||
|
||
const cogs = payload.find((p: any) => p.dataKey === "cogs")?.value || 0;
|
||
const operatingExpenses =
|
||
payload.find((p: any) => p.dataKey === "operatingExpenses")?.value || 0;
|
||
const total = Number(cogs) + Number(operatingExpenses);
|
||
|
||
return (
|
||
<div className="rounded-lg border border-border bg-popover p-2 shadow-lg sm:p-3">
|
||
<p className="mb-1.5 text-xs font-medium text-foreground sm:mb-2 sm:text-sm">
|
||
{label}
|
||
</p>
|
||
<div className="space-y-1 sm:space-y-1.5">
|
||
<div className="flex items-center gap-1.5 sm:gap-2">
|
||
<div
|
||
className="size-2 rounded-full sm:size-2.5"
|
||
style={{ backgroundColor: colors.primary }}
|
||
/>
|
||
<span className="text-[10px] text-muted-foreground sm:text-sm">
|
||
{costsChartConfig.cogs.label}:
|
||
</span>
|
||
<span className="text-[10px] font-medium text-foreground sm:text-sm">
|
||
{currencyFormatter.format(Number(cogs))}
|
||
</span>
|
||
</div>
|
||
<div className="flex items-center gap-1.5 sm:gap-2">
|
||
<div
|
||
className="size-2 rounded-full sm:size-2.5"
|
||
style={{ backgroundColor: colors.secondary }}
|
||
/>
|
||
<span className="text-[10px] text-muted-foreground sm:text-sm">
|
||
هزینه عملیاتی:
|
||
</span>
|
||
<span className="text-[10px] font-medium text-foreground sm:text-sm">
|
||
{currencyFormatter.format(Number(operatingExpenses))}
|
||
</span>
|
||
</div>
|
||
<div className="mt-1 border-t border-border pt-1">
|
||
<span className="text-[10px] font-medium text-foreground sm:text-xs">
|
||
جمع: {currencyFormatter.format(total)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const CostsChart = ({ period }: { period: PeriodKey }) => {
|
||
const data = costsData[period];
|
||
const summary = costsSummary[period];
|
||
|
||
return (
|
||
<div className="flex min-w-0 flex-1 flex-col gap-4 rounded-xl border bg-card p-4 sm:gap-5 sm:p-5">
|
||
<div className="flex flex-col gap-1">
|
||
<div className="flex items-center justify-between">
|
||
<p className="text-xs text-muted-foreground sm:text-sm">
|
||
تفکیک هزینهها
|
||
</p>
|
||
<div className="flex items-center gap-3">
|
||
<div className="flex items-center gap-1.5">
|
||
<div
|
||
className="size-2 rounded-full"
|
||
style={{ backgroundColor: "var(--color-cogs)" }}
|
||
/>
|
||
<span className="text-[10px] text-muted-foreground sm:text-xs">
|
||
{costsChartConfig.cogs.label}
|
||
</span>
|
||
</div>
|
||
<div className="flex items-center gap-1.5">
|
||
<div
|
||
className="size-2 rounded-full"
|
||
style={{ backgroundColor: "var(--color-operatingExpenses)" }}
|
||
/>
|
||
<span className="text-[10px] text-muted-foreground sm:text-xs">
|
||
{costsChartConfig.operatingExpenses.label}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<p className="text-xl leading-tight font-semibold tracking-tight sm:text-xl">
|
||
{currencyFormatter.format(summary.total)}
|
||
</p>
|
||
<div className="flex items-center gap-0.5">
|
||
{summary.isPositive ? (
|
||
<ArrowUpRight
|
||
className="size-3.5 text-emerald-600"
|
||
aria-hidden="true"
|
||
/>
|
||
) : (
|
||
<ArrowDownRight
|
||
className="size-3.5 text-red-600"
|
||
aria-hidden="true"
|
||
/>
|
||
)}
|
||
<span
|
||
className={cn(
|
||
"text-xs font-medium",
|
||
summary.isPositive ? "text-emerald-600" : "text-red-600",
|
||
)}
|
||
>
|
||
{summary.isPositive ? "+" : "-"}
|
||
{summary.change.toLocaleString("fa-IR")}٪
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="h-[180px] w-full min-w-0 sm:h-[220px]">
|
||
<ChartContainer config={costsChartConfig} className="h-full w-full">
|
||
<BarChart data={data}>
|
||
<CartesianGrid strokeDasharray="0" vertical={false} />
|
||
<XAxis
|
||
dataKey="month"
|
||
axisLine={false}
|
||
tickLine={false}
|
||
tick={{ fontSize: 10 }}
|
||
dy={8}
|
||
/>
|
||
<YAxis
|
||
axisLine={false}
|
||
tickLine={false}
|
||
tick={{ fontSize: 10 }}
|
||
dx={-5}
|
||
tickFormatter={(value) => compactCurrencyFormatter.format(value)}
|
||
width={40}
|
||
/>
|
||
<Tooltip
|
||
content={
|
||
<CostsTooltip
|
||
colors={{
|
||
primary: "var(--color-cogs)",
|
||
secondary: "var(--color-operatingExpenses)",
|
||
}}
|
||
/>
|
||
}
|
||
cursor={{ fillOpacity: 0.05 }}
|
||
/>
|
||
<Bar
|
||
dataKey="cogs"
|
||
stackId="costs"
|
||
fill="var(--color-cogs)"
|
||
radius={[0, 0, 0, 0]}
|
||
/>
|
||
<Bar
|
||
dataKey="operatingExpenses"
|
||
stackId="costs"
|
||
fill="var(--color-operatingExpenses)"
|
||
radius={[3, 3, 0, 0]}
|
||
/>
|
||
</BarChart>
|
||
</ChartContainer>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const StatsCards = () => {
|
||
return (
|
||
<div className="grid grid-cols-2 gap-3 rounded-xl border bg-card p-4 sm:gap-4 sm:p-5 lg:grid-cols-4 lg:gap-6 lg:p-6">
|
||
{statsData.map((stat, index) => {
|
||
const formatter =
|
||
stat.format === "currency" ? currencyFormatter : percentFormatter;
|
||
|
||
return (
|
||
<div key={stat.title} className="flex items-start">
|
||
<div className="flex-1 space-y-1 sm:space-y-2 lg:space-y-3">
|
||
<div className="flex items-center gap-1.5 text-muted-foreground sm:gap-2">
|
||
<stat.icon className="size-3.5 sm:size-4" aria-hidden="true" />
|
||
<span className="truncate text-[10px] font-medium sm:text-xs lg:text-sm">
|
||
{stat.title}
|
||
</span>
|
||
</div>
|
||
<p className="hidden text-[10px] text-muted-foreground/70 sm:block sm:text-xs">
|
||
{formatter.format(stat.previousValue)} ماه قبل
|
||
</p>
|
||
<p className="text-xl leading-tight font-semibold tracking-tight sm:text-xl lg:text-[28px]">
|
||
{formatter.format(stat.value)}
|
||
</p>
|
||
<div className="flex flex-wrap items-center gap-x-1 gap-y-0.5 text-[10px] sm:text-xs">
|
||
{stat.isPositive ? (
|
||
<ArrowUpRight
|
||
className="size-3 shrink-0 text-emerald-600 sm:size-3.5"
|
||
aria-hidden="true"
|
||
/>
|
||
) : (
|
||
<ArrowDownRight
|
||
className="size-3 shrink-0 text-red-600 sm:size-3.5"
|
||
aria-hidden="true"
|
||
/>
|
||
)}
|
||
<span
|
||
className={cn(
|
||
"whitespace-nowrap",
|
||
stat.isPositive ? "text-emerald-600" : "text-red-600",
|
||
)}
|
||
>
|
||
{stat.isPositive ? "+" : "-"}
|
||
{stat.changePercent.toLocaleString("fa-IR", {
|
||
minimumFractionDigits: 1,
|
||
maximumFractionDigits: 1,
|
||
})}
|
||
٪
|
||
</span>
|
||
<span className="whitespace-nowrap text-muted-foreground">
|
||
نسبت به ماه قبل
|
||
</span>
|
||
</div>
|
||
</div>
|
||
{index < statsData.length - 1 && (
|
||
<div className="mx-4 hidden h-full w-px bg-border lg:block xl:mx-6" />
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const transactionStatusStyles: Record<TransactionStatus, string> = {
|
||
completed:
|
||
"bg-emerald-50 text-emerald-700 ring-1 ring-inset ring-emerald-600/20 dark:bg-emerald-900/30 dark:text-emerald-400 dark:ring-emerald-400/20",
|
||
pending:
|
||
"bg-amber-50 text-amber-700 ring-1 ring-inset ring-amber-600/20 dark:bg-amber-900/30 dark:text-amber-400 dark:ring-amber-400/20",
|
||
failed:
|
||
"bg-red-50 text-red-700 ring-1 ring-inset ring-red-600/20 dark:bg-red-900/30 dark:text-red-400 dark:ring-red-400/20",
|
||
refunded:
|
||
"bg-blue-50 text-blue-700 ring-1 ring-inset ring-blue-700/10 dark:bg-blue-900/30 dark:text-blue-400 dark:ring-blue-400/20",
|
||
};
|
||
|
||
const transactionStatusIcons: Record<
|
||
TransactionStatus,
|
||
React.ComponentType<React.SVGProps<SVGSVGElement>>
|
||
> = {
|
||
completed: CheckCircle2,
|
||
pending: Clock,
|
||
failed: XCircle,
|
||
refunded: RotateCcw,
|
||
};
|
||
|
||
const transactionStatusLabels: Record<TransactionStatus, string> = {
|
||
completed: "تکمیلشده",
|
||
pending: "در انتظار",
|
||
failed: "ناموفق",
|
||
refunded: "بازپرداختشده",
|
||
};
|
||
|
||
const allTransactionStatuses: TransactionStatus[] = [
|
||
"completed",
|
||
"pending",
|
||
"failed",
|
||
"refunded",
|
||
];
|
||
|
||
const allPaymentMethods = [
|
||
"Credit Card",
|
||
"Debit Card",
|
||
"PayPal",
|
||
"Bank Transfer",
|
||
] as const;
|
||
|
||
const dateFilterOptions = [
|
||
{ label: "همه", value: "all" },
|
||
{ label: "امروز", value: "today" },
|
||
{ label: "دیروز", value: "yesterday" },
|
||
{ label: "۷ روز اخیر", value: "7days" },
|
||
{ label: "۳۰ روز اخیر", value: "30days" },
|
||
];
|
||
|
||
const filterChipClassName =
|
||
"inline-flex h-5 cursor-pointer items-center gap-1 rounded-md bg-gray-50 px-2 text-[10px] font-medium text-gray-600 ring-1 ring-inset ring-gray-500/10 sm:h-6 sm:text-xs dark:bg-gray-800/50 dark:text-gray-400 dark:ring-gray-400/20";
|
||
|
||
const TransactionsTable = () => {
|
||
const [searchQuery, setSearchQuery] = React.useState("");
|
||
const [statusFilter, setStatusFilter] = React.useState<string>("all");
|
||
const [dateFilter, setDateFilter] = React.useState<string>("all");
|
||
const [paymentMethodFilter, setPaymentMethodFilter] =
|
||
React.useState<string>("all");
|
||
const [currentPage, setCurrentPage] = React.useState(1);
|
||
const [pageSize, setPageSize] = React.useState(10);
|
||
const [isHydrated, setIsHydrated] = React.useState(false);
|
||
|
||
const hasActiveFilters =
|
||
statusFilter !== "all" ||
|
||
dateFilter !== "all" ||
|
||
paymentMethodFilter !== "all";
|
||
|
||
const clearFilters = () => {
|
||
setStatusFilter("all");
|
||
setDateFilter("all");
|
||
setPaymentMethodFilter("all");
|
||
};
|
||
|
||
React.useEffect(() => {
|
||
if (typeof window === "undefined") return;
|
||
const params = new URLSearchParams(window.location.search);
|
||
setSearchQuery(params.get("q") ?? "");
|
||
|
||
const nextStatus = params.get("status");
|
||
if (
|
||
nextStatus &&
|
||
(nextStatus === "all" ||
|
||
allTransactionStatuses.includes(nextStatus as TransactionStatus))
|
||
) {
|
||
setStatusFilter(nextStatus);
|
||
}
|
||
|
||
const nextDate = params.get("date");
|
||
if (
|
||
nextDate &&
|
||
dateFilterOptions.some((option) => option.value === nextDate)
|
||
) {
|
||
setDateFilter(nextDate);
|
||
}
|
||
|
||
const nextPayment = params.get("payment");
|
||
if (
|
||
nextPayment &&
|
||
(nextPayment === "all" ||
|
||
allPaymentMethods.includes(
|
||
nextPayment as (typeof allPaymentMethods)[number],
|
||
))
|
||
) {
|
||
setPaymentMethodFilter(nextPayment);
|
||
}
|
||
|
||
const nextPage = Number(params.get("page"));
|
||
if (!Number.isNaN(nextPage) && nextPage > 0) {
|
||
setCurrentPage(nextPage);
|
||
}
|
||
|
||
const nextPageSize = Number(params.get("pageSize"));
|
||
if (
|
||
!Number.isNaN(nextPageSize) &&
|
||
PAGE_SIZE_OPTIONS.includes(nextPageSize)
|
||
) {
|
||
setPageSize(nextPageSize);
|
||
}
|
||
|
||
setIsHydrated(true);
|
||
}, []);
|
||
|
||
const filteredTransactions = React.useMemo(() => {
|
||
const query = searchQuery.toLowerCase();
|
||
return transactionRecords.filter((txn) => {
|
||
const matchesSearch =
|
||
txn.txnId.toLowerCase().includes(query) ||
|
||
txn.customerName.toLowerCase().includes(query);
|
||
|
||
const matchesStatus =
|
||
statusFilter === "all" || txn.status === statusFilter;
|
||
|
||
const matchesPaymentMethod =
|
||
paymentMethodFilter === "all" ||
|
||
txn.paymentMethod === paymentMethodFilter;
|
||
|
||
let matchesDate = true;
|
||
const pd = txn.date.toLowerCase();
|
||
switch (dateFilter) {
|
||
case "today":
|
||
matchesDate = pd === "today";
|
||
break;
|
||
case "yesterday":
|
||
matchesDate = pd === "1 day ago";
|
||
break;
|
||
case "7days":
|
||
matchesDate =
|
||
pd === "today" ||
|
||
pd.includes("day ago") ||
|
||
(pd.includes("days ago") && parseInt(pd) <= 7);
|
||
break;
|
||
case "30days":
|
||
matchesDate =
|
||
pd === "today" ||
|
||
pd.includes("day ago") ||
|
||
(pd.includes("days ago") && parseInt(pd) <= 30);
|
||
break;
|
||
}
|
||
|
||
return (
|
||
matchesSearch && matchesStatus && matchesPaymentMethod && matchesDate
|
||
);
|
||
});
|
||
}, [searchQuery, statusFilter, dateFilter, paymentMethodFilter]);
|
||
|
||
const totalPages = Math.ceil(filteredTransactions.length / pageSize);
|
||
|
||
const paginatedTransactions = React.useMemo(() => {
|
||
const startIndex = (currentPage - 1) * pageSize;
|
||
const endIndex = startIndex + pageSize;
|
||
return filteredTransactions.slice(startIndex, endIndex);
|
||
}, [filteredTransactions, currentPage, pageSize]);
|
||
|
||
React.useEffect(() => {
|
||
setCurrentPage(1);
|
||
}, [searchQuery, statusFilter, dateFilter, paymentMethodFilter, pageSize]);
|
||
|
||
React.useEffect(() => {
|
||
if (!isHydrated || typeof window === "undefined") return;
|
||
const params = new URLSearchParams(window.location.search);
|
||
|
||
if (searchQuery) {
|
||
params.set("q", searchQuery);
|
||
} else {
|
||
params.delete("q");
|
||
}
|
||
|
||
if (statusFilter !== "all") {
|
||
params.set("status", statusFilter);
|
||
} else {
|
||
params.delete("status");
|
||
}
|
||
|
||
if (dateFilter !== "all") {
|
||
params.set("date", dateFilter);
|
||
} else {
|
||
params.delete("date");
|
||
}
|
||
|
||
if (paymentMethodFilter !== "all") {
|
||
params.set("payment", paymentMethodFilter);
|
||
} else {
|
||
params.delete("payment");
|
||
}
|
||
|
||
if (currentPage > 1) {
|
||
params.set("page", String(currentPage));
|
||
} else {
|
||
params.delete("page");
|
||
}
|
||
|
||
if (pageSize !== PAGE_SIZE_OPTIONS[0]) {
|
||
params.set("pageSize", String(pageSize));
|
||
} else {
|
||
params.delete("pageSize");
|
||
}
|
||
|
||
const nextQuery = params.toString();
|
||
const nextUrl = nextQuery
|
||
? `${window.location.pathname}?${nextQuery}`
|
||
: window.location.pathname;
|
||
window.history.replaceState(null, "", nextUrl);
|
||
}, [
|
||
searchQuery,
|
||
statusFilter,
|
||
dateFilter,
|
||
paymentMethodFilter,
|
||
currentPage,
|
||
pageSize,
|
||
isHydrated,
|
||
]);
|
||
|
||
const goToPage = (page: number) => {
|
||
setCurrentPage(Math.max(1, Math.min(page, totalPages)));
|
||
};
|
||
|
||
return (
|
||
<div className="rounded-xl border bg-card">
|
||
<div className="flex flex-col gap-3 p-3 sm:flex-row sm:items-center sm:gap-4 sm:px-6 sm:py-3.5">
|
||
<div className="flex flex-1 items-center gap-2 sm:gap-2.5">
|
||
<Button
|
||
variant="outline"
|
||
size="icon"
|
||
className="size-7 shrink-0 sm:size-8"
|
||
aria-label="تراکنشها"
|
||
>
|
||
<ShoppingCart className="size-4 text-muted-foreground sm:size-[18px]" />
|
||
</Button>
|
||
<span className="text-sm font-medium sm:text-base">تراکنشها</span>
|
||
<span className="ms-1 inline-flex items-center rounded-md bg-gray-50 px-2 py-1 text-[10px] font-medium text-gray-600 ring-1 ring-gray-500/10 ring-inset sm:text-xs dark:bg-gray-800/50 dark:text-gray-400 dark:ring-gray-400/20">
|
||
{filteredTransactions.length.toLocaleString("fa-IR")}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<div className="relative flex-1 sm:flex-none">
|
||
<Search
|
||
className="absolute top-1/2 start-3 size-4 -translate-y-1/2 text-muted-foreground sm:size-5"
|
||
aria-hidden="true"
|
||
/>
|
||
<Input
|
||
type="search"
|
||
name="transactions-search"
|
||
inputMode="search"
|
||
autoComplete="off"
|
||
aria-label="جستجوی تراکنشها"
|
||
placeholder="جستجوی تراکنش… (مثلاً تراکنش-100201)"
|
||
value={searchQuery}
|
||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||
setSearchQuery(e.target.value)
|
||
}
|
||
className="h-8 w-full ps-9 text-sm sm:h-9 sm:w-[160px] sm:ps-10 lg:w-[200px]"
|
||
/>
|
||
</div>
|
||
|
||
<Select value={dateFilter} onValueChange={setDateFilter}>
|
||
<SelectTrigger
|
||
className="h-8 w-[120px] text-xs sm:h-9 sm:w-[140px] sm:text-sm"
|
||
aria-label="فیلتر بر اساس تاریخ"
|
||
>
|
||
<SelectValue placeholder="تاریخ" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{dateFilterOptions.map((option) => (
|
||
<SelectItem key={option.value} value={option.value}>
|
||
{option.label}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className={cn(
|
||
"h-8 gap-1.5 sm:h-9 sm:gap-2",
|
||
statusFilter !== "all" && "border-primary",
|
||
)}
|
||
aria-label="فیلتر بر اساس وضعیت"
|
||
>
|
||
<Filter className="size-3.5 sm:size-4" aria-hidden="true" />
|
||
<span className="hidden sm:inline">وضعیت</span>
|
||
{statusFilter !== "all" && (
|
||
<span className="size-1.5 rounded-full bg-primary sm:size-2" />
|
||
)}
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end" className="w-[180px]">
|
||
<DropdownMenuLabel>فیلتر بر اساس وضعیت</DropdownMenuLabel>
|
||
<DropdownMenuCheckboxItem
|
||
checked={statusFilter === "all"}
|
||
onCheckedChange={() => setStatusFilter("all")}
|
||
>
|
||
همه وضعیتها
|
||
</DropdownMenuCheckboxItem>
|
||
{allTransactionStatuses.map((status) => (
|
||
<DropdownMenuCheckboxItem
|
||
key={status}
|
||
checked={statusFilter === status}
|
||
onCheckedChange={() => setStatusFilter(status)}
|
||
>
|
||
{transactionStatusLabels[status]}
|
||
</DropdownMenuCheckboxItem>
|
||
))}
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className={cn(
|
||
"h-8 gap-1.5 sm:h-9 sm:gap-2",
|
||
paymentMethodFilter !== "all" && "border-primary",
|
||
)}
|
||
aria-label="فیلتر بر اساس روش پرداخت"
|
||
>
|
||
<Wallet className="size-3.5 sm:size-4" aria-hidden="true" />
|
||
<span className="hidden sm:inline">روش</span>
|
||
{paymentMethodFilter !== "all" && (
|
||
<span className="size-1.5 rounded-full bg-primary sm:size-2" />
|
||
)}
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end" className="w-[200px]">
|
||
<DropdownMenuLabel>روش پرداخت</DropdownMenuLabel>
|
||
<DropdownMenuCheckboxItem
|
||
checked={paymentMethodFilter === "all"}
|
||
onCheckedChange={() => setPaymentMethodFilter("all")}
|
||
>
|
||
همه روشها
|
||
</DropdownMenuCheckboxItem>
|
||
{allPaymentMethods.map((method) => (
|
||
<DropdownMenuCheckboxItem
|
||
key={method}
|
||
checked={paymentMethodFilter === method}
|
||
onCheckedChange={() => setPaymentMethodFilter(method)}
|
||
>
|
||
{paymentMethodLabels[method]}
|
||
</DropdownMenuCheckboxItem>
|
||
))}
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
</div>
|
||
</div>
|
||
|
||
{hasActiveFilters && (
|
||
<div className="flex flex-wrap items-center gap-2 px-3 pb-3 sm:px-6">
|
||
<span className="text-[10px] text-muted-foreground sm:text-xs">
|
||
فیلترها:
|
||
</span>
|
||
{statusFilter !== "all" && (
|
||
<button
|
||
type="button"
|
||
className={filterChipClassName}
|
||
onClick={() => setStatusFilter("all")}
|
||
aria-label={`حذف فیلتر ${transactionStatusLabels[statusFilter as TransactionStatus]}`}
|
||
>
|
||
{transactionStatusLabels[statusFilter as TransactionStatus]}
|
||
<X className="size-2.5 sm:size-3" aria-hidden="true" />
|
||
</button>
|
||
)}
|
||
{dateFilter !== "all" && (
|
||
<button
|
||
type="button"
|
||
className={filterChipClassName}
|
||
onClick={() => setDateFilter("all")}
|
||
aria-label={`حذف فیلتر ${dateFilterOptions.find((o) => o.value === dateFilter)?.label}`}
|
||
>
|
||
{dateFilterOptions.find((o) => o.value === dateFilter)?.label}
|
||
<X className="size-2.5 sm:size-3" aria-hidden="true" />
|
||
</button>
|
||
)}
|
||
{paymentMethodFilter !== "all" && (
|
||
<button
|
||
type="button"
|
||
className={filterChipClassName}
|
||
onClick={() => setPaymentMethodFilter("all")}
|
||
aria-label={`حذف فیلتر ${paymentMethodLabels[paymentMethodFilter as Transaction["paymentMethod"]]}`}
|
||
>
|
||
{paymentMethodLabels[paymentMethodFilter as Transaction["paymentMethod"]]}
|
||
<X className="size-2.5 sm:size-3" aria-hidden="true" />
|
||
</button>
|
||
)}
|
||
<button
|
||
onClick={clearFilters}
|
||
className="text-[10px] text-destructive hover:underline sm:text-xs"
|
||
>
|
||
پاک کردن همه
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
<div className="overflow-x-auto px-3 pb-3 sm:px-6 sm:pb-4">
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow className="bg-muted/50 hover:bg-muted/50">
|
||
<TableHead className="min-w-[100px] text-xs font-medium text-muted-foreground sm:text-sm">
|
||
شناسه تراکنش
|
||
</TableHead>
|
||
<TableHead className="hidden min-w-[140px] text-xs font-medium text-muted-foreground sm:text-sm md:table-cell">
|
||
مشتری
|
||
</TableHead>
|
||
<TableHead className="min-w-[100px] text-xs font-medium text-muted-foreground sm:text-sm">
|
||
مبلغ
|
||
</TableHead>
|
||
<TableHead className="hidden min-w-[100px] text-xs font-medium text-muted-foreground sm:text-sm lg:table-cell">
|
||
نوع
|
||
</TableHead>
|
||
<TableHead className="hidden min-w-[120px] text-xs font-medium text-muted-foreground sm:text-sm md:table-cell">
|
||
روش پرداخت
|
||
</TableHead>
|
||
<TableHead className="hidden text-xs font-medium text-muted-foreground sm:table-cell sm:text-sm">
|
||
تاریخ
|
||
</TableHead>
|
||
<TableHead className="min-w-[100px] text-xs font-medium text-muted-foreground sm:text-sm">
|
||
وضعیت
|
||
</TableHead>
|
||
<TableHead className="w-[40px]"></TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{paginatedTransactions.length === 0 ? (
|
||
<TableRow>
|
||
<TableCell
|
||
colSpan={8}
|
||
className="h-24 text-center text-sm text-muted-foreground"
|
||
>
|
||
تراکنشی با این فیلترها یافت نشد.
|
||
</TableCell>
|
||
</TableRow>
|
||
) : (
|
||
paginatedTransactions.map((txn) => {
|
||
const StatusIcon = transactionStatusIcons[txn.status];
|
||
return (
|
||
<TableRow key={txn.id}>
|
||
<TableCell className="text-xs font-medium sm:text-sm">
|
||
{txn.txnId}
|
||
</TableCell>
|
||
<TableCell className="hidden md:table-cell">
|
||
<div className="flex items-center gap-2">
|
||
<Avatar className="size-6 bg-muted">
|
||
<AvatarFallback className="text-[8px] font-semibold text-muted-foreground uppercase">
|
||
{txn.customerInitials}
|
||
</AvatarFallback>
|
||
</Avatar>
|
||
<span className="text-xs text-muted-foreground sm:text-sm">
|
||
{txn.customerName}
|
||
</span>
|
||
</div>
|
||
</TableCell>
|
||
<TableCell className="text-xs text-foreground tabular-nums sm:text-sm">
|
||
{currencyFormatter.format(txn.amount)}
|
||
</TableCell>
|
||
<TableCell className="hidden lg:table-cell">
|
||
<span className="inline-flex items-center rounded-md border px-2 py-0.5 text-[10px] font-normal text-muted-foreground sm:text-xs">
|
||
{transactionTypeLabels[txn.type]}
|
||
</span>
|
||
</TableCell>
|
||
<TableCell className="hidden md:table-cell">
|
||
<span className="inline-flex items-center rounded-md border px-2 py-0.5 text-[10px] font-normal text-muted-foreground sm:text-xs">
|
||
{paymentMethodLabels[txn.paymentMethod]}
|
||
</span>
|
||
</TableCell>
|
||
<TableCell className="hidden text-xs text-muted-foreground sm:table-cell sm:text-sm">
|
||
{formatTransactionDate(txn.date)}
|
||
</TableCell>
|
||
<TableCell>
|
||
<span
|
||
className={cn(
|
||
"inline-flex items-center gap-1 rounded-md px-2 py-1 text-[10px] font-medium sm:text-xs",
|
||
transactionStatusStyles[txn.status],
|
||
)}
|
||
>
|
||
<StatusIcon className="size-3" aria-hidden="true" />
|
||
{transactionStatusLabels[txn.status]}
|
||
</span>
|
||
</TableCell>
|
||
<TableCell>
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="size-7 text-muted-foreground hover:text-foreground sm:size-8"
|
||
aria-label={`باز کردن عملیات ${txn.txnId}`}
|
||
>
|
||
<MoreHorizontal className="size-3.5 sm:size-4" />
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end">
|
||
<DropdownMenuItem>
|
||
<Eye className="me-2 size-4" aria-hidden="true" />
|
||
مشاهده جزئیات
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem>
|
||
<Pencil
|
||
className="me-2 size-4"
|
||
aria-hidden="true"
|
||
/>
|
||
ویرایش تراکنش
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem>
|
||
<Copy className="me-2 size-4" aria-hidden="true" />
|
||
تکثیر
|
||
</DropdownMenuItem>
|
||
<DropdownMenuSeparator />
|
||
<DropdownMenuItem
|
||
className="text-destructive"
|
||
onSelect={(event: Event) => {
|
||
event.preventDefault();
|
||
if (typeof window === "undefined") return;
|
||
window.confirm(
|
||
"این تراکنش باطل شود؟ این عمل قابل بازگشت نیست.",
|
||
);
|
||
}}
|
||
>
|
||
<X className="me-2 size-4" aria-hidden="true" />
|
||
باطل کردن تراکنش
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
</TableCell>
|
||
</TableRow>
|
||
);
|
||
})
|
||
)}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
|
||
<div className="flex flex-col items-center justify-between gap-3 border-t px-3 py-3 sm:flex-row sm:px-6">
|
||
<div className="flex items-center gap-2 text-xs text-muted-foreground sm:text-sm">
|
||
<span className="hidden sm:inline">ردیف در هر صفحه:</span>
|
||
<Select
|
||
value={pageSize.toString()}
|
||
onValueChange={(value: string) => setPageSize(Number(value))}
|
||
>
|
||
<SelectTrigger className="h-8 w-[70px]">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{PAGE_SIZE_OPTIONS.map((size) => (
|
||
<SelectItem key={size} value={size.toString()}>
|
||
{size.toLocaleString("fa-IR")}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<span className="text-muted-foreground">
|
||
{filteredTransactions.length === 0
|
||
? "۰"
|
||
: `${((currentPage - 1) * pageSize + 1).toLocaleString("fa-IR")}-${Math.min(
|
||
currentPage * pageSize,
|
||
filteredTransactions.length,
|
||
).toLocaleString("fa-IR")}`}{" "}
|
||
از {filteredTransactions.length.toLocaleString("fa-IR")}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-1">
|
||
<Button
|
||
variant="outline"
|
||
size="icon"
|
||
className="size-8"
|
||
onClick={() => goToPage(1)}
|
||
disabled={currentPage === 1}
|
||
aria-label="رفتن به صفحه اول"
|
||
>
|
||
<ChevronsLeft className="size-4 rtl:rotate-180" />
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="icon"
|
||
className="size-8"
|
||
onClick={() => goToPage(currentPage - 1)}
|
||
disabled={currentPage === 1}
|
||
aria-label="رفتن به صفحه قبل"
|
||
>
|
||
<ChevronLeft className="size-4 rtl:rotate-180" />
|
||
</Button>
|
||
|
||
<div className="flex items-center gap-1 px-2">
|
||
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
|
||
let pageNum: number;
|
||
if (totalPages <= 5) {
|
||
pageNum = i + 1;
|
||
} else if (currentPage <= 3) {
|
||
pageNum = i + 1;
|
||
} else if (currentPage >= totalPages - 2) {
|
||
pageNum = totalPages - 4 + i;
|
||
} else {
|
||
pageNum = currentPage - 2 + i;
|
||
}
|
||
|
||
return (
|
||
<Button
|
||
key={pageNum}
|
||
variant={currentPage === pageNum ? "default" : "ghost"}
|
||
size="icon"
|
||
className="size-8"
|
||
onClick={() => goToPage(pageNum)}
|
||
>
|
||
{pageNum.toLocaleString("fa-IR")}
|
||
</Button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
<Button
|
||
variant="outline"
|
||
size="icon"
|
||
className="size-8"
|
||
onClick={() => goToPage(currentPage + 1)}
|
||
disabled={currentPage === totalPages || totalPages === 0}
|
||
aria-label="رفتن به صفحه بعد"
|
||
>
|
||
<ChevronRight className="size-4 rtl:rotate-180" />
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="icon"
|
||
className="size-8"
|
||
onClick={() => goToPage(totalPages)}
|
||
disabled={currentPage === totalPages || totalPages === 0}
|
||
aria-label="رفتن به صفحه آخر"
|
||
>
|
||
<ChevronsRight className="size-4 rtl:rotate-180" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const DashboardContent = () => {
|
||
const [period, setPeriod] = React.useState<PeriodKey>("1year");
|
||
const [newTxnOpen, setNewTxnOpen] = React.useState(false);
|
||
const [txnNote, setTxnNote] = React.useState("");
|
||
|
||
React.useEffect(() => {
|
||
if (typeof window === "undefined") return;
|
||
const params = new URLSearchParams(window.location.search);
|
||
const nextPeriod = params.get("period");
|
||
if (
|
||
nextPeriod === "1year" ||
|
||
nextPeriod === "3months" ||
|
||
nextPeriod === "30days"
|
||
) {
|
||
setPeriod(nextPeriod);
|
||
}
|
||
}, []);
|
||
|
||
React.useEffect(() => {
|
||
if (typeof window === "undefined") return;
|
||
const params = new URLSearchParams(window.location.search);
|
||
if (period !== "1year") {
|
||
params.set("period", period);
|
||
} else {
|
||
params.delete("period");
|
||
}
|
||
const nextQuery = params.toString();
|
||
const nextUrl = nextQuery
|
||
? `${window.location.pathname}?${nextQuery}`
|
||
: window.location.pathname;
|
||
window.history.replaceState(null, "", nextUrl);
|
||
}, [period]);
|
||
|
||
return (
|
||
<div
|
||
id="dashboard-main"
|
||
tabIndex={-1}
|
||
className="w-full flex-1 space-y-4 overflow-auto bg-background p-3 sm:space-y-6 sm:p-4 md:p-6"
|
||
>
|
||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||
<PeriodTabs activePeriod={period} onPeriodChange={setPeriod} />
|
||
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="h-8 gap-2 sm:h-9"
|
||
aria-label="خروجی"
|
||
>
|
||
<Download className="size-4" aria-hidden="true" />
|
||
<span className="hidden sm:inline">خروجی</span>
|
||
</Button>
|
||
<Dialog open={newTxnOpen} onOpenChange={setNewTxnOpen}>
|
||
<DialogTrigger asChild>
|
||
<Button
|
||
size="sm"
|
||
className="h-8 gap-2 sm:h-9"
|
||
aria-label="تراکنش جدید"
|
||
>
|
||
<Plus className="size-4" aria-hidden="true" />
|
||
<span className="hidden sm:inline">تراکنش جدید</span>
|
||
</Button>
|
||
</DialogTrigger>
|
||
<DialogContent className="sm:max-w-md">
|
||
<DialogHeader>
|
||
<DialogTitle>ثبت تراکنش</DialogTitle>
|
||
<DialogDescription>
|
||
یادداشت سریعی برای چکلیست بستن حسابها ثبت کنید. این
|
||
نمایش آزمایشی داده را ذخیره نمیکند.
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<div className="grid gap-4 py-2">
|
||
<div className="grid gap-2">
|
||
<Label htmlFor="dashboard2-txn-note">توضیحات</Label>
|
||
<Input
|
||
id="dashboard2-txn-note"
|
||
name="txn-note"
|
||
value={txnNote}
|
||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||
setTxnNote(e.target.value)
|
||
}
|
||
placeholder="مثلاً حواله پرداخت مالیات فصل اول"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<DialogFooter>
|
||
<DialogClose asChild>
|
||
<Button type="button" variant="outline">
|
||
انصراف
|
||
</Button>
|
||
</DialogClose>
|
||
<Button
|
||
type="button"
|
||
onClick={() => {
|
||
setNewTxnOpen(false);
|
||
setTxnNote("");
|
||
}}
|
||
>
|
||
ذخیره پیشنویس
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-4 sm:gap-6 lg:flex-row">
|
||
<RevenueChart period={period} />
|
||
<CostsChart period={period} />
|
||
</div>
|
||
|
||
<StatsCards />
|
||
|
||
<TransactionsTable />
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const Dashboard2 = ({ className }: { className?: string }) => {
|
||
return (
|
||
<TooltipProvider>
|
||
<SidebarProvider
|
||
dir="rtl"
|
||
lang="fa"
|
||
className={cn("min-h-svh w-full overflow-hidden bg-sidebar", className)}
|
||
>
|
||
<a
|
||
href="#dashboard-main"
|
||
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:start-4 focus:z-50 focus:rounded-md focus:bg-background focus:px-3 focus:py-2 focus:text-sm focus:text-foreground focus:ring-2 focus:ring-ring"
|
||
>
|
||
رفتن به محتوای اصلی
|
||
</a>
|
||
<AppSidebar />
|
||
<SidebarInset className="min-h-svh min-w-0 w-full overflow-hidden md:peer-data-[variant=inset]:m-0 md:peer-data-[variant=inset]:rounded-none md:peer-data-[variant=inset]:shadow-none md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ms-0 md:peer-data-[variant=inset]:peer-data-[state=collapsed]:me-0">
|
||
<DashboardHeader />
|
||
<DashboardContent />
|
||
</SidebarInset>
|
||
</SidebarProvider>
|
||
</TooltipProvider>
|
||
);
|
||
};
|
||
|
||
export { Dashboard2 };
|