добавил пользователей для сервера и роли

добавил инвайт-ссылки с ролью оператор для сервера
добавил супер-админку для смены владельцев
добавил уведомления о смене ролей на серверах
добавил модалку для фото прям в черновике
добавил UI для редактирования прав
This commit is contained in:
2025-12-23 13:06:06 +03:00
parent 9441579a34
commit b4ce819931
21 changed files with 9244 additions and 418 deletions

File diff suppressed because it is too large Load Diff

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

View File

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

View File

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

View File

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

View File

@@ -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 для обновления строки