%s\n"+
+ "📦 Количество: %s\n"+
+ "💰 Цена: %s ₽\n"+
+ "💵 Сумма: %s ₽\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(
+ "📝 Изменение названия позиции №%d\n\n"+
+ "Текущее название: %s\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(
+ "📦 Изменение количества позиции №%d\n\n"+
+ "Текущее количество: %s\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(
+ "💰 Изменение цены позиции №%d\n\n"+
+ "Текущая цена: %s ₽\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(
+ "✅ Черновик успешно сохранён!\n\n"+
+ "📊 Статус: READY_TO_VERIFY\n"+
+ "📦 Позиций: %d\n"+
+ "📅 Дата: %s\n\n",
+ len(updatedDraft.Items),
+ updatedDraft.DateIncoming.Format("02.01.2006"),
+ )
+
+ // Добавляем информацию о дальнейшей обработке
+ if server != nil && server.AutoProcess {
+ txt += "🔄 Авто-процессинг в iiko включён.\n" +
+ "Черновик будет автоматически отправлен в систему iiko после проверки администратором.\n\n"
+ } else {
+ txt += "📋 Черновик готов к дальнейшей обработке.\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 := "❌ Редактирование отменено\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)
+}
diff --git a/internal/transport/telegram/fsm.go b/internal/transport/telegram/fsm.go
index 202945f..10a20aa 100644
--- a/internal/transport/telegram/fsm.go
+++ b/internal/transport/telegram/fsm.go
@@ -1,6 +1,10 @@
package telegram
-import "sync"
+import (
+ "sync"
+
+ "github.com/google/uuid"
+)
// Состояния пользователя
type State int
@@ -13,16 +17,32 @@ const (
StateAddServerConfirmName
StateAddServerInputName
StateBillingGiftURL
+
+ // Состояния редактора черновиков (начиная с 100)
+ StateDraftEditItemName State = 100 // Ожидание ввода нового названия позиции
+ StateDraftEditItemQty State = 101 // Ожидание ввода количества
+ StateDraftEditItemPrice State = 102 // Ожидание ввода цены
)
// UserContext хранит временные данные в процессе диалога
type UserContext struct {
- State State
- TempURL string
- TempLogin string
- TempPassword string
- TempServerName string
+ State State
+
+ // Поля для добавления сервера
+ TempURL string
+ TempLogin string
+ TempPassword string
+ TempServerName 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 управляет состояниями
@@ -80,3 +100,36 @@ func (sm *StateManager) Reset(userID int64) {
defer sm.mu.Unlock()
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
+}
diff --git a/rmser-view/src/pages/DraftsList.tsx b/rmser-view/src/pages/DraftsList.tsx
index 9e816a2..3e439e0 100644
--- a/rmser-view/src/pages/DraftsList.tsx
+++ b/rmser-view/src/pages/DraftsList.tsx
@@ -1,6 +1,6 @@
// src/pages/DraftsList.tsx
-import React, { useState } from "react";
+import React, { useState, useMemo, useCallback, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import {
List,
@@ -8,13 +8,13 @@ import {
Tag,
Spin,
Empty,
- DatePicker,
Flex,
Button,
+ Select,
+ DatePicker,
} from "antd";
import { useNavigate } from "react-router-dom";
import {
- ArrowRightOutlined,
CheckCircleOutlined,
DeleteOutlined,
PlusOutlined,
@@ -25,21 +25,58 @@ import {
SyncOutlined,
CloudServerOutlined,
} from "@ant-design/icons";
-import dayjs, { Dayjs } from "dayjs";
+import dayjs from "dayjs";
+import "dayjs/locale/ru";
import { api } from "../services/api";
import type { UnifiedInvoice } from "../services/types";
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 (
+