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("/upload-documents", web.UploadDocumentsHandler).Methods("POST")
protected.HandleFunc("/documents/upload/results", web.UploadResultsHandler).Methods("GET")
protected.HandleFunc("/documents/upload/job/file", web.UploadJobFileHandler).Methods("GET")
// Document removal routes
protected.HandleFunc("/documents/remove", web.DocumentRemoveHandler).Methods("GET")
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/file", web.RemovalJobFileHandler).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/bulk", web.BulkRemoveDocumentsHandler).Methods("POST")

181
internal/handlers/web/document_remove.go

@ -15,6 +15,7 @@ import (
root "marmic/servicetrade-toolbox"
"marmic/servicetrade-toolbox/internal/api"
"marmic/servicetrade-toolbox/internal/middleware"
"marmic/servicetrade-toolbox/internal/utils"
"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)
removalSessions[sessionID] = removalSession
// Return first page of results
renderRemovalResultsPage(w, sessionID, 1, 20) // Default to page 1, 20 items per page
// Get configurable page size from form, with fallback to default
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
@ -801,12 +815,12 @@ func RemovalResultsHandler(w http.ResponseWriter, r *http.Request) {
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
if page < 1 {
page = 1
page = utils.DefaultPage
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit < 1 {
limit = 20
limit = utils.DefaultPageSize
}
renderRemovalResultsPage(w, sessionID, page, limit)
@ -821,53 +835,48 @@ func renderRemovalResultsPage(w http.ResponseWriter, sessionID string, page, lim
}
totalResults := len(removalSession.Results)
totalPages := (totalResults + limit - 1) / limit // Ceiling division
if page > totalPages && totalPages > 0 {
page = totalPages
}
pagination := utils.CalculatePagination(totalResults, page, limit)
startIndex := (page - 1) * limit
endIndex := startIndex + limit
// Get results for this page
startIndex := (pagination.CurrentPage - 1) * pagination.Limit
endIndex := startIndex + pagination.Limit
if endIndex > totalResults {
endIndex = totalResults
}
// 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 {
startPage = page - 4
endPage = page + 5
pageResults := utils.GetPageResults(removalSession.Results, startIndex, endIndex)
// Add pagination info to each job result for the template
var resultsWithPagination []map[string]interface{}
for _, jobResult := range pageResults {
resultMap := map[string]interface{}{
"JobID": jobResult.JobID,
"FilesFound": jobResult.FilesFound,
"FilesRemoved": jobResult.FilesRemoved,
"Success": jobResult.Success,
"ErrorMsg": jobResult.ErrorMsg,
"Files": jobResult.Files,
"FilePage": 1, // Default to first file
"TotalFiles": len(jobResult.Files),
"SessionID": sessionID,
}
resultsWithPagination = append(resultsWithPagination, resultMap)
}
data := map[string]interface{}{
"Results": pageResults,
"Results": resultsWithPagination,
"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,
"TotalResults": pagination.TotalResults,
"TotalPages": pagination.TotalPages,
"CurrentPage": pagination.CurrentPage,
"Limit": pagination.Limit,
"StartIndex": pagination.StartIndex,
"EndIndex": pagination.EndIndex,
"StartPage": pagination.StartPage,
"EndPage": pagination.EndPage,
"SessionID": sessionID,
}
@ -903,3 +912,99 @@ func matchesAnyPattern(s string, patterns []string) bool {
}
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"
"marmic/servicetrade-toolbox/internal/api"
"marmic/servicetrade-toolbox/internal/middleware"
"marmic/servicetrade-toolbox/internal/utils"
)
// UploadResult represents the result of a single file upload
@ -32,6 +33,19 @@ type UploadResult struct {
// UploadSession stores upload results for pagination
type UploadSession struct {
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
TotalSuccess 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",
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
sessionID := fmt.Sprintf("upload_%d", time.Now().UnixNano())
uploadSession := UploadSession{
Results: allResults,
Results: allResults, // Keep original results for backward compatibility
GroupedResults: groupedResults,
TotalJobs: len(jobs),
TotalSuccess: 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)
uploadSessions[sessionID] = uploadSession
// Return first page of results
renderUploadResultsPage(w, sessionID, 1, 20) // Default to page 1, 20 items per page
// Get configurable page size from form, with fallback to default
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
@ -483,12 +599,12 @@ func UploadResultsHandler(w http.ResponseWriter, r *http.Request) {
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
if page < 1 {
page = 1
page = utils.DefaultPage
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit < 1 {
limit = 20
limit = utils.DefaultPageSize
}
renderUploadResultsPage(w, sessionID, page, limit)
@ -502,54 +618,49 @@ func renderUploadResultsPage(w http.ResponseWriter, sessionID string, page, limi
return
}
totalResults := len(uploadSession.Results)
totalPages := (totalResults + limit - 1) / limit // Ceiling division
if page > totalPages && totalPages > 0 {
page = totalPages
}
totalResults := len(uploadSession.GroupedResults)
pagination := utils.CalculatePagination(totalResults, page, limit)
startIndex := (page - 1) * limit
endIndex := startIndex + limit
// Get results for this page
startIndex := (pagination.CurrentPage - 1) * pagination.Limit
endIndex := startIndex + pagination.Limit
if endIndex > totalResults {
endIndex = totalResults
}
// Get results for this page
var pageResults []UploadResult
if startIndex < totalResults {
pageResults = uploadSession.Results[startIndex:endIndex]
}
// Calculate pagination info
startPage := 1
endPage := totalPages
if totalPages > 10 {
if page <= 5 {
endPage = 10
} else if page >= totalPages-4 {
startPage = totalPages - 9
} else {
startPage = page - 4
endPage = page + 5
pageResults := utils.GetPageResults(uploadSession.GroupedResults, startIndex, endIndex)
// Add pagination info to each job result for the template
var resultsWithPagination []map[string]interface{}
for _, jobResult := range pageResults {
resultMap := map[string]interface{}{
"JobID": jobResult.JobID,
"FilesFound": jobResult.FilesFound,
"FilesUploaded": jobResult.FilesUploaded,
"Success": jobResult.Success,
"ErrorMsg": jobResult.ErrorMsg,
"Files": jobResult.Files,
"FilePage": 1, // Default to first file
"TotalFiles": len(jobResult.Files),
"SessionID": sessionID,
}
resultsWithPagination = append(resultsWithPagination, resultMap)
}
data := map[string]interface{}{
"Results": pageResults,
"Results": resultsWithPagination,
"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,
"TotalResults": pagination.TotalResults,
"TotalPages": pagination.TotalPages,
"CurrentPage": pagination.CurrentPage,
"Limit": pagination.Limit,
"StartIndex": pagination.StartIndex,
"EndIndex": pagination.EndIndex,
"StartPage": pagination.StartPage,
"EndPage": pagination.EndPage,
"SessionID": sessionID,
}
@ -588,3 +699,124 @@ func (r *readCloserWithSize) Size() int64 {
// DocumentFieldAddHandler and DocumentFieldRemoveHandler are REMOVED
// 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;
align-items: center;
z-index: 1000;
border-radius: inherit;
border-radius: 0.5rem;
backdrop-filter: blur(2px);
}
@ -804,3 +804,70 @@
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"}}
<h2>Document Uploads</h2>
<form id="upload-form" hx-post="/upload-documents" hx-encoding="multipart/form-data" hx-target="#upload-results"
hx-indicator=".upload-overlay">
<div class="upload-container">
<!-- Upload overlay -->
<div class="upload-container">
<!-- Upload overlay - moved outside the form -->
<div class="upload-overlay htmx-indicator">
<div class="upload-overlay-content">
<div class="overlay-spinner"></div>
@ -69,18 +66,29 @@
</button>
</div>
<!-- Step 3: Submit -->
<!-- Step 3: Submit - form moved here -->
<div id="step3" class="content" style="display: none;">
<h3 class="submenu-header">Step 3: Submit Uploads</h3>
<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>
</form>
<div id="upload-loading-indicator" class="htmx-indicator">
<span>Uploading...</span>
<div class="loading-indicator"></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>
@ -89,8 +97,7 @@
<h3 class="submenu-header">Upload Complete</h3>
<button type="button" class="btn-primary" hx-on:click="restartUpload()">Start New Upload</button>
</div>
</div>
</form>
</div>
<!-- Edit File Modal (Initially Hidden) -->
<div id="editFileModal" class="modal" style="display:none;">
@ -200,39 +207,40 @@
const icon = getFileIcon(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 = `
<span class="file-chip-icon">${icon}</span>
<span class="file-chip-name" title="${fileMetadata.displayName}" onclick="openEditModal(${index})">${truncatedName}</span>
<span class="file-chip-doctype">${docTypeText}</span>
<button type="button" class="file-chip-edit" onclick="openEditModal(${index})">✏️</button>
<button type="button" class="file-chip-remove" onclick="removeFileChip(${index})">&times;</button>
<span class="file-chip-name" title="${fileMetadata.displayName}">${truncatedName}</span>
<span class="file-chip-doctype">Type: ${fileMetadata.documentType}</span>
<button type="button" class="file-chip-edit" onclick="openEditModal(${index})" title="Edit file details">✏️</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);
}
function openEditModal(index) {
const fileMetadata = selectedFilesData[index];
if (!fileMetadata || !fileMetadata.isActive) return; // Don't edit removed or non-existent files
function toggleFileActive(index) {
if (index >= 0 && index < selectedFilesData.length) {
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('editDisplayName').value = fileMetadata.displayName;
document.getElementById('editDocumentType').value = fileMetadata.documentType;
// 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';
document.getElementById('editDisplayName').value = fileData.displayName;
document.getElementById('editDocumentType').value = fileData.documentType;
document.getElementById('editFileModal').style.display = 'block';
}
}
function closeEditModal() {
@ -241,99 +249,104 @@
function saveFileChanges() {
const index = parseInt(document.getElementById('editFileOriginalIndex').value);
const newDisplayName = document.getElementById('editDisplayName').value;
const newDocumentType = document.getElementById('editDocumentType').value;
if (selectedFilesData[index]) {
selectedFilesData[index].displayName = newDisplayName;
selectedFilesData[index].documentType = newDocumentType;
if (index >= 0 && index < selectedFilesData.length) {
selectedFilesData[index].displayName = document.getElementById('editDisplayName').value;
selectedFilesData[index].documentType = document.getElementById('editDocumentType').value;
// Re-render the chip's display name, icon, and doc type
const chipElement = document.querySelector(`.file-chip[data-index="${index}"]`);
if (chipElement) {
const truncatedName = truncateFilename(newDisplayName);
chipElement.querySelector('.file-chip-name').textContent = truncatedName;
chipElement.querySelector('.file-chip-name').title = newDisplayName;
chipElement.querySelector('.file-chip-icon').textContent = getFileIcon(newDisplayName);
// Re-render the chip
const chip = document.querySelector(`[data-index="${index}"]`);
if (chip) {
const icon = getFileIcon(selectedFilesData[index].displayName);
const truncatedName = truncateFilename(selectedFilesData[index].displayName);
// Update doc type text on chip
const docTypeSelect = document.getElementById('editDocumentType');
let docTypeText = '';
if (docTypeSelect) {
const selectedOption = Array.from(docTypeSelect.options).find(opt => opt.value === newDocumentType);
if (selectedOption) {
docTypeText = selectedOption.text;
}
}
chipElement.querySelector('.file-chip-doctype').textContent = docTypeText;
chip.innerHTML = `
<span class="file-chip-icon">${icon}</span>
<span class="file-chip-name" title="${selectedFilesData[index].displayName}">${truncatedName}</span>
<span class="file-chip-doctype">Type: ${selectedFilesData[index].documentType}</span>
<button type="button" class="file-chip-edit" onclick="openEditModal(${index})" title="Edit file details">✏️</button>
<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>
`;
}
}
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 restartUpload() {
// Reset the form and file input
const form = document.getElementById('upload-form');
form.reset();
const fileInput = document.getElementById('document-files');
if (fileInput) fileInput.value = '';
// Remove any hidden jobNumbers inputs
form.querySelectorAll('input[name="jobNumbers"]').forEach(el => el.remove());
// Hide all sections except step 1
document.getElementById('step2').style.display = 'none';
document.getElementById('step3').style.display = 'none';
document.getElementById('step4').style.display = 'none';
document.getElementById('restart-section').style.display = 'none';
// Clear and hide the job IDs container
const jobIdsContainer = document.getElementById('job-ids-container');
jobIdsContainer.innerHTML = '';
jobIdsContainer.style.display = 'none';
// Clear results
document.getElementById('upload-results').innerHTML = '';
// Clear and hide the CSV preview
// Reset CSV preview if it exists
const csvPreview = document.getElementById('csv-preview');
if (csvPreview) csvPreview.style.display = 'none';
const csvContent = document.getElementById('csv-preview-content');
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>';
if (csvPreview) {
csvPreview.style.display = 'none';
}
// Clear upload results
const uploadResults = document.getElementById('upload-results');
if (uploadResults) uploadResults.innerHTML = '';
const csvPreviewContent = document.getElementById('csv-preview-content');
if (csvPreviewContent) {
csvPreviewContent.innerHTML = '<p>No jobs loaded yet</p>';
}
// Hide steps 2, 3, and restart section
document.getElementById('step2').style.display = 'none';
document.getElementById('step3').style.display = 'none';
document.getElementById('restart-section').style.display = 'none';
// Reset job IDs container
document.getElementById('job-ids-container').innerHTML = '';
// Show step 1
document.getElementById('step1').style.display = 'block';
// Disable the continue-to-step3 button
if (continueToStep3Button) continueToStep3Button.disabled = true;
// Reset any file inputs
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
document.getElementById('upload-form').addEventListener('htmx:afterRequest', function (evt) {
if (evt.detail.pathInfo.requestPath === '/upload-documents' && evt.detail.successful) {
// If it's an upload action and successful, show step 4 and restart section
if (evt.detail.successful) {
document.getElementById('step4').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
@ -370,8 +383,16 @@
evt.detail.parameters['file_document_types'] = JSON.stringify(documentTypesArr);
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">
// 'jobNumbers' will be sent from the hidden input populated by the CSV step.
});
});
</script>
{{end}}

23
templates/partials/removal_result_card.html

@ -35,7 +35,7 @@
{{if .Files}}
<div class="file-results">
{{range .Files}}
{{with index .Files 0}}
<div class="file-result {{if .Success}}success{{else}}error{{end}}">
<span class="file-name">{{.Name}}</span>
{{if .Success}}
@ -46,6 +46,27 @@
{{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/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>
{{end}}
</div>

9
templates/partials/removal_results.html

@ -6,7 +6,7 @@
</div>
{{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}}
{{if gt .ErrorCount 0}}
<p class="text-warning">Failed to remove {{.ErrorCount}} document(s). See details below.</p>
@ -28,14 +28,15 @@
{{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">
hx-target="#removal-results" hx-indicator="false" class="pagination-btn">
← Previous
</button>
{{end}}
{{range $i := sequence .StartPage .EndPage}}
<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}}
</button>
{{end}}
@ -43,7 +44,7 @@
{{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">
hx-target="#removal-results" hx-indicator="false" class="pagination-btn">
Next →
</button>
{{end}}

2
templates/partials/removal_stats.html

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

56
templates/partials/upload_result_card.html

@ -1,8 +1,7 @@
{{define "upload_result_card"}}
<div id="upload-card-{{.JobID}}-{{.DocName}}" class="upload-result-card" data-job-id="{{.JobID}}"
data-doc-name="{{.DocName}}">
<div id="upload-card-{{.JobID}}" class="upload-result-card" data-job-id="{{.JobID}}">
<div class="upload-header">
<h4>{{.DocName}}</h4>
<h4>Job #{{.JobID}}</h4>
<div class="upload-status {{if .Success}}success{{else}}error{{end}}">
{{if .Success}}✓ Success{{else}}✗ Failed{{end}}
</div>
@ -10,12 +9,12 @@
<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>
<p><strong>Files Found:</strong> {{.FilesFound}}</p>
<p><strong>Files Uploaded:</strong> {{.FilesUploaded}}</p>
{{if .Success}}
<p class="success-text">Successfully uploaded to ServiceTrade</p>
<p class="success-text">Successfully processed</p>
{{else}}
<p class="error-text">{{.Error}}</p>
<p class="error-text">{{.ErrorMsg}}</p>
{{end}}
</div>
@ -33,6 +32,42 @@
{{end}}
</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>
{{end}}
@ -87,14 +122,15 @@
{{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">
hx-target="#upload-results" hx-indicator="false" 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}}">
hx-target="#upload-results" hx-indicator="false"
class="pagination-btn {{if eq $i $.CurrentPage}}active{{end}}">
{{$i}}
</button>
{{end}}
@ -102,7 +138,7 @@
{{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">
hx-target="#upload-results" hx-indicator="false" class="pagination-btn">
Next →
</button>
{{end}}

9
templates/partials/upload_results_pagination.html

@ -6,7 +6,7 @@
</div>
{{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}}
{{if gt .TotalFailure 0}}
<p class="text-warning">Failed to upload {{.TotalFailure}} document(s). See details below.</p>
@ -28,14 +28,15 @@
{{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">
hx-target="#upload-results" hx-indicator="false" 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}}">
hx-target="#upload-results" hx-indicator="false"
class="pagination-btn {{if eq $i $.CurrentPage}}active{{end}}">
{{$i}}
</button>
{{end}}
@ -43,7 +44,7 @@
{{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">
hx-target="#upload-results" hx-indicator="false" class="pagination-btn">
Next →
</button>
{{end}}

2
templates/partials/upload_stats.html

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

17
web_templates.go

@ -2,10 +2,12 @@ package root
import (
"embed"
"fmt"
"html/template"
"io/fs"
"log"
"path/filepath"
"time"
)
//go:embed templates static/*
@ -38,6 +40,21 @@ var funcMap = template.FuncMap{
}
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

Loading…
Cancel
Save