Compare commits

...

3 Commits

  1. 2
      apps/web/main.go
  2. BIN
      apps/web/web-app
  3. 1095
      internal/handlers/web/document_remove.go
  4. 231
      internal/handlers/web/documents.go
  5. 226
      static/css/styles.css
  6. 1094
      static/css/upload.css
  7. 52
      templates/partials/removal_result_card.html
  8. 69
      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.

1095
internal/handlers/web/document_remove.go

File diff suppressed because it is too large

231
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)
@ -166,6 +190,8 @@ func ProcessCSVHandler(w http.ResponseWriter, r *http.Request) {
// UploadDocumentsHandler handles document uploads to jobs // UploadDocumentsHandler handles document uploads to jobs
func UploadDocumentsHandler(w http.ResponseWriter, r *http.Request) { func UploadDocumentsHandler(w http.ResponseWriter, r *http.Request) {
startTime := time.Now()
session, ok := r.Context().Value(middleware.SessionKey).(*api.Session) session, ok := r.Context().Value(middleware.SessionKey).(*api.Session)
if !ok { if !ok {
http.Error(w, "Unauthorized", http.StatusUnauthorized) http.Error(w, "Unauthorized", http.StatusUnauthorized)
@ -310,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
@ -410,113 +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)
} }
log.Printf("All results collected. Total: %d, Total bytes uploaded: %.2f MB", // Calculate total duration
resultsCount, float64(totalBytesUploaded)/(1024*1024)) totalDuration := time.Since(startTime)
log.Printf("All results collected. Total: %d, Total bytes uploaded: %.2f MB, Total time: %v",
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(),
}
var resultHTML bytes.Buffer // Calculate totals
var totalSuccess, totalFailure int for _, result := range allResults {
for _, jobResults := range results {
for _, result := range jobResults {
if result.Success { if result.Success {
totalSuccess++ uploadSession.TotalSuccess++
} else { } else {
totalFailure++ uploadSession.TotalFailure++
} }
} }
// Store in global map (in production, use Redis or database)
uploadSessions[sessionID] = uploadSession
// 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
} }
resultHTML.WriteString("<div class=\"upload-summary\">") page, _ := strconv.Atoi(r.URL.Query().Get("page"))
resultHTML.WriteString("<h3>Upload Results</h3>") if page < 1 {
resultHTML.WriteString("<div class=\"upload-stats\">") page = 1
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("</div>")
if totalFailure == 0 && resultsCount > 0 { limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
resultHTML.WriteString("<p>All documents were successfully uploaded to ServiceTrade!</p>") if limit < 1 {
} else if resultsCount == 0 { limit = 20
resultHTML.WriteString("<p>No documents were processed for upload.</p>")
} else {
resultHTML.WriteString("<p>Some documents failed to upload. See details below.</p>")
} }
resultHTML.WriteString("</div>")
resultHTML.WriteString("<div class=\"job-results\">") renderUploadResultsPage(w, sessionID, page, limit)
sortedJobs := make([]string, 0, len(results)) }
for jobID := range results {
sortedJobs = append(sortedJobs, jobID) // 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
} }
sort.Strings(sortedJobs)
for _, jobID := range sortedJobs { totalResults := len(uploadSession.Results)
jobResults := results[jobID] totalPages := (totalResults + limit - 1) / limit // Ceiling division
jobHasSuccess := false
jobHasFailure := false if page > totalPages && totalPages > 0 {
for _, result := range jobResults { page = totalPages
if result.Success { }
jobHasSuccess = true
} else { startIndex := (page - 1) * limit
jobHasFailure = true endIndex := startIndex + limit
} if endIndex > totalResults {
} endIndex = totalResults
jobClass := "neutral" }
if jobHasSuccess && !jobHasFailure {
jobClass = "success" // Get results for this page
} else if jobHasFailure { var pageResults []UploadResult
jobClass = "error" if startIndex < totalResults {
} pageResults = uploadSession.Results[startIndex:endIndex]
resultHTML.WriteString(fmt.Sprintf("<div class=\"job-result %s\">", jobClass)) }
resultHTML.WriteString(fmt.Sprintf("<div class=\"job-id\">Job ID: %s</div>", jobID))
if len(jobResults) > 0 { // Calculate pagination info
resultHTML.WriteString("<div class=\"file-results\">") startPage := 1
sort.Slice(jobResults, func(i, j int) bool { endPage := totalPages
return jobResults[i].DocName < jobResults[j].DocName if totalPages > 10 {
}) if page <= 5 {
for _, result := range jobResults { endPage = 10
fileClass := "success" } else if page >= totalPages-4 {
icon := "✓" startPage = totalPages - 9
message := "Successfully uploaded"
if !result.Success {
fileClass = "error"
icon = "✗"
message = strings.ReplaceAll(result.Error, "<", "&lt;")
message = strings.ReplaceAll(message, ">", "&gt;")
}
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))
resultHTML.WriteString(fmt.Sprintf("<span class=\"file-message\">%s</span>", message))
resultHTML.WriteString("</div>")
}
resultHTML.WriteString("</div>")
} 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;
}
} }

1094
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}}

69
templates/partials/removal_results.html

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