diff --git a/apps/web/main.go b/apps/web/main.go index 8a4d19d..40feb0a 100644 --- a/apps/web/main.go +++ b/apps/web/main.go @@ -64,6 +64,7 @@ func main() { protected.HandleFunc("/documents", web.DocumentsHandler).Methods("GET") protected.HandleFunc("/process-csv", web.ProcessCSVHandler).Methods("POST") protected.HandleFunc("/upload-documents", web.UploadDocumentsHandler).Methods("POST") + protected.HandleFunc("/documents/upload/results", web.UploadResultsHandler).Methods("GET") // Document removal routes protected.HandleFunc("/documents/remove", web.DocumentRemoveHandler).Methods("GET") @@ -72,6 +73,7 @@ func main() { protected.HandleFunc("/documents/remove/job/{jobID}", web.GetJobAttachmentsHandler).Methods("GET") protected.HandleFunc("/documents/remove/attachments/{jobID}", web.RemoveJobAttachmentsHandler).Methods("POST") protected.HandleFunc("/documents/remove/bulk", web.BulkRemoveDocumentsHandler).Methods("POST") + protected.HandleFunc("/documents/remove/results", web.RemovalResultsHandler).Methods("GET") port := os.Getenv("PORT") if port == "" { diff --git a/apps/web/web-app b/apps/web/web-app new file mode 100644 index 0000000..c89706a Binary files /dev/null and b/apps/web/web-app differ diff --git a/internal/handlers/web/document_remove.go b/internal/handlers/web/document_remove.go index 8fd5400..31ae675 100644 --- a/internal/handlers/web/document_remove.go +++ b/internal/handlers/web/document_remove.go @@ -7,7 +7,6 @@ import ( "log" "net/http" "regexp" - "sort" "strconv" "strings" "sync" @@ -20,6 +19,34 @@ import ( "github.com/gorilla/mux" ) +// RemovalResult represents the result of a single job removal +type RemovalResult struct { + JobID string + FilesFound int + FilesRemoved int + Success bool + ErrorMsg string + Files []struct { + Name string + Success bool + Error string + } +} + +// RemovalSession stores removal results for pagination +type RemovalSession struct { + Results []RemovalResult + TotalJobs int + SuccessCount int + ErrorCount int + TotalFiles int + TotalTime time.Duration + CreatedAt time.Time +} + +// Global map to store removal sessions (in production, use Redis or database) +var removalSessions = make(map[string]RemovalSession) + // DocumentRemoveHandler handles the document removal page func DocumentRemoveHandler(w http.ResponseWriter, r *http.Request) { session, ok := r.Context().Value(middleware.SessionKey).(*api.Session) @@ -731,114 +758,126 @@ func BulkRemoveDocumentsHandler(w http.ResponseWriter, r *http.Request) { log.Printf("Document removal completed in %v", totalDuration) - // Generate HTML for results - var resultHTML bytes.Buffer + // Convert results to RemovalResult format for session storage + var removalResults []RemovalResult + for _, jobResult := range results.JobResults { + removalResult := RemovalResult{ + JobID: jobResult.JobID, + FilesFound: jobResult.FilesFound, + FilesRemoved: jobResult.FilesRemoved, + Success: jobResult.Success, + ErrorMsg: jobResult.ErrorMsg, + Files: jobResult.Files, + } + removalResults = append(removalResults, removalResult) + } - // Add summary section - resultHTML.WriteString("
") - resultHTML.WriteString("

Document Removal Results

") - resultHTML.WriteString("
") - - // Total jobs stat - resultHTML.WriteString("
") - resultHTML.WriteString(fmt.Sprintf("
%d
", results.JobsProcessed)) - resultHTML.WriteString("
Total Jobs
") - resultHTML.WriteString("
") - - // Success stat - resultHTML.WriteString("
") - resultHTML.WriteString(fmt.Sprintf("
%d
", results.SuccessCount)) - resultHTML.WriteString("
Successful Removals
") - resultHTML.WriteString("
") - - // Failure stat - resultHTML.WriteString("
") - resultHTML.WriteString(fmt.Sprintf("
%d
", results.ErrorCount)) - resultHTML.WriteString("
Failed Removals
") - resultHTML.WriteString("
") - - // File count stat - resultHTML.WriteString("
") - resultHTML.WriteString(fmt.Sprintf("
%d
", results.TotalFiles)) - resultHTML.WriteString("
Files Processed
") - resultHTML.WriteString("
") - - // Duration stat - resultHTML.WriteString("
") - resultHTML.WriteString(fmt.Sprintf("
%v
", totalDuration)) - resultHTML.WriteString("
Total Time
") - resultHTML.WriteString("
") - - resultHTML.WriteString("
") // End of upload-stats - - // Add completion message with timing - if results.ErrorCount == 0 { - resultHTML.WriteString(fmt.Sprintf("

All documents were successfully removed from ServiceTrade in %v!

", totalDuration)) - } else { - resultHTML.WriteString(fmt.Sprintf("

Some documents failed to be removed. Process completed in %v. See details below.

", totalDuration)) + // Store results in session for pagination + sessionID := fmt.Sprintf("removal_%d", time.Now().UnixNano()) + removalSession := RemovalSession{ + Results: removalResults, + TotalJobs: results.JobsProcessed, + SuccessCount: results.SuccessCount, + ErrorCount: results.ErrorCount, + TotalFiles: results.TotalFiles, + TotalTime: totalDuration, + CreatedAt: time.Now(), } - resultHTML.WriteString("
") // End of upload-summary + // Store in global map (in production, use Redis or database) + removalSessions[sessionID] = removalSession - // Add detailed job results - resultHTML.WriteString("
") + // Return first page of results + renderRemovalResultsPage(w, sessionID, 1, 20) // Default to page 1, 20 items per page +} - // Sort job IDs for consistent display - sort.Slice(results.JobResults, func(i, j int) bool { - return results.JobResults[i].JobID < results.JobResults[j].JobID - }) +// RemovalResultsHandler handles pagination for removal results +func RemovalResultsHandler(w http.ResponseWriter, r *http.Request) { + sessionID := r.URL.Query().Get("session_id") + if sessionID == "" { + http.Error(w, "Session ID required", http.StatusBadRequest) + return + } - for _, jobResult := range results.JobResults { - // Job result row - jobClass := "success" - if !jobResult.Success { - jobClass = "error" - } + page, _ := strconv.Atoi(r.URL.Query().Get("page")) + if page < 1 { + page = 1 + } - resultHTML.WriteString(fmt.Sprintf("
", jobClass)) - resultHTML.WriteString(fmt.Sprintf("Job ID: %s", jobResult.JobID)) + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + if limit < 1 { + limit = 20 + } - if jobResult.ErrorMsg != "" { - resultHTML.WriteString(fmt.Sprintf("
%s
", jobResult.ErrorMsg)) - } else { - resultHTML.WriteString(fmt.Sprintf("
Found %d document(s), removed %d
", - jobResult.FilesFound, jobResult.FilesRemoved)) - } + renderRemovalResultsPage(w, sessionID, page, limit) +} - // File results - if len(jobResult.Files) > 0 { - resultHTML.WriteString("
") +// renderRemovalResultsPage renders a paginated page of removal results +func renderRemovalResultsPage(w http.ResponseWriter, sessionID string, page, limit int) { + removalSession, exists := removalSessions[sessionID] + if !exists { + http.Error(w, "Removal session not found", http.StatusNotFound) + return + } - for _, file := range jobResult.Files { - fileClass := "success" - icon := "✓" - message := "Successfully removed" + totalResults := len(removalSession.Results) + totalPages := (totalResults + limit - 1) / limit // Ceiling division - if !file.Success { - fileClass = "error" - icon = "✗" - message = file.Error - } + if page > totalPages && totalPages > 0 { + page = totalPages + } - resultHTML.WriteString(fmt.Sprintf("
", fileClass)) - resultHTML.WriteString(fmt.Sprintf("%s", icon)) - resultHTML.WriteString(fmt.Sprintf("%s:", file.Name)) - resultHTML.WriteString(fmt.Sprintf("%s", message)) - resultHTML.WriteString("
") - } + startIndex := (page - 1) * limit + endIndex := startIndex + limit + if endIndex > totalResults { + endIndex = totalResults + } - resultHTML.WriteString("
") // End of file-results + // Get results for this page + var pageResults []RemovalResult + if startIndex < totalResults { + pageResults = removalSession.Results[startIndex:endIndex] + } + + // Calculate pagination info + startPage := 1 + endPage := totalPages + if totalPages > 10 { + if page <= 5 { + endPage = 10 + } else if page >= totalPages-4 { + startPage = totalPages - 9 } else { - resultHTML.WriteString("

No files processed for this job.

") + startPage = page - 4 + endPage = page + 5 } - - resultHTML.WriteString("
") // End of job-result } - resultHTML.WriteString("
") // End of job-results + data := map[string]interface{}{ + "Results": pageResults, + "TotalJobs": removalSession.TotalJobs, + "SuccessCount": removalSession.SuccessCount, + "ErrorCount": removalSession.ErrorCount, + "TotalFiles": removalSession.TotalFiles, + "TotalTime": removalSession.TotalTime, + "TotalResults": totalResults, + "TotalPages": totalPages, + "CurrentPage": page, + "Limit": limit, + "StartIndex": startIndex + 1, + "EndIndex": endIndex, + "StartPage": startPage, + "EndPage": endPage, + "SessionID": sessionID, + } - w.Header().Set("Content-Type", "text/html") - w.Write(resultHTML.Bytes()) + tmpl := root.WebTemplates + if err := tmpl.ExecuteTemplate(w, "removal_results", data); err != nil { + log.Printf("Template execution error: %v", err) + // Don't call http.Error here as the response may have already started + // Just log the error and return + return + } } // Helper function to check if a string matches any pattern in a slice diff --git a/internal/handlers/web/documents.go b/internal/handlers/web/documents.go index 665e16b..24036e6 100644 --- a/internal/handlers/web/documents.go +++ b/internal/handlers/web/documents.go @@ -9,7 +9,7 @@ import ( "log" "math" "net/http" - "sort" + "strconv" "strings" "sync" "time" @@ -19,6 +19,30 @@ import ( "marmic/servicetrade-toolbox/internal/middleware" ) +// UploadResult represents the result of a single file upload +type UploadResult struct { + JobID string + DocName string + Success bool + Error string + Data map[string]interface{} + FileSize int64 +} + +// UploadSession stores upload results for pagination +type UploadSession struct { + Results []UploadResult + TotalJobs int + TotalSuccess int + TotalFailure int + TotalBytesUploaded int64 + TotalTime time.Duration + CreatedAt time.Time +} + +// Global map to store upload sessions (in production, use Redis or database) +var uploadSessions = make(map[string]UploadSession) + // DocumentsHandler handles the document upload page func DocumentsHandler(w http.ResponseWriter, r *http.Request) { session, ok := r.Context().Value(middleware.SessionKey).(*api.Session) @@ -312,15 +336,6 @@ func UploadDocumentsHandler(w http.ResponseWriter, r *http.Request) { const maxConcurrent = 5 const requestDelay = 300 * time.Millisecond - type UploadResult struct { - JobID string - DocName string - Success bool - Error string - Data map[string]interface{} - FileSize int64 - } - totalUploads := len(jobs) * activeFilesProcessedCount resultsChan := make(chan UploadResult, totalUploads) var wg sync.WaitGroup @@ -412,115 +427,139 @@ func UploadDocumentsHandler(w http.ResponseWriter, r *http.Request) { log.Println("All upload goroutines finished.") }() - results := make(map[string][]UploadResult) - resultsCount := 0 + // Collect all results + var allResults []UploadResult var totalBytesUploaded int64 for result := range resultsChan { - resultsCount++ - log.Printf("Received result %d/%d: Job %s, File %s, Success: %v, Size: %.2f MB", - resultsCount, totalUploads, result.JobID, result.DocName, result.Success, - float64(result.FileSize)/(1024*1024)) + log.Printf("Received result: Job %s, File %s, Success: %v, Size: %.2f MB", + result.JobID, result.DocName, result.Success, float64(result.FileSize)/(1024*1024)) if result.Success { totalBytesUploaded += result.FileSize } - if _, exists := results[result.JobID]; !exists { - results[result.JobID] = []UploadResult{} - } - results[result.JobID] = append(results[result.JobID], result) + allResults = append(allResults, result) } // Calculate total duration totalDuration := time.Since(startTime) log.Printf("All results collected. Total: %d, Total bytes uploaded: %.2f MB, Total time: %v", - resultsCount, float64(totalBytesUploaded)/(1024*1024), totalDuration) - - var resultHTML bytes.Buffer - var totalSuccess, totalFailure int - for _, jobResults := range results { - for _, result := range jobResults { - if result.Success { - totalSuccess++ - } else { - totalFailure++ - } + len(allResults), float64(totalBytesUploaded)/(1024*1024), totalDuration) + + // Store results in session for pagination + sessionID := fmt.Sprintf("upload_%d", time.Now().UnixNano()) + uploadSession := UploadSession{ + Results: allResults, + TotalJobs: len(jobs), + TotalSuccess: 0, + TotalFailure: 0, + TotalBytesUploaded: totalBytesUploaded, + TotalTime: totalDuration, + CreatedAt: time.Now(), + } + + // Calculate totals + for _, result := range allResults { + if result.Success { + uploadSession.TotalSuccess++ + } else { + uploadSession.TotalFailure++ } } - resultHTML.WriteString("
") - resultHTML.WriteString("

Upload Results

") - resultHTML.WriteString("
") - resultHTML.WriteString(fmt.Sprintf("
%d
Total Jobs
", len(results))) - resultHTML.WriteString(fmt.Sprintf("
%d
Successful Uploads
", totalSuccess)) - resultHTML.WriteString(fmt.Sprintf("
%d
Failed Uploads
", totalFailure)) - resultHTML.WriteString(fmt.Sprintf("
%d
Files Processed
", resultsCount)) - resultHTML.WriteString(fmt.Sprintf("
%v
Total Time
", totalDuration)) + // Store in global map (in production, use Redis or database) + uploadSessions[sessionID] = uploadSession - if totalFailure == 0 && resultsCount > 0 { - resultHTML.WriteString(fmt.Sprintf("

All documents were successfully uploaded to ServiceTrade in %v!

", totalDuration)) - } else if resultsCount == 0 { - resultHTML.WriteString("

No documents were processed for upload.

") - } else { - resultHTML.WriteString(fmt.Sprintf("

Some documents failed to upload. Process completed in %v. See details below.

", totalDuration)) - } - resultHTML.WriteString("
") - - resultHTML.WriteString("
") - sortedJobs := make([]string, 0, len(results)) - for jobID := range results { - sortedJobs = append(sortedJobs, jobID) - } - sort.Strings(sortedJobs) - - for _, jobID := range sortedJobs { - jobResults := results[jobID] - jobHasSuccess := false - jobHasFailure := false - for _, result := range jobResults { - if result.Success { - jobHasSuccess = true - } else { - jobHasFailure = true - } - } - jobClass := "neutral" - if jobHasSuccess && !jobHasFailure { - jobClass = "success" - } else if jobHasFailure { - jobClass = "error" - } - resultHTML.WriteString(fmt.Sprintf("
", jobClass)) - resultHTML.WriteString(fmt.Sprintf("
Job ID: %s
", jobID)) - if len(jobResults) > 0 { - resultHTML.WriteString("
") - sort.Slice(jobResults, func(i, j int) bool { - return jobResults[i].DocName < jobResults[j].DocName - }) - for _, result := range jobResults { - fileClass := "success" - icon := "✓" - message := "Successfully uploaded" - if !result.Success { - fileClass = "error" - icon = "✗" - message = strings.ReplaceAll(result.Error, "<", "<") - message = strings.ReplaceAll(message, ">", ">") - } - 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("
") + // Return first page of results + renderUploadResultsPage(w, sessionID, 1, 20) // Default to page 1, 20 items per page +} + +// UploadResultsHandler handles pagination for upload results +func UploadResultsHandler(w http.ResponseWriter, r *http.Request) { + sessionID := r.URL.Query().Get("session_id") + if sessionID == "" { + http.Error(w, "Session ID required", http.StatusBadRequest) + return + } + + page, _ := strconv.Atoi(r.URL.Query().Get("page")) + if page < 1 { + page = 1 + } + + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + if limit < 1 { + limit = 20 + } + + renderUploadResultsPage(w, sessionID, page, limit) +} + +// renderUploadResultsPage renders a paginated page of upload results +func renderUploadResultsPage(w http.ResponseWriter, sessionID string, page, limit int) { + uploadSession, exists := uploadSessions[sessionID] + if !exists { + http.Error(w, "Upload session not found", http.StatusNotFound) + return + } + + totalResults := len(uploadSession.Results) + totalPages := (totalResults + limit - 1) / limit // Ceiling division + + if page > totalPages && totalPages > 0 { + page = totalPages + } + + startIndex := (page - 1) * limit + endIndex := startIndex + limit + if endIndex > totalResults { + endIndex = totalResults + } + + // Get results for this page + var pageResults []UploadResult + if startIndex < totalResults { + pageResults = uploadSession.Results[startIndex:endIndex] + } + + // Calculate pagination info + startPage := 1 + endPage := totalPages + if totalPages > 10 { + if page <= 5 { + endPage = 10 + } else if page >= totalPages-4 { + startPage = totalPages - 9 } else { - resultHTML.WriteString("

No file upload results for this job.

") + startPage = page - 4 + endPage = page + 5 } - resultHTML.WriteString("
") } - resultHTML.WriteString("
") - w.Header().Set("Content-Type", "text/html") - w.Write(resultHTML.Bytes()) + + data := map[string]interface{}{ + "Results": pageResults, + "TotalJobs": uploadSession.TotalJobs, + "TotalSuccess": uploadSession.TotalSuccess, + "TotalFailure": uploadSession.TotalFailure, + "TotalBytesUploaded": uploadSession.TotalBytesUploaded, + "TotalTime": uploadSession.TotalTime, + "TotalResults": totalResults, + "TotalPages": totalPages, + "CurrentPage": page, + "Limit": limit, + "StartIndex": startIndex + 1, + "EndIndex": endIndex, + "StartPage": startPage, + "EndPage": endPage, + "SessionID": sessionID, + } + + tmpl := root.WebTemplates + if err := tmpl.ExecuteTemplate(w, "upload_results_pagination", data); err != nil { + log.Printf("Template execution error: %v", err) + // Don't call http.Error here as the response may have already started + // Just log the error and return + return + } } // readCloserWithSize is a custom io.Reader that counts the bytes read diff --git a/static/css/styles.css b/static/css/styles.css index 4520130..6c3fe9b 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -1731,5 +1731,229 @@ html { } .btn-secondary:hover { - background: var(--input-border); + background-color: var(--btn-caution-hover); +} + +/* Upload Result Cards */ +.upload-results-container { + background-color: var(--dashboard-bg); + border-radius: 0.5rem; + box-shadow: var(--dashboard-shadow); + padding: 1.5rem; + margin-top: 1rem; + max-width: 100%; +} + +.upload-results-header { + margin-bottom: 1.5rem; +} + +.upload-results-header h3 { + color: var(--text-color); + font-size: 1.5rem; + margin: 0 0 1rem 0; +} + +.upload-stats { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 1rem; + margin-bottom: 1.5rem; + max-width: 600px; +} + +.stat-item { + background-color: rgba(30, 33, 43, 0.8); + border-radius: 0.5rem; + padding: 1.25rem; + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.stat-item:last-child { + grid-column: 1; +} + +.stat-value { + font-size: 2.5rem; + font-weight: bold; + color: var(--text-color); + line-height: 1; + margin-bottom: 0.5rem; +} + +.stat-label { + font-size: 0.875rem; + color: var(--text-muted); +} + +.success-stat .stat-value { + color: var(--success-color, #4CAF50); +} + +.error-stat .stat-value { + color: var(--error-color, #f44336); +} + +.upload-results-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 1rem; + margin-top: 1.5rem; +} + +.upload-result-card { + background-color: rgba(30, 33, 43, 0.8); + border-radius: 0.5rem; + padding: 1.25rem; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.upload-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; +} + +.upload-header h4 { + margin: 0; + color: var(--text-color); + font-size: 1rem; + font-weight: 500; + flex: 1; +} + +.upload-status { + padding: 0.25rem 0.75rem; + border-radius: 1rem; + font-size: 0.875rem; +} + +.upload-status.success { + background-color: rgba(76, 175, 80, 0.1); + color: var(--success-color, #4CAF50); +} + +.upload-status.error { + background-color: rgba(244, 67, 54, 0.1); + color: var(--error-color, #f44336); +} + +.upload-details { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.upload-info { + font-size: 0.875rem; + color: var(--text-muted); +} + +.upload-info p { + margin: 0; +} + +.success-text { + color: var(--success-color, #4CAF50); +} + +.error-text { + color: var(--error-color, #f44336); +} + +/* Pagination Controls */ +.pagination-controls { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 1.5rem; + padding-top: 1rem; + border-top: 1px solid var(--content-border); +} + +.pagination-info { + font-size: 0.875rem; + color: var(--label-color); +} + +.pagination-buttons { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.pagination-btn { + padding: 0.5rem 0.75rem; + border: 1px solid var(--content-border); + background-color: var(--input-bg); + color: var(--text-color); + border-radius: 0.25rem; + cursor: pointer; + font-size: 0.875rem; + transition: all 0.2s ease; +} + +.pagination-btn:hover { + background-color: var(--btn-primary-bg); + color: white; + border-color: var(--btn-primary-bg); +} + +.pagination-btn.active { + background-color: var(--btn-primary-bg); + color: white; + border-color: var(--btn-primary-bg); +} + +.pagination-btn:disabled { + background-color: var(--btn-disabled); + color: var(--label-color); + cursor: not-allowed; + border-color: var(--btn-disabled); +} + +/* Responsive Design */ +@media (max-width: 768px) { + .upload-results-header { + flex-direction: column; + align-items: flex-start; + gap: 1rem; + } + + .upload-stats { + flex-wrap: wrap; + gap: 0.5rem; + } + + .upload-results-grid { + grid-template-columns: 1fr; + } + + .upload-details { + flex-direction: column; + gap: 1rem; + } + + .upload-actions { + margin-left: 0; + align-items: flex-start; + } + + .pagination-controls { + flex-direction: column; + gap: 1rem; + align-items: center; + } + + .pagination-buttons { + flex-wrap: wrap; + justify-content: center; + } } \ No newline at end of file diff --git a/static/css/upload.css b/static/css/upload.css index 165d757..53e5dc2 100644 --- a/static/css/upload.css +++ b/static/css/upload.css @@ -1,698 +1,806 @@ -/* 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-specific CSS Variables (extending styles.css) */ +:root { + /* Upload-specific colors */ + --upload-success-color: #22c55e; + --upload-error-color: #ef4444; + --upload-warning-color: #eab308; + --upload-info-color: #4299e1; + + /* Upload overlay colors */ + --upload-overlay-bg: rgba(0, 0, 0, 0.7); + --upload-overlay-content-bg: transparent; + + /* Upload card colors */ + --upload-card-bg: var(--content-bg); + --upload-card-border: var(--content-border); + --upload-card-shadow: var(--dashboard-shadow); + + /* Upload stats colors */ + --upload-stat-bg: var(--input-bg); + --upload-stat-border: var(--input-border); + --upload-stat-success-bg: rgba(34, 197, 94, 0.1); + --upload-stat-error-bg: rgba(239, 68, 68, 0.1); + --upload-stat-warning-bg: rgba(234, 179, 8, 0.1); } -.upload-stats { - display: flex; - gap: 1.5rem; - margin-bottom: 1rem; +@media (prefers-color-scheme: dark) { + :root { + /* Upload-specific colors for dark theme */ + --upload-overlay-bg: rgba(0, 0, 0, 0.8); + --upload-overlay-content-bg: transparent; + + /* Upload card colors for dark theme */ + --upload-card-bg: rgba(30, 33, 43, 0.8); + --upload-card-border: none; + --upload-card-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); + + /* Upload stats colors for dark theme */ + --upload-stat-bg: rgba(30, 33, 43, 0.8); + --upload-stat-border: none; + --upload-stat-success-bg: rgba(52, 211, 153, 0.1); + --upload-stat-error-bg: rgba(248, 113, 113, 0.1); + --upload-stat-warning-bg: rgba(245, 158, 11, 0.1); + } } -.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; +/* Upload Container */ +.upload-container { + position: relative; + min-height: 400px; } -.stat-value { - font-size: 2rem; - font-weight: 600; - margin-bottom: 0.25rem; - color: var(--content-text); +/* Upload Overlay - Fixed positioning to be contained within upload section */ +.upload-overlay, +.removal-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: var(--upload-overlay-bg); + display: none; + justify-content: center; + align-items: center; + z-index: 1000; + border-radius: inherit; + backdrop-filter: blur(2px); } -.stat-label { - font-size: 0.875rem; - color: var(--label-color); +.upload-overlay.htmx-request, +.removal-overlay.htmx-request { + display: flex; } -.success-stat .stat-value { - color: var(--btn-success-bg); +.upload-overlay-content { + background-color: var(--upload-overlay-content-bg); + padding: 2rem; + border-radius: 0.5rem; + text-align: center; + max-width: 400px; + width: 90%; } -.error-stat .stat-value { - color: var(--btn-warning-bg); +.upload-overlay h3, +.removal-overlay h3 { + margin: 0 0 1rem 0; + color: white; + font-size: 1.5rem; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); } -/* Job Results Styles */ -.job-results { - margin-top: 1.5rem; +.upload-overlay p, +.removal-overlay p { + margin: 0 0 1.5rem 0; + color: white; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); } -.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); +.upload-overlay .overlay-spinner, +.removal-overlay .overlay-spinner { + margin-bottom: 1rem; } -.job-result.success { - border-left-color: var(--btn-success-bg); +/* Overlay Spinner */ +.overlay-spinner { + width: 50px; + height: 50px; + margin: 0 auto; + border: 3px solid rgba(255, 255, 255, 0.3); + border-radius: 50%; + border-top-color: white; + animation: spin 1s ease-in-out infinite; } -.job-result.error { - border-left-color: var(--btn-warning-bg); -} +@keyframes spin { + 0% { + transform: rotate(0deg); + } -.job-id { - font-weight: 600; - display: block; - margin-bottom: 0.5rem; - color: var(--dashboard-header-color); + 100% { + transform: rotate(360deg); + } } -.file-results { - margin-left: 1rem; - margin-top: 0.5rem; +/* File Chip and Modal Styles */ +.selected-files-grid { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-top: 1rem; + margin-bottom: 1rem; } -.file-result { - padding: 0.75rem; - margin-bottom: 0.5rem; - border-radius: 4px; +.file-chip { + background-color: var(--input-bg); + border: 1px solid var(--input-border); + border-radius: 16px; + padding: 0.4rem 0.8rem; display: flex; align-items: center; - background-color: var(--input-bg); - border: var(--input-border); + font-size: 0.875rem; + box-shadow: var(--dashboard-shadow); + transition: background-color 0.2s, opacity 0.2s, box-shadow 0.2s; + color: var(--content-text); } -.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-chip:hover { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); } -.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); +.file-chip.removed { + opacity: 0.65; + background-color: var(--progress-bg); + text-decoration: line-through; + box-shadow: none; } -.status-icon { - font-size: 1.25rem; - margin-right: 0.75rem; +.file-chip.removed .file-chip-name { + color: var(--label-color); } -.success .status-icon { - color: var(--btn-success-bg); +.file-chip-icon { + margin-right: 0.5rem; + font-size: 1.1em; + color: var(--btn-primary-bg); } -.error .status-icon { - color: var(--btn-warning-bg); +.file-chip.removed .file-chip-icon { + color: var(--label-color); } -.file-name { - font-weight: 500; +.file-chip-name { margin-right: 0.5rem; - color: var(--content-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 150px; + cursor: pointer; } -.file-message { +.file-chip-doctype { + font-size: 0.8em; 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); + padding: 0.1em 0.4em; + border-radius: 4px; + margin-right: 0.5rem; + white-space: nowrap; } -.progress-info { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 0.75rem; +.file-chip-edit, +.file-chip-remove { + background: none; + border: none; + color: var(--label-color); + cursor: pointer; + font-size: 1em; + padding: 0 0.25rem; + margin-left: 0.25rem; + line-height: 1; + opacity: 0.8; } -.progress-info span { - font-weight: 500; +.file-chip-edit:hover, +.file-chip-remove:hover { color: var(--content-text); + opacity: 1; } -/* Original Spinner - used for general loading indicators */ -.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; +.file-chip.removed .file-chip-edit, +.file-chip.removed .file-chip-remove { + color: var(--label-color); + cursor: not-allowed; + opacity: 0.5; } -/* Larger spinner specifically for overlays */ -.overlay-spinner { - width: 50px; - height: 50px; - margin: 0 auto; - border: 3px solid rgba(255, 255, 255, 0.3); - border-radius: 50%; - border-top-color: white; - animation: spin 1s ease-in-out infinite; +#no-files-selected-placeholder { + color: var(--label-color); + font-style: italic; + width: 100%; + text-align: center; + padding: 1rem 0; } -@keyframes spin { - 0% { - transform: rotate(0deg); - } - - 100% { - transform: rotate(360deg); - } +/* Modal Styles */ +.modal { + display: none; + position: fixed; + z-index: 1001; + left: 0; + top: 0; + width: 100%; + height: 100%; + overflow: auto; + background-color: rgba(0, 0, 0, 0.4); + justify-content: center; + align-items: center; } -.progress { - height: 0.5rem; - background-color: var(--progress-bg, #e2e8f0); - border-radius: 999px; - overflow: hidden; +.modal-content { + background-color: var(--content-bg); + color: var(--content-text); + margin: auto; + padding: 20px; + border: 1px solid var(--input-border); + border-radius: 8px; + width: 90%; + max-width: 500px; + box-shadow: var(--dashboard-shadow); } -.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; +.modal-content .form-group label { + font-weight: bold; } -@keyframes progress-animation { - 0% { - background-position: 1rem 0; - } - - 100% { - background-position: 0 0; - } +.modal-content .card-input { + width: 100%; + box-sizing: border-box; } -#upload-status { - margin-top: 0.75rem; - font-size: 0.875rem; +.close-button { color: var(--label-color); + float: right; + font-size: 1.75rem; + font-weight: bold; + line-height: 1; + padding: 0; + background: transparent; + border: none; + cursor: pointer; + opacity: 0.7; } -.pulsing { - animation: pulse 2s infinite; +.close-button:hover { + color: var(--content-text); + opacity: 1; } -@keyframes pulse { - 0% { - opacity: 0.6; - } - - 50% { - opacity: 1; - } - - 100% { - opacity: 0.6; - } +.modal-content .form-actions { + margin-top: 1.5rem; + text-align: right; } -.advice { - font-weight: 500; - color: var(--btn-primary-bg); +.modal-content .btn-primary, +.modal-content .btn-secondary { + margin-left: 0.5rem; } -/* CSV Preview styles */ -#csv-preview, -#csv-preview-removal { +/* Upload Results Container */ +.upload-results-container { + background-color: var(--upload-card-bg); + border-radius: 0.5rem; + box-shadow: var(--upload-card-shadow); + padding: 1.5rem; margin-top: 1rem; - padding: 1rem; - background-color: var(--content-bg); - border-radius: 6px; - border-left: 4px solid var(--btn-primary-bg); + max-width: 100%; } -.csv-sample { - margin-top: 0.5rem; - padding: 0.75rem; - background-color: var(--input-bg); - border-radius: 4px; - box-shadow: var(--dashboard-shadow); +.upload-results-header { + margin-bottom: 1.5rem; } -#csv-preview p, -#csv-preview-removal p { - margin: 0.5rem 0; - color: var(--content-text); +.upload-results-header h3 { + color: var(--text-color); + font-size: 1.5rem; + margin: 0 0 1rem 0; } -/* HTMX indicator styles */ -.htmx-indicator { - display: none; - opacity: 0; - transition: opacity 200ms ease-in; +/* Upload Stats Grid */ +.upload-stats { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 1rem; + margin-bottom: 1.5rem; + max-width: 600px; } -.htmx-request .htmx-indicator { - opacity: 1; +.stat-item { + background-color: var(--upload-stat-bg); + border-radius: 0.5rem; + padding: 1.25rem; + text-align: center; display: flex; + flex-direction: column; align-items: center; justify-content: center; + min-width: 120px; } -.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; +.stat-item:last-child { + grid-column: 1; } -:root.dark-theme .spinner, -:root.dark-theme .loading-indicator { - border-color: rgba(255, 255, 255, 0.1); - border-top-color: var(--btn-primary-bg); +.stat-value { + font-size: 2.5rem; + font-weight: bold; + color: var(--text-color); + line-height: 1.2; + margin-bottom: 0.5rem; } -/* Tab buttons */ -.tab-buttons { - display: flex; - margin-bottom: 1rem; - border-bottom: 1px solid var(--dropdown-border); +.stat-label { + font-size: 0.875rem; + color: var(--label-color); + text-align: center; } -.tab-button { - padding: 0.5rem 1rem; - background: var(--card-bg); - border: 1px solid var(--dropdown-border); - border-bottom: none; - border-radius: 4px 4px 0 0; - color: var(--content-text); - cursor: pointer; - margin-right: 0.5rem; - position: relative; - bottom: -1px; +/* Stat item variants */ +.success-stat .stat-value { + color: var(--upload-success-color); } -.tab-button.active { - background: var(--content-bg); - border-bottom: 1px solid var(--content-bg); - font-weight: bold; +.error-stat .stat-value { + color: var(--upload-error-color); } -/* Checkbox styles */ -.checkbox-group { +.warning-stat .stat-value { + color: var(--upload-warning-color); +} + +/* Upload Results Grid */ +.upload-results-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 1rem; + margin-top: 1.5rem; +} + +.upload-result-card { + background-color: var(--upload-card-bg); + border-radius: 0.5rem; + padding: 1.25rem; display: flex; - flex-wrap: wrap; - gap: 0.5rem 1.5rem; - margin: 0.5rem 0 1rem 0; + flex-direction: column; + gap: 0.75rem; } -.checkbox-item { +.upload-header { display: flex; + justify-content: space-between; align-items: center; + gap: 1rem; } -.checkbox-item input[type="checkbox"] { - margin-right: 0.5rem; +.upload-header h4 { + margin: 0; + color: var(--text-color); + font-size: 1rem; + font-weight: 500; + flex: 1; } -/* Form groups */ -.form-group { - margin-bottom: 1rem; +.upload-status { + padding: 0.25rem 0.75rem; + border-radius: 1rem; + font-size: 0.875rem; + font-weight: 500; } -.form-group label { - display: block; - margin-bottom: 0.5rem; - color: var(--label-color); - font-weight: bold; +.upload-status.success { + background-color: var(--upload-stat-success-bg); + color: var(--upload-success-color); } -.form-actions { - margin-top: 1.5rem; +.upload-status.error { + background-color: var(--upload-stat-error-bg); + color: var(--upload-error-color); +} + +.upload-details { display: flex; - align-items: center; - gap: 1rem; + flex-direction: column; + gap: 0.5rem; } -/* Job summary in results */ -.job-summary { - margin: 0.5rem 0; - font-size: 0.9rem; - color: var(--soft-text); +.upload-info { + display: flex; + flex-direction: column; + gap: 0.25rem; } -/* Upload Overlay */ -.upload-container { - position: relative; - min-height: 200px; - width: 100%; +.upload-info p { + margin: 0; + font-size: 0.875rem; + color: var(--content-text); } -.upload-overlay, -.removal-overlay { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: linear-gradient(135deg, rgba(255, 255, 255, 0.95), rgba(240, 240, 240, 0.95)); - display: none; - justify-content: center; - align-items: center; - z-index: 1000; - border-radius: inherit; +.success-text { + color: var(--upload-success-color); + font-weight: 500; } -.upload-overlay.htmx-request, -.removal-overlay.htmx-request { +.error-text { + color: var(--upload-error-color); + font-weight: 500; +} + +/* Pagination Controls */ +.pagination-controls { display: flex; + justify-content: space-between; + align-items: center; + margin-top: 2rem; + padding-top: 1rem; + border-top: 1px solid var(--content-border); } -.upload-overlay-content { +.pagination-info { + font-size: 0.875rem; + color: var(--label-color); +} + +.pagination-buttons { display: flex; - flex-direction: column; + gap: 0.5rem; align-items: center; - justify-content: center; - text-align: center; - max-width: 80%; } -.upload-overlay h3, -.removal-overlay h3 { - margin-bottom: 0.5rem; - color: #333; +.pagination-btn { + padding: 0.5rem 1rem; + border: 1px solid var(--input-border); + background-color: var(--input-bg); + color: var(--input-text); + border-radius: 0.25rem; + cursor: pointer; + font-size: 0.875rem; + transition: all 0.2s ease; } -.upload-overlay p, -.removal-overlay p { - margin-top: 0.5rem; - color: #555; +.pagination-btn:hover { + background-color: var(--btn-primary-bg); + color: white; + border-color: var(--btn-primary-bg); +} + +.pagination-btn.active { + background-color: var(--btn-primary-bg); + color: white; + border-color: var(--btn-primary-bg); +} + +.pagination-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + background-color: var(--btn-disabled); + border-color: var(--btn-disabled); +} + +/* Upload Summary (for removal results) */ +.upload-summary { + margin: 1.5rem 0; + padding: 1.5rem; + border-radius: 0.5rem; + background-color: var(--upload-card-bg); + box-shadow: var(--upload-card-shadow); + color: var(--content-text); } -.upload-overlay .overlay-spinner, -.removal-overlay .overlay-spinner { - width: 50px; - height: 50px; - border: 5px solid #f3f3f3; - border-top: 5px solid #3498db; - border-radius: 50%; - animation: spin 1s linear infinite; +.upload-summary h3 { + margin-top: 0; + font-size: 1.25rem; + color: var(--dashboard-header-color); margin-bottom: 1rem; } -/* Dark theme support */ -@media (prefers-color-scheme: dark) { +/* Job Results (for removal results) */ +.job-results { + margin-top: 1.5rem; +} - .upload-overlay, - .removal-overlay { - background: linear-gradient(135deg, rgba(30, 30, 30, 0.95), rgba(20, 20, 20, 0.95)); - } +.job-result { + padding: 1rem; + margin-bottom: 0.5rem; + border-radius: 0.5rem; + border: var(--upload-card-border); + border-left-width: 4px; + background-color: var(--upload-card-bg); + box-shadow: var(--upload-card-shadow); + color: var(--content-text); +} - .upload-overlay h3, - .removal-overlay h3 { - color: #fff; - } +.job-result.success { + border-left-color: var(--upload-success-color); +} - .upload-overlay p, - .removal-overlay p { - color: #ccc; - } +.job-result.error { + border-left-color: var(--upload-error-color); } -@keyframes spin { - 0% { - transform: rotate(0deg); - } +.job-id { + font-weight: 600; + display: block; + margin-bottom: 0.5rem; + color: var(--dashboard-header-color); +} - 100% { - transform: rotate(360deg); - } +.job-summary { + font-size: 0.875rem; + color: var(--label-color); + margin-bottom: 0.5rem; } -/* Styles for Modal and File Chips */ +.error-message { + color: var(--upload-error-color); + font-size: 0.875rem; + margin-bottom: 0.5rem; +} -/* Basic Modal Styling */ -.modal { - display: none; - /* Hidden by default */ - position: fixed; - /* Stay in place */ - z-index: 1001; - /* Sit on top, above overlays which are 1000 */ - left: 0; - top: 0; - width: 100%; - /* Full width */ - height: 100%; - /* Full height */ - overflow: auto; - /* Enable scroll if needed */ - background-color: rgba(0, 0, 0, 0.4); - /* Semi-transparent black backdrop for light theme */ - justify-content: center; - align-items: center; +.file-results { + margin-left: 1rem; + margin-top: 0.5rem; } -.modal-content { - background-color: var(--content-bg); - color: var(--content-text); - margin: auto; - padding: 20px; - /* Standard padding */ - border: 1px solid var(--input-border); - border-radius: 8px; - /* Consistent border-radius */ - width: 90%; - /* Responsive width */ - max-width: 500px; - /* Max width for the modal */ - box-shadow: var(--dashboard-shadow); +.file-result { + padding: 0.75rem; + margin-bottom: 0.5rem; + border-radius: 0.25rem; + display: flex; + align-items: center; + background-color: var(--upload-stat-bg); + border: var(--upload-stat-border); } -/* Modal uses existing .form-group, ensure label is bold if that's the standard */ -.modal-content .form-group label { - /* font-weight: bold; Inherits from global .form-group label if already bold */ +.file-result.success { + background-color: var(--upload-stat-success-bg); + border-color: rgba(34, 197, 94, 0.3); } -.modal-content .card-input { - /* Ensure modal inputs are full width and box-sizing */ - width: 100%; - box-sizing: border-box; +.file-result.error { + background-color: var(--upload-stat-error-bg); + border-color: rgba(239, 68, 68, 0.3); } -.close-button { - color: var(--label-color); - float: right; - font-size: 1.75rem; - /* Clearer size */ - font-weight: bold; - line-height: 1; - padding: 0; - background: transparent; - border: none; - cursor: pointer; - opacity: 0.7; +.status-icon { + font-size: 1.25rem; + margin-right: 0.75rem; } -.close-button:hover { - color: var(--content-text); - opacity: 1; +.success .status-icon { + color: var(--upload-success-color); } -/* File Chip Styling */ -.selected-files-grid { - display: flex; - flex-wrap: wrap; - gap: 0.75rem; - /* padding: 0.5rem 0; No specific padding, gap handles spacing */ - margin-top: 1rem; - /* Retain consistent margins */ - margin-bottom: 1rem; +.error .status-icon { + color: var(--upload-error-color); } -.file-chip { - background-color: var(--input-bg); - border: 1px solid var(--input-border); - border-radius: 16px; - /* Pill shape for chips */ - padding: 0.4rem 0.8rem; - display: flex; - align-items: center; - font-size: 0.875rem; - /* Slightly smaller font for chips */ - box-shadow: var(--dashboard-shadow); - transition: background-color 0.2s, opacity 0.2s, box-shadow 0.2s; +.file-name { + font-weight: 500; + margin-right: 0.5rem; color: var(--content-text); } -.file-chip:hover { - box-shadow: var(--button-shadow); - /* Use button-shadow for hover if defined and suitable */ +.file-message { + color: var(--label-color); + font-size: 0.875rem; } -.file-chip.removed { - opacity: 0.65; - /* Make it visibly less prominent */ - background-color: var(--progress-bg); - /* Using a neutral theme color */ - text-decoration: line-through; - box-shadow: none; - /* No shadow for removed items */ +/* Form Elements */ +.form-group { + margin-bottom: 1.5rem; } -.file-chip.removed .file-chip-name { +.form-group label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; color: var(--label-color); - /* Dim the text of removed items */ } -.file-chip-icon { - margin-right: 0.5rem; - font-size: 1.1em; - /* Relative to chip's font size */ - color: var(--btn-primary-bg); - /* Use a theme color for the icon */ +.form-actions { + display: flex; + gap: 1rem; + justify-content: flex-end; + margin-top: 2rem; + padding-top: 1rem; + border-top: 1px solid var(--content-border); } -.file-chip.removed .file-chip-icon { - color: var(--label-color); - /* Dimmed icon for removed items */ +.checkbox-group { + display: flex; + flex-wrap: wrap; + gap: 1rem; } -.file-chip-name { - margin-right: 0.5rem; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - max-width: 150px; - /* Adjust as needed */ - cursor: pointer; - /* For the title attribute & clickability */ +.checkbox-item { + display: flex; + align-items: center; + gap: 0.5rem; } -.file-chip-doctype { - font-size: 0.8em; - /* Smaller than the filename */ - color: var(--label-color); - background-color: var(--content-bg); - /* Slightly different background */ - padding: 0.1em 0.4em; - border-radius: 4px; - margin-right: 0.5rem; - white-space: nowrap; +.checkbox-item input[type="checkbox"] { + margin: 0; } -.file-chip-edit, -.file-chip-remove { - background: none; +/* Warning Button */ +.warning-button { + background-color: var(--btn-warning-bg); + color: white; + padding: 0.75rem 1.5rem; border: none; - color: var(--label-color); - /* Subtler color for actions */ + border-radius: 0.25rem; cursor: pointer; - font-size: 1em; - /* Relative to chip's font size */ - padding: 0 0.25rem; - margin-left: 0.25rem; - /* Slight spacing */ - line-height: 1; - opacity: 0.8; + font-size: 1rem; + font-weight: 500; + transition: background-color 0.2s ease; } -.file-chip-edit:hover, -.file-chip-remove:hover { - color: var(--content-text); - /* Darker on hover for clarity */ - opacity: 1; +.warning-button:hover { + background-color: var(--btn-warning-hover); } -.file-chip.removed .file-chip-edit, -.file-chip.removed .file-chip-remove { - /* Actions on removed chips */ - color: var(--label-color); +.warning-button:disabled { + background-color: var(--btn-disabled); cursor: not-allowed; - opacity: 0.5; - /* Make them more faded */ } -#no-files-selected-placeholder { - color: var(--label-color); - font-style: italic; - width: 100%; - /* Take full width */ - text-align: center; - /* Center placeholder text */ - padding: 1rem 0; - /* Give it some space */ +/* CSV Preview */ +#csv-preview, +#csv-preview-removal { + background-color: var(--upload-stat-success-bg); + border: 1px solid rgba(34, 197, 94, 0.3); + border-radius: 0.5rem; + padding: 1rem; + margin: 1rem 0; + display: none; } -/* Modal Buttons Styling */ -.modal-content .form-actions { - /* If you have a .form-actions div in modal */ - margin-top: 1.5rem; - /* Space above action buttons */ - text-align: right; - /* Align buttons to the right */ +#csv-preview h4, +#csv-preview-removal h4 { + color: var(--upload-success-color); + margin: 0 0 0.5rem 0; + font-size: 1.125rem; } -.modal-content .btn-primary, -.modal-content .btn-secondary { - margin-left: 0.5rem; - /* Space between buttons if aligned right */ +#csv-preview p, +#csv-preview-removal p { + margin: 0; + color: var(--content-text); + font-size: 0.875rem; } -/* If not using .form-actions, direct styling: */ -/* .modal-content > button { margin-top: 1rem; margin-right: 0.5rem; } */ - +.csv-preview-active { + display: block; +} -/* Dark theme adjustments for modal backdrop & specific modal elements */ -/* Using @media (prefers-color-scheme: dark) as per existing structure at end of file */ +/* Dark theme adjustments for modal */ @media (prefers-color-scheme: dark) { .modal { background-color: rgba(20, 20, 20, 0.75); - /* Darker, more opaque backdrop for dark theme */ } .close-button { - /* Ensure close button is visible in dark mode */ color: var(--label-color); - /* Should pick up dark theme var */ } .close-button:hover { color: var(--content-text); - /* Should pick up dark theme var */ } - /* Specific modal button styling for dark theme if needed, e.g., for btn-secondary */ .modal-content .btn-secondary { - /* Assuming global .btn-secondary might not be fully dark-theme aware */ - /* background-color: var(--input-bg); Picks up dark theme variable */ - /* color: var(--content-text); Picks up dark theme variable */ - /* border: 1px solid var(--input-border); Picks up dark theme variable */ + background-color: var(--input-bg); + color: var(--content-text); + border: 1px solid var(--input-border); + } +} + +/* Responsive Design */ +@media (max-width: 768px) { + .upload-results-header { + flex-direction: column; + gap: 1rem; + } + + .upload-stats { + grid-template-columns: 1fr; + max-width: none; + } + + .upload-results-grid { + grid-template-columns: 1fr; + } + + .upload-details { + flex-direction: column; + } + + .upload-actions { + flex-direction: column; + gap: 0.5rem; } - /* .modal-content .btn-secondary:hover { */ - /* background-color: var(--content-bg); Slightly different hover for dark */ - /* } */ + .pagination-controls { + flex-direction: column; + gap: 1rem; + align-items: stretch; + } + + .pagination-buttons { + justify-content: center; + flex-wrap: wrap; + } + + .checkbox-group { + flex-direction: column; + gap: 0.5rem; + } + + .form-actions { + flex-direction: column; + } + + .selected-files-grid { + gap: 0.5rem; + } + + .file-chip { + padding: 0.3rem 0.6rem; + font-size: 0.8rem; + } + + .file-chip-name { + max-width: 100px; + } +} + +@media (max-width: 480px) { + .upload-overlay-content { + padding: 1.5rem; + margin: 1rem; + } + + .upload-results-container { + padding: 1rem; + } + + .stat-item { + padding: 1rem; + } + + .stat-value { + font-size: 2rem; + } + + .upload-result-card { + padding: 1rem; + } + + .upload-header { + flex-direction: column; + align-items: flex-start; + gap: 0.5rem; + } + + .modal-content { + padding: 1rem; + margin: 1rem; + } + + .file-chip { + padding: 0.25rem 0.5rem; + font-size: 0.75rem; + } + + .file-chip-name { + max-width: 80px; + } } \ No newline at end of file diff --git a/templates/partials/removal_result_card.html b/templates/partials/removal_result_card.html new file mode 100644 index 0000000..1674ef6 --- /dev/null +++ b/templates/partials/removal_result_card.html @@ -0,0 +1,52 @@ +{{define "removal_result_card"}} +
+
+

Job #{{.JobID}}

+
+ {{if .Success}}✓ Success{{else}}✗ Failed{{end}} +
+
+ +
+
+

Files Found: {{.FilesFound}}

+

Files Removed: {{.FilesRemoved}}

+ {{if .Success}} +

Successfully processed

+ {{else}} +

{{.ErrorMsg}}

+ {{end}} +
+ +
+ {{if .Success}} +
+ + Removal Complete +
+ {{else}} +
+ + Removal Failed +
+ {{end}} +
+
+ + {{if .Files}} +
+ {{range .Files}} +
+ {{.Name}} + {{if .Success}} + + {{else}} + + {{.Error}} + {{end}} +
+ {{end}} +
+ {{end}} +
+{{end}} \ No newline at end of file diff --git a/templates/partials/removal_results.html b/templates/partials/removal_results.html index e43ab1f..c5de7dd 100644 --- a/templates/partials/removal_results.html +++ b/templates/partials/removal_results.html @@ -1,51 +1,54 @@ {{define "removal_results"}} -
-

Document Removal Results

+
+
+

Document Removal Results

+ {{template "removal_stats" .}} +
- {{if .Error}} -
Error: {{.Error}}
- {{else}} -
-

Successfully removed {{.SuccessCount}} document(s).

- {{if gt .ErrorCount 0}} -

Failed to remove {{.ErrorCount}} document(s).

- {{end}} - {{if gt .JobsProcessed 0}} -

Processed {{.JobsProcessed}} job(s).

+ {{if gt .SuccessCount 0}} +

Successfully removed {{.SuccessCount}} document(s) from ServiceTrade in {{.TotalTime}}!

+ {{end}} + {{if gt .ErrorCount 0}} +

Failed to remove {{.ErrorCount}} document(s). See details below.

+ {{end}} + +
+ {{range .Results}} + {{template "removal_result_card" .}} {{end}}
- {{if .Results}} -
- {{range $job := .Results}} -
-

Job #{{$job.JobID}}

+ {{if gt .TotalPages 1}} +
+
+ Showing {{.StartIndex}}-{{.EndIndex}} of {{.TotalResults}} results +
+ +
+ {{if gt .CurrentPage 1}} + + {{end}} - {{if $job.Success}} -
Successfully processed
- {{else}} -
Error: {{$job.Error}}
+ {{range $i := sequence .StartPage .EndPage}} + {{end}} - {{if $job.Files}} -
- {{range $file := $job.Files}} -
- {{$file.Name}} - {{if $file.Success}} - - {{else}} - - {{$file.Error}} - {{end}} -
- {{end}} -
+ {{if lt .CurrentPage .TotalPages}} + {{end}}
- {{end}}
{{end}} - {{end}}
{{end}} \ No newline at end of file diff --git a/templates/partials/removal_stats.html b/templates/partials/removal_stats.html new file mode 100644 index 0000000..ff88020 --- /dev/null +++ b/templates/partials/removal_stats.html @@ -0,0 +1,24 @@ +{{define "removal_stats"}} +
+
+ {{.TotalJobs}} + Total Jobs +
+
+ {{.SuccessCount}} + Successful +
+
+ {{.ErrorCount}} + Failed +
+
+ {{.TotalFiles}} + Files Processed +
+
+ {{.TotalTime}} + Total Time +
+
+{{end}} \ No newline at end of file diff --git a/templates/partials/upload_result_card.html b/templates/partials/upload_result_card.html new file mode 100644 index 0000000..225d1b2 --- /dev/null +++ b/templates/partials/upload_result_card.html @@ -0,0 +1,113 @@ +{{define "upload_result_card"}} +
+
+

{{.DocName}}

+
+ {{if .Success}}✓ Success{{else}}✗ Failed{{end}} +
+
+ +
+
+

Job ID: {{.JobID}}

+

File Size: {{printf "%.2f MB" (div .FileSize 1048576.0)}}

+ {{if .Success}} +

Successfully uploaded to ServiceTrade

+ {{else}} +

{{.Error}}

+ {{end}} +
+ +
+ {{if .Success}} +
+ + Upload Complete +
+ {{else}} +
+ + Upload Failed +
+ {{end}} +
+
+
+{{end}} + +{{define "upload_results_pagination"}} +
+
+

Upload Results

+
+
+ {{.TotalJobs}} + Total Jobs +
+
+ {{.TotalSuccess}} + Successful +
+
+ {{.TotalFailure}} + Failed +
+
+ {{printf "%.1f" (div .TotalBytesUploaded 1048576.0)}} + MB Uploaded +
+
+ {{.TotalTime}} + Total Time +
+
+
+ + {{if gt .TotalSuccess 0}} +

Successfully uploaded {{.TotalSuccess}} document(s) to ServiceTrade in {{.TotalTime}}!

+ {{end}} + {{if gt .TotalFailure 0}} +

Failed to upload {{.TotalFailure}} document(s). See details below.

+ {{end}} + +
+ {{range .Results}} + {{template "upload_result_card" .}} + {{end}} +
+ + {{if gt .TotalPages 1}} +
+
+ Showing {{.StartIndex}}-{{.EndIndex}} of {{.TotalResults}} results +
+ +
+ {{if gt .CurrentPage 1}} + + {{end}} + + {{range $i := sequence .StartPage .EndPage}} + + {{end}} + + {{if lt .CurrentPage .TotalPages}} + + {{end}} +
+
+ {{end}} +
+{{end}} \ No newline at end of file diff --git a/templates/partials/upload_results_pagination.html b/templates/partials/upload_results_pagination.html new file mode 100644 index 0000000..e8bd3ca --- /dev/null +++ b/templates/partials/upload_results_pagination.html @@ -0,0 +1,54 @@ +{{define "upload_results_pagination"}} +
+
+

Upload Results

+ {{template "upload_stats" .}} +
+ + {{if gt .TotalSuccess 0}} +

Successfully uploaded {{.TotalSuccess}} document(s) to ServiceTrade in {{.TotalTime}}!

+ {{end}} + {{if gt .TotalFailure 0}} +

Failed to upload {{.TotalFailure}} document(s). See details below.

+ {{end}} + +
+ {{range .Results}} + {{template "upload_result_card" .}} + {{end}} +
+ + {{if gt .TotalPages 1}} +
+
+ Showing {{.StartIndex}}-{{.EndIndex}} of {{.TotalResults}} results +
+ +
+ {{if gt .CurrentPage 1}} + + {{end}} + + {{range $i := sequence .StartPage .EndPage}} + + {{end}} + + {{if lt .CurrentPage .TotalPages}} + + {{end}} +
+
+ {{end}} +
+{{end}} \ No newline at end of file diff --git a/templates/partials/upload_stats.html b/templates/partials/upload_stats.html new file mode 100644 index 0000000..ac8c972 --- /dev/null +++ b/templates/partials/upload_stats.html @@ -0,0 +1,24 @@ +{{define "upload_stats"}} +
+
+ {{.TotalJobs}} + Total Jobs +
+
+ {{.TotalSuccess}} + Successful +
+
+ {{.TotalFailure}} + Failed +
+
+ {{printf "%.1f" (div .TotalBytesUploaded 1048576.0)}} + MB Uploaded +
+
+ {{.TotalTime}} + Total Time +
+
+{{end}} \ No newline at end of file diff --git a/web_templates.go b/web_templates.go index 40688ee..7ddf9b4 100644 --- a/web_templates.go +++ b/web_templates.go @@ -19,6 +19,25 @@ var funcMap = template.FuncMap{ // This allows us to reference specific content blocks return "{{template \"" + name + "-content\" .}}" }, + "add": func(a, b int) int { + return a + b + }, + "subtract": func(a, b int) int { + return a - b + }, + "div": func(a int64, b float64) float64 { + return float64(a) / b + }, + "sequence": func(start, end int) []int { + if start > end { + return []int{} + } + result := make([]int, end-start+1) + for i := range result { + result[i] = start + i + } + return result + }, } // InitializeWebTemplates parses all HTML templates in the embedded filesystem