Browse Source

chore: style updates for document uploads

document-upload-removal-layout-update
nic 5 months ago
parent
commit
1af2a8af2a
  1. 177
      internal/handlers/web/documents.go
  2. 28
      static/css/styles.css
  3. 162
      static/css/upload.css
  4. 92
      templates/partials/upload_result_card.html
  5. 7
      templates/partials/upload_results_pagination.html
  6. 14
      templates/partials/upload_stats.html

177
internal/handlers/web/documents.go

@ -586,7 +586,7 @@ func UploadDocumentsHandler(w http.ResponseWriter, r *http.Request) {
}
// Return first page of results with configurable page size
renderUploadResultsPage(w, sessionID, utils.DefaultPage, limit)
renderUploadResultsPage(w, sessionID, utils.DefaultPage, limit, "all")
}
// UploadResultsHandler handles pagination for upload results
@ -607,18 +607,93 @@ func UploadResultsHandler(w http.ResponseWriter, r *http.Request) {
limit = utils.DefaultPageSize
}
renderUploadResultsPage(w, sessionID, page, limit)
// Optional filter: all|success|failed
filter := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("filter")))
if filter != "success" && filter != "failed" {
filter = "all"
}
renderUploadResultsPage(w, sessionID, page, limit, filter)
}
// renderUploadResultsPage renders a paginated page of upload results
func renderUploadResultsPage(w http.ResponseWriter, sessionID string, page, limit int) {
func renderUploadResultsPage(w http.ResponseWriter, sessionID string, page, limit int, filter string) {
uploadSession, exists := uploadSessions[sessionID]
if !exists {
http.Error(w, "Upload session not found", http.StatusNotFound)
return
}
totalResults := len(uploadSession.GroupedResults)
// Build a filtered view per job at the file level
var filteredJobs []struct {
JobID string
FilesFound int
FilesUploaded int
Success bool
ErrorMsg string
Files []struct {
Name string
Success bool
Error string
FileSize int64
}
}
for _, jr := range uploadSession.GroupedResults {
// Filter files per job
var files []struct {
Name string
Success bool
Error string
FileSize int64
}
for _, f := range jr.Files {
if filter == "success" && !f.Success {
continue
}
if filter == "failed" && f.Success {
continue
}
files = append(files, f)
}
if len(files) == 0 {
// Skip jobs that have no files matching the filter
if filter != "all" {
continue
}
}
// Copy job with filtered files
copy := struct {
JobID string
FilesFound int
FilesUploaded int
Success bool
ErrorMsg string
Files []struct {
Name string
Success bool
Error string
FileSize int64
}
}{
JobID: jr.JobID,
FilesFound: jr.FilesFound,
FilesUploaded: jr.FilesUploaded,
Success: jr.Success,
ErrorMsg: jr.ErrorMsg,
Files: func() []struct {
Name string
Success bool
Error string
FileSize int64
} {
return files
}(),
}
filteredJobs = append(filteredJobs, copy)
}
totalResults := len(filteredJobs)
pagination := utils.CalculatePagination(totalResults, page, limit)
// Get results for this page
@ -627,21 +702,25 @@ func renderUploadResultsPage(w http.ResponseWriter, sessionID string, page, limi
if endIndex > totalResults {
endIndex = totalResults
}
pageResults := utils.GetPageResults(uploadSession.GroupedResults, startIndex, endIndex)
pageResults := utils.GetPageResults(filteredJobs, startIndex, endIndex)
// Add pagination info to each job result for the template
var resultsWithPagination []map[string]interface{}
for _, jobResult := range pageResults {
filesLen := len(jobResult.Files)
displaySuccess := (filter == "success") || (filter != "failed" && jobResult.Success)
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,
"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": filesLen,
"SessionID": sessionID,
"Filter": filter,
"DisplaySuccess": displaySuccess,
}
resultsWithPagination = append(resultsWithPagination, resultMap)
}
@ -662,13 +741,12 @@ func renderUploadResultsPage(w http.ResponseWriter, sessionID string, page, limi
"StartPage": pagination.StartPage,
"EndPage": pagination.EndPage,
"SessionID": sessionID,
"Filter": filter,
}
tmpl := root.WebTemplates
if err := tmpl.ExecuteTemplate(w, "upload_results_pagination", data); err != nil {
log.Printf("Template execution error: %v", err)
// Don't call http.Error here as the response may have already started
// Just log the error and return
return
}
}
@ -692,7 +770,6 @@ func (r *readCloserWithSize) Close() error {
return nil // Allow closing nil reader safely
}
// Size returns the current size of data read
func (r *readCloserWithSize) Size() int64 {
return r.size
}
@ -711,6 +788,10 @@ func UploadJobFileHandler(w http.ResponseWriter, r *http.Request) {
filePage = parsed
}
}
filter := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("filter")))
if filter != "success" && filter != "failed" {
filter = "all"
}
uploadSession, exists := uploadSessions[sessionID]
if !exists {
@ -743,19 +824,37 @@ func UploadJobFileHandler(w http.ResponseWriter, r *http.Request) {
return
}
totalFiles := len(jobResult.Files)
// Apply file-level filter
var filteredFiles []struct {
Name string
Success bool
Error string
FileSize int64
}
for _, f := range jobResult.Files {
if filter == "success" && !f.Success {
continue
}
if filter == "failed" && f.Success {
continue
}
filteredFiles = append(filteredFiles, f)
}
totalFiles := len(filteredFiles)
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,
"JobID": jobResult.JobID,
"FilesFound": jobResult.FilesFound,
"FilesUploaded": jobResult.FilesUploaded,
"Success": jobResult.Success,
"ErrorMsg": jobResult.ErrorMsg,
"Files": nil,
"FilePage": 1,
"TotalFiles": 0,
"SessionID": sessionID,
"Filter": filter,
"DisplaySuccess": (filter == "success") || (filter != "failed" && jobResult.Success),
}
tmpl := root.WebTemplates
@ -798,20 +897,22 @@ func UploadJobFileHandler(w http.ResponseWriter, r *http.Request) {
Success bool
Error string
FileSize int64
}{jobResult.Files[filePage-1]},
}{filteredFiles[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,
"JobID": jobResultCopy.JobID,
"FilesFound": jobResultCopy.FilesFound,
"FilesUploaded": jobResultCopy.FilesUploaded,
"Success": jobResultCopy.Success,
"ErrorMsg": jobResultCopy.ErrorMsg,
"Files": jobResultCopy.Files,
"FilePage": filePage,
"TotalFiles": totalFiles,
"SessionID": sessionID,
"Filter": filter,
"DisplaySuccess": (filter == "success") || (filter != "failed" && jobResult.Success),
}
tmpl := root.WebTemplates

28
static/css/styles.css

@ -95,6 +95,9 @@ html {
color: var(--text-color);
}
/* Reserve vertical scrollbar space to prevent layout shift when content height changes */
/* Removed scrollbar-gutter reservation to avoid visual misalignment */
.flex {
display: flex;
}
@ -559,6 +562,10 @@ html {
.error::before {
content: "⚠️";
display: inline-block;
width: 1em;
/* reserve horizontal space so layout doesn't shift */
text-align: center;
}
.not-found::before {
@ -1736,9 +1743,10 @@ html {
/* Upload Result Cards */
.upload-results-container {
background-color: var(--dashboard-bg);
background-color: var(--upload-card-bg);
border: var(--upload-card-border);
border-radius: 0.5rem;
box-shadow: var(--dashboard-shadow);
box-shadow: var(--upload-card-shadow);
padding: 1.5rem;
margin-top: 1rem;
max-width: 100%;
@ -1763,7 +1771,8 @@ html {
}
.stat-item {
background-color: rgba(30, 33, 43, 0.8);
background-color: var(--upload-stat-bg);
border: var(--upload-stat-border);
border-radius: 0.5rem;
padding: 1.25rem;
text-align: center;
@ -1773,9 +1782,7 @@ html {
justify-content: center;
}
.stat-item:last-child {
grid-column: 1;
}
/* Do not force the last item into column 1; allow natural placement */
.stat-value {
font-size: 2.5rem;
@ -1800,13 +1807,14 @@ html {
.upload-results-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1rem;
margin-top: 1.5rem;
}
.upload-result-card {
background-color: rgba(30, 33, 43, 0.8);
background-color: var(--upload-card-bg);
border: var(--upload-card-border);
border-radius: 0.5rem;
padding: 1.25rem;
display: flex;
@ -1833,6 +1841,10 @@ html {
padding: 0.25rem 0.75rem;
border-radius: 1rem;
font-size: 0.875rem;
display: inline-flex;
align-items: center;
gap: 0.25rem;
white-space: nowrap;
}
.upload-status.success {

162
static/css/upload.css

@ -288,21 +288,23 @@
/* Upload Results Container */
.upload-results-container {
background-color: var(--upload-card-bg);
border: var(--upload-card-border);
border-radius: 0.5rem;
box-shadow: var(--upload-card-shadow);
padding: 1.5rem;
margin-top: 1rem;
max-width: 100%;
width: 100%;
box-sizing: border-box;
overflow-x: hidden;
/* Revert: avoid forcing gutter here to prevent content width reduction */
}
.upload-results-header {
margin-bottom: 1.5rem;
}
.upload-results-header h3 {
color: var(--text-color);
font-size: 1.5rem;
margin: 0 0 1rem 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
/* Upload Stats Grid */
@ -312,10 +314,16 @@
gap: 1rem;
margin-bottom: 1.5rem;
max-width: 600px;
width: 100%;
box-sizing: border-box;
padding: 0 0.5rem;
/* ensure visual breathing room on both sides */
}
/* Stat item card styling (works in light and dark via CSS variables) */
.stat-item {
background-color: var(--upload-stat-bg);
border: var(--upload-stat-border);
border-radius: 0.5rem;
padding: 1.25rem;
text-align: center;
@ -326,10 +334,6 @@
min-width: 120px;
}
.stat-item:last-child {
grid-column: 1;
}
.stat-value {
font-size: 2.5rem;
font-weight: bold;
@ -344,94 +348,17 @@
text-align: center;
}
/* Stat item variants */
.success-stat .stat-value {
color: var(--upload-success-color);
}
.error-stat .stat-value {
color: var(--upload-error-color);
}
.warning-stat .stat-value {
color: var(--upload-warning-color);
}
/* Upload Results Grid */
.upload-results-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1rem;
margin-top: 1.5rem;
}
.upload-result-card {
background-color: var(--upload-card-bg);
border-radius: 0.5rem;
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.upload-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.upload-header h4 {
margin: 0;
color: var(--text-color);
font-size: 1rem;
font-weight: 500;
flex: 1;
}
.upload-status {
padding: 0.25rem 0.75rem;
border-radius: 1rem;
font-size: 0.875rem;
font-weight: 500;
}
.upload-status.success {
background-color: var(--upload-stat-success-bg);
color: var(--upload-success-color);
}
.upload-status.error {
background-color: var(--upload-stat-error-bg);
color: var(--upload-error-color);
}
.upload-details {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.upload-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.upload-info p {
margin: 0;
font-size: 0.875rem;
color: var(--content-text);
}
.success-text {
color: var(--upload-success-color);
font-weight: 500;
/* Clickable stat items (success/failed) */
.upload-stats .success-stat,
.upload-stats .error-stat,
.upload-stats .all-stat {
cursor: pointer;
}
.error-text {
color: var(--upload-error-color);
font-weight: 500;
.upload-stats .success-stat:hover,
.upload-stats .error-stat:hover,
.upload-stats .all-stat:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, .12);
}
/* Pagination Controls */
@ -442,6 +369,7 @@
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid var(--content-border);
overflow-x: auto;
}
.pagination-info {
@ -453,6 +381,35 @@
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
}
/* Prevent long error text from blowing out cards */
.clamp-text {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 5;
overflow: hidden;
word-break: break-word;
white-space: pre-wrap;
position: relative;
}
/* show pointer and underline hint on hover */
.error-text.clamp-text,
.error-message.clamp-text {
cursor: pointer;
}
.error-text.clamp-text:hover,
.error-message.clamp-text:hover {
text-decoration: underline dotted;
}
/* When expanded, remove clamping */
.clamp-text.expanded {
-webkit-line-clamp: initial;
overflow: visible;
}
.pagination-btn {
@ -565,9 +522,12 @@
border-color: rgba(34, 197, 94, 0.3);
}
/* Error bubble refinement */
.file-result.error {
background-color: var(--upload-stat-error-bg);
border-color: rgba(239, 68, 68, 0.3);
border-radius: 0.5rem;
/* rounder corners like old version */
}
.status-icon {
@ -871,4 +831,14 @@
width: 100%;
justify-content: center;
}
}
/* Ensure the results grid has inner gutters and doesn't touch the edges */
.upload-results-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1rem;
margin-top: 1.5rem;
padding: 0 0.5rem;
box-sizing: border-box;
}

92
templates/partials/upload_result_card.html

@ -2,8 +2,8 @@
<div id="upload-card-{{.JobID}}" class="upload-result-card" data-job-id="{{.JobID}}">
<div class="upload-header">
<h4>Job #{{.JobID}}</h4>
<div class="upload-status {{if .Success}}success{{else}}error{{end}}">
{{if .Success}}✓ Success{{else}}✗ Failed{{end}}
<div class="upload-status {{if .DisplaySuccess}}success{{else}}error{{end}}">
{{if .DisplaySuccess}}✓ Success{{else}}✗ Failed{{end}}
</div>
</div>
@ -11,7 +11,7 @@
<div class="upload-info">
<p><strong>Files Found:</strong> {{.FilesFound}}</p>
<p><strong>Files Uploaded:</strong> {{.FilesUploaded}}</p>
{{if .Success}}
{{if .DisplaySuccess}}
<p class="success-text">Successfully processed</p>
{{else}}
<p class="error-text">{{.ErrorMsg}}</p>
@ -19,7 +19,7 @@
</div>
<div class="upload-actions">
{{if .Success}}
{{if .DisplaySuccess}}
<div class="success-indicator">
<span class="icon"></span>
<span>Upload Complete</span>
@ -43,7 +43,8 @@
<span class="success-icon"></span>
{{else}}
<span class="error-icon"></span>
<span class="error-message">{{.Error}}</span>
<span class="error-message clamp-text" data-full-text="{{.Error}}"
onclick="this.classList.toggle('expanded')">{{.Error}}</span>
{{end}}
</div>
{{end}}
@ -52,14 +53,14 @@
<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-get="/documents/upload/job/file?job_id={{.JobID}}&session_id={{.SessionID}}&file_page={{subtract .FilePage 1}}&filter={{.Filter}}"
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-get="/documents/upload/job/file?job_id={{.JobID}}&session_id={{.SessionID}}&file_page={{add .FilePage 1}}&filter={{.Filter}}"
hx-target="#upload-card-{{.JobID}}" hx-swap="outerHTML" hx-indicator="false" class="pagination-btn">
Next File →
</button>
@ -69,81 +70,4 @@
</div>
{{end}}
</div>
{{end}}
{{define "upload_results_pagination"}}
<div class="upload-results-container">
<div class="upload-results-header">
<h3>Upload Results</h3>
<div class="upload-stats">
<div class="stat-item">
<span class="stat-value">{{.TotalJobs}}</span>
<span class="stat-label">Total Jobs</span>
</div>
<div class="stat-item success-stat">
<span class="stat-value">{{.TotalSuccess}}</span>
<span class="stat-label">Successful</span>
</div>
<div class="stat-item error-stat">
<span class="stat-value">{{.TotalFailure}}</span>
<span class="stat-label">Failed</span>
</div>
<div class="stat-item">
<span class="stat-value">{{printf "%.1f" (div .TotalBytesUploaded 1048576.0)}}</span>
<span class="stat-label">MB Uploaded</span>
</div>
<div class="stat-item">
<span class="stat-value">{{.TotalTime}}</span>
<span class="stat-label">Total Time</span>
</div>
</div>
</div>
{{if gt .TotalSuccess 0}}
<p>Successfully uploaded {{.TotalSuccess}} document(s) to ServiceTrade in {{.TotalTime}}!</p>
{{end}}
{{if gt .TotalFailure 0}}
<p class="text-warning">Failed to upload {{.TotalFailure}} document(s). See details below.</p>
{{end}}
<div class="upload-results-grid">
{{range .Results}}
{{template "upload_result_card" .}}
{{end}}
</div>
{{if gt .TotalPages 1}}
<div class="pagination-controls">
<div class="pagination-info">
Showing {{.StartIndex}}-{{.EndIndex}} of {{.TotalResults}} results
</div>
<div class="pagination-buttons">
{{if gt .CurrentPage 1}}
<button
hx-get="/documents/upload/results?page={{subtract .CurrentPage 1}}&limit={{.Limit}}&session_id={{.SessionID}}"
hx-target="#upload-results" 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" hx-indicator="false"
class="pagination-btn {{if eq $i $.CurrentPage}}active{{end}}">
{{$i}}
</button>
{{end}}
{{if lt .CurrentPage .TotalPages}}
<button
hx-get="/documents/upload/results?page={{add .CurrentPage 1}}&limit={{.Limit}}&session_id={{.SessionID}}"
hx-target="#upload-results" hx-indicator="false" class="pagination-btn">
Next →
</button>
{{end}}
</div>
</div>
{{end}}
</div>
{{end}}

7
templates/partials/upload_results_pagination.html

@ -27,14 +27,15 @@
<div class="pagination-buttons">
{{if gt .CurrentPage 1}}
<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}}&filter={{.Filter}}"
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}}"
<button
hx-get="/documents/upload/results?page={{$i}}&limit={{$.Limit}}&session_id={{$.SessionID}}&filter={{$.Filter}}"
hx-target="#upload-results" hx-indicator="false"
class="pagination-btn {{if eq $i $.CurrentPage}}active{{end}}">
{{$i}}
@ -43,7 +44,7 @@
{{if lt .CurrentPage .TotalPages}}
<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}}&filter={{.Filter}}"
hx-target="#upload-results" hx-indicator="false" class="pagination-btn">
Next →
</button>

14
templates/partials/upload_stats.html

@ -4,11 +4,21 @@
<span class="stat-value">{{.TotalJobs}}</span>
<span class="stat-label">Total Jobs</span>
</div>
<div class="stat-item success-stat">
<div class="stat-item all-stat"
hx-get="/documents/upload/results?page=1&limit={{.Limit}}&session_id={{.SessionID}}&filter=all"
hx-target="#upload-results" hx-indicator="false">
<span class="stat-value">{{add .TotalSuccess .TotalFailure}}</span>
<span class="stat-label">All Results</span>
</div>
<div class="stat-item success-stat"
hx-get="/documents/upload/results?page=1&limit={{.Limit}}&session_id={{.SessionID}}&filter=success"
hx-target="#upload-results" hx-indicator="false">
<span class="stat-value">{{.TotalSuccess}}</span>
<span class="stat-label">Successful</span>
</div>
<div class="stat-item error-stat">
<div class="stat-item error-stat"
hx-get="/documents/upload/results?page=1&limit={{.Limit}}&session_id={{.SessionID}}&filter=failed"
hx-target="#upload-results" hx-indicator="false">
<span class="stat-value">{{.TotalFailure}}</span>
<span class="stat-label">Failed</span>
</div>

Loading…
Cancel
Save