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 - Основная точка входа для добавления сервера
|
||||
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, // Дефолтное ограничение
|
||||
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
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,6 +6757,19 @@ export const InvoiceDraftPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Правая часть хедера: Кнопка чека и Кнопка удаления */}
|
||||
<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"}
|
||||
@@ -6553,6 +6781,7 @@ export const InvoiceDraftPage: React.FC = () => {
|
||||
{isCanceled ? "Удалить" : "Отмена"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form: Склады и Поставщики */}
|
||||
<div
|
||||
@@ -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 для обновления строки
|
||||
|
||||
Reference in New Issue
Block a user