mirror of
https://github.com/serty2005/rmser.git
synced 2026-02-04 19:02:33 -06:00
добавил приветственный бонус и README
This commit is contained in:
33
README.md
Normal file
33
README.md
Normal 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 дней** для бесплатного тестирования.
|
||||||
@@ -72,27 +72,29 @@ func (r *pgRepository) GetUserByID(id uuid.UUID) (*account.User, error) {
|
|||||||
|
|
||||||
// ConnectServer - Основная точка входа для добавления сервера
|
// ConnectServer - Основная точка входа для добавления сервера
|
||||||
func (r *pgRepository) ConnectServer(userID uuid.UUID, rawURL, login, encryptedPass, name string) (*account.RMSServer, error) {
|
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)), "/")
|
cleanURL := strings.TrimRight(strings.ToLower(strings.TrimSpace(rawURL)), "/")
|
||||||
|
|
||||||
var server account.RMSServer
|
var server account.RMSServer
|
||||||
var created bool
|
var created bool
|
||||||
|
|
||||||
err := r.db.Transaction(func(tx *gorm.DB) error {
|
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||||
// 2. Ищем, существует ли сервер с таким URL
|
|
||||||
err := tx.Where("base_url = ?", cleanURL).First(&server).Error
|
err := tx.Where("base_url = ?", cleanURL).First(&server).Error
|
||||||
if err != nil && err != gorm.ErrRecordNotFound {
|
if err != nil && err != gorm.ErrRecordNotFound {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err == gorm.ErrRecordNotFound {
|
if err == gorm.ErrRecordNotFound {
|
||||||
// --- СЦЕНАРИЙ 1: НОВЫЙ СЕРВЕР ---
|
// --- СЦЕНАРИЙ 1: НОВЫЙ СЕРВЕР (Приветственный бонус) ---
|
||||||
|
trialDays := 30
|
||||||
|
welcomeBalance := 10
|
||||||
|
paidUntil := time.Now().AddDate(0, 0, trialDays)
|
||||||
|
|
||||||
server = account.RMSServer{
|
server = account.RMSServer{
|
||||||
BaseURL: cleanURL,
|
BaseURL: cleanURL,
|
||||||
Name: name,
|
Name: name,
|
||||||
MaxUsers: 5, // Дефолтное ограничение
|
MaxUsers: 5,
|
||||||
|
Balance: welcomeBalance,
|
||||||
|
PaidUntil: &paidUntil,
|
||||||
}
|
}
|
||||||
if err := tx.Create(&server).Error; err != nil {
|
if err := tx.Create(&server).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -100,11 +102,9 @@ func (r *pgRepository) ConnectServer(userID uuid.UUID, rawURL, login, encryptedP
|
|||||||
created = true
|
created = true
|
||||||
} else {
|
} else {
|
||||||
// --- СЦЕНАРИЙ 2: СУЩЕСТВУЮЩИЙ СЕРВЕР ---
|
// --- СЦЕНАРИЙ 2: СУЩЕСТВУЮЩИЙ СЕРВЕР ---
|
||||||
// Проверяем лимит пользователей
|
|
||||||
var userCount int64
|
var userCount int64
|
||||||
tx.Model(&account.ServerUser{}).Where("server_id = ?", server.ID).Count(&userCount)
|
tx.Model(&account.ServerUser{}).Where("server_id = ?", server.ID).Count(&userCount)
|
||||||
if userCount >= int64(server.MaxUsers) {
|
if userCount >= int64(server.MaxUsers) {
|
||||||
// Проверяем, может пользователь УЖЕ там есть? Тогда это не добавление, а обновление кредов
|
|
||||||
var exists int64
|
var exists int64
|
||||||
tx.Model(&account.ServerUser{}).Where("server_id = ? AND user_id = ?", server.ID, userID).Count(&exists)
|
tx.Model(&account.ServerUser{}).Where("server_id = ? AND user_id = ?", server.ID, userID).Count(&exists)
|
||||||
if exists == 0 {
|
if exists == 0 {
|
||||||
@@ -113,18 +113,24 @@ func (r *pgRepository) ConnectServer(userID uuid.UUID, rawURL, login, encryptedP
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Определяем роль
|
|
||||||
targetRole := account.RoleOperator
|
targetRole := account.RoleOperator
|
||||||
if created {
|
if created {
|
||||||
targetRole = account.RoleOwner
|
targetRole = account.RoleOwner
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Создаем или обновляем связь с пользователем
|
|
||||||
// Сбрасываем активность других серверов
|
|
||||||
if err := tx.Model(&account.ServerUser{}).Where("user_id = ?", userID).Update("is_active", false).Error; err != nil {
|
if err := tx.Model(&account.ServerUser{}).Where("user_id = ?", userID).Update("is_active", false).Error; err != nil {
|
||||||
return err
|
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{
|
userLink := account.ServerUser{
|
||||||
ServerID: server.ID,
|
ServerID: server.ID,
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
@@ -133,22 +139,6 @@ func (r *pgRepository) ConnectServer(userID uuid.UUID, rawURL, login, encryptedP
|
|||||||
Login: login,
|
Login: login,
|
||||||
EncryptedPassword: encryptedPass,
|
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
|
return tx.Create(&userLink).Error
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -156,7 +156,43 @@ func (bot *Bot) handleStartCommand(c tele.Context) error {
|
|||||||
if payload != "" && strings.HasPrefix(payload, "invite_") {
|
if payload != "" && strings.HasPrefix(payload, "invite_") {
|
||||||
return bot.handleInviteLink(c, strings.TrimPrefix(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 {
|
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 {
|
if err != nil {
|
||||||
return c.Send("Ошибка чтения файла.")
|
return c.Send("Ошибка чтения файла.")
|
||||||
}
|
}
|
||||||
c.Send("⏳ Обрабатываю чек: создаю черновик и распознаю...")
|
c.Send("⏳ <b>ИИ анализирует документ...</b>\nОбрабатываю позиции и ищу совпадения в вашей базе iiko.", tele.ModeHTML)
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
draft, err := bot.ocrService.ProcessReceiptImage(ctx, userDB.ID, imgData)
|
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())
|
fullURL := fmt.Sprintf("%s/invoice/%s", baseURL, draft.ID.String())
|
||||||
var msgText string
|
var msgText string
|
||||||
if matchedCount == len(draft.Items) {
|
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 {
|
} 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{}
|
menu := &tele.ReplyMarkup{}
|
||||||
role, _ := bot.accountRepo.GetUserRole(userDB.ID, draft.RMSServerID)
|
role, _ := bot.accountRepo.GetUserRole(userDB.ID, draft.RMSServerID)
|
||||||
if role != account.RoleOperator {
|
if role != account.RoleOperator {
|
||||||
btnOpen := menu.WebApp("📝 Открыть накладную", &tele.WebApp{URL: fullURL})
|
btnOpen := menu.WebApp("📝 Открыть и сопоставить", &tele.WebApp{URL: fullURL})
|
||||||
menu.Inline(menu.Row(btnOpen))
|
menu.Inline(menu.Row(btnOpen))
|
||||||
} else {
|
} else {
|
||||||
msgText += "\n\n<i>(Редактирование доступно Администратору)</i>"
|
msgText += "\n\n<i>(Редактирование доступно Администратору)</i>"
|
||||||
@@ -807,18 +843,32 @@ func (bot *Bot) handleConfirmNameNo(c tele.Context) error {
|
|||||||
return c.EditOrSend("🏷 Хорошо, введите желаемое <b>название</b>:")
|
return c.EditOrSend("🏷 Хорошо, введите желаемое <b>название</b>:")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Обновленный метод saveServerFinal (добавление уведомления о бонусе)
|
||||||
func (bot *Bot) saveServerFinal(c tele.Context, userID int64, serverName string) error {
|
func (bot *Bot) saveServerFinal(c tele.Context, userID int64, serverName string) error {
|
||||||
ctx := bot.fsm.GetContext(userID)
|
ctx := bot.fsm.GetContext(userID)
|
||||||
userDB, _ := bot.accountRepo.GetOrCreateUser(userID, c.Sender().Username, "", "")
|
userDB, _ := bot.accountRepo.GetOrCreateUser(userID, c.Sender().Username, "", "")
|
||||||
encPass, _ := bot.cryptoManager.Encrypt(ctx.TempPassword)
|
encPass, _ := bot.cryptoManager.Encrypt(ctx.TempPassword)
|
||||||
|
|
||||||
server, err := bot.accountRepo.ConnectServer(userDB.ID, ctx.TempURL, ctx.TempLogin, encPass, serverName)
|
server, err := bot.accountRepo.ConnectServer(userDB.ID, ctx.TempURL, ctx.TempLogin, encPass, serverName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Send("Ошибка подключения сервера: " + err.Error())
|
return c.Send("❌ Ошибка подключения сервера: " + err.Error())
|
||||||
}
|
}
|
||||||
bot.fsm.Reset(userID)
|
bot.fsm.Reset(userID)
|
||||||
|
|
||||||
role, _ := bot.accountRepo.GetUserRole(userDB.ID, server.ID)
|
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)
|
go bot.syncService.SyncAllData(userDB.ID)
|
||||||
|
|
||||||
return bot.renderMainMenu(c)
|
return bot.renderMainMenu(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
# ===================================================================
|
# ===================================================================
|
||||||
# Полный контекст Vue.js проекта
|
# Полный контекст React Typescript проекта
|
||||||
# Сгенерировано: 2025-12-23 08:36:21
|
# Сгенерировано: 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
|
# Файл: src/hooks/useOcr.ts
|
||||||
# ===================================================================
|
# ===================================================================
|
||||||
@@ -6265,6 +6478,7 @@ import {
|
|||||||
Affix,
|
Affix,
|
||||||
Modal,
|
Modal,
|
||||||
Tag,
|
Tag,
|
||||||
|
Image,
|
||||||
} from "antd";
|
} from "antd";
|
||||||
import {
|
import {
|
||||||
ArrowLeftOutlined,
|
ArrowLeftOutlined,
|
||||||
@@ -6273,9 +6487,10 @@ import {
|
|||||||
ExclamationCircleFilled,
|
ExclamationCircleFilled,
|
||||||
RestOutlined,
|
RestOutlined,
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
|
FileImageOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { api } from "../services/api";
|
import { api, getStaticUrl } from "../services/api";
|
||||||
import { DraftItemRow } from "../components/invoices/DraftItemRow";
|
import { DraftItemRow } from "../components/invoices/DraftItemRow";
|
||||||
import type {
|
import type {
|
||||||
UpdateDraftItemRequest,
|
UpdateDraftItemRequest,
|
||||||
@@ -6294,6 +6509,9 @@ export const InvoiceDraftPage: React.FC = () => {
|
|||||||
|
|
||||||
const [updatingItems, setUpdatingItems] = useState<Set<string>>(new Set());
|
const [updatingItems, setUpdatingItems] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// Состояние для просмотра фото чека
|
||||||
|
const [previewVisible, setPreviewVisible] = useState(false);
|
||||||
|
|
||||||
// --- ЗАПРОСЫ ---
|
// --- ЗАПРОСЫ ---
|
||||||
|
|
||||||
const dictQuery = useQuery({
|
const dictQuery = useQuery({
|
||||||
@@ -6344,20 +6562,17 @@ export const InvoiceDraftPage: React.FC = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// ДОБАВЛЕНО: Добавление строки
|
|
||||||
const addItemMutation = useMutation({
|
const addItemMutation = useMutation({
|
||||||
mutationFn: () => api.addDraftItem(id!),
|
mutationFn: () => api.addDraftItem(id!),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
message.success("Строка добавлена");
|
message.success("Строка добавлена");
|
||||||
queryClient.invalidateQueries({ queryKey: ["draft", id] });
|
queryClient.invalidateQueries({ queryKey: ["draft", id] });
|
||||||
// Можно сделать скролл вниз, но пока оставим как есть
|
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
message.error("Ошибка создания строки");
|
message.error("Ошибка создания строки");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// ДОБАВЛЕНО: Удаление строки
|
|
||||||
const deleteItemMutation = useMutation({
|
const deleteItemMutation = useMutation({
|
||||||
mutationFn: (itemId: string) => api.deleteDraftItem(id!, itemId),
|
mutationFn: (itemId: string) => api.deleteDraftItem(id!, itemId),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -6542,16 +6757,30 @@ export const InvoiceDraftPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
{/* Правая часть хедера: Кнопка чека и Кнопка удаления */}
|
||||||
danger={isCanceled}
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
type={isCanceled ? "primary" : "default"}
|
{/* Кнопка просмотра чека (только если есть URL) */}
|
||||||
icon={isCanceled ? <DeleteOutlined /> : <RestOutlined />}
|
{draft.photo_url && (
|
||||||
onClick={handleDelete}
|
<Button
|
||||||
loading={deleteDraftMutation.isPending}
|
icon={<FileImageOutlined />}
|
||||||
size="small"
|
onClick={() => setPreviewVisible(true)}
|
||||||
>
|
size="small"
|
||||||
{isCanceled ? "Удалить" : "Отмена"}
|
>
|
||||||
</Button>
|
Чек
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
danger={isCanceled}
|
||||||
|
type={isCanceled ? "primary" : "default"}
|
||||||
|
icon={isCanceled ? <DeleteOutlined /> : <RestOutlined />}
|
||||||
|
onClick={handleDelete}
|
||||||
|
loading={deleteDraftMutation.isPending}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{isCanceled ? "Удалить" : "Отмена"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Form: Склады и Поставщики */}
|
{/* Form: Склады и Поставщики */}
|
||||||
@@ -6671,7 +6900,7 @@ export const InvoiceDraftPage: React.FC = () => {
|
|||||||
type="dashed"
|
type="dashed"
|
||||||
block
|
block
|
||||||
icon={<PlusOutlined />}
|
icon={<PlusOutlined />}
|
||||||
style={{ marginTop: 12, marginBottom: 80, height: 48 }} // Увеличенный margin bottom для Affix
|
style={{ marginTop: 12, marginBottom: 80, height: 48 }}
|
||||||
onClick={() => addItemMutation.mutate()}
|
onClick={() => addItemMutation.mutate()}
|
||||||
loading={addItemMutation.isPending}
|
loading={addItemMutation.isPending}
|
||||||
disabled={isCanceled}
|
disabled={isCanceled}
|
||||||
@@ -6725,6 +6954,22 @@ export const InvoiceDraftPage: React.FC = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Affix>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -6809,16 +7054,19 @@ import {
|
|||||||
TreeSelect,
|
TreeSelect,
|
||||||
Spin,
|
Spin,
|
||||||
message,
|
message,
|
||||||
|
Tabs,
|
||||||
} from "antd";
|
} from "antd";
|
||||||
import {
|
import {
|
||||||
SaveOutlined,
|
SaveOutlined,
|
||||||
BarChartOutlined,
|
BarChartOutlined,
|
||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
FolderOpenOutlined,
|
FolderOpenOutlined,
|
||||||
|
TeamOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { api } from "../services/api";
|
import { api } from "../services/api";
|
||||||
import type { UserSettings } from "../services/types";
|
import type { UserSettings } from "../services/types";
|
||||||
|
import { TeamList } from "../components/settings/TeamList";
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
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 (
|
return (
|
||||||
<div style={{ padding: "0 16px 80px" }}>
|
<div style={{ padding: "0 16px 80px" }}>
|
||||||
<Title level={4} style={{ marginTop: 16 }}>
|
<Title level={4} style={{ marginTop: 16 }}>
|
||||||
@@ -6943,90 +7300,8 @@ export const SettingsPage: React.FC = () => {
|
|||||||
</Row>
|
</Row>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Форма настроек */}
|
{/* Табы настроек */}
|
||||||
<Form form={form} layout="vertical">
|
<Tabs defaultActiveKey="general" items={tabsItems} />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -7062,11 +7337,21 @@ import type {
|
|||||||
AddContainerRequest,
|
AddContainerRequest,
|
||||||
AddContainerResponse,
|
AddContainerResponse,
|
||||||
DictionariesResponse,
|
DictionariesResponse,
|
||||||
DraftSummary
|
DraftSummary,
|
||||||
|
ServerUser,
|
||||||
|
UserRole
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
// Базовый URL
|
// Базовый 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;
|
const tg = window.Telegram?.WebApp;
|
||||||
@@ -7075,7 +7360,7 @@ const tg = window.Telegram?.WebApp;
|
|||||||
export const UNAUTHORIZED_EVENT = 'rms_unauthorized';
|
export const UNAUTHORIZED_EVENT = 'rms_unauthorized';
|
||||||
|
|
||||||
const apiClient = axios.create({
|
const apiClient = axios.create({
|
||||||
baseURL: API_URL,
|
baseURL: API_BASE_URL,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
@@ -7243,6 +7528,23 @@ export const api = {
|
|||||||
const { data } = await apiClient.get<ProductGroup[]>('/dictionaries/groups');
|
const { data } = await apiClient.get<ProductGroup[]>('/dictionaries/groups');
|
||||||
return data;
|
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 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) ---
|
// --- Каталог и Фасовки (API v2.0) ---
|
||||||
|
|
||||||
export interface ProductContainer {
|
export interface ProductContainer {
|
||||||
@@ -7386,6 +7702,7 @@ export interface UserSettings {
|
|||||||
root_group_id: UUID | null;
|
root_group_id: UUID | null;
|
||||||
default_store_id: UUID | null;
|
default_store_id: UUID | null;
|
||||||
auto_conduct: boolean;
|
auto_conduct: boolean;
|
||||||
|
role: UserRole; // Добавляем поле роли в настройки текущего пользователя
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InvoiceStats {
|
export interface InvoiceStats {
|
||||||
@@ -7449,6 +7766,7 @@ export interface DraftInvoice {
|
|||||||
comment: string;
|
comment: string;
|
||||||
items: DraftItem[];
|
items: DraftItem[];
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
|
photo_url?: string; // Добавлено поле фото чека
|
||||||
}
|
}
|
||||||
|
|
||||||
// DTO для обновления строки
|
// DTO для обновления строки
|
||||||
|
|||||||
Reference in New Issue
Block a user