Первый коммит

This commit is contained in:
Redsandyg 2025-06-02 13:04:22 +03:00
parent c0d101a79e
commit 00f5ecfb9c
31 changed files with 3483 additions and 101 deletions

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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
View 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>
);
}

View File

@ -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>
); );

View File

@ -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
View 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>
);
}

View 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;

View 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 } }}
/>
);
}

View 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;

View 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 } }}
/>
);
}

View 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;

View 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;

View 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;

View 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;

View 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;

View 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>
);
}

View 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;

View 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>
);
}

View 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 } }}
/>
);
}

View 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;

View 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 } }}
/>
);
}

View 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
View 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
View 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;

View 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;
}
}

View 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;
}
}

View 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;
}

View 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
View 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;
}

View 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;
}