From 1f53830b94bfd5dc6e0dcc07ffbd7d0d2341ae41 Mon Sep 17 00:00:00 2001 From: nic Date: Thu, 3 Apr 2025 17:24:02 -0400 Subject: [PATCH] fix: css junk that I made claude do --- internal/handlers/web/documents.go | 318 ++++++++++++++++++------- static/css/upload.css | 281 ++++++++++++++++++++++ templates/layout.html | 1 + templates/partials/upload_actions.html | 27 ++- 4 files changed, 530 insertions(+), 97 deletions(-) create mode 100644 static/css/upload.css diff --git a/internal/handlers/web/documents.go b/internal/handlers/web/documents.go index 9867fa2..6cfb7c6 100644 --- a/internal/handlers/web/documents.go +++ b/internal/handlers/web/documents.go @@ -10,8 +10,10 @@ import ( "net/http" "path/filepath" "regexp" + "sort" "strconv" "strings" + "sync" "time" root "marmic/servicetrade-toolbox" @@ -131,62 +133,57 @@ func ProcessCSVHandler(w http.ResponseWriter, r *http.Request) { jobID := strings.TrimSpace(row[idColumnIndex]) if jobID != "" { jobNumbers = append(jobNumbers, jobID) - log.Printf("Added job ID: %s", jobID) } } } - log.Printf("Extracted %d job IDs from CSV", len(jobNumbers)) + totalJobs := len(jobNumbers) + log.Printf("Extracted %d job IDs from CSV", totalJobs) - // Create a list of valid job numbers - var validJobNumbers []string - validJobNumbers = append(validJobNumbers, jobNumbers...) + if totalJobs == 0 { + http.Error(w, "No valid job IDs found in the CSV file", http.StatusBadRequest) + return + } - // Generate HTML for job list - var jobListHTML string - if len(validJobNumbers) > 0 { - // Create a hidden input with the job IDs - jobsValue := strings.Join(validJobNumbers, ",") + // Create a hidden input with the job IDs + jobsValue := strings.Join(jobNumbers, ",") - // Insert a hidden input for job numbers and show the job list - jobListHTML = fmt.Sprintf(` + // Generate HTML for job preview - don't show all IDs for large datasets + var jobPreviewHTML string + if totalJobs > 0 { + jobPreviewHTML = fmt.Sprintf(` - - `, jobsValue, buildJobListJS(validJobNumbers)) +
+

Found %d job(s) in the CSV file

+
+

Sample job IDs: %s

