Compare commits

..

16 Commits

Author SHA1 Message Date
Redsandyg
7924acd23f Удалено поле agentCommissionRate из компонентов AccountProfile и AccountProfileCompany, а также соответствующие элементы интерфейса. Обновлены запросы в компоненте SaleCategoriesTable для получения данных с нового API-эндпоинта. 2025-06-21 15:02:32 +03:00
Redsandyg
223c2d3bd6 Добавлены новые компоненты для управления категориями товаров: SaleCategoriesTable и CategoryPage. Обновлен компонент Navigation для добавления ссылки на страницу категорий. Добавлены стили для новой страницы категорий. Реализована логика получения, создания и редактирования категорий с использованием токена авторизации из куки. 2025-06-18 10:48:31 +03:00
Redsandyg
cb630b4c01 Добавлено новое поле 'Промокод' в компонент ReferralsTable для улучшения отображения информации о рефералах. 2025-06-15 17:03:46 +03:00
Redsandyg
4e0409949c Добавлено новое поле agentCommissionRate в компоненты AccountProfile и AccountProfileCompany для отображения процента комиссии агента. Обновлены соответствующие метки и значения в интерфейсе. 2025-06-10 14:16:16 +03:00
Redsandyg
5380866af3 Обновлен компонент AccountIntegration для асинхронного получения, создания, редактирования и удаления токенов с использованием токена авторизации из куки. Добавлены функции обработки ошибок и улучшено взаимодействие с пользователем в компонентах CreateTokenDialog и IntegrationTokensTable. Введен новый тип Token для унификации структуры данных. 2025-06-09 15:28:01 +03:00
Redsandyg
9ea671b57c Добавлен новый компонент AccountIntegration для управления интеграциями и токенами. Обновлена страница аккаунта для интеграции нового компонента, добавлены функции создания, редактирования и удаления токенов. Также добавлены компоненты CreateTokenDialog и IntegrationTokensTable для улучшения взаимодействия с пользователем и отображения токенов. 2025-06-09 12:53:07 +03:00
Redsandyg
5614894e49 Добавлен новый компонент TabsNav для управления навигацией по вкладкам в страницах аккаунта и статистики. Обновлены соответствующие страницы для использования нового компонента, что улучшает структуру кода и упрощает управление вкладками. Также внесены изменения в стили для улучшения визуального восприятия. 2025-06-07 17:17:20 +03:00
Redsandyg
14921074a5 Обновлено получение данных в компонентах: AgentsBarChart, AgentsTable, BillingPayoutsTable, BillingPieChart, BillingStatChart, PayoutsTransactionsTable, ReferralsTable, RevenueChart и SalesTable. Теперь данные извлекаются из свойства items ответа API, что улучшает обработку данных и предотвращает возможные ошибки. 2025-06-07 14:15:13 +03:00
Redsandyg
410410bc85 Добавлено использование токена авторизации из куки в компонентах: AgentsBarChart, AgentsTable, BillingMetricCards, BillingPieChart, BillingStatChart, MetricCards, PayoutsTransactionsTable, ReferralsTable, RevenueChart и SalesTable. Реализована проверка наличия токена перед выполнением запросов к API, добавлены соответствующие сообщения об ошибках при его отсутствии. 2025-06-07 12:43:06 +03:00
Redsandyg
4b17da42e8 Добавлено управление настройками автоподтверждения транзакций в компоненте AccountAgentTransactionSection. Реализован асинхронный запрос для получения и обновления настроек автоподтверждения с использованием токена авторизации. Обновлен компонент AccountAgentTransactionTable для поддержки новых функций и добавлена логика обработки утверждения транзакций. Обновлены стили и добавлены новые статусы для отображения в таблицах. 2025-06-07 12:25:26 +03:00
Redsandyg
a475c50b20 Добавлен новый компонент AccountAgentTransactionSection для отображения и управления транзакциями агентов. Обновлена страница аккаунта для интеграции нового компонента. Добавлен компонент AccountAgentTransactionTable для отображения данных транзакций с возможностью фильтрации по датам. Обновлены стили для кнопок подтверждения в account.module.css. 2025-06-06 14:18:00 +03:00
Redsandyg
94900b3875 Добавлено экспортирование данных в формате CSV для таблиц: AgentsTable, BillingPayoutsTable, PayoutsTransactionsTable, ReferralsTable и SalesTable. Обновлены зависимости в package.json и package-lock.json для поддержки новой функциональности. 2025-06-03 20:56:22 +03:00
Redsandyg
582f5330c8 Добавлены компоненты для управления профилем пользователя, включая редактирование личной информации, смену пароля и настройки уведомлений. Обновлен контекст пользователя для хранения имени и фамилии. Обновлены стили для страницы аккаунта и компонентов. 2025-06-03 20:38:11 +03:00
Redsandyg
0e024b00a1 Добавлено сохранение логина пользователя в куки при авторизации и отображение его в навигации. Обновлен компонент Navigation для отображения первых двух букв логина или имени по умолчанию. 2025-06-03 17:28:15 +03:00
Redsandyg
af0c52dbb6 Добавлен компонент AuthGuard для защиты страниц от неавторизованных пользователей. Обновлен middleware для редиректа на страницу авторизации при отсутствии токена. Обернуты страницы дашборда, аккаунта, статистики и финансов в AuthGuard для проверки авторизации. 2025-06-03 14:58:35 +03:00
Redsandyg
6ab1a42be7 Добавлен middleware для обработки авторизации, страница входа с формой и валидацией, а также стили для страницы авторизации. Обновлены зависимости js-cookie и @types/js-cookie. 2025-06-03 12:07:03 +03:00
48 changed files with 3192 additions and 276 deletions

21
middleware.ts Normal file
View File

@@ -0,0 +1,21 @@
import { NextRequest, NextResponse } from 'next/server';
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Получаем access_token из куков (SSR)
const token = request.cookies.get('access_token');
// Если не на /auth и нет токена, редиректим на /auth
if (pathname !== '/auth' && !token) {
return NextResponse.redirect(new URL('/auth', request.url));
}
// Если на /auth и токен есть, редиректим на главную
if (pathname === '/auth' && token) {
return NextResponse.redirect(new URL('/', request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};

28
package-lock.json generated
View File

@@ -12,6 +12,8 @@
"@mui/material": "^7.1.0",
"@mui/x-data-grid": "^8.5.0",
"@types/react-datepicker": "^6.2.0",
"export-to-csv": "^1.4.0",
"js-cookie": "^3.0.5",
"material-react-table": "^3.2.1",
"next": "15.3.3",
"react": "^19.0.0",
@@ -20,6 +22,7 @@
"recharts": "^2.15.3"
},
"devDependencies": {
"@types/js-cookie": "^3.0.6",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
@@ -1497,6 +1500,13 @@
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/js-cookie": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz",
"integrity": "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "20.17.57",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.57.tgz",
@@ -1942,6 +1952,15 @@
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"license": "MIT"
},
"node_modules/export-to-csv": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/export-to-csv/-/export-to-csv-1.4.0.tgz",
"integrity": "sha512-6CX17Cu+rC2Fi2CyZ4CkgVG3hLl6BFsdAxfXiZkmDFIDY4mRx2y2spdeH6dqPHI9rP+AsHEfGeKz84Uuw7+Pmg==",
"license": "MIT",
"engines": {
"node": "^v12.20.0 || >=v14.13.0"
}
},
"node_modules/fast-equals": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.2.2.tgz",
@@ -2067,6 +2086,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/js-cookie": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
"integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==",
"license": "MIT",
"engines": {
"node": ">=14"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",

View File

@@ -13,6 +13,8 @@
"@mui/material": "^7.1.0",
"@mui/x-data-grid": "^8.5.0",
"@types/react-datepicker": "^6.2.0",
"export-to-csv": "^1.4.0",
"js-cookie": "^3.0.5",
"material-react-table": "^3.2.1",
"next": "15.3.3",
"react": "^19.0.0",
@@ -21,6 +23,7 @@
"recharts": "^2.15.3"
},
"devDependencies": {
"@types/js-cookie": "^3.0.6",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",

View File

@@ -1,50 +1,82 @@
"use client";
import { useEffect, useState } from "react";
import { useState } from "react";
import AuthGuard from "../../components/AuthGuard";
import styles from "../../styles/dashboard.module.css";
import navStyles from "../../styles/navigation.module.css";
import accountStyles from "../../styles/account.module.css";
import {
Person as UserIcon,
Email as MailIcon,
Phone as PhoneIcon,
LocationOn as MapPinIcon,
CalendarMonth as CalendarIcon,
Business as CompanyIcon,
Work as WorkIcon,
Edit as EditIcon,
Save as SaveIcon,
Lock as KeyIcon,
Visibility as EyeIcon,
VisibilityOff as EyeOffIcon,
Notifications as BellIcon,
IntegrationInstructions as IntegrationIcon
} from "@mui/icons-material";
import AccountProfile from "../../components/AccountProfile";
import AccountSecurity from "../../components/AccountSecurity";
import AccountNotifications from "../../components/AccountNotifications";
import AccountAgentTransactionSection from "../../components/AccountAgentTransactionSection";
import TabsNav from "../../components/TabsNav";
import AccountIntegration from "../../components/AccountIntegration";
import Cookies from "js-cookie";
interface AccountData {
id: number;
login: string;
name: string | null;
email: string | null;
balance: number;
}
const initialNotifications = {
emailNotifications: true,
smsNotifications: false,
pushNotifications: true,
weeklyReports: true,
payoutAlerts: true
};
export default function AccountPage() {
const [account, setAccount] = useState<AccountData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showPassword, setShowPassword] = useState(false);
const [activeTab, setActiveTab] = useState("profile");
const [passwordForm, setPasswordForm] = useState({
currentPassword: "",
newPassword: "",
confirmPassword: ""
});
const [notifications, setNotifications] = useState(initialNotifications);
useEffect(() => {
fetch("/api/account")
.then((res) => {
if (!res.ok) throw new Error("Ошибка загрузки данных аккаунта");
return res.json();
})
.then((data) => {
setAccount(data);
setLoading(false);
})
.catch((err) => {
setError(err.message);
setLoading(false);
});
}, []);
if (loading) return <div className={styles.dashboard}>Загрузка...</div>;
if (error) return <div className={styles.dashboard}>Ошибка: {error}</div>;
if (!account) return <div className={styles.dashboard}>Нет данных</div>;
const tabs = [
{ id: "profile", label: "Профиль", icon: <UserIcon fontSize="small" /> },
{ id: "security", label: "Безопасность", icon: <KeyIcon fontSize="small" /> },
{ id: "notifications", label: "Уведомления", icon: <BellIcon fontSize="small" /> },
{ id: "agent-transactions", label: "Транзакции агентов", icon: <WorkIcon fontSize="small" /> },
{ id: "integration", label: "Интеграции", icon: <IntegrationIcon fontSize="small" /> },
];
return (
<div className={styles.dashboard}>
<h1 className={styles.title}>Аккаунт</h1>
<div className={styles.card} style={{ maxWidth: 400, margin: "0 auto" }}>
<div><b>ID:</b> {account.id}</div>
<div><b>Логин:</b> {account.login}</div>
<div><b>Имя:</b> {account.name || "-"}</div>
<div><b>Email:</b> {account.email || "-"}</div>
<div><b>Баланс:</b> {account.balance.toLocaleString("ru-RU", { style: "currency", currency: "RUB" })}</div>
<AuthGuard>
<div className={styles.dashboard}>
<h1 className={styles.title}>Аккаунт</h1>
<div className={accountStyles.accountTabsNav}>
<TabsNav activeTab={activeTab} setActiveTab={setActiveTab} tabs={tabs} />
</div>
{activeTab === "profile" && (
<AccountProfile />
)}
{activeTab === "security" && (
<AccountSecurity />
)}
{activeTab === "notifications" && (
<AccountNotifications notifications={notifications} setNotifications={setNotifications} />
)}
{activeTab === "agent-transactions" && (
<AccountAgentTransactionSection />
)}
{activeTab === "integration" && (
<AccountIntegration />
)}
</div>
</div>
</AuthGuard>
);
}

104
src/app/auth/page.tsx Normal file
View File

