Browse Source

fix: updated pagination to be reuseable; lots of styling/formatting work

document-upload-removal-layout-update
nic 7 months ago
parent
commit
3af9a2806c
  1. 2
      apps/web/main.go
  2. 181
      internal/handlers/web/document_remove.go
  3. 312
      internal/handlers/web/documents.go
  4. 88
      internal/utils/pagination.go
  5. 69
      static/css/upload.css
  6. 233
      templates/partials/document_upload.html
  7. 23
      templates/partials/removal_result_card.html
  8. 9
      templates/partials/removal_results.html
  9. 2
      templates/partials/removal_stats.html
  10. 56
      templates/partials/upload_result_card.html
  11. 9
      templates/partials/upload_results_pagination.html
  12. 2
      templates/partials/upload_stats.html
  13. 17
      web_templates.go

2
apps/web/main.go

@ -65,11 +65,13 @@ func main() {
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") protected.HandleFunc("/documents/upload/results", web.UploadResultsHandler).Methods("GET")
protected.HandleFunc("/documents/upload/job/file", web.UploadJobFileHandler).Methods("GET")
// Document removal routes // Document removal routes
protected.HandleFunc("/documents/remove", web.DocumentRemoveHandler).Methods("GET") protected.HandleFunc("/documents/remove", web.DocumentRemoveHandler).Methods("GET")
protected.HandleFunc("/documents/remove/process-csv", web.ProcessRemoveCSVHandler).Methods("POST") protected.HandleFunc("/documents/remove/process-csv", web.ProcessRemoveCSVHandler).Methods("POST")
protected.HandleFunc("/documents/remove/job-selection", web.JobSelectionHandler).Methods("POST") protected.HandleFunc("/documents/remove/job-selection", web.JobSelectionHandler).Methods("POST")
protected.HandleFunc("/documents/remove/job/file", web.RemovalJobFileHandler).Methods("GET")
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")

181
internal/handlers/web/document_remove.go

@ -15,6 +15,7 @@ import (
root "marmic/servicetrade-toolbox" root "marmic/servicetrade-toolbox"
"marmic/servicetrade-toolbox/internal/api" "marmic/servicetrade-toolbox/internal/api"
"marmic/servicetrade-toolbox/internal/middleware" "marmic/servicetrade-toolbox/internal/middleware"
"marmic/servicetrade-toolbox/internal/utils"
"github.com/gorilla/mux" "github.com/gorilla/mux"
) )
@ -787,8 +788,21 @@ func BulkRemoveDocumentsHandler(w http.ResponseWriter, r *http.Request) {
// Store in global map (in production, use Redis or database) // Store in global map (in production, use Redis or database)
removalSessions[sessionID] = removalSession removalSessions[sessionID] = removalSession
// Return first page of results // Get configurable page size from form, with fallback to default
renderRemovalResultsPage(w, sessionID, 1, 20) // Default to page 1, 20 items per page limitStr := r.FormValue("limit")
limit := utils.DefaultPageSize
if limitStr != "" {
if parsedLimit, err := strconv.Atoi(limitStr); err == nil && parsedLimit > 0 {
if parsedLimit > utils.MaxPageSize {
limit = utils.MaxPageSize
} else {
limit = parsedLimit
}
}
}
// Return first page of results with configurable page size
renderRemovalResultsPage(w, sessionID, utils.DefaultPage, limit)
} }
// RemovalResultsHandler handles pagination for removal results // RemovalResultsHandler handles pagination for removal results
@ -801,12 +815,12 @@ func RemovalResultsHandler(w http.ResponseWriter, r *http.Request) {
page, _ := strconv.Atoi(r.URL.Query().Get("page")) page, _ := strconv.Atoi(r.URL.Query().Get("page"))
if page < 1 { if page < 1 {
page = 1 page = utils.DefaultPage
} }
limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit < 1 { if limit < 1 {
limit = 20 limit = utils.DefaultPageSize
} }
renderRemovalResultsPage(w, sessionID, page, limit) renderRemovalResultsPage(w, sessionID, page, limit)
@ -821,53 +835,48 @@ func renderRemovalResultsPage(w http.ResponseWriter, sessionID string, page, lim
} }
totalResults := len(removalSession.Results) totalResults := len(removalSession.Results)
totalPages := (totalResults + limit - 1) / limit // Ceiling division pagination := utils.CalculatePagination(totalResults, page, limit)
if page > totalPages && totalPages > 0 {
page = totalPages
}
startIndex := (page - 1) * limit // Get results for this page
endIndex := startIndex + limit startIndex := (pagination.CurrentPage - 1) * pagination.Limit
endIndex := startIndex + pagination.Limit
if endIndex > totalResults { if endIndex > totalResults {
endIndex = totalResults endIndex = totalResults
} }
pageResults := utils.GetPageResults(removalSession.Results, startIndex, endIndex)
// Get results for this page
var pageResults []RemovalResult // Add pagination info to each job result for the template
if startIndex < totalResults { var resultsWithPagination []map[string]interface{}
pageResults = removalSession.Results[startIndex:endIndex] for _, jobResult := range pageResults {
} resultMap := map[string]interface{}{
"JobID": jobResult.JobID,
// Calculate pagination info "FilesFound": jobResult.FilesFound,
startPage := 1 "FilesRemoved": jobResult.FilesRemoved,
endPage := totalPages "Success": jobResult.Success,
if totalPages > 10 { "ErrorMsg": jobResult.ErrorMsg,
if page <= 5 { "Files": jobResult.Files,
endPage = 10 "FilePage": 1, // Default to first file
} else if page >= totalPages-4 { "TotalFiles": len(jobResult.Files),
startPage = totalPages - 9 "SessionID": sessionID,
} else {
startPage = page - 4
endPage = page + 5
} }
resultsWithPagination = append(resultsWithPagination, resultMap)
} }
data := map[string]interface{}{ data := map[string]interface{}{
"Results": pageResults, "Results": resultsWithPagination,
"TotalJobs": removalSession.TotalJobs, "TotalJobs": removalSession.TotalJobs,
"SuccessCount": removalSession.SuccessCount, "SuccessCount": removalSession.SuccessCount,
"ErrorCount": removalSession.ErrorCount, "ErrorCount": removalSession.ErrorCount,
"TotalFiles": removalSession.TotalFiles, "TotalFiles": removalSession.TotalFiles,
"TotalTime": removalSession.TotalTime, "TotalTime": removalSession.TotalTime,
"TotalResults": totalResults, "TotalResults": pagination.TotalResults,
"TotalPages": totalPages, "TotalPages": pagination.TotalPages,
"CurrentPage": page, "CurrentPage": pagination.CurrentPage,
"Limit": limit, "Limit": pagination.Limit,
"StartIndex": startIndex + 1, "StartIndex": pagination.StartIndex,
"EndIndex": endIndex, "EndIndex": pagination.EndIndex,
"StartPage": startPage, "StartPage": pagination.StartPage,
"EndPage": endPage, "EndPage": pagination.EndPage,
"SessionID": sessionID, "SessionID": sessionID,
} }
@ -903,3 +912,99 @@ func matchesAnyPattern(s string, patterns []string) bool {
} }
return false return false
} }
// New handler: Serve a single job card with only one file (per-job file pagination)
func RemovalJobFileHandler(w http.ResponseWriter, r *http.Request) {
sessionID := r.URL.Query().Get("session_id")
jobID := r.URL.Query().Get("job_id")
filePageStr := r.URL.Query().Get("file_page")
filePage := 1
if filePageStr != "" {
if parsed, err := strconv.Atoi(filePageStr); err == nil && parsed > 0 {
filePage = parsed
}
}
removalSession, exists := removalSessions[sessionID]
if !exists {
http.Error(w, "Removal session not found", http.StatusNotFound)
return
}
// Find the job result
var jobResult *RemovalResult
for i := range removalSession.Results {
if removalSession.Results[i].JobID == jobID {
jobResult = &removalSession.Results[i]
break
}
}
if jobResult == nil {
http.Error(w, "Job not found in session", http.StatusNotFound)
return
}
totalFiles := len(jobResult.Files)
if totalFiles == 0 {
// No files to show
data := map[string]interface{}{
"JobID": jobResult.JobID,
"FilesFound": jobResult.FilesFound,
"FilesRemoved": jobResult.FilesRemoved,
"Success": jobResult.Success,
"ErrorMsg": jobResult.ErrorMsg,
"Files": nil,
"FilePage": 1,
"TotalFiles": 0,
"SessionID": sessionID,
}
tmpl := root.WebTemplates
if err := tmpl.ExecuteTemplate(w, "removal_result_card", data); err != nil {
log.Printf("Template execution error: %v", err)
return
}
return
}
// Ensure filePage is within bounds
if filePage > totalFiles {
filePage = totalFiles
}
if filePage < 1 {
filePage = 1
}
// Create a copy of the job result with only the requested file
jobResultCopy := RemovalResult{
JobID: jobResult.JobID,
FilesFound: jobResult.FilesFound,
FilesRemoved: jobResult.FilesRemoved,
Success: jobResult.Success,
ErrorMsg: jobResult.ErrorMsg,
Files: []struct {
Name string
Success bool
Error string
}{jobResult.Files[filePage-1]},
}
// Add pagination info for the template
data := map[string]interface{}{
"JobID": jobResultCopy.JobID,
"FilesFound": jobResultCopy.FilesFound,
"FilesRemoved": jobResultCopy.FilesRemoved,
"Success": jobResultCopy.Success,
"ErrorMsg": jobResultCopy.ErrorMsg,
"Files": jobResultCopy.Files,
"FilePage": filePage,
"TotalFiles": totalFiles,
"SessionID": sessionID,
}
tmpl := root.WebTemplates
if err := tmpl.ExecuteTemplate(w, "removal_result_card", data); err != nil {
log.Printf("Template execution error: %v", err)
return
}
}

