добавил приветственный бонус и README

This commit is contained in:
2025-12-26 07:02:01 +03:00
parent 5f35d7a75f
commit dfd855cb6e
4 changed files with 532 additions and 141 deletions

33
README.md Normal file
View File

@@ -0,0 +1,33 @@
**RMSer** — это SaaS-платформа, которая избавляет бухгалтеров и менеджеров ресторанов от рутинного ввода накладных. С помощью оптического распознавания (OCR) и алгоритмов сопоставления данных, сервис сокращает время приемки товара в несколько раз.
## ✨ Ключевые преимущества
### 🧠 Умный матчинг и самообучение
Интерфейс сопоставления позволяет один раз связать текст из чека поставщика («Томат черри 250г имп») с вашей позицией в iiko («Томаты Черри»). Система сохраняет эту связь и при следующих загрузках подставляет нужный товар автоматически.
### ⚙️ Тонкая настройка под объект
* **Склад по умолчанию:** Автоматическая подстановка нужного склада для каждой новой накладной.
* **Корневая группа поиска:** Ограничьте область поиска товаров (например, только категория «Продукты»). Это исключает попадание в накладные услуг, инвентаря или блюд.
* **Авто-проведение:** Возможность настроить автоматическое создание накладной в статусе «Проведено» (Processed).
* **Добавление фасовок:** Для каждого товара из iiko можно добавить фасовку и текст из чека будет сопоставлен с ней.
### 👥 Управление командой в приложении
Полноценный интерфейс управления пользователями внутри Telegram Mini App:
* **Owner:** Полный доступ и управление подпиской.
* **Admin:** Настройка интеграции и сопоставление товаров.
* **Operator:** Режим «только фото» — идеально для линейного персонала на приемке.
* **Инвайт-система:** Быстрое добавление сотрудников через ссылку.
## 🛠 Стек технологий
* **Language:** Go 1.25
* **Framework:** Gin Gonic (REST API)
* **Database:** PostgreSQL 16 (GORM)
* **Cache:** Redis
* **Bot Engine:** Telebot v3
* **Containerization:** Docker & Docker Compose
* **Security:** AES-256 (GCM) для шифрования учетных данных RMS.
## 💳 Биллинг и Бонусы
Сервис работает по модели Pay-as-you-go (пакеты накладных) или по подписке.
**Welcome Bonus:** При подключении нового сервера система автоматически начисляет **10 накладных на 30 дней** для бесплатного тестирования.

View File