@@ -0,0 +1,104 @@
"use client";
import { useState, useEffect } from "react";
import Cookies from "js-cookie";
import styles from "../../styles/auth.module.css";
import { useUser } from "../../components/UserContext";
function hasToken() {
if (typeof document === "undefined") return false;
return Cookies.get('access_token') !== undefined;
}
export default function AuthPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const { setUser } = useUser();
useEffect(() => {
// Удаляем токен при заходе на страницу авторизации
Cookies.remove('access_token', { path: '/' });
}, []);
useEffect(() => {
if (hasToken()) {
window.location.href = "/";
}
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
if (!email || !password) {
setError("Пожалуйста, заполните все поля");
setLoading(false);
return;
}
try {
const res = await fetch("/api/token", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
username: email,
password: password,
}),
});
if (!res.ok) {
const data = await res.json();
setError(data.detail || "Ошибка авторизации");
setLoading(false);
return;
}
const data = await res.json();
// Сохраняем токен и логин в куки на 60 минут через js-cookie
Cookies.set('access_token', data.access_token, { expires: 1/24, path: '/' });
Cookies.set('user_login', email, { expires: 1/24, path: '/' });
setError("");
// Получаем имя и фамилию пользователя
const accRes = await fetch("/api/account", {
headers: { "Authorization": `Bearer ${data.access_token}` }
});
if (accRes.ok) {
const accData = await accRes.json();
setUser({ firstName: accData.firstName || "", surname: accData.surname || "" });
}
window.location.href = "/";
} catch (err) {
setError("Ошибка сети или сервера");
} finally {
setLoading(false);
}
};
return (
<div className={styles.authContainer}>
<h1 className={styles.authTitle}>Вход в систему</h1>
<form className={styles.authForm} onSubmit={handleSubmit}>
<input
className={styles.authInput}
type="text"
placeholder="Логин"
value={email}
onChange={e => setEmail(e.target.value)}
autoComplete="username"
/>
<input
className={styles.authInput}
type="password"
placeholder="Пароль"
value={password}
onChange={e => setPassword(e.target.value)}
autoComplete="current-password"
/>
{error && <div className={styles.authError}>{error}</div>}
<button className={styles.authButton} type="submit" disabled={loading}>
{loading ? "Вход..." : "Войти"}
</button>
</form>
</div>
);
}

View File

@@ -7,6 +7,7 @@ import BillingMetricCards from "../../components/BillingMetricCards";
import PayoutsTransactionsTable from "../../components/PayoutsTransactionsTable";
import BillingStatChart from "../../components/BillingStatChart";
import DateFilters from "../../components/DateFilters";
import AuthGuard from "../../components/AuthGuard";
export default function BillingPage() {
const [payoutForm, setPayoutForm] = useState({
@@ -36,29 +37,31 @@ export default function BillingPage() {
}
return (
<div className={styles.billingPage}>
<h1 className={styles.title}>Финансы</h1>
<BillingMetricCards />
<div className={styles.grid2}>
<BillingStatChart />
<BillingPieChart />
<AuthGuard>
<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>
<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>
</AuthGuard>
);
}

15
src/app/category/page.tsx Normal file
View File

@@ -0,0 +1,15 @@
"use client";
import SaleCategoriesTable from "../../components/SaleCategoriesTable";
import AuthGuard from "../../components/AuthGuard";
import styles from "../../styles/category.module.css";
export default function CategoryPage() {
return (
<AuthGuard>
<div className={styles.categoryPage}>
<h1 className={styles.categoryTitle}>Категории товаров</h1>
<SaleCategoriesTable />
</div>
</AuthGuard>
);
}

View File

@@ -6,7 +6,7 @@
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
--foreground: #312f7f;
}
}
@@ -40,3 +40,8 @@ a {
color-scheme: dark;
}
}
input, select, textarea {
background: #fff;
color: #111827;
}

View File

@@ -2,6 +2,8 @@ import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import Navigation from "../components/Navigation";
import { UserProvider } from "../components/UserContext";
import UserInitializer from "../components/UserInitializer";
const geistSans = Geist({
variable: "--font-geist-sans",
@@ -29,18 +31,21 @@ export default function RootLayout({
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}
</main>
<UserProvider>
<UserInitializer />
<Navigation />
<main
style={{
maxWidth: "1200px",
margin: "0 auto",
padding: "32px 8px",
width: "100%",
boxSizing: "border-box",
}}
>
{children}
</main>
</UserProvider>
</body>
</html>
);

View File

@@ -1,8 +1,8 @@
import mockData from "../data/mockData";
'use client'
import MetricCards from "../components/MetricCards";
import Table from "../components/Table";
import StatCharts from "../components/StatCharts";
import styles from "../styles/dashboard.module.css";
import AuthGuard from "../components/AuthGuard";
function formatCurrency(amount: number) {
return amount.toLocaleString("ru-RU", {
@@ -14,31 +14,33 @@ function formatCurrency(amount: number) {
export default function DashboardPage() {
return (
<div className={styles.dashboard}>
<h1 className={styles.title}>Дашборд</h1>
<MetricCards />
<StatCharts />
{/* <div className={styles.tableBlock}>
<h3 className={styles.tableTitle}>Последние продажи</h3>
<Table
headers={["ID реферала", "Агент", "Сумма продажи", "Комиссия", "Дата", "Статус"]}
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>
)}
/>
</div> */}
</div>
<AuthGuard>
<div className={styles.dashboard}>
<h1 className={styles.title}>Дашборд</h1>
<MetricCards />
<StatCharts />
{/* <div className={styles.tableBlock}>
<h3 className={styles.tableTitle}>Последние продажи</h3>
<Table
headers={["ID реферала", "Агент", "Сумма продажи", "Комиссия", "Дата", "Статус"]}
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>
)}
/>
</div> */}
</div>
</AuthGuard>
);
}

View File

@@ -7,6 +7,8 @@ import SalesTable from "../../components/SalesTable";
import styles from "../../styles/stat.module.css";
import DateInput from "../../components/DateInput";
import DateFilters from "../../components/DateFilters";
import AuthGuard from "../../components/AuthGuard";
import TabsNav from "../../components/TabsNav";
const tabs = [
{ id: "agents", label: "Агенты" },
@@ -32,53 +34,43 @@ export default function StatPage() {
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}
/>
<AuthGuard>
<div className={styles.statPage}>
<div className={styles.headerRow}>
<h1 className={styles.title}>Статистика и аналитика</h1>
{/* <button className={styles.exportBtn}>Экспорт</button> */}
</div>
<TabsNav activeTab={activeTab} setActiveTab={setActiveTab} tabs={tabs} />
<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 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>
</div>
</AuthGuard>
);
}

View File

@@ -0,0 +1,199 @@
import { useState, useEffect } from "react";
import DateFilters from "./DateFilters";
import AccountAgentTransactionTable from "./AccountAgentTransactionTable";
import styles from "../styles/account.module.css";
import Cookies from 'js-cookie';
export default function AccountAgentTransactionSection() {
const [filters, setFilters] = useState({
dateStart: "",
dateEnd: "",
});
const [reloadKey, setReloadKey] = useState(0);
const [autoConfirmEnabled, setAutoConfirmEnabled] = useState(false);
const [showConfirmationUI, setShowConfirmationUI] = useState(false);
useEffect(() => {
const fetchAutoApproveSettings = async () => {
const token = Cookies.get("access_token");
if (!token) {
console.warn("Токен авторизации не найден при загрузке настроек автоподтверждения.");
return;
}
try {
const res = await fetch("/api/account/auto-approve", {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!res.ok) {
console.error('Ошибка при загрузке настроек автоподтверждения:', res.status, res.statusText);
return;
}
const data = await res.json();
setAutoConfirmEnabled(data.auto_approve_transactions);
} catch (error) {
console.error('Ошибка при загрузке настроек автоподтверждения:', error);
}
};
fetchAutoApproveSettings();
}, []);
function handleApply() {
setReloadKey(k => k + 1);
}
function handleClear() {
setFilters(f => ({ ...f, dateStart: '', dateEnd: '' }));
setReloadKey(k => k + 1);
}
const handleToggleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const isChecked = e.target.checked;
if (isChecked) {
setShowConfirmationUI(true);
} else {
updateAutoApproveSettings(false, false);
setAutoConfirmEnabled(false);
setShowConfirmationUI(false);
console.log("Автоматическое подтверждение выключено.");
}
};
const updateAutoApproveSettings = async (auto_approve: boolean, apply_to_current: boolean = false) => {
const token = Cookies.get("access_token");
if (!token) {
console.warn("Токен авторизации не найден при попытке обновить настройки автоподтверждения.");
return;
}
try {
const res = await fetch("/api/account/auto-approve", {
method: "POST",
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
auto_approve: auto_approve,
apply_to_current: apply_to_current,
})
});
if (!res.ok) {
const errorData = await res.json();
console.error('Ошибка при обновлении настроек автоподтверждения:', res.status, res.statusText, errorData);
return false;
}
const responseData = await res.json();
console.log('Настройка автоподтверждения успешно обновлена:', responseData);
if (apply_to_current) {
setReloadKey(k => k + 1);
}
return true;
} catch (error) {
console.error('Ошибка при отправке запроса на обновление настроек автоподтверждения:', error);
return false;
}
};
const handleConfirmYes = async () => {
const success = await updateAutoApproveSettings(true, true);
if (success) {
setAutoConfirmEnabled(true);
setShowConfirmationUI(false);
console.log("Автоматическое подтверждение включено и текущие транзакции будут подтверждены.");
} else {
setAutoConfirmEnabled(false);
setShowConfirmationUI(false);
console.log("Включение автоматического подтверждения не удалось.");
}
};
const handleConfirmNo = async () => {
const success = await updateAutoApproveSettings(true, false);
if (success) {
setAutoConfirmEnabled(true);
setShowConfirmationUI(false);
console.log("Автоматическое подтверждение включено для новых транзакций. Текущие остаются неподтвержденными.");
} else {
setAutoConfirmEnabled(false);
setShowConfirmationUI(false);
console.log("Включение автоматического подтверждения не удалось.");
}
};
const handleConfirmCancel = () => {
setAutoConfirmEnabled(false);
setShowConfirmationUI(false);
console.log("Включение автоматического подтверждения отменено.");
};
return (
<>
<div className={styles.notificationsContainer} style={{ marginBottom: '20px' }}>
<div className={styles.notificationsCard}>
<h3 className={styles.notificationsTitle}>Настройки выплат</h3>
<div className={styles.notificationsList}>
<div className={styles.notificationsItem}>
<div>
<div className={styles.notificationsItemLabel}>Автоматическое подтверждение</div>
</div>
<label className={styles.notificationsSwitchLabel}>
<input
type="checkbox"
checked={autoConfirmEnabled}
onChange={handleToggleChange}
className={styles.notificationsSwitchInput}
disabled={showConfirmationUI}
/>
<span
className={
autoConfirmEnabled
? `${styles.notificationsSwitchTrack} ${styles.notificationsSwitchTrackActive}`
: styles.notificationsSwitchTrack
}
>
<span
className={
autoConfirmEnabled
? `${styles.notificationsSwitchThumb} ${styles.notificationsSwitchThumbActive}`
: styles.notificationsSwitchThumb
}
/>
</span>
</label>
</div>
{showConfirmationUI && (
<div className={styles.confirmationBlock} style={{ marginTop: '15px', padding: '15px', border: '1px solid #ccc', borderRadius: '5px' }}>
<p style={{ marginBottom: '10px' }}>Подтвердить автоматически текущие транзакции?</p>
<div style={{ display: 'flex', gap: '10px' }}>
<button className={styles.primaryButton} onClick={handleConfirmYes}>Да</button>
<button className={styles.secondaryButton} onClick={handleConfirmNo}>Нет</button>
<button className={styles.tertiaryButton} onClick={handleConfirmCancel}>Отмена</button>
</div>
</div>
)}
</div>
</div>
</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}
/>
<AccountAgentTransactionTable filters={filters} reloadKey={reloadKey} setReloadKey={setReloadKey} />
</>
);
}

View File

@@ -0,0 +1,184 @@
import { useEffect, useState, useMemo } from 'react';
import { MaterialReactTable, MRT_ColumnDef, useMaterialReactTable } from 'material-react-table';
import styles from "../styles/stat.module.css";
import { Box, Button } from '@mui/material';
import FileDownloadIcon from '@mui/icons-material/FileDownload';
import { mkConfig, generateCsv, download } from 'export-to-csv';
import Cookies from 'js-cookie';
import PaymentIcon from '@mui/icons-material/Payment';
const STATUS_COLORS: Record<string, string> = {
"done": "#10B981", // Зеленый
"waiting": "#F59E0B", // Желтый
"error": "#EF4444", // Красный
"process": "#3B82F6", // Синий
"reject": "#800080", // Фиолетовый
"new": "#E30B5C", // Малиновый
};
function formatCurrency(amount: number) {
return amount?.toLocaleString("ru-RU", {
style: "currency",
currency: "RUB",
minimumFractionDigits: 0,
}) ?? "";
}
export default function AccountAgentTransactionTable({ filters, reloadKey, setReloadKey }: { filters: { dateStart: string, dateEnd: string }, reloadKey: number, setReloadKey: React.Dispatch<React.SetStateAction<number>> }) {
const [data, setData] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
const token = Cookies.get("access_token");
if (token) {
setIsLoading(true);
const params = new URLSearchParams();
if (filters.dateStart) params.append('date_start', filters.dateStart);
if (filters.dateEnd) params.append('date_end', filters.dateEnd);
params.append('statuses', 'waiting');
fetch(`/api/account/agent-transaction?${params.toString()}`, {
headers: {
'Authorization': `Bearer ${token}`
}
})
.then(res => {
if (!res.ok) {
console.error('Ошибка при загрузке данных транзакций агентов:', res.status, res.statusText);
setData([]);
return null;
}
return res.json();
})
.then(data => {
if (data !== null) {
setData(data);
}
})
.catch(error => {
console.error('Ошибка при загрузке данных транзакций агентов:', error);
setData([]);
})
.finally(() => {
setIsLoading(false);
});
} else {
console.warn('Токен авторизации не найден в куках.');
setData([]);
}
}, [filters.dateStart, filters.dateEnd, reloadKey]);
const columns = useMemo<MRT_ColumnDef<any>[]>(
() => [
{ accessorKey: 'transaction_group', header: 'ID Транзакции' },
{ accessorKey: 'amount', header: 'Сумма', Cell: ({ cell }) => formatCurrency(cell.getValue() as number) },
{ accessorKey: 'agent_name', header: 'Агент' },
{ accessorKey: 'status', header: 'Статус',
Cell: ({ cell }) => {
const status = cell.getValue() as string;
const color = STATUS_COLORS[status] || '#A3A3A3';
return <span style={{ color: color, fontWeight: 600 }}>{status}</span>;
}
},
{ accessorKey: 'create_dttm', header: 'Дата создания', Cell: ({ cell }) => new Date(cell.getValue() as string).toLocaleDateString() },
{ accessorKey: 'update_dttm', header: 'Дата обновления', Cell: ({ cell }) => new Date(cell.getValue() as string).toLocaleDateString() },
],
[]
);
const csvConfig = mkConfig({
fieldSeparator: ',',
decimalSeparator: '.',
useKeysAsHeaders: true,
});
const table = useMaterialReactTable({
columns,
data,
enableRowSelection: true,
state: {
isLoading: isLoading,
},
renderTopToolbarCustomActions: ({ table }) => (
<Box sx={{ display: 'flex', gap: 2, p: 1, flexWrap: 'wrap' }}>
<Button
onClick={async () => {
const selectedRows = table.getSelectedRowModel().rows;
const transactionIdsToApprove = selectedRows.map(row => row.original.transaction_group);
if (transactionIdsToApprove.length === 0) {
console.warn('Выберите транзакции для выплаты.');
return;
}
const token = Cookies.get("access_token");
if (!token) {
console.warn('Токен авторизации не найден. Пожалуйста, войдите снова.');
return;
}
setIsLoading(true);
try {
const response = await fetch('/api/account/approve-transactions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ transaction_ids: transactionIdsToApprove }),
});
const result = await response.json();
if (response.ok) {
console.log(result.msg || `Успешно утверждено ${result.approved_count || 0} транзакций.`);
table.toggleAllRowsSelected(false);
setReloadKey(prev => prev + 1);
} else {
console.error('Ошибка утверждения транзакций:', result);
}
} catch (error) {
console.error('Ошибка запроса утверждения транзакций:', error);
} finally {
setIsLoading(false);
}
}}
startIcon={<PaymentIcon />}
disabled={!table.getSelectedRowModel().rows.length || isLoading}
variant="contained"
color="primary"
>
Выплатить выбранные
</Button>
<Button onClick={() => handleExportData()} startIcon={<FileDownloadIcon />}>
Экспорт всех данных
</Button>
<Button onClick={() => handleExportRows(table.getPrePaginationRowModel().rows)} startIcon={<FileDownloadIcon />} disabled={table.getPrePaginationRowModel().rows.length === 0}>
Экспорт всех строк
</Button>
<Button onClick={() => handleExportRows(table.getRowModel().rows)} startIcon={<FileDownloadIcon />} disabled={table.getRowModel().rows.length === 0}>
Экспорт текущей страницы
</Button>
</Box>
),
muiTableBodyCellProps: { sx: { fontSize: 14 } },
muiTableHeadCellProps: { sx: { fontWeight: 700 } },
initialState: { pagination: { pageSize: 10, pageIndex: 0 } },
});
const handleExportRows = (rows: any[]) => {
const rowData = rows.map((row) => row.original);
const csv = generateCsv(csvConfig)(rowData);
download(csvConfig)(csv);
};
const handleExportData = () => {
const csv = generateCsv(csvConfig)(data);
download(csvConfig)(csv);
};
return <MaterialReactTable table={table} />;
}