312
internal/handlers/web/documents.go

@ -17,6 +17,7 @@ import (
root "marmic/servicetrade-toolbox" root "marmic/servicetrade-toolbox"
"marmic/servicetrade-toolbox/internal/api" "marmic/servicetrade-toolbox/internal/api"
"marmic/servicetrade-toolbox/internal/middleware" "marmic/servicetrade-toolbox/internal/middleware"
"marmic/servicetrade-toolbox/internal/utils"
) )
// UploadResult represents the result of a single file upload // UploadResult represents the result of a single file upload
@ -32,6 +33,19 @@ type UploadResult struct {
// UploadSession stores upload results for pagination // UploadSession stores upload results for pagination
type UploadSession struct { type UploadSession struct {
Results []UploadResult Results []UploadResult
GroupedResults []struct {
JobID string
FilesFound int
FilesUploaded int
Success bool
ErrorMsg string
Files []struct {
Name string
Success bool
Error string
FileSize int64
}
}
TotalJobs int TotalJobs int
TotalSuccess int TotalSuccess int
TotalFailure int TotalFailure int
@ -445,10 +459,99 @@ func UploadDocumentsHandler(w http.ResponseWriter, r *http.Request) {
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",
len(allResults), float64(totalBytesUploaded)/(1024*1024), totalDuration) len(allResults), float64(totalBytesUploaded)/(1024*1024), totalDuration)
// Group results by job for consistent display with removal results
type JobUploadResult struct {
JobID string
FilesFound int
FilesUploaded int
Success bool
ErrorMsg string
Files []struct {
Name string
Success bool
Error string
FileSize int64
}
}
// Group results by job
jobResults := make(map[string]*JobUploadResult)
for _, result := range allResults {
if jobResult, exists := jobResults[result.JobID]; exists {
// Add file to existing job
jobResult.FilesFound++
if result.Success {
jobResult.FilesUploaded++
} else {
jobResult.Success = false
if jobResult.ErrorMsg == "" {
jobResult.ErrorMsg = "Some files failed to upload"
}
}
jobResult.Files = append(jobResult.Files, struct {
Name string
Success bool
Error string
FileSize int64
}{
Name: result.DocName,
Success: result.Success,
Error: result.Error,
FileSize: result.FileSize,
})
} else {
// Create new job result
jobResults[result.JobID] = &JobUploadResult{
JobID: result.JobID,
FilesFound: 1,
FilesUploaded: 0,
Success: result.Success,
ErrorMsg: "",
Files: []struct {
Name string
Success bool
Error string
FileSize int64
}{
{
Name: result.DocName,
Success: result.Success,
Error: result.Error,
FileSize: result.FileSize,
},
},
}
if result.Success {
jobResults[result.JobID].FilesUploaded = 1
} else {
jobResults[result.JobID].ErrorMsg = "Some files failed to upload"
}
}
}
// Convert grouped results to slice
var groupedResults []struct {
JobID string
FilesFound int
FilesUploaded int
Success bool
ErrorMsg string
Files []struct {
Name string
Success bool
Error string
FileSize int64
}
}
for _, jobResult := range jobResults {
groupedResults = append(groupedResults, *jobResult)
}
// Store results in session for pagination // Store results in session for pagination
sessionID := fmt.Sprintf("upload_%d", time.Now().UnixNano()) sessionID := fmt.Sprintf("upload_%d", time.Now().UnixNano())
uploadSession := UploadSession{ uploadSession := UploadSession{
Results: allResults, Results: allResults, // Keep original results for backward compatibility
GroupedResults: groupedResults,
TotalJobs: len(jobs), TotalJobs: len(jobs),
TotalSuccess: 0, TotalSuccess: 0,
TotalFailure: 0, TotalFailure: 0,
@ -469,8 +572,21 @@ func UploadDocumentsHandler(w http.ResponseWriter, r *http.Request) {
// Store in global map (in production, use Redis or database) // Store in global map (in production, use Redis or database)
uploadSessions[sessionID] = uploadSession uploadSessions[sessionID] = uploadSession
// Return first page of results // Get configurable page size from form, with fallback to default
renderUploadResultsPage(w, sessionID, 1, 20) // Default to page 1, 20 items per page limitStr := r.FormValue("limit")
limit := utils.DefaultPageSize
if limitStr != "" {
if parsedLimit, err := strconv.Atoi(limitStr); err == nil && parsedLimit > 0 {
if parsedLimit > utils.MaxPageSize {
limit = utils.MaxPageSize
} else {
limit = parsedLimit
}
}
}
// Return first page of results with configurable page size
renderUploadResultsPage(w, sessionID, utils.DefaultPage, limit)
} }
// UploadResultsHandler handles pagination for upload results // UploadResultsHandler handles pagination for upload results
@ -483,12 +599,12 @@ func UploadResultsHandler(w http.ResponseWriter, r *http.Request) {
page, _ := strconv.Atoi(r.URL.Query().Get("page")) page, _ := strconv.Atoi(r.URL.Query().Get("page"))
if page < 1 { if page < 1 {
page = 1 page = utils.DefaultPage
} }
limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit < 1 { if limit < 1 {
limit = 20 limit = utils.DefaultPageSize
} }
renderUploadResultsPage(w, sessionID, page, limit) renderUploadResultsPage(w, sessionID, page, limit)
@ -502,54 +618,49 @@ func renderUploadResultsPage(w http.ResponseWriter, sessionID string, page, limi
return return
} }
totalResults := len(uploadSession.Results) totalResults := len(uploadSession.GroupedResults)
totalPages := (totalResults + limit - 1) / limit // Ceiling division pagination := utils.CalculatePagination(totalResults, page, limit)
if page > totalPages && totalPages > 0 {
page = totalPages
}
startIndex := (page - 1) * limit // Get results for this page
endIndex := startIndex + limit startIndex := (pagination.CurrentPage - 1) * pagination.Limit
endIndex := startIndex + pagination.Limit
if endIndex > totalResults { if endIndex > totalResults {
endIndex = totalResults endIndex = totalResults
} }
pageResults := utils.GetPageResults(uploadSession.GroupedResults, startIndex, endIndex)
// Get results for this page
var pageResults []UploadResult // Add pagination info to each job result for the template
if startIndex < totalResults { var resultsWithPagination []map[string]interface{}
pageResults = uploadSession.Results[startIndex:endIndex] for _, jobResult := range pageResults {
} resultMap := map[string]interface{}{
"JobID": jobResult.JobID,
// Calculate pagination info "FilesFound": jobResult.FilesFound,
startPage := 1 "FilesUploaded": jobResult.FilesUploaded,
endPage := totalPages "Success": jobResult.Success,
if totalPages > 10 { "ErrorMsg": jobResult.ErrorMsg,
if page <= 5 { "Files": jobResult.Files,
endPage = 10 "FilePage": 1, // Default to first file
} else if page >= totalPages-4 { "TotalFiles": len(jobResult.Files),
startPage = totalPages - 9 "SessionID": sessionID,
} else {
startPage = page - 4
endPage = page + 5
} }
resultsWithPagination = append(resultsWithPagination, resultMap)
} }
data := map[string]interface{}{ data := map[string]interface{}{
"Results": pageResults, "Results": resultsWithPagination,
"TotalJobs": uploadSession.TotalJobs, "TotalJobs": uploadSession.TotalJobs,
"TotalSuccess": uploadSession.TotalSuccess, "TotalSuccess": uploadSession.TotalSuccess,
"TotalFailure": uploadSession.TotalFailure, "TotalFailure": uploadSession.TotalFailure,
"TotalBytesUploaded": uploadSession.TotalBytesUploaded, "TotalBytesUploaded": uploadSession.TotalBytesUploaded,
"TotalTime": uploadSession.TotalTime, "TotalTime": uploadSession.TotalTime,
"TotalResults": totalResults, "TotalResults": pagination.TotalResults,
"TotalPages": totalPages, "TotalPages": pagination.TotalPages,
"CurrentPage": page, "CurrentPage": pagination.CurrentPage,
"Limit": limit, "Limit": pagination.Limit,
"StartIndex": startIndex + 1, "StartIndex": pagination.StartIndex,
"EndIndex": endIndex, "EndIndex": pagination.EndIndex,
"StartPage": startPage, "StartPage": pagination.StartPage,
"EndPage": endPage, "EndPage": pagination.EndPage,
"SessionID": sessionID, "SessionID": sessionID,
} }
@ -588,3 +699,124 @@ func (r *readCloserWithSize) Size() int64 {
// DocumentFieldAddHandler and DocumentFieldRemoveHandler are REMOVED // DocumentFieldAddHandler and DocumentFieldRemoveHandler are REMOVED
// as they are no longer needed with the multi-file input and new chip UI. // as they are no longer needed with the multi-file input and new chip UI.
// New handler: Serve a single job card with only one file (per-job file pagination)
func UploadJobFileHandler(w http.ResponseWriter, r *http.Request) {
sessionID := r.URL.Query().Get("session_id")
jobID := r.URL.Query().Get("job_id")
filePageStr := r.URL.Query().Get("file_page")
filePage := 1
if filePageStr != "" {
if parsed, err := strconv.Atoi(filePageStr); err == nil && parsed > 0 {
filePage = parsed
}
}
uploadSession, exists := uploadSessions[sessionID]
if !exists {
http.Error(w, "Upload session not found", http.StatusNotFound)
return
}
// Find the job result
var jobResult *struct {
JobID string
FilesFound int
FilesUploaded int
Success bool
ErrorMsg string
Files []struct {
Name string
Success bool
Error string
FileSize int64
}
}
for i := range uploadSession.GroupedResults {
if uploadSession.GroupedResults[i].JobID == jobID {
jobResult = &uploadSession.GroupedResults[i]
break
}
}
if jobResult == nil {
http.Error(w, "Job not found in session", http.StatusNotFound)
return
}
totalFiles := len(jobResult.Files)
if totalFiles == 0 {
// No files to show
data := map[string]interface{}{
"JobID": jobResult.JobID,
"FilesFound": jobResult.FilesFound,
"FilesUploaded": jobResult.FilesUploaded,
"Success": jobResult.Success,
"ErrorMsg": jobResult.ErrorMsg,
"Files": nil,
"FilePage": 1,
"TotalFiles": 0,
"SessionID": sessionID,
}
tmpl := root.WebTemplates
if err := tmpl.ExecuteTemplate(w, "upload_result_card", data); err != nil {
log.Printf("Template execution error: %v", err)
return
}
return
}
// Ensure filePage is within bounds
if filePage > totalFiles {
filePage = totalFiles
}
if filePage < 1 {
filePage = 1
}
// Create a copy of the job result with only the requested file
jobResultCopy := struct {
JobID string
FilesFound int
FilesUploaded int
Success bool
ErrorMsg string
Files []struct {
Name string
Success bool
Error string
FileSize int64
}
}{
JobID: jobResult.JobID,
FilesFound: jobResult.FilesFound,
FilesUploaded: jobResult.FilesUploaded,
Success: jobResult.Success,
ErrorMsg: jobResult.ErrorMsg,
Files: []struct {
Name string
Success bool
Error string
FileSize int64
}{jobResult.Files[filePage-1]},
}
// Add pagination info for the template
data := map[string]interface{}{
"JobID": jobResultCopy.JobID,
"FilesFound": jobResultCopy.FilesFound,
"FilesUploaded": jobResultCopy.FilesUploaded,
"Success": jobResultCopy.Success,
"ErrorMsg": jobResultCopy.ErrorMsg,
"Files": jobResultCopy.Files,
"FilePage": filePage,
"TotalFiles": totalFiles,
"SessionID": sessionID,
}
tmpl := root.WebTemplates
if err := tmpl.ExecuteTemplate(w, "upload_result_card", data); err != nil {
log.Printf("Template execution error: %v", err)
return
}
}

88
internal/utils/pagination.go

@ -0,0 +1,88 @@
package utils
import (
"math"
)
// Default pagination constants
const (
DefaultPage = 1
DefaultPageSize = 5
MaxPageSize = 100
)
// PaginationInfo contains all the information needed for pagination
type PaginationInfo struct {
TotalResults int
TotalPages int
CurrentPage int
Limit int
StartIndex int
EndIndex int
StartPage int
EndPage int
}
// CalculatePagination calculates pagination information based on total results, current page, and limit
func CalculatePagination(totalResults, currentPage, limit int) PaginationInfo {
if currentPage < 1 {
currentPage = 1
}
if limit < 1 {
limit = DefaultPageSize
}
totalPages := int(math.Ceil(float64(totalResults) / float64(limit)))
if totalPages < 1 {
totalPages = 1
}
// Ensure current page is within bounds
if currentPage > totalPages {
currentPage = totalPages
}
startIndex := (currentPage - 1) * limit
endIndex := startIndex + limit
if endIndex > totalResults {
endIndex = totalResults
}
// Calculate pagination range for display
startPage := 1
endPage := totalPages
// Show up to 10 page numbers, centered around current page when possible
if totalPages > 10 {
if currentPage <= 5 {
endPage = 10
} else if currentPage >= totalPages-4 {
startPage = totalPages - 9
} else {
startPage = currentPage - 4
endPage = currentPage + 5
}
}
return PaginationInfo{
TotalResults: totalResults,
TotalPages: totalPages,
CurrentPage: currentPage,
Limit: limit,
StartIndex: startIndex + 1, // Convert to 1-based for display
EndIndex: endIndex,
StartPage: startPage,
EndPage: endPage,
}
}
// GetPageResults extracts the results for the current page from a slice
func GetPageResults[T any](allResults []T, startIndex, endIndex int) []T {
if startIndex >= len(allResults) {
return []T{}
}
if endIndex > len(allResults) {
endIndex = len(allResults)
}
return allResults[startIndex:endIndex]
}

69
static/css/upload.css

@ -62,7 +62,7 @@
justify-content: center; justify-content: center;
align-items: center; align-items: center;
z-index: 1000; z-index: 1000;
border-radius: inherit; border-radius: 0.5rem;
backdrop-filter: blur(2px); backdrop-filter: blur(2px);
} }
@ -804,3 +804,70 @@
max-width: 80px; max-width: 80px;
} }
} }
/* File pagination controls within job cards */
.file-pagination-controls {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--input-border);
display: flex;
flex-direction: column;
gap: 0.5rem;
align-items: center;
}
.file-pagination-controls span {
font-size: 0.875rem;
color: var(--label-color);
font-weight: 500;
}
.file-pagination-buttons {
display: flex;
gap: 0.5rem;
align-items: center;
}
.file-pagination-buttons .pagination-btn {
background-color: var(--input-bg);
border: 1px solid var(--input-border);
color: var(--content-text);
padding: 0.375rem 0.75rem;
border-radius: 0.375rem;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.file-pagination-buttons .pagination-btn:hover {
background-color: var(--btn-primary-bg);
color: white;
border-color: var(--btn-primary-bg);
}
.file-pagination-buttons .pagination-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
background-color: var(--progress-bg);
}
@media (max-width: 768px) {
.file-pagination-controls {
margin-top: 0.5rem;
padding-top: 0.5rem;
}
.file-pagination-buttons {
flex-direction: column;
width: 100%;
}
.file-pagination-buttons .pagination-btn {
width: 100%;
justify-content: center;
}
}

