diff --git a/cmd/main.go b/cmd/main.go
index d742510..83f2c5e 100644
--- a/cmd/main.go
+++ b/cmd/main.go
@@ -26,6 +26,7 @@ import (
invoicesPkg "rmser/internal/infrastructure/repository/invoices"
ocrRepoPkg "rmser/internal/infrastructure/repository/ocr"
opsRepoPkg "rmser/internal/infrastructure/repository/operations"
+ photosPkg "rmser/internal/infrastructure/repository/photos"
recipesPkg "rmser/internal/infrastructure/repository/recipes"
recRepoPkg "rmser/internal/infrastructure/repository/recommendations"
suppliersPkg "rmser/internal/infrastructure/repository/suppliers"
@@ -37,6 +38,7 @@ import (
draftsServicePkg "rmser/internal/services/drafts"
invoicesServicePkg "rmser/internal/services/invoices"
ocrServicePkg "rmser/internal/services/ocr"
+ photosServicePkg "rmser/internal/services/photos"
recServicePkg "rmser/internal/services/recommend"
"rmser/internal/services/sync"
@@ -79,6 +81,7 @@ func main() {
opsRepo := opsRepoPkg.NewRepository(database)
recRepo := recRepoPkg.NewRepository(database)
ocrRepo := ocrRepoPkg.NewRepository(database)
+ photosRepo := photosPkg.NewRepository(database)
draftsRepo := draftsPkg.NewRepository(database)
supplierRepo := suppliersPkg.NewRepository(database)
@@ -92,14 +95,16 @@ func main() {
syncService := sync.NewService(rmsFactory, accountRepo, catalogRepo, recipesRepo, invoicesRepo, opsRepo, supplierRepo)
recService := recServicePkg.NewService(recRepo)
- ocrService := ocrServicePkg.NewService(ocrRepo, catalogRepo, draftsRepo, accountRepo, pyClient, cfg.App.StoragePath)
- draftsService := draftsServicePkg.NewService(draftsRepo, ocrRepo, catalogRepo, accountRepo, supplierRepo, invoicesRepo, rmsFactory, billingService)
+ ocrService := ocrServicePkg.NewService(ocrRepo, catalogRepo, draftsRepo, accountRepo, photosRepo, pyClient, cfg.App.StoragePath)
+ draftsService := draftsServicePkg.NewService(draftsRepo, ocrRepo, catalogRepo, accountRepo, supplierRepo, photosRepo, invoicesRepo, rmsFactory, billingService)
invoicesService := invoicesServicePkg.NewService(invoicesRepo, draftsRepo, supplierRepo, rmsFactory)
+ photosService := photosServicePkg.NewService(photosRepo, draftsRepo, accountRepo)
// 7. Handlers
draftsHandler := handlers.NewDraftsHandler(draftsService)
billingHandler := handlers.NewBillingHandler(billingService)
ocrHandler := handlers.NewOCRHandler(ocrService)
+ photosHandler := handlers.NewPhotosHandler(photosService)
recommendHandler := handlers.NewRecommendationsHandler(recService)
settingsHandler := handlers.NewSettingsHandler(accountRepo, catalogRepo)
invoicesHandler := handlers.NewInvoiceHandler(invoicesService, syncService)
@@ -152,6 +157,9 @@ func main() {
// Settings
api.GET("/settings", settingsHandler.GetSettings)
api.POST("/settings", settingsHandler.UpdateSettings)
+ // Photos Storage
+ api.GET("/photos", photosHandler.GetPhotos)
+ api.DELETE("/photos/:id", photosHandler.DeletePhoto)
// User Management
api.GET("/settings/users", settingsHandler.GetServerUsers)
api.PATCH("/settings/users/:userId", settingsHandler.UpdateUserRole)
diff --git a/internal/domain/photos/entity.go b/internal/domain/photos/entity.go
new file mode 100644
index 0000000..720d412
--- /dev/null
+++ b/internal/domain/photos/entity.go
@@ -0,0 +1,54 @@
+package photos
+
+import (
+ "time"
+
+ "github.com/google/uuid"
+)
+
+type PhotoStatus string
+
+const (
+ PhotoStatusOrphan PhotoStatus = "ORPHAN" // Нет связанного черновика
+ PhotoStatusHasDraft PhotoStatus = "HAS_DRAFT" // Есть черновик
+ PhotoStatusHasInvoice PhotoStatus = "HAS_INVOICE" // Есть накладная в iiko
+)
+
+// ReceiptPhoto - фото чека
+type ReceiptPhoto struct {
+ ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
+ RMSServerID uuid.UUID `gorm:"type:uuid;not null;index" json:"rms_server_id"`
+ UploadedBy uuid.UUID `gorm:"type:uuid;not null" json:"uploaded_by"` // User ID
+
+ FilePath string `gorm:"type:varchar(500);not null" json:"file_path"`
+ FileURL string `gorm:"type:varchar(500);not null" json:"file_url"` // Public URL
+ FileName string `gorm:"type:varchar(255)" json:"file_name"`
+ FileSize int64 `json:"file_size"`
+
+ // Связи (указатели, так как могут быть null)
+ DraftID *uuid.UUID `gorm:"type:uuid;index" json:"draft_id"`
+ InvoiceID *uuid.UUID `gorm:"type:uuid;index" json:"invoice_id"` // RMS Invoice ID (когда накладная создана)
+
+ // Метаданные OCR (чтобы можно было пересоздать черновик без повторного запроса к нейросети)
+ OCRSource string `gorm:"type:varchar(50)" json:"ocr_source"` // qr_api, yandex, etc.
+ OCRRawText string `gorm:"type:text" json:"ocr_raw_text"`
+
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+// Repository interface
+type Repository interface {
+ Create(photo *ReceiptPhoto) error
+ GetByID(id uuid.UUID) (*ReceiptPhoto, error)
+ // GetByServerID возвращает фото с пагинацией
+ GetByServerID(serverID uuid.UUID, page, limit int) ([]ReceiptPhoto, int64, error)
+
+ UpdateDraftLink(photoID uuid.UUID, draftID *uuid.UUID) error
+ UpdateInvoiceLink(photoID uuid.UUID, invoiceID *uuid.UUID) error
+
+ // ClearDraftLinkByDraftID очищает ссылку на черновик (когда черновик удаляется)
+ ClearDraftLinkByDraftID(draftID uuid.UUID) error
+
+ Delete(id uuid.UUID) error
+}
diff --git a/internal/infrastructure/db/postgres.go b/internal/infrastructure/db/postgres.go
index 9adc1ac..6559b95 100644
--- a/internal/infrastructure/db/postgres.go
+++ b/internal/infrastructure/db/postgres.go
@@ -13,6 +13,7 @@ import (
"rmser/internal/domain/invoices"
"rmser/internal/domain/ocr"
"rmser/internal/domain/operations"
+ "rmser/internal/domain/photos"
"rmser/internal/domain/recipes"
"rmser/internal/domain/recommendations"
"rmser/internal/domain/suppliers"
@@ -68,6 +69,7 @@ func NewPostgresDB(dsn string) *gorm.DB {
&recommendations.Recommendation{},
&ocr.ProductMatch{},
&ocr.UnmatchedItem{},
+ &photos.ReceiptPhoto{},
)
if err != nil {
panic(fmt.Sprintf("ошибка миграции БД: %v", err))
diff --git a/internal/infrastructure/repository/photos/postgres.go b/internal/infrastructure/repository/photos/postgres.go
new file mode 100644
index 0000000..d735b6b
--- /dev/null
+++ b/internal/infrastructure/repository/photos/postgres.go
@@ -0,0 +1,70 @@
+package photos
+
+import (
+ "rmser/internal/domain/photos"
+
+ "github.com/google/uuid"
+ "gorm.io/gorm"
+)
+
+type pgRepository struct {
+ db *gorm.DB
+}
+
+func NewRepository(db *gorm.DB) photos.Repository {
+ return &pgRepository{db: db}
+}
+
+func (r *pgRepository) Create(photo *photos.ReceiptPhoto) error {
+ return r.db.Create(photo).Error
+}
+
+func (r *pgRepository) GetByID(id uuid.UUID) (*photos.ReceiptPhoto, error) {
+ var photo photos.ReceiptPhoto
+ err := r.db.First(&photo, id).Error
+ return &photo, err
+}
+
+func (r *pgRepository) GetByServerID(serverID uuid.UUID, page, limit int) ([]photos.ReceiptPhoto, int64, error) {
+ var items []photos.ReceiptPhoto
+ var total int64
+
+ offset := (page - 1) * limit
+
+ err := r.db.Model(&photos.ReceiptPhoto{}).
+ Where("rms_server_id = ?", serverID).
+ Count(&total).Error
+ if err != nil {
+ return nil, 0, err
+ }
+
+ err = r.db.Where("rms_server_id = ?", serverID).
+ Order("created_at DESC").
+ Offset(offset).
+ Limit(limit).
+ Find(&items).Error
+
+ return items, total, err
+}
+
+func (r *pgRepository) UpdateDraftLink(photoID uuid.UUID, draftID *uuid.UUID) error {
+ return r.db.Model(&photos.ReceiptPhoto{}).
+ Where("id = ?", photoID).
+ Update("draft_id", draftID).Error
+}
+
+func (r *pgRepository) UpdateInvoiceLink(photoID uuid.UUID, invoiceID *uuid.UUID) error {
+ return r.db.Model(&photos.ReceiptPhoto{}).
+ Where("id = ?", photoID).
+ Update("invoice_id", invoiceID).Error
+}
+
+func (r *pgRepository) ClearDraftLinkByDraftID(draftID uuid.UUID) error {
+ return r.db.Model(&photos.ReceiptPhoto{}).
+ Where("draft_id = ?", draftID).
+ Update("draft_id", nil).Error
+}
+
+func (r *pgRepository) Delete(id uuid.UUID) error {
+ return r.db.Delete(&photos.ReceiptPhoto{}, id).Error
+}
diff --git a/internal/services/drafts/service.go b/internal/services/drafts/service.go
index e6cd72a..a87f5ba 100644
--- a/internal/services/drafts/service.go
+++ b/internal/services/drafts/service.go
@@ -16,6 +16,7 @@ import (
"rmser/internal/domain/drafts"
"rmser/internal/domain/invoices"
"rmser/internal/domain/ocr"
+ "rmser/internal/domain/photos"
"rmser/internal/domain/suppliers"
"rmser/internal/infrastructure/rms"
"rmser/internal/services/billing"
@@ -29,6 +30,7 @@ type Service struct {
accountRepo account.Repository
supplierRepo suppliers.Repository
invoiceRepo invoices.Repository
+ photoRepo photos.Repository
rmsFactory *rms.Factory
billingService *billing.Service
}
@@ -39,6 +41,7 @@ func NewService(
catalogRepo catalog.Repository,
accountRepo account.Repository,
supplierRepo suppliers.Repository,
+ photoRepo photos.Repository,
invoiceRepo invoices.Repository,
rmsFactory *rms.Factory,
billingService *billing.Service,
@@ -49,6 +52,7 @@ func NewService(
catalogRepo: catalogRepo,
accountRepo: accountRepo,
supplierRepo: supplierRepo,
+ photoRepo: photoRepo,
invoiceRepo: invoiceRepo,
rmsFactory: rmsFactory,
billingService: billingService,
@@ -126,16 +130,25 @@ func (s *Service) DeleteDraft(id uuid.UUID) (string, error) {
return "", err
}
+ // Логика статусов
if draft.Status == drafts.StatusCanceled {
- draft.Status = drafts.StatusDeleted
- s.draftRepo.Update(draft)
+ // Окончательное удаление
+ if err := s.draftRepo.Delete(id); err != nil {
+ return "", err
+ }
+ // ВАЖНО: Разрываем связь с фото (оно становится ORPHAN)
+ if err := s.photoRepo.ClearDraftLinkByDraftID(id); err != nil {
+ logger.Log.Error("failed to clear photo draft link", zap.Error(err))
+ }
return drafts.StatusDeleted, nil
}
+
if draft.Status != drafts.StatusCompleted {
draft.Status = drafts.StatusCanceled
s.draftRepo.Update(draft)
return drafts.StatusCanceled, nil
}
+
return draft.Status, nil
}
diff --git a/internal/services/ocr/service.go b/internal/services/ocr/service.go
index 8f99dc4..3c4e23c 100644
--- a/internal/services/ocr/service.go
+++ b/internal/services/ocr/service.go
@@ -6,6 +6,7 @@ import (
"fmt"
"os"
"path/filepath"
+ "time"
"github.com/google/uuid"
"github.com/shopspring/decimal"
@@ -14,6 +15,7 @@ import (
"rmser/internal/domain/catalog"
"rmser/internal/domain/drafts"
"rmser/internal/domain/ocr"
+ "rmser/internal/domain/photos"
"rmser/internal/infrastructure/ocr_client"
)
@@ -22,6 +24,7 @@ type Service struct {
catalogRepo catalog.Repository
draftRepo drafts.Repository
accountRepo account.Repository
+ photoRepo photos.Repository
pyClient *ocr_client.Client
storagePath string
}
@@ -31,6 +34,7 @@ func NewService(
catalogRepo catalog.Repository,
draftRepo drafts.Repository,
accountRepo account.Repository,
+ photoRepo photos.Repository,
pyClient *ocr_client.Client,
storagePath string,
) *Service {
@@ -39,6 +43,7 @@ func NewService(
catalogRepo: catalogRepo,
draftRepo: draftRepo,
accountRepo: accountRepo,
+ photoRepo: photoRepo,
pyClient: pyClient,
storagePath: storagePath,
}
@@ -58,37 +63,56 @@ func (s *Service) checkWriteAccess(userID, serverID uuid.UUID) error {
// ProcessReceiptImage - Доступно всем (включая Операторов)
func (s *Service) ProcessReceiptImage(ctx context.Context, userID uuid.UUID, imgData []byte) (*drafts.DraftInvoice, error) {
- // 1. Получаем активный сервер для UserID
server, err := s.accountRepo.GetActiveServer(userID)
if err != nil || server == nil {
return nil, fmt.Errorf("no active server for user")
}
serverID := server.ID
- // 2. Создаем черновик
- draft := &drafts.DraftInvoice{
- UserID: userID,
- RMSServerID: serverID,
- Status: drafts.StatusProcessing,
- StoreID: server.DefaultStoreID,
- }
+ // 1. Создаем ID для фото и черновика
+ photoID := uuid.New()
+ draftID := uuid.New()
- draft.ID = uuid.New()
-
- fileName := fmt.Sprintf("receipt_%s.jpg", draft.ID.String())
+ fileName := fmt.Sprintf("receipt_%s.jpg", photoID.String())
filePath := filepath.Join(s.storagePath, fileName)
+ // 2. Сохраняем файл
if err := os.WriteFile(filePath, imgData, 0644); err != nil {
return nil, fmt.Errorf("failed to save image: %w", err)
}
+ fileURL := "/uploads/" + fileName
- draft.SenderPhotoURL = "/uploads/" + fileName
+ // 3. Создаем запись ReceiptPhoto
+ photo := &photos.ReceiptPhoto{
+ ID: photoID,
+ RMSServerID: serverID,
+ UploadedBy: userID,
+ FilePath: filePath,
+ FileURL: fileURL,
+ FileName: fileName,
+ FileSize: int64(len(imgData)),
+ DraftID: &draftID, // Сразу связываем с будущим черновиком
+ CreatedAt: time.Now(),
+ }
+ if err := s.photoRepo.Create(photo); err != nil {
+ return nil, fmt.Errorf("failed to create photo record: %w", err)
+ }
+
+ // 4. Создаем черновик
+ draft := &drafts.DraftInvoice{
+ ID: draftID,
+ UserID: userID,
+ RMSServerID: serverID,
+ Status: drafts.StatusProcessing,
+ StoreID: server.DefaultStoreID,
+ SenderPhotoURL: fileURL, // Оставляем для совместимости, но теперь есть ReceiptPhoto
+ }
if err := s.draftRepo.Create(draft); err != nil {
return nil, fmt.Errorf("failed to create draft: %w", err)
}
- // 3. Отправляем в Python OCR
+ // 5. Отправляем в Python OCR
rawResult, err := s.pyClient.ProcessImage(ctx, imgData, "receipt.jpg")
if err != nil {
draft.Status = drafts.StatusError
@@ -96,9 +120,8 @@ func (s *Service) ProcessReceiptImage(ctx context.Context, userID uuid.UUID, img
return nil, fmt.Errorf("python ocr error: %w", err)
}
- // 4. Матчинг (с учетом ServerID)
+ // 6. Матчинг и сохранение позиций
var draftItems []drafts.DraftInvoiceItem
-
for _, rawItem := range rawResult.Items {
item := drafts.DraftInvoiceItem{
DraftID: draft.ID,
@@ -111,23 +134,19 @@ func (s *Service) ProcessReceiptImage(ctx context.Context, userID uuid.UUID, img
}
match, _ := s.ocrRepo.FindMatch(serverID, rawItem.RawName)
-
if match != nil {
item.IsMatched = true
item.ProductID = &match.ProductID
item.ContainerID = match.ContainerID
} else {
- s.ocrRepo.UpsertUnmatched(serverID, rawItem.RawName) // <-- ServerID
+ s.ocrRepo.UpsertUnmatched(serverID, rawItem.RawName)
}
-
draftItems = append(draftItems, item)
}
- // 5. Сохраняем
draft.Status = drafts.StatusReadyToVerify
s.draftRepo.Update(draft)
s.draftRepo.CreateItems(draftItems)
-
draft.Items = draftItems
return draft, nil
diff --git a/internal/services/photos/service.go b/internal/services/photos/service.go
new file mode 100644
index 0000000..d856dd7
--- /dev/null
+++ b/internal/services/photos/service.go
@@ -0,0 +1,119 @@
+package photos
+
+import (
+ "errors"
+ "os"
+
+ "github.com/google/uuid"
+
+ "rmser/internal/domain/account"
+ "rmser/internal/domain/drafts"
+ "rmser/internal/domain/photos"
+)
+
+var (
+ ErrPhotoHasInvoice = errors.New("нельзя удалить фото: есть созданная накладная")
+ ErrPhotoHasDraft = errors.New("фото связано с черновиком")
+)
+
+type Service struct {
+ photoRepo photos.Repository
+ draftRepo drafts.Repository
+ accountRepo account.Repository
+}
+
+func NewService(photoRepo photos.Repository, draftRepo drafts.Repository, accountRepo account.Repository) *Service {
+ return &Service{
+ photoRepo: photoRepo,
+ draftRepo: draftRepo,
+ accountRepo: accountRepo,
+ }
+}
+
+// DTO для ответа
+type PhotoWithStatus struct {
+ photos.ReceiptPhoto
+ Status photos.PhotoStatus `json:"status"`
+ CanDelete bool `json:"can_delete"`
+ CanRegenerate bool `json:"can_regenerate"`
+}
+
+func (s *Service) GetPhotosForServer(userID uuid.UUID, page, limit int) ([]PhotoWithStatus, int64, error) {
+ server, err := s.accountRepo.GetActiveServer(userID)
+ if err != nil || server == nil {
+ return nil, 0, errors.New("active server not found")
+ }
+
+ items, total, err := s.photoRepo.GetByServerID(server.ID, page, limit)
+ if err != nil {
+ return nil, 0, err
+ }
+
+ result := make([]PhotoWithStatus, len(items))
+ for i, photo := range items {
+ status := photos.PhotoStatusOrphan
+ if photo.InvoiceID != nil {
+ status = photos.PhotoStatusHasInvoice
+ } else if photo.DraftID != nil {
+ status = photos.PhotoStatusHasDraft
+ }
+
+ result[i] = PhotoWithStatus{
+ ReceiptPhoto: photo,
+ Status: status,
+ // Удалить можно только если нет накладной
+ CanDelete: photo.InvoiceID == nil,
+ // Пересоздать можно только если это "сирота"
+ CanRegenerate: photo.DraftID == nil && photo.InvoiceID == nil,
+ }
+ }
+
+ return result, total, nil
+}
+
+func (s *Service) DeletePhoto(userID, photoID uuid.UUID, forceDeleteDraft bool) error {
+ // Проверка прав
+ server, err := s.accountRepo.GetActiveServer(userID)
+ if err != nil || server == nil {
+ return errors.New("no active server")
+ }
+
+ // Операторы не могут удалять фото из архива (только админы)
+ role, _ := s.accountRepo.GetUserRole(userID, server.ID)
+ if role == account.RoleOperator {
+ return errors.New("access denied")
+ }
+
+ photo, err := s.photoRepo.GetByID(photoID)
+ if err != nil {
+ return err
+ }
+
+ // Проверка: есть накладная
+ if photo.InvoiceID != nil {
+ return ErrPhotoHasInvoice
+ }
+
+ // Проверка: есть черновик
+ if photo.DraftID != nil {
+ if !forceDeleteDraft {
+ return ErrPhotoHasDraft
+ }
+ // Если форсируем удаление - удаляем и черновик
+ if err := s.draftRepo.Delete(*photo.DraftID); err != nil {
+ return err
+ }
+ }
+
+ // Удаляем файл с диска (физически)
+ // В продакшене лучше делать это асинхронно или не делать вовсе (soft delete),
+ // но для экономии места удалим.
+ // Путь в БД может быть относительным или абсолютным, зависит от реализации загрузки.
+ // В ocr/service мы пишем абсолютный путь.
+ // Но в поле FilePath у нас путь.
+ if photo.FilePath != "" {
+ _ = os.Remove(photo.FilePath)
+ }
+
+ return s.photoRepo.Delete(photoID)
+}
diff --git a/internal/transport/http/handlers/photos.go b/internal/transport/http/handlers/photos.go
new file mode 100644
index 0000000..5c3f35f
--- /dev/null
+++ b/internal/transport/http/handlers/photos.go
@@ -0,0 +1,68 @@
+package handlers
+
+import (
+ "net/http"
+ "strconv"
+
+ "github.com/gin-gonic/gin"
+ "github.com/google/uuid"
+
+ photosSvc "rmser/internal/services/photos"
+)
+
+type PhotosHandler struct {
+ service *photosSvc.Service
+}
+
+func NewPhotosHandler(service *photosSvc.Service) *PhotosHandler {
+ return &PhotosHandler{service: service}
+}
+
+// GetPhotos GET /api/photos
+func (h *PhotosHandler) GetPhotos(c *gin.Context) {
+ userID := c.MustGet("userID").(uuid.UUID)
+
+ page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
+ limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
+
+ photos, total, err := h.service.GetPhotosForServer(userID, page, limit)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "photos": photos,
+ "total": total,
+ "page": page,
+ "limit": limit,
+ })
+}
+
+// DeletePhoto DELETE /api/photos/:id
+func (h *PhotosHandler) DeletePhoto(c *gin.Context) {
+ userID := c.MustGet("userID").(uuid.UUID)
+ photoID, err := uuid.Parse(c.Param("id"))
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
+ return
+ }
+
+ forceDeleteDraft := c.Query("force") == "true"
+
+ err = h.service.DeletePhoto(userID, photoID, forceDeleteDraft)
+ if err != nil {
+ if err == photosSvc.ErrPhotoHasDraft {
+ // Специальный статус, чтобы фронт показал Confirm
+ c.JSON(http.StatusConflict, gin.H{
+ "error": err.Error(),
+ "requires_confirm": true,
+ })
+ return
+ }
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{"status": "deleted"})
+}
diff --git a/rmser-view/src/components/settings/PhotoStorageTab.tsx b/rmser-view/src/components/settings/PhotoStorageTab.tsx
new file mode 100644
index 0000000..9186518
--- /dev/null
+++ b/rmser-view/src/components/settings/PhotoStorageTab.tsx
@@ -0,0 +1,215 @@
+import React, { useState } from "react";
+import {
+ Card,
+ Image,
+ Button,
+ Popconfirm,
+ Tag,
+ Pagination,
+ Empty,
+ Spin,
+ message,
+ Tooltip,
+} from "antd";
+import {
+ DeleteOutlined,
+ ReloadOutlined,
+ FileImageOutlined,
+ CheckCircleOutlined,
+ FileTextOutlined,
+} from "@ant-design/icons";
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import { api, getStaticUrl } from "../../services/api";
+import { AxiosError } from "axios";
+import type { ReceiptPhoto, PhotoStatus } from "../../services/types";
+
+export const PhotoStorageTab: React.FC = () => {
+ const [page, setPage] = useState(1);
+ const queryClient = useQueryClient();
+
+ const { data, isLoading, isError } = useQuery({
+ queryKey: ["photos", page],
+ queryFn: () => api.getPhotos(page, 18), // 18 - удобно делится на 2, 3, 6 колонок
+ });
+
+ const deleteMutation = useMutation({
+ mutationFn: ({ id, force }: { id: string; force: boolean }) =>
+ api.deletePhoto(id, force),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["photos"] });
+ message.success("Фото удалено");
+ },
+ // Исправленная типизация:
+ onError: (error: AxiosError<{ error: string }>) => {
+ if (error.response?.status === 409) {
+ message.warning(
+ "Это фото связано с черновиком. Используйте кнопку 'Удалить' с подтверждением."
+ );
+ } else {
+ message.error(error.response?.data?.error || "Ошибка удаления");
+ }
+ },
+ });
+
+ const regenerateMutation = useMutation({
+ mutationFn: (id: string) => api.regenerateDraftFromPhoto(id),
+ onSuccess: () => {
+ message.success("Черновик восстановлен");
+ // Можно редиректить, но пока просто обновим список
+ },
+ onError: () => {
+ message.error("Ошибка восстановления");
+ },
+ });
+
+ const getStatusTag = (status: PhotoStatus) => {
+ switch (status) {
+ case "ORPHAN":
+ return