Первый коммит
This commit is contained in:
parent
c0d101a79e
commit
00f5ecfb9c
@ -1,7 +1,14 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
async rewrites() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: "/api/:path*",
|
||||||
|
destination: "http://127.0.0.1:8000/:path*",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
1733
package-lock.json
generated
1733
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@ -9,14 +9,21 @@
|
|||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@mui/icons-material": "^7.1.0",
|
||||||
|
"@mui/material": "^7.1.0",
|
||||||
|
"@mui/x-data-grid": "^8.5.0",
|
||||||
|
"@types/react-datepicker": "^6.2.0",
|
||||||
|
"material-react-table": "^3.2.1",
|
||||||
|
"next": "15.3.3",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
|
"react-datepicker": "^8.4.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"next": "15.3.3"
|
"recharts": "^2.15.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5",
|
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19"
|
"@types/react-dom": "^19",
|
||||||
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
64
src/app/billing/page.tsx
Normal file
64
src/app/billing/page.tsx
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
"use client";
|
||||||
|
import { useState, useEffect, useMemo } from "react";
|
||||||
|
import MetricCard from "../../components/MetricCard";
|
||||||
|
import styles from "../../styles/billing.module.css";
|
||||||
|
import BillingPieChart from "../../components/BillingPieChart";
|
||||||
|
import BillingMetricCards from "../../components/BillingMetricCards";
|
||||||
|
import PayoutsTransactionsTable from "../../components/PayoutsTransactionsTable";
|
||||||
|
import BillingStatChart from "../../components/BillingStatChart";
|
||||||
|
import DateFilters from "../../components/DateFilters";
|
||||||
|
|
||||||
|
export default function BillingPage() {
|
||||||
|
const [payoutForm, setPayoutForm] = useState({
|
||||||
|
amount: "",
|
||||||
|
method: "bank",
|
||||||
|
});
|
||||||
|
const [filters, setFilters] = useState({
|
||||||
|
dateStart: "",
|
||||||
|
dateEnd: "",
|
||||||
|
});
|
||||||
|
const [reloadKey, setReloadKey] = useState(0);
|
||||||
|
|
||||||
|
function formatCurrency(amount: number) {
|
||||||
|
return amount?.toLocaleString("ru-RU", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "RUB",
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
}) ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleApply() {
|
||||||
|
setReloadKey(k => k + 1);
|
||||||
|
}
|
||||||
|
function handleClear() {
|
||||||
|
setFilters(f => ({ ...f, dateStart: '', dateEnd: '' }));
|
||||||
|
setReloadKey(k => k + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.billingPage}>
|
||||||
|
<h1 className={styles.title}>Финансы</h1>
|
||||||
|
<BillingMetricCards />
|
||||||
|
<div className={styles.grid2}>
|
||||||
|
<BillingStatChart />
|
||||||
|
<BillingPieChart />
|
||||||
|
</div>
|
||||||
|
<DateFilters
|
||||||
|
dateStart={filters.dateStart}
|
||||||
|
dateEnd={filters.dateEnd}
|
||||||
|
onChange={(field, value) => {
|
||||||
|
if (field === "dateStart") {
|
||||||
|
if (filters.dateEnd && value > filters.dateEnd) return;
|
||||||
|
}
|
||||||
|
if (field === "dateEnd") {
|
||||||
|
if (filters.dateStart && value < filters.dateStart) return;
|
||||||
|
}
|
||||||
|
setFilters(f => ({ ...f, [field]: value }));
|
||||||
|
}}
|
||||||
|
onApply={handleApply}
|
||||||
|
onClear={handleClear}
|
||||||
|
/>
|
||||||
|
<PayoutsTransactionsTable filters={filters} reloadKey={reloadKey} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
import Navigation from "../components/Navigation";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
@ -13,8 +14,8 @@ const geistMono = Geist_Mono({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "RE:Premium Partner",
|
||||||
description: "Generated by create next app",
|
description: "Партнерская платформа",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@ -23,9 +24,23 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="ru">
|
||||||
<body className={`${geistSans.variable} ${geistMono.variable}`}>
|
<body
|
||||||
|
className={`${geistSans.variable} ${geistMono.variable}`}
|
||||||
|
style={{ background: "#f9fafb", minHeight: "100vh", margin: 0 }}
|
||||||
|
>
|
||||||
|
<Navigation />
|
||||||
|
<main
|
||||||
|
style={{
|
||||||
|
maxWidth: "1200px",
|
||||||
|
margin: "0 auto",
|
||||||
|
padding: "32px 8px",
|
||||||
|
width: "100%",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
127
src/app/page.tsx
127
src/app/page.tsx
@ -1,95 +1,44 @@
|
|||||||
import Image from "next/image";
|
import mockData from "../data/mockData";
|
||||||
import styles from "./page.module.css";
|
import MetricCards from "../components/MetricCards";
|
||||||
|
import Table from "../components/Table";
|
||||||
|
import StatCharts from "../components/StatCharts";
|
||||||
|
import styles from "../styles/dashboard.module.css";
|
||||||
|
|
||||||
export default function Home() {
|
function formatCurrency(amount: number) {
|
||||||
|
return amount.toLocaleString("ru-RU", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "RUB",
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
return (
|
return (
|
||||||
<div className={styles.page}>
|
<div className={styles.dashboard}>
|
||||||
<main className={styles.main}>
|
<h1 className={styles.title}>Дашборд</h1>
|
||||||
<Image
|
<MetricCards />
|
||||||
className={styles.logo}
|
<StatCharts />
|
||||||
src="/next.svg"
|
{/* <div className={styles.tableBlock}>
|
||||||
alt="Next.js logo"
|
<h3 className={styles.tableTitle}>Последние продажи</h3>
|
||||||
width={180}
|
<Table
|
||||||
height={38}
|
headers={["ID реферала", "Агент", "Сумма продажи", "Комиссия", "Дата", "Статус"]}
|
||||||
priority
|
data={mockData.dashboard.recentSales}
|
||||||
|
renderRow={(sale: any, index: number) => (
|
||||||
|
<tr key={index}>
|
||||||
|
<td>{sale.id}</td>
|
||||||
|
<td>{sale.agent}</td>
|
||||||
|
<td>{formatCurrency(sale.amount)}</td>
|
||||||
|
<td>{formatCurrency(sale.commission)}</td>
|
||||||
|
<td>{sale.date}</td>
|
||||||
|
<td>
|
||||||
|
<span className={sale.status === "Выплачено" ? styles.statusPaid : styles.statusPending}>
|
||||||
|
{sale.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
<ol>
|
</div> */}
|
||||||
<li>
|
|
||||||
Get started by editing <code>src/app/page.tsx</code>.
|
|
||||||
</li>
|
|
||||||
<li>Save and see your changes instantly.</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<div className={styles.ctas}>
|
|
||||||
<a
|
|
||||||
className={styles.primary}
|
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
className={styles.logo}
|
|
||||||
src="/vercel.svg"
|
|
||||||
alt="Vercel logomark"
|
|
||||||
width={20}
|
|
||||||
height={20}
|
|
||||||
/>
|
|
||||||
Deploy now
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className={styles.secondary}
|
|
||||||
>
|
|
||||||
Read our docs
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
<footer className={styles.footer}>
|
|
||||||
<a
|
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
aria-hidden
|
|
||||||
src="/file.svg"
|
|
||||||
alt="File icon"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Learn
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
aria-hidden
|
|
||||||
src="/window.svg"
|
|
||||||
alt="Window icon"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Examples
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
aria-hidden
|
|
||||||
src="/globe.svg"
|
|
||||||
alt="Globe icon"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Go to nextjs.org →
|
|
||||||
</a>
|
|
||||||
</footer>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
84
src/app/stat/page.tsx
Normal file
84
src/app/stat/page.tsx
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
"use client";
|
||||||
|
import { useState } from "react";
|
||||||
|
import mockData from "../../data/mockData";
|
||||||
|
import AgentsTable from "../../components/AgentsTable";
|
||||||
|
import ReferralsTable from "../../components/ReferralsTable";
|
||||||
|
import SalesTable from "../../components/SalesTable";
|
||||||
|
import styles from "../../styles/stat.module.css";
|
||||||
|
import DateInput from "../../components/DateInput";
|
||||||
|
import DateFilters from "../../components/DateFilters";
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ id: "agents", label: "Агенты" },
|
||||||
|
{ id: "referrals", label: "Рефералы" },
|
||||||
|
{ id: "sales", label: "Продажи" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function StatPage() {
|
||||||
|
const [activeTab, setActiveTab] = useState("agents");
|
||||||
|
|
||||||
|
const [filters, setFilters] = useState({
|
||||||
|
dateStart: "",
|
||||||
|
dateEnd: "",
|
||||||
|
agentStatus: "all",
|
||||||
|
});
|
||||||
|
const [reloadKey, setReloadKey] = useState(0);
|
||||||
|
|
||||||
|
function handleApply() {
|
||||||
|
setReloadKey(k => k + 1);
|
||||||
|
}
|
||||||
|
function handleClear() {
|
||||||
|
setFilters(f => ({ ...f, dateStart: '', dateEnd: '' }));
|
||||||
|
setReloadKey(k => k + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.statPage}>
|
||||||
|
<div className={styles.headerRow}>
|
||||||
|
<h1 className={styles.title}>Статистика и аналитика</h1>
|
||||||
|
{/* <button className={styles.exportBtn}>Экспорт</button> */}
|
||||||
|
</div>
|
||||||
|
<div className={styles.tabs}>
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
className={activeTab === tab.id ? styles.activeTab : styles.tab}
|
||||||
|
onClick={() => setActiveTab(tab.id)}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DateFilters
|
||||||
|
dateStart={filters.dateStart}
|
||||||
|
dateEnd={filters.dateEnd}
|
||||||
|
onChange={(field, value) => {
|
||||||
|
if (field === "dateStart") {
|
||||||
|
if (filters.dateEnd && value > filters.dateEnd) return;
|
||||||
|
}
|
||||||
|
if (field === "dateEnd") {
|
||||||
|
if (filters.dateStart && value < filters.dateStart) return;
|
||||||
|
}
|
||||||
|
setFilters(f => ({ ...f, [field]: value }));
|
||||||
|
}}
|
||||||
|
onApply={handleApply}
|
||||||
|
onClear={handleClear}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className={styles.tabContent}>
|
||||||
|
{activeTab === "agents" && (
|
||||||
|
<AgentsTable filters={filters} reloadKey={reloadKey} />
|
||||||
|
)}
|
||||||
|
{activeTab === "referrals" && (
|
||||||
|
<ReferralsTable filters={filters} reloadKey={reloadKey} />
|
||||||
|
)}
|
||||||
|
{activeTab === "sales" && (
|
||||||
|
<SalesTable filters={filters} reloadKey={reloadKey} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
src/components/AgentsBarChart.tsx
Normal file
98
src/components/AgentsBarChart.tsx
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
"use client";
|
||||||
|
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, LabelList, Label } from "recharts";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
interface AgentBarData {
|
||||||
|
name: string;
|
||||||
|
count: number;
|
||||||
|
sum: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatCurrency = (amount: number) => {
|
||||||
|
return amount.toLocaleString("ru-RU", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "RUB",
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatShort = (value: number) => {
|
||||||
|
if (value >= 1_000_000) return (value / 1_000_000).toFixed(1).replace('.0', '') + 'M ₽';
|
||||||
|
if (value >= 1_000) return (value / 1_000).toFixed(1).replace('.0', '') + 'K ₽';
|
||||||
|
return value + ' ₽';
|
||||||
|
};
|
||||||
|
|
||||||
|
const AgentsBarChart: React.FC = () => {
|
||||||
|
const [data, setData] = useState<AgentBarData[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/api/dashboard/chart/agent")
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) throw new Error("Ошибка загрузки данных");
|
||||||
|
return res.json();
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
setData(data);
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
setError(err.message);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) return <div>Загрузка графика...</div>;
|
||||||
|
if (error) return <div>Ошибка: {error}</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<BarChart
|
||||||
|
data={data}
|
||||||
|
margin={{ top: 30, right: 40, left: 20, bottom: 40 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="name"
|
||||||
|
angle={-30}
|
||||||
|
textAnchor="end"
|
||||||
|
tick={({ x, y, payload }) => {
|
||||||
|
let name = payload.value;
|
||||||
|
if (name.length > 10) {
|
||||||
|
name = name.slice(0, 10) + '...';
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<text x={x} y={y + 10} textAnchor="end" fontSize={14} fill="#666" transform={`rotate(-30,${x},${y + 10})`}>
|
||||||
|
{name}
|
||||||
|
</text>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<YAxis yAxisId="left" orientation="left">
|
||||||
|
<Label value="Кол-во продаж" angle={-90} position="insideLeft" style={{ textAnchor: 'middle' }} />
|
||||||
|
</YAxis>
|
||||||
|
<YAxis yAxisId="right" orientation="right" tickFormatter={formatShort} width={80}>
|
||||||
|
<Label value="Сумма продаж" angle={90} position="insideRight" style={{ textAnchor: 'middle' }} />
|
||||||
|
</YAxis>
|
||||||
|
<Tooltip formatter={(value: any, name: string) => {
|
||||||
|
if (name === "sum" || name === "Сумма продаж") {
|
||||||
|
return [formatCurrency(Number(value)), "Сумма продаж"];
|
||||||
|
}
|
||||||
|
if (name === "count" || name === "Кол-во продаж") {
|
||||||
|
return [value, "Кол-во продаж"];
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}} />
|
||||||
|
<Bar yAxisId="left" dataKey="count" fill="#3B82F6" name="Кол-во продаж">
|
||||||
|
<LabelList dataKey="count" position="top" fill="#3B82F6" fontSize={14} />
|
||||||
|
</Bar>
|
||||||
|
<Bar yAxisId="right" dataKey="sum" fill="#10B981" name="Сумма продаж">
|
||||||
|
<LabelList dataKey="sum" position="top" fill="#10B981" fontSize={14} formatter={formatShort} />
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AgentsBarChart;
|
||||||
52
src/components/AgentsTable.tsx
Normal file
52
src/components/AgentsTable.tsx
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { useEffect, useState, useMemo } from 'react';
|
||||||
|
import { MaterialReactTable, MRT_ColumnDef } from 'material-react-table';
|
||||||
|
import styles from "../styles/stat.module.css";
|
||||||
|
|
||||||
|
function formatCurrency(amount: number) {
|
||||||
|
return amount?.toLocaleString("ru-RU", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "RUB",
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
}) ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AgentsTable({ filters, reloadKey }: { filters: { dateStart: string, dateEnd: string }, reloadKey: number }) {
|
||||||
|
const [data, setData] = useState<any[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filters.dateStart) params.append('date_start', filters.dateStart);
|
||||||
|
if (filters.dateEnd) params.append('date_end', filters.dateEnd);
|
||||||
|
fetch(`/api/stat/agents?${params.toString()}`)
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(setData)
|
||||||
|
.catch(() => setData([]));
|
||||||
|
}, [filters.dateStart, filters.dateEnd, reloadKey]);
|
||||||
|
|
||||||
|
const columns = useMemo<MRT_ColumnDef<any>[]>(
|
||||||
|
() => [
|
||||||
|
{ accessorKey: 'name', header: 'Имя' },
|
||||||
|
{ accessorKey: 'refCount', header: 'Кол-во рефов' },
|
||||||
|
{ accessorKey: 'salesCount', header: 'Кол-во продаж' },
|
||||||
|
{ accessorKey: 'salesSum', header: 'Сумма продаж',
|
||||||
|
Cell: ({ cell }) => formatCurrency(cell.getValue() as number) },
|
||||||
|
{ accessorKey: 'crediting', header: 'Начислено',
|
||||||
|
Cell: ({ cell }) => formatCurrency(cell.getValue() as number) },
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MaterialReactTable
|
||||||
|
columns={columns}
|
||||||
|
data={data}
|
||||||
|
enableColumnActions={false}
|
||||||
|
enableColumnFilters={false}
|
||||||
|
enableSorting={true}
|
||||||
|
enablePagination={true}
|
||||||
|
muiTableBodyCellProps={{ sx: { fontSize: 14 } }}
|
||||||
|
muiTableHeadCellProps={{ sx: { fontWeight: 700 } }}
|
||||||
|
initialState={{ pagination: { pageSize: 10, pageIndex: 0 } }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
src/components/BillingMetricCards.tsx
Normal file
53
src/components/BillingMetricCards.tsx
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import MetricCard from "./MetricCard";
|
||||||
|
import styles from "../styles/billing.module.css";
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
interface BillingCardsData {
|
||||||
|
cost: number;
|
||||||
|
crediting: number;
|
||||||
|
pendingPayouts: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BillingMetricCards: React.FC = () => {
|
||||||
|
const [data, setData] = useState<BillingCardsData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function formatCurrency(amount: number) {
|
||||||
|
return amount?.toLocaleString("ru-RU", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "RUB",
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
}) ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/api/billing/cards")
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) throw new Error("Ошибка загрузки данных");
|
||||||
|
return res.json();
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
setData(data);
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
setError(err.message);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) return <div>Загрузка...</div>;
|
||||||
|
if (error) return <div>Ошибка: {error}</div>;
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.metricsGrid}>
|
||||||
|
<MetricCard title="Общий заработок" value={formatCurrency(data.cost)} />
|
||||||
|
<MetricCard title="Общие выплаты" value={formatCurrency(data.crediting)} />
|
||||||
|
<MetricCard title="Доступно к выводу" value={formatCurrency(data.pendingPayouts)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BillingMetricCards;
|
||||||
51
src/components/BillingPayoutsTable.tsx
Normal file
51
src/components/BillingPayoutsTable.tsx
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { MaterialReactTable, MRT_ColumnDef } from 'material-react-table';
|
||||||
|
import mockData from '../data/mockData';
|
||||||
|
|
||||||
|
function formatCurrency(amount: number) {
|
||||||
|
return amount?.toLocaleString('ru-RU', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'RUB',
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
}) ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusColor: Record<string, string> = {
|
||||||
|
'Завершена': '#4caf50',
|
||||||
|
'Ожидается': '#ff9800',
|
||||||
|
'Ошибка': '#f44336',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function BillingPayoutsTable() {
|
||||||
|
const columns = useMemo<MRT_ColumnDef<any>[]>(
|
||||||
|
() => [
|
||||||
|
{ accessorKey: 'id', header: 'ID' },
|
||||||
|
{ accessorKey: 'amount', header: 'Сумма',
|
||||||
|
Cell: ({ cell }) => formatCurrency(cell.getValue() as number) },
|
||||||
|
{ accessorKey: 'date', header: 'Дата' },
|
||||||
|
{ accessorKey: 'status', header: 'Статус',
|
||||||
|
Cell: ({ cell }) => (
|
||||||
|
<span style={{ color: statusColor[cell.getValue() as string] || '#333', fontWeight: 600 }}>
|
||||||
|
{cell.getValue() as string}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ accessorKey: 'method', header: 'Способ' },
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MaterialReactTable
|
||||||
|
columns={columns}
|
||||||
|
data={mockData.payouts}
|
||||||
|
enableColumnActions={false}
|
||||||
|
enableColumnFilters={false}
|
||||||
|
enableSorting={true}
|
||||||
|
enablePagination={true}
|
||||||
|
muiTableBodyCellProps={{ sx: { fontSize: 14 } }}
|
||||||
|
muiTableHeadCellProps={{ sx: { fontWeight: 700 } }}
|
||||||
|
initialState={{ pagination: { pageSize: 10, pageIndex: 0 } }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
src/components/BillingPieChart.tsx
Normal file
52
src/components/BillingPieChart.tsx
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
"use client";
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from "recharts";
|
||||||
|
import styles from "../styles/billing.module.css";
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
|
"done": "#10B981",
|
||||||
|
"waiting": "#F59E0B",
|
||||||
|
"error": "#EF4444",
|
||||||
|
"process": "#3B82F6",
|
||||||
|
};
|
||||||
|
|
||||||
|
const BillingPieChart: React.FC = () => {
|
||||||
|
const [data, setData] = useState<{ name: string; value: number; fill: string }[]>([]);
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("api/billing/chart/pie")
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((apiData) => {
|
||||||
|
const mapped = apiData.map((item: { status: string; count: number }) => ({
|
||||||
|
name: item.status,
|
||||||
|
value: item.count,
|
||||||
|
fill: STATUS_COLORS[item.status] || "#A3A3A3",
|
||||||
|
}));
|
||||||
|
setData(mapped);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.pieChartBlock}>
|
||||||
|
<h3 className={styles.blockTitle}>Статистика выплат</h3>
|
||||||
|
<ResponsiveContainer width="100%" height={200}>
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={data}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
outerRadius={70}
|
||||||
|
dataKey="value"
|
||||||
|
label={({ name, value }) => `${name}: ${value}`}
|
||||||
|
>
|
||||||
|
{data.map((entry, idx) => (
|
||||||
|
<Cell key={`cell-${idx}`} fill={entry.fill} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BillingPieChart;
|
||||||
107
src/components/BillingStatChart.tsx
Normal file
107
src/components/BillingStatChart.tsx
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
"use client";
|
||||||
|
import { ResponsiveContainer, LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Label } from "recharts";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import styles from "../styles/billing.module.css";
|
||||||
|
import { TooltipProps } from "recharts";
|
||||||
|
import { ValueType, NameType } from "recharts/types/component/DefaultTooltipContent";
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
done: "#10B981",
|
||||||
|
process: "#3B82F6",
|
||||||
|
waiting: "#F59E42",
|
||||||
|
error: "#EF4444",
|
||||||
|
};
|
||||||
|
|
||||||
|
const BillingStatChart: React.FC = () => {
|
||||||
|
const [data, setData] = useState<any[]>([]);
|
||||||
|
const [statuses, setStatuses] = useState<string[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/api/billing/chart/stat")
|
||||||
|
.then(res => res.json())
|
||||||
|
.then((apiData) => {
|
||||||
|
// Собираем все уникальные даты и статусы
|
||||||
|
const allDates = new Set<string>();
|
||||||
|
const allStatuses = new Set<string>();
|
||||||
|
apiData.forEach((item: any) => {
|
||||||
|
allDates.add(item.date);
|
||||||
|
allStatuses.add(item.status);
|
||||||
|
});
|
||||||
|
const sortedDates = Array.from(allDates).sort((a, b) => a.localeCompare(b));
|
||||||
|
const statusesArr = Array.from(allStatuses);
|
||||||
|
// Группируем по дате, для каждой даты заполняем все статусы (если нет — 0)
|
||||||
|
const grouped: Record<string, any> = {};
|
||||||
|
sortedDates.forEach(date => {
|
||||||
|
grouped[date] = { date };
|
||||||
|
statusesArr.forEach(status => {
|
||||||
|
grouped[date][status] = 0;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
apiData.forEach((item: any) => {
|
||||||
|
grouped[item.date][item.status] = item.count;
|
||||||
|
});
|
||||||
|
const sorted = sortedDates.map(date => grouped[date]);
|
||||||
|
setData(sorted);
|
||||||
|
setStatuses(statusesArr);
|
||||||
|
})
|
||||||
|
.catch(() => setData([]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Кастомный тултип
|
||||||
|
const CustomTooltip = ({ active, payload, label }: TooltipProps<ValueType, NameType>) => {
|
||||||
|
if (active && payload && payload.length) {
|
||||||
|
return (
|
||||||
|
<div style={{ background: '#fff', border: '1px solid #eee', borderRadius: 8, padding: 12, boxShadow: '0 2px 8px #0001', minWidth: 120, color: '#111' }}>
|
||||||
|
<div style={{ fontWeight: 700, marginBottom: 6 }}>{label}</div>
|
||||||
|
{payload.map((entry, idx) => (
|
||||||
|
<div key={idx} style={{ fontWeight: 500, marginBottom: 2 }}>
|
||||||
|
<span style={{ color: statusColors[entry.name as string] || undefined }}>{entry.name}</span> : {entry.value}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.payoutFormBlock}>
|
||||||
|
<h3 className={styles.blockTitle}>Динамика выплат по статусам</h3>
|
||||||
|
<ResponsiveContainer width="100%" height={220}>
|
||||||
|
<LineChart data={data} margin={{ top: 20, right: 40, left: 20, bottom: 20 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="date" tickFormatter={d => new Date(d).toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit" })}>
|
||||||
|
<Label value="Дата" offset={-10} position="insideBottom" />
|
||||||
|
</XAxis>
|
||||||
|
<YAxis allowDecimals={false} width={60}>
|
||||||
|
<Label value="Кол-во" angle={-90} position="insideLeft" style={{ textAnchor: 'middle' }} />
|
||||||
|
</YAxis>
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
{statuses.map(status => (
|
||||||
|
<Line
|
||||||
|
key={status}
|
||||||
|
type="monotone"
|
||||||
|
dataKey={status}
|
||||||
|
stroke={statusColors[status] || "#8884d8"}
|
||||||
|
strokeWidth={2}
|
||||||
|
name={status}
|
||||||
|
dot={{ r: 3 }}
|
||||||
|
activeDot={{ r: 6 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
{/* Кастомная легенда под графиком */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', gap: 24, marginTop: 12 }}>
|
||||||
|
{statuses.map(status => (
|
||||||
|
<div key={status} style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 15, color: '#111' }}>
|
||||||
|
<span style={{ display: 'inline-block', width: 14, height: 14, borderRadius: '50%', background: statusColors[status] || '#8884d8', marginRight: 4 }}></span>
|
||||||
|
<span>{status}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BillingStatChart;
|
||||||
32
src/components/DateFilters.tsx
Normal file
32
src/components/DateFilters.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import React from "react";
|
||||||
|
import DateInput from "./DateInput";
|
||||||
|
import styles from "../styles/stat.module.css";
|
||||||
|
|
||||||
|
interface DateFiltersProps {
|
||||||
|
dateStart: string;
|
||||||
|
dateEnd: string;
|
||||||
|
onChange: (field: "dateStart" | "dateEnd", value: string) => void;
|
||||||
|
onApply: () => void;
|
||||||
|
onClear: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DateFilters: React.FC<DateFiltersProps> = ({ dateStart, dateEnd, onChange, onApply, onClear }) => (
|
||||||
|
<div className={styles.filters}>
|
||||||
|
<DateInput
|
||||||
|
label="Дата начала"
|
||||||
|
value={dateStart}
|
||||||
|
onChange={e => onChange("dateStart", e.target.value)}
|
||||||
|
max={dateEnd || undefined}
|
||||||
|
/>
|
||||||
|
<DateInput
|
||||||
|
label="Дата окончания"
|
||||||
|
value={dateEnd}
|
||||||
|
onChange={e => onChange("dateEnd", e.target.value)}
|
||||||
|
min={dateStart || undefined}
|
||||||
|
/>
|
||||||
|
<button className={styles.filterBtn} onClick={onApply}>Применить</button>
|
||||||
|
<button className={styles.filterBtn} onClick={onClear}>Очистить фильтр</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default DateFilters;
|
||||||
24
src/components/DateInput.tsx
Normal file
24
src/components/DateInput.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
interface DateInputProps {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
min?: string;
|
||||||
|
max?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DateInput: React.FC<DateInputProps> = ({ label, value, onChange, min, max }) => (
|
||||||
|
<div>
|
||||||
|
<label>{label}</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default DateInput;
|
||||||
24
src/components/MetricCard.tsx
Normal file
24
src/components/MetricCard.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import styles from '../styles/metricCard.module.css';
|
||||||
|
|
||||||
|
interface MetricCardProps {
|
||||||
|
title: string;
|
||||||
|
value: string | number;
|
||||||
|
change?: number;
|
||||||
|
isPositive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MetricCard: React.FC<MetricCardProps> = ({ title, value, change, isPositive = true }) => (
|
||||||
|
<div className={styles.card}>
|
||||||
|
<h3 className={styles.title}>{title}</h3>
|
||||||
|
<div className={styles.valueRow}>
|
||||||
|
<p className={styles.value}>{value}</p>
|
||||||
|
{change !== undefined && (
|
||||||
|
<span className={isPositive ? styles.positive : styles.negative}>
|
||||||
|
{isPositive ? '+' : ''}{change}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default MetricCard;
|
||||||
57
src/components/MetricCards.tsx
Normal file
57
src/components/MetricCards.tsx
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
"use client";
|
||||||
|
import MetricCard from "./MetricCard";
|
||||||
|
import styles from "../styles/dashboard.module.css";
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
function formatCurrency(amount: number) {
|
||||||
|
return amount.toLocaleString("ru-RU", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "RUB",
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DashboardCardsData {
|
||||||
|
totalRevenue: number;
|
||||||
|
totalPayouts: number;
|
||||||
|
activeReferrals: number;
|
||||||
|
pendingPayouts: number;
|
||||||
|
totalSales: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MetricCards() {
|
||||||
|
const [data, setData] = useState<DashboardCardsData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("api/dashboard/cards")
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) throw new Error("Ошибка загрузки данных");
|
||||||
|
return res.json();
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
setData(data);
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
setError(err.message);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) return <div>Загрузка...</div>;
|
||||||
|
if (error) return <div>Ошибка: {error}</div>;
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.metricsFlex}>
|
||||||
|
<MetricCard title="Общий доход" value={formatCurrency(data.totalRevenue)} />
|
||||||
|
<MetricCard title="Общие выплаты" value={formatCurrency(data.totalPayouts)} />
|
||||||
|
<MetricCard title="Активные рефералы" value={data.activeReferrals.toLocaleString()} />
|
||||||
|
<MetricCard title="Количество продаж" value={data.totalSales.toLocaleString()} />
|
||||||
|
{/* <MetricCard title="Конверсия" value={`${data.conversion}%`} change={2.1} /> */}
|
||||||
|
<MetricCard title="Ожидающие выплаты" value={formatCurrency(data.pendingPayouts)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
src/components/Navigation.tsx
Normal file
46
src/components/Navigation.tsx
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
"use client";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import styles from "../styles/navigation.module.css";
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
href: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const navItems: NavItem[] = [
|
||||||
|
{ id: "home", label: "Дашборд", href: "/" },
|
||||||
|
{ id: "stat", label: "Статистика", href: "/stat" },
|
||||||
|
{ id: "billing", label: "Финансы", href: "/billing" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const Navigation: React.FC = () => {
|
||||||
|
const pathname = usePathname();
|
||||||
|
return (
|
||||||
|
<nav className={styles.nav}>
|
||||||
|
<div className={styles.logo}>RE:Premium Partner</div>
|
||||||
|
<div className={styles.links}>
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.id}
|
||||||
|
href={item.href}
|
||||||
|
className={
|
||||||
|
pathname === item.href
|
||||||
|
? styles.active
|
||||||
|
: styles.link
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className={styles.profile}>
|
||||||
|
<div className={styles.avatar}>ПП</div>
|
||||||
|
<span className={styles.profileName}>Партнер RE:Premium</span>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Navigation;
|
||||||
72
src/components/PayoutsTransactionsTable.tsx
Normal file
72
src/components/PayoutsTransactionsTable.tsx
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import { useEffect, useState, useMemo } from "react";
|
||||||
|
import { MaterialReactTable, MRT_ColumnDef } from "material-react-table";
|
||||||
|
import styles from "../styles/billing.module.css";
|
||||||
|
|
||||||
|
|
||||||
|
function formatCurrency(amount: number) {
|
||||||
|
return amount?.toLocaleString("ru-RU", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "RUB",
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
}) ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusColor: Record<string, string> = {
|
||||||
|
'done': '#4caf50',
|
||||||
|
'waiting': '#ff9800',
|
||||||
|
'process': '#2196f3',
|
||||||
|
'error': '#f44336',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PayoutsTransactionsTable({ filters, reloadKey }: { filters: { dateStart: string, dateEnd: string }, reloadKey: number }) {
|
||||||
|
const [data, setData] = useState<any[]>([]);
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filters.dateStart) params.append('date_start', filters.dateStart);
|
||||||
|
if (filters.dateEnd) params.append('date_end', filters.dateEnd);
|
||||||
|
fetch(`/api/billing/payouts/transactions?${params.toString()}`)
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(setData)
|
||||||
|
.catch(() => setData([]));
|
||||||
|
}, [filters.dateStart, filters.dateEnd, reloadKey]);
|
||||||
|
|
||||||
|
const columns = useMemo<MRT_ColumnDef<any>[]>(
|
||||||
|
() => [
|
||||||
|
{ accessorKey: 'id', header: 'ID' },
|
||||||
|
{ accessorKey: 'sum', header: 'Сумма',
|
||||||
|
Cell: ({ cell }) => formatCurrency(cell.getValue() as number) },
|
||||||
|
{ accessorKey: 'agent', header: 'Агент' },
|
||||||
|
{ accessorKey: 'status', header: 'Статус',
|
||||||
|
Cell: ({ cell }) => (
|
||||||
|
<span style={{ color: statusColor[cell.getValue() as string] || '#333', fontWeight: 600 }}>
|
||||||
|
{cell.getValue() as string}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ accessorKey: 'create_dttm', header: 'Создано',
|
||||||
|
Cell: ({ cell }) => new Date(cell.getValue() as string).toLocaleString('ru-RU') },
|
||||||
|
{ accessorKey: 'update_dttm', header: 'Обновлено',
|
||||||
|
Cell: ({ cell }) => new Date(cell.getValue() as string).toLocaleString('ru-RU') },
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
|
||||||
|
<div className={styles.tableBlock}>
|
||||||
|
<h3 className={styles.tableTitle}>История выплат</h3>
|
||||||
|
<MaterialReactTable
|
||||||
|
columns={columns}
|
||||||
|
data={data}
|
||||||
|
enableColumnActions={false}
|
||||||
|
enableColumnFilters={false}
|
||||||
|
enableSorting={true}
|
||||||
|
enablePagination={true}
|
||||||
|
muiTableBodyCellProps={{ sx: { fontSize: 14 } }}
|
||||||
|
muiTableHeadCellProps={{ sx: { fontWeight: 700 } }}
|
||||||
|
initialState={{ pagination: { pageSize: 10, pageIndex: 0 } }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
);
|
||||||
|
}
|
||||||
51
src/components/ReferralsTable.tsx
Normal file
51
src/components/ReferralsTable.tsx
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { useEffect, useState, useMemo } from 'react';
|
||||||
|
import { MaterialReactTable, MRT_ColumnDef } from 'material-react-table';
|
||||||
|
import styles from "../styles/stat.module.css";
|
||||||
|
|
||||||
|
function formatCurrency(amount: number) {
|
||||||
|
return amount?.toLocaleString("ru-RU", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "RUB",
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
}) ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ReferralsTable({ filters, reloadKey }: { filters: { dateStart: string, dateEnd: string }, reloadKey: number }) {
|
||||||
|
const [data, setData] = useState<any[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filters.dateStart) params.append('date_start', filters.dateStart);
|
||||||
|
if (filters.dateEnd) params.append('date_end', filters.dateEnd);
|
||||||
|
fetch(`/api/stat/referrals?${params.toString()}`)
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(setData)
|
||||||
|
.catch(() => setData([]));
|
||||||
|
}, [filters.dateStart, filters.dateEnd, reloadKey]);
|
||||||
|
|
||||||
|
const columns = useMemo<MRT_ColumnDef<any>[]>(
|
||||||
|
() => [
|
||||||
|
{ accessorKey: 'ref', header: 'Ref' },
|
||||||
|
{ accessorKey: 'agent', header: 'Агент' },
|
||||||
|
{ accessorKey: 'description', header: 'Описание' },
|
||||||
|
{ accessorKey: 'salesCount', header: 'Кол-во продаж' },
|
||||||
|
{ accessorKey: 'salesSum', header: 'Сумма продаж',
|
||||||
|
Cell: ({ cell }) => formatCurrency(cell.getValue() as number) },
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MaterialReactTable
|
||||||
|
columns={columns}
|
||||||
|
data={data}
|
||||||
|
enableColumnActions={false}
|
||||||
|
enableColumnFilters={false}
|
||||||
|
enableSorting={true}
|
||||||
|
enablePagination={true}
|
||||||
|
muiTableBodyCellProps={{ sx: { fontSize: 14 } }}
|
||||||
|
muiTableHeadCellProps={{ sx: { fontWeight: 700 } }}
|
||||||
|
initialState={{ pagination: { pageSize: 10, pageIndex: 0 } }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
src/components/RevenueChart.tsx
Normal file
72
src/components/RevenueChart.tsx
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
"use client";
|
||||||
|
import { ResponsiveContainer, LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Label } from "recharts";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
const formatCurrency = (amount: number) => {
|
||||||
|
return amount.toLocaleString("ru-RU", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "RUB",
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatShort = (value: number) => {
|
||||||
|
if (value >= 1_000_000) return (value / 1_000_000).toFixed(1).replace('.0', '') + 'M ₽';
|
||||||
|
if (value >= 1_000) return (value / 1_000).toFixed(1).replace('.0', '') + 'K ₽';
|
||||||
|
return value + ' ₽';
|
||||||
|
};
|
||||||
|
|
||||||
|
const RevenueChart: React.FC = () => {
|
||||||
|
const [data, setData] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/api/dashboard/chart/total")
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) throw new Error("Ошибка загрузки данных");
|
||||||
|
return res.json();
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
setData(data);
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
setError(err.message);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) return <div>Загрузка графика...</div>;
|
||||||
|
if (error) return <div>Ошибка: {error}</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<LineChart data={data} margin={{ top: 20, right: 40, left: 20, bottom: 20 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="date">
|
||||||
|
<Label value="Дата" offset={-10} position="insideBottom" />
|
||||||
|
</XAxis>
|
||||||
|
<YAxis yAxisId="left" orientation="left" tickFormatter={formatShort} width={80}>
|
||||||
|
<Label value="Сумма продаж" angle={-90} position="insideLeft" style={{ textAnchor: 'middle' }} />
|
||||||
|
</YAxis>
|
||||||
|
<YAxis yAxisId="right" orientation="right" width={60}>
|
||||||
|
<Label value="Кол-во продаж" angle={90} position="insideRight" style={{ textAnchor: 'middle' }} />
|
||||||
|
</YAxis>
|
||||||
|
<Tooltip formatter={(value: any, name: string) => {
|
||||||
|
if (name === "revenue" || name === "Сумма продаж") {
|
||||||
|
return [formatCurrency(Number(value)), "Сумма продаж"];
|
||||||
|
}
|
||||||
|
if (name === "sales" || name === "Кол-во продаж") {
|
||||||
|
return [value, "Кол-во продаж"];
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}} />
|
||||||
|
<Line yAxisId="left" type="monotone" dataKey="revenue" stroke="#3B82F6" strokeWidth={2} name="Сумма продаж" dot={{ r: 3 }} activeDot={{ r: 6 }} />
|
||||||
|
<Line yAxisId="right" type="monotone" dataKey="sales" stroke="#F59E0B" strokeWidth={2} name="Кол-во продаж" dot={{ r: 3 }} activeDot={{ r: 6 }} />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RevenueChart;
|
||||||
52
src/components/SalesTable.tsx
Normal file
52
src/components/SalesTable.tsx
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { useEffect, useState, useMemo } from 'react';
|
||||||
|
import { MaterialReactTable, MRT_ColumnDef } from 'material-react-table';
|
||||||
|
import styles from "../styles/stat.module.css";
|
||||||
|
|
||||||
|
function formatCurrency(amount: number) {
|
||||||
|
return amount?.toLocaleString("ru-RU", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "RUB",
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
}) ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SalesTable({ filters, reloadKey }: { filters: { dateStart: string, dateEnd: string }, reloadKey: number }) {
|
||||||
|
const [data, setData] = useState<any[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filters.dateStart) params.append('date_start', filters.dateStart);
|
||||||
|
if (filters.dateEnd) params.append('date_end', filters.dateEnd);
|
||||||
|
fetch(`/api/stat/sales?${params.toString()}`)
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(setData)
|
||||||
|
.catch(() => setData([]));
|
||||||
|
}, [filters.dateStart, filters.dateEnd, reloadKey]);
|
||||||
|
|
||||||
|
const columns = useMemo<MRT_ColumnDef<any>[]>(
|
||||||
|
() => [
|
||||||
|
{ accessorKey: 'saleId', header: 'ID продажи' },
|
||||||
|
{ accessorKey: 'cost', header: 'Сумма',
|
||||||
|
Cell: ({ cell }) => formatCurrency(cell.getValue() as number) },
|
||||||
|
{ accessorKey: 'crediting', header: 'Начислено',
|
||||||
|
Cell: ({ cell }) => formatCurrency(cell.getValue() as number) },
|
||||||
|
{ accessorKey: 'ref', header: 'Ref' },
|
||||||
|
{ accessorKey: 'name', header: 'Агент' },
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MaterialReactTable
|
||||||
|
columns={columns}
|
||||||
|
data={data}
|
||||||
|
enableColumnActions={false}
|
||||||
|
enableColumnFilters={false}
|
||||||
|
enableSorting={true}
|
||||||
|
enablePagination={true}
|
||||||
|
muiTableBodyCellProps={{ sx: { fontSize: 14 } }}
|
||||||
|
muiTableHeadCellProps={{ sx: { fontWeight: 700 } }}
|
||||||
|
initialState={{ pagination: { pageSize: 10, pageIndex: 0 } }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
src/components/StatCharts.tsx
Normal file
16
src/components/StatCharts.tsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import RevenueChart from "./RevenueChart";
|
||||||
|
import AgentsBarChart from "./AgentsBarChart";
|
||||||
|
import styles from "../styles/dashboard.module.css";
|
||||||
|
|
||||||
|
const StatCharts: React.FC = () => (
|
||||||
|
<div className={styles.chartsGrid}>
|
||||||
|
<div className={styles.chartStub} style={{ padding: 0 }}>
|
||||||
|
<RevenueChart />
|
||||||
|
</div>
|
||||||
|
<div className={styles.chartStub} style={{ padding: 0 }}>
|
||||||
|
<AgentsBarChart />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default StatCharts;
|
||||||
31
src/components/Table.tsx
Normal file
31
src/components/Table.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import styles from '../styles/table.module.css';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface TableProps<T> {
|
||||||
|
headers: string[];
|
||||||
|
data: T[];
|
||||||
|
renderRow: (item: T, index: number) => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Table<T>({ headers, data, renderRow }: TableProps<T>) {
|
||||||
|
return (
|
||||||
|
<div className={styles.tableWrapper}>
|
||||||
|
<div className={styles.overflowX}>
|
||||||
|
<table className={styles.table}>
|
||||||
|
<thead className={styles.thead}>
|
||||||
|
<tr>
|
||||||
|
{headers.map((header, index) => (
|
||||||
|
<th key={index} className={styles.th}>{header}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className={styles.tbody}>
|
||||||
|
{data.map((item, index) => renderRow(item, index))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Table;
|
||||||
54
src/data/mockData.js
Normal file
54
src/data/mockData.js
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
// Мок данные для Dashboard, Stat, Billing и других страниц
|
||||||
|
const mockData = {
|
||||||
|
dashboard: {
|
||||||
|
totalRevenue: 245680,
|
||||||
|
totalPayouts: 189420,
|
||||||
|
activeReferrals: 1247,
|
||||||
|
conversion: 24.7,
|
||||||
|
pendingPayouts: 12890,
|
||||||
|
revenueChart: [
|
||||||
|
{ date: '01.01', revenue: 12500, payouts: 8900 },
|
||||||
|
{ date: '08.01', revenue: 18200, payouts: 12100 },
|
||||||
|
{ date: '15.01', revenue: 22400, payouts: 15800 },
|
||||||
|
{ date: '22.01', revenue: 19800, payouts: 13200 },
|
||||||
|
{ date: '29.01', revenue: 25100, payouts: 18900 },
|
||||||
|
],
|
||||||
|
recentSales: [
|
||||||
|
{ id: 'REF-2024-001', agent: 'Алексей Петров', amount: 15000, commission: 2250, date: '2024-01-29', status: 'Выплачено' },
|
||||||
|
{ id: 'REF-2024-002', agent: 'Мария Сидорова', amount: 8500, commission: 1275, date: '2024-01-28', status: 'Ожидает' },
|
||||||
|
{ id: 'REF-2024-003', agent: 'Дмитрий Козлов', amount: 12300, commission: 1845, date: '2024-01-27', status: 'Выплачено' },
|
||||||
|
{ id: 'REF-2024-004', agent: 'Елена Волкова', amount: 6700, commission: 1005, date: '2024-01-26', status: 'Ожидает' },
|
||||||
|
{ id: 'REF-2024-005', agent: 'Игорь Смирнов', amount: 19200, commission: 2880, date: '2024-01-25', status: 'Выплачено' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
agents: [
|
||||||
|
{ id: 1, name: 'Алексей Петров', referrals: 45, sales: 12, conversion: 26.7, commission: 18500, status: 'Активен' },
|
||||||
|
{ id: 2, name: 'Мария Сидорова', referrals: 38, sales: 8, conversion: 21.1, commission: 12300, status: 'Активен' },
|
||||||
|
{ id: 3, name: 'Дмитрий Козлов', referrals: 52, sales: 15, conversion: 28.8, commission: 22100, status: 'Активен' },
|
||||||
|
{ id: 4, name: 'Елена Волкова', referrals: 29, sales: 6, conversion: 20.7, commission: 8900, status: 'Неактивен' },
|
||||||
|
{ id: 5, name: 'Игорь Смирнов', referrals: 67, sales: 18, conversion: 26.9, commission: 28700, status: 'Активен' },
|
||||||
|
],
|
||||||
|
referrals: [
|
||||||
|
{ id: 'REF-001', client: 'ООО "Альфа Строй"', date: '2024-01-15', status: 'Конвертирован', amount: 15000 },
|
||||||
|
{ id: 'REF-002', client: 'ИП Иванов А.С.', date: '2024-01-18', status: 'Ожидает', amount: null },
|
||||||
|
{ id: 'REF-003', client: 'ООО "БетаТех"', date: '2024-01-20', status: 'Конвертирован', amount: 8500 },
|
||||||
|
{ id: 'REF-004', client: 'ООО "ГаммаПро"', date: '2024-01-22', status: 'Ожидает', amount: null },
|
||||||
|
{ id: 'REF-005', client: 'ИП Петрова М.В.', date: '2024-01-25', status: 'Конвертирован', amount: 12300 },
|
||||||
|
],
|
||||||
|
sales: [
|
||||||
|
{ id: 'SALE-001', client: 'ООО "Альфа Строй"', agent: 'Алексей Петров', amount: 15000, commission: 15, status: 'Выплачено' },
|
||||||
|
{ id: 'SALE-002', client: 'ООО "БетаТех"', agent: 'Мария Сидорова', amount: 8500, commission: 15, status: 'Ожидает' },
|
||||||
|
{ id: 'SALE-003', client: 'ИП Петрова М.В.', agent: 'Дмитрий Козлов', amount: 12300, commission: 15, status: 'Выплачено' },
|
||||||
|
{ id: 'SALE-004', client: 'ООО "ДельтаГрупп"', agent: 'Игорь Смирнов', amount: 19200, commission: 15, status: 'Выплачено' },
|
||||||
|
{ id: 'SALE-005', client: 'ИП Сидоров В.П.', agent: 'Елена Волкова', amount: 6700, commission: 15, status: 'Ожидает' },
|
||||||
|
],
|
||||||
|
payouts: [
|
||||||
|
{ id: 'PAY-001', amount: 18500, date: '2024-01-25', status: 'Завершена', method: 'Банк' },
|
||||||
|
{ id: 'PAY-002', amount: 12300, date: '2024-01-20', status: 'Завершена', method: 'Крипто' },
|
||||||
|
{ id: 'PAY-003', amount: 8900, date: '2024-01-15', status: 'Ожидается', method: 'Банк' },
|
||||||
|
{ id: 'PAY-004', amount: 22100, date: '2024-01-10', status: 'Завершена', method: 'Банк' },
|
||||||
|
{ id: 'PAY-005', amount: 15600, date: '2024-01-05', status: 'Ошибка', method: 'Крипто' },
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
export default mockData;
|
||||||
145
src/styles/billing.module.css
Normal file
145
src/styles/billing.module.css
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
.billingPage {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 32px;
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
.metricsGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.metricsGrid {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.grid2 {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.grid2 {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.payoutFormBlock, .pieChartBlock {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
.blockTitle {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #111827;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.formGroup {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.formGroup label {
|
||||||
|
display: block;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #374151;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.formGroup input,
|
||||||
|
.formGroup select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 15px;
|
||||||
|
outline: none;
|
||||||
|
transition: border 0.2s;
|
||||||
|
}
|
||||||
|
.formGroup input:focus,
|
||||||
|
.formGroup select:focus {
|
||||||
|
border-color: #2563eb;
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6b7280;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.payoutBtn {
|
||||||
|
width: 100%;
|
||||||
|
background: #2563eb;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-top: 8px;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.payoutBtn:hover {
|
||||||
|
background: #1d4ed8;
|
||||||
|
}
|
||||||
|
.tableBlock {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
.tableTitle {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #111827;
|
||||||
|
padding: 24px 24px 0 24px;
|
||||||
|
}
|
||||||
|
.statusDone {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #065f46;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.statusPending {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.statusError {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.metricsGrid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.grid2 {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.payoutFormBlock, .pieChartBlock {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
.tableTitle {
|
||||||
|
padding: 16px 8px 0 8px;
|
||||||
|
}
|
||||||
|
.tableBlock {
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
107
src/styles/dashboard.module.css
Normal file
107
src/styles/dashboard.module.css
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
.dashboard {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 32px;
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #111827;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.metricsFlex {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.metricsFlex {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.metricsFlex {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.metricCard {
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
.chartsGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.chartsGrid {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.chartStub {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
min-height: 220px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
.tableBlock {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
.tableTitle {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #111827;
|
||||||
|
padding: 24px 24px 0 24px;
|
||||||
|
}
|
||||||
|
.statusPaid {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #065f46;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.statusPending {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.dashboard {
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.metricsFlex {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.chartsGrid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.chartStub {
|
||||||
|
min-height: 140px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.tableTitle {
|
||||||
|
padding: 16px 8px 0 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.tableBlock {
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/styles/metricCard.module.css
Normal file
35
src/styles/metricCard.module.css
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
.card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 1px 2px rgba(16, 30, 54, 0.04);
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
padding: 24px;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #6b7280;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.valueRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
.value {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
.positive {
|
||||||
|
margin-left: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #16a34a;
|
||||||
|
}
|
||||||
|
.negative {
|
||||||
|
margin-left: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
59
src/styles/navigation.module.css
Normal file
59
src/styles/navigation.module.css
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
.nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
height: 64px;
|
||||||
|
padding: 0 32px;
|
||||||
|
box-shadow: 0 1px 2px rgba(16, 30, 54, 0.04);
|
||||||
|
}
|
||||||
|
.logo {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
.links {
|
||||||
|
display: flex;
|
||||||
|
gap: 32px;
|
||||||
|
}
|
||||||
|
.link {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
padding: 8px 0;
|
||||||
|
transition: color 0.2s, border 0.2s;
|
||||||
|
}
|
||||||
|
.link:hover {
|
||||||
|
color: #2563eb;
|
||||||
|
border-bottom: 2px solid #dbeafe;
|
||||||
|
}
|
||||||
|
.active {
|
||||||
|
color: #2563eb;
|
||||||
|
border-bottom: 2px solid #2563eb;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.profile {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.avatar {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
background: #2563eb;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.profileName {
|
||||||
|
color: #374151;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
199
src/styles/stat.module.css
Normal file
199
src/styles/stat.module.css
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
.statPage {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 32px;
|
||||||
|
}
|
||||||
|
.headerRow {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
.exportBtn {
|
||||||
|
background: #2563eb;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 24px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.exportBtn:hover {
|
||||||
|
background: #1d4ed8;
|
||||||
|
}
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 32px;
|
||||||
|
border-bottom: 1.5px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
.tab {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #6b7280;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s, border 0.2s;
|
||||||
|
}
|
||||||
|
.tab:hover {
|
||||||
|
color: #2563eb;
|
||||||
|
border-bottom: 2px solid #dbeafe;
|
||||||
|
}
|
||||||
|
.activeTab {
|
||||||
|
color: #2563eb;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid #2563eb;
|
||||||
|
font-weight: 600;
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
.filters {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(1, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.filters {
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.filters label {
|
||||||
|
display: block;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #374151;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.filters input,
|
||||||
|
.filters select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 15px;
|
||||||
|
outline: none;
|
||||||
|
transition: border 0.2s;
|
||||||
|
background: #fff !important;
|
||||||
|
color: #111827 !important;
|
||||||
|
}
|
||||||
|
.filters input:focus,
|
||||||
|
.filters select:focus {
|
||||||
|
border-color: #2563eb;
|
||||||
|
}
|
||||||
|
.filters input:-webkit-autofill,
|
||||||
|
.filters input:-webkit-autofill:focus,
|
||||||
|
.filters input:-webkit-autofill:hover,
|
||||||
|
.filters input:-webkit-autofill:active {
|
||||||
|
-webkit-box-shadow: 0 0 0 1000px #fff inset !important;
|
||||||
|
box-shadow: 0 0 0 1000px #fff inset !important;
|
||||||
|
-webkit-text-fill-color: #111827 !important;
|
||||||
|
color: #111827 !important;
|
||||||
|
transition: background-color 5000s ease-in-out 0s;
|
||||||
|
}
|
||||||
|
.filterBtnWrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
.filterBtn {
|
||||||
|
width: 100%;
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #374151;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.filterBtn:hover {
|
||||||
|
background: #e5e7eb;
|
||||||
|
}
|
||||||
|
.tabContent {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.statusActive {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #065f46;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.statusInactive {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.statusPending {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.statPage {
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.headerRow {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.tabs {
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.filters {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
.tabContent {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.tableTitle {
|
||||||
|
padding: 16px 8px 0 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Стили для иконки календаря в input[type='date'] */
|
||||||
|
.filters input[type="date"]::-webkit-calendar-picker-indicator {
|
||||||
|
filter: invert(0.3) sepia(1) saturate(5) hue-rotate(180deg);
|
||||||
|
/* Делает иконку тёмной, можно скорректировать под нужный цвет */
|
||||||
|
}
|
||||||
|
.filters input[type="date"]::-webkit-input-placeholder {
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
.filters input[type="date"]::-moz-placeholder {
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
.filters input[type="date"]:-ms-input-placeholder {
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
.filters input[type="date"]::placeholder {
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Для Firefox */
|
||||||
|
.filters input[type="date"]::-moz-focus-inner {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
40
src/styles/table.module.css
Normal file
40
src/styles/table.module.css
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
.tableWrapper {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 1px 2px rgba(16, 30, 54, 0.04);
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.overflowX {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.table {
|
||||||
|
min-width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
.thead {
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
.th {
|
||||||
|
padding: 12px 24px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #6b7280;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
.tbody tr {
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.tbody tr:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
.tbody td {
|
||||||
|
padding: 12px 24px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #111827;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user