233
templates/partials/document_upload.html

@ -1,11 +1,8 @@
{{define "document_upload"}} {{define "document_upload"}}
<h2>Document Uploads</h2> <h2>Document Uploads</h2>
<form id="upload-form" hx-post="/upload-documents" hx-encoding="multipart/form-data" hx-target="#upload-results" <div class="upload-container">
hx-indicator=".upload-overlay"> <!-- Upload overlay - moved outside the form -->
<div class="upload-container">
<!-- Upload overlay -->
<div class="upload-overlay htmx-indicator"> <div class="upload-overlay htmx-indicator">
<div class="upload-overlay-content"> <div class="upload-overlay-content">
<div class="overlay-spinner"></div> <div class="overlay-spinner"></div>
@ -69,18 +66,29 @@
</button> </button>
</div> </div>
<!-- Step 3: Submit --> <!-- Step 3: Submit - form moved here -->
<div id="step3" class="content" style="display: none;"> <div id="step3" class="content" style="display: none;">
<h3 class="submenu-header">Step 3: Submit Uploads</h3> <h3 class="submenu-header">Step 3: Submit Uploads</h3>
<div> <div>
<form id="upload-form" hx-post="/upload-documents" hx-encoding="multipart/form-data"
hx-include="[name='documentFiles']" hx-target="#upload-results" hx-indicator=".upload-overlay">
<input type="hidden" name="job-ids" id="job-ids-field">
<button type="submit" class="success-button" id="final-submit-button">Upload Documents to Jobs</button> <button type="submit" class="success-button" id="final-submit-button">Upload Documents to Jobs</button>
</form>
<div id="upload-loading-indicator" class="htmx-indicator"> <div id="upload-loading-indicator" class="htmx-indicator">
<span>Uploading...</span> <span>Uploading...</span>
<div class="loading-indicator"></div> <div class="loading-indicator"></div>
</div> </div>
</div>
</div>
<div id="upload-results" class="upload-results"></div> <!-- Step 4: Results - moved outside the form -->
<div id="step4" class="content" style="display: none;">
<h3 class="submenu-header">Step 4: Upload Results</h3>
<div id="upload-results" class="upload-results">
<!-- Results will appear here after uploading documents -->
</div> </div>
</div> </div>
@ -89,8 +97,7 @@
<h3 class="submenu-header">Upload Complete</h3> <h3 class="submenu-header">Upload Complete</h3>
<button type="button" class="btn-primary" hx-on:click="restartUpload()">Start New Upload</button> <button type="button" class="btn-primary" hx-on:click="restartUpload()">Start New Upload</button>
</div> </div>
</div> </div>
</form>
<!-- Edit File Modal (Initially Hidden) --> <!-- Edit File Modal (Initially Hidden) -->
<div id="editFileModal" class="modal" style="display:none;"> <div id="editFileModal" class="modal" style="display:none;">
@ -200,39 +207,40 @@
const icon = getFileIcon(fileMetadata.displayName); const icon = getFileIcon(fileMetadata.displayName);
const truncatedName = truncateFilename(fileMetadata.displayName); const truncatedName = truncateFilename(fileMetadata.displayName);
// Get document type text
const docTypeSelect = document.getElementById('editDocumentType'); // Use the modal's select for options
let docTypeText = '';
if (docTypeSelect) {
const selectedOption = Array.from(docTypeSelect.options).find(opt => opt.value === fileMetadata.documentType);
if (selectedOption) {
docTypeText = selectedOption.text;
}
}
chip.innerHTML = ` chip.innerHTML = `
<span class="file-chip-icon">${icon}</span> <span class="file-chip-icon">${icon}</span>
<span class="file-chip-name" title="${fileMetadata.displayName}" onclick="openEditModal(${index})">${truncatedName}</span> <span class="file-chip-name" title="${fileMetadata.displayName}">${truncatedName}</span>
<span class="file-chip-doctype">${docTypeText}</span> <span class="file-chip-doctype">Type: ${fileMetadata.documentType}</span>
<button type="button" class="file-chip-edit" onclick="openEditModal(${index})">✏️</button> <button type="button" class="file-chip-edit" onclick="openEditModal(${index})" title="Edit file details">✏️</button>
<button type="button" class="file-chip-remove" onclick="removeFileChip(${index})">&times;</button> <button type="button" class="file-chip-remove" onclick="toggleFileActive(${index})" title="${fileMetadata.isActive ? 'Remove from upload' : 'Add back to upload'}">${fileMetadata.isActive ? '❌' : '➕'}</button>
`; `;
filesArea.appendChild(chip); filesArea.appendChild(chip);
} }
function openEditModal(index) { function toggleFileActive(index) {
const fileMetadata = selectedFilesData[index]; if (index >= 0 && index < selectedFilesData.length) {
if (!fileMetadata || !fileMetadata.isActive) return; // Don't edit removed or non-existent files selectedFilesData[index].isActive = !selectedFilesData[index].isActive;
const chip = document.querySelector(`[data-index="${index}"]`);
if (chip) {
chip.className = `file-chip ${selectedFilesData[index].isActive ? '' : 'removed'}`;
const removeBtn = chip.querySelector('.file-chip-remove');
if (removeBtn) {
removeBtn.innerHTML = selectedFilesData[index].isActive ? '❌' : '➕';
removeBtn.title = selectedFilesData[index].isActive ? 'Remove from upload' : 'Add back to upload';
}
}
}
}
function openEditModal(index) {
if (index >= 0 && index < selectedFilesData.length) {
const fileData = selectedFilesData[index];
document.getElementById('editFileOriginalIndex').value = index; document.getElementById('editFileOriginalIndex').value = index;
document.getElementById('editDisplayName').value = fileMetadata.displayName; document.getElementById('editDisplayName').value = fileData.displayName;
document.getElementById('editDocumentType').value = fileMetadata.documentType; document.getElementById('editDocumentType').value = fileData.documentType;
document.getElementById('editFileModal').style.display = 'block';
// Later: Add preview logic here if possible for fileMetadata.originalFile }
const previewArea = document.getElementById('modal-preview-area');
previewArea.innerHTML = '<p>Document preview will be shown here in a future update.</p>'; // Placeholder
document.getElementById('editFileModal').style.display = 'flex';
} }
function closeEditModal() { function closeEditModal() {
@ -241,99 +249,104 @@
function saveFileChanges() { function saveFileChanges() {
const index = parseInt(document.getElementById('editFileOriginalIndex').value); const index = parseInt(document.getElementById('editFileOriginalIndex').value);
const newDisplayName = document.getElementById('editDisplayName').value; if (index >= 0 && index < selectedFilesData.length) {
const newDocumentType = document.getElementById('editDocumentType').value; selectedFilesData[index].displayName = document.getElementById('editDisplayName').value;
selectedFilesData[index].documentType = document.getElementById('editDocumentType').value;
if (selectedFilesData[index]) {
selectedFilesData[index].displayName = newDisplayName;
selectedFilesData[index].documentType = newDocumentType;
// Re-render the chip's display name, icon, and doc type // Re-render the chip
const chipElement = document.querySelector(`.file-chip[data-index="${index}"]`); const chip = document.querySelector(`[data-index="${index}"]`);
if (chipElement) { if (chip) {
const truncatedName = truncateFilename(newDisplayName); const icon = getFileIcon(selectedFilesData[index].displayName);
chipElement.querySelector('.file-chip-name').textContent = truncatedName; const truncatedName = truncateFilename(selectedFilesData[index].displayName);
chipElement.querySelector('.file-chip-name').title = newDisplayName;
chipElement.querySelector('.file-chip-icon').textContent = getFileIcon(newDisplayName);
// Update doc type text on chip chip.innerHTML = `
const docTypeSelect = document.getElementById('editDocumentType'); <span class="file-chip-icon">${icon}</span>
let docTypeText = ''; <span class="file-chip-name" title="${selectedFilesData[index].displayName}">${truncatedName}</span>
if (docTypeSelect) { <span class="file-chip-doctype">Type: ${selectedFilesData[index].documentType}</span>
const selectedOption = Array.from(docTypeSelect.options).find(opt => opt.value === newDocumentType); <button type="button" class="file-chip-edit" onclick="openEditModal(${index})" title="Edit file details">✏️</button>
if (selectedOption) { <button type="button" class="file-chip-remove" onclick="toggleFileActive(${index})" title="${selectedFilesData[index].isActive ? 'Remove from upload' : 'Add back to upload'}">${selectedFilesData[index].isActive ? '❌' : '➕'}</button>
docTypeText = selectedOption.text; `;
}
}
chipElement.querySelector('.file-chip-doctype').textContent = docTypeText;
} }
} }
closeEditModal(); closeEditModal();
} }
function removeFileChip(index) {
if (selectedFilesData[index]) {
selectedFilesData[index].isActive = false;
const chipElement = document.querySelector(`.file-chip[data-index="${index}"]`);
if (chipElement) {
chipElement.classList.add('removed');
// Optionally disable edit/remove buttons on a "removed" chip
chipElement.querySelector('.file-chip-edit').disabled = true;
}
// Check if all files are removed to disable "Continue" button
const allRemoved = selectedFilesData.every(f => !f.isActive);
continueToStep3Button.disabled = allRemoved || selectedFilesData.length === 0;
}
}
// Function to restart the upload process // Function to restart the upload process
function restartUpload() { function restartUpload() {
// Reset the form and file input // Hide all sections except step 1
const form = document.getElementById('upload-form'); document.getElementById('step2').style.display = 'none';
form.reset(); document.getElementById('step3').style.display = 'none';
const fileInput = document.getElementById('document-files'); document.getElementById('step4').style.display = 'none';
if (fileInput) fileInput.value = ''; document.getElementById('restart-section').style.display = 'none';
// Remove any hidden jobNumbers inputs
form.querySelectorAll('input[name="jobNumbers"]').forEach(el => el.remove());
// Clear and hide the job IDs container // Clear results
const jobIdsContainer = document.getElementById('job-ids-container'); document.getElementById('upload-results').innerHTML = '';
jobIdsContainer.innerHTML = '';
jobIdsContainer.style.display = 'none';
// Clear and hide the CSV preview // Reset CSV preview if it exists
const csvPreview = document.getElementById('csv-preview'); const csvPreview = document.getElementById('csv-preview');
if (csvPreview) csvPreview.style.display = 'none'; if (csvPreview) {
const csvContent = document.getElementById('csv-preview-content'); csvPreview.style.display = 'none';
if (csvContent) csvContent.innerHTML = '<p>No jobs loaded yet</p>'; }
// Clear selected files data and UI
selectedFilesData = [];
const filesArea = document.getElementById('selected-files-area');
filesArea.innerHTML = '<p id="no-files-selected-placeholder">No files selected yet</p>';
// Clear upload results const csvPreviewContent = document.getElementById('csv-preview-content');
const uploadResults = document.getElementById('upload-results'); if (csvPreviewContent) {
if (uploadResults) uploadResults.innerHTML = ''; csvPreviewContent.innerHTML = '<p>No jobs loaded yet</p>';
}
// Hide steps 2, 3, and restart section // Reset job IDs container
document.getElementById('step2').style.display = 'none'; document.getElementById('job-ids-container').innerHTML = '';
document.getElementById('step3').style.display = 'none';
document.getElementById('restart-section').style.display = 'none';
// Show step 1 // Show step 1
document.getElementById('step1').style.display = 'block'; document.getElementById('step1').style.display = 'block';
// Disable the continue-to-step3 button // Reset any file inputs
if (continueToStep3Button) continueToStep3Button.disabled = true; const fileInput = document.getElementById('csv-file');
if (fileInput) {
fileInput.value = '';
}
const documentFilesInput = document.getElementById('document-files');
if (documentFilesInput) {
documentFilesInput.value = '';
}
// Reset selected files
selectedFilesData = [];
const filesArea = document.getElementById('selected-files-area');
if (filesArea) {
filesArea.innerHTML = '<p id="no-files-selected-placeholder">No files selected yet.</p>';
}
// Reset continue button
if (continueToStep3Button) {
continueToStep3Button.disabled = true;
}
}
// Add event listeners for the upload overlay
document.addEventListener('DOMContentLoaded', function () {
document.querySelectorAll('form').forEach(form => {
form.addEventListener('htmx:beforeRequest', function (evt) {
if (evt.detail.pathInfo.requestPath.includes('/upload-documents')) {
document.querySelector('.upload-overlay').style.display = 'flex';
} }
});
form.addEventListener('htmx:afterRequest', function (evt) {
if (evt.detail.pathInfo.requestPath.includes('/upload-documents')) {
document.querySelector('.upload-overlay').style.display = 'none';
// Show restart section after successful upload // If it's an upload action and successful, show step 4 and restart section
document.getElementById('upload-form').addEventListener('htmx:afterRequest', function (evt) { if (evt.detail.successful) {
if (evt.detail.pathInfo.requestPath === '/upload-documents' && evt.detail.successful) { document.getElementById('step4').style.display = 'block';
document.getElementById('restart-section').style.display = 'block'; document.getElementById('restart-section').style.display = 'block';
} }
}
});
form.addEventListener('htmx:error', function (evt) {
document.querySelector('.upload-overlay').style.display = 'none';
});
}); });
// Prepare metadata for backend on form submission // Prepare metadata for backend on form submission
@ -370,8 +383,16 @@
evt.detail.parameters['file_document_types'] = JSON.stringify(documentTypesArr); evt.detail.parameters['file_document_types'] = JSON.stringify(documentTypesArr);
evt.detail.parameters['file_is_active_flags'] = JSON.stringify(isActiveArr); evt.detail.parameters['file_is_active_flags'] = JSON.stringify(isActiveArr);
// Set the job numbers from the hidden input created by CSV processing
const jobIdsInput = document.querySelector('input[name="jobNumbers"]');
if (jobIdsInput) {
evt.detail.parameters['jobNumbers'] = jobIdsInput.value;
} else {
console.error('No jobNumbers input found. Make sure CSV was processed first.');
}
// 'documentFiles' will be sent by the browser from the <input type="file" name="documentFiles"> // 'documentFiles' will be sent by the browser from the <input type="file" name="documentFiles">
// 'jobNumbers' will be sent from the hidden input populated by the CSV step. });
}); });
</script> </script>
{{end}} {{end}}

23
templates/partials/removal_result_card.html

@ -35,7 +35,7 @@
{{if .Files}} {{if .Files}}
<div class="file-results"> <div class="file-results">
{{range .Files}} {{with index .Files 0}}
<div class="file-result {{if .Success}}success{{else}}error{{end}}"> <div class="file-result {{if .Success}}success{{else}}error{{end}}">
<span class="file-name">{{.Name}}</span> <span class="file-name">{{.Name}}</span>
{{if .Success}} {{if .Success}}
@ -46,6 +46,27 @@
{{end}} {{end}}
</div> </div>
{{end}} {{end}}
<div class="file-pagination-controls">
<span>File {{.FilePage}} of {{.TotalFiles}}</span>
<div class="file-pagination-buttons">
{{if gt .FilePage 1}}
<button
hx-get="/documents/remove/job/file?job_id={{.JobID}}&session_id={{.SessionID}}&file_page={{subtract .FilePage 1}}"
hx-target="#removal-card-{{.JobID}}" hx-swap="outerHTML" hx-indicator="false"
class="pagination-btn">
← Previous File
</button>
{{end}}
{{if lt .FilePage .TotalFiles}}
<button
hx-get="/documents/remove/job/file?job_id={{.JobID}}&session_id={{.SessionID}}&file_page={{add .FilePage 1}}"
hx-target="#removal-card-{{.JobID}}" hx-swap="outerHTML" hx-indicator="false"
class="pagination-btn">
Next File →
</button>
{{end}}
</div>
</div>
</div> </div>
{{end}} {{end}}
</div> </div>

9
templates/partials/removal_results.html

@ -6,7 +6,7 @@
</div> </div>
{{if gt .SuccessCount 0}} {{if gt .SuccessCount 0}}
<p>Successfully removed {{.SuccessCount}} document(s) from ServiceTrade in {{.TotalTime}}!</p> <p>Successfully removed {{.SuccessCount}} document(s) from ServiceTrade in {{formatDuration .TotalTime}}!</p>
{{end}} {{end}}
{{if gt .ErrorCount 0}} {{if gt .ErrorCount 0}}
<p class="text-warning">Failed to remove {{.ErrorCount}} document(s). See details below.</p> <p class="text-warning">Failed to remove {{.ErrorCount}} document(s). See details below.</p>
@ -28,14 +28,15 @@
{{if gt .CurrentPage 1}} {{if gt .CurrentPage 1}}
<button <button
hx-get="/documents/remove/results?page={{subtract .CurrentPage 1}}&limit={{.Limit}}&session_id={{.SessionID}}" hx-get="/documents/remove/results?page={{subtract .CurrentPage 1}}&limit={{.Limit}}&session_id={{.SessionID}}"
hx-target="#removal-results" class="pagination-btn"> hx-target="#removal-results" hx-indicator="false" class="pagination-btn">
← Previous ← Previous
</button> </button>
{{end}} {{end}}
{{range $i := sequence .StartPage .EndPage}} {{range $i := sequence .StartPage .EndPage}}
<button hx-get="/documents/remove/results?page={{$i}}&limit={{$.Limit}}&session_id={{$.SessionID}}" <button hx-get="/documents/remove/results?page={{$i}}&limit={{$.Limit}}&session_id={{$.SessionID}}"
hx-target="#removal-results" class="pagination-btn {{if eq $i $.CurrentPage}}active{{end}}"> hx-target="#removal-results" hx-indicator="false"
class="pagination-btn {{if eq $i $.CurrentPage}}active{{end}}">
{{$i}} {{$i}}
</button> </button>
{{end}} {{end}}
@ -43,7 +44,7 @@
{{if lt .CurrentPage .TotalPages}} {{if lt .CurrentPage .TotalPages}}
<button <button
hx-get="/documents/remove/results?page={{add .CurrentPage 1}}&limit={{.Limit}}&session_id={{.SessionID}}" hx-get="/documents/remove/results?page={{add .CurrentPage 1}}&limit={{.Limit}}&session_id={{.SessionID}}"
hx-target="#removal-results" class="pagination-btn"> hx-target="#removal-results" hx-indicator="false" class="pagination-btn">
Next → Next →
</button> </button>
{{end}} {{end}}

2
templates/partials/removal_stats.html

@ -17,7 +17,7 @@
<span class="stat-label">Files Processed</span> <span class="stat-label">Files Processed</span>
</div> </div>
<div class="stat-item"> <div class="stat-item">
<span class="stat-value">{{.TotalTime}}</span> <span class="stat-value">{{formatDuration .TotalTime}}</span>
<span class="stat-label">Total Time</span> <span class="stat-label">Total Time</span>
</div> </div>
</div> </div>

56
templates/partials/upload_result_card.html

@ -1,8 +1,7 @@
{{define "upload_result_card"}} {{define "upload_result_card"}}
<div id="upload-card-{{.JobID}}-{{.DocName}}" class="upload-result-card" data-job-id="{{.JobID}}" <div id="upload-card-{{.JobID}}" class="upload-result-card" data-job-id="{{.JobID}}">
data-doc-name="{{.DocName}}">
<div class="upload-header"> <div class="upload-header">
<h4>{{.DocName}}</h4> <h4>Job #{{.JobID}}</h4>
<div class="upload-status {{if .Success}}success{{else}}error{{end}}"> <div class="upload-status {{if .Success}}success{{else}}error{{end}}">
{{if .Success}}✓ Success{{else}}✗ Failed{{end}} {{if .Success}}✓ Success{{else}}✗ Failed{{end}}
</div> </div>
@ -10,12 +9,12 @@
<div class="upload-details"> <div class="upload-details">
<div class="upload-info"> <div class="upload-info">
<p><strong>Job ID:</strong> {{.JobID}}</p> <p><strong>Files Found:</strong> {{.FilesFound}}</p>
<p><strong>File Size:</strong> {{printf "%.2f MB" (div .FileSize 1048576.0)}}</p> <p><strong>Files Uploaded:</strong> {{.FilesUploaded}}</p>
{{if .Success}} {{if .Success}}
<p class="success-text">Successfully uploaded to ServiceTrade</p> <p class="success-text">Successfully processed</p>
{{else}} {{else}}
<p class="error-text">{{.Error}}</p> <p class="error-text">{{.ErrorMsg}}</p>
{{end}} {{end}}
</div> </div>
@ -33,6 +32,42 @@
{{end}} {{end}}
</div> </div>
</div> </div>
{{if .Files}}
<div class="file-results">
{{with index .Files 0}}
<div class="file-result {{if .Success}}success{{else}}error{{end}}">
<span class="file-name">{{.Name}}</span>
<span class="file-size">({{printf "%.2f MB" (div .FileSize 1048576.0)}})</span>
{{if .Success}}
<span class="success-icon"></span>
{{else}}
<span class="error-icon"></span>
<span class="error-message">{{.Error}}</span>
{{end}}
</div>
{{end}}
<div class="file-pagination-controls">
<span>File {{.FilePage}} of {{.TotalFiles}}</span>
<div class="file-pagination-buttons">
{{if gt .FilePage 1}}
<button
hx-get="/documents/upload/job/file?job_id={{.JobID}}&session_id={{.SessionID}}&file_page={{subtract .FilePage 1}}"
hx-target="#upload-card-{{.JobID}}" hx-swap="outerHTML" hx-indicator="false" class="pagination-btn">
← Previous File
</button>
{{end}}
{{if lt .FilePage .TotalFiles}}
<button
hx-get="/documents/upload/job/file?job_id={{.JobID}}&session_id={{.SessionID}}&file_page={{add .FilePage 1}}"
hx-target="#upload-card-{{.JobID}}" hx-swap="outerHTML" hx-indicator="false" class="pagination-btn">
Next File →
</button>
{{end}}
</div>
</div>
</div>
{{end}}
</div> </div>
{{end}} {{end}}
@ -87,14 +122,15 @@
{{if gt .CurrentPage 1}} {{if gt .CurrentPage 1}}
<button <button
hx-get="/documents/upload/results?page={{subtract .CurrentPage 1}}&limit={{.Limit}}&session_id={{.SessionID}}" hx-get="/documents/upload/results?page={{subtract .CurrentPage 1}}&limit={{.Limit}}&session_id={{.SessionID}}"
hx-target="#upload-results" class="pagination-btn"> hx-target="#upload-results" hx-indicator="false" class="pagination-btn">
← Previous ← Previous
</button> </button>
{{end}} {{end}}
{{range $i := sequence .StartPage .EndPage}} {{range $i := sequence .StartPage .EndPage}}
<button hx-get="/documents/upload/results?page={{$i}}&limit={{$.Limit}}&session_id={{$.SessionID}}" <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}}"> hx-target="#upload-results" hx-indicator="false"
class="pagination-btn {{if eq $i $.CurrentPage}}active{{end}}">
{{$i}} {{$i}}
</button> </button>
{{end}} {{end}}
@ -102,7 +138,7 @@
{{if lt .CurrentPage .TotalPages}} {{if lt .CurrentPage .TotalPages}}
<button <button
hx-get="/documents/upload/results?page={{add .CurrentPage 1}}&limit={{.Limit}}&session_id={{.SessionID}}" hx-get="/documents/upload/results?page={{add .CurrentPage 1}}&limit={{.Limit}}&session_id={{.SessionID}}"
hx-target="#upload-results" class="pagination-btn"> hx-target="#upload-results" hx-indicator="false" class="pagination-btn">
Next → Next →
</button> </button>
{{end}} {{end}}

9
templates/partials/upload_results_pagination.html

@ -6,7 +6,7 @@
</div> </div>
{{if gt .TotalSuccess 0}} {{if gt .TotalSuccess 0}}
<p>Successfully uploaded {{.TotalSuccess}} document(s) to ServiceTrade in {{.TotalTime}}!</p> <p>Successfully uploaded {{.TotalSuccess}} document(s) to ServiceTrade in {{formatDuration .TotalTime}}!</p>
{{end}} {{end}}
{{if gt .TotalFailure 0}} {{if gt .TotalFailure 0}}
<p class="text-warning">Failed to upload {{.TotalFailure}} document(s). See details below.</p> <p class="text-warning">Failed to upload {{.TotalFailure}} document(s). See details below.</p>
@ -28,14 +28,15 @@
{{if gt .CurrentPage 1}} {{if gt .CurrentPage 1}}
<button <button
hx-get="/documents/upload/results?page={{subtract .CurrentPage 1}}&limit={{.Limit}}&session_id={{.SessionID}}" hx-get="/documents/upload/results?page={{subtract .CurrentPage 1}}&limit={{.Limit}}&session_id={{.SessionID}}"
hx-target="#upload-results" class="pagination-btn"> hx-target="#upload-results" hx-indicator="false" class="pagination-btn">
← Previous ← Previous
</button> </button>
{{end}} {{end}}
{{range $i := sequence .StartPage .EndPage}} {{range $i := sequence .StartPage .EndPage}}
<button hx-get="/documents/upload/results?page={{$i}}&limit={{$.Limit}}&session_id={{$.SessionID}}" <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}}"> hx-target="#upload-results" hx-indicator="false"
class="pagination-btn {{if eq $i $.CurrentPage}}active{{end}}">
{{$i}} {{$i}}
</button> </button>
{{end}} {{end}}
@ -43,7 +44,7 @@
{{if lt .CurrentPage .TotalPages}} {{if lt .CurrentPage .TotalPages}}
<button <button
hx-get="/documents/upload/results?page={{add .CurrentPage 1}}&limit={{.Limit}}&session_id={{.SessionID}}" hx-get="/documents/upload/results?page={{add .CurrentPage 1}}&limit={{.Limit}}&session_id={{.SessionID}}"
hx-target="#upload-results" class="pagination-btn"> hx-target="#upload-results" hx-indicator="false" class="pagination-btn">
Next → Next →
</button> </button>
{{end}} {{end}}

2
templates/partials/upload_stats.html

@ -17,7 +17,7 @@
<span class="stat-label">MB Uploaded</span> <span class="stat-label">MB Uploaded</span>
</div> </div>
<div class="stat-item"> <div class="stat-item">
<span class="stat-value">{{.TotalTime}}</span> <span class="stat-value">{{formatDuration .TotalTime}}</span>
<span class="stat-label">Total Time</span> <span class="stat-label">Total Time</span>
</div> </div>
</div> </div>

17
web_templates.go

@ -2,10 +2,12 @@ package root
import ( import (
"embed" "embed"
"fmt"
"html/template" "html/template"
"io/fs" "io/fs"
"log" "log"
"path/filepath" "path/filepath"
"time"
) )
//go:embed templates static/* //go:embed templates static/*
@ -38,6 +40,21 @@ var funcMap = template.FuncMap{
} }
return result return result
}, },
"formatDuration": func(d time.Duration) string {
seconds := d.Seconds()
if seconds < 60 {
return fmt.Sprintf("%.2fs", seconds)
} else if seconds < 3600 {
minutes := int(seconds) / 60
remainingSeconds := seconds - float64(minutes*60)
return fmt.Sprintf("%dm %.2fs", minutes, remainingSeconds)
} else {
hours := int(seconds) / 3600
remainingMinutes := int(seconds) % 3600 / 60
remainingSeconds := seconds - float64(hours*3600) - float64(remainingMinutes*60)
return fmt.Sprintf("%dh %dm %.2fs", hours, remainingMinutes, remainingSeconds)
}
},
} }
// InitializeWebTemplates parses all HTML templates in the embedded filesystem // InitializeWebTemplates parses all HTML templates in the embedded filesystem

Loading…
Cancel
Save