mirror of
https://github.com/serty2005/rmser.git
synced 2026-02-04 19:02:33 -06:00
добавил пользователей для сервера и роли
добавил инвайт-ссылки с ролью оператор для сервера добавил супер-админку для смены владельцев добавил уведомления о смене ролей на серверах добавил модалку для фото прям в черновике добавил UI для редактирования прав
This commit is contained in:
7588
rmser-view/project_context.md
Normal file
7588
rmser-view/project_context.md
Normal file
File diff suppressed because it is too large
Load Diff
205
rmser-view/src/components/settings/TeamList.tsx
Normal file
205
rmser-view/src/components/settings/TeamList.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import React from "react";
|
||||
import {
|
||||
List,
|
||||
Avatar,
|
||||
Tag,
|
||||
Button,
|
||||
Select,
|
||||
Popconfirm,
|
||||
message,
|
||||
Spin,
|
||||
Alert,
|
||||
Typography,
|
||||
} from "antd";
|
||||
import { DeleteOutlined, UserOutlined } from "@ant-design/icons";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "../../services/api";
|
||||
import type { ServerUser, UserRole } from "../../services/types";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface Props {
|
||||
currentUserRole: UserRole;
|
||||
}
|
||||
|
||||
export const TeamList: React.FC<Props> = ({ currentUserRole }) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Запрос списка пользователей
|
||||
const {
|
||||
data: users,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery({
|
||||
queryKey: ["serverUsers"],
|
||||
queryFn: api.getUsers,
|
||||
});
|
||||
|
||||
// Мутация изменения роли
|
||||
const updateRoleMutation = useMutation({
|
||||
mutationFn: ({ userId, newRole }: { userId: string; newRole: UserRole }) =>
|
||||
api.updateUserRole(userId, newRole),
|
||||
onSuccess: () => {
|
||||
message.success("Роль пользователя обновлена");
|
||||
queryClient.invalidateQueries({ queryKey: ["serverUsers"] });
|
||||
},
|
||||
onError: () => {
|
||||
message.error("Не удалось изменить роль");
|
||||
},
|
||||
});
|
||||
|
||||
// Мутация удаления пользователя
|
||||
const removeUserMutation = useMutation({
|
||||
mutationFn: (userId: string) => api.removeUser(userId),
|
||||
onSuccess: () => {
|
||||
message.success("Пользователь удален из команды");
|
||||
queryClient.invalidateQueries({ queryKey: ["serverUsers"] });
|
||||
},
|
||||
onError: () => {
|
||||
message.error("Не удалось удалить пользователя");
|
||||
},
|
||||
});
|
||||
|
||||
// Хелперы для UI
|
||||
const getRoleColor = (role: UserRole) => {
|
||||
switch (role) {
|
||||
case "OWNER":
|
||||
return "gold";
|
||||
case "ADMIN":
|
||||
return "blue";
|
||||
case "OPERATOR":
|
||||
return "default";
|
||||
default:
|
||||
return "default";
|
||||
}
|
||||
};
|
||||
|
||||
const getRoleName = (role: UserRole) => {
|
||||
switch (role) {
|
||||
case "OWNER":
|
||||
return "Владелец";
|
||||
case "ADMIN":
|
||||
return "Админ";
|
||||
case "OPERATOR":
|
||||
return "Оператор";
|
||||
default:
|
||||
return role;
|
||||
}
|
||||
};
|
||||
|
||||
// Проверка прав на удаление
|
||||
const canDelete = (targetUser: ServerUser) => {
|
||||
if (targetUser.is_me) return false; // Себя удалить нельзя
|
||||
if (targetUser.role === "OWNER") return false; // Владельца удалить нельзя
|
||||
if (currentUserRole === "ADMIN" && targetUser.role === "ADMIN")
|
||||
return false; // Админ не может удалить админа
|
||||
return true;
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div style={{ textAlign: "center", padding: 20 }}>
|
||||
<Spin />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <Alert type="error" message="Не удалось загрузить список команды" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message="Приглашение сотрудников"
|
||||
description="Чтобы добавить сотрудника, отправьте ему ссылку-приглашение. Ссылку можно сгенерировать в Telegram-боте в меню «Управление сервером»."
|
||||
/>
|
||||
|
||||
<List
|
||||
itemLayout="horizontal"
|
||||
dataSource={users || []}
|
||||
renderItem={(user) => (
|
||||
<List.Item
|
||||
actions={[
|
||||
// Селектор роли (только для Владельца и не для себя)
|
||||
currentUserRole === "OWNER" && !user.is_me ? (
|
||||
<Select
|
||||
key="role-select"
|
||||
defaultValue={user.role}
|
||||
size="small"
|
||||
style={{ width: 110 }}
|
||||
disabled={updateRoleMutation.isPending}
|
||||
onChange={(val) =>
|
||||
updateRoleMutation.mutate({
|
||||
userId: user.user_id,
|
||||
newRole: val,
|
||||
})
|
||||
}
|
||||
options={[
|
||||
{ value: "ADMIN", label: "Админ" },
|
||||
{ value: "OPERATOR", label: "Оператор" },
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<Tag key="role-tag" color={getRoleColor(user.role)}>
|
||||
{getRoleName(user.role)}
|
||||
</Tag>
|
||||
),
|
||||
|
||||
// Кнопка удаления
|
||||
<Popconfirm
|
||||
key="delete"
|
||||
title="Закрыть доступ?"
|
||||
description={`Вы уверены, что хотите удалить ${user.first_name}?`}
|
||||
onConfirm={() => removeUserMutation.mutate(user.user_id)}
|
||||
disabled={!canDelete(user)}
|
||||
okText="Да"
|
||||
cancelText="Нет"
|
||||
>
|
||||
<Button
|
||||
danger
|
||||
type="text"
|
||||
icon={<DeleteOutlined />}
|
||||
disabled={!canDelete(user) || removeUserMutation.isPending}
|
||||
/>
|
||||
</Popconfirm>,
|
||||
]}
|
||||
>
|
||||
<List.Item.Meta
|
||||
avatar={
|
||||
<Avatar src={user.photo_url} icon={<UserOutlined />}>
|
||||
{user.first_name?.[0]}
|
||||
</Avatar>
|
||||
}
|
||||
title={
|
||||
<span>
|
||||
{user.first_name} {user.last_name}{" "}
|
||||
{user.is_me && <Text type="secondary">(Вы)</Text>}
|
||||
</span>
|
||||
}
|
||||
description={
|
||||
user.username ? (
|
||||
<a
|
||||
href={`https://t.me/${user.username}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ fontSize: 12 }}
|
||||
>
|
||||
@{user.username}
|
||||
</a>
|
||||
) : (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Нет username
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
Affix,
|
||||
Modal,
|
||||
Tag,
|
||||
Image,
|
||||
} from "antd";
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
@@ -24,9 +25,10 @@ import {
|
||||
ExclamationCircleFilled,
|
||||
RestOutlined,
|
||||
PlusOutlined,
|
||||
FileImageOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import dayjs from "dayjs";
|
||||
import { api } from "../services/api";
|
||||
import { api, getStaticUrl } from "../services/api";
|
||||
import { DraftItemRow } from "../components/invoices/DraftItemRow";
|
||||
import type {
|
||||
UpdateDraftItemRequest,
|
||||
@@ -45,6 +47,9 @@ export const InvoiceDraftPage: React.FC = () => {
|
||||
|
||||
const [updatingItems, setUpdatingItems] = useState<Set<string>>(new Set());
|
||||
|
||||
// Состояние для просмотра фото чека
|
||||
const [previewVisible, setPreviewVisible] = useState(false);
|
||||
|
||||
// --- ЗАПРОСЫ ---
|
||||
|
||||
const dictQuery = useQuery({
|
||||
@@ -95,20 +100,17 @@ export const InvoiceDraftPage: React.FC = () => {
|
||||
},
|
||||
});
|
||||
|
||||
// ДОБАВЛЕНО: Добавление строки
|
||||
const addItemMutation = useMutation({
|
||||
mutationFn: () => api.addDraftItem(id!),
|
||||
onSuccess: () => {
|
||||
message.success("Строка добавлена");
|
||||
queryClient.invalidateQueries({ queryKey: ["draft", id] });
|
||||
// Можно сделать скролл вниз, но пока оставим как есть
|
||||
},
|
||||
onError: () => {
|
||||
message.error("Ошибка создания строки");
|
||||
},
|
||||
});
|
||||
|
||||
// ДОБАВЛЕНО: Удаление строки
|
||||
const deleteItemMutation = useMutation({
|
||||
mutationFn: (itemId: string) => api.deleteDraftItem(id!, itemId),
|
||||
onSuccess: () => {
|
||||
@@ -293,16 +295,30 @@ export const InvoiceDraftPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
danger={isCanceled}
|
||||
type={isCanceled ? "primary" : "default"}
|
||||
icon={isCanceled ? <DeleteOutlined /> : <RestOutlined />}
|
||||
onClick={handleDelete}
|
||||
loading={deleteDraftMutation.isPending}
|
||||
size="small"
|
||||
>
|
||||
{isCanceled ? "Удалить" : "Отмена"}
|
||||
</Button>
|
||||
{/* Правая часть хедера: Кнопка чека и Кнопка удаления */}
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
{/* Кнопка просмотра чека (только если есть URL) */}
|
||||
{draft.photo_url && (
|
||||
<Button
|
||||
icon={<FileImageOutlined />}
|
||||
onClick={() => setPreviewVisible(true)}
|
||||
size="small"
|
||||
>
|
||||
Чек
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
danger={isCanceled}
|
||||
type={isCanceled ? "primary" : "default"}
|
||||
icon={isCanceled ? <DeleteOutlined /> : <RestOutlined />}
|
||||
onClick={handleDelete}
|
||||
loading={deleteDraftMutation.isPending}
|
||||
size="small"
|
||||
>
|
||||
{isCanceled ? "Удалить" : "Отмена"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form: Склады и Поставщики */}
|
||||
@@ -422,7 +438,7 @@ export const InvoiceDraftPage: React.FC = () => {
|
||||
type="dashed"
|
||||
block
|
||||
icon={<PlusOutlined />}
|
||||
style={{ marginTop: 12, marginBottom: 80, height: 48 }} // Увеличенный margin bottom для Affix
|
||||
style={{ marginTop: 12, marginBottom: 80, height: 48 }}
|
||||
onClick={() => addItemMutation.mutate()}
|
||||
loading={addItemMutation.isPending}
|
||||
disabled={isCanceled}
|
||||
@@ -476,6 +492,22 @@ export const InvoiceDraftPage: React.FC = () => {
|
||||
</Button>
|
||||
</div>
|
||||
</Affix>
|
||||
|
||||
{/* Скрытый компонент для просмотра изображения */}
|
||||
{draft.photo_url && (
|
||||
<div style={{ display: "none" }}>
|
||||
<Image.PreviewGroup
|
||||
preview={{
|
||||
visible: previewVisible,
|
||||
onVisibleChange: (vis) => setPreviewVisible(vis),
|
||||
movable: true,
|
||||
scaleStep: 0.5,
|
||||
}}
|
||||
>
|
||||
<Image src={getStaticUrl(draft.photo_url)} />
|
||||
</Image.PreviewGroup>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -12,16 +12,19 @@ import {
|
||||
TreeSelect,
|
||||
Spin,
|
||||
message,
|
||||
Tabs,
|
||||
} from "antd";
|
||||
import {
|
||||
SaveOutlined,
|
||||
BarChartOutlined,
|
||||
SettingOutlined,
|
||||
FolderOpenOutlined,
|
||||
TeamOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "../services/api";
|
||||
import type { UserSettings } from "../services/types";
|
||||
import { TeamList } from "../components/settings/TeamList";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
@@ -95,6 +98,115 @@ export const SettingsPage: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
// Определяем роль текущего пользователя
|
||||
const currentUserRole = settingsQuery.data?.role || "OPERATOR";
|
||||
const showTeamSettings =
|
||||
currentUserRole === "ADMIN" || currentUserRole === "OWNER";
|
||||
|
||||
// Сохраняем JSX в переменную вместо создания вложенного компонента
|
||||
const generalSettingsContent = (
|
||||
<Form form={form} layout="vertical">
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Form.Item
|
||||
label="Склад по умолчанию"
|
||||
name="default_store_id"
|
||||
tooltip="Этот склад будет выбираться автоматически при создании новой накладной"
|
||||
>
|
||||
<Select
|
||||
placeholder="Не выбрано"
|
||||
allowClear
|
||||
loading={dictQuery.isLoading}
|
||||
options={dictQuery.data?.stores.map((s) => ({
|
||||
label: s.name,
|
||||
value: s.id,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="Корневая группа товаров"
|
||||
name="root_group_id"
|
||||
tooltip="Товары для распознавания будут искаться только внутри этой группы (и её подгрупп)."
|
||||
>
|
||||
<TreeSelect
|
||||
showSearch
|
||||
style={{ width: "100%" }}
|
||||
dropdownStyle={{ maxHeight: 400, overflow: "auto" }}
|
||||
placeholder="Выберите папку"
|
||||
allowClear
|
||||
treeDefaultExpandAll={false}
|
||||
treeData={groupsQuery.data}
|
||||
fieldNames={{
|
||||
label: "title",
|
||||
value: "value",
|
||||
children: "children",
|
||||
}}
|
||||
treeNodeFilterProp="title"
|
||||
suffixIcon={<FolderOpenOutlined />}
|
||||
loading={groupsQuery.isLoading}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="auto_conduct"
|
||||
valuePropName="checked"
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Text>Проводить накладные автоматически</Text>
|
||||
<br />
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Если выключено, накладные в iiko будут создаваться как
|
||||
"Непроведенные"
|
||||
</Text>
|
||||
</div>
|
||||
<Switch />
|
||||
</div>
|
||||
</Form.Item>
|
||||
</Card>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
block
|
||||
size="large"
|
||||
onClick={handleSave}
|
||||
loading={saveMutation.isPending}
|
||||
>
|
||||
Сохранить настройки
|
||||
</Button>
|
||||
</Form>
|
||||
);
|
||||
|
||||
const tabsItems = [
|
||||
{
|
||||
key: "general",
|
||||
label: "Общие",
|
||||
icon: <SettingOutlined />,
|
||||
children: generalSettingsContent,
|
||||
},
|
||||
];
|
||||
|
||||
if (showTeamSettings) {
|
||||
tabsItems.push({
|
||||
key: "team",
|
||||
label: "Команда",
|
||||
icon: <TeamOutlined />,
|
||||
children: (
|
||||
<Card size="small">
|
||||
<TeamList currentUserRole={currentUserRole} />
|
||||
</Card>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: "0 16px 80px" }}>
|
||||
<Title level={4} style={{ marginTop: 16 }}>
|
||||
@@ -146,90 +258,8 @@ export const SettingsPage: React.FC = () => {
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* Форма настроек */}
|
||||
<Form form={form} layout="vertical">
|
||||
<Card
|
||||
size="small"
|
||||
title="Основные параметры"
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
<Form.Item
|
||||
label="Склад по умолчанию"
|
||||
name="default_store_id"
|
||||
tooltip="Этот склад будет выбираться автоматически при создании новой накладной"
|
||||
>
|
||||
<Select
|
||||
placeholder="Не выбрано"
|
||||
allowClear
|
||||
loading={dictQuery.isLoading}
|
||||
options={dictQuery.data?.stores.map((s) => ({
|
||||
label: s.name,
|
||||
value: s.id,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="Корневая группа товаров"
|
||||
name="root_group_id"
|
||||
tooltip="Товары для распознавания будут искаться только внутри этой группы (и её подгрупп)."
|
||||
>
|
||||
<TreeSelect
|
||||
showSearch
|
||||
style={{ width: "100%" }}
|
||||
dropdownStyle={{ maxHeight: 400, overflow: "auto" }}
|
||||
placeholder="Выберите папку"
|
||||
allowClear
|
||||
treeDefaultExpandAll={false}
|
||||
treeData={groupsQuery.data}
|
||||
// ИСПРАВЛЕНО: Маппинг полей под структуру JSON (title, value)
|
||||
fieldNames={{
|
||||
label: "title",
|
||||
value: "value",
|
||||
children: "children",
|
||||
}}
|
||||
treeNodeFilterProp="title"
|
||||
suffixIcon={<FolderOpenOutlined />}
|
||||
loading={groupsQuery.isLoading}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="auto_conduct"
|
||||
valuePropName="checked"
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Text>Проводить накладные автоматически</Text>
|
||||
<br />
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Если выключено, накладные в iiko будут создаваться как
|
||||
"Непроведенные"
|
||||
</Text>
|
||||
</div>
|
||||
<Switch />
|
||||
</div>
|
||||
</Form.Item>
|
||||
</Card>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
block
|
||||
size="large"
|
||||
onClick={handleSave}
|
||||
loading={saveMutation.isPending}
|
||||
>
|
||||
Сохранить настройки
|
||||
</Button>
|
||||
</Form>
|
||||
{/* Табы настроек */}
|
||||
<Tabs defaultActiveKey="general" items={tabsItems} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -22,11 +22,21 @@ import type {
|
||||
AddContainerRequest,
|
||||
AddContainerResponse,
|
||||
DictionariesResponse,
|
||||
DraftSummary
|
||||
DraftSummary,
|
||||
ServerUser,
|
||||
UserRole
|
||||
} from './types';
|
||||
|
||||
// Базовый URL
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api';
|
||||
export const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api';
|
||||
|
||||
// Хелпер для получения полного URL картинки (убирает /api если путь статики идет от корня, или добавляет как есть)
|
||||
// В данном ТЗ сказано просто склеивать.
|
||||
export const getStaticUrl = (path: string | null | undefined): string => {
|
||||
if (!path) return '';
|
||||
if (path.startsWith('http')) return path;
|
||||
return `${API_BASE_URL}${path}`;
|
||||
};
|
||||
|
||||
// Телеграм объект
|
||||
const tg = window.Telegram?.WebApp;
|
||||
@@ -35,7 +45,7 @@ const tg = window.Telegram?.WebApp;
|
||||
export const UNAUTHORIZED_EVENT = 'rms_unauthorized';
|
||||
|
||||
const apiClient = axios.create({
|
||||
baseURL: API_URL,
|
||||
baseURL: API_BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
@@ -203,4 +213,21 @@ export const api = {
|
||||
const { data } = await apiClient.get<ProductGroup[]>('/dictionaries/groups');
|
||||
return data;
|
||||
},
|
||||
|
||||
// --- Управление командой ---
|
||||
|
||||
getUsers: async (): Promise<ServerUser[]> => {
|
||||
const { data } = await apiClient.get<ServerUser[]>('/settings/users');
|
||||
return data;
|
||||
},
|
||||
|
||||
updateUserRole: async (userId: string, newRole: UserRole): Promise<{ status: string }> => {
|
||||
const { data } = await apiClient.patch<{ status: string }>(`/settings/users/${userId}`, { new_role: newRole });
|
||||
return data;
|
||||
},
|
||||
|
||||
removeUser: async (userId: string): Promise<{ status: string }> => {
|
||||
const { data } = await apiClient.delete<{ status: string }>(`/settings/users/${userId}`);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
@@ -2,6 +2,20 @@
|
||||
|
||||
export type UUID = string;
|
||||
|
||||
// Добавляем типы ролей
|
||||
export type UserRole = 'OWNER' | 'ADMIN' | 'OPERATOR';
|
||||
|
||||
// Интерфейс пользователя сервера
|
||||
export interface ServerUser {
|
||||
user_id: string;
|
||||
username: string; // @username или пустая строка
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
photo_url: string; // URL картинки или пустая строка
|
||||
role: UserRole;
|
||||
is_me: boolean; // Флаг, является ли этот юзер текущим пользователем
|
||||
}
|
||||
|
||||
// --- Каталог и Фасовки (API v2.0) ---
|
||||
|
||||
export interface ProductContainer {
|
||||
@@ -133,6 +147,7 @@ export interface UserSettings {
|
||||
root_group_id: UUID | null;
|
||||
default_store_id: UUID | null;
|
||||
auto_conduct: boolean;
|
||||
role: UserRole; // Добавляем поле роли в настройки текущего пользователя
|
||||
}
|
||||
|
||||
export interface InvoiceStats {
|
||||
@@ -196,6 +211,7 @@ export interface DraftInvoice {
|
||||
comment: string;
|
||||
items: DraftItem[];
|
||||
created_at?: string;
|
||||
photo_url?: string; // Добавлено поле фото чека
|
||||
}
|
||||
|
||||
// DTO для обновления строки
|
||||
|
||||
Reference in New Issue
Block a user