mirror of
https://github.com/serty2005/rmser.git
synced 2026-02-04 19:02:33 -06:00
2701-есть флоу для оператора и красивый список накладных
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,6 +6,7 @@ pack_py_files.py
|
|||||||
pack_react_files.py
|
pack_react_files.py
|
||||||
ocr-service/python_project_dump.py
|
ocr-service/python_project_dump.py
|
||||||
project_dump.py
|
project_dump.py
|
||||||
|
python_project_dump.py
|
||||||
temp
|
temp
|
||||||
|
|
||||||
node_modules
|
node_modules
|
||||||
@@ -113,7 +113,7 @@ func main() {
|
|||||||
|
|
||||||
// 8. Telegram Bot (Передаем syncService)
|
// 8. Telegram Bot (Передаем syncService)
|
||||||
if cfg.Telegram.Token != "" {
|
if cfg.Telegram.Token != "" {
|
||||||
bot, err := tgBot.NewBot(cfg.Telegram, ocrService, syncService, billingService, accountRepo, rmsFactory, cryptoManager, cfg.App.MaintenanceMode, cfg.App.DevIDs)
|
bot, err := tgBot.NewBot(cfg.Telegram, ocrService, syncService, billingService, accountRepo, rmsFactory, cryptoManager, draftsService, cfg.App.MaintenanceMode, cfg.App.DevIDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Log.Fatal("Ошибка создания Telegram бота", zap.Error(err))
|
logger.Log.Fatal("Ошибка создания Telegram бота", zap.Error(err))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ app:
|
|||||||
storage_path: "./uploads"
|
storage_path: "./uploads"
|
||||||
public_url: "https://rmser.serty.top"
|
public_url: "https://rmser.serty.top"
|
||||||
maintenance_mode: true
|
maintenance_mode: true
|
||||||
dev_ids: [665599275] # Укажите здесь ваш ID и ID тестировщиков
|
dev_ids: [665599275,2126923472] # Укажите здесь ваш ID и ID тестировщиков
|
||||||
|
|
||||||
db:
|
db:
|
||||||
dsn: "host=postgres user=rmser password=mhrcadmin994525 dbname=rmser_db port=5432 sslmode=disable TimeZone=Europe/Moscow"
|
dsn: "host=postgres user=rmser password=mhrcadmin994525 dbname=rmser_db port=5432 sslmode=disable TimeZone=Europe/Moscow"
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ type ServerUser struct {
|
|||||||
Role Role `gorm:"type:varchar(20);default:'OPERATOR'"`
|
Role Role `gorm:"type:varchar(20);default:'OPERATOR'"`
|
||||||
IsActive bool `gorm:"default:false"` // Выбран ли этот сервер сейчас
|
IsActive bool `gorm:"default:false"` // Выбран ли этот сервер сейчас
|
||||||
|
|
||||||
|
// === Настройки уведомлений ===
|
||||||
|
MuteDraftNotifications bool `gorm:"default:false" json:"mute_draft_notifications"` // Не получать уведомления о новых черновиках
|
||||||
|
|
||||||
// Персональные данные для подключения (могут быть null у операторов)
|
// Персональные данные для подключения (могут быть null у операторов)
|
||||||
Login string `gorm:"type:varchar(100)"`
|
Login string `gorm:"type:varchar(100)"`
|
||||||
EncryptedPassword string `gorm:"type:text"`
|
EncryptedPassword string `gorm:"type:text"`
|
||||||
@@ -123,4 +126,12 @@ type Repository interface {
|
|||||||
|
|
||||||
// GetConnectionByID получает связь ServerUser по её ID (нужно для админки, чтобы сократить callback_data)
|
// GetConnectionByID получает связь ServerUser по её ID (нужно для админки, чтобы сократить callback_data)
|
||||||
GetConnectionByID(id uuid.UUID) (*ServerUser, error)
|
GetConnectionByID(id uuid.UUID) (*ServerUser, error)
|
||||||
|
|
||||||
|
// === Уведомления о черновиках ===
|
||||||
|
// GetServerUsersForDraftNotification возвращает Admin/Owner пользователей,
|
||||||
|
// которым нужно отправить уведомление о новом черновике
|
||||||
|
GetServerUsersForDraftNotification(serverID uuid.UUID, excludeUserID uuid.UUID) ([]ServerUser, error)
|
||||||
|
|
||||||
|
// SetMuteDraftNotifications включает/выключает уведомления для пользователя
|
||||||
|
SetMuteDraftNotifications(userID, serverID uuid.UUID, mute bool) error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
StatusDraft = "DRAFT"
|
||||||
StatusProcessing = "PROCESSING"
|
StatusProcessing = "PROCESSING"
|
||||||
StatusReadyToVerify = "READY_TO_VERIFY"
|
StatusReadyToVerify = "READY_TO_VERIFY"
|
||||||
StatusCompleted = "COMPLETED"
|
StatusCompleted = "COMPLETED"
|
||||||
|
|||||||
@@ -435,3 +435,25 @@ func (r *pgRepository) DecrementBalance(serverID uuid.UUID) error {
|
|||||||
Where("id = ? AND balance > 0", serverID).
|
Where("id = ? AND balance > 0", serverID).
|
||||||
UpdateColumn("balance", gorm.Expr("balance - ?", 1)).Error
|
UpdateColumn("balance", gorm.Expr("balance - ?", 1)).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === Уведомления о черновиках ===
|
||||||
|
|
||||||
|
// GetServerUsersForDraftNotification возвращает Admin/Owner пользователей,
|
||||||
|
// которым нужно отправить уведомление о новом черновике
|
||||||
|
func (r *pgRepository) GetServerUsersForDraftNotification(serverID uuid.UUID, excludeUserID uuid.UUID) ([]account.ServerUser, error) {
|
||||||
|
var users []account.ServerUser
|
||||||
|
err := r.db.Preload("User").
|
||||||
|
Where("server_id = ?", serverID).
|
||||||
|
Where("role IN ?", []account.Role{account.RoleOwner, account.RoleAdmin}).
|
||||||
|
Where("mute_draft_notifications = ?", false).
|
||||||
|
Where("user_id != ?", excludeUserID).
|
||||||
|
Find(&users).Error
|
||||||
|
return users, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMuteDraftNotifications включает/выключает уведомления для пользователя
|
||||||
|
func (r *pgRepository) SetMuteDraftNotifications(userID, serverID uuid.UUID, mute bool) error {
|
||||||
|
return r.db.Model(&account.ServerUser{}).
|
||||||
|
Where("user_id = ? AND server_id = ?", userID, serverID).
|
||||||
|
Update("mute_draft_notifications", mute).Error
|
||||||
|
}
|
||||||
|
|||||||
@@ -689,3 +689,168 @@ func (s *Service) GetInvoiceDetails(invoiceID, userID uuid.UUID) (*invoices.Invo
|
|||||||
|
|
||||||
return inv, photoURL, nil
|
return inv, photoURL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// === МЕТОДЫ ДЛЯ IN-CHAT DRAFT EDITOR ===
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// CreateDraft создаёт новый пустой черновик для пользователя
|
||||||
|
func (s *Service) CreateDraft(userID uuid.UUID) (*drafts.DraftInvoice, error) {
|
||||||
|
server, err := s.accountRepo.GetActiveServer(userID)
|
||||||
|
if err != nil || server == nil {
|
||||||
|
return nil, errors.New("нет активного сервера")
|
||||||
|
}
|
||||||
|
|
||||||
|
draft := &drafts.DraftInvoice{
|
||||||
|
ID: uuid.New(),
|
||||||
|
UserID: userID,
|
||||||
|
RMSServerID: server.ID,
|
||||||
|
Status: drafts.StatusProcessing,
|
||||||
|
DateIncoming: func() *time.Time {
|
||||||
|
t := time.Now()
|
||||||
|
return &t
|
||||||
|
}(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.draftRepo.Create(draft); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return draft, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDraftForEditor возвращает черновик для отображения в редакторе.
|
||||||
|
// Доступен ВСЕМ ролям (Owner, Admin, Operator).
|
||||||
|
// Проверяет только принадлежность черновика к активному серверу пользователя.
|
||||||
|
func (s *Service) GetDraftForEditor(draftID, userID uuid.UUID) (*drafts.DraftInvoice, error) {
|
||||||
|
draft, err := s.draftRepo.GetByID(draftID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
server, err := s.accountRepo.GetActiveServer(userID)
|
||||||
|
if err != nil || server == nil {
|
||||||
|
return nil, errors.New("нет активного сервера")
|
||||||
|
}
|
||||||
|
|
||||||
|
if draft.RMSServerID != server.ID {
|
||||||
|
return nil, errors.New("черновик не принадлежит активному серверу")
|
||||||
|
}
|
||||||
|
|
||||||
|
return draft, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateItemBelongsToDraft проверяет, что позиция принадлежит указанному черновику
|
||||||
|
func (s *Service) validateItemBelongsToDraft(item *drafts.DraftInvoiceItem, draftID uuid.UUID) error {
|
||||||
|
if item.DraftID != draftID {
|
||||||
|
return errors.New("позиция не принадлежит указанному черновику")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateItemRawName обновляет raw_name позиции черновика.
|
||||||
|
// Возвращает обновлённую позицию.
|
||||||
|
func (s *Service) UpdateItemRawName(draftID, itemID uuid.UUID, newName string) (*drafts.DraftInvoiceItem, error) {
|
||||||
|
item, err := s.draftRepo.GetItemByID(itemID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.validateItemBelongsToDraft(item, draftID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
item.RawName = strings.TrimSpace(newName)
|
||||||
|
if item.RawName == "" {
|
||||||
|
return nil, errors.New("название позиции не может быть пустым")
|
||||||
|
}
|
||||||
|
|
||||||
|
updates := map[string]interface{}{
|
||||||
|
"raw_name": item.RawName,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.draftRepo.UpdateItem(itemID, updates); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateItemQuantity обновляет количество позиции и пересчитывает сумму.
|
||||||
|
// Возвращает обновлённую позицию.
|
||||||
|
func (s *Service) UpdateItemQuantity(draftID, itemID uuid.UUID, qty decimal.Decimal) (*drafts.DraftInvoiceItem, error) {
|
||||||
|
item, err := s.draftRepo.GetItemByID(itemID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.validateItemBelongsToDraft(item, draftID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
item.Quantity = qty
|
||||||
|
s.RecalculateItemFields(item, drafts.FieldQuantity)
|
||||||
|
|
||||||
|
updates := map[string]interface{}{
|
||||||
|
"quantity": item.Quantity,
|
||||||
|
"sum": item.Sum,
|
||||||
|
"last_edited_field1": item.LastEditedField1,
|
||||||
|
"last_edited_field2": item.LastEditedField2,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.draftRepo.UpdateItem(itemID, updates); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateItemPrice обновляет цену позиции и пересчитывает сумму.
|
||||||
|
// Возвращает обновлённую позицию.
|
||||||
|
func (s *Service) UpdateItemPrice(draftID, itemID uuid.UUID, price decimal.Decimal) (*drafts.DraftInvoiceItem, error) {
|
||||||
|
item, err := s.draftRepo.GetItemByID(itemID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.validateItemBelongsToDraft(item, draftID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
item.Price = price
|
||||||
|
s.RecalculateItemFields(item, drafts.FieldPrice)
|
||||||
|
|
||||||
|
updates := map[string]interface{}{
|
||||||
|
"price": item.Price,
|
||||||
|
"sum": item.Sum,
|
||||||
|
"last_edited_field1": item.LastEditedField1,
|
||||||
|
"last_edited_field2": item.LastEditedField2,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.draftRepo.UpdateItem(itemID, updates); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetDraftReadyToVerify переводит черновик в статус READY_TO_VERIFY.
|
||||||
|
// Вызывается при подтверждении оператором.
|
||||||
|
// Возвращает обновлённый черновик.
|
||||||
|
func (s *Service) SetDraftReadyToVerify(draftID, userID uuid.UUID) (*drafts.DraftInvoice, error) {
|
||||||
|
draft, err := s.GetDraftForEditor(draftID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if draft.Status == drafts.StatusCompleted {
|
||||||
|
return nil, errors.New("черновик уже завершён")
|
||||||
|
}
|
||||||
|
|
||||||
|
draft.Status = drafts.StatusReadyToVerify
|
||||||
|
if err := s.draftRepo.Update(draft); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return draft, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,12 +20,24 @@ import (
|
|||||||
"rmser/internal/domain/account"
|
"rmser/internal/domain/account"
|
||||||
"rmser/internal/infrastructure/rms"
|
"rmser/internal/infrastructure/rms"
|
||||||
"rmser/internal/services/billing"
|
"rmser/internal/services/billing"
|
||||||
|
draftsService "rmser/internal/services/drafts"
|
||||||
"rmser/internal/services/ocr"
|
"rmser/internal/services/ocr"
|
||||||
"rmser/internal/services/sync"
|
"rmser/internal/services/sync"
|
||||||
"rmser/pkg/crypto"
|
"rmser/pkg/crypto"
|
||||||
"rmser/pkg/logger"
|
"rmser/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const DraftEditorPageSize = 10
|
||||||
|
|
||||||
|
// shortUUID возвращает первые 8 символов UUID для callback data
|
||||||
|
func shortUUID(id uuid.UUID) string {
|
||||||
|
s := id.String()
|
||||||
|
if len(s) >= 8 {
|
||||||
|
return s[:8]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
type Bot struct {
|
type Bot struct {
|
||||||
b *tele.Bot
|
b *tele.Bot
|
||||||
ocrService *ocr.Service
|
ocrService *ocr.Service
|
||||||
@@ -34,6 +46,8 @@ type Bot struct {
|
|||||||
accountRepo account.Repository
|
accountRepo account.Repository
|
||||||
rmsFactory *rms.Factory
|
rmsFactory *rms.Factory
|
||||||
cryptoManager *crypto.CryptoManager
|
cryptoManager *crypto.CryptoManager
|
||||||
|
draftsService *draftsService.Service
|
||||||
|
draftEditor *DraftEditor
|
||||||
|
|
||||||
fsm *StateManager
|
fsm *StateManager
|
||||||
adminIDs map[int64]struct{}
|
adminIDs map[int64]struct{}
|
||||||
@@ -54,6 +68,7 @@ func NewBot(
|
|||||||
accountRepo account.Repository,
|
accountRepo account.Repository,
|
||||||
rmsFactory *rms.Factory,
|
rmsFactory *rms.Factory,
|
||||||
cryptoManager *crypto.CryptoManager,
|
cryptoManager *crypto.CryptoManager,
|
||||||
|
draftsService *draftsService.Service,
|
||||||
maintenanceMode bool,
|
maintenanceMode bool,
|
||||||
devIDs []int64,
|
devIDs []int64,
|
||||||
) (*Bot, error) {
|
) (*Bot, error) {
|
||||||
@@ -89,6 +104,7 @@ func NewBot(
|
|||||||
accountRepo: accountRepo,
|
accountRepo: accountRepo,
|
||||||
rmsFactory: rmsFactory,
|
rmsFactory: rmsFactory,
|
||||||
cryptoManager: cryptoManager,
|
cryptoManager: cryptoManager,
|
||||||
|
draftsService: draftsService,
|
||||||
fsm: NewStateManager(),
|
fsm: NewStateManager(),
|
||||||
adminIDs: admins,
|
adminIDs: admins,
|
||||||
devIDs: devs,
|
devIDs: devs,
|
||||||
@@ -100,6 +116,8 @@ func NewBot(
|
|||||||
bot.webAppURL = "http://example.com"
|
bot.webAppURL = "http://example.com"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bot.draftEditor = NewDraftEditor(bot.b, bot.draftsService, bot.accountRepo, bot.fsm)
|
||||||
|
|
||||||
bot.initMenus()
|
bot.initMenus()
|
||||||
bot.initHandlers()
|
bot.initHandlers()
|
||||||
return bot, nil
|
return bot, nil
|
||||||
@@ -133,6 +151,8 @@ func (bot *Bot) initHandlers() {
|
|||||||
|
|
||||||
bot.b.Handle("/start", bot.handleStartCommand)
|
bot.b.Handle("/start", bot.handleStartCommand)
|
||||||
bot.b.Handle("/admin", bot.handleAdminCommand)
|
bot.b.Handle("/admin", bot.handleAdminCommand)
|
||||||
|
bot.b.Handle("/draft", bot.draftEditor.handleDraftCommand)
|
||||||
|
bot.b.Handle("/newdraft", bot.draftEditor.handleNewDraftCommand)
|
||||||
|
|
||||||
bot.b.Handle(&tele.Btn{Unique: "nav_main"}, bot.renderMainMenu)
|
bot.b.Handle(&tele.Btn{Unique: "nav_main"}, bot.renderMainMenu)
|
||||||
bot.b.Handle(&tele.Btn{Unique: "nav_servers"}, bot.renderServersMenu)
|
bot.b.Handle(&tele.Btn{Unique: "nav_servers"}, bot.renderServersMenu)
|
||||||
@@ -152,6 +172,10 @@ func (bot *Bot) initHandlers() {
|
|||||||
bot.b.Handle(tele.OnText, bot.handleText)
|
bot.b.Handle(tele.OnText, bot.handleText)
|
||||||
bot.b.Handle(tele.OnPhoto, bot.handlePhoto)
|
bot.b.Handle(tele.OnPhoto, bot.handlePhoto)
|
||||||
bot.b.Handle(tele.OnDocument, bot.handleDocument)
|
bot.b.Handle(tele.OnDocument, bot.handleDocument)
|
||||||
|
|
||||||
|
bot.b.Handle(&tele.Btn{Unique: "noop"}, func(c tele.Context) error {
|
||||||
|
return c.Respond()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (bot *Bot) Start() {
|
func (bot *Bot) Start() {
|
||||||
@@ -471,6 +495,15 @@ func (bot *Bot) handleCallback(c tele.Context) error {
|
|||||||
return c.Respond(&tele.CallbackResponse{Text: "Сервис на обслуживании"})
|
return c.Respond(&tele.CallbackResponse{Text: "Сервис на обслуживании"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === DRAFT EDITOR CALLBACKS ===
|
||||||
|
// Проверяем префиксы редактора черновиков
|
||||||
|
draftPrefixes := []string{"di:", "dp:", "da:", "dc:", "dx:", "den:", "deq:", "dep:", "did:", "dib:"}
|
||||||
|
for _, prefix := range draftPrefixes {
|
||||||
|
if strings.HasPrefix(data, prefix) {
|
||||||
|
return bot.draftEditor.handleDraftEditorCallback(c, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- INTEGRATION: Billing Callbacks ---
|
// --- INTEGRATION: Billing Callbacks ---
|
||||||
if strings.HasPrefix(data, "bill_") || strings.HasPrefix(data, "buy_id_") || strings.HasPrefix(data, "pay_") {
|
if strings.HasPrefix(data, "bill_") || strings.HasPrefix(data, "buy_id_") || strings.HasPrefix(data, "pay_") {
|
||||||
return bot.handleBillingCallbacks(c, data, userDB)
|
return bot.handleBillingCallbacks(c, data, userDB)
|
||||||
@@ -749,6 +782,14 @@ func (bot *Bot) handleText(c tele.Context) error {
|
|||||||
return bot.renderMainMenu(c)
|
return bot.renderMainMenu(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === DRAFT EDITOR TEXT HANDLERS ===
|
||||||
|
// Проверяем, находимся ли мы в одном из состояний редактирования черновика
|
||||||
|
if state == StateDraftEditItemName ||
|
||||||
|
state == StateDraftEditItemQty ||
|
||||||
|
state == StateDraftEditItemPrice {
|
||||||
|
return bot.draftEditor.handleDraftEditorText(c)
|
||||||
|
}
|
||||||
|
|
||||||
if state == StateNone {
|
if state == StateNone {
|
||||||
return c.Send("Используйте меню для навигации 👇")
|
return c.Send("Используйте меню для навигации 👇")
|
||||||
}
|
}
|
||||||
@@ -864,6 +905,7 @@ func (bot *Bot) handlePhoto(c tele.Context) error {
|
|||||||
matchedCount++
|
matchedCount++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Для разработчиков отправляем debug-информацию
|
||||||
if bot.isDev(userID) {
|
if bot.isDev(userID) {
|
||||||
baseURL := strings.TrimRight(bot.webAppURL, "/")
|
baseURL := strings.TrimRight(bot.webAppURL, "/")
|
||||||
fullURL := fmt.Sprintf("%s/invoice/%s", baseURL, draft.ID.String())
|
fullURL := fmt.Sprintf("%s/invoice/%s", baseURL, draft.ID.String())
|
||||||
@@ -881,10 +923,12 @@ func (bot *Bot) handlePhoto(c tele.Context) error {
|
|||||||
} else {
|
} else {
|
||||||
msgText += "\n\n<i>(Редактирование доступно Администратору)</i>"
|
msgText += "\n\n<i>(Редактирование доступно Администратору)</i>"
|
||||||
}
|
}
|
||||||
return c.Send(msgText, menu, tele.ModeHTML)
|
c.Send(msgText, menu, tele.ModeHTML)
|
||||||
} else {
|
|
||||||
return c.Send("✅ **Фото принято!** Мы получили ваш документ и передали его оператору. Спасибо!", tele.ModeHTML)
|
|
||||||
}
|
}
|
||||||
|
// Инициализируем FSM редактора для всех пользователей
|
||||||
|
bot.fsm.InitDraftEditor(userID, draft.ID)
|
||||||
|
// Показываем меню редактора
|
||||||
|
return bot.draftEditor.renderDraftEditorMenu(c, draft, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (bot *Bot) handleDocument(c tele.Context) error {
|
func (bot *Bot) handleDocument(c tele.Context) error {
|
||||||
@@ -942,6 +986,7 @@ func (bot *Bot) handleDocument(c tele.Context) error {
|
|||||||
matchedCount++
|
matchedCount++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Для разработчиков отправляем debug-информацию
|
||||||
if bot.isDev(userID) {
|
if bot.isDev(userID) {
|
||||||
baseURL := strings.TrimRight(bot.webAppURL, "/")
|
baseURL := strings.TrimRight(bot.webAppURL, "/")
|
||||||
fullURL := fmt.Sprintf("%s/invoice/%s", baseURL, draft.ID.String())
|
fullURL := fmt.Sprintf("%s/invoice/%s", baseURL, draft.ID.String())
|
||||||
@@ -959,10 +1004,12 @@ func (bot *Bot) handleDocument(c tele.Context) error {
|
|||||||
} else {
|
} else {
|
||||||
msgText += "\n\n<i>(Редактирование доступно Администратору)</i>"
|
msgText += "\n\n<i>(Редактирование доступно Администратору)</i>"
|
||||||
}
|
}
|
||||||
return c.Send(msgText, menu, tele.ModeHTML)
|
c.Send(msgText, menu, tele.ModeHTML)
|
||||||
} else {
|
|
||||||
return c.Send("✅ **Документ принят!** Мы получили ваш документ и передали его оператору. Спасибо!", tele.ModeHTML)
|
|
||||||
}
|
}
|
||||||
|
// Инициализируем FSM редактора для всех пользователей
|
||||||
|
bot.fsm.InitDraftEditor(userID, draft.ID)
|
||||||
|
// Показываем меню редактора
|
||||||
|
return bot.draftEditor.renderDraftEditorMenu(c, draft, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (bot *Bot) handleConfirmNameYes(c tele.Context) error {
|
func (bot *Bot) handleConfirmNameYes(c tele.Context) error {
|
||||||
|
|||||||
826
internal/transport/telegram/draft_editor.go
Normal file
826
internal/transport/telegram/draft_editor.go
Normal file
@@ -0,0 +1,826 @@
|
|||||||
|
// internal/transport/telegram/draft_editor.go
|
||||||
|
|
||||||
|
package telegram
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/shopspring/decimal"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
tele "gopkg.in/telebot.v3"
|
||||||
|
|
||||||
|
"rmser/internal/domain/account"
|
||||||
|
"rmser/internal/domain/drafts"
|
||||||
|
draftsService "rmser/internal/services/drafts"
|
||||||
|
"rmser/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DraftEditor управляет редактированием черновиков накладных
|
||||||
|
type DraftEditor struct {
|
||||||
|
bot *tele.Bot
|
||||||
|
draftsService *draftsService.Service
|
||||||
|
accountRepo account.Repository
|
||||||
|
fsm *StateManager
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDraftEditor создаёт новый экземпляр редактора черновиков
|
||||||
|
func NewDraftEditor(bot *tele.Bot, draftsService *draftsService.Service, accountRepo account.Repository, fsm *StateManager) *DraftEditor {
|
||||||
|
return &DraftEditor{
|
||||||
|
bot: bot,
|
||||||
|
draftsService: draftsService,
|
||||||
|
accountRepo: accountRepo,
|
||||||
|
fsm: fsm,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// === КОМАНДЫ ЗАПУСКА РЕДАКТОРА ЧЕРНОВИКОВ ===
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// handleDraftCommand обрабатывает команду /draft <draft_id>
|
||||||
|
// Запускает редактор для указанного черновика
|
||||||
|
func (de *DraftEditor) handleDraftCommand(c tele.Context) error {
|
||||||
|
userID := c.Sender().ID
|
||||||
|
|
||||||
|
// Получаем аргументы команды
|
||||||
|
args := c.Args()
|
||||||
|
if len(args) == 0 {
|
||||||
|
return c.Send("❌ Укажите ID черновика.\n\nПример: /draft 123e4567-e89b-12d3-a456-426614174000")
|
||||||
|
}
|
||||||
|
|
||||||
|
draftIDStr := args[0]
|
||||||
|
|
||||||
|
// Парсим UUID
|
||||||
|
draftID, err := uuid.Parse(draftIDStr)
|
||||||
|
if err != nil {
|
||||||
|
return c.Send("❌ Некорректный формат ID черновика.\n\nПример: /draft 123e4567-e89b-12d3-a456-426614174000")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем пользователя
|
||||||
|
userDB, err := de.accountRepo.GetUserByTelegramID(userID)
|
||||||
|
if err != nil {
|
||||||
|
return c.Send("❌ Ошибка получения данных пользователя")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем черновик
|
||||||
|
draft, err := de.draftsService.GetDraftForEditor(draftID, userDB.ID)
|
||||||
|
if err != nil {
|
||||||
|
return c.Send("❌ Черновик не найден или у вас нет прав на его редактирование")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем статус черновика
|
||||||
|
if draft.Status == drafts.StatusCanceled {
|
||||||
|
return c.Send("❌ Этот черновик нельзя редактировать. Текущий статус: " + string(draft.Status))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Инициализируем FSM для редактора
|
||||||
|
de.fsm.InitDraftEditor(userID, draft.ID)
|
||||||
|
|
||||||
|
// Показываем редактор
|
||||||
|
return de.renderDraftEditorMenu(c, draft, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleNewDraftCommand обрабатывает команду /newdraft
|
||||||
|
// Создаёт новый черновик и запускает редактор
|
||||||
|
func (de *DraftEditor) handleNewDraftCommand(c tele.Context) error {
|
||||||
|
userID := c.Sender().ID
|
||||||
|
|
||||||
|
// Получаем пользователя
|
||||||
|
userDB, err := de.accountRepo.GetUserByTelegramID(userID)
|
||||||
|
if err != nil {
|
||||||
|
return c.Send("❌ Ошибка получения данных пользователя")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаём новый черновик
|
||||||
|
draft, err := de.draftsService.CreateDraft(userDB.ID)
|
||||||
|
if err != nil {
|
||||||
|
return c.Send("❌ Ошибка создания черновика: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Инициализируем FSM для редактора
|
||||||
|
de.fsm.InitDraftEditor(userID, draft.ID)
|
||||||
|
|
||||||
|
// Показываем редактор
|
||||||
|
return de.renderDraftEditorMenu(c, draft, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// === РЕНДЕРИНГ МЕНЮ РЕДАКТОРА ЧЕРНОВИКОВ ===
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// renderDraftEditorMenu отображает интерактивный список позиций черновика
|
||||||
|
// с пагинацией и кнопками управления.
|
||||||
|
// Параметры:
|
||||||
|
// - c: контекст Telegram
|
||||||
|
// - draft: черновик для отображения
|
||||||
|
// - page: номер страницы (0-based)
|
||||||
|
//
|
||||||
|
// Сохраняет ID сообщения в FSM для последующего редактирования.
|
||||||
|
func (de *DraftEditor) renderDraftEditorMenu(c tele.Context, draft *drafts.DraftInvoice, page int) error {
|
||||||
|
// Расчёт пагинации
|
||||||
|
totalItems := len(draft.Items)
|
||||||
|
totalPages := (totalItems + DraftEditorPageSize - 1) / DraftEditorPageSize
|
||||||
|
if totalPages == 0 {
|
||||||
|
totalPages = 1
|
||||||
|
}
|
||||||
|
if page < 0 {
|
||||||
|
page = 0
|
||||||
|
}
|
||||||
|
if page >= totalPages {
|
||||||
|
page = totalPages - 1
|
||||||
|
}
|
||||||
|
startIdx := page * DraftEditorPageSize
|
||||||
|
endIdx := startIdx + DraftEditorPageSize
|
||||||
|
if endIdx > totalItems {
|
||||||
|
endIdx = totalItems
|
||||||
|
}
|
||||||
|
|
||||||
|
// Расчёт общей суммы
|
||||||
|
var totalSum decimal.Decimal
|
||||||
|
for _, item := range draft.Items {
|
||||||
|
if !item.Sum.IsZero() {
|
||||||
|
totalSum = totalSum.Add(item.Sum)
|
||||||
|
} else {
|
||||||
|
totalSum = totalSum.Add(item.Quantity.Mul(item.Price))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Формирование текста заголовка
|
||||||
|
dateStr := "—"
|
||||||
|
if draft.DateIncoming != nil {
|
||||||
|
dateStr = draft.DateIncoming.Format("02.01.2006")
|
||||||
|
}
|
||||||
|
|
||||||
|
txt := fmt.Sprintf(
|
||||||
|
"📄 <b>Черновик</b> от %s\n"+
|
||||||
|
"💰 Сумма: <b>%.2f ₽</b> | 📦 Позиций: <b>%d</b>\n\n"+
|
||||||
|
"👇 Нажмите на позицию для редактирования:\n",
|
||||||
|
dateStr,
|
||||||
|
totalSum.InexactFloat64(),
|
||||||
|
totalItems,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Формирование кнопок позиций
|
||||||
|
menu := &tele.ReplyMarkup{}
|
||||||
|
var rows []tele.Row
|
||||||
|
draftShort := shortUUID(draft.ID)
|
||||||
|
|
||||||
|
pageItems := draft.Items[startIdx:endIdx]
|
||||||
|
for i, item := range pageItems {
|
||||||
|
globalIdx := startIdx + i + 1 // 1-based для отображения
|
||||||
|
|
||||||
|
// Формируем label: "1. Название — 2 шт × 80.00 ₽"
|
||||||
|
qtyStr := item.Quantity.StringFixed(2)
|
||||||
|
priceStr := item.Price.StringFixed(2)
|
||||||
|
|
||||||
|
// Обрезаем название если слишком длинное
|
||||||
|
name := item.RawName
|
||||||
|
if len(name) > 25 {
|
||||||
|
name = name[:22] + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
label := fmt.Sprintf("%d. %s — %s × %s", globalIdx, name, qtyStr, priceStr)
|
||||||
|
|
||||||
|
callbackData := fmt.Sprintf("di:%s:%d", draftShort, globalIdx)
|
||||||
|
btn := menu.Data(label, callbackData)
|
||||||
|
rows = append(rows, menu.Row(btn))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Кнопки пагинации (если нужно)
|
||||||
|
if totalPages > 1 {
|
||||||
|
var navRow []tele.Btn
|
||||||
|
|
||||||
|
if page > 0 {
|
||||||
|
navRow = append(navRow, menu.Data("◀️ Назад", fmt.Sprintf("dp:%s:%d", draftShort, page-1)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Индикатор страницы (не кнопка, а часть текста - но в Telegram нельзя смешивать)
|
||||||
|
// Поэтому добавляем как неактивную кнопку или в текст сообщения
|
||||||
|
pageIndicator := menu.Data(fmt.Sprintf("📄 %d/%d", page+1, totalPages), "noop")
|
||||||
|
navRow = append(navRow, pageIndicator)
|
||||||
|
|
||||||
|
if page < totalPages-1 {
|
||||||
|
navRow = append(navRow, menu.Data("Вперёд ▶️", fmt.Sprintf("dp:%s:%d", draftShort, page+1)))
|
||||||
|
}
|
||||||
|
|
||||||
|
rows = append(rows, navRow)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Кнопки управления
|
||||||
|
// Кнопка "Добавить позицию"
|
||||||
|
btnAdd := menu.Data("➕ Добавить позицию", fmt.Sprintf("da:%s", draftShort))
|
||||||
|
rows = append(rows, menu.Row(btnAdd))
|
||||||
|
|
||||||
|
// Кнопки "Подтвердить" и "Отменить"
|
||||||
|
btnConfirm := menu.Data("✅ Подтвердить", fmt.Sprintf("dc:%s", draftShort))
|
||||||
|
btnCancel := menu.Data("❌ Отменить", fmt.Sprintf("dx:%s", draftShort))
|
||||||
|
rows = append(rows, menu.Row(btnConfirm, btnCancel))
|
||||||
|
|
||||||
|
menu.Inline(rows...)
|
||||||
|
|
||||||
|
// Обновляем контекст FSM
|
||||||
|
de.fsm.UpdateContext(c.Sender().ID, func(ctx *UserContext) {
|
||||||
|
ctx.EditingDraftID = draft.ID
|
||||||
|
ctx.DraftCurrentPage = page
|
||||||
|
})
|
||||||
|
|
||||||
|
// Отправляем или редактируем сообщение
|
||||||
|
return c.EditOrSend(txt, menu, tele.ModeHTML)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderItemEditMenu отображает меню редактирования конкретной позиции черновика.
|
||||||
|
// Параметры:
|
||||||
|
// - c: контекст Telegram
|
||||||
|
// - draft: черновик (для получения draftShort)
|
||||||
|
// - item: редактируемая позиция
|
||||||
|
// - itemIndex: порядковый номер позиции (1-based)
|
||||||
|
func (de *DraftEditor) renderItemEditMenu(c tele.Context, draft *drafts.DraftInvoice, item *drafts.DraftInvoiceItem, itemIndex int) error {
|
||||||
|
// Вычисление суммы
|
||||||
|
sum := item.Sum
|
||||||
|
if sum.IsZero() {
|
||||||
|
sum = item.Quantity.Mul(item.Price)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Формирование текста
|
||||||
|
txt := fmt.Sprintf(
|
||||||
|
"✏️ <b>Редактирование позиции №%d</b>\n\n"+
|
||||||
|
"📝 Название: <code>%s</code>\n"+
|
||||||
|
"📦 Количество: <b>%s</b>\n"+
|
||||||
|
"💰 Цена: <b>%s ₽</b>\n"+
|
||||||
|
"💵 Сумма: <b>%s ₽</b>\n\n"+
|
||||||
|
"Выберите, что изменить:",
|
||||||
|
itemIndex,
|
||||||
|
html.EscapeString(item.RawName),
|
||||||
|
item.Quantity.StringFixed(2),
|
||||||
|
item.Price.StringFixed(2),
|
||||||
|
sum.StringFixed(2),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Формирование кнопок
|
||||||
|
menu := &tele.ReplyMarkup{}
|
||||||
|
draftShort := shortUUID(draft.ID)
|
||||||
|
|
||||||
|
btnName := menu.Data("📝 Название", fmt.Sprintf("den:%s:%d", draftShort, itemIndex))
|
||||||
|
btnQty := menu.Data("📦 Количество", fmt.Sprintf("deq:%s:%d", draftShort, itemIndex))
|
||||||
|
btnPrice := menu.Data("💰 Цена", fmt.Sprintf("dep:%s:%d", draftShort, itemIndex))
|
||||||
|
btnDelete := menu.Data("🗑 Удалить", fmt.Sprintf("did:%s:%d", draftShort, itemIndex))
|
||||||
|
btnBack := menu.Data("🔙 Назад", fmt.Sprintf("dib:%s", draftShort))
|
||||||
|
|
||||||
|
menu.Inline(
|
||||||
|
menu.Row(btnName, btnQty, btnPrice),
|
||||||
|
menu.Row(btnDelete),
|
||||||
|
menu.Row(btnBack),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Обновление FSM
|
||||||
|
de.fsm.UpdateContext(c.Sender().ID, func(ctx *UserContext) {
|
||||||
|
ctx.EditingItemID = item.ID
|
||||||
|
ctx.EditingItemIndex = itemIndex
|
||||||
|
})
|
||||||
|
|
||||||
|
return c.Edit(txt, menu, tele.ModeHTML)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// === ВСПОМОГАТЕЛЬНЫЕ МЕТОДЫ РЕДАКТОРА ===
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// getItemByIndex возвращает позицию черновика по её порядковому номеру (1-based)
|
||||||
|
func (de *DraftEditor) getItemByIndex(draft *drafts.DraftInvoice, index int) *drafts.DraftInvoiceItem {
|
||||||
|
if index < 1 || index > len(draft.Items) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &draft.Items[index-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// promptForItemName запрашивает ввод нового названия позиции
|
||||||
|
func (de *DraftEditor) promptForItemName(c tele.Context, item *drafts.DraftInvoiceItem, itemIndex int) error {
|
||||||
|
de.fsm.SetState(c.Sender().ID, StateDraftEditItemName)
|
||||||
|
|
||||||
|
txt := fmt.Sprintf(
|
||||||
|
"📝 <b>Изменение названия позиции №%d</b>\n\n"+
|
||||||
|
"Текущее название: <code>%s</code>\n\n"+
|
||||||
|
"Введите новое название:",
|
||||||
|
itemIndex,
|
||||||
|
html.EscapeString(item.RawName),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Кнопка отмены
|
||||||
|
menu := &tele.ReplyMarkup{}
|
||||||
|
btnCancel := menu.Data("❌ Отмена", fmt.Sprintf("dib:%s", shortUUID(de.fsm.GetContext(c.Sender().ID).EditingDraftID)))
|
||||||
|
menu.Inline(menu.Row(btnCancel))
|
||||||
|
|
||||||
|
return c.Edit(txt, menu, tele.ModeHTML)
|
||||||
|
}
|
||||||
|
|
||||||
|
// promptForItemQuantity запрашивает ввод нового количества
|
||||||
|
func (de *DraftEditor) promptForItemQuantity(c tele.Context, item *drafts.DraftInvoiceItem, itemIndex int) error {
|
||||||
|
de.fsm.SetState(c.Sender().ID, StateDraftEditItemQty)
|
||||||
|
|
||||||
|
txt := fmt.Sprintf(
|
||||||
|
"📦 <b>Изменение количества позиции №%d</b>\n\n"+
|
||||||
|
"Текущее количество: <b>%s</b>\n\n"+
|
||||||
|
"Введите новое количество (число):",
|
||||||
|
itemIndex,
|
||||||
|
item.Quantity.StringFixed(2),
|
||||||
|
)
|
||||||
|
|
||||||
|
menu := &tele.ReplyMarkup{}
|
||||||
|
btnCancel := menu.Data("❌ Отмена", fmt.Sprintf("dib:%s", shortUUID(de.fsm.GetContext(c.Sender().ID).EditingDraftID)))
|
||||||
|
menu.Inline(menu.Row(btnCancel))
|
||||||
|
|
||||||
|
return c.Edit(txt, menu, tele.ModeHTML)
|
||||||
|
}
|
||||||
|
|
||||||
|
// promptForItemPrice запрашивает ввод новой цены
|
||||||
|
func (de *DraftEditor) promptForItemPrice(c tele.Context, item *drafts.DraftInvoiceItem, itemIndex int) error {
|
||||||
|
de.fsm.SetState(c.Sender().ID, StateDraftEditItemPrice)
|
||||||
|
|
||||||
|
txt := fmt.Sprintf(
|
||||||
|
"💰 <b>Изменение цены позиции №%d</b>\n\n"+
|
||||||
|
"Текущая цена: <b>%s ₽</b>\n\n"+
|
||||||
|
"Введите новую цену (число):",
|
||||||
|
itemIndex,
|
||||||
|
item.Price.StringFixed(2),
|
||||||
|
)
|
||||||
|
|
||||||
|
menu := &tele.ReplyMarkup{}
|
||||||
|
btnCancel := menu.Data("❌ Отмена", fmt.Sprintf("dib:%s", shortUUID(de.fsm.GetContext(c.Sender().ID).EditingDraftID)))
|
||||||
|
menu.Inline(menu.Row(btnCancel))
|
||||||
|
|
||||||
|
return c.Edit(txt, menu, tele.ModeHTML)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// === ОБРАБОТЧИКИ CALLBACK'ОВ РЕДАКТОРА ЧЕРНОВИКОВ ===
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// parseDraftCallback разбирает callback data редактора черновиков.
|
||||||
|
// Формат: "prefix:draftShort:param" или "prefix:draftShort"
|
||||||
|
// Возвращает: prefix, draftShort, param (если есть), error
|
||||||
|
func (de *DraftEditor) parseDraftCallback(data string) (prefix, draftShort string, param int, err error) {
|
||||||
|
// Убираем возможный префикс \f от telebot
|
||||||
|
if len(data) > 0 && data[0] == '\f' {
|
||||||
|
data = data[1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(data, ":")
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return "", "", 0, errors.New("invalid callback format")
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix = parts[0]
|
||||||
|
draftShort = parts[1]
|
||||||
|
|
||||||
|
if len(parts) >= 3 {
|
||||||
|
param, err = strconv.Atoi(parts[2])
|
||||||
|
if err != nil {
|
||||||
|
return "", "", 0, fmt.Errorf("invalid param: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return prefix, draftShort, param, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// findDraftByShortID ищет черновик по первым 8 символам UUID.
|
||||||
|
// Использует EditingDraftID из FSM контекста для точного совпадения.
|
||||||
|
func (de *DraftEditor) findDraftByShortID(userID int64, draftShort string) (*drafts.DraftInvoice, error) {
|
||||||
|
ctx := de.fsm.GetContext(userID)
|
||||||
|
|
||||||
|
// Проверяем, совпадает ли shortUUID с сохранённым в контексте
|
||||||
|
if shortUUID(ctx.EditingDraftID) == draftShort {
|
||||||
|
userDB, err := de.accountRepo.GetUserByTelegramID(userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return de.draftsService.GetDraftForEditor(ctx.EditingDraftID, userDB.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, errors.New("черновик не найден или сессия истекла")
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDraftEditorCallback обрабатывает все callback'ы редактора черновиков
|
||||||
|
func (de *DraftEditor) handleDraftEditorCallback(c tele.Context, data string) error {
|
||||||
|
prefix, draftShort, param, err := de.parseDraftCallback(data)
|
||||||
|
if err != nil {
|
||||||
|
return c.Respond(&tele.CallbackResponse{Text: "Ошибка: " + err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := c.Sender().ID
|
||||||
|
|
||||||
|
// Получаем черновик
|
||||||
|
draft, err := de.findDraftByShortID(userID, draftShort)
|
||||||
|
if err != nil {
|
||||||
|
return c.Respond(&tele.CallbackResponse{Text: "Черновик не найден. Начните заново."})
|
||||||
|
}
|
||||||
|
|
||||||
|
switch prefix {
|
||||||
|
case "di": // Draft Item — открыть меню позиции
|
||||||
|
return de.handleDraftItemSelect(c, draft, param)
|
||||||
|
|
||||||
|
case "dp": // Draft Page — пагинация
|
||||||
|
return de.handleDraftPageChange(c, draft, param)
|
||||||
|
|
||||||
|
case "da": // Draft Add — добавить позицию
|
||||||
|
return de.handleDraftAddItem(c, draft)
|
||||||
|
|
||||||
|
case "dc": // Draft Confirm — подтвердить
|
||||||
|
return de.handleDraftConfirm(c, draft)
|
||||||
|
|
||||||
|
case "dx": // Draft eXit — отменить
|
||||||
|
return de.handleDraftCancel(c, draft)
|
||||||
|
|
||||||
|
case "den": // Draft Edit Name
|
||||||
|
return de.handleDraftEditName(c, draft, param)
|
||||||
|
|
||||||
|
case "deq": // Draft Edit Quantity
|
||||||
|
return de.handleDraftEditQuantity(c, draft, param)
|
||||||
|
|
||||||
|
case "dep": // Draft Edit Price
|
||||||
|
return de.handleDraftEditPrice(c, draft, param)
|
||||||
|
|
||||||
|
case "did": // Draft Item Delete
|
||||||
|
return de.handleDraftItemDelete(c, draft, param)
|
||||||
|
|
||||||
|
case "dib": // Draft Item Back — назад к списку
|
||||||
|
return de.handleDraftItemBack(c, draft)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDraftItemSelect — открытие меню позиции
|
||||||
|
func (de *DraftEditor) handleDraftItemSelect(c tele.Context, draft *drafts.DraftInvoice, itemIndex int) error {
|
||||||
|
item := de.getItemByIndex(draft, itemIndex)
|
||||||
|
if item == nil {
|
||||||
|
return c.Respond(&tele.CallbackResponse{Text: "Позиция не найдена"})
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Respond() // Убираем "часики"
|
||||||
|
return de.renderItemEditMenu(c, draft, item, itemIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDraftPageChange — переход на страницу
|
||||||
|
func (de *DraftEditor) handleDraftPageChange(c tele.Context, draft *drafts.DraftInvoice, page int) error {
|
||||||
|
c.Respond()
|
||||||
|
return de.renderDraftEditorMenu(c, draft, page)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDraftAddItem — добавление позиции
|
||||||
|
func (de *DraftEditor) handleDraftAddItem(c tele.Context, draft *drafts.DraftInvoice) error {
|
||||||
|
userDB, _ := de.accountRepo.GetUserByTelegramID(c.Sender().ID)
|
||||||
|
|
||||||
|
_, err := de.draftsService.AddItem(draft.ID)
|
||||||
|
if err != nil {
|
||||||
|
return c.Respond(&tele.CallbackResponse{Text: "Ошибка: " + err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Respond(&tele.CallbackResponse{Text: "✅ Позиция добавлена"})
|
||||||
|
|
||||||
|
// Перезагружаем черновик и переходим на последнюю страницу
|
||||||
|
updatedDraft, _ := de.draftsService.GetDraftForEditor(draft.ID, userDB.ID)
|
||||||
|
lastPage := (len(updatedDraft.Items) - 1) / DraftEditorPageSize
|
||||||
|
|
||||||
|
return de.renderDraftEditorMenu(c, updatedDraft, lastPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDraftConfirm — подтверждение черновика
|
||||||
|
func (de *DraftEditor) handleDraftConfirm(c tele.Context, draft *drafts.DraftInvoice) error {
|
||||||
|
userDB, _ := de.accountRepo.GetUserByTelegramID(c.Sender().ID)
|
||||||
|
|
||||||
|
// Переводим в статус READY_TO_VERIFY
|
||||||
|
updatedDraft, err := de.draftsService.SetDraftReadyToVerify(draft.ID, userDB.ID)
|
||||||
|
if err != nil {
|
||||||
|
return c.Respond(&tele.CallbackResponse{Text: "Ошибка: " + err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сбрасываем FSM
|
||||||
|
de.fsm.ResetDraftEditor(c.Sender().ID)
|
||||||
|
|
||||||
|
c.Respond(&tele.CallbackResponse{Text: "✅ Черновик сохранён!"})
|
||||||
|
|
||||||
|
// Отправляем уведомление админам (будет реализовано на Этапе 9)
|
||||||
|
go de.notifyAdminsAboutDraft(updatedDraft, userDB)
|
||||||
|
|
||||||
|
// Получаем информацию о сервере для проверки авто-процессинга
|
||||||
|
server, err := de.accountRepo.GetServerByID(updatedDraft.RMSServerID)
|
||||||
|
if err != nil {
|
||||||
|
logger.Log.Warn("Не удалось получить информацию о сервере", zap.Error(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Формируем финальное сообщение
|
||||||
|
txt := fmt.Sprintf(
|
||||||
|
"✅ <b>Черновик успешно сохранён!</b>\n\n"+
|
||||||
|
"📊 <b>Статус:</b> <code>READY_TO_VERIFY</code>\n"+
|
||||||
|
"📦 <b>Позиций:</b> %d\n"+
|
||||||
|
"📅 <b>Дата:</b> %s\n\n",
|
||||||
|
len(updatedDraft.Items),
|
||||||
|
updatedDraft.DateIncoming.Format("02.01.2006"),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Добавляем информацию о дальнейшей обработке
|
||||||
|
if server != nil && server.AutoProcess {
|
||||||
|
txt += "🔄 <b>Авто-процессинг в iiko включён.</b>\n" +
|
||||||
|
"Черновик будет автоматически отправлен в систему iiko после проверки администратором.\n\n"
|
||||||
|
} else {
|
||||||
|
txt += "📋 <b>Черновик готов к дальнейшей обработке.</b>\n" +
|
||||||
|
"Администраторы получили уведомление и скоро проверят накладную.\n\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
txt += "Вы можете вернуться к работе через меню накладных."
|
||||||
|
|
||||||
|
return c.Edit(txt, tele.ModeHTML)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDraftCancel — отмена редактирования
|
||||||
|
func (de *DraftEditor) handleDraftCancel(c tele.Context, draft *drafts.DraftInvoice) error {
|
||||||
|
de.fsm.ResetDraftEditor(c.Sender().ID)
|
||||||
|
|
||||||
|
c.Respond(&tele.CallbackResponse{Text: "Редактирование отменено"})
|
||||||
|
|
||||||
|
txt := "❌ <b>Редактирование отменено</b>\n\n" +
|
||||||
|
"Черновик сохранён. Вы можете вернуться к нему позже через меню накладных."
|
||||||
|
|
||||||
|
return c.Edit(txt, tele.ModeHTML)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDraftEditName — переход к вводу названия
|
||||||
|
func (de *DraftEditor) handleDraftEditName(c tele.Context, draft *drafts.DraftInvoice, itemIndex int) error {
|
||||||
|
item := de.getItemByIndex(draft, itemIndex)
|
||||||
|
if item == nil {
|
||||||
|
return c.Respond(&tele.CallbackResponse{Text: "Позиция не найдена"})
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Respond()
|
||||||
|
return de.promptForItemName(c, item, itemIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDraftEditQuantity — переход к вводу количества
|
||||||
|
func (de *DraftEditor) handleDraftEditQuantity(c tele.Context, draft *drafts.DraftInvoice, itemIndex int) error {
|
||||||
|
item := de.getItemByIndex(draft, itemIndex)
|
||||||
|
if item == nil {
|
||||||
|
return c.Respond(&tele.CallbackResponse{Text: "Позиция не найдена"})
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Respond()
|
||||||
|
return de.promptForItemQuantity(c, item, itemIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDraftEditPrice — переход к вводу цены
|
||||||
|
func (de *DraftEditor) handleDraftEditPrice(c tele.Context, draft *drafts.DraftInvoice, itemIndex int) error {
|
||||||
|
item := de.getItemByIndex(draft, itemIndex)
|
||||||
|
if item == nil {
|
||||||
|
return c.Respond(&tele.CallbackResponse{Text: "Позиция не найдена"})
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Respond()
|
||||||
|
return de.promptForItemPrice(c, item, itemIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDraftItemDelete — удаление позиции
|
||||||
|
func (de *DraftEditor) handleDraftItemDelete(c tele.Context, draft *drafts.DraftInvoice, itemIndex int) error {
|
||||||
|
item := de.getItemByIndex(draft, itemIndex)
|
||||||
|
if item == nil {
|
||||||
|
return c.Respond(&tele.CallbackResponse{Text: "Позиция не найдена"})
|
||||||
|
}
|
||||||
|
|
||||||
|
userDB, _ := de.accountRepo.GetUserByTelegramID(c.Sender().ID)
|
||||||
|
|
||||||
|
_, err := de.draftsService.DeleteItem(draft.ID, item.ID)
|
||||||
|
if err != nil {
|
||||||
|
return c.Respond(&tele.CallbackResponse{Text: "Ошибка удаления: " + err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Respond(&tele.CallbackResponse{Text: "🗑 Позиция удалена"})
|
||||||
|
|
||||||
|
// Перезагружаем черновик и возвращаемся к списку
|
||||||
|
updatedDraft, _ := de.draftsService.GetDraftForEditor(draft.ID, userDB.ID)
|
||||||
|
|
||||||
|
// Корректируем страницу если нужно
|
||||||
|
ctx := de.fsm.GetContext(c.Sender().ID)
|
||||||
|
page := ctx.DraftCurrentPage
|
||||||
|
totalPages := (len(updatedDraft.Items) + DraftEditorPageSize - 1) / DraftEditorPageSize
|
||||||
|
if page >= totalPages && page > 0 {
|
||||||
|
page = totalPages - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return de.renderDraftEditorMenu(c, updatedDraft, page)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDraftItemBack — возврат к списку
|
||||||
|
func (de *DraftEditor) handleDraftItemBack(c tele.Context, draft *drafts.DraftInvoice) error {
|
||||||
|
c.Respond()
|
||||||
|
|
||||||
|
ctx := de.fsm.GetContext(c.Sender().ID)
|
||||||
|
return de.renderDraftEditorMenu(c, draft, ctx.DraftCurrentPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// notifyAdminsAboutDraft отправляет уведомление администраторам о новом черновике
|
||||||
|
//
|
||||||
|
// Функция должна:
|
||||||
|
// 1. Получить список администраторов и владельцев сервера (исключая автора черновика)
|
||||||
|
// 2. Отправить каждому администратору уведомление с информацией о черновике
|
||||||
|
// 3. Учесть настройку MuteDraftNotifications для каждого пользователя
|
||||||
|
//
|
||||||
|
// TODO: Полная реализация на Этапе 9
|
||||||
|
// Для реализации использовать метод accountRepo.GetServerUsersForDraftNotification()
|
||||||
|
func (de *DraftEditor) notifyAdminsAboutDraft(draft *drafts.DraftInvoice, author *account.User) {
|
||||||
|
// Заглушка — будет реализовано позже
|
||||||
|
logger.Log.Info("Draft ready for review",
|
||||||
|
zap.String("draft_id", draft.ID.String()),
|
||||||
|
zap.String("author", author.FirstName),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// === ОБРАБОТЧИКИ ТЕКСТОВЫХ СООБЩЕНИЙ РЕДАКТОРА ЧЕРНОВИКОВ ===
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// handleDraftEditorText обрабатывает текстовые сообщения в состояниях редактора черновиков
|
||||||
|
func (de *DraftEditor) handleDraftEditorText(c tele.Context) error {
|
||||||
|
userID := c.Sender().ID
|
||||||
|
ctx := de.fsm.GetContext(userID)
|
||||||
|
|
||||||
|
// Проверяем, находимся ли мы в одном из состояний редактирования
|
||||||
|
switch ctx.State {
|
||||||
|
case StateDraftEditItemName:
|
||||||
|
return de.handleDraftNameInput(c, ctx)
|
||||||
|
|
||||||
|
case StateDraftEditItemQty:
|
||||||
|
return de.handleDraftQuantityInput(c, ctx)
|
||||||
|
|
||||||
|
case StateDraftEditItemPrice:
|
||||||
|
return de.handleDraftPriceInput(c, ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil // Не наше сообщение
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDraftNameInput обрабатывает ввод названия позиции
|
||||||
|
func (de *DraftEditor) handleDraftNameInput(c tele.Context, ctx *UserContext) error {
|
||||||
|
userID := c.Sender().ID
|
||||||
|
text := c.Text()
|
||||||
|
|
||||||
|
// Валидация
|
||||||
|
if strings.TrimSpace(text) == "" {
|
||||||
|
return c.Send("❌ Название не может быть пустым. Попробуйте ещё раз:")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(text) > 200 {
|
||||||
|
return c.Send("❌ Название слишком длинное (максимум 200 символов). Попробуйте ещё раз:")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем пользователя
|
||||||
|
userDB, err := de.accountRepo.GetUserByTelegramID(userID)
|
||||||
|
if err != nil {
|
||||||
|
return c.Send("❌ Ошибка получения данных пользователя")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем черновик
|
||||||
|
draft, err := de.draftsService.GetDraftForEditor(ctx.EditingDraftID, userDB.ID)
|
||||||
|
if err != nil {
|
||||||
|
de.fsm.ResetDraftEditor(userID)
|
||||||
|
return c.Send("❌ Черновик не найден. Редактирование отменено.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем позицию
|
||||||
|
item := de.getItemByIndex(draft, ctx.EditingItemIndex)
|
||||||
|
if item == nil {
|
||||||
|
de.fsm.ResetDraftEditor(userID)
|
||||||
|
return c.Send("❌ Позиция не найдена. Редактирование отменено.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем название
|
||||||
|
_, err = de.draftsService.UpdateItemRawName(draft.ID, item.ID, text)
|
||||||
|
if err != nil {
|
||||||
|
return c.Send("❌ Ошибка обновления названия: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сбрасываем состояние редактирования
|
||||||
|
de.fsm.SetState(userID, StateNone)
|
||||||
|
|
||||||
|
// Перезагружаем черновик и показываем меню позиции
|
||||||
|
updatedDraft, _ := de.draftsService.GetDraftForEditor(draft.ID, userDB.ID)
|
||||||
|
updatedItem := de.getItemByIndex(updatedDraft, ctx.EditingItemIndex)
|
||||||
|
|
||||||
|
return de.renderItemEditMenu(c, updatedDraft, updatedItem, ctx.EditingItemIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDraftQuantityInput обрабатывает ввод количества позиции
|
||||||
|
func (de *DraftEditor) handleDraftQuantityInput(c tele.Context, ctx *UserContext) error {
|
||||||
|
userID := c.Sender().ID
|
||||||
|
text := c.Text()
|
||||||
|
|
||||||
|
// Парсинг количества
|
||||||
|
qty, err := strconv.ParseFloat(text, 64)
|
||||||
|
if err != nil {
|
||||||
|
return c.Send("❌ Некорректное значение. Введите число (например: 10 или 5.5):")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Валидация
|
||||||
|
if qty <= 0 {
|
||||||
|
return c.Send("❌ Количество должно быть больше 0. Попробуйте ещё раз:")
|
||||||
|
}
|
||||||
|
|
||||||
|
if qty > 1000000 {
|
||||||
|
return c.Send("❌ Слишком большое количество. Попробуйте ещё раз:")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем пользователя
|
||||||
|
userDB, err := de.accountRepo.GetUserByTelegramID(userID)
|
||||||
|
if err != nil {
|
||||||
|
return c.Send("❌ Ошибка получения данных пользователя")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем черновик
|
||||||
|
draft, err := de.draftsService.GetDraftForEditor(ctx.EditingDraftID, userDB.ID)
|
||||||
|
if err != nil {
|
||||||
|
de.fsm.ResetDraftEditor(userID)
|
||||||
|
return c.Send("❌ Черновик не найден. Редактирование отменено.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем позицию
|
||||||
|
item := de.getItemByIndex(draft, ctx.EditingItemIndex)
|
||||||
|
if item == nil {
|
||||||
|
de.fsm.ResetDraftEditor(userID)
|
||||||
|
return c.Send("❌ Позиция не найдена. Редактирование отменено.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем количество
|
||||||
|
qtyDecimal := decimal.NewFromFloat(qty)
|
||||||
|
_, err = de.draftsService.UpdateItemQuantity(draft.ID, item.ID, qtyDecimal)
|
||||||
|
if err != nil {
|
||||||
|
return c.Send("❌ Ошибка обновления количества: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сбрасываем состояние редактирования
|
||||||
|
de.fsm.SetState(userID, StateNone)
|
||||||
|
|
||||||
|
// Перезагружаем черновик и показываем меню позиции
|
||||||
|
updatedDraft, _ := de.draftsService.GetDraftForEditor(draft.ID, userDB.ID)
|
||||||
|
updatedItem := de.getItemByIndex(updatedDraft, ctx.EditingItemIndex)
|
||||||
|
|
||||||
|
return de.renderItemEditMenu(c, updatedDraft, updatedItem, ctx.EditingItemIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDraftPriceInput обрабатывает ввод цены позиции
|
||||||
|
func (de *DraftEditor) handleDraftPriceInput(c tele.Context, ctx *UserContext) error {
|
||||||
|
userID := c.Sender().ID
|
||||||
|
text := c.Text()
|
||||||
|
|
||||||
|
// Парсинг цены
|
||||||
|
price, err := strconv.ParseFloat(text, 64)
|
||||||
|
if err != nil {
|
||||||
|
return c.Send("❌ Некорректное значение. Введите число (например: 100.50):")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Валидация
|
||||||
|
if price < 0 {
|
||||||
|
return c.Send("❌ Цена не может быть отрицательной. Попробуйте ещё раз:")
|
||||||
|
}
|
||||||
|
|
||||||
|
if price > 1000000000 {
|
||||||
|
return c.Send("❌ Слишком большая цена. Попробуйте ещё раз:")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем пользователя
|
||||||
|
userDB, err := de.accountRepo.GetUserByTelegramID(userID)
|
||||||
|
if err != nil {
|
||||||
|
return c.Send("❌ Ошибка получения данных пользователя")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем черновик
|
||||||
|
draft, err := de.draftsService.GetDraftForEditor(ctx.EditingDraftID, userDB.ID)
|
||||||
|
if err != nil {
|
||||||
|
de.fsm.ResetDraftEditor(userID)
|
||||||
|
return c.Send("❌ Черновик не найден. Редактирование отменено.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем позицию
|
||||||
|
item := de.getItemByIndex(draft, ctx.EditingItemIndex)
|
||||||
|
if item == nil {
|
||||||
|
de.fsm.ResetDraftEditor(userID)
|
||||||
|
return c.Send("❌ Позиция не найдена. Редактирование отменено.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем цену
|
||||||
|
priceDecimal := decimal.NewFromFloat(price)
|
||||||
|
_, err = de.draftsService.UpdateItemPrice(draft.ID, item.ID, priceDecimal)
|
||||||
|
if err != nil {
|
||||||
|
return c.Send("❌ Ошибка обновления цены: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сбрасываем состояние редактирования
|
||||||
|
de.fsm.SetState(userID, StateNone)
|
||||||
|
|
||||||
|
// Перезагружаем черновик и показываем меню позиции
|
||||||
|
updatedDraft, _ := de.draftsService.GetDraftForEditor(draft.ID, userDB.ID)
|
||||||
|
updatedItem := de.getItemByIndex(updatedDraft, ctx.EditingItemIndex)
|
||||||
|
|
||||||
|
return de.renderItemEditMenu(c, updatedDraft, updatedItem, ctx.EditingItemIndex)
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
package telegram
|
package telegram
|
||||||
|
|
||||||
import "sync"
|
import (
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
// Состояния пользователя
|
// Состояния пользователя
|
||||||
type State int
|
type State int
|
||||||
@@ -13,16 +17,32 @@ const (
|
|||||||
StateAddServerConfirmName
|
StateAddServerConfirmName
|
||||||
StateAddServerInputName
|
StateAddServerInputName
|
||||||
StateBillingGiftURL
|
StateBillingGiftURL
|
||||||
|
|
||||||
|
// Состояния редактора черновиков (начиная с 100)
|
||||||
|
StateDraftEditItemName State = 100 // Ожидание ввода нового названия позиции
|
||||||
|
StateDraftEditItemQty State = 101 // Ожидание ввода количества
|
||||||
|
StateDraftEditItemPrice State = 102 // Ожидание ввода цены
|
||||||
)
|
)
|
||||||
|
|
||||||
// UserContext хранит временные данные в процессе диалога
|
// UserContext хранит временные данные в процессе диалога
|
||||||
type UserContext struct {
|
type UserContext struct {
|
||||||
State State
|
State State
|
||||||
|
|
||||||
|
// Поля для добавления сервера
|
||||||
TempURL string
|
TempURL string
|
||||||
TempLogin string
|
TempLogin string
|
||||||
TempPassword string
|
TempPassword string
|
||||||
TempServerName string
|
TempServerName string
|
||||||
|
|
||||||
|
// Поля для биллинга
|
||||||
BillingTargetURL string
|
BillingTargetURL string
|
||||||
|
|
||||||
|
// Поля редактора черновиков
|
||||||
|
EditingDraftID uuid.UUID // ID редактируемого черновика
|
||||||
|
EditingItemID uuid.UUID // ID редактируемой позиции (DraftInvoiceItem)
|
||||||
|
EditingItemIndex int // Порядковый номер позиции (1-based, для отображения пользователю)
|
||||||
|
DraftMenuMsgID int // ID сообщения со списком позиций (для обновления через EditMessage)
|
||||||
|
DraftCurrentPage int // Текущая страница пагинации (0-based)
|
||||||
}
|
}
|
||||||
|
|
||||||
// StateManager управляет состояниями
|
// StateManager управляет состояниями
|
||||||
@@ -80,3 +100,36 @@ func (sm *StateManager) Reset(userID int64) {
|
|||||||
defer sm.mu.Unlock()
|
defer sm.mu.Unlock()
|
||||||
delete(sm.states, userID)
|
delete(sm.states, userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResetDraftEditor сбрасывает только поля редактора черновика
|
||||||
|
func (sm *StateManager) ResetDraftEditor(userID int64) {
|
||||||
|
sm.mu.Lock()
|
||||||
|
defer sm.mu.Unlock()
|
||||||
|
|
||||||
|
if ctx, ok := sm.states[userID]; ok {
|
||||||
|
ctx.State = StateNone
|
||||||
|
ctx.EditingDraftID = uuid.Nil
|
||||||
|
ctx.EditingItemID = uuid.Nil
|
||||||
|
ctx.EditingItemIndex = 0
|
||||||
|
ctx.DraftMenuMsgID = 0
|
||||||
|
ctx.DraftCurrentPage = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// InitDraftEditor инициализирует FSM для редактора черновика
|
||||||
|
func (sm *StateManager) InitDraftEditor(userID int64, draftID uuid.UUID) {
|
||||||
|
sm.mu.Lock()
|
||||||
|
defer sm.mu.Unlock()
|
||||||
|
|
||||||
|
if _, ok := sm.states[userID]; !ok {
|
||||||
|
sm.states[userID] = &UserContext{}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := sm.states[userID]
|
||||||
|
ctx.State = StateNone
|
||||||
|
ctx.EditingDraftID = draftID
|
||||||
|
ctx.EditingItemID = uuid.Nil
|
||||||
|
ctx.EditingItemIndex = 0
|
||||||
|
ctx.DraftMenuMsgID = 0
|
||||||
|
ctx.DraftCurrentPage = 0
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// src/pages/DraftsList.tsx
|
// src/pages/DraftsList.tsx
|
||||||
|
|
||||||
import React, { useState } from "react";
|
import React, { useState, useMemo, useCallback, useRef } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
List,
|
List,
|
||||||
@@ -8,13 +8,13 @@ import {
|
|||||||
Tag,
|
Tag,
|
||||||
Spin,
|
Spin,
|
||||||
Empty,
|
Empty,
|
||||||
DatePicker,
|
|
||||||
Flex,
|
Flex,
|
||||||
Button,
|
Button,
|
||||||
|
Select,
|
||||||
|
DatePicker,
|
||||||
} from "antd";
|
} from "antd";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
ArrowRightOutlined,
|
|
||||||
CheckCircleOutlined,
|
CheckCircleOutlined,
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
@@ -25,21 +25,58 @@ import {
|
|||||||
SyncOutlined,
|
SyncOutlined,
|
||||||
CloudServerOutlined,
|
CloudServerOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import dayjs, { Dayjs } from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
import "dayjs/locale/ru";
|
||||||
import { api } from "../services/api";
|
import { api } from "../services/api";
|
||||||
import type { UnifiedInvoice } from "../services/types";
|
import type { UnifiedInvoice } from "../services/types";
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
type FilterType = "ALL" | "DRAFT" | "SYNCED";
|
||||||
|
|
||||||
|
dayjs.locale("ru");
|
||||||
|
|
||||||
|
const DayDivider: React.FC<{ date: string }> = ({ date }) => {
|
||||||
|
const d = dayjs(date);
|
||||||
|
const dayOfWeek = d.format("dddd");
|
||||||
|
const formattedDate = d.format("D MMMM YYYY");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 12,
|
||||||
|
padding: "16px 0 8px",
|
||||||
|
borderBottom: "1px solid #f0f0f0",
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text strong style={{ fontSize: 14, color: "#1890ff" }}>
|
||||||
|
{formattedDate}
|
||||||
|
</Text>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{dayOfWeek}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const DraftsList: React.FC = () => {
|
export const DraftsList: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// Состояние фильтра дат: по умолчанию последние 7 дней
|
|
||||||
const [startDate, setStartDate] = useState<Dayjs>(dayjs().subtract(7, "day"));
|
|
||||||
const [endDate, setEndDate] = useState<Dayjs>(dayjs());
|
|
||||||
const [syncLoading, setSyncLoading] = useState(false);
|
const [syncLoading, setSyncLoading] = useState(false);
|
||||||
|
const [filterType, setFilterType] = useState<FilterType>("ALL");
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(20);
|
||||||
|
const [startDate, setStartDate] = useState<dayjs.Dayjs | null>(
|
||||||
|
dayjs().subtract(30, "day")
|
||||||
|
);
|
||||||
|
const [endDate, setEndDate] = useState<dayjs.Dayjs | null>(dayjs());
|
||||||
|
|
||||||
|
const touchStartX = useRef<number>(0);
|
||||||
|
const touchEndX = useRef<number>(0);
|
||||||
|
|
||||||
// Запрос данных с учетом дат (даты в ключе обеспечивают авто-перезапрос)
|
|
||||||
const {
|
const {
|
||||||
data: invoices,
|
data: invoices,
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -48,13 +85,13 @@ export const DraftsList: React.FC = () => {
|
|||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: [
|
queryKey: [
|
||||||
"drafts",
|
"drafts",
|
||||||
startDate.format("YYYY-MM-DD"),
|
startDate?.format("YYYY-MM-DD"),
|
||||||
endDate.format("YYYY-MM-DD"),
|
endDate?.format("YYYY-MM-DD"),
|
||||||
],
|
],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
api.getDrafts(
|
api.getDrafts(
|
||||||
startDate.format("YYYY-MM-DD"),
|
startDate?.format("YYYY-MM-DD"),
|
||||||
endDate.format("YYYY-MM-DD")
|
endDate?.format("YYYY-MM-DD")
|
||||||
),
|
),
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
refetchOnMount: true,
|
refetchOnMount: true,
|
||||||
@@ -134,6 +171,106 @@ export const DraftsList: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleFilterChange = (value: FilterType) => {
|
||||||
|
setFilterType(value);
|
||||||
|
setCurrentPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePageSizeChange = (value: number) => {
|
||||||
|
setPageSize(value);
|
||||||
|
setCurrentPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
||||||
|
touchStartX.current = e.changedTouches[0].screenX;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleTouchMove = useCallback((e: React.TouchEvent) => {
|
||||||
|
touchEndX.current = e.changedTouches[0].screenX;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getItemDate = (item: UnifiedInvoice) =>
|
||||||
|
item.type === "DRAFT" ? item.created_at : item.date_incoming;
|
||||||
|
|
||||||
|
const filteredAndSortedInvoices = useMemo(() => {
|
||||||
|
if (!invoices || invoices.length === 0) return [];
|
||||||
|
|
||||||
|
let result = [...invoices];
|
||||||
|
|
||||||
|
if (filterType !== "ALL") {
|
||||||
|
result = result.filter((item) => item.type === filterType);
|
||||||
|
}
|
||||||
|
|
||||||
|
result.sort((a, b) => {
|
||||||
|
const dateA = dayjs(getItemDate(a)).startOf("day");
|
||||||
|
const dateB = dayjs(getItemDate(b)).startOf("day");
|
||||||
|
|
||||||
|
// Сначала по дате DESC
|
||||||
|
if (!dateA.isSame(dateB)) {
|
||||||
|
return dateB.valueOf() - dateA.valueOf();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Внутри дня: DRAFT < SYNCED
|
||||||
|
if (a.type !== b.type) {
|
||||||
|
return a.type === "DRAFT" ? -1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Внутри типа: по номеру DESC
|
||||||
|
return (b.document_number || "").localeCompare(
|
||||||
|
a.document_number || "",
|
||||||
|
"ru",
|
||||||
|
{ numeric: true }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [invoices, filterType]);
|
||||||
|
|
||||||
|
const handleTouchEnd = useCallback(() => {
|
||||||
|
const diff = touchStartX.current - touchEndX.current;
|
||||||
|
const totalPages = Math.ceil(
|
||||||
|
(filteredAndSortedInvoices.length || 0) / pageSize
|
||||||
|
);
|
||||||
|
|
||||||
|
if (Math.abs(diff) > 50) {
|
||||||
|
if (diff > 0 && currentPage < totalPages) {
|
||||||
|
setCurrentPage((prev) => prev + 1);
|
||||||
|
} else if (diff < 0 && currentPage > 1) {
|
||||||
|
setCurrentPage((prev) => prev - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [currentPage, pageSize, filteredAndSortedInvoices]);
|
||||||
|
|
||||||
|
const paginatedInvoices = useMemo(() => {
|
||||||
|
const startIndex = (currentPage - 1) * pageSize;
|
||||||
|
return filteredAndSortedInvoices.slice(startIndex, startIndex + pageSize);
|
||||||
|
}, [filteredAndSortedInvoices, currentPage, pageSize]);
|
||||||
|
|
||||||
|
const groupedInvoices = useMemo(() => {
|
||||||
|
const groups: { [key: string]: UnifiedInvoice[] } = {};
|
||||||
|
paginatedInvoices.forEach((item) => {
|
||||||
|
const dateKey = dayjs(getItemDate(item)).format("YYYY-MM-DD");
|
||||||
|
if (!groups[dateKey]) {
|
||||||
|
groups[dateKey] = [];
|
||||||
|
}
|
||||||
|
groups[dateKey].push(item);
|
||||||
|
});
|
||||||
|
return groups;
|
||||||
|
}, [paginatedInvoices]);
|
||||||
|
|
||||||
|
const filterCounts = useMemo(() => {
|
||||||
|
if (!invoices) return { all: 0, draft: 0, synced: 0 };
|
||||||
|
return {
|
||||||
|
all: invoices.length,
|
||||||
|
draft: invoices.filter((item) => item.type === "DRAFT").length,
|
||||||
|
synced: invoices.filter((item) => item.type === "SYNCED").length,
|
||||||
|
};
|
||||||
|
}, [invoices]);
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(
|
||||||
|
(filteredAndSortedInvoices.length || 0) / pageSize
|
||||||
|
);
|
||||||
|
|
||||||
if (isError) {
|
if (isError) {
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 20 }}>
|
<div style={{ padding: 20 }}>
|
||||||
@@ -144,7 +281,12 @@ export const DraftsList: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: "0 4px 20px" }}>
|
<div style={{ padding: "0 4px 20px" }}>
|
||||||
<Flex align="center" gap={8} style={{ marginTop: 16, marginBottom: 16 }}>
|
<Flex
|
||||||
|
align="center"
|
||||||
|
justify="space-between"
|
||||||
|
style={{ marginTop: 16, marginBottom: 16 }}
|
||||||
|
>
|
||||||
|
<Flex align="center" gap={8}>
|
||||||
<Title level={4} style={{ margin: 0 }}>
|
<Title level={4} style={{ margin: 0 }}>
|
||||||
Накладные
|
Накладные
|
||||||
</Title>
|
</Title>
|
||||||
@@ -154,38 +296,45 @@ export const DraftsList: React.FC = () => {
|
|||||||
onClick={handleSync}
|
onClick={handleSync}
|
||||||
/>
|
/>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
<Select
|
||||||
|
value={filterType}
|
||||||
|
onChange={handleFilterChange}
|
||||||
|
options={[
|
||||||
|
{ label: `Все (${filterCounts.all})`, value: "ALL" },
|
||||||
|
{ label: `Черновики (${filterCounts.draft})`, value: "DRAFT" },
|
||||||
|
{ label: `Накладные (${filterCounts.synced})`, value: "SYNCED" },
|
||||||
|
]}
|
||||||
|
size="small"
|
||||||
|
style={{ width: 140 }}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
{/* Фильтр дат */}
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
marginBottom: 16,
|
marginBottom: 12,
|
||||||
background: "#fff",
|
background: "#fff",
|
||||||
padding: 12,
|
padding: 12,
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text
|
<Flex align="center" gap={8}>
|
||||||
type="secondary"
|
<Text style={{ fontSize: 13 }}>Период:</Text>
|
||||||
style={{ display: "block", marginBottom: 8, fontSize: 12 }}
|
|
||||||
>
|
|
||||||
Период загрузки:
|
|
||||||
</Text>
|
|
||||||
<Flex gap={8}>
|
|
||||||
<DatePicker
|
<DatePicker
|
||||||
value={startDate}
|
value={startDate}
|
||||||
onChange={(date) => date && setStartDate(date)}
|
onChange={setStartDate}
|
||||||
style={{ flex: 1 }}
|
|
||||||
placeholder="Начало"
|
|
||||||
format="DD.MM.YYYY"
|
format="DD.MM.YYYY"
|
||||||
allowClear={false}
|
size="small"
|
||||||
|
placeholder="Начало"
|
||||||
|
style={{ width: 110 }}
|
||||||
/>
|
/>
|
||||||
|
<Text type="secondary">—</Text>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
value={endDate}
|
value={endDate}
|
||||||
onChange={(date) => date && setEndDate(date)}
|
onChange={setEndDate}
|
||||||
style={{ flex: 1 }}
|
|
||||||
placeholder="Конец"
|
|
||||||
format="DD.MM.YYYY"
|
format="DD.MM.YYYY"
|
||||||
allowClear={false}
|
size="small"
|
||||||
|
placeholder="Конец"
|
||||||
|
style={{ width: 110 }}
|
||||||
/>
|
/>
|
||||||
</Flex>
|
</Flex>
|
||||||
</div>
|
</div>
|
||||||
@@ -195,32 +344,53 @@ export const DraftsList: React.FC = () => {
|
|||||||
<Spin size="large" />
|
<Spin size="large" />
|
||||||
</div>
|
</div>
|
||||||
) : !invoices || invoices.length === 0 ? (
|
) : !invoices || invoices.length === 0 ? (
|
||||||
<Empty description="Нет данных за выбранный период" />
|
<Empty description="Нет данных" />
|
||||||
) : (
|
) : (
|
||||||
<List
|
<>
|
||||||
dataSource={invoices}
|
<div
|
||||||
renderItem={(item) => {
|
onTouchStart={handleTouchStart}
|
||||||
|
onTouchMove={handleTouchMove}
|
||||||
|
onTouchEnd={handleTouchEnd}
|
||||||
|
>
|
||||||
|
{Object.entries(groupedInvoices).map(([dateKey, items]) => (
|
||||||
|
<div key={dateKey}>
|
||||||
|
<DayDivider date={dateKey} />
|
||||||
|
{items.map((item) => {
|
||||||
const isSynced = item.type === "SYNCED";
|
const isSynced = item.type === "SYNCED";
|
||||||
const displayDate =
|
const displayDate =
|
||||||
item.type === "DRAFT" ? item.created_at : item.date_incoming;
|
item.type === "DRAFT"
|
||||||
|
? item.created_at
|
||||||
|
: item.date_incoming;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<List.Item
|
<List.Item
|
||||||
|
key={item.id}
|
||||||
style={{
|
style={{
|
||||||
background: isSynced ? "#fafafa" : "#fff",
|
background: isSynced ? "#fafafa" : "#fff",
|
||||||
padding: 12,
|
padding: 12,
|
||||||
marginBottom: 10,
|
marginBottom: 10,
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
border: isSynced ? "1px solid #f0f0f0" : "1px solid #e6f7ff",
|
border: isSynced
|
||||||
|
? "1px solid #f0f0f0"
|
||||||
|
: "1px solid #e6f7ff",
|
||||||
boxShadow: "0 2px 4px rgba(0,0,0,0.02)",
|
boxShadow: "0 2px 4px rgba(0,0,0,0.02)",
|
||||||
display: "block",
|
display: "block",
|
||||||
|
position: "relative",
|
||||||
}}
|
}}
|
||||||
onClick={() => handleInvoiceClick(item)}
|
onClick={() => handleInvoiceClick(item)}
|
||||||
>
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 12,
|
||||||
|
right: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getStatusTag(item)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<Flex vertical gap={4}>
|
<Flex vertical gap={4}>
|
||||||
<Flex justify="space-between" align="start">
|
|
||||||
<Flex vertical>
|
|
||||||
<Flex align="center" gap={8}>
|
<Flex align="center" gap={8}>
|
||||||
<Text strong style={{ fontSize: 16 }}>
|
<Text strong style={{ fontSize: 16 }}>
|
||||||
{item.document_number || "Без номера"}
|
{item.document_number || "Без номера"}
|
||||||
@@ -245,25 +415,6 @@ export const DraftsList: React.FC = () => {
|
|||||||
Вх. № {item.incoming_number}
|
Вх. № {item.incoming_number}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Flex>
|
|
||||||
<Flex vertical align="start" gap={4}>
|
|
||||||
{getStatusTag(item)}
|
|
||||||
{isSynced && item.items_preview && (
|
|
||||||
<div>
|
|
||||||
{item.items_preview
|
|
||||||
.split(", ")
|
|
||||||
.map((previewItem, idx) => (
|
|
||||||
<div
|
|
||||||
key={idx}
|
|
||||||
style={{ fontSize: 12, color: "#666" }}
|
|
||||||
>
|
|
||||||
{previewItem}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Flex>
|
|
||||||
</Flex>
|
|
||||||
|
|
||||||
<Flex justify="space-between" align="center">
|
<Flex justify="space-between" align="center">
|
||||||
<div></div>
|
<div></div>
|
||||||
@@ -299,15 +450,88 @@ export const DraftsList: React.FC = () => {
|
|||||||
maximumFractionDigits: 0,
|
maximumFractionDigits: 0,
|
||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
{!isSynced && (
|
{item.items_preview && (
|
||||||
<ArrowRightOutlined style={{ color: "#1890ff" }} />
|
<div
|
||||||
|
style={{
|
||||||
|
textAlign: "right",
|
||||||
|
maxWidth: 150,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.items_preview
|
||||||
|
.split(", ")
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((previewItem, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: "#666",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{previewItem}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
);
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<Flex
|
||||||
|
justify="space-between"
|
||||||
|
align="center"
|
||||||
|
style={{
|
||||||
|
marginTop: 16,
|
||||||
|
padding: "8px 12px",
|
||||||
|
background: "#fff",
|
||||||
|
borderRadius: 8,
|
||||||
}}
|
}}
|
||||||
|
>
|
||||||
|
<Flex align="center" gap={8}>
|
||||||
|
<Text style={{ fontSize: 12 }}>На странице:</Text>
|
||||||
|
<Select
|
||||||
|
value={pageSize}
|
||||||
|
onChange={handlePageSizeChange}
|
||||||
|
options={[
|
||||||
|
{ label: "10", value: 10 },
|
||||||
|
{ label: "20", value: 20 },
|
||||||
|
{ label: "50", value: 50 },
|
||||||
|
]}
|
||||||
|
size="small"
|
||||||
|
style={{ width: 70 }}
|
||||||
/>
|
/>
|
||||||
|
</Flex>
|
||||||
|
<Flex align="center" gap={8}>
|
||||||
|
<Text style={{ fontSize: 13 }}>
|
||||||
|
Стр. {currentPage} из {totalPages}
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
onClick={() => setCurrentPage((prev) => prev - 1)}
|
||||||
|
>
|
||||||
|
←
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
onClick={() => setCurrentPage((prev) => prev + 1)}
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user