View File

@@ -0,0 +1,208 @@
"use client";
import React, { useState, useMemo, useEffect } from "react";
import {
Box,
Button,
Typography,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
IconButton
} from "@mui/material";
import CreateTokenDialog from "./CreateTokenDialog";
import IntegrationTokensTable from "./IntegrationTokensTable";
import styles from "../styles/account.module.css";
import Cookies from "js-cookie";
import { Token } from "../types/tokens";
// Компонент для управления интеграциями и токенами
// interface Token {
// description: string;
// masked_token: string;
// rawToken?: string;
// create_dttm: string;
// use_dttm?: string;
// }
const AccountIntegration = () => {
const [tokens, setTokens] = useState<Token[]>([]);
const [openCreateDialog, setOpenCreateDialog] = useState(false);
const [showTokenCreatedSuccess, setShowTokenCreatedSuccess] = useState(false);
const [createdRawToken, setCreatedRawToken] = useState<string | null>(null);
const [openEditDialog, setOpenEditDialog] = useState(false);
const [editingToken, setEditingToken] = useState<Token | null>(null);
const fetchTokens = async () => {
try {
const token = Cookies.get("access_token");
if (!token) {
console.error("Access token not found");
return;
}
const res = await fetch("/api/account/integration-tokens", {
method: "GET",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`
},
});
if (!res.ok) {
throw new Error(`HTTP error! status: ${res.status}`);
}
const data: Token[] = await res.json();
setTokens(data);
} catch (error) {
console.error("Failed to fetch tokens:", error);
}
};
useEffect(() => {
fetchTokens();
}, []);
const handleOpenCreateDialog = () => {
setOpenCreateDialog(true);
setCreatedRawToken(null);
};
const handleCloseCreateDialog = () => {
setOpenCreateDialog(false);
};
const handleOpenEditDialog = (token: Token) => {
setEditingToken(token);
setOpenEditDialog(true);
};
const handleCloseEditDialog = () => {
setOpenEditDialog(false);
setEditingToken(null);
};
const handleTokenGenerate = async (description: string) => {
try {
const authToken = Cookies.get("access_token");
if (!authToken) {
console.error("Access token not found");
return;
}
const res = await fetch("/api/account/integration-tokens", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${authToken}`
},
body: JSON.stringify({ description: description })
});
if (!res.ok) {
const errorData = await res.json();
throw new Error(errorData.detail || "Ошибка генерации токена");
}
const newTokenData: Token = await res.json();
await fetchTokens();
setCreatedRawToken(newTokenData.rawToken || null);
setShowTokenCreatedSuccess(true);
setTimeout(() => setShowTokenCreatedSuccess(false), 3000);
} catch (error: any) {
console.error("Ошибка при генерации токена:", error.message);
}
};
const handleTokenUpdate = async (id: number, updatedDescription: string) => {
try {
const authToken = Cookies.get("access_token");
if (!authToken) {
console.error("Access token not found");
return;
}
const res = await fetch("/api/account/integration-tokens/update-description", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${authToken}`
},
body: JSON.stringify({ id: id, description: updatedDescription })
});
if (!res.ok) {
const errorData = await res.json();
throw new Error(errorData.detail || "Ошибка обновления описания токена");
}
// После успешного обновления, обновим список токенов
await fetchTokens();
setOpenEditDialog(false);
setEditingToken(null);
} catch (error: any) {
console.error("Ошибка при обновлении токена:", error.message);
}
};
const handleDeleteToken = async (id: number) => {
try {
const authToken = Cookies.get("access_token");
if (!authToken) {
console.error("Access token not found");
return;
}
const res = await fetch(`/api/account/integration-tokens/${id}`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${authToken}`
},
});
if (!res.ok) {
const errorData = await res.json();
throw new Error(errorData.detail || "Ошибка удаления токена");
}
await fetchTokens();
} catch (error: any) {
console.error("Ошибка при удалении токена:", error.message);
}
};
return (
<Box sx={{ padding: 2 }}>
<Typography variant="h5" gutterBottom>
Управление токенами интеграции
</Typography>
<IntegrationTokensTable
tokens={tokens}
onOpenCreateDialog={handleOpenCreateDialog}
onOpenEditDialog={handleOpenEditDialog}
onDeleteToken={handleDeleteToken}
/>
<CreateTokenDialog
open={openCreateDialog}
onClose={handleCloseCreateDialog}
onTokenGenerate={handleTokenGenerate}
generatedToken={createdRawToken}
/>
<CreateTokenDialog
open={openEditDialog}
onClose={handleCloseEditDialog}
isEditMode={true}
initialDescription={editingToken?.description || ""}
editingTokenId={editingToken?.id || 0}
onTokenUpdate={handleTokenUpdate}
/>
</Box>
);
};
export default AccountIntegration;

View File

@@ -0,0 +1,66 @@
import React from "react";
import styles from "../styles/account.module.css";
interface AccountNotificationsProps {
notifications: {
emailNotifications: boolean;
smsNotifications: boolean;
pushNotifications: boolean;
weeklyReports: boolean;
payoutAlerts: boolean;
};
setNotifications: (value: any) => void;
}
const notificationSettings = [
{ key: 'emailNotifications', label: 'Email уведомления', description: 'Получать уведомления на электронную почту' },
{ key: 'smsNotifications', label: 'SMS уведомления', description: 'Получать SMS на указанный номер телефона' },
{ key: 'pushNotifications', label: 'Push уведомления', description: 'Получать push-уведомления в браузере' },
{ key: 'weeklyReports', label: 'Еженедельные отчеты', description: 'Автоматическая отправка еженедельной статистики' },
{ key: 'payoutAlerts', label: 'Уведомления о выплатах', description: 'Получать уведомления о статусе выплат' },
];
const AccountNotifications: React.FC<AccountNotificationsProps> = ({ notifications, setNotifications }) => {
return (
<div className={styles.notificationsContainer}>
<div className={styles.notificationsCard}>
<h3 className={styles.notificationsTitle}>Настройки уведомлений</h3>
<div className={styles.notificationsList}>
{notificationSettings.map(setting => (
<div key={setting.key} className={styles.notificationsItem}>
<div>
<div className={styles.notificationsItemLabel}>{setting.label}</div>
<div className={styles.notificationsItemDescription}>{setting.description}</div>
</div>
<label className={styles.notificationsSwitchLabel}>
<input
type="checkbox"
checked={notifications[setting.key as keyof typeof notifications]}
onChange={e => setNotifications({ ...notifications, [setting.key]: e.target.checked })}
className={styles.notificationsSwitchInput}
/>
<span
className={
notifications[setting.key as keyof typeof notifications]
? `${styles.notificationsSwitchTrack} ${styles.notificationsSwitchTrackActive}`
: styles.notificationsSwitchTrack
}
>
<span
className={
notifications[setting.key as keyof typeof notifications]
? `${styles.notificationsSwitchThumb} ${styles.notificationsSwitchThumbActive}`
: styles.notificationsSwitchThumb
}
/>
</span>
</label>
</div>
))}
</div>
</div>
</div>
);
};
export default AccountNotifications;

View File

