Compare commits

...

6 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
11 changed files with 716 additions and 2 deletions

View File

@@ -17,13 +17,16 @@ import {
Lock as KeyIcon,
Visibility as EyeIcon,
VisibilityOff as EyeOffIcon,
Notifications as BellIcon
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";
const initialNotifications = {
emailNotifications: true,
@@ -48,6 +51,7 @@ export default function AccountPage() {
{ 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 (
@@ -69,6 +73,9 @@ export default function AccountPage() {
{activeTab === "agent-transactions" && (
<AccountAgentTransactionSection />
)}
{activeTab === "integration" && (
<AccountIntegration />
)}
</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

@@ -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>
<label className={styles.label}>Процент комиссии</label>
<label className={styles.label}>Процент комиссии компании</label>
<div className={styles.commissionValue}>{commissionRate}%</div>
</div>
</div>

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

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

@@ -18,6 +18,7 @@ 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 = () => {

View File

@@ -42,6 +42,7 @@ export default function ReferralsTable({ filters, reloadKey }: { filters: { date
const columns = useMemo<MRT_ColumnDef<any>[]>(
() => [
{ accessorKey: 'ref', header: 'Ref' },
{ accessorKey: 'promocode', header: 'Промокод' },
{ accessorKey: 'agent', header: 'Агент' },
{ accessorKey: 'description', header: 'Описание' },
{ accessorKey: 'salesCount', header: 'Кол-во продаж' },

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

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

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