Browse Source

feat: added pagination to results

document-upload-removal-layout-update
nic 9 months ago
parent
commit
0a169b8b8b
  1. 2
      apps/web/main.go
  2. BIN
      apps/web/web-app
  3. 221
      internal/handlers/web/document_remove.go
  4. 241
      internal/handlers/web/documents.go
  5. 226
      static/css/styles.css
  6. 1092
      static/css/upload.css
  7. 52
      templates/partials/removal_result_card.html
  8. 77
      templates/partials/removal_results.html
  9. 24
      templates/partials/removal_stats.html
  10. 113
      templates/partials/upload_result_card.html
  11. 54
      templates/partials/upload_results_pagination.html
  12. 24
      templates/partials/upload_stats.html
  13. 19
      web_templates.go

2
apps/web/main.go

@ -64,6 +64,7 @@ func main() {
protected.HandleFunc("/documents", web.DocumentsHandler).Methods("GET") protected.HandleFunc("/documents", web.DocumentsHandler).Methods("GET")
protected.HandleFunc("/process-csv", web.ProcessCSVHandler).Methods("POST") protected.HandleFunc("/process-csv", web.ProcessCSVHandler).Methods("POST")
protected.HandleFunc("/upload-documents", web.UploadDocumentsHandler).Methods("POST") protected.HandleFunc("/upload-documents", web.UploadDocumentsHandler).Methods("POST")
protected.HandleFunc("/documents/upload/results", web.UploadResultsHandler).Methods("GET")
// Document removal routes // Document removal routes
protected.HandleFunc("/documents/remove", web.DocumentRemoveHandler).Methods("GET") 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/job/{jobID}", web.GetJobAttachmentsHandler).Methods("GET")
protected.HandleFunc("/documents/remove/attachments/{jobID}", web.RemoveJobAttachmentsHandler).Methods("POST") protected.HandleFunc("/documents/remove/attachments/{jobID}", web.RemoveJobAttachmentsHandler).Methods("POST")
protected.HandleFunc("/documents/remove/bulk", web.BulkRemoveDocumentsHandler).Methods("POST") protected.HandleFunc("/documents/remove/bulk", web.BulkRemoveDocumentsHandler).Methods("POST")
protected.HandleFunc("/documents/remove/results", web.RemovalResultsHandler).Methods("GET")
port := os.Getenv("PORT") port := os.Getenv("PORT")
if port == "" { if port == "" {

BIN
apps/web/web-app

Binary file not shown.

221
internal/handlers/web/document_remove.go

@ -7,7 +7,6 @@ import (
"log" "log"
"net/http" "net/http"
"regexp" "regexp"
"sort"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@ -20,6 +19,34 @@ import (
"github.com/gorilla/mux" "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 // DocumentRemoveHandler handles the document removal page
func DocumentRemoveHandler(w http.ResponseWriter, r *http.Request) { func DocumentRemoveHandler(w http.ResponseWriter, r *http.Request) {
session, ok := r.Context().Value(middleware.SessionKey).(*api.Session) 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) log.Printf("Document removal completed in %v", totalDuration)
// Generate HTML for results // Convert results to RemovalResult format for session storage
var resultHTML bytes.Buffer 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 // Store results in session for pagination
resultHTML.WriteString("<div class=\"upload-summary\">") sessionID := fmt.Sprintf("removal_%d", time.Now().UnixNano())
resultHTML.WriteString("<h3>Document Removal Results</h3>") removalSession := RemovalSession{
resultHTML.WriteString("<div class=\"upload-stats\">") Results: removalResults,
TotalJobs: results.JobsProcessed,
// Total jobs stat SuccessCount: results.SuccessCount,
resultHTML.WriteString("<div class=\"stat-box\">") ErrorCount: results.ErrorCount,
resultHTML.WriteString(fmt.Sprintf("<div class=\"stat-value\">%d</div>", results.JobsProcessed)) TotalFiles: results.TotalFiles,
resultHTML.WriteString("<div class=\"stat-label\">Total Jobs</div>") TotalTime: totalDuration,
resultHTML.WriteString("</div>") CreatedAt: time.Now(),
// Success stat
resultHTML.WriteString("<div class=\"stat-box success-stat\">")
resultHTML.WriteString(fmt.Sprintf("<div class=\"stat-value\">%d</div>", results.SuccessCount))
resultHTML.WriteString("<div class=\"stat-label\">Successful Removals</div>")
resultHTML.WriteString("</div>")
// Failure stat
resultHTML.WriteString("<div class=\"stat-box error-stat\">")
resultHTML.WriteString(fmt.Sprintf("<div class=\"stat-value\">%d</div>", results.ErrorCount))
resultHTML.WriteString("<div class=\"stat-label\">Failed Removals</div>")
resultHTML.WriteString("</div>")
// File count stat
resultHTML.WriteString("<div class=\"stat-box\">")
resultHTML.WriteString(fmt.Sprintf("<div class=\"stat-value\">%d</div>", results.TotalFiles))
resultHTML.WriteString("<div class=\"stat-label\">Files Processed</div>")
resultHTML.WriteString("</div>")
// Duration stat
resultHTML.WriteString("<div class=\"stat-box\">")
resultHTML.WriteString(fmt.Sprintf("<div class=\"stat-value\">%v</div>", totalDuration))
resultHTML.WriteString("<div class=\"stat-label\">Total Time</div>")
resultHTML.WriteString("</div>")
resultHTML.WriteString("</div>") // End of upload-stats
// Add completion message with timing
if results.ErrorCount == 0 {
resultHTML.WriteString(fmt.Sprintf("<p>All documents were successfully removed from ServiceTrade in %v!</p>", totalDuration))
} else {
resultHTML.WriteString(fmt.Sprintf("<p>Some documents failed to be removed. Process completed in %v. See details below.</p>", totalDuration))
} }
resultHTML.WriteString("</div>") // End of upload-summary // Store in global map (in production, use Redis or database)
removalSessions[sessionID] = removalSession
// Add detailed job results // Return first page of results
resultHTML.WriteString("<div class=\"job-results\">") renderRemovalResultsPage(w, sessionID, 1, 20) // Default to page 1, 20 items per page
}
// Sort job IDs for consistent display // RemovalResultsHandler handles pagination for removal results
sort.Slice(results.JobResults, func(i, j int) bool { func RemovalResultsHandler(w http.ResponseWriter, r *http.Request) {
return results.JobResults[i].JobID < results.JobResults[j].JobID sessionID := r.URL.Query().Get("session_id")
}) if sessionID == "" {
http.Error(w, "Session ID required", http.StatusBadRequest)
return
}
for _, jobResult := range results.JobResults { page, _ := strconv.Atoi(r.URL.Query().Get("page"))
// Job result row if page < 1 {
jobClass := "success" page = 1
if !jobResult.Success { }
jobClass = "error"
}
resultHTML.WriteString(fmt.Sprintf("<div class=\"job-result %s\">", jobClass)) limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
resultHTML.WriteString(fmt.Sprintf("<span class=\"job-id\">Job ID: %s</span>", jobResult.JobID)) if limit < 1 {
limit = 20
}
if jobResult.ErrorMsg != "" { renderRemovalResultsPage(w, sessionID, page, limit)
resultHTML.WriteString(fmt.Sprintf("<div class=\"error-message\">%s</div>", jobResult.ErrorMsg)) }
} else {
resultHTML.WriteString(fmt.Sprintf("<div class=\"job-summary\">Found %d document(s), removed %d</div>",
jobResult.FilesFound, jobResult.FilesRemoved))
}
// File results // renderRemovalResultsPage renders a paginated page of removal results
if len(jobResult.Files) > 0 { func renderRemovalResultsPage(w http.ResponseWriter, sessionID string, page, limit int) {
resultHTML.WriteString("<div class=\"file-results\">") removalSession, exists := removalSessions[sessionID]
if !exists {
http.Error(w, "Removal session not found", http.StatusNotFound)
return
}
for _, file := range jobResult.Files { totalResults := len(removalSession.Results)
fileClass := "success" totalPages := (totalResults + limit - 1) / limit // Ceiling division
icon := "✓"
message := "Successfully removed"
if !file.Success { if page > totalPages && totalPages > 0 {
fileClass = "error" page = totalPages
icon = "✗" }
message = file.Error
}
resultHTML.WriteString(fmt.Sprintf("<div class=\"file-result %s\">", fileClass)) startIndex := (page - 1) * limit
resultHTML.WriteString(fmt.Sprintf("<span class=\"status-icon\">%s</span>", icon)) endIndex := startIndex + limit
resultHTML.WriteString(fmt.Sprintf("<span class=\"file-name\">%s:</span>", file.Name)) if endIndex > totalResults {
resultHTML.WriteString(fmt.Sprintf("<span class=\"file-message\">%s</span>", message)) endIndex = totalResults
resultHTML.WriteString("</div>") }
}
resultHTML.WriteString("</div>") // 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 { } else {
resultHTML.WriteString("<p>No files processed for this job.</p>") startPage = page - 4
endPage = page + 5
} }
resultHTML.WriteString("</div>") // End of job-result
} }
resultHTML.WriteString("</div>") // 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") tmpl := root.WebTemplates
w.Write(resultHTML.Bytes()) 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 // Helper function to check if a string matches any pattern in a slice

241
internal/handlers/web/documents.go

@ -9,7 +9,7 @@ import (
"log" "log"
"math" "math"
"net/http" "net/http"
"sort" "strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
@ -19,6 +19,30 @@ import (
"marmic/servicetrade-toolbox/internal/middleware" "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 // DocumentsHandler handles the document upload page
func DocumentsHandler(w http.ResponseWriter, r *http.Request) { func DocumentsHandler(w http.ResponseWriter, r *http.Request) {
session, ok := r.Context().Value(middleware.SessionKey).(*api.Session) 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 maxConcurrent = 5
const requestDelay = 300 * time.Millisecond 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 totalUploads := len(jobs) * activeFilesProcessedCount
resultsChan := make(chan UploadResult, totalUploads) resultsChan := make(chan UploadResult, totalUploads)
var wg sync.WaitGroup var wg sync.WaitGroup
@ -412,115 +427,139 @@ func UploadDocumentsHandler(w http.ResponseWriter, r *http.Request) {
log.Println("All upload goroutines finished.") log.Println("All upload goroutines finished.")
}() }()
results := make(map[string][]UploadResult) // Collect all results
resultsCount := 0 var allResults []UploadResult
var totalBytesUploaded int64 var totalBytesUploaded int64
for result := range resultsChan { for result := range resultsChan {
resultsCount++ log.Printf("Received result: Job %s, File %s, Success: %v, Size: %.2f MB",
log.Printf("Received result %d/%d: Job %s, File %s, Success: %v, Size: %.2f MB", result.JobID, result.DocName, result.Success, float64(result.FileSize)/(1024*1024))
resultsCount, totalUploads, result.JobID, result.DocName, result.Success,
float64(result.FileSize)/(1024*1024))
if result.Success { if result.Success {
totalBytesUploaded += result.FileSize totalBytesUploaded += result.FileSize
} }
if _, exists := results[result.JobID]; !exists { allResults = append(allResults, result)
results[result.JobID] = []UploadResult{}
}
results[result.JobID] = append(results[result.JobID], result)
} }
// Calculate total duration // Calculate total duration
totalDuration := time.Since(startTime) totalDuration := time.Since(startTime)
log.Printf("All results collected. Total: %d, Total bytes uploaded: %.2f MB, Total time: %v", log.Printf("All results collected. Total: %d, Total bytes uploaded: %.2f MB, Total time: %v",
resultsCount, float64(totalBytesUploaded)/(1024*1024), totalDuration) len(allResults), float64(totalBytesUploaded)/(1024*1024), totalDuration)
var resultHTML bytes.Buffer // Store results in session for pagination
var totalSuccess, totalFailure int sessionID := fmt.Sprintf("upload_%d", time.Now().UnixNano())
for _, jobResults := range results { uploadSession := UploadSession{
for _, result := range jobResults { Results: allResults,
if result.Success { TotalJobs: len(jobs),
totalSuccess++ TotalSuccess: 0,
} else { TotalFailure: 0,
totalFailure++ TotalBytesUploaded: totalBytesUploaded,
} TotalTime: totalDuration,
CreatedAt: time.Now(),
}
// Calculate totals
for _, result := range allResults {
if result.Success {
uploadSession.TotalSuccess++
} else {
uploadSession.TotalFailure++
} }
} }
resultHTML.WriteString("<div class=\"upload-summary\">") // Store in global map (in production, use Redis or database)
resultHTML.WriteString("<h3>Upload Results</h3>") uploadSessions[sessionID] = uploadSession
resultHTML.WriteString("<div class=\"upload-stats\">")
resultHTML.WriteString(fmt.Sprintf("<div class=\"stat-box\"><div class=\"stat-value\">%d</div><div class=\"stat-label\">Total Jobs</div></div>", len(results)))
resultHTML.WriteString(fmt.Sprintf("<div class=\"stat-box success-stat\"><div class=\"stat-value\">%d</div><div class=\"stat-label\">Successful Uploads</div></div>", totalSuccess))
resultHTML.WriteString(fmt.Sprintf("<div class=\"stat-box error-stat\"><div class=\"stat-value\">%d</div><div class=\"stat-label\">Failed Uploads</div></div>", totalFailure))
resultHTML.WriteString(fmt.Sprintf("<div class=\"stat-box\"><div class=\"stat-value\">%d</div><div class=\"stat-label\">Files Processed</div></div>", resultsCount))
resultHTML.WriteString(fmt.Sprintf("<div class=\"stat-box\"><div class=\"stat-value\">%v</div><div class=\"stat-label\">Total Time</div></div>", totalDuration))
if totalFailure == 0 && resultsCount > 0 { // Return first page of results
resultHTML.WriteString(fmt.Sprintf("<p>All documents were successfully uploaded to ServiceTrade in %v!</p>", totalDuration)) renderUploadResultsPage(w, sessionID, 1, 20) // Default to page 1, 20 items per page
} else if resultsCount == 0 { }
resultHTML.WriteString("<p>No documents were processed for upload.</p>")
} else { // UploadResultsHandler handles pagination for upload results
resultHTML.WriteString(fmt.Sprintf("<p>Some documents failed to upload. Process completed in %v. See details below.</p>", totalDuration)) func UploadResultsHandler(w http.ResponseWriter, r *http.Request) {
} sessionID := r.URL.Query().Get("session_id")
resultHTML.WriteString("</div>") if sessionID == "" {
http.Error(w, "Session ID required", http.StatusBadRequest)
resultHTML.WriteString("<div class=\"job-results\">") return
sortedJobs := make([]string, 0, len(results)) }
for jobID := range results {
sortedJobs = append(sortedJobs, jobID) page, _ := strconv.Atoi(r.URL.Query().Get("page"))
} if page < 1 {
sort.Strings(sortedJobs) page = 1
}
for _, jobID := range sortedJobs {
jobResults := results[jobID] limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
jobHasSuccess := false if limit < 1 {
jobHasFailure := false limit = 20
for _, result := range jobResults { }
if result.Success {
jobHasSuccess = true renderUploadResultsPage(w, sessionID, page, limit)
} else { }
jobHasFailure = true
} // renderUploadResultsPage renders a paginated page of upload results
} func renderUploadResultsPage(w http.ResponseWriter, sessionID string, page, limit int) {
jobClass := "neutral" uploadSession, exists := uploadSessions[sessionID]
if jobHasSuccess && !jobHasFailure { if !exists {
jobClass = "success" http.Error(w, "Upload session not found", http.StatusNotFound)
} else if jobHasFailure { return
jobClass = "error" }
}
resultHTML.WriteString(fmt.Sprintf("<div class=\"job-result %s\">", jobClass)) totalResults := len(uploadSession.Results)
resultHTML.WriteString(fmt.Sprintf("<div class=\"job-id\">Job ID: %s</div>", jobID)) totalPages := (totalResults + limit - 1) / limit // Ceiling division
if len(jobResults) > 0 {
resultHTML.WriteString("<div class=\"file-results\">") if page > totalPages && totalPages > 0 {
sort.Slice(jobResults, func(i, j int) bool { page = totalPages
return jobResults[i].DocName < jobResults[j].DocName }
})
for _, result := range jobResults { startIndex := (page - 1) * limit
fileClass := "success" endIndex := startIndex + limit
icon := "✓" if endIndex > totalResults {
message := "Successfully uploaded" endIndex = totalResults
if !result.Success { }
fileClass = "error"
icon = "✗" // Get results for this page
message = strings.ReplaceAll(result.Error, "<", "&lt;") var pageResults []UploadResult
message = strings.ReplaceAll(message, ">", "&gt;") if startIndex < totalResults {
} pageResults = uploadSession.Results[startIndex:endIndex]
resultHTML.WriteString(fmt.Sprintf("<div class=\"file-result %s\">", fileClass)) }
resultHTML.WriteString(fmt.Sprintf("<span class=\"status-icon\">%s</span>", icon))
resultHTML.WriteString(fmt.Sprintf("<span class=\"file-name\">%s:</span>", result.DocName)) // Calculate pagination info
resultHTML.WriteString(fmt.Sprintf("<span class=\"file-message\">%s</span>", message)) startPage := 1
resultHTML.WriteString("</div>") endPage := totalPages
} if totalPages > 10 {
resultHTML.WriteString("</div>") if page <= 5 {
endPage = 10
} else if page >= totalPages-4 {
startPage = totalPages - 9
} else { } else {
resultHTML.WriteString("<p>No file upload results for this job.</p>") startPage = page - 4
endPage = page + 5
} }
resultHTML.WriteString("</div>")
} }
resultHTML.WriteString("</div>")
w.Header().Set("Content-Type", "text/html") data := map[string]interface{}{
w.Write(resultHTML.Bytes()) "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 // readCloserWithSize is a custom io.Reader that counts the bytes read

226
static/css/styles.css

@ -1731,5 +1731,229 @@ html {
} }
.btn-secondary:hover { .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;
}
} }

1092
static/css/upload.css

File diff suppressed because it is too large

52
templates/partials/removal_result_card.html

@ -0,0 +1,52 @@
{{define "removal_result_card"}}
<div id="removal-card-{{.JobID}}" class="upload-result-card" data-job-id="{{.JobID}}">
<div class="upload-header">
<h4>Job #{{.JobID}}</h4>
<div class="upload-status {{if .Success}}success{{else}}error{{end}}">
{{if .Success}}✓ Success{{else}}✗ Failed{{end}}
</div>
</div>
<div class="upload-details">
<div class="upload-info">
<p><strong>Files Found:</strong> {{.FilesFound}}</p>
<p><strong>Files Removed:</strong> {{.FilesRemoved}}</p>
{{if .Success}}
<p class="success-text">Successfully processed</p>
{{else}}
<p class="error-text">{{.ErrorMsg}}</p>
{{end}}
</div>
<div class="upload-actions">
{{if .Success}}
<div class="success-indicator">
<span class="icon"></span>
<span>Removal Complete</span>
</div>
{{else}}
<div class="error-indicator">
<span class="icon"></span>
<span>Removal Failed</span>
</div>
{{end}}
</div>
</div>
{{if .Files}}
<div class="file-results">
{{range .Files}}
<div class="file-result {{if .Success}}success{{else}}error{{end}}">
<span class="file-name">{{.Name}}</span>
{{if .Success}}
<span class="success-icon"></span>
{{else}}
<span class="error-icon"></span>
<span class="error-message">{{.Error}}</span>
{{end}}
</div>
{{end}}
</div>
{{end}}
</div>
{{end}}

77
templates/partials/removal_results.html

@ -1,51 +1,54 @@
{{define "removal_results"}} {{define "removal_results"}}
<div class="upload-summary"> <div class="upload-results-container">
<h3>Document Removal Results</h3> <div class="upload-results-header">
<h3>Document Removal Results</h3>
{{template "removal_stats" .}}
</div>
{{if .Error}} {{if gt .SuccessCount 0}}
<div class="error-message">Error: {{.Error}}</div> <p>Successfully removed {{.SuccessCount}} document(s) from ServiceTrade in {{.TotalTime}}!</p>
{{else}} {{end}}
<div class="results-summary"> {{if gt .ErrorCount 0}}
<p>Successfully removed {{.SuccessCount}} document(s).</p> <p class="text-warning">Failed to remove {{.ErrorCount}} document(s). See details below.</p>
{{if gt .ErrorCount 0}} {{end}}
<p class="text-warning">Failed to remove {{.ErrorCount}} document(s).</p>
{{end}} <div class="upload-results-grid">
{{if gt .JobsProcessed 0}} {{range .Results}}
<p>Processed {{.JobsProcessed}} job(s).</p> {{template "removal_result_card" .}}
{{end}} {{end}}
</div> </div>
{{if .Results}} {{if gt .TotalPages 1}}
<div class="job-results"> <div class="pagination-controls">
{{range $job := .Results}} <div class="pagination-info">
<div class="job-result"> Showing {{.StartIndex}}-{{.EndIndex}} of {{.TotalResults}} results
<h4>Job #{{$job.JobID}}</h4> </div>
<div class="pagination-buttons">
{{if gt .CurrentPage 1}}
<button
hx-get="/documents/remove/results?page={{subtract .CurrentPage 1}}&limit={{.Limit}}&session_id={{.SessionID}}"
hx-target="#removal-results" class="pagination-btn">
← Previous
</button>
{{end}}
{{if $job.Success}} {{range $i := sequence .StartPage .EndPage}}
<div class="success-message">Successfully processed</div> <button hx-get="/documents/remove/results?page={{$i}}&limit={{$.Limit}}&session_id={{$.SessionID}}"
{{else}} hx-target="#removal-results" class="pagination-btn {{if eq $i $.CurrentPage}}active{{end}}">
<div class="error-message">Error: {{$job.Error}}</div> {{$i}}
</button>
{{end}} {{end}}
{{if $job.Files}} {{if lt .CurrentPage .TotalPages}}
<div class="file-results"> <button
{{range $file := $job.Files}} hx-get="/documents/remove/results?page={{add .CurrentPage 1}}&limit={{.Limit}}&session_id={{.SessionID}}"
<div class="file-result {{if $file.Success}}success{{else}}error{{end}}"> hx-target="#removal-results" class="pagination-btn">
<span class="file-name">{{$file.Name}}</span> Next →
{{if $file.Success}} </button>
<span class="success-icon"></span>
{{else}}
<span class="error-icon"></span>
<span class="error-message">{{$file.Error}}</span>
{{end}}
</div>
{{end}}
</div>
{{end}} {{end}}
</div> </div>
{{end}}
</div> </div>
{{end}} {{end}}
{{end}}
</div> </div>
{{end}} {{end}}

24
templates/partials/removal_stats.html

@ -0,0 +1,24 @@
{{define "removal_stats"}}
<div class="upload-stats">
<div class="stat-item">
<span class="stat-value">{{.TotalJobs}}</span>
<span class="stat-label">Total Jobs</span>
</div>
<div class="stat-item success-stat">
<span class="stat-value">{{.SuccessCount}}</span>
<span class="stat-label">Successful</span>
</div>
<div class="stat-item error-stat">
<span class="stat-value">{{.ErrorCount}}</span>
<span class="stat-label">Failed</span>
</div>
<div class="stat-item">
<span class="stat-value">{{.TotalFiles}}</span>
<span class="stat-label">Files Processed</span>
</div>
<div class="stat-item">
<span class="stat-value">{{.TotalTime}}</span>
<span class="stat-label">Total Time</span>
</div>
</div>
{{end}}

113
templates/partials/upload_result_card.html

@ -0,0 +1,113 @@
{{define "upload_result_card"}}
<div id="upload-card-{{.JobID}}-{{.DocName}}" class="upload-result-card" data-job-id="{{.JobID}}"
data-doc-name="{{.DocName}}">
<div class="upload-header">
<h4>{{.DocName}}</h4>
<div class="upload-status {{if .Success}}success{{else}}error{{end}}">
{{if .Success}}✓ Success{{else}}✗ Failed{{end}}
</div>
</div>
<div class="upload-details">
<div class="upload-info">
<p><strong>Job ID:</strong> {{.JobID}}</p>
<p><strong>File Size:</strong> {{printf "%.2f MB" (div .FileSize 1048576.0)}}</p>
{{if .Success}}
<p class="success-text">Successfully uploaded to ServiceTrade</p>
{{else}}
<p class="error-text">{{.Error}}</p>
{{end}}
</div>
<div class="upload-actions">
{{if .Success}}
<div class="success-indicator">
<span class="icon"></span>
<span>Upload Complete</span>
</div>
{{else}}
<div class="error-indicator">
<span class="icon"></span>
<span>Upload Failed</span>
</div>
{{end}}
</div>
</div>
</div>
{{end}}
{{define "upload_results_pagination"}}
<div class="upload-results-container">
<div class="upload-results-header">
<h3>Upload Results</h3>
<div class="upload-stats">
<div class="stat-item">
<span class="stat-value">{{.TotalJobs}}</span>
<span class="stat-label">Total Jobs</span>
</div>
<div class="stat-item success-stat">
<span class="stat-value">{{.TotalSuccess}}</span>
<span class="stat-label">Successful</span>
</div>
<div class="stat-item error-stat">
<span class="stat-value">{{.TotalFailure}}</span>
<span class="stat-label">Failed</span>
</div>
<div class="stat-item">
<span class="stat-value">{{printf "%.1f" (div .TotalBytesUploaded 1048576.0)}}</span>
<span class="stat-label">MB Uploaded</span>
</div>
<div class="stat-item">
<span class="stat-value">{{.TotalTime}}</span>
<span class="stat-label">Total Time</span>
</div>
</div>
</div>
{{if gt .TotalSuccess 0}}
<p>Successfully uploaded {{.TotalSuccess}} document(s) to ServiceTrade in {{.TotalTime}}!</p>
{{end}}
{{if gt .TotalFailure 0}}
<p class="text-warning">Failed to upload {{.TotalFailure}} document(s). See details below.</p>
{{end}}
<div class="upload-results-grid">
{{range .Results}}
{{template "upload_result_card" .}}
{{end}}
</div>
{{if gt .TotalPages 1}}
<div class="pagination-controls">
<div class="pagination-info">
Showing {{.StartIndex}}-{{.EndIndex}} of {{.TotalResults}} results
</div>
<div class="pagination-buttons">
{{if gt .CurrentPage 1}}
<button
hx-get="/documents/upload/results?page={{subtract .CurrentPage 1}}&limit={{.Limit}}&session_id={{.SessionID}}"
hx-target="#upload-results" class="pagination-btn">
← Previous
</button>
{{end}}
{{range $i := sequence .StartPage .EndPage}}
<button hx-get="/documents/upload/results?page={{$i}}&limit={{$.Limit}}&session_id={{$.SessionID}}"
hx-target="#upload-results" class="pagination-btn {{if eq $i $.CurrentPage}}active{{end}}">
{{$i}}
</button>
{{end}}
{{if lt .CurrentPage .TotalPages}}
<button
hx-get="/documents/upload/results?page={{add .CurrentPage 1}}&limit={{.Limit}}&session_id={{.SessionID}}"
hx-target="#upload-results" class="pagination-btn">
Next →
</button>
{{end}}
</div>
</div>
{{end}}
</div>
{{end}}

54
templates/partials/upload_results_pagination.html

@ -0,0 +1,54 @@
{{define "upload_results_pagination"}}
<div class="upload-results-container">
<div class="upload-results-header">
<h3>Upload Results</h3>
{{template "upload_stats" .}}
</div>
{{if gt .TotalSuccess 0}}
<p>Successfully uploaded {{.TotalSuccess}} document(s) to ServiceTrade in {{.TotalTime}}!</p>
{{end}}
{{if gt .TotalFailure 0}}
<p class="text-warning">Failed to upload {{.TotalFailure}} document(s). See details below.</p>
{{end}}
<div class="upload-results-grid">
{{range .Results}}
{{template "upload_result_card" .}}
{{end}}
</div>
{{if gt .TotalPages 1}}
<div class="pagination-controls">
<div class="pagination-info">
Showing {{.StartIndex}}-{{.EndIndex}} of {{.TotalResults}} results
</div>
<div class="pagination-buttons">
{{if gt .CurrentPage 1}}
<button
hx-get="/documents/upload/results?page={{subtract .CurrentPage 1}}&limit={{.Limit}}&session_id={{.SessionID}}"
hx-target="#upload-results" class="pagination-btn">
← Previous
</button>
{{end}}
{{range $i := sequence .StartPage .EndPage}}
<button hx-get="/documents/upload/results?page={{$i}}&limit={{$.Limit}}&session_id={{$.SessionID}}"
hx-target="#upload-results" class="pagination-btn {{if eq $i $.CurrentPage}}active{{end}}">
{{$i}}
</button>
{{end}}
{{if lt .CurrentPage .TotalPages}}
<button
hx-get="/documents/upload/results?page={{add .CurrentPage 1}}&limit={{.Limit}}&session_id={{.SessionID}}"
hx-target="#upload-results" class="pagination-btn">
Next →
</button>
{{end}}
</div>
</div>
{{end}}
</div>
{{end}}

24
templates/partials/upload_stats.html

@ -0,0 +1,24 @@
{{define "upload_stats"}}
<div class="upload-stats">
<div class="stat-item">
<span class="stat-value">{{.TotalJobs}}</span>
<span class="stat-label">Total Jobs</span>
</div>
<div class="stat-item success-stat">
<span class="stat-value">{{.TotalSuccess}}</span>
<span class="stat-label">Successful</span>
</div>
<div class="stat-item error-stat">
<span class="stat-value">{{.TotalFailure}}</span>
<span class="stat-label">Failed</span>
</div>
<div class="stat-item">
<span class="stat-value">{{printf "%.1f" (div .TotalBytesUploaded 1048576.0)}}</span>
<span class="stat-label">MB Uploaded</span>
</div>
<div class="stat-item">
<span class="stat-value">{{.TotalTime}}</span>
<span class="stat-label">Total Time</span>
</div>
</div>
{{end}}

19
web_templates.go

@ -19,6 +19,25 @@ var funcMap = template.FuncMap{
// This allows us to reference specific content blocks // This allows us to reference specific content blocks
return "{{template \"" + name + "-content\" .}}" 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 // InitializeWebTemplates parses all HTML templates in the embedded filesystem

Loading…
Cancel
Save