Compare commits

..

8 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
27 changed files with 1020 additions and 120 deletions

View File

@@ -17,12 +17,16 @@ import {
Lock as KeyIcon, Lock as KeyIcon,
Visibility as EyeIcon, Visibility as EyeIcon,
VisibilityOff as EyeOffIcon, VisibilityOff as EyeOffIcon,
Notifications as BellIcon Notifications as BellIcon,
IntegrationInstructions as IntegrationIcon
} from "@mui/icons-material"; } from "@mui/icons-material";
import AccountProfile from "../../components/AccountProfile"; import AccountProfile from "../../components/AccountProfile";
import AccountSecurity from "../../components/AccountSecurity"; import AccountSecurity from "../../components/AccountSecurity";
import AccountNotifications from "../../components/AccountNotifications"; import AccountNotifications from "../../components/AccountNotifications";
import AccountAgentTransactionSection from "../../components/AccountAgentTransactionSection"; import AccountAgentTransactionSection from "../../components/AccountAgentTransactionSection";
import TabsNav from "../../components/TabsNav";
import AccountIntegration from "../../components/AccountIntegration";
import Cookies from "js-cookie";
const initialNotifications = { const initialNotifications = {
emailNotifications: true, emailNotifications: true,
@@ -47,6 +51,7 @@ export default function AccountPage() {
{ id: "security", label: "Безопасность", icon: <KeyIcon fontSize="small" /> }, { id: "security", label: "Безопасность", icon: <KeyIcon fontSize="small" /> },
{ id: "notifications", label: "Уведомления", icon: <BellIcon fontSize="small" /> }, { id: "notifications", label: "Уведомления", icon: <BellIcon fontSize="small" /> },
{ id: "agent-transactions", label: "Транзакции агентов", icon: <WorkIcon fontSize="small" /> }, { id: "agent-transactions", label: "Транзакции агентов", icon: <WorkIcon fontSize="small" /> },
{ id: "integration", label: "Интеграции", icon: <IntegrationIcon fontSize="small" /> },
]; ];
return ( return (
@@ -54,22 +59,7 @@ export default function AccountPage() {
<div className={styles.dashboard}> <div className={styles.dashboard}>
<h1 className={styles.title}>Аккаунт</h1> <h1 className={styles.title}>Аккаунт</h1>
<div className={accountStyles.accountTabsNav}> <div className={accountStyles.accountTabsNav}>
<nav className={accountStyles.accountTabsNav}> <TabsNav activeTab={activeTab} setActiveTab={setActiveTab} tabs={tabs} />
{tabs.map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={
activeTab === tab.id
? `${accountStyles.accountTabsButton} ${accountStyles.accountTabsButtonActive}`
: accountStyles.accountTabsButton
}
>
{tab.icon}
{tab.label}
</button>
))}
</nav>
</div> </div>
{activeTab === "profile" && ( {activeTab === "profile" && (
<AccountProfile /> <AccountProfile />
@@ -83,6 +73,9 @@ export default function AccountPage() {
{activeTab === "agent-transactions" && ( {activeTab === "agent-transactions" && (
<AccountAgentTransactionSection /> <AccountAgentTransactionSection />
)} )}
{activeTab === "integration" && (
<AccountIntegration />
)}
</div> </div>
</AuthGuard> </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

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

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

@@ -32,7 +32,7 @@ const AccountProfileCompany: React.FC<AccountProfileCompanyProps> = ({
</div> </div>
</div> </div>
<div> <div>
<label className={styles.label}>Процент комиссии</label> <label className={styles.label}>Процент комиссии компании</label>
<div className={styles.commissionValue}>{commissionRate}%</div> <div className={styles.commissionValue}>{commissionRate}%</div>
</div> </div>
</div> </div>

View File

@@ -47,7 +47,7 @@ const AgentsBarChart: React.FC = () => {
return res.json(); return res.json();
}) })
.then((data) => { .then((data) => {
setData(data); setData(data.items);
setLoading(false); setLoading(false);
}) })
.catch((err) => { .catch((err) => {

View File

@@ -35,7 +35,7 @@ export default function AgentsTable({ filters, reloadKey }: { filters: { dateSta
} }
}) })
.then(res => res.json()) .then(res => res.json())
.then(setData) .then(apiData => setData(apiData.items))
.catch(() => setData([])); .catch(() => setData([]));
}, [filters.dateStart, filters.dateEnd, reloadKey]); }, [filters.dateStart, filters.dateEnd, reloadKey]);

View File

@@ -1,9 +1,10 @@
import { useMemo } from 'react'; import { useMemo, useState, useEffect } from 'react';
import { MaterialReactTable, MRT_ColumnDef, MRT_Row, useMaterialReactTable } from 'material-react-table'; import { MaterialReactTable, MRT_ColumnDef, MRT_Row, useMaterialReactTable } from 'material-react-table';
import mockData from '../data/mockData'; import mockData from '../data/mockData';
import { Box, Button } from '@mui/material'; import { Box, Button } from '@mui/material';
import FileDownloadIcon from '@mui/icons-material/FileDownload'; import FileDownloadIcon from '@mui/icons-material/FileDownload';
import { mkConfig, generateCsv, download } from 'export-to-csv'; import { mkConfig, generateCsv, download } from 'export-to-csv';
import Cookies from 'js-cookie';
function formatCurrency(amount: number) { function formatCurrency(amount: number) {
return amount?.toLocaleString('ru-RU', { return amount?.toLocaleString('ru-RU', {
@@ -14,24 +15,67 @@ function formatCurrency(amount: number) {
} }
const statusColor: Record<string, string> = { const statusColor: Record<string, string> = {
'Завершена': '#4caf50', 'done': '#10B981', // green
'Ожидается': '#ff9800', 'waiting': '#F59E0B', // orange
'Ошибка': '#f44336', 'error': '#EF4444', // red
'process': '#3B82F6', // blue
'reject': '#800080', // purple
'new': '#E30B5C', // pink
}; };
const csvConfig = mkConfig({ const csvConfig = mkConfig({
fieldSeparator: ',', fieldSeparator: ',',
decimalSeparator: '.', decimalSeparator: '.', // This should be '.' for CSV regardless of locale for parsing.
useKeysAsHeaders: true, useKeysAsHeaders: true,
}); });
export default function BillingPayoutsTable() { 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>[]>( const columns = useMemo<MRT_ColumnDef<any>[]>(
() => [ () => [
{ accessorKey: 'id', header: 'ID' }, { accessorKey: 'id', header: 'ID' },
{ accessorKey: 'amount', header: 'Сумма', { accessorKey: 'amount', header: 'Сумма',
Cell: ({ cell }) => formatCurrency(cell.getValue() as number) }, Cell: ({ cell }) => formatCurrency(cell.getValue() as number) },
{ accessorKey: 'date', header: 'Дата' }, { accessorKey: 'agent', header: 'Агент' }, // Добавлено поле для имени агента
{ accessorKey: 'status', header: 'Статус', { accessorKey: 'status', header: 'Статус',
Cell: ({ cell }) => ( Cell: ({ cell }) => (
<span style={{ color: statusColor[cell.getValue() as string] || '#333', fontWeight: 600 }}> <span style={{ color: statusColor[cell.getValue() as string] || '#333', fontWeight: 600 }}>
@@ -39,14 +83,17 @@ export default function BillingPayoutsTable() {
</span> </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") },
], ],
[] []
); );
const table = useMaterialReactTable({ const table = useMaterialReactTable({
columns, columns,
data: mockData.payouts, data,
enableRowSelection: false, enableRowSelection: false,
renderTopToolbarCustomActions: ({ table }) => ( renderTopToolbarCustomActions: ({ table }) => (
<Box sx={{ display: 'flex', gap: 2, p: 1, flexWrap: 'wrap' }}> <Box sx={{ display: 'flex', gap: 2, p: 1, flexWrap: 'wrap' }}>
@@ -64,6 +111,10 @@ export default function BillingPayoutsTable() {
muiTableBodyCellProps: { sx: { fontSize: 14 } }, muiTableBodyCellProps: { sx: { fontSize: 14 } },
muiTableHeadCellProps: { sx: { fontWeight: 700 } }, muiTableHeadCellProps: { sx: { fontWeight: 700 } },
initialState: { pagination: { pageSize: 10, pageIndex: 0 } }, 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 handleExportRows = (rows: MRT_Row<any>[]) => {
@@ -73,7 +124,7 @@ export default function BillingPayoutsTable() {
}; };
const handleExportData = () => { const handleExportData = () => {
const csv = generateCsv(csvConfig)(mockData.payouts); const csv = generateCsv(csvConfig)(data);
download(csvConfig)(csv); download(csvConfig)(csv);
}; };

View File

@@ -29,7 +29,7 @@ const BillingPieChart: React.FC = () => {
}) })
.then((res) => res.json()) .then((res) => res.json())
.then((apiData) => { .then((apiData) => {
const mapped = apiData.map((item: { status: string; count: number }) => ({ const mapped = apiData.items.map((item: { status: string; count: number }) => ({
name: item.status, name: item.status,
value: item.count, value: item.count,
fill: STATUS_COLORS[item.status] || "#A3A3A3", fill: STATUS_COLORS[item.status] || "#A3A3A3",

View File

@@ -36,7 +36,7 @@ const BillingStatChart: React.FC = () => {
// Собираем все уникальные даты и статусы // Собираем все уникальные даты и статусы
const allDates = new Set<string>(); const allDates = new Set<string>();
const allStatuses = new Set<string>(); const allStatuses = new Set<string>();
apiData.forEach((item: any) => { apiData.items.forEach((item: any) => {
allDates.add(item.date); allDates.add(item.date);
allStatuses.add(item.status); allStatuses.add(item.status);
}); });
@@ -50,7 +50,7 @@ const BillingStatChart: React.FC = () => {
grouped[date][status] = 0; grouped[date][status] = 0;
}); });
}); });
apiData.forEach((item: any) => { apiData.items.forEach((item: any) => {
grouped[item.date][item.status] = item.count; grouped[item.date][item.status] = item.count;
}); });
const sorted = sortedDates.map(date => grouped[date]); 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 React from "react";
import { CalendarToday } from "@mui/icons-material";
import styles from "../styles/dateinput.module.css";
interface DateInputProps { interface DateInputProps {
label: string; label: string;
@@ -11,13 +13,28 @@ interface DateInputProps {
const DateInput: React.FC<DateInputProps> = ({ label, value, onChange, min, max }) => ( const DateInput: React.FC<DateInputProps> = ({ label, value, onChange, min, max }) => (
<div> <div>
<label>{label}</label> <label>{label}</label>
<div style={{ position: "relative" }}>
<input <input
type="date" type="date"
value={value} value={value}
onChange={onChange} onChange={onChange}
min={min} min={min}
max={max} 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> </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

@@ -5,6 +5,8 @@ import styles from "../styles/navigation.module.css";
import Cookies from "js-cookie"; import Cookies from "js-cookie";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useUser } from "./UserContext"; import { useUser } from "./UserContext";
import TabsNav from "./TabsNav";
import { useRouter } from "next/navigation";
interface NavItem { interface NavItem {
id: string; id: string;
@@ -16,12 +18,14 @@ const navItems: NavItem[] = [
{ id: "home", label: "Дашборд", href: "/" }, { id: "home", label: "Дашборд", href: "/" },
{ id: "stat", label: "Статистика", href: "/stat" }, { id: "stat", label: "Статистика", href: "/stat" },
{ id: "billing", label: "Финансы", href: "/billing" }, { id: "billing", label: "Финансы", href: "/billing" },
{ id: "category", label: "Категории товаров", href: "/category" },
]; ];
const Navigation: React.FC = () => { const Navigation: React.FC = () => {
const pathname = usePathname(); const pathname = usePathname();
const [login, setLogin] = useState<string>(""); const [login, setLogin] = useState<string>("");
const { firstName, surname } = useUser(); const { firstName, surname } = useUser();
const router = useRouter();
useEffect(() => { useEffect(() => {
if (typeof document !== "undefined") { if (typeof document !== "undefined") {
@@ -30,25 +34,21 @@ const Navigation: React.FC = () => {
} }
}, []); }, []);
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; if (pathname === "/auth") return null;
return ( return (
<nav className={styles.nav}> <nav className={styles.nav}>
<div className={styles.logo}>RE:Premium Partner</div> <div className={styles.logo}>RE:Premium Partner</div>
<div className={styles.links}> <TabsNav activeTab={pathname} setActiveTab={handleNavigationChange} tabs={navItems} />
{navItems.map((item) => (
<Link
key={item.id}
href={item.href}
className={
pathname === item.href
? styles.active
: styles.link
}
>
{item.label}
</Link>
))}
</div>
<div className={styles.profile}> <div className={styles.profile}>
<Link href="/account" style={{ display: 'flex', alignItems: 'center', gap: 12, textDecoration: 'none' }}> <Link href="/account" style={{ display: 'flex', alignItems: 'center', gap: 12, textDecoration: 'none' }}>
<div className={styles.avatar}> <div className={styles.avatar}>

View File

@@ -26,6 +26,9 @@ const statusColor: Record<string, string> = {
export default function PayoutsTransactionsTable({ filters, reloadKey }: { filters: { dateStart: string, dateEnd: string }, reloadKey: number }) { export default function PayoutsTransactionsTable({ filters, reloadKey }: { filters: { dateStart: string, dateEnd: string }, reloadKey: number }) {
const [data, setData] = useState<any[]>([]); const [data, setData] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (filters.dateStart) params.append('date_start', filters.dateStart); if (filters.dateStart) params.append('date_start', filters.dateStart);
@@ -34,20 +37,40 @@ export default function PayoutsTransactionsTable({ filters, reloadKey }: { filte
const token = Cookies.get("access_token"); const token = Cookies.get("access_token");
if (!token) { if (!token) {
console.warn("Токен авторизации не найден."); console.warn("Токен авторизации не найден.");
setData([]); // Очистить данные, если токен отсутствует setError("Токен авторизации не найден.");
setLoading(false);
setData([]);
return; return;
} }
setLoading(true);
setError(null);
fetch(`/api/billing/payouts/transactions?${params.toString()}`, { fetch(`/api/billing/payouts/transactions?${params.toString()}`, {
headers: { headers: {
'Authorization': `Bearer ${token}` 'Authorization': `Bearer ${token}`
} }
}) })
.then(res => res.json()) .then(res => {
.then(setData) if (!res.ok) {
.catch(() => setData([])); 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]); }, [filters.dateStart, filters.dateEnd, reloadKey]);
useEffect(() => {
}, [data]);
const columns = useMemo<MRT_ColumnDef<any>[]>( const columns = useMemo<MRT_ColumnDef<any>[]>(
() => [ () => [
{ accessorKey: 'id', header: 'ID' }, { accessorKey: 'id', header: 'ID' },
@@ -95,6 +118,10 @@ export default function PayoutsTransactionsTable({ filters, reloadKey }: { filte
muiTableBodyCellProps: { sx: { fontSize: 14 } }, muiTableBodyCellProps: { sx: { fontSize: 14 } },
muiTableHeadCellProps: { sx: { fontWeight: 700 } }, muiTableHeadCellProps: { sx: { fontWeight: 700 } },
initialState: { pagination: { pageSize: 10, pageIndex: 0 } }, 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 handleExportRows = (rows: MRT_Row<any>[]) => {

View File

@@ -35,13 +35,14 @@ export default function ReferralsTable({ filters, reloadKey }: { filters: { date
} }
}) })
.then(res => res.json()) .then(res => res.json())
.then(setData) .then(apiData => setData(apiData.items))
.catch(() => setData([])); .catch(() => setData([]));
}, [filters.dateStart, filters.dateEnd, reloadKey]); }, [filters.dateStart, filters.dateEnd, reloadKey]);
const columns = useMemo<MRT_ColumnDef<any>[]>( const columns = useMemo<MRT_ColumnDef<any>[]>(
() => [ () => [
{ accessorKey: 'ref', header: 'Ref' }, { accessorKey: 'ref', header: 'Ref' },
{ accessorKey: 'promocode', header: 'Промокод' },
{ accessorKey: 'agent', header: 'Агент' }, { accessorKey: 'agent', header: 'Агент' },
{ accessorKey: 'description', header: 'Описание' }, { accessorKey: 'description', header: 'Описание' },
{ accessorKey: 'salesCount', header: 'Кол-во продаж' }, { accessorKey: 'salesCount', header: 'Кол-во продаж' },

View File

@@ -41,7 +41,7 @@ const RevenueChart: React.FC = () => {
return res.json(); return res.json();
}) })
.then((data) => { .then((data) => {
setData(data); setData(data.items);
setLoading(false); setLoading(false);
}) })
.catch((err) => { .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

@@ -35,7 +35,7 @@ export default function SalesTable({ filters, reloadKey }: { filters: { dateStar
} }
}) })
.then(res => res.json()) .then(res => res.json())
.then(setData) .then(apiData => setData(apiData.items))
.catch(() => setData([])); .catch(() => setData([]));
}, [filters.dateStart, filters.dateEnd, reloadKey]); }, [filters.dateStart, filters.dateEnd, reloadKey]);

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

@@ -258,6 +258,7 @@
font-weight: 500; font-weight: 500;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center;
gap: 8px; gap: 8px;
cursor: pointer; cursor: pointer;
margin-top: 8px; margin-top: 8px;
@@ -273,6 +274,7 @@
padding: 0; padding: 0;
display: flex; display: flex;
align-items: center; align-items: center;
color: #6b7280;
} }
/* --- Стили для вкладки уведомлений --- */ /* --- Стили для вкладки уведомлений --- */
@@ -362,9 +364,11 @@
left: 22px; left: 22px;
} }
/* --- Стили для табов и навигации из page.tsx --- */
.accountTabsNav {
/* Стили для табов и навигации из page.tsx */
/* .accountTabsNav {
display: flex; display: flex;
gap: 32px; gap: 32px;
border-bottom: 1px solid #e5e7eb; border-bottom: 1px solid #e5e7eb;
@@ -389,51 +393,18 @@
border-bottom: 2px solid #2563eb; border-bottom: 2px solid #2563eb;
color: #2563eb; color: #2563eb;
font-weight: 600; font-weight: 600;
} } */
/* Стили для кнопок подтверждения */ /* Стили для кнопок подтверждения */
.primaryButton { .primaryButton {
background-color: #2563eb; /* Синий */ background: #2563eb;
color: #ffffff; color: #fff;
border: none; border: none;
border-radius: 6px; border-radius: 6px;
padding: 8px 16px; padding: 8px 20px;
font-weight: 500; font-weight: 500;
display: flex;
align-items: center;
gap: 8px;
cursor: pointer; cursor: pointer;
transition: background-color 0.2s ease;
} }
.primaryButton:hover {
background-color: #1d4ed8;
}
.secondaryButton {
background-color: #f3f4f6; /* Серый */
color: #374151;
border: 1px solid #d1d5db;
border-radius: 6px;
padding: 8px 16px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s ease;
}
.secondaryButton:hover {
background-color: #e5e7eb;
}
.tertiaryButton {
background: none;
color: #6b7280; /* Темно-серый */
border: none;
padding: 8px 16px;
font-weight: 500;
cursor: pointer;
transition: color 0.2s ease;
}
.tertiaryButton:hover {
color: #4b5563;
}
/* ... можно добавить другие стили по необходимости ... */

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

View File

@@ -27,11 +27,14 @@
.exportBtn:hover { .exportBtn:hover {
background: #1d4ed8; background: #1d4ed8;
} }
.tabs { /* Tabs */
/* .tabs {
display: flex; display: flex;
gap: 32px; gap: 32px;
border-bottom: 1.5px solid #e5e7eb; border-bottom: 1.5px solid #e5e7eb;
margin-bottom: 24px;
} }
.tab { .tab {
background: none; background: none;
border: none; border: none;
@@ -43,17 +46,19 @@
cursor: pointer; cursor: pointer;
transition: color 0.2s, border 0.2s; transition: color 0.2s, border 0.2s;
} }
.tab:hover { .tab:hover {
color: #2563eb; color: #2563eb;
border-bottom: 2px solid #dbeafe; border-bottom: 2px solid #dbeafe;
} }
.activeTab { .activeTab {
color: #2563eb; color: #2563eb;
border: none; border: none;
border-bottom: 2px solid #2563eb; border-bottom: 2px solid #2563eb;
font-weight: 600; font-weight: 600;
background: none; background: none;
} } */
.filters { .filters {
display: grid; display: grid;
grid-template-columns: repeat(1, 1fr); 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;
}