@@ -0,0 +1,203 @@
import React, { useEffect, useState } from "react";
import styles from "../styles/account.module.css";
import {
Edit as EditIcon,
Save as SaveIcon,
Mail as MailIcon,
Phone as PhoneIcon,
LocationOn as MapPinIcon,
CalendarMonth as CalendarIcon,
ContentCopy as ContentCopyIcon
} from "@mui/icons-material";
import Cookies from "js-cookie";
import AccountProfileTitle from "./AccountProfileTitle";
import AccountProfileInfo from "./AccountProfileInfo";
import AccountProfileCompany from "./AccountProfileCompany";
import { useUser } from "./UserContext";
interface ProfileData {
firstName: string;
lastName: string;
email: string;
phone: string;
registrationDate: string;
company: string;
commissionRate: number;
companyKey: string;
address?: string;
birthDate?: string;
position?: string;
referralCode?: string;
}
interface AccountProfileProps {
onNameChange?: (user: { firstName: string; surname: string }) => void;
}
const AccountProfile: React.FC<AccountProfileProps> = ({ onNameChange }) => {
const [profileData, setProfileData] = useState<ProfileData | null>(null);
const [isEditing, setIsEditing] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showCopySuccess, setShowCopySuccess] = useState(false);
const [validationError, setValidationError] = useState<string | null>(null);
const { setUser } = useUser();
const formatDate = (dateString: string) => {
if (!dateString) return "";
const date = new Date(dateString);
return date.toLocaleDateString("ru-RU", { year: "numeric", month: "long", day: "numeric" });
};
const handleCopy = () => {
if (!profileData) return;
if (typeof navigator !== "undefined" && navigator.clipboard) {
navigator.clipboard.writeText(profileData.companyKey);
} else {
// fallback для старых браузеров и SSR
const textarea = document.createElement("textarea");
textarea.value = profileData.companyKey;
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
document.body.removeChild(textarea);
}
setShowCopySuccess(true);
setTimeout(() => setShowCopySuccess(false), 1500);
};
const onProfileFieldChange = (field: string, value: string) => {
if (!profileData) return;
setProfileData({ ...profileData, [field]: value });
};
const handleProfileSave = async () => {
if (!profileData) return;
// Валидация
if (!profileData.firstName.trim() || !profileData.lastName.trim() || !profileData.email.trim() || !profileData.phone.trim()) {
setValidationError("Все поля должны быть заполнены");
setTimeout(() => setValidationError(null), 2000);
return;
}
setLoading(true);
setError(null);
setValidationError(null);
try {
const token = Cookies.get("access_token");
const res = await fetch("/api/account/profile", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`
},
body: JSON.stringify({
firstName: profileData.firstName,
surname: profileData.lastName,
email: profileData.email,
phone: profileData.phone
})
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.detail || "Ошибка сохранения профиля");
}
setIsEditing(false);
if (onNameChange) {
onNameChange({ firstName: profileData.firstName, surname: profileData.lastName });
}
setUser({ firstName: profileData.firstName, surname: profileData.lastName });
} catch (e: any) {
setError(e.message || "Ошибка");
} finally {
setLoading(false);
}
};
useEffect(() => {
const fetchProfile = async () => {
setLoading(true);
setError(null);
try {
const token = Cookies.get("access_token");
const res = await fetch("/api/account/profile", {
headers: {
"Authorization": `Bearer ${token}`
}
});
if (!res.ok) throw new Error("Ошибка загрузки профиля");
const data = await res.json();
setProfileData({
firstName: data.firstName || "",
lastName: data.surname || "",
email: data.email || "",
phone: data.phone || "",
registrationDate: data.create_dttm || "",
company: data.company?.name || "",
commissionRate: data.company?.commission || 0,
companyKey: data.company?.key || "",
});
if (onNameChange) {
onNameChange({ firstName: data.firstName || "", surname: data.surname || "" });
}
} catch (e: any) {
setError(e.message || "Ошибка");
} finally {
setLoading(false);
}
};
fetchProfile();
}, [onNameChange]);
if (loading) return <div>Загрузка...</div>;
if (error) return <div style={{ color: 'red' }}>{error}</div>;
if (!profileData) return null;
return (
<div className={styles.accountContainer}>
<div className={styles.card}>
<AccountProfileTitle
firstName={profileData.firstName}
lastName={profileData.lastName}
registrationDate={profileData.registrationDate}
isEditing={isEditing}
onEditClick={() => setIsEditing(!isEditing)}
formatDate={formatDate}
/>
</div>
<AccountProfileInfo
firstName={profileData.firstName}
lastName={profileData.lastName}
email={profileData.email}
phone={profileData.phone}
isEditing={isEditing}
onChange={onProfileFieldChange}
/>
<AccountProfileCompany
company={profileData.company}
companyKey={profileData.companyKey}
commissionRate={profileData.commissionRate}
onCopy={handleCopy}
/>
{isEditing && (
<div className={styles.actionRow}>
<button onClick={() => setIsEditing(false)} className={styles.cancelButton}>Отмена</button>
<button onClick={handleProfileSave} className={styles.saveButton}>
<SaveIcon fontSize="small" />Сохранить
</button>
</div>
)}
{showCopySuccess && (
<div className={styles.copySuccessToast}>
Скопировано!
</div>
)}
{validationError && (
<div className={styles.copySuccessToast} style={{background: '#e53e3e'}}>
{validationError}
</div>
)}
</div>
);
};
export default AccountProfile;

View File

@@ -0,0 +1,42 @@
import React from "react";
import styles from "../styles/account.module.css";
import { ContentCopy as ContentCopyIcon } from "@mui/icons-material";
interface AccountProfileCompanyProps {
company: string;
companyKey: string;
commissionRate: number;
onCopy: () => void;
}
const AccountProfileCompany: React.FC<AccountProfileCompanyProps> = ({
company,
companyKey,
commissionRate,
onCopy
}) => (
<div className={styles.card}>
<h3 className={styles.sectionTitle}>Рабочая информация</h3>
<div className={styles.infoGrid}>
<div>
<label className={styles.label}>Компания</label>
<div className={styles.value}>{company}</div>
</div>
<div>
<label className={styles.label}>Код компании</label>
<div className={styles.companyCodeRow}>
<div className={styles.companyCodeBox}>{companyKey}</div>
<button className={styles.copyButton} onClick={onCopy} title="Скопировать код компании">
<ContentCopyIcon fontSize="small" />
</button>
</div>
</div>
<div>
<label className={styles.label}>Процент комиссии компании</label>
<div className={styles.commissionValue}>{commissionRate}%</div>
</div>
</div>
</div>
);
export default AccountProfileCompany;

View File

@@ -0,0 +1,67 @@
import React from "react";
import styles from "../styles/account.module.css";
import { Mail as MailIcon, Phone as PhoneIcon } from "@mui/icons-material";
interface AccountProfileInfoProps {
firstName: string;
lastName: string;
email: string;
phone: string;
isEditing: boolean;
onChange: (field: string, value: string) => void;
}
const AccountProfileInfo: React.FC<AccountProfileInfoProps> = ({
firstName,
lastName,
email,
phone,
isEditing,
onChange
}) => (
<div className={styles.card}>
<h3 className={styles.sectionTitle}>Личная информация</h3>
<div className={styles.infoGrid}>
<div>
<label className={styles.label}>Имя</label>
{isEditing ? (
<input type="text" value={firstName} onChange={e => onChange("firstName", e.target.value)} className={styles.input} />
) : (
<div className={styles.value}>{firstName}</div>
)}
</div>
<div>
<label className={styles.label}>Фамилия</label>
{isEditing ? (
<input type="text" value={lastName} onChange={e => onChange("lastName", e.target.value)} className={styles.input} />
) : (
<div className={styles.value}>{lastName}</div>
)}
</div>
<div>
<label className={styles.label}>Email</label>
<div className={styles.iconInputRow}>
<MailIcon fontSize="small" style={{ color: "#9ca3af" }} />
{isEditing ? (
<input type="email" value={email} onChange={e => onChange("email", e.target.value)} className={styles.input} />
) : (
<div className={styles.value}>{email}</div>
)}
</div>
</div>
<div>
<label className={styles.label}>Телефон</label>
<div className={styles.iconInputRow}>
<PhoneIcon fontSize="small" style={{ color: "#9ca3af" }} />
{isEditing ? (
<input type="tel" value={phone} onChange={e => onChange("phone", e.target.value)} className={styles.input} />
) : (
<div className={styles.value}>{phone}</div>
)}
</div>
</div>
</div>
</div>
);
export default AccountProfileInfo;

View File

@@ -0,0 +1,38 @@
import React from "react";
import styles from "../styles/account.module.css";
import { Edit as EditIcon } from "@mui/icons-material";
interface AccountProfileTitleProps {
firstName: string;
lastName: string;
registrationDate: string;
isEditing: boolean;
onEditClick: () => void;
formatDate: (dateString: string) => string;
}
const AccountProfileTitle: React.FC<AccountProfileTitleProps> = ({
firstName,
lastName,
registrationDate,
isEditing,
onEditClick,
formatDate
}) => (
<div className={styles.profileHeader}>
<div className={styles.profileAvatar}>{firstName[0]}{lastName[0]}</div>
<div className={styles.profileHeaderMain}>
<h2 className={styles.profileTitle}>{firstName} {lastName}</h2>
<div className={styles.profileSince}>Партнер с {formatDate(registrationDate)}</div>
</div>
<button
onClick={onEditClick}
className={styles.editButton}
>
<EditIcon fontSize="small" />
{isEditing ? "Отмена" : "Редактировать"}
</button>
</div>
);
export default AccountProfileTitle;

View File

@@ -0,0 +1,125 @@
import { useState } from "react";
import {
Lock as KeyIcon,
Visibility as EyeIcon,
VisibilityOff as EyeOffIcon
} from "@mui/icons-material";
import styles from "../styles/account.module.css";
import Cookies from "js-cookie";
const initialPasswordForm = {
currentPassword: "",
newPassword: "",
confirmPassword: ""
};
export default function AccountSecurity() {
const [showPassword, setShowPassword] = useState(false);
const [passwordForm, setPasswordForm] = useState(initialPasswordForm);
const [error, setError] = useState<string | null>(null);
const [validationError, setValidationError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const handlePasswordChange = async () => {
setError(null);
setSuccess(null);
setValidationError(null);
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
setValidationError("Новый пароль и подтверждение не совпадают");
setTimeout(() => setValidationError(null), 2000);
return;
}
if (!passwordForm.currentPassword || !passwordForm.newPassword) {
setValidationError("Все поля должны быть заполнены");
setTimeout(() => setValidationError(null), 2000);
return;
}
try {
const token = Cookies.get("access_token");
const res = await fetch("/api/account/password", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`
},
body: JSON.stringify({
currentPassword: passwordForm.currentPassword,
newPassword: passwordForm.newPassword
})
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.detail || "Ошибка смены пароля");
}
setSuccess("Пароль успешно изменён");
setPasswordForm(initialPasswordForm);
setTimeout(() => setSuccess(null), 2000);
} catch (e: any) {
setError(e.message || "Ошибка");
setTimeout(() => setError(null), 2000);
}
};
return (
<div className={styles.securityContainer}>
<div className={styles.securityCard}>
<h3 className={styles.securitySectionTitle}>Смена пароля</h3>
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<div>
<label className={styles.securityLabel}>Текущий пароль</label>
<div style={{ position: "relative" }}>
<input
type={showPassword ? "text" : "password"}
value={passwordForm.currentPassword}
onChange={e => setPasswordForm({ ...passwordForm, currentPassword: e.target.value })}
className={styles.securityInput}
/>
<button type="button" onClick={() => setShowPassword(!showPassword)} className={styles.securityPasswordToggleBtn}>
{showPassword ? <EyeOffIcon fontSize="small" /> : <EyeIcon fontSize="small" />}
</button>
</div>
</div>
<div>
<label className={styles.securityLabel}>Новый пароль</label>
<input
type="password"
value={passwordForm.newPassword}
onChange={e => setPasswordForm({ ...passwordForm, newPassword: e.target.value })}
className={styles.securityInput}
/>
</div>
<div>
<label className={styles.securityLabel}>Подтвердите новый пароль</label>
<input
type="password"
value={passwordForm.confirmPassword}
onChange={e => setPasswordForm({ ...passwordForm, confirmPassword: e.target.value })}
className={styles.securityInput}
/>
</div>
<button
onClick={handlePasswordChange}
className={styles.securitySaveButton}
>
<KeyIcon fontSize="small" />Изменить пароль
</button>
{validationError && (
<div className={styles.copySuccessToast} style={{background: '#e53e3e'}}>
{validationError}
</div>
)}
{error && (
<div className={styles.copySuccessToast} style={{background: '#e53e3e'}}>
{error}
</div>
)}
{success && (
<div className={styles.copySuccessToast}>
{success}
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,6 +1,7 @@
"use client";
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, LabelList, Label } from "recharts";
import { useEffect, useState } from "react";
import Cookies from 'js-cookie';
interface AgentBarData {
name: string;
@@ -28,13 +29,25 @@ const AgentsBarChart: React.FC = () => {
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetch("/api/dashboard/chart/agent")
const token = Cookies.get("access_token");
if (!token) {
console.warn("Токен авторизации не найден.");
setError("Токен авторизации не найден.");
setLoading(false);
return;
}
fetch("/api/dashboard/chart/agent", {
headers: {
'Authorization': `Bearer ${token}`
}
})
.then((res) => {
if (!res.ok) throw new Error("Ошибка загрузки данных");
return res.json();
})
.then((data) => {
setData(data);
setData(data.items);
setLoading(false);
})
.catch((err) => {

View File

@@ -1,6 +1,10 @@
import { useEffect, useState, useMemo } from 'react';
import { MaterialReactTable, MRT_ColumnDef } from 'material-react-table';
import { MaterialReactTable, MRT_ColumnDef, MRT_Row, useMaterialReactTable } from 'material-react-table';
import styles from "../styles/stat.module.css";
import { Box, Button } from '@mui/material';
import FileDownloadIcon from '@mui/icons-material/FileDownload';
import { mkConfig, generateCsv, download } from 'export-to-csv';
import Cookies from 'js-cookie';
function formatCurrency(amount: number) {
return amount?.toLocaleString("ru-RU", {
@@ -17,9 +21,21 @@ export default function AgentsTable({ filters, reloadKey }: { filters: { dateSta
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()}`)
const token = Cookies.get("access_token");
if (!token) {
console.warn("Токен авторизации не найден.");
setData([]); // Очистить данные, если токен отсутствует
return;
}
fetch(`/api/stat/agents?${params.toString()}`, {
headers: {
'Authorization': `Bearer ${token}`
}
})
.then(res => res.json())
.then(setData)
.then(apiData => setData(apiData.items))
.catch(() => setData([]));
}, [filters.dateStart, filters.dateEnd, reloadKey]);
@@ -36,17 +52,44 @@ export default function AgentsTable({ filters, reloadKey }: { filters: { dateSta
[]
);
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 } }}
/>
);
const csvConfig = mkConfig({
fieldSeparator: ',',
decimalSeparator: '.',
useKeysAsHeaders: true,
});
const table = useMaterialReactTable({
columns,
data,
enableRowSelection: false,
renderTopToolbarCustomActions: ({ table }) => (
<Box sx={{ display: 'flex', gap: 2, p: 1, flexWrap: 'wrap' }}>
<Button onClick={() => handleExportData()} startIcon={<FileDownloadIcon />}>
Экспорт всех данных
</Button>
<Button onClick={() => handleExportRows(table.getPrePaginationRowModel().rows)} startIcon={<FileDownloadIcon />} disabled={table.getPrePaginationRowModel().rows.length === 0}>
Экспорт всех строк
</Button>
<Button onClick={() => handleExportRows(table.getRowModel().rows)} startIcon={<FileDownloadIcon />} disabled={table.getRowModel().rows.length === 0}>
Экспорт текущей страницы
</Button>
</Box>
),
muiTableBodyCellProps: { sx: { fontSize: 14 } },
muiTableHeadCellProps: { sx: { fontWeight: 700 } },
initialState: { pagination: { pageSize: 10, pageIndex: 0 } },
});
const handleExportRows = (rows: MRT_Row<any>[]) => {
const rowData = rows.map((row) => row.original);
const csv = generateCsv(csvConfig)(rowData);
download(csvConfig)(csv);
};
const handleExportData = () => {
const csv = generateCsv(csvConfig)(data);
download(csvConfig)(csv);
};
return <MaterialReactTable table={table} />;
}

View File

@@ -0,0 +1,23 @@
"use client";
import { useEffect, useState, ReactNode } from "react";
import Cookies from "js-cookie";
interface AuthGuardProps {
children: ReactNode;
}
export default function AuthGuard({ children }: AuthGuardProps) {
const [checked, setChecked] = useState(false);
useEffect(() => {
const token = Cookies.get('access_token');
if (!token) {
window.location.href = "/auth";
} else {
setChecked(true);
}
}, []);
if (!checked) return null;
return <>{children}</>;
}

View File

@@ -1,6 +1,7 @@
import MetricCard from "./MetricCard";
import styles from "../styles/billing.module.css";
import React, { useEffect, useState } from "react";
import Cookies from 'js-cookie';
interface BillingCardsData {
cost: number;
@@ -22,7 +23,19 @@ const BillingMetricCards: React.FC = () => {
}
useEffect(() => {
fetch("/api/billing/cards")
const token = Cookies.get("access_token");
if (!token) {
console.warn("Токен авторизации не найден.");
setError("Токен авторизации не найден.");
setLoading(false);
return;
}
fetch("/api/billing/cards", {
headers: {
'Authorization': `Bearer ${token}`
}
})
.then((res) => {
if (!res.ok) throw new Error("Ошибка загрузки данных");
return res.json();

View File

@@ -1,6 +1,10 @@
import { useMemo } from 'react';
import { MaterialReactTable, MRT_ColumnDef } from 'material-react-table';
import { useMemo, useState, useEffect } from 'react';
import { MaterialReactTable, MRT_ColumnDef, MRT_Row, useMaterialReactTable } from 'material-react-table';
import mockData from '../data/mockData';
import { Box, Button } from '@mui/material';
import FileDownloadIcon from '@mui/icons-material/FileDownload';
import { mkConfig, generateCsv, download } from 'export-to-csv';
import Cookies from 'js-cookie';
function formatCurrency(amount: number) {
return amount?.toLocaleString('ru-RU', {
@@ -11,18 +15,67 @@ function formatCurrency(amount: number) {
}
const statusColor: Record<string, string> = {
'Завершена': '#4caf50',
'Ожидается': '#ff9800',
'Ошибка': '#f44336',
'done': '#10B981', // green
'waiting': '#F59E0B', // orange
'error': '#EF4444', // red
'process': '#3B82F6', // blue
'reject': '#800080', // purple
'new': '#E30B5C', // pink
};
export default function BillingPayoutsTable() {
const csvConfig = mkConfig({
fieldSeparator: ',',
decimalSeparator: '.', // This should be '.' for CSV regardless of locale for parsing.
useKeysAsHeaders: true,
});
export default function BillingPayoutsTable({ filters, reloadKey }: { filters: { dateStart: string, dateEnd: string }, reloadKey: number }) {
const [data, setData] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const params = new URLSearchParams();
if (filters.dateStart) params.append('date_start', filters.dateStart);
if (filters.dateEnd) params.append('date_end', filters.dateEnd);
const token = Cookies.get("access_token");
if (!token) {
console.warn("Токен авторизации не найден.");
setError("Токен авторизации не найден.");
setLoading(false);
setData([]);
return;
}
fetch(`/api/billing/payouts/transactions?${params.toString()}`, {
headers: {
'Authorization': `Bearer ${token}`
}
})
.then(res => {
if (!res.ok) {
throw new Error(`Ошибка загрузки данных: ${res.status} ${res.statusText}`);
}
return res.json();
})
.then(apiData => {
setData(apiData.items);
setLoading(false);
})
.catch(err => {
setError(err.message);
setLoading(false);
setData([]);
});
}, [filters.dateStart, filters.dateEnd, reloadKey]);
const columns = useMemo<MRT_ColumnDef<any>[]>(
() => [
{ accessorKey: 'id', header: 'ID' },
{ accessorKey: 'amount', header: 'Сумма',
Cell: ({ cell }) => formatCurrency(cell.getValue() as number) },
{ accessorKey: 'date', header: 'Дата' },
{ accessorKey: 'agent', header: 'Агент' }, // Добавлено поле для имени агента
{ accessorKey: 'status', header: 'Статус',
Cell: ({ cell }) => (
<span style={{ color: statusColor[cell.getValue() as string] || '#333', fontWeight: 600 }}>
@@ -30,22 +83,50 @@ export default function BillingPayoutsTable() {
</span>
)
},
{ accessorKey: 'method', header: 'Способ' },
{ accessorKey: 'create_dttm', header: 'Дата создания',
Cell: ({ cell }) => new Date(cell.getValue() as string).toLocaleDateString("ru-RU") },
{ accessorKey: 'update_dttm', header: 'Дата обновления',
Cell: ({ cell }) => new Date(cell.getValue() as string).toLocaleDateString("ru-RU") },
],
[]
);
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 } }}
/>
);
const table = useMaterialReactTable({
columns,
data,
enableRowSelection: false,
renderTopToolbarCustomActions: ({ table }) => (
<Box sx={{ display: 'flex', gap: 2, p: 1, flexWrap: 'wrap' }}>
<Button onClick={() => handleExportData()} startIcon={<FileDownloadIcon />}>
Экспорт всех данных
</Button>
<Button onClick={() => handleExportRows(table.getPrePaginationRowModel().rows)} startIcon={<FileDownloadIcon />} disabled={table.getPrePaginationRowModel().rows.length === 0}>
Экспорт всех строк
</Button>
<Button onClick={() => handleExportRows(table.getRowModel().rows)} startIcon={<FileDownloadIcon />} disabled={table.getRowModel().rows.length === 0}>
Экспорт текущей страницы
</Button>
</Box>
),
muiTableBodyCellProps: { sx: { fontSize: 14 } },
muiTableHeadCellProps: { sx: { fontWeight: 700 } },
initialState: { pagination: { pageSize: 10, pageIndex: 0 } },
state: { isLoading: loading, showAlertBanner: error !== null, showProgressBars: loading },
renderEmptyRowsFallback: () => (
<div>{error ? `Ошибка: ${error}` : (loading ? 'Загрузка данных...' : 'Данные не найдены.')}</div>
)
});
const handleExportRows = (rows: MRT_Row<any>[]) => {
const rowData = rows.map((row) => row.original);
const csv = generateCsv(csvConfig)(rowData);
download(csvConfig)(csv);
};
const handleExportData = () => {
const csv = generateCsv(csvConfig)(data);
download(csvConfig)(csv);
};
return <MaterialReactTable table={table} />;
}

View File

@@ -2,21 +2,34 @@
import React, { useEffect, useState } from "react";
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from "recharts";
import styles from "../styles/billing.module.css";
import Cookies from 'js-cookie';
const STATUS_COLORS: Record<string, string> = {
"done": "#10B981",
"waiting": "#F59E0B",
"error": "#EF4444",
"process": "#3B82F6",
"reject": "#800080",
"new": "#E30B5C",
};
const BillingPieChart: React.FC = () => {
const [data, setData] = useState<{ name: string; value: number; fill: string }[]>([]);
useEffect(() => {
fetch("api/billing/chart/pie")
const token = Cookies.get("access_token");
if (!token) {
console.warn("Токен авторизации не найден.");
return;
}
fetch("/api/billing/chart/pie", {
headers: {
'Authorization': `Bearer ${token}`
}
})
.then((res) => res.json())
.then((apiData) => {
const mapped = apiData.map((item: { status: string; count: number }) => ({
const mapped = apiData.items.map((item: { status: string; count: number }) => ({
name: item.status,
value: item.count,
fill: STATUS_COLORS[item.status] || "#A3A3A3",

View File

@@ -4,12 +4,15 @@ import { useEffect, useState } from "react";
import styles from "../styles/billing.module.css";
import { TooltipProps } from "recharts";
import { ValueType, NameType } from "recharts/types/component/DefaultTooltipContent";
import Cookies from 'js-cookie';
const statusColors: Record<string, string> = {
done: "#10B981",
process: "#3B82F6",
waiting: "#F59E42",
error: "#EF4444",
reject: "#800080",
new: "#E30B5C",
};
const BillingStatChart: React.FC = () => {
@@ -17,13 +20,23 @@ const BillingStatChart: React.FC = () => {
const [statuses, setStatuses] = useState<string[]>([]);
useEffect(() => {
fetch("/api/billing/chart/stat")
const token = Cookies.get("access_token");
if (!token) {
console.warn("Токен авторизации не найден.");
return;
}
fetch("/api/billing/chart/stat", {
headers: {
'Authorization': `Bearer ${token}`
}
})
.then(res => res.json())
.then((apiData) => {
// Собираем все уникальные даты и статусы
const allDates = new Set<string>();
const allStatuses = new Set<string>();
apiData.forEach((item: any) => {
apiData.items.forEach((item: any) => {
allDates.add(item.date);
allStatuses.add(item.status);
});
@@ -37,7 +50,7 @@ const BillingStatChart: React.FC = () => {
grouped[date][status] = 0;
});
});
apiData.forEach((item: any) => {
apiData.items.forEach((item: any) => {
grouped[item.date][item.status] = item.count;
});
const sorted = sortedDates.map(date => grouped[date]);

View File

@@ -0,0 +1,120 @@
"use client";
import React, { useState, useEffect } from "react";
import {
Box,
Button,
TextField,
Typography,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
} from "@mui/material";
import styles from "../styles/account.module.css";
import { Token } from "../types/tokens";
interface CreateTokenDialogProps {
open: boolean;
onClose: () => void;
onTokenGenerate?: (description: string) => Promise<void>;
isEditMode?: boolean;
initialDescription?: string;
editingTokenId?: number;
onTokenUpdate?: (id: number, description: string) => void;
generatedToken?: string | null;
}
const CreateTokenDialog: React.FC<CreateTokenDialogProps> = ({
open,
onClose,
onTokenGenerate,
isEditMode = false,
initialDescription = "",
onTokenUpdate,
generatedToken,
editingTokenId,
}) => {
const [description, setDescription] = useState(initialDescription);
const [showWarning, setShowWarning] = useState(false);
useEffect(() => {
if (open) {
setDescription(initialDescription);
setShowWarning(false);
}
}, [open, initialDescription]);
const handleGenerateClick = async () => {
if (description.trim() === "") {
setShowWarning(true);
return;
}
await onTokenGenerate?.(description);
};
const handleUpdateClick = () => {
if (description.trim() === "") {
setShowWarning(true);
return;
}
console.log("Attempting to update token. editingTokenId:", editingTokenId, "Description:", description);
if (onTokenUpdate && typeof editingTokenId === 'number') {
onTokenUpdate(editingTokenId, description);
onClose();
} else {
console.error("Cannot update token: onTokenUpdate is missing or editingTokenId is invalid.", {onTokenUpdateExists: !!onTokenUpdate, editingTokenIdValue: editingTokenId});
}
};
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>{isEditMode ? "Редактировать токен" : "Создать новый токен"}</DialogTitle>
<DialogContent>
{showWarning && (
<Typography color="error" variant="body2" sx={{ mb: 2 }}>
Пожалуйста, введите описание токена.
</Typography>
)}
<TextField
autoFocus
margin="dense"
label="Описание токена"
type="text"
fullWidth
variant="outlined"
value={description}
onChange={(e) => setDescription(e.target.value)}
error={showWarning}
helperText={showWarning ? "Описание не может быть пустым" : ""}
/>
{!isEditMode && generatedToken && (
<Box sx={{ mt: 2, p: 2, border: '1px solid #ccc', borderRadius: '4px', backgroundColor: '#f0f0f0'}}>
<Typography variant="subtitle2">Созданный токен:</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: '8px'}}>
<Typography variant="body2" sx={{ wordBreak: 'break-all'}}>
{generatedToken}
</Typography>
</Box>
<Typography variant="caption" color="textSecondary">
Скопируйте этот токен. Он будет виден только сейчас.
</Typography>
</Box>
)}
</DialogContent>
<DialogActions>
{!generatedToken && <Button onClick={onClose}>Отмена</Button>}
{isEditMode ? (
<Button onClick={handleUpdateClick} variant="contained">
Сохранить
</Button>
) : (
<Button onClick={generatedToken ? onClose : handleGenerateClick} variant="contained">
{generatedToken ? "ОК" : "Создать"}
</Button>
)}
</DialogActions>
</Dialog>
);
};
export default CreateTokenDialog;

View File

@@ -1,4 +1,6 @@
import React from "react";
import { CalendarToday } from "@mui/icons-material";
import styles from "../styles/dateinput.module.css";
interface DateInputProps {
label: string;
@@ -11,13 +13,28 @@ interface DateInputProps {
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 style={{ position: "relative" }}>
<input
type="date"
value={value}
onChange={onChange}
min={min}
max={max}
className={`${styles.dateInputHiddenIcon} ${styles.dateInputBase}`}
/>
<CalendarToday
className={styles.dateIcon}
fontSize="small"
onClick={(e) => {
const inputElement = e.currentTarget.previousElementSibling as HTMLInputElement;
if (inputElement && typeof inputElement.showPicker === 'function') {
inputElement.showPicker();
} else {
inputElement.focus();
}
}}
/>
</div>
</div>
);

View File

@@ -0,0 +1,95 @@
"use client";
import React, { useMemo } from "react";
import {
Box,
Button,
Typography,
IconButton
} from "@mui/material";
import { MaterialReactTable, type MRT_ColumnDef, useMaterialReactTable } from "material-react-table";
import { Add as AddIcon, Edit as EditIcon, Delete as DeleteIcon } from "@mui/icons-material";
import { Token } from "../types/tokens";
interface IntegrationTokensTableProps {
tokens: Token[];
onOpenCreateDialog: () => void;
onOpenEditDialog: (token: Token) => void;
onDeleteToken: (id: number) => void;
}
const IntegrationTokensTable: React.FC<IntegrationTokensTableProps> = ({
tokens,
onOpenCreateDialog,
onOpenEditDialog,
onDeleteToken,
}) => {
const columns = useMemo<MRT_ColumnDef<Token>[]>(
() => [
{
accessorKey: "description",
header: "Описание",
size: 200,
},
{
accessorKey: "masked_token",
header: "Токен",
size: 250,
Cell: ({ cell }) => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: '8px'}}>
<Typography variant="body2">
{cell.getValue<string>()}
</Typography>
</Box>
)
},
{
accessorKey: "create_dttm",
header: "Дата создания",
size: 150,
Cell: ({ cell }) => new Date(cell.getValue<string>()).toLocaleString(),
},
{
accessorKey: "use_dttm",
header: "Дата последнего использования",
size: 200,
Cell: ({ cell }) => cell.getValue() ? new Date(cell.getValue<string>()).toLocaleString() : "Никогда",
},
],
[],
);
const table = useMaterialReactTable({
columns,
data: tokens,
enableRowActions: true,
positionActionsColumn: "last",
renderRowActions: ({ row }) => (
<Box sx={{ display: "flex", flexWrap: "nowrap", gap: "8px" }}>
<IconButton
color="primary"
onClick={() => onOpenEditDialog(row.original)}
>
<EditIcon />
</IconButton>
<IconButton
color="error"
onClick={() => onDeleteToken(row.original.id)}
>
<DeleteIcon />
</IconButton>
</Box>
),
renderTopToolbarCustomActions: () => (
<Button
onClick={onOpenCreateDialog}
variant="contained"
>
Создать новый токен
</Button>
),
});
return <MaterialReactTable table={table} />;
};
export default IntegrationTokensTable;

View File

@@ -2,6 +2,7 @@
import MetricCard from "./MetricCard";
import styles from "../styles/dashboard.module.css";
import React, { useEffect, useState } from "react";
import Cookies from 'js-cookie';
function formatCurrency(amount: number) {
return amount.toLocaleString("ru-RU", {
@@ -25,7 +26,19 @@ export default function MetricCards() {
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetch("api/dashboard/cards")
const token = Cookies.get("access_token");
if (!token) {
console.warn("Токен авторизации не найден.");
setError("Токен авторизации не найден.");
setLoading(false);
return;
}
fetch("/api/dashboard/cards", {
headers: {
'Authorization': `Bearer ${token}`
}
})
.then((res) => {
if (!res.ok) throw new Error("Ошибка загрузки данных");
return res.json();

View File

@@ -2,6 +2,11 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import styles from "../styles/navigation.module.css";
import Cookies from "js-cookie";
import { useEffect, useState } from "react";
import { useUser } from "./UserContext";
import TabsNav from "./TabsNav";
import { useRouter } from "next/navigation";
interface NavItem {
id: string;
@@ -13,31 +18,46 @@ const navItems: NavItem[] = [
{ id: "home", label: "Дашборд", href: "/" },
{ id: "stat", label: "Статистика", href: "/stat" },
{ id: "billing", label: "Финансы", href: "/billing" },
{ id: "category", label: "Категории товаров", href: "/category" },
];
const Navigation: React.FC = () => {
const pathname = usePathname();
const [login, setLogin] = useState<string>("");
const { firstName, surname } = useUser();
const router = useRouter();
useEffect(() => {
if (typeof document !== "undefined") {
const userLogin = Cookies.get('user_login');
if (userLogin) setLogin(userLogin);
}
}, []);
const handleNavigationChange = (tabId: string) => {
if (tabId === "home") {
router.push("/");
} else if (tabId === "stat") {
router.push("/stat");
} else if (tabId === "billing") {
router.push("/billing");
}
};
if (pathname === "/auth") return null;
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>
<TabsNav activeTab={pathname} setActiveTab={handleNavigationChange} tabs={navItems} />
<div className={styles.profile}>
<div className={styles.avatar}>ПП</div>
<span className={styles.profileName}>Партнер RE:Premium</span>
<Link href="/account" style={{ display: 'flex', alignItems: 'center', gap: 12, textDecoration: 'none' }}>
<div className={styles.avatar}>
{firstName && surname ? `${firstName[0]}${surname[0]}`.toUpperCase() : (login ? login.slice(0, 2).toUpperCase() : "ПП")}
</div>
<span className={styles.profileName}>
{firstName && surname ? `${firstName} ${surname}` : (login ? login : "Партнер RE:Premium")}
</span>
</Link>
</div>
</nav>
);

View File

@@ -1,6 +1,10 @@
import { useEffect, useState, useMemo } from "react";
import { MaterialReactTable, MRT_ColumnDef } from "material-react-table";
import { MaterialReactTable, MRT_ColumnDef, MRT_Row, useMaterialReactTable } from "material-react-table";
import styles from "../styles/billing.module.css";
import { Box, Button } from '@mui/material';
import FileDownloadIcon from '@mui/icons-material/FileDownload';
import { mkConfig, generateCsv, download } from 'export-to-csv';
import Cookies from 'js-cookie';
function formatCurrency(amount: number) {
@@ -16,24 +20,61 @@ const statusColor: Record<string, string> = {
'waiting': '#ff9800',
'process': '#2196f3',
'error': '#f44336',
'reject': '#800080',
'new': '#E30B5C',
};
export default function PayoutsTransactionsTable({ filters, reloadKey }: { filters: { dateStart: string, dateEnd: string }, reloadKey: number }) {
const [data, setData] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
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([]));
const token = Cookies.get("access_token");
if (!token) {
console.warn("Токен авторизации не найден.");
setError("Токен авторизации не найден.");
setLoading(false);
setData([]);
return;
}
setLoading(true);
setError(null);
fetch(`/api/billing/payouts/transactions?${params.toString()}`, {
headers: {
'Authorization': `Bearer ${token}`
}
})
.then(res => {
if (!res.ok) {
throw new Error(`Ошибка загрузки данных: ${res.status} ${res.statusText}`);
}
return res.json();
})
.then(apiData => {
setData(apiData.items);
setLoading(false);
})
.catch(err => {
setError(err.message);
setLoading(false);
setData([]);
});
}, [filters.dateStart, filters.dateEnd, reloadKey]);
useEffect(() => {
}, [data]);
const columns = useMemo<MRT_ColumnDef<any>[]>(
() => [
{ accessorKey: 'id', header: 'ID' },
{ accessorKey: 'sum', header: 'Сумма',
{ accessorKey: 'amount', header: 'Сумма',
Cell: ({ cell }) => formatCurrency(cell.getValue() as number) },
{ accessorKey: 'agent', header: 'Агент' },
{ accessorKey: 'status', header: 'Статус',
@@ -51,22 +92,53 @@ export default function PayoutsTransactionsTable({ filters, reloadKey }: { filte
[]
);
const csvConfig = mkConfig({
fieldSeparator: ',',
decimalSeparator: '.',
useKeysAsHeaders: true,
});
const table = useMaterialReactTable({
columns,
data,
enableRowSelection: false,
renderTopToolbarCustomActions: ({ table }) => (
<Box sx={{ display: 'flex', gap: 2, p: 1, flexWrap: 'wrap' }}>
<Button onClick={() => handleExportData()} startIcon={<FileDownloadIcon />}>
Экспорт всех данных
</Button>
<Button onClick={() => handleExportRows(table.getPrePaginationRowModel().rows)} startIcon={<FileDownloadIcon />} disabled={table.getPrePaginationRowModel().rows.length === 0}>
Экспорт всех строк
</Button>
<Button onClick={() => handleExportRows(table.getRowModel().rows)} startIcon={<FileDownloadIcon />} disabled={table.getRowModel().rows.length === 0}>
Экспорт текущей страницы
</Button>
</Box>
),
muiTableBodyCellProps: { sx: { fontSize: 14 } },
muiTableHeadCellProps: { sx: { fontWeight: 700 } },
initialState: { pagination: { pageSize: 10, pageIndex: 0 } },
state: { isLoading: loading, showAlertBanner: error !== null, showProgressBars: loading },
renderEmptyRowsFallback: () => (
<div>{error ? `Ошибка: ${error}` : (loading ? 'Загрузка данных...' : 'Данные не найдены.')}</div>
)
});
const handleExportRows = (rows: MRT_Row<any>[]) => {
const rowData = rows.map((row) => row.original);
const csv = generateCsv(csvConfig)(rowData);
download(csvConfig)(csv);
};
const handleExportData = () => {
const csv = generateCsv(csvConfig)(data);
download(csvConfig)(csv);
};
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 } }}
/>
<h3 className={styles.tableTitle}>История выплат</h3>
<MaterialReactTable table={table} />
</div>
);
}

View File

@@ -1,6 +1,10 @@
import { useEffect, useState, useMemo } from 'react';
import { MaterialReactTable, MRT_ColumnDef } from 'material-react-table';
import { MaterialReactTable, MRT_ColumnDef, MRT_Row, useMaterialReactTable } from 'material-react-table';
import styles from "../styles/stat.module.css";
import { Box, Button } from '@mui/material';
import FileDownloadIcon from '@mui/icons-material/FileDownload';
import { mkConfig, generateCsv, download } from 'export-to-csv';
import Cookies from 'js-cookie';
function formatCurrency(amount: number) {
return amount?.toLocaleString("ru-RU", {
@@ -17,15 +21,28 @@ export default function ReferralsTable({ filters, reloadKey }: { filters: { date
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()}`)
const token = Cookies.get("access_token");
if (!token) {
console.warn("Токен авторизации не найден.");
setData([]); // Очистить данные, если токен отсутствует
return;
}
fetch(`/api/stat/referrals?${params.toString()}`, {
headers: {
'Authorization': `Bearer ${token}`
}
})
.then(res => res.json())
.then(setData)
.then(apiData => setData(apiData.items))
.catch(() => setData([]));
}, [filters.dateStart, filters.dateEnd, reloadKey]);
const columns = useMemo<MRT_ColumnDef<any>[]>(
() => [
{ accessorKey: 'ref', header: 'Ref' },
{ accessorKey: 'promocode', header: 'Промокод' },
{ accessorKey: 'agent', header: 'Агент' },
{ accessorKey: 'description', header: 'Описание' },
{ accessorKey: 'salesCount', header: 'Кол-во продаж' },
@@ -35,17 +52,44 @@ export default function ReferralsTable({ filters, reloadKey }: { filters: { date
[]
);
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 } }}
/>
);
const csvConfig = mkConfig({
fieldSeparator: ',',
decimalSeparator: '.',
useKeysAsHeaders: true,
});
const table = useMaterialReactTable({
columns,
data,
enableRowSelection: false,
renderTopToolbarCustomActions: ({ table }) => (
<Box sx={{ display: 'flex', gap: 2, p: 1, flexWrap: 'wrap' }}>
<Button onClick={() => handleExportData()} startIcon={<FileDownloadIcon />}>
Экспорт всех данных
</Button>
<Button onClick={() => handleExportRows(table.getPrePaginationRowModel().rows)} startIcon={<FileDownloadIcon />} disabled={table.getPrePaginationRowModel().rows.length === 0}>
Экспорт всех строк
</Button>
<Button onClick={() => handleExportRows(table.getRowModel().rows)} startIcon={<FileDownloadIcon />} disabled={table.getRowModel().rows.length === 0}>
Экспорт текущей страницы
</Button>
</Box>
),
muiTableBodyCellProps: { sx: { fontSize: 14 } },
muiTableHeadCellProps: { sx: { fontWeight: 700 } },
initialState: { pagination: { pageSize: 10, pageIndex: 0 } },
});
const handleExportRows = (rows: MRT_Row<any>[]) => {
const rowData = rows.map((row) => row.original);
const csv = generateCsv(csvConfig)(rowData);
download(csvConfig)(csv);
};
const handleExportData = () => {
const csv = generateCsv(csvConfig)(data);
download(csvConfig)(csv);
};
return <MaterialReactTable table={table} />;
}

View File

@@ -1,6 +1,7 @@
"use client";
import { ResponsiveContainer, LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Label } from "recharts";
import { useEffect, useState } from "react";
import Cookies from 'js-cookie';
const formatCurrency = (amount: number) => {
return amount.toLocaleString("ru-RU", {
@@ -22,13 +23,25 @@ const RevenueChart: React.FC = () => {
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetch("/api/dashboard/chart/total")
const token = Cookies.get("access_token");
if (!token) {
console.warn("Токен авторизации не найден.");
setError("Токен авторизации не найден.");
setLoading(false);
return;
}
fetch("/api/dashboard/chart/total", {
headers: {
'Authorization': `Bearer ${token}`
}
})
.then((res) => {
if (!res.ok) throw new Error("Ошибка загрузки данных");
return res.json();
})
.then((data) => {
setData(data);
setData(data.items);
setLoading(false);
})
.catch((err) => {

View File

@@ -0,0 +1,239 @@
"use client";
import React, { useMemo, useEffect, useState } from "react";
import {
Box,
Button,
IconButton,
Tooltip
} from "@mui/material";
import { MaterialReactTable, type MRT_ColumnDef, useMaterialReactTable, type MRT_Row, type MRT_TableOptions } from "material-react-table";
import { Add as AddIcon, Edit as EditIcon, Save as SaveIcon, Cancel as CancelIcon } from "@mui/icons-material";
import Cookies from "js-cookie";
interface SaleCategory {
id: number;
category: string;
description?: string;
perc: number;
create_dttm: string;
update_dttm: string;
}
const SaleCategoriesTable: React.FC = () => {
const [categories, setCategories] = useState<SaleCategory[]>([]);
const [validationErrors, setValidationErrors] = useState<Record<string, string | undefined>>({});
const [creationKey, setCreationKey] = useState(0);
const fetchCategories = async () => {
try {
const token = Cookies.get("access_token");
if (!token) return;
const res = await fetch("/api/category", {
method: "GET",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`
},
});
if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`);
const data: SaleCategory[] = await res.json();
setCategories(data);
} catch (error) {
console.error("Failed to fetch categories:", error);
}
};
useEffect(() => {
fetchCategories();
}, []);
// CREATE
const handleCreateCategory: MRT_TableOptions<SaleCategory>["onCreatingRowSave"] = async ({ values, table }) => {
const token = Cookies.get("access_token");
if (!token) return;
try {
const res = await fetch("/api/category", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`
},
body: JSON.stringify({
category: values.category,
description: values.description,
perc: Number(values.perc)
})
});
if (!res.ok) {
if (res.status === 400) {
const data = await res.json();
setValidationErrors((prev) => ({ ...prev, category: data.detail || "Категория с таким именем уже существует" }));
return;
}
throw new Error("Ошибка создания категории");
}
await fetchCategories();
table.setCreatingRow(null);
} catch (e) {
alert("Ошибка создания категории");
}
};
// UPDATE
const handleSaveCategory: MRT_TableOptions<SaleCategory>["onEditingRowSave"] = async ({ values, table }) => {
const token = Cookies.get("access_token");
if (!token) return;
try {
const res = await fetch("/api/category", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`
},
body: JSON.stringify({
id: Number(values.id),
category: values.category,
description: values.description,
perc: Number(values.perc)
})
});
if (!res.ok) {
if (res.status === 400) {
const data = await res.json();
setValidationErrors((prev) => ({ ...prev, category: data.detail || "Категория с таким именем уже существует" }));
return;
}
throw new Error("Ошибка обновления категории");
}
await fetchCategories();
table.setEditingRow(null);
} catch (e) {
alert("Ошибка обновления категории");
}
};
// Валидация (минимальная)
const validateCategory = (values: Partial<SaleCategory>) => {
return {
category: !values.category ? "Обязательное поле" : undefined,
perc: values.perc === undefined || isNaN(Number(values.perc)) ? "Введите число" : undefined
};
};
const columns = useMemo<MRT_ColumnDef<SaleCategory>[]>(
() => [
{
accessorKey: "id",
header: "",
size: 1,
enableEditing: false,
enableHiding: false,
enableColumnActions: false,
enableSorting: false,
enableColumnFilter: false,
Cell: () => null,
Edit: () => null,
muiTableBodyCellProps: { sx: { display: 'none' } },
muiTableHeadCellProps: { sx: { display: 'none' } },
},
{
accessorKey: "category",
header: "Категория",
size: 200,
muiEditTextFieldProps: {
required: true,
error: !!validationErrors?.category,
helperText: validationErrors?.category,
onFocus: () => setValidationErrors((prev) => ({ ...prev, category: undefined }))
}
},
{
accessorKey: "description",
header: "Описание",
size: 250,
muiEditTextFieldProps: {
onFocus: () => undefined
}
},
{
accessorKey: "perc",
header: "% начисления",
size: 100,
muiEditTextFieldProps: {
required: true,
type: "number",
error: !!validationErrors?.perc,
helperText: validationErrors?.perc,
onFocus: () => setValidationErrors((prev) => ({ ...prev, perc: undefined }))
}
},
{
accessorKey: "create_dttm",
header: "Создана",
size: 160,
enableEditing: false,
Cell: ({ cell }) => new Date(cell.getValue<string>()).toLocaleString(),
},
{
accessorKey: "update_dttm",
header: "Обновлена",
size: 160,
enableEditing: false,
Cell: ({ cell }) => new Date(cell.getValue<string>()).toLocaleString(),
},
],
[validationErrors],
);
const table = useMaterialReactTable({
columns,
data: categories,
createDisplayMode: "row",
editDisplayMode: "row",
enableEditing: true,
enableRowActions: false,
getRowId: (row) => String(row.id),
onCreatingRowSave: async (props) => {
const errors = validateCategory(props.values);
setValidationErrors(errors);
if (Object.values(errors).some(Boolean)) return;
await handleCreateCategory(props);
},
onEditingRowSave: async (props) => {
const errors = validateCategory(props.values);
setValidationErrors(errors);
if (Object.values(errors).some(Boolean)) return;
props.values.id = Number(props.values.id);
await handleSaveCategory(props);
},
onCreatingRowCancel: () => setValidationErrors({}),
onEditingRowCancel: () => setValidationErrors({}),
renderTopToolbarCustomActions: ({ table }) => (
<Button
onClick={() => {
setValidationErrors({});
table.setEditingRow(null);
setCreationKey((k) => k + 1);
table.setCreatingRow(true);
}}
variant="contained"
startIcon={<AddIcon />}
>
Создать категорию
</Button>
),
muiTableBodyCellProps: { sx: { fontSize: 14 } },
muiTableHeadCellProps: { sx: { fontWeight: 700 } },
initialState: {
pagination: { pageSize: 10, pageIndex: 0 },
},
});
useEffect(() => {
setValidationErrors({});
}, [table.getState().editingRow]);
return <MaterialReactTable key={creationKey} table={table} />;
};
export default SaleCategoriesTable;

View File

@@ -1,6 +1,10 @@
import { useEffect, useState, useMemo } from 'react';
import { MaterialReactTable, MRT_ColumnDef } from 'material-react-table';
import { MaterialReactTable, MRT_ColumnDef, MRT_Row, useMaterialReactTable } from 'material-react-table';
import styles from "../styles/stat.module.css";
import { Box, Button } from '@mui/material';
import FileDownloadIcon from '@mui/icons-material/FileDownload';
import { mkConfig, generateCsv, download } from 'export-to-csv';
import Cookies from 'js-cookie';
function formatCurrency(amount: number) {
return amount?.toLocaleString("ru-RU", {
@@ -17,9 +21,21 @@ export default function SalesTable({ filters, reloadKey }: { filters: { dateStar
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()}`)
const token = Cookies.get("access_token");
if (!token) {
console.warn("Токен авторизации не найден.");
setData([]); // Очистить данные, если токен отсутствует
return;
}
fetch(`/api/stat/sales?${params.toString()}`, {
headers: {
'Authorization': `Bearer ${token}`
}
})
.then(res => res.json())
.then(setData)
.then(apiData => setData(apiData.items))
.catch(() => setData([]));
}, [filters.dateStart, filters.dateEnd, reloadKey]);
@@ -36,17 +52,44 @@ export default function SalesTable({ filters, reloadKey }: { filters: { dateStar
[]
);
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 } }}
/>
);
const csvConfig = mkConfig({
fieldSeparator: ',',
decimalSeparator: '.',
useKeysAsHeaders: true,
});
const table = useMaterialReactTable({
columns,
data,
enableRowSelection: false,
renderTopToolbarCustomActions: ({ table }) => (
<Box sx={{ display: 'flex', gap: 2, p: 1, flexWrap: 'wrap' }}>
<Button onClick={() => handleExportData()} startIcon={<FileDownloadIcon />}>
Экспорт всех данных
</Button>
<Button onClick={() => handleExportRows(table.getPrePaginationRowModel().rows)} startIcon={<FileDownloadIcon />} disabled={table.getPrePaginationRowModel().rows.length === 0}>
Экспорт всех строк
</Button>
<Button onClick={() => handleExportRows(table.getRowModel().rows)} startIcon={<FileDownloadIcon />} disabled={table.getRowModel().rows.length === 0}>
Экспорт текущей страницы
</Button>
</Box>
),
muiTableBodyCellProps: { sx: { fontSize: 14 } },
muiTableHeadCellProps: { sx: { fontWeight: 700 } },
initialState: { pagination: { pageSize: 10, pageIndex: 0 } },
});
const handleExportRows = (rows: MRT_Row<any>[]) => {
const rowData = rows.map((row) => row.original);
const csv = generateCsv(csvConfig)(rowData);
download(csvConfig)(csv);
};
const handleExportData = () => {
const csv = generateCsv(csvConfig)(data);
download(csvConfig)(csv);
};
return <MaterialReactTable table={table} />;
}

View File

@@ -0,0 +1,71 @@
import React from "react";
import Link from "next/link";
import defaultTabsStyles from "../styles/tabs.module.css";
interface TabItem {
id: string;
label: string;
href?: string;
icon?: React.ReactNode;
}
interface TabsNavProps {
activeTab: string;
setActiveTab: (tabId: string) => void;
tabs: TabItem[];
tabStyles?: {
nav: string;
button: string;
buttonActive: string;
};
}
const TabsNav: React.FC<TabsNavProps> = ({
activeTab,
setActiveTab,
tabs,
tabStyles,
}) => {
const currentTabStyles = tabStyles || {
nav: defaultTabsStyles.tabsNav,
button: defaultTabsStyles.tabButton,
buttonActive: defaultTabsStyles.tabButtonActive,
};
return (
<nav className={currentTabStyles.nav}>
{tabs.map((tab) => (
tab.href ? (
<Link
key={tab.id}
href={tab.href}
onClick={() => setActiveTab(tab.id)}
className={
activeTab === tab.href
? `${currentTabStyles.button} ${currentTabStyles.buttonActive}`
: currentTabStyles.button
}
>
{tab.icon}
{tab.label}
</Link>
) : (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={
activeTab === tab.id
? `${currentTabStyles.button} ${currentTabStyles.buttonActive}`
: currentTabStyles.button
}
>
{tab.icon}
{tab.label}
</button>
)
))}
</nav>
);
};
export default TabsNav;

View File

@@ -0,0 +1,35 @@
"use client";
import React, { createContext, useContext, useState } from "react";
interface UserContextType {
firstName: string;
surname: string;
setUser: (user: { firstName: string; surname: string }) => void;
}
const UserContext = createContext<UserContextType | undefined>(undefined);
export const UserProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [firstName, setFirstName] = useState("");
const [surname, setSurname] = useState("");
const setUser = (user: { firstName: string; surname: string }) => {
setFirstName(user.firstName);
setSurname(user.surname);
};
return (
<UserContext.Provider value={{ firstName, surname, setUser }}>
{children}
</UserContext.Provider>
);
};
export const useUser = () => {
const context = useContext(UserContext);
if (!context) {
throw new Error("useUser должен использоваться внутри UserProvider");
}
return context;
};

View File

@@ -0,0 +1,27 @@
"use client";
import { useEffect } from "react";
import { useUser } from "./UserContext";
import Cookies from "js-cookie";
export default function UserInitializer() {
const { firstName, surname, setUser } = useUser();
useEffect(() => {
if (!firstName && !surname) {
const token = Cookies.get("access_token");
if (token) {
fetch("/api/account", {
headers: { "Authorization": `Bearer ${token}` }
})
.then(res => res.ok ? res.json() : null)
.then(data => {
if (data && data.firstName && data.surname) {
setUser({ firstName: data.firstName, surname: data.surname });
}
});
}
}
}, [firstName, surname, setUser]);
return null;
}

View File

@@ -0,0 +1,410 @@
/* Стили для страницы аккаунта и профиля */
.accountContainer {
max-width: 700px;
margin: 0 auto;
width: 100%;
}
.card {
background: #fff;
border-radius: 12px;
border: 1px solid #e5e7eb;
padding: 24px;
margin-bottom: 24px;
}
.profileHeader {
display: flex;
align-items: center;
gap: 24px;
}
.profileAvatar {
width: 64px;
height: 64px;
font-size: 28px;
background: #2563eb;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
color: #fff;
}
.profileHeaderMain {
flex: 1;
}
.profileTitle {
font-size: 24px;
font-weight: 700;
margin: 0;
}
.profileSince {
color: #9ca3af;
font-size: 14px;
}
.editButton {
background: #2563eb;
color: #fff;
border: none;
border-radius: 6px;
padding: 8px 20px;
font-weight: 500;
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.sectionTitle {
font-size: 18px;
font-weight: 600;
margin-bottom: 16px;
}
.infoGrid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
}
.label {
color: #6b7280;
font-size: 14px;
}
.input,
.input[type="text"],
.input[type="email"],
.input[type="tel"] {
width: 100%;
padding: 8px;
border: 1px solid #e5e7eb;
border-radius: 6px;
margin-top: 4px;
box-sizing: border-box;
}
.value {
font-size: 16px;
}
.companyCodeRow {
display: flex;
align-items: center;
gap: 8px;
}
.companyCodeBox {
font-family: monospace;
background: #f3f4f6;
padding: 6px 12px;
border-radius: 6px;
border: 1px solid #e5e7eb;
}
.copyButton {
background: #2563eb;
color: #fff;
border: none;
border-radius: 6px;
padding: 6px 12px;
cursor: pointer;
font-size: 14px;
}
.commissionValue {
font-size: 18px;
font-weight: 600;
}
.actionRow {
display: flex;
justify-content: flex-end;
gap: 16px;
}
.cancelButton {
background: #f3f4f6;
color: #374151;
border: none;
border-radius: 6px;
padding: 8px 20px;
font-weight: 500;
cursor: pointer;
}
.saveButton {
background: #2563eb;
color: #fff;
border: none;
border-radius: 6px;
padding: 8px 20px;
font-weight: 500;
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.tabsNav {
display: flex;
gap: 32px;
}
.tabsButton {
background: none;
border: none;
border-bottom: 2px solid transparent;
color: #6b7280;
font-weight: 500;
font-size: 16px;
padding: 8px 0;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
}
.tabsButtonActive {
border-bottom: 2px solid #2563eb;
color: #2563eb;
font-weight: 600;
}
.dashboardTitle {
font-size: 32px;
font-weight: 700;
margin-bottom: 24px;
}
.copySuccessToast {
position: fixed;
top: 24px;
right: 24px;
background: #2563eb;
color: #fff;
padding: 12px 24px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(16,30,54,0.12);
z-index: 1000;
font-weight: 500;
}
.iconInputRow {
display: flex;
align-items: center;
gap: 8px;
}
.passwordToggleBtn {
position: absolute;
right: 8px;
top: 12px;
background: none;
border: none;
cursor: pointer;
padding: 0;
display: flex;
align-items: center;
}
/* --- Стили для AccountSecurity --- */
.securityContainer {
max-width: 500px;
margin: 0 auto;
width: 100%;
}
.securityCard {
background: #fff;
border-radius: 12px;
border: 1px solid #e5e7eb;
padding: 24px;
margin-bottom: 24px;
}
.securitySectionTitle {
font-size: 18px;
font-weight: 600;
margin-bottom: 16px;
}
.securityLabel {
color: #6b7280;
font-size: 14px;
}
.securityInput {
width: 100%;
padding: 8px;
border: 1px solid #e5e7eb;
border-radius: 6px;
margin-top: 4px;
box-sizing: border-box;
}
.securitySaveButton {
background: #2563eb;
color: #fff;
border: none;
border-radius: 6px;
padding: 8px 20px;
font-weight: 500;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
cursor: pointer;
margin-top: 8px;
}
.securityPasswordToggleBtn {
position: absolute;
right: 8px;
top: 12px;
background: none;
border: none;
cursor: pointer;
padding: 0;
display: flex;
align-items: center;
color: #6b7280;
}
/* --- Стили для вкладки уведомлений --- */
.notificationsContainer {
max-width: 500px;
margin: 0 auto;
width: 100%;
}
.notificationsCard {
background: #fff;
border-radius: 12px;
border: 1px solid #e5e7eb;
padding: 24px;
}
.notificationsTitle {
font-size: 18px;
font-weight: 600;
margin-bottom: 16px;
}
.notificationsList {
display: flex;
flex-direction: column;
gap: 16px;
}
.notificationsItem {
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #f3f4f6;
padding: 8px 0;
}
.notificationsItemLabel {
font-weight: 500;
}
.notificationsItemDescription {
color: #9ca3af;
font-size: 14px;
}
.notificationsSwitchLabel {
display: inline-flex;
align-items: center;
cursor: pointer;
}
.notificationsSwitchInput {
width: 0;
height: 0;
opacity: 0;
position: absolute;
}
.notificationsSwitchTrack {
width: 44px;
height: 24px;
border-radius: 12px;
position: relative;
display: inline-block;
transition: background 0.2s;
background: #e5e7eb;
}
.notificationsSwitchTrackActive {
background: #2563eb;
}
.notificationsSwitchThumb {
position: absolute;
top: 2px;
width: 20px;
height: 20px;
background: #fff;
border-radius: 50%;
box-shadow: 0 1px 2px rgba(16,30,54,0.04);
transition: left 0.2s;
left: 2px;
}
.notificationsSwitchThumbActive {
left: 22px;
}
/* Стили для табов и навигации из page.tsx */
/* .accountTabsNav {
display: flex;
gap: 32px;
border-bottom: 1px solid #e5e7eb;
margin-bottom: 24px;
}
.accountTabsButton {
background: none;
border: none;
border-bottom: 2px solid transparent;
color: #6b7280;
font-weight: 500;
font-size: 16px;
padding: 8px 0;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
}
.accountTabsButtonActive {
border-bottom: 2px solid #2563eb;
color: #2563eb;
font-weight: 600;
} */
/* Стили для кнопок подтверждения */
.primaryButton {
background: #2563eb;
color: #fff;
border: none;
border-radius: 6px;
padding: 8px 20px;
font-weight: 500;
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}

View File

@@ -0,0 +1,55 @@
.authContainer {
max-width: 400px;
margin: 60px auto;
background: #fff;
border-radius: 16px;
box-shadow: 0 2px 16px rgba(0,0,0,0.07);
padding: 32px 32px 24px 32px;
display: flex;
flex-direction: column;
align-items: center;
}
.authTitle {
font-size: 2rem;
font-weight: 700;
margin-bottom: 24px;
color: #222;
}
.authForm {
width: 100%;
display: flex;
flex-direction: column;
gap: 18px;
}
.authInput {
padding: 12px 16px;
border: 1px solid #e0e0e0;
border-radius: 8px;
font-size: 1rem;
outline: none;
transition: border 0.2s;
}
.authInput:focus {
border: 1.5px solid #0070f3;
}
.authButton {
background: #0070f3;
color: #fff;
border: none;
border-radius: 8px;
padding: 12px 0;
font-size: 1.1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.authButton:hover {
background: #005bb5;
}
.authError {
color: #e53935;
font-size: 0.95rem;
margin-top: -10px;
margin-bottom: 10px;
text-align: center;
}

View File

@@ -0,0 +1,19 @@
.categoryPage {
display: flex;
flex-direction: column;
gap: 32px;
}
.categoryTitle {
font-size: 28px;
font-weight: bold;
color: #111827;
margin-bottom: 8px;
}
@media (max-width: 600px) {
.categoryPage {
gap: 16px;
}
.categoryTitle {
font-size: 16px;
}
}

View File

@@ -0,0 +1,37 @@
.dateInputHiddenIcon {
-webkit-appearance: none; /* Safari и Chrome */
-moz-appearance: none; /* Firefox */
appearance: none; /* Стандартное свойство */
}
.dateInputHiddenIcon::-webkit-calendar-picker-indicator {
display: none; /* Скрыть для WebKit-браузеров */
}
.dateInputHiddenIcon::-moz-calendar-picker-indicator {
display: none; /* Скрыть для Mozilla-браузеров */
}
.dateInputHiddenIcon::-ms-calendar-picker-indicator {
display: none; /* Скрыть для Internet Explorer/Edge */
}
.dateInputBase {
width: 100%;
padding: 8px;
border: 1px solid #e5e7eb;
border-radius: 6px;
margin-top: 4px;
box-sizing: border-box;
padding-right: 40px;
cursor: pointer;
}
.dateIcon {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
cursor: pointer;
color: #6b7280;
}

View File

@@ -13,7 +13,7 @@
font-weight: bold;
color: #2563eb;
}
.links {
/* .links {
display: flex;
gap: 32px;
}
@@ -34,7 +34,7 @@
color: #2563eb;
border-bottom: 2px solid #2563eb;
font-weight: 600;
}
} */
.profile {
display: flex;
align-items: center;

View File

@@ -27,11 +27,14 @@
.exportBtn:hover {
background: #1d4ed8;
}
.tabs {
/* Tabs */
/* .tabs {
display: flex;
gap: 32px;
border-bottom: 1.5px solid #e5e7eb;
margin-bottom: 24px;
}
.tab {
background: none;
border: none;
@@ -43,17 +46,19 @@
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);

View File

@@ -0,0 +1,31 @@
.tabsNav {
display: flex;
gap: 32px;
border-bottom: 1px solid #e5e7eb;
margin-bottom: 24px;
}
.tabButton {
background: none;
border: none;
border-bottom: 2px solid transparent;
color: #6b7280;
font-weight: 500;
font-size: 16px;
padding: 8px 0;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
}
.tabButton:hover {
color: #2563eb;
border-bottom: 2px solid #dbeafe;
}
.tabButtonActive {
border-bottom: 2px solid #2563eb;
color: #2563eb;
font-weight: 600;
}

9
src/types/tokens.ts Normal file
View File

@@ -0,0 +1,9 @@
export interface Token {
id: number;
description: string;
masked_token: string;
rawToken?: string;
create_dttm: string;
use_dttm?: string;
}