+
+
+ `, jobsValue, totalJobs, getJobSampleDisplay(jobNumbers)) } else { - jobListHTML = ` + jobPreviewHTML = `

No valid job numbers found in the CSV file.

` } w.Header().Set("Content-Type", "text/html") - w.Write([]byte(jobListHTML)) + w.Write([]byte(jobPreviewHTML)) } -// Helper function to build JavaScript for job list -func buildJobListJS(jobIDs []string) string { - var js strings.Builder - for _, id := range jobIDs { - js.WriteString(fmt.Sprintf(` - var li = document.createElement("li"); - li.setAttribute("data-job-id", "%s"); - li.textContent = "Job #%s"; - ul.appendChild(li); - `, id, id)) - } - return js.String() +// Helper function to show sample job IDs with a limit +func getJobSampleDisplay(jobIDs []string) string { + const maxSamples = 5 + if len(jobIDs) <= maxSamples { + return strings.Join(jobIDs, ", ") + } + + sample := append([]string{}, jobIDs[:maxSamples]...) + return strings.Join(sample, ", ") + fmt.Sprintf(" and %d more...", len(jobIDs)-maxSamples) } // UploadDocumentsHandler handles document uploads to jobs @@ -308,79 +305,230 @@ func UploadDocumentsHandler(w http.ResponseWriter, r *http.Request) { }) } - // Process each file and upload to each job - results := make(map[string][]map[string]interface{}) - for _, doc := range documents { - defer doc.File.Close() + if len(documents) == 0 { + http.Error(w, "No valid documents selected for upload", http.StatusBadRequest) + return + } + // Read all file contents first to avoid keeping files open during concurrent uploads + type DocumentWithContent struct { + Name string + Type string + FileContent []byte + } + + var docsWithContent []DocumentWithContent + for _, doc := range documents { // Read file content fileContent, err := io.ReadAll(doc.File) if err != nil { - http.Error(w, fmt.Sprintf("Error reading file %s: %v", doc.Header.Filename, err), http.StatusInternalServerError) - return + log.Printf("Error reading file %s: %v", doc.Header.Filename, err) + continue } + doc.File.Close() // Close the file as soon as we're done with it + + docsWithContent = append(docsWithContent, DocumentWithContent{ + Name: doc.Name, + Type: doc.Type, + FileContent: fileContent, + }) + } - // Upload to each job - for _, jobID := range jobs { - // Call the ServiceTrade API - result, err := session.UploadAttachment(jobID, doc.Name, doc.Type, fileContent) - if err != nil { - log.Printf("Error uploading %s to job %s: %v", doc.Name, jobID, err) - if _, exists := results[jobID]; !exists { - results[jobID] = []map[string]interface{}{} + // Concurrent upload with throttling + // ServiceTrade API allows 30s of availability per minute (approximately 15 requests at 2s each) + const maxConcurrent = 5 // A conservative limit to avoid rate limiting + const requestDelay = 300 * time.Millisecond // Delay between requests + + // Channel for collecting results + type UploadResult struct { + JobID string + DocName string + Success bool + Error string + Data map[string]interface{} + } + + totalUploads := len(jobs) * len(docsWithContent) + resultsChan := make(chan UploadResult, totalUploads) + + // Create a wait group to track when all uploads are done + var wg sync.WaitGroup + + // Create a semaphore channel to limit concurrent uploads + semaphore := make(chan struct{}, maxConcurrent) + + // Start the upload workers + for _, jobID := range jobs { + for _, doc := range docsWithContent { + wg.Add(1) + + // Launch a goroutine for each job+document combination + go func(jobID string, doc DocumentWithContent) { + defer wg.Done() + + // Acquire a semaphore slot + semaphore <- struct{}{} + defer func() { <-semaphore }() // Release when done + + // Add a small delay to avoid overwhelming the API + time.Sleep(requestDelay) + + // Call the ServiceTrade API + result, err := session.UploadAttachment(jobID, doc.Name, doc.Type, doc.FileContent) + + if err != nil { + log.Printf("Error uploading %s to job %s: %v", doc.Name, jobID, err) + resultsChan <- UploadResult{ + JobID: jobID, + DocName: doc.Name, + Success: false, + Error: err.Error(), + } + } else { + log.Printf("Successfully uploaded %s to job %s", doc.Name, jobID) + resultsChan <- UploadResult{ + JobID: jobID, + DocName: doc.Name, + Success: true, + Data: result, + } } - results[jobID] = append(results[jobID], map[string]interface{}{ - "filename": doc.Name, - "success": false, - "error": err.Error(), - }) - continue - } + }(jobID, doc) + } + } - // Record the success - if _, exists := results[jobID]; !exists { - results[jobID] = []map[string]interface{}{} - } - results[jobID] = append(results[jobID], map[string]interface{}{ - "filename": doc.Name, - "success": true, - "data": result, - }) - log.Printf("Successfully uploaded %s to job %s", doc.Name, jobID) + // Close the results channel when all uploads are done + go func() { + wg.Wait() + close(resultsChan) + }() + + // Collect results + results := make(map[string][]UploadResult) + for result := range resultsChan { + if _, exists := results[result.JobID]; !exists { + results[result.JobID] = []UploadResult{} } + results[result.JobID] = append(results[result.JobID], result) } // Generate HTML for results var resultHTML bytes.Buffer - resultHTML.WriteString("
") - resultHTML.WriteString("

Upload Results

") - if len(results) == 0 { - resultHTML.WriteString("

No documents were uploaded. Please check that you have selected files and document types.

") + // Count successes and failures + var totalSuccess, totalFailure int + for _, jobResults := range results { + for _, result := range jobResults { + if result.Success { + totalSuccess++ + } else { + totalFailure++ + } + } + } + + // Add summary section + resultHTML.WriteString("
") + resultHTML.WriteString("

Upload Results

") + resultHTML.WriteString("
") + + // Total jobs stat + resultHTML.WriteString("
") + resultHTML.WriteString(fmt.Sprintf("
%d
", len(results))) + resultHTML.WriteString("
Total Jobs
") + resultHTML.WriteString("
") + + // Success stat + resultHTML.WriteString("
") + resultHTML.WriteString(fmt.Sprintf("
%d
", totalSuccess)) + resultHTML.WriteString("
Successful Uploads
") + resultHTML.WriteString("
") + + // Failure stat + resultHTML.WriteString("
") + resultHTML.WriteString(fmt.Sprintf("
%d
", totalFailure)) + resultHTML.WriteString("
Failed Uploads
") + resultHTML.WriteString("
") + + // File count stat + resultHTML.WriteString("
") + resultHTML.WriteString(fmt.Sprintf("
%d
", totalSuccess+totalFailure)) + resultHTML.WriteString("
Files Processed
") + resultHTML.WriteString("
") + + resultHTML.WriteString("
") // End of upload-stats + + // Add completion message + if totalFailure == 0 { + resultHTML.WriteString("