@@ -72,27 +72,29 @@ func (r *pgRepository) GetUserByID(id uuid.UUID) (*account.User, error) {
// ConnectServer - Основная точка входа для добавления сервера
func (r *pgRepository) ConnectServer(userID uuid.UUID, rawURL, login, encryptedPass, name string) (*account.RMSServer, error) {
// 1. Нормализация URL (удаляем слеш в конце, приводим к нижнему регистру)
// Важно: мы не удаляем http/https, так как iiko может работать и так и так, но обычно это разные эндпоинты.
// Для надежности уникальности можно вырезать протокол, но пока оставим как есть, просто тримминг.
cleanURL := strings.TrimRight(strings.ToLower(strings.TrimSpace(rawURL)), "/")
var server account.RMSServer
var created bool
err := r.db.Transaction(func(tx *gorm.DB) error {
// 2. Ищем, существует ли сервер с таким URL
err := tx.Where("base_url = ?", cleanURL).First(&server).Error
if err != nil && err != gorm.ErrRecordNotFound {
return err
}
if err == gorm.ErrRecordNotFound {
// --- СЦЕНАРИЙ 1: НОВЫЙ СЕРВЕР ---
// --- СЦЕНАРИЙ 1: НОВЫЙ СЕРВЕР (Приветственный бонус) ---
trialDays := 30
welcomeBalance := 10
paidUntil := time.Now().AddDate(0, 0, trialDays)
server = account.RMSServer{
BaseURL: cleanURL,
Name: name,
MaxUsers: 5, // Дефолтное ограничение
BaseURL: cleanURL,
Name: name,
MaxUsers: 5,
Balance: welcomeBalance,
PaidUntil: &paidUntil,
}
if err := tx.Create(&server).Error; err != nil {
return err
@@ -100,11 +102,9 @@ func (r *pgRepository) ConnectServer(userID uuid.UUID, rawURL, login, encryptedP
created = true
} else {
// --- СЦЕНАРИЙ 2: СУЩЕСТВУЮЩИЙ СЕРВЕР ---
// Проверяем лимит пользователей
var userCount int64
tx.Model(&account.ServerUser{}).Where("server_id = ?", server.ID).Count(&userCount)
if userCount >= int64(server.MaxUsers) {
// Проверяем, может пользователь УЖЕ там есть? Тогда это не добавление, а обновление кредов
var exists int64
tx.Model(&account.ServerUser{}).Where("server_id = ? AND user_id = ?", server.ID, userID).Count(&exists)
if exists == 0 {
@@ -113,18 +113,24 @@ func (r *pgRepository) ConnectServer(userID uuid.UUID, rawURL, login, encryptedP
}
}
// 3. Определяем роль
targetRole := account.RoleOperator
if created {
targetRole = account.RoleOwner
}
// 4. Создаем или обновляем связь с пользователем
// Сбрасываем активность других серверов
if err := tx.Model(&account.ServerUser{}).Where("user_id = ?", userID).Update("is_active", false).Error; err != nil {
return err
}
var existingLink account.ServerUser
err = tx.Where("server_id = ? AND user_id = ?", server.ID, userID).First(&existingLink).Error
if err == nil {
existingLink.Login = login
existingLink.EncryptedPassword = encryptedPass
existingLink.IsActive = true
return tx.Save(&existingLink).Error
}
userLink := account.ServerUser{
ServerID: server.ID,
UserID: userID,
@@ -133,22 +139,6 @@ func (r *pgRepository) ConnectServer(userID uuid.UUID, rawURL, login, encryptedP
Login: login,
EncryptedPassword: encryptedPass,
}
// Upsert для связи (на случай если пользователь подключает уже подключенный сервер, обновляем пароль)
// Если пользователь уже был OWNER/ADMIN, роль НЕ понижаем.
// Поэтому используем хитрый Upsert: обновляем роль только если запись новая.
var existingLink account.ServerUser
err = tx.Where("server_id = ? AND user_id = ?", server.ID, userID).First(&existingLink).Error
if err == nil {
// Запись есть -> обновляем креды и делаем активным, роль НЕ меняем
existingLink.Login = login
existingLink.EncryptedPassword = encryptedPass
existingLink.IsActive = true
return tx.Save(&existingLink).Error
}
// Записи нет -> создаем с вычисленной ролью
return tx.Create(&userLink).Error
})

View File

@@ -156,7 +156,43 @@ func (bot *Bot) handleStartCommand(c tele.Context) error {
if payload != "" && strings.HasPrefix(payload, "invite_") {
return bot.handleInviteLink(c, strings.TrimPrefix(payload, "invite_"))
}
return bot.renderMainMenu(c)
welcomeTxt := "🚀 <b>RMSer — ваш умный ассистент для iiko</b>\n\n" +
"Больше не нужно вводить накладные вручную. Просто сфотографируйте чек, а я сделаю всё остальное.\n\n" +
"<b>Почему это удобно:</b>\n" +
"🧠 <b>Самообучение:</b> Сопоставьте товар один раз, и в следующий раз я узнаю его сам.\n" +
"⚙️ <b>Гибкая настройка:</b> Укажите склад по умолчанию и ограничьте область поиска товаров только нужными категориями.\n" +
"👥 <b>Работа в команде:</b> Приглашайте сотрудников, распределяйте роли и управляйте доступом прямо в Mini App.\n\n" +
"🎁 <b>Старт без риска:</b> Дарим 10 накладных на 30 дней каждому новому серверу!"
return bot.renderMainMenuWithText(c, welcomeTxt)
}
// Вспомогательный метод для рендера (чтобы не дублировать код меню)
func (bot *Bot) renderMainMenuWithText(c tele.Context, text string) error {
bot.fsm.Reset(c.Sender().ID)
userDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID)
activeServer, _ := bot.accountRepo.GetActiveServer(userDB.ID)
menu := &tele.ReplyMarkup{}
btnServers := menu.Data("🖥 Серверы", "nav_servers")
btnDicts := menu.Data("🔄 Справочники", "nav_dicts")
btnBalance := menu.Data("💰 Баланс", "nav_balance")
var rows []tele.Row
rows = append(rows, menu.Row(btnServers, btnDicts))
rows = append(rows, menu.Row(btnBalance))
if activeServer != nil {
role, _ := bot.accountRepo.GetUserRole(userDB.ID, activeServer.ID)
if role == account.RoleOwner || role == account.RoleAdmin {
btnApp := menu.WebApp("📱 Открыть приложение", &tele.WebApp{URL: bot.webAppURL})
rows = append(rows, menu.Row(btnApp))
}
}
menu.Inline(rows...)
return c.EditOrSend(text, menu, tele.ModeHTML)
}
func (bot *Bot) handleInviteLink(c tele.Context, serverIDStr string) error {
@@ -759,7 +795,7 @@ func (bot *Bot) handlePhoto(c tele.Context) error {
if err != nil {
return c.Send("Ошибка чтения файла.")
}
c.Send("⏳ Обрабатываю чек: создаю черновик и распознаю...")
c.Send("⏳ <b>ИИ анализирует документ...</b>\nОбрабатываю позиции и ищу совпадения в вашей базе iiko.", tele.ModeHTML)
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
draft, err := bot.ocrService.ProcessReceiptImage(ctx, userDB.ID, imgData)
@@ -777,14 +813,14 @@ func (bot *Bot) handlePhoto(c tele.Context) error {
fullURL := fmt.Sprintf("%s/invoice/%s", baseURL, draft.ID.String())
var msgText string
if matchedCount == len(draft.Items) {
msgText = fmt.Sprintf("✅ <b>Успех!</b> Все позиции (%d) распознаны.\n\nПереходите к созданию накладной.", len(draft.Items))
msgText = fmt.Sprintf("✅ <b>Все позиции распознаны!</b>\n\nВсе товары найдены в вашей номенклатуре. Проверьте количество (%d) и цену в приложении перед отправкой.", len(draft.Items))
} else {
msgText = fmt.Sprintf("⚠️ <b>Внимание!</b> Распознано %d из %d позиций.\n\nНекоторые товары требуют ручного сопоставления.", matchedCount, len(draft.Items))
msgText = fmt.Sprintf("⚠️ <b>Распознано позиций: %d из %d</b>\n\nОстальные товары я вижу впервые. Воспользуйтесь <b>удобным интерфейсом сопоставления</b> в приложении — я запомню ваш выбор навсегда.", matchedCount, len(draft.Items))
}
menu := &tele.ReplyMarkup{}
role, _ := bot.accountRepo.GetUserRole(userDB.ID, draft.RMSServerID)
if role != account.RoleOperator {
btnOpen := menu.WebApp("📝 Открыть накладную", &tele.WebApp{URL: fullURL})
btnOpen := menu.WebApp("📝 Открыть и сопоставить", &tele.WebApp{URL: fullURL})
menu.Inline(menu.Row(btnOpen))
} else {
msgText += "\n\n<i>(Редактирование доступно Администратору)</i>"
@@ -807,18 +843,32 @@ func (bot *Bot) handleConfirmNameNo(c tele.Context) error {
return c.EditOrSend("🏷 Хорошо, введите желаемое <b>название</b>:")
}
// Обновленный метод saveServerFinal (добавление уведомления о бонусе)
func (bot *Bot) saveServerFinal(c tele.Context, userID int64, serverName string) error {
ctx := bot.fsm.GetContext(userID)
userDB, _ := bot.accountRepo.GetOrCreateUser(userID, c.Sender().Username, "", "")
encPass, _ := bot.cryptoManager.Encrypt(ctx.TempPassword)
server, err := bot.accountRepo.ConnectServer(userDB.ID, ctx.TempURL, ctx.TempLogin, encPass, serverName)
if err != nil {
return c.Send("Ошибка подключения сервера: " + err.Error())
return c.Send("Ошибка подключения сервера: " + err.Error())
}
bot.fsm.Reset(userID)
role, _ := bot.accountRepo.GetUserRole(userDB.ID, server.ID)
c.Send(fmt.Sprintf("✅ Сервер <b>%s</b> подключен!\nВаша роль: <b>%s</b>", server.Name, role), tele.ModeHTML)
successMsg := fmt.Sprintf("✅ Сервер <b>%s</b> успешно подключен!\nВаша роль: <b>%s</b>\n\n", server.Name, role)
// Проверяем, новый ли это сервер по балансу и дате создания (упрощенно для уведомления)
if server.Balance == 10 {
successMsg += "🎁 Вам начислен <b>приветственный бонус: 10 накладных</b> на 30 дней! Пользуйтесь с удовольствием.\n\n"
}
successMsg += "Начинаю первичную синхронизацию данных..."
c.Send(successMsg, tele.ModeHTML)
go bot.syncService.SyncAllData(userDB.ID)
return bot.renderMainMenu(c)
}

View File

@@ -1,9 +1,9 @@
# ===================================================================
# Полный контекст Vue.js проекта
# Сгенерировано: 2025-12-23 08:36:21
# Полный контекст React Typescript проекта
# Сгенерировано: 2025-12-26 05:56:35
# ===================================================================
Это полный дамп исходного кода Vue/Vite проекта.
Это полный дамп исходного кода React Typescript (Vite) проекта.
Каждый файл предваряется заголовком с путём к нему.
@@ -5990,6 +5990,219 @@ export const RecommendationCard: React.FC<Props> = ({ item }) => {
};
```
# ===================================================================
# Файл: src/components/settings/TeamList.tsx
# ===================================================================
```
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>
)}
/>
</>
);
};
```
# ===================================================================
# Файл: src/hooks/useOcr.ts
# ===================================================================
@@ -6265,6 +6478,7 @@ import {
Affix,
Modal,
Tag,
Image,
} from "antd";
import {
ArrowLeftOutlined,
@@ -6273,9 +6487,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,
@@ -6294,6 +6509,9 @@ export const InvoiceDraftPage: React.FC = () => {
const [updatingItems, setUpdatingItems] = useState<Set<string>>(new Set());
// Состояние для просмотра фото чека
const [previewVisible, setPreviewVisible] = useState(false);
// --- ЗАПРОСЫ ---
const dictQuery = useQuery({
@@ -6344,20 +6562,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: () => {
@@ -6542,16 +6757,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: Склады и Поставщики */}
@@ -6671,7 +6900,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}
@@ -6725,6 +6954,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>
);
};
@@ -6809,16 +7054,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;
@@ -6892,6 +7140,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 }}>
@@ -6943,90 +7300,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>
);
};
@@ -7062,11 +7337,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;
@@ -7075,7 +7360,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',
},
@@ -7243,6 +7528,23 @@ 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;
},
};
```
@@ -7255,6 +7557,20 @@ export const api = {
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 {
@@ -7386,6 +7702,7 @@ export interface UserSettings {
root_group_id: UUID | null;
default_store_id: UUID | null;
auto_conduct: boolean;
role: UserRole; // Добавляем поле роли в настройки текущего пользователя
}
export interface InvoiceStats {
@@ -7449,6 +7766,7 @@ export interface DraftInvoice {
comment: string;
items: DraftItem[];
created_at?: string;
photo_url?: string; // Добавлено поле фото чека
}
// DTO для обновления строки