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 Без привязки; + case "HAS_DRAFT": + return ( + } color="processing"> + Черновик + + ); + case "HAS_INVOICE": + return ( + } color="success"> + В iiko + + ); + default: + return {status}; + } + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (isError) { + return ; + } + + if (!data?.photos?.length) { + return ; + } + + return ( +
+
+ {data.photos.map((photo: ReceiptPhoto) => ( + + {photo.file_name} }} + /> +
+ } + actions={[ + photo.can_regenerate ? ( + +
+ } + description={getStatusTag(photo.status)} + /> + + ))} + + + + + ); +}; diff --git a/rmser-view/src/pages/SettingsPage.tsx b/rmser-view/src/pages/SettingsPage.tsx index 3baa5c5..faa4fde 100644 --- a/rmser-view/src/pages/SettingsPage.tsx +++ b/rmser-view/src/pages/SettingsPage.tsx @@ -20,11 +20,13 @@ import { SettingOutlined, FolderOpenOutlined, TeamOutlined, + CameraOutlined, } from "@ant-design/icons"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { api } from "../services/api"; import type { UserSettings } from "../services/types"; import { TeamList } from "../components/settings/TeamList"; +import { PhotoStorageTab } from "../components/settings/PhotoStorageTab"; const { Title, Text } = Typography; @@ -207,6 +209,16 @@ export const SettingsPage: React.FC = () => { }); } + // Добавляем вкладку с фото (доступна для OWNER) + if (currentUserRole === "OWNER") { + tabsItems.push({ + key: "photos", + label: "Архив фото", + icon: , + children: , + }); + } + return (
diff --git a/rmser-view/src/services/api.ts b/rmser-view/src/services/api.ts index 7f940e3..f78a390 100644 --- a/rmser-view/src/services/api.ts +++ b/rmser-view/src/services/api.ts @@ -25,7 +25,8 @@ import type { UnifiedInvoice, ServerUser, UserRole, - InvoiceDetails + InvoiceDetails, + GetPhotosResponse } from './types'; // Базовый URL @@ -255,4 +256,21 @@ export const api = { syncInvoices: async (): Promise<void> => { await apiClient.post('/invoices/sync'); }, -}; \ No newline at end of file + getPhotos: async (page = 1, limit = 20): Promise<GetPhotosResponse> => { + const { data } = await apiClient.get<GetPhotosResponse>('/photos', { + params: { page, limit } + }); + return data; + }, + + deletePhoto: async (id: string, force = false): Promise<void> => { + await apiClient.delete(`/photos/${id}`, { + params: { force } + }); + }, + + regenerateDraftFromPhoto: async (id: string): Promise<void> => { + await apiClient.post(`/photos/${id}/regenerate`); + }, +}; + diff --git a/rmser-view/src/services/types.ts b/rmser-view/src/services/types.ts index fb58190..16507d4 100644 --- a/rmser-view/src/services/types.ts +++ b/rmser-view/src/services/types.ts @@ -274,4 +274,28 @@ export interface InvoiceDetails { total: number; }[]; photo_url: string | null; +} + +export type PhotoStatus = 'ORPHAN' | 'HAS_DRAFT' | 'HAS_INVOICE'; + +export interface ReceiptPhoto { + id: string; + rms_server_id: string; + uploaded_by: string; + file_url: string; + file_name: string; + file_size: number; + draft_id?: string; + invoice_id?: string; + created_at: string; + status: PhotoStatus; + can_delete: boolean; + can_regenerate: boolean; +} + +export interface GetPhotosResponse { + photos: ReceiptPhoto[]; + total: number; + page: number; + limit: number; } \ No newline at end of file