All documents were successfully uploaded to ServiceTrade!

") } else { - for jobID, jobResults := range results { - resultHTML.WriteString(fmt.Sprintf("
Job #%s
    ", jobID)) + resultHTML.WriteString("

    Some documents failed to upload. See details below.

    ") + } - for _, result := range jobResults { - filename := result["filename"].(string) - success := result["success"].(bool) + resultHTML.WriteString("
") // End of upload-summary - if success { - resultHTML.WriteString(fmt.Sprintf("
  • %s: Uploaded successfully
  • ", filename)) - } else { - errorMsg := result["error"].(string) - resultHTML.WriteString(fmt.Sprintf("
  • %s: %s
  • ", filename, errorMsg)) + // Add detailed job results + resultHTML.WriteString("
    ") + + // Sort job IDs for consistent display + sortedJobs := make([]string, 0, len(results)) + for jobID := range results { + sortedJobs = append(sortedJobs, jobID) + } + sort.Strings(sortedJobs) + + for _, jobID := range sortedJobs { + jobResults := results[jobID] + + // Determine job success status + jobSuccess := true + for _, result := range jobResults { + if !result.Success { + jobSuccess = false + break + } + } + + // Job result row + jobClass := "success" + if !jobSuccess { + jobClass = "error" + } + + resultHTML.WriteString(fmt.Sprintf("
    ", jobClass)) + resultHTML.WriteString(fmt.Sprintf("Job #%s", jobID)) + + // File results + if len(jobResults) > 0 { + resultHTML.WriteString("
    ") + + for _, result := range jobResults { + fileClass := "success" + icon := "✓" + message := "Successfully uploaded" + + if !result.Success { + fileClass = "error" + icon = "✗" + message = result.Error } + + resultHTML.WriteString(fmt.Sprintf("
    ", fileClass)) + resultHTML.WriteString(fmt.Sprintf("%s", icon)) + resultHTML.WriteString(fmt.Sprintf("%s:", result.DocName)) + resultHTML.WriteString(fmt.Sprintf("%s", message)) + resultHTML.WriteString("
    ") } - resultHTML.WriteString("
    ") + resultHTML.WriteString("
    ") // End of file-results + } else { + resultHTML.WriteString("

    No files processed for this job.

    ") } - } - resultHTML.WriteString("
    ") + resultHTML.WriteString("
    ") // End of job-result + } - // Add JavaScript to scroll to results - resultHTML.WriteString("") + resultHTML.WriteString("
    ") // End of job-results w.Header().Set("Content-Type", "text/html") w.Write(resultHTML.Bytes()) diff --git a/static/css/upload.css b/static/css/upload.css new file mode 100644 index 0000000..cf1e9aa --- /dev/null +++ b/static/css/upload.css @@ -0,0 +1,281 @@ +/* Upload Summary Styles */ +.upload-summary { + margin: 1.5rem 0; + padding: 1.5rem; + border-radius: 6px; + background-color: var(--content-bg); + box-shadow: var(--dashboard-shadow); + color: var(--content-text); +} + +.upload-summary h3 { + margin-top: 0; + font-size: 1.25rem; + color: var(--dashboard-header-color); + margin-bottom: 1rem; +} + +.upload-stats { + display: flex; + gap: 1.5rem; + margin-bottom: 1rem; +} + +.stat-box { + flex: 1; + padding: 1rem; + border-radius: 4px; + background-color: var(--input-bg); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + text-align: center; +} + +.stat-value { + font-size: 2rem; + font-weight: 600; + margin-bottom: 0.25rem; + color: var(--content-text); +} + +.stat-label { + font-size: 0.875rem; + color: var(--label-color); +} + +.success-stat .stat-value { + color: var(--btn-success-bg); +} + +.error-stat .stat-value { + color: var(--btn-warning-bg); +} + +/* Job Results Styles */ +.job-results { + margin-top: 1.5rem; +} + +.job-result { + padding: 1rem; + margin-bottom: 0.5rem; + border-radius: 4px; + border: var(--input-border); + border-left-width: 4px; + background-color: var(--content-bg); + box-shadow: var(--dashboard-shadow); + color: var(--content-text); +} + +.job-result.success { + border-left-color: var(--btn-success-bg); +} + +.job-result.error { + border-left-color: var(--btn-warning-bg); +} + +.job-id { + font-weight: 600; + display: block; + margin-bottom: 0.5rem; + color: var(--dashboard-header-color); +} + +.file-results { + margin-left: 1rem; + margin-top: 0.5rem; +} + +.file-result { + padding: 0.75rem; + margin-bottom: 0.5rem; + border-radius: 4px; + display: flex; + align-items: center; + background-color: var(--input-bg); + border: var(--input-border); +} + +.file-result.success { + background-color: rgba(var(--btn-success-rgb, 52, 211, 153), 0.1); + border-color: rgba(var(--btn-success-rgb, 52, 211, 153), 0.3); +} + +.file-result.error { + background-color: rgba(var(--btn-warning-rgb, 248, 113, 113), 0.1); + border-color: rgba(var(--btn-warning-rgb, 248, 113, 113), 0.3); +} + +.status-icon { + font-size: 1.25rem; + margin-right: 0.75rem; +} + +.success .status-icon { + color: var(--btn-success-bg); +} + +.error .status-icon { + color: var(--btn-warning-bg); +} + +.file-name { + font-weight: 500; + margin-right: 0.5rem; + color: var(--content-text); +} + +.file-message { + color: var(--label-color); + font-size: 0.875rem; +} + +/* Upload Progress Styles */ +.upload-progress { + background-color: var(--content-bg); + border-radius: 6px; + padding: 1.5rem; + margin: 1.5rem 0; + box-shadow: var(--dashboard-shadow); +} + +.progress-info { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.75rem; +} + +.progress-info span { + font-weight: 500; + color: var(--content-text); +} + +.spinner { + border: 3px solid rgba(0, 0, 0, 0.1); + border-radius: 50%; + border-top: 3px solid var(--btn-primary-bg); + width: 20px; + height: 20px; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } +} + +.progress { + height: 0.5rem; + background-color: var(--progress-bg, #e2e8f0); + border-radius: 999px; + overflow: hidden; +} + +.progress-bar { + height: 100%; + width: 100%; + background-color: var(--progress-fill, #4299e1); + background-image: linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); + background-size: 1rem 1rem; + border-radius: 999px; + animation: progress-animation 1s linear infinite; +} + +@keyframes progress-animation { + 0% { + background-position: 1rem 0; + } + + 100% { + background-position: 0 0; + } +} + +#upload-status { + margin-top: 0.75rem; + font-size: 0.875rem; + color: var(--label-color); +} + +.pulsing { + animation: pulse 2s infinite; +} + +@keyframes pulse { + 0% { + opacity: 0.6; + } + + 50% { + opacity: 1; + } + + 100% { + opacity: 0.6; + } +} + +.advice { + font-weight: 500; + color: var(--btn-primary-bg); +} + +/* CSV Preview styles */ +#csv-preview { + margin-top: 1rem; + padding: 1rem; + background-color: var(--content-bg); + border-radius: 6px; + border-left: 4px solid var(--btn-primary-bg); +} + +.csv-sample { + margin-top: 0.5rem; + padding: 0.75rem; + background-color: var(--input-bg); + border-radius: 4px; + box-shadow: var(--dashboard-shadow); +} + +#csv-preview p { + margin: 0.5rem 0; + color: var(--content-text); +} + +/* HTMX indicator styles */ +.htmx-indicator { + opacity: 0; + transition: opacity 200ms ease-in; +} + +.htmx-request .htmx-indicator { + opacity: 1; +} + +.htmx-request.htmx-indicator { + opacity: 1; +} + +.loading-indicator { + display: inline-block; + width: 1rem; + height: 1rem; + border: 2px solid rgba(0, 0, 0, 0.1); + border-radius: 50%; + border-top-color: var(--btn-primary-bg); + animation: spin 1s linear infinite; + margin-left: 0.5rem; + vertical-align: middle; +} + +:root.dark-theme .spinner, +:root.dark-theme .loading-indicator { + border-color: rgba(255, 255, 255, 0.1); + border-top-color: var(--btn-primary-bg); +} \ No newline at end of file diff --git a/templates/layout.html b/templates/layout.html index b2694fd..0443247 100644 --- a/templates/layout.html +++ b/templates/layout.html @@ -7,6 +7,7 @@ ServiceTrade Tools + diff --git a/templates/partials/upload_actions.html b/templates/partials/upload_actions.html index acdbe94..dfe7f9f 100644 --- a/templates/partials/upload_actions.html +++ b/templates/partials/upload_actions.html @@ -4,24 +4,27 @@
    + hx-target="#upload-results" hx-indicator="#upload-progress"> - + -