From 8f596cec68f1afcd7d5c6820767024b462b37a0f Mon Sep 17 00:00:00 2001 From: Mikhail Chusavitin Date: Mon, 9 Feb 2026 17:22:51 +0300 Subject: [PATCH] fix: standardize CSV export filename format to use project name Unified export filename format across both ExportCSV and ExportConfigCSV: - Format: YYYY-MM-DD (project_name) config_name BOM.csv - Use PriceUpdatedAt if available, otherwise CreatedAt - Extract project name from ProjectUUID for ExportCSV via projectService - Pass project_uuid from frontend to backend in export request - Add projectUUID and projectName state variables to track project context This ensures consistent naming whether exporting from form or project view, and uses most recent price update timestamp in filename. Co-Authored-By: Claude Haiku 4.5 --- cmd/qfs/main.go | 2 +- internal/handlers/export.go | 40 ++++++++++++++++++++++++++------ internal/handlers/export_test.go | 6 +++++ web/templates/index.html | 7 ++++-- 4 files changed, 45 insertions(+), 10 deletions(-) diff --git a/cmd/qfs/main.go b/cmd/qfs/main.go index 6bf6bf2..34d74cd 100644 --- a/cmd/qfs/main.go +++ b/cmd/qfs/main.go @@ -695,7 +695,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect // Handlers componentHandler := handlers.NewComponentHandler(componentService, local) quoteHandler := handlers.NewQuoteHandler(quoteService) - exportHandler := handlers.NewExportHandler(exportService, configService, componentService) + exportHandler := handlers.NewExportHandler(exportService, configService, componentService, projectService) pricelistHandler := handlers.NewPricelistHandler(local) syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, templatesPath, backgroundSyncInterval) if err != nil { diff --git a/internal/handlers/export.go b/internal/handlers/export.go index 7b9ab50..86159a6 100644 --- a/internal/handlers/export.go +++ b/internal/handlers/export.go @@ -14,24 +14,28 @@ type ExportHandler struct { exportService *services.ExportService configService services.ConfigurationGetter componentService *services.ComponentService + projectService *services.ProjectService } func NewExportHandler( exportService *services.ExportService, configService services.ConfigurationGetter, componentService *services.ComponentService, + projectService *services.ProjectService, ) *ExportHandler { return &ExportHandler{ exportService: exportService, configService: configService, componentService: componentService, + projectService: projectService, } } type ExportRequest struct { - Name string `json:"name" binding:"required"` - ProjectName string `json:"project_name"` - Items []struct { + Name string `json:"name" binding:"required"` + ProjectName string `json:"project_name"` + ProjectUUID string `json:"project_uuid"` + Items []struct { LotName string `json:"lot_name" binding:"required"` Quantity int `json:"quantity" binding:"required,min=1"` UnitPrice float64 `json:"unit_price"` @@ -54,12 +58,22 @@ func (h *ExportHandler) ExportCSV(c *gin.Context) { return } - // Set headers before streaming + // Get project name if available projectName := req.ProjectName + if projectName == "" && req.ProjectUUID != "" { + // Try to load project name from database + username := middleware.GetUsername(c) + if project, err := h.projectService.GetByUUID(req.ProjectUUID, username); err == nil && project != nil { + projectName = project.Name + } + } if projectName == "" { projectName = req.Name } - filename := fmt.Sprintf("%s (%s) %s BOM.csv", time.Now().Format("2006-01-02"), projectName, req.Name) + + // Set headers before streaming + exportDate := data.CreatedAt + filename := fmt.Sprintf("%s (%s) %s BOM.csv", exportDate.Format("2006-01-02"), projectName, req.Name) c.Header("Content-Type", "text/csv; charset=utf-8") c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) @@ -128,9 +142,21 @@ func (h *ExportHandler) ExportConfigCSV(c *gin.Context) { return } + // Get project name if configuration belongs to a project + projectName := config.Name // fallback: use config name if no project + if config.ProjectUUID != nil && *config.ProjectUUID != "" { + if project, err := h.projectService.GetByUUID(*config.ProjectUUID, username); err == nil && project != nil { + projectName = project.Name + } + } + // Set headers before streaming - // For config export, use config name for both project and quotation name - filename := fmt.Sprintf("%s (%s) %s BOM.csv", config.CreatedAt.Format("2006-01-02"), config.Name, config.Name) + // Use price update time if available, otherwise creation time + exportDate := config.CreatedAt + if config.PriceUpdatedAt != nil { + exportDate = *config.PriceUpdatedAt + } + filename := fmt.Sprintf("%s (%s) %s BOM.csv", exportDate.Format("2006-01-02"), projectName, config.Name) c.Header("Content-Type", "text/csv; charset=utf-8") c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) diff --git a/internal/handlers/export_test.go b/internal/handlers/export_test.go index df40019..74be502 100644 --- a/internal/handlers/export_test.go +++ b/internal/handlers/export_test.go @@ -39,6 +39,7 @@ func TestExportCSV_Success(t *testing.T) { exportSvc, &mockConfigService{}, mockComponentService, + nil, ) // Create JSON request body @@ -113,6 +114,7 @@ func TestExportCSV_InvalidRequest(t *testing.T) { exportSvc, &mockConfigService{}, &services.ComponentService{}, + nil, ) // Create invalid request (missing required field) @@ -146,6 +148,7 @@ func TestExportCSV_EmptyItems(t *testing.T) { exportSvc, &mockConfigService{}, &services.ComponentService{}, + nil, ) // Create request with empty items array - should fail binding validation @@ -187,6 +190,7 @@ func TestExportConfigCSV_Success(t *testing.T) { exportSvc, &mockConfigService{config: mockConfig}, &services.ComponentService{}, + nil, ) // Create HTTP request @@ -236,6 +240,7 @@ func TestExportConfigCSV_NotFound(t *testing.T) { exportSvc, &mockConfigService{err: errors.New("config not found")}, &services.ComponentService{}, + nil, ) req, _ := http.NewRequest("GET", "/api/configs/nonexistent-uuid/export", nil) @@ -280,6 +285,7 @@ func TestExportConfigCSV_EmptyItems(t *testing.T) { exportSvc, &mockConfigService{config: mockConfig}, &services.ComponentService{}, + nil, ) req, _ := http.NewRequest("GET", "/api/configs/test-uuid/export", nil) diff --git a/web/templates/index.html b/web/templates/index.html index 6938929..6a3d346 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -326,6 +326,8 @@ let ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG) // State let configUUID = '{{.ConfigUUID}}'; let configName = ''; +let projectUUID = ''; +let projectName = ''; let currentTab = 'base'; let allComponents = []; let cart = []; @@ -609,6 +611,7 @@ document.addEventListener('DOMContentLoaded', async function() { const config = await resp.json(); configName = config.name; + projectUUID = config.project_uuid || ''; document.getElementById('config-name').textContent = config.name; document.getElementById('save-buttons').classList.remove('hidden'); @@ -1795,7 +1798,7 @@ async function exportCSV() { const resp = await fetch('/api/export/csv', { method: 'POST', headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({items: exportItems, name: configName}) + body: JSON.stringify({items: exportItems, name: configName, project_uuid: projectUUID}) }); const blob = await resp.blob(); @@ -2048,7 +2051,7 @@ async function exportCSVWithCustomPrice() { const resp = await fetch('/api/export/csv', { method: 'POST', headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({items: adjustedCart, name: configName}) + body: JSON.stringify({items: adjustedCart, name: configName, project_uuid: projectUUID}) }); const blob = await resp.blob();