Browse Source

feat: multi-upload modal setup, need to js my way to client-side handing this info off to the backend now

document-upload-removal-layout-update
nic 11 months ago
parent
commit
c16789b099
  1. 328
      internal/handlers/web/documents.go
  2. 246
      static/css/upload.css
  3. 1
      static/js/htmx.min.js
  4. 1
      templates/layout.html
  5. 284
      templates/partials/document_upload.html

328
internal/handlers/web/documents.go

@ -173,7 +173,6 @@ func UploadDocumentsHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
// Check if the request method is POST
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return return
@ -182,8 +181,6 @@ func UploadDocumentsHandler(w http.ResponseWriter, r *http.Request) {
log.Printf("Starting document upload handler with Content-Length: %.2f MB", log.Printf("Starting document upload handler with Content-Length: %.2f MB",
float64(r.ContentLength)/(1024*1024)) float64(r.ContentLength)/(1024*1024))
// Parse the multipart form with a reasonable buffer size
// Files larger than this will be saved to temporary files automatically
maxMemory := int64(32 << 20) // 32MB in memory, rest to disk maxMemory := int64(32 << 20) // 32MB in memory, rest to disk
if err := r.ParseMultipartForm(maxMemory); err != nil { if err := r.ParseMultipartForm(maxMemory); err != nil {
log.Printf("Error parsing multipart form: %v", err) log.Printf("Error parsing multipart form: %v", err)
@ -192,14 +189,12 @@ func UploadDocumentsHandler(w http.ResponseWriter, r *http.Request) {
} }
defer r.MultipartForm.RemoveAll() // Clean up temporary files defer r.MultipartForm.RemoveAll() // Clean up temporary files
// Get job numbers from form values
jobNumbers := r.FormValue("jobNumbers") jobNumbers := r.FormValue("jobNumbers")
if jobNumbers == "" { if jobNumbers == "" {
log.Printf("No job numbers found in hidden 'jobNumbers' input.") log.Printf("No job numbers found in hidden 'jobNumbers' input.")
http.Error(w, "No job numbers provided", http.StatusBadRequest) http.Error(w, "No job numbers provided", http.StatusBadRequest)
return return
} }
log.Printf("Job numbers: %s", jobNumbers) log.Printf("Job numbers: %s", jobNumbers)
jobs := strings.Split(jobNumbers, ",") jobs := strings.Split(jobNumbers, ",")
if len(jobs) == 0 { if len(jobs) == 0 {
@ -207,118 +202,66 @@ func UploadDocumentsHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
// Get the single document type // Simple multi-upload: use original filenames as display names and default type "1"
docType := r.FormValue("documentType")
if docType == "" {
log.Printf("No document type selected")
http.Error(w, "Please select a document type", http.StatusBadRequest)
return
}
log.Printf("Document Type selected: %s", docType)
// Get the uploaded files from the 'documentFiles' input
fileHeaders := r.MultipartForm.File["documentFiles"] fileHeaders := r.MultipartForm.File["documentFiles"]
if len(fileHeaders) == 0 { if len(fileHeaders) == 0 {
http.Error(w, "No documents selected for upload", http.StatusBadRequest) http.Error(w, "No documents selected for upload", http.StatusBadRequest)
return return
} }
type FileToUploadMetadata struct {
// Store file metadata OriginalFilename string
type FileMetadata struct { DisplayName string
FileName string
Type string Type string
TempFile string // Path to temp file TempFile string
File *os.File // Open file handle for the temp file File *os.File
} }
var filesToUpload []FileToUploadMetadata
var filesToUpload []FileMetadata
// Process each uploaded file
for _, fileHeader := range fileHeaders { for _, fileHeader := range fileHeaders {
if fileHeader.Filename == "" {
log.Printf("Skipping file header with empty filename.")
continue
}
// Open the uploaded file
uploadedFile, err := fileHeader.Open() uploadedFile, err := fileHeader.Open()
if err != nil { if err != nil {
log.Printf("Error opening uploaded file %s: %v", fileHeader.Filename, err) log.Printf("Error opening uploaded file %s: %v. Skipping.", fileHeader.Filename, err)
// Optionally: decide if one error should halt all uploads or just skip this file continue
continue // Skip this file
} }
metadata := FileToUploadMetadata{
// Prepare metadata OriginalFilename: fileHeader.Filename,
metadata := FileMetadata{ DisplayName: fileHeader.Filename,
FileName: fileHeader.Filename, Type: "1",
Type: docType, // Use the single document type for all files
} }
tempFileHandle, err := os.CreateTemp("", "upload-*"+filepath.Ext(fileHeader.Filename))
// Create a temp file for the upload (regardless of size to ensure streaming)
tempFile, err := os.CreateTemp("", "upload-*"+filepath.Ext(fileHeader.Filename))
if err != nil { if err != nil {
log.Printf("Error creating temp file for %s: %v", fileHeader.Filename, err) log.Printf("Error creating temp file for %s: %v. Skipping.", fileHeader.Filename, err)
uploadedFile.Close() uploadedFile.Close()
continue // Skip this file continue
} }
if _, err := io.Copy(tempFileHandle, uploadedFile); err != nil {
// Copy the file content to the temp file log.Printf("Error copying to temp file for %s: %v. Skipping.", fileHeader.Filename, err)
bytesCopied, err := io.Copy(tempFile, uploadedFile) uploadedFile.Close()
uploadedFile.Close() // Close the original multipart file handle tempFileHandle.Close()
if err != nil { os.Remove(tempFileHandle.Name())
log.Printf("Error copying to temp file for %s: %v", fileHeader.Filename, err) continue
tempFile.Close() // Close the temp file handle
os.Remove(tempFile.Name()) // Remove the partially written temp file
continue // Skip this file
} }
uploadedFile.Close()
log.Printf("Copied %d bytes of %s to temporary file: %s", if _, err := tempFileHandle.Seek(0, 0); err != nil {
bytesCopied, fileHeader.Filename, tempFile.Name()) log.Printf("Error seeking temp file for %s: %v. Skipping.", fileHeader.Filename, err)
tempFileHandle.Close()
// Seek back to beginning for later reading by upload goroutines os.Remove(tempFileHandle.Name())
if _, err := tempFile.Seek(0, 0); err != nil { continue
log.Printf("Error seeking temp file for %s: %v", fileHeader.Filename, err)
tempFile.Close()
os.Remove(tempFile.Name())
continue // Skip this file
} }
metadata.TempFile = tempFileHandle.Name()
metadata.TempFile = tempFile.Name() metadata.File = tempFileHandle
metadata.File = tempFile // Store the open temp file handle
filesToUpload = append(filesToUpload, metadata) filesToUpload = append(filesToUpload, metadata)
} }
activeFilesProcessedCount := len(filesToUpload)
// Ensure temp files associated with metadata are closed and removed later if activeFilesProcessedCount == 0 {
defer func() { log.Println("No files processed for upload.")
log.Println("Running deferred cleanup for temp files...") http.Error(w, "No documents were processed for upload.", http.StatusBadRequest)
for _, fm := range filesToUpload {
if fm.File != nil {
fm.File.Close()
if fm.TempFile != "" {
err := os.Remove(fm.TempFile)
if err != nil && !os.IsNotExist(err) { // Don't log error if file already gone
log.Printf("Error cleaning up temp file %s: %v", fm.TempFile, err)
} else if err == nil {
log.Printf("Cleaned up temp file: %s", fm.TempFile)
}
}
}
}
}()
if len(filesToUpload) == 0 {
http.Error(w, "No valid documents could be processed for upload", http.StatusBadRequest)
return return
} }
log.Printf("Total active files prepared for upload: %d", activeFilesProcessedCount)
log.Printf("Total valid files to upload: %d", len(filesToUpload)) const maxConcurrent = 5
const requestDelay = 300 * time.Millisecond
// Concurrent upload with throttling
// ServiceTrade API allows 30s of availability per minute (approximately 15 requests at 2s each)
const maxConcurrent = 5 // A conservative limit to avoid rate limiting
const requestDelay = 300 * time.Millisecond // Delay between requests
// Channel for collecting results
type UploadResult struct { type UploadResult struct {
JobID string JobID string
DocName string DocName string
@ -328,140 +271,112 @@ func UploadDocumentsHandler(w http.ResponseWriter, r *http.Request) {
FileSize int64 FileSize int64
} }
totalUploads := len(jobs) * len(filesToUpload) totalUploads := len(jobs) * activeFilesProcessedCount
resultsChan := make(chan UploadResult, totalUploads) resultsChan := make(chan UploadResult, totalUploads)
// Create a wait group to track when all uploads are done
var wg sync.WaitGroup var wg sync.WaitGroup
// Create a semaphore channel to limit concurrent uploads
semaphore := make(chan struct{}, maxConcurrent) semaphore := make(chan struct{}, maxConcurrent)
// Start the upload workers log.Printf("Starting up to %d concurrent upload workers for %d total uploads (%d jobs x %d active files)",
log.Printf("Starting %d upload workers for %d total uploads (%d jobs x %d files)", maxConcurrent, totalUploads, len(jobs), activeFilesProcessedCount)
maxConcurrent, totalUploads, len(jobs), len(filesToUpload))
for _, jobID := range jobs { for _, jobID := range jobs {
for _, metadata := range filesToUpload { for _, metadataToUpload := range filesToUpload {
// Create a closure capture of the metadata for the goroutine currentUploadMetadata := metadataToUpload
// This is crucial because the 'metadata' variable in the loop will change
currentMetadata := metadata
wg.Add(1) wg.Add(1)
go func(jobID string, meta FileToUploadMetadata) {
// Launch a goroutine for each job+document combination
go func(jobID string, meta FileMetadata) {
defer wg.Done() defer wg.Done()
// Acquire a semaphore slot
semaphore <- struct{}{} semaphore <- struct{}{}
defer func() { <-semaphore }() // Release when done defer func() { <-semaphore }()
// Add a small delay to avoid overwhelming the API
time.Sleep(requestDelay) time.Sleep(requestDelay)
// Get the file name to use (original filename) fileNameForUpload := meta.DisplayName
fileName := meta.FileName fileHandleForUpload, err := os.Open(meta.TempFile)
// Use a fresh reader for the temp file for each upload goroutine
// Re-open the temp file for reading to avoid race conditions on the file pointer
fileHandle, err := os.Open(meta.TempFile)
if err != nil { if err != nil {
log.Printf("Error re-opening temp file %s for job %s: %v", meta.TempFile, jobID, err) log.Printf("Goroutine Error: Failed to re-open temp file %s for job %s (uploading as %s): %v",
meta.TempFile, jobID, fileNameForUpload, err)
resultsChan <- UploadResult{ resultsChan <- UploadResult{
JobID: jobID, JobID: jobID,
DocName: fileName, DocName: fileNameForUpload,
Success: false, Success: false,
Error: fmt.Sprintf("Error preparing file: %v", err), Error: fmt.Sprintf("Error preparing file for upload: %v", err),
FileSize: 0, FileSize: 0,
} }
return return
} }
defer fileHandle.Close() // Close this handle when done with this upload defer fileHandleForUpload.Close()
// Get the expected file size for validation fileInfo, statErr := fileHandleForUpload.Stat()
fileInfo, statErr := fileHandle.Stat() // Stat the newly opened handle
var expectedSize int64 var expectedSize int64
if statErr == nil { if statErr == nil {
expectedSize = fileInfo.Size() expectedSize = fileInfo.Size()
} else { } else {
log.Printf("Error getting file info for %s (job %s): %v", fileName, jobID, statErr) log.Printf("Goroutine Warning: Failed to get file info for %s (original: %s, job %s, uploading as %s): %v",
// Continue without size check if stat fails, but log it meta.TempFile, meta.OriginalFilename, jobID, fileNameForUpload, statErr)
} }
// Add jitter delay for large batch uploads (more than 10 jobs)
if len(jobs) > 10 { if len(jobs) > 10 {
jitter := time.Duration(100+(time.Now().UnixNano()%400)) * time.Millisecond jitter := time.Duration(100+(time.Now().UnixNano()%400)) * time.Millisecond
time.Sleep(jitter) time.Sleep(jitter)
} }
// Wrap with size tracker sizeTracker := &readCloserWithSize{reader: fileHandleForUpload, size: 0}
sizeTracker := &readCloserWithSize{reader: fileHandle, size: 0} log.Printf("Goroutine Info: Starting to stream file %s (original: %s, uploading as %s, type: %s) to job %s from temp file %s",
meta.TempFile, meta.OriginalFilename, fileNameForUpload, meta.Type, jobID, meta.TempFile)
log.Printf("Starting to stream file %s to job %s from temp file %s", fileName, jobID, meta.TempFile)
// Call ServiceTrade API with the file reader // Define uploadStart here for per-goroutine timing
uploadStart := time.Now() uploadStartGoroutine := time.Now()
result, err := session.UploadAttachmentFile(jobID, fileName, meta.Type, sizeTracker) uploadResultData, errUpload := session.UploadAttachmentFile(jobID, fileNameForUpload, meta.Type, sizeTracker)
uploadDuration := time.Since(uploadStart) uploadDuration := time.Since(uploadStartGoroutine)
// Get the actual size that was uploaded fileSizeUploaded := sizeTracker.Size()
fileSize := sizeTracker.Size()
// Verify the upload size matches the expected file size
sizeMatch := true sizeMatch := true
if expectedSize > 0 && math.Abs(float64(expectedSize-fileSize)) > float64(expectedSize)*0.05 { // Allow 5% tolerance if expectedSize > 0 && math.Abs(float64(expectedSize-fileSizeUploaded)) > float64(expectedSize)*0.05 {
sizeMatch = false sizeMatch = false
log.Printf("WARNING: Size mismatch for %s to job %s. Expected: %d, Uploaded: %d", log.Printf("Goroutine WARNING: Size mismatch for %s (original: %s, uploaded as %s) to job %s. Expected: %d, Uploaded: %d",
fileName, jobID, expectedSize, fileSize) meta.TempFile, meta.OriginalFilename, fileNameForUpload, jobID, expectedSize, fileSizeUploaded)
} }
if err != nil { if errUpload != nil {
log.Printf("Error uploading %s to job %s after %v: %v", log.Printf("Goroutine Error: Uploading %s (original: %s, as %s) to job %s failed after %v: %v",
fileName, jobID, uploadDuration, err) meta.TempFile, meta.OriginalFilename, fileNameForUpload, jobID, uploadDuration, errUpload)
resultsChan <- UploadResult{ resultsChan <- UploadResult{
JobID: jobID, JobID: jobID,
DocName: fileName, DocName: fileNameForUpload,
Success: false, Success: false,
Error: err.Error(), Error: errUpload.Error(),
FileSize: fileSize, FileSize: fileSizeUploaded,
} }
} else if !sizeMatch { } else if !sizeMatch {
// API returned success, but we detected size mismatch log.Printf("Goroutine Error: Upload of %s (original: %s, as %s) to job %s appears corrupted. API reported success but file sizes mismatch.",
log.Printf("Corrupted upload of %s to job %s detected. API returned success but file sizes don't match.", meta.TempFile, meta.OriginalFilename, fileNameForUpload, jobID)
fileName, jobID)
resultsChan <- UploadResult{ resultsChan <- UploadResult{
JobID: jobID, JobID: jobID,
DocName: fileName, DocName: fileNameForUpload,
Success: false, Success: false,
Error: "Upload appears corrupted (file size mismatch)", Error: "Upload appears corrupted (file size mismatch)",
FileSize: fileSize, FileSize: fileSizeUploaded,
} }
} else { } else {
log.Printf("Successfully uploaded %s (%.2f MB) to job %s in %v", log.Printf("Goroutine Success: Uploaded %s (original: %s, %.2f MB, as %s, type: %s) to job %s in %v",
fileName, float64(fileSize)/(1024*1024), jobID, uploadDuration) meta.TempFile, meta.OriginalFilename, float64(fileSizeUploaded)/(1024*1024), fileNameForUpload, meta.Type, jobID, uploadDuration)
resultsChan <- UploadResult{ resultsChan <- UploadResult{
JobID: jobID, JobID: jobID,
DocName: fileName, DocName: fileNameForUpload,
Success: true, Success: true,
Data: result, Data: uploadResultData,
FileSize: fileSize, FileSize: fileSizeUploaded,
} }
} }
}(jobID, currentMetadata) // Pass the captured metadata }(jobID, currentUploadMetadata)
} }
} }
// NOTE: The deferred cleanup function for temp files defined earlier will run after this point.
// Close the results channel when all uploads are done
go func() { go func() {
wg.Wait() wg.Wait()
close(resultsChan) close(resultsChan)
log.Println("All upload goroutines finished.") log.Println("All upload goroutines finished.")
}() }()
// Collect results
results := make(map[string][]UploadResult) results := make(map[string][]UploadResult)
resultsCount := 0 resultsCount := 0
var totalBytesUploaded int64 var totalBytesUploaded int64
@ -471,11 +386,9 @@ func UploadDocumentsHandler(w http.ResponseWriter, r *http.Request) {
log.Printf("Received result %d/%d: Job %s, File %s, Success: %v, Size: %.2f MB", log.Printf("Received result %d/%d: Job %s, File %s, Success: %v, Size: %.2f MB",
resultsCount, totalUploads, result.JobID, result.DocName, result.Success, resultsCount, totalUploads, result.JobID, result.DocName, result.Success,
float64(result.FileSize)/(1024*1024)) float64(result.FileSize)/(1024*1024))
if result.Success { if result.Success {
totalBytesUploaded += result.FileSize totalBytesUploaded += result.FileSize
} }
if _, exists := results[result.JobID]; !exists { if _, exists := results[result.JobID]; !exists {
results[result.JobID] = []UploadResult{} results[result.JobID] = []UploadResult{}
} }
@ -485,10 +398,7 @@ func UploadDocumentsHandler(w http.ResponseWriter, r *http.Request) {
log.Printf("All results collected. Total: %d, Total bytes uploaded: %.2f MB", log.Printf("All results collected. Total: %d, Total bytes uploaded: %.2f MB",
resultsCount, float64(totalBytesUploaded)/(1024*1024)) resultsCount, float64(totalBytesUploaded)/(1024*1024))
// Generate HTML for results
var resultHTML bytes.Buffer var resultHTML bytes.Buffer
// Count successes and failures
var totalSuccess, totalFailure int var totalSuccess, totalFailure int
for _, jobResults := range results { for _, jobResults := range results {
for _, result := range jobResults { for _, result := range jobResults {
@ -500,39 +410,15 @@ func UploadDocumentsHandler(w http.ResponseWriter, r *http.Request) {
} }
} }
// Add summary section
resultHTML.WriteString("<div class=\"upload-summary\">") resultHTML.WriteString("<div class=\"upload-summary\">")
resultHTML.WriteString("<h3>Upload Results</h3>") resultHTML.WriteString("<h3>Upload Results</h3>")
resultHTML.WriteString("<div class=\"upload-stats\">") resultHTML.WriteString("<div class=\"upload-stats\">")
resultHTML.WriteString(fmt.Sprintf("<div class=\"stat-box\"><div class=\"stat-value\">%d</div><div class=\"stat-label\">Total Jobs</div></div>", len(results)))
// Total jobs stat resultHTML.WriteString(fmt.Sprintf("<div class=\"stat-box success-stat\"><div class=\"stat-value\">%d</div><div class=\"stat-label\">Successful Uploads</div></div>", totalSuccess))
resultHTML.WriteString("<div class=\"stat-box\">") resultHTML.WriteString(fmt.Sprintf("<div class=\"stat-box error-stat\"><div class=\"stat-value\">%d</div><div class=\"stat-label\">Failed Uploads</div></div>", totalFailure))
resultHTML.WriteString(fmt.Sprintf("<div class=\"stat-value\">%d</div>", len(results))) resultHTML.WriteString(fmt.Sprintf("<div class=\"stat-box\"><div class=\"stat-value\">%d</div><div class=\"stat-label\">Files Processed</div></div>", resultsCount))
resultHTML.WriteString("<div class=\"stat-label\">Total Jobs</div>")
resultHTML.WriteString("</div>")
// Success stat
resultHTML.WriteString("<div class=\"stat-box success-stat\">")
resultHTML.WriteString(fmt.Sprintf("<div class=\"stat-value\">%d</div>", totalSuccess))
resultHTML.WriteString("<div class=\"stat-label\">Successful Uploads</div>")
resultHTML.WriteString("</div>")
// Failure stat
resultHTML.WriteString("<div class=\"stat-box error-stat\">")
resultHTML.WriteString(fmt.Sprintf("<div class=\"stat-value\">%d</div>", totalFailure))
resultHTML.WriteString("<div class=\"stat-label\">Failed Uploads</div>")
resultHTML.WriteString("</div>") resultHTML.WriteString("</div>")
// File count stat
resultHTML.WriteString("<div class=\"stat-box\">")
// Use resultsCount which reflects total files attempted
resultHTML.WriteString(fmt.Sprintf("<div class=\"stat-value\">%d</div>", resultsCount))
resultHTML.WriteString("<div class=\"stat-label\">Files Processed</div>")
resultHTML.WriteString("</div>")
resultHTML.WriteString("</div>") // End of upload-stats
// Add completion message
if totalFailure == 0 && resultsCount > 0 { if totalFailure == 0 && resultsCount > 0 {
resultHTML.WriteString("<p>All documents were successfully uploaded to ServiceTrade!</p>") resultHTML.WriteString("<p>All documents were successfully uploaded to ServiceTrade!</p>")
} else if resultsCount == 0 { } else if resultsCount == 0 {
@ -540,13 +426,9 @@ func UploadDocumentsHandler(w http.ResponseWriter, r *http.Request) {
} else { } else {
resultHTML.WriteString("<p>Some documents failed to upload. See details below.</p>") resultHTML.WriteString("<p>Some documents failed to upload. See details below.</p>")
} }
resultHTML.WriteString("</div>")
resultHTML.WriteString("</div>") // End of upload-summary
// Add detailed job results
resultHTML.WriteString("<div class=\"job-results\">") resultHTML.WriteString("<div class=\"job-results\">")
// Sort job IDs for consistent display
sortedJobs := make([]string, 0, len(results)) sortedJobs := make([]string, 0, len(results))
for jobID := range results { for jobID := range results {
sortedJobs = append(sortedJobs, jobID) sortedJobs = append(sortedJobs, jobID)
@ -555,8 +437,6 @@ func UploadDocumentsHandler(w http.ResponseWriter, r *http.Request) {
for _, jobID := range sortedJobs { for _, jobID := range sortedJobs {
jobResults := results[jobID] jobResults := results[jobID]
// Determine job success status based on results for *this job*
jobHasSuccess := false jobHasSuccess := false
jobHasFailure := false jobHasFailure := false
for _, result := range jobResults { for _, result := range jobResults {
@ -566,57 +446,42 @@ func UploadDocumentsHandler(w http.ResponseWriter, r *http.Request) {
jobHasFailure = true jobHasFailure = true
} }
} }
jobClass := "neutral"
// Job result row styling
jobClass := "neutral" // Default if somehow no results for a job ID
if jobHasSuccess && !jobHasFailure { if jobHasSuccess && !jobHasFailure {
jobClass = "success" jobClass = "success"
} else if jobHasFailure { } else if jobHasFailure {
jobClass = "error" // Prioritize showing error if any file failed for this job jobClass = "error"
} }
resultHTML.WriteString(fmt.Sprintf("<div class=\"job-result %s\">", jobClass)) resultHTML.WriteString(fmt.Sprintf("<div class=\"job-result %s\">", jobClass))
resultHTML.WriteString(fmt.Sprintf("<div class=\"job-id\">Job ID: %s</div>", jobID)) // Wrap ID for better styling resultHTML.WriteString(fmt.Sprintf("<div class=\"job-id\">Job ID: %s</div>", jobID))
// File results
if len(jobResults) > 0 { if len(jobResults) > 0 {
resultHTML.WriteString("<div class=\"file-results\">") resultHTML.WriteString("<div class=\"file-results\">")
// Sort file results by name for consistency
sort.Slice(jobResults, func(i, j int) bool { sort.Slice(jobResults, func(i, j int) bool {
return jobResults[i].DocName < jobResults[j].DocName return jobResults[i].DocName < jobResults[j].DocName
}) })
for _, result := range jobResults { for _, result := range jobResults {
fileClass := "success" fileClass := "success"
icon := "✓" icon := "✓"
message := "Successfully uploaded" message := "Successfully uploaded"
if !result.Success { if !result.Success {
fileClass = "error" fileClass = "error"
icon = "✗" icon = "✗"
// Sanitize error message slightly for HTML display if needed
message = strings.ReplaceAll(result.Error, "<", "&lt;") message = strings.ReplaceAll(result.Error, "<", "&lt;")
message = strings.ReplaceAll(message, ">", "&gt;") message = strings.ReplaceAll(message, ">", "&gt;")
} }
resultHTML.WriteString(fmt.Sprintf("<div class=\"file-result %s\">", fileClass)) resultHTML.WriteString(fmt.Sprintf("<div class=\"file-result %s\">", fileClass))
resultHTML.WriteString(fmt.Sprintf("<span class=\"status-icon\">%s</span>", icon)) resultHTML.WriteString(fmt.Sprintf("<span class=\"status-icon\">%s</span>", icon))
resultHTML.WriteString(fmt.Sprintf("<span class=\"file-name\">%s:</span>", result.DocName)) resultHTML.WriteString(fmt.Sprintf("<span class=\"file-name\">%s:</span>", result.DocName))
resultHTML.WriteString(fmt.Sprintf("<span class=\"file-message\">%s</span>", message)) resultHTML.WriteString(fmt.Sprintf("<span class=\"file-message\">%s</span>", message))
resultHTML.WriteString("</div>") resultHTML.WriteString("</div>")
} }
resultHTML.WriteString("</div>")
resultHTML.WriteString("</div>") // End of file-results
} else { } else {
resultHTML.WriteString("<p>No file upload results for this job.</p>") // More specific message resultHTML.WriteString("<p>No file upload results for this job.</p>")
} }
resultHTML.WriteString("</div>")
resultHTML.WriteString("</div>") // End of job-result
} }
resultHTML.WriteString("</div>")
resultHTML.WriteString("</div>") // End of job-results
w.Header().Set("Content-Type", "text/html") w.Header().Set("Content-Type", "text/html")
w.Write(resultHTML.Bytes()) w.Write(resultHTML.Bytes())
} }
@ -645,8 +510,5 @@ func (r *readCloserWithSize) Size() int64 {
return r.size return r.size
} }
// DocumentFieldAddHandler generates a new document field for the form // DocumentFieldAddHandler and DocumentFieldRemoveHandler are REMOVED
// REMOVED as it's no longer needed with the multi-file input // as they are no longer needed with the multi-file input and new chip UI.
// DocumentFieldRemoveHandler handles the removal of a document field
// REMOVED as it's no longer needed with the multi-file input

246
static/css/upload.css

@ -450,3 +450,249 @@
transform: rotate(360deg); transform: rotate(360deg);
} }
} }
/* Styles for Modal and File Chips */
/* Basic Modal Styling */
.modal {
display: none;
/* Hidden by default */
position: fixed;
/* Stay in place */
z-index: 1001;
/* Sit on top, above overlays which are 1000 */
left: 0;
top: 0;
width: 100%;
/* Full width */
height: 100%;
/* Full height */
overflow: auto;
/* Enable scroll if needed */
background-color: rgba(0, 0, 0, 0.4);
/* Semi-transparent black backdrop for light theme */
justify-content: center;
align-items: center;
}
.modal-content {
background-color: var(--content-bg);
color: var(--content-text);
margin: auto;
padding: 20px;
/* Standard padding */
border: 1px solid var(--input-border);
border-radius: 8px;
/* Consistent border-radius */
width: 90%;
/* Responsive width */
max-width: 500px;
/* Max width for the modal */
box-shadow: var(--dashboard-shadow);
}
/* Modal uses existing .form-group, ensure label is bold if that's the standard */
.modal-content .form-group label {
/* font-weight: bold; Inherits from global .form-group label if already bold */
}
.modal-content .card-input {
/* Ensure modal inputs are full width and box-sizing */
width: 100%;
box-sizing: border-box;
}
.close-button {
color: var(--label-color);
float: right;
font-size: 1.75rem;
/* Clearer size */
font-weight: bold;
line-height: 1;
padding: 0;
background: transparent;
border: none;
cursor: pointer;
opacity: 0.7;
}
.close-button:hover {
color: var(--content-text);
opacity: 1;
}
/* File Chip Styling */
.selected-files-grid {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
/* padding: 0.5rem 0; No specific padding, gap handles spacing */
margin-top: 1rem;
/* Retain consistent margins */
margin-bottom: 1rem;
}
.file-chip {
background-color: var(--input-bg);
border: 1px solid var(--input-border);
border-radius: 16px;
/* Pill shape for chips */
padding: 0.4rem 0.8rem;
display: flex;
align-items: center;
font-size: 0.875rem;
/* Slightly smaller font for chips */
box-shadow: var(--dashboard-shadow);
transition: background-color 0.2s, opacity 0.2s, box-shadow 0.2s;
color: var(--content-text);
}
.file-chip:hover {
box-shadow: var(--button-shadow);
/* Use button-shadow for hover if defined and suitable */
}
.file-chip.removed {
opacity: 0.65;
/* Make it visibly less prominent */
background-color: var(--progress-bg);
/* Using a neutral theme color */
text-decoration: line-through;
box-shadow: none;
/* No shadow for removed items */
}
.file-chip.removed .file-chip-name {
color: var(--label-color);
/* Dim the text of removed items */
}
.file-chip-icon {
margin-right: 0.5rem;
font-size: 1.1em;
/* Relative to chip's font size */
color: var(--btn-primary-bg);
/* Use a theme color for the icon */
}
.file-chip.removed .file-chip-icon {
color: var(--label-color);
/* Dimmed icon for removed items */
}
.file-chip-name {
margin-right: 0.5rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 150px;
/* Adjust as needed */
cursor: pointer;
/* For the title attribute & clickability */
}
.file-chip-doctype {
font-size: 0.8em;
/* Smaller than the filename */
color: var(--label-color);
background-color: var(--content-bg);
/* Slightly different background */
padding: 0.1em 0.4em;
border-radius: 4px;
margin-right: 0.5rem;
white-space: nowrap;
}
.file-chip-edit,
.file-chip-remove {
background: none;
border: none;
color: var(--label-color);
/* Subtler color for actions */
cursor: pointer;
font-size: 1em;
/* Relative to chip's font size */
padding: 0 0.25rem;
margin-left: 0.25rem;
/* Slight spacing */
line-height: 1;
opacity: 0.8;
}
.file-chip-edit:hover,
.file-chip-remove:hover {
color: var(--content-text);
/* Darker on hover for clarity */
opacity: 1;
}
.file-chip.removed .file-chip-edit,
.file-chip.removed .file-chip-remove {
/* Actions on removed chips */
color: var(--label-color);
cursor: not-allowed;
opacity: 0.5;
/* Make them more faded */
}
#no-files-selected-placeholder {
color: var(--label-color);
font-style: italic;
width: 100%;
/* Take full width */
text-align: center;
/* Center placeholder text */
padding: 1rem 0;
/* Give it some space */
}
/* Modal Buttons Styling */
.modal-content .form-actions {
/* If you have a .form-actions div in modal */
margin-top: 1.5rem;
/* Space above action buttons */
text-align: right;
/* Align buttons to the right */
}
.modal-content .btn-primary,
.modal-content .btn-secondary {
margin-left: 0.5rem;
/* Space between buttons if aligned right */
}
/* If not using .form-actions, direct styling: */
/* .modal-content > button { margin-top: 1rem; margin-right: 0.5rem; } */
/* Dark theme adjustments for modal backdrop & specific modal elements */
/* Using @media (prefers-color-scheme: dark) as per existing structure at end of file */
@media (prefers-color-scheme: dark) {
.modal {
background-color: rgba(20, 20, 20, 0.75);
/* Darker, more opaque backdrop for dark theme */
}
.close-button {
/* Ensure close button is visible in dark mode */
color: var(--label-color);
/* Should pick up dark theme var */
}
.close-button:hover {
color: var(--content-text);
/* Should pick up dark theme var */
}
/* Specific modal button styling for dark theme if needed, e.g., for btn-secondary */
.modal-content .btn-secondary {
/* Assuming global .btn-secondary might not be fully dark-theme aware */
/* background-color: var(--input-bg); Picks up dark theme variable */
/* color: var(--content-text); Picks up dark theme variable */
/* border: 1px solid var(--input-border); Picks up dark theme variable */
}
/* .modal-content .btn-secondary:hover { */
/* background-color: var(--content-bg); Slightly different hover for dark */
/* } */
}

1
static/js/htmx.min.js

@ -1 +0,0 @@
Redirecting to https://unpkg.com/htmx.org@1.9.12/dist/htmx.min.js

1
templates/layout.html

@ -52,7 +52,6 @@
</div> </div>
</main> </main>
<script src="/static/js/htmx.min.js"></script>
</body> </body>
</html> </html>

284
templates/partials/document_upload.html

@ -49,7 +49,7 @@
<!-- Step 2: Document Upload --> <!-- Step 2: Document Upload -->
<div id="step2" class="content" style="display: none;"> <div id="step2" class="content" style="display: none;">
<h3 class="submenu-header">Step 2: Select Documents</h3> <h3 class="submenu-header">Step 2: Select Documents & Types</h3>
<!-- Single file input for multiple documents --> <!-- Single file input for multiple documents -->
<div class="document-field"> <div class="document-field">
@ -57,24 +57,14 @@
<input class="card-input" type="file" id="document-files" name="documentFiles" multiple> <input class="card-input" type="file" id="document-files" name="documentFiles" multiple>
</div> </div>
<!-- Single document type selector --> <!-- Area to display selected file chips -->
<div class="document-field document-type-field"> <div id="selected-files-area" class="selected-files-grid" style="margin-top: 1rem; margin-bottom: 1rem;">
<label for="document-type">Document Type (applies to all selected documents):</label> <!-- File chips will be dynamically inserted here by JavaScript -->
<select class="card-input" id="document-type" name="documentType"> <p id="no-files-selected-placeholder">No files selected yet.</p>
<option value="">Select Document Type</option>
<option value="1" selected>Job Paperwork</option>
<option value="2">Job Vendor Bill</option>
<option value="7">Generic Attachment</option>
<option value="10">Blank Paperwork</option>
<option value="14">Job Invoice</option>
</select>
</div> </div>
<!-- Remove the Add Another Document button --> <button type="button" class="btn-primary" id="continue-to-step3-button" disabled
<!-- Remove the dynamic document rows and their remove buttons --> onclick="if (!this.disabled) document.getElementById('step3').style.display = 'block';">
<button type="button" class="btn-primary"
onclick="document.getElementById('step3').style.display = 'block';">
Continue to Step 3 Continue to Step 3
</button> </button>
</div> </div>
@ -83,7 +73,7 @@
<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>
<button type="submit" class="success-button" id="submit-button">Upload Documents to Jobs</button> <button type="submit" class="success-button" id="final-submit-button">Upload Documents to Jobs</button>
<div id="upload-loading-indicator" class="htmx-indicator"> <div id="upload-loading-indicator" class="htmx-indicator">
<span>Uploading...</span> <span>Uploading...</span>
@ -97,62 +87,248 @@
<!-- Restart Button (initially hidden) --> <!-- Restart Button (initially hidden) -->
<div id="restart-section" class="content" style="display: none;"> <div id="restart-section" class="content" style="display: none;">
<h3 class="submenu-header">Upload Complete</h3> <h3 class="submenu-header">Upload Complete</h3>
<button type="button" class="btn-primary" onclick="restartUpload()">Start New Upload</button> <button type="button" class="btn-primary" hx-on:click="restartUpload()">Start New Upload</button>
</div> </div>
</div> </div>
</form> </form>
<!-- Edit File Modal (Initially Hidden) -->
<div id="editFileModal" class="modal" style="display:none;">
<div class="modal-content">
<span class="close-button" onclick="closeEditModal()">&times;</span>
<h4>Edit File Details</h4>
<input type="hidden" id="editFileOriginalIndex">
<div class="form-group">
<label for="editDisplayName">Display Name:</label>
<input type="text" id="editDisplayName" class="card-input">
</div>
<div class="form-group">
<label for="editDocumentType">Document Type:</label>
<select id="editDocumentType" class="card-input">
<option value="1">Job Paperwork</option>
<option value="2">Job Vendor Bill</option>
<option value="7">Generic Attachment</option>
<option value="10">Blank Paperwork</option>
<option value="14">Job Invoice</option>
</select>
</div>
<div id="modal-preview-area"
style="margin-top: 1rem; margin-bottom: 1rem; max-height: 300px; overflow-y: auto;">
<!-- Document preview will be attempted here later -->
<p>Document preview will be shown here in a future update.</p>
</div>
<button type="button" class="btn-primary" onclick="saveFileChanges()">Save Changes</button>
<button type="button" class="btn-secondary" onclick="closeEditModal()">Cancel</button>
</div>
</div>
<script> <script>
let selectedFilesData = []; // To store {originalFile, displayName, documentType, isActive, originalIndex}
const MAX_FILENAME_LENGTH = 30; // Max length for displayed filename on chip
document.getElementById('document-files').addEventListener('change', handleFileSelectionChange);
const continueToStep3Button = document.getElementById('continue-to-step3-button');
function handleFileSelectionChange(event) {
selectedFilesData = []; // Reset
const filesArea = document.getElementById('selected-files-area');
filesArea.innerHTML = ''; // Clear previous chips
const noFilesPlaceholder = document.getElementById('no-files-selected-placeholder');
const files = event.target.files;
if (files.length === 0) {
if (noFilesPlaceholder) noFilesPlaceholder.style.display = 'block';
filesArea.innerHTML = '<p id="no-files-selected-placeholder">No files selected yet.</p>'; // Re-add if cleared
continueToStep3Button.disabled = true;
return;
}
if (noFilesPlaceholder) noFilesPlaceholder.style.display = 'none';
else { // if it was removed entirely, ensure it's not there
const existingPlaceholder = document.getElementById('no-files-selected-placeholder');
if (existingPlaceholder) existingPlaceholder.remove();
}
for (let i = 0; i < files.length; i++) {
const file = files[i];
const fileMetadata = {
originalFile: file,
displayName: file.name,
documentType: "1", // Default to "Job Paperwork"
isActive: true,
originalIndex: i
};
selectedFilesData.push(fileMetadata);
renderFileChip(fileMetadata, i);
}
continueToStep3Button.disabled = files.length === 0;
}
function getFileIcon(filename) {
const extension = filename.split('.').pop().toLowerCase();
// Simple icon logic, can be expanded
if (['jpg', 'jpeg', 'png', 'gif'].includes(extension)) return '🖼️'; // Image icon
if (extension === 'pdf') return '📄'; // PDF icon
if (['doc', 'docx'].includes(extension)) return '📝'; // Word doc
return '📁'; // Generic file icon
}
function truncateFilename(filename, maxLength = MAX_FILENAME_LENGTH) {
if (filename.length <= maxLength) return filename;
const start = filename.substring(0, maxLength - 3 - Math.floor((maxLength - 3) / 3));
const end = filename.substring(filename.length - Math.floor((maxLength - 3) / 3));
return `${start}...${end}`;
}
function renderFileChip(fileMetadata, index) {
const filesArea = document.getElementById('selected-files-area');
const chip = document.createElement('div');
chip.className = `file-chip ${fileMetadata.isActive ? '' : 'removed'}`;
chip.dataset.index = index;
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>
`;
filesArea.appendChild(chip);
}
function openEditModal(index) {
const fileMetadata = selectedFilesData[index];
if (!fileMetadata || !fileMetadata.isActive) return; // Don't edit removed or non-existent files
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';
}
function closeEditModal() {
document.getElementById('editFileModal').style.display = 'none';
}
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;
// 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);
// 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;
}
}
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 form // Reset the form and file input
document.getElementById('upload-form').reset(); const form = document.getElementById('upload-form');
form.reset();
const fileInput = document.getElementById('document-files');
if (fileInput) fileInput.value = '';
// Hide all sections except step 1 // Remove any hidden jobNumbers inputs
document.getElementById('step2').style.display = 'none'; form.querySelectorAll('input[name="jobNumbers"]').forEach(el => el.remove());
document.getElementById('step3').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 // Clear and hide the CSV preview
document.getElementById('upload-results').innerHTML = ''; 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>';
// Hide and reset CSV preview // Clear selected files data and UI
document.getElementById('csv-preview').style.display = 'none'; selectedFilesData = [];
document.getElementById('csv-preview-content').innerHTML = '<p>No jobs loaded yet</p>'; const filesArea = document.getElementById('selected-files-area');
filesArea.innerHTML = '<p id="no-files-selected-placeholder">No files selected yet</p>';
// Reset job IDs container // Clear upload results
document.getElementById('job-ids-container').innerHTML = ''; const uploadResults = document.getElementById('upload-results');
if (uploadResults) uploadResults.innerHTML = '';
// 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';
// Show step 1 // Show step 1
document.getElementById('step1').style.display = 'block'; document.getElementById('step1').style.display = 'block';
// Reset the file input (important for `multiple`) // Disable the continue-to-step3 button
document.getElementById('document-files').value = ''; if (continueToStep3Button) continueToStep3Button.disabled = true;
} }
// Add event listener for form submission
document.getElementById('upload-form').addEventListener('htmx:beforeRequest', function (evt) {
// Only show the overlay for document uploads, not CSV processing
if (evt.detail.pathInfo.requestPath === '/upload-documents') {
document.querySelector('.upload-overlay').style.display = 'flex';
}
});
document.getElementById('upload-form').addEventListener('htmx:afterRequest', function (evt) {
if (evt.detail.pathInfo.requestPath === '/upload-documents') {
if (evt.detail.successful) {
// Show restart section after successful upload // 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) {
document.getElementById('restart-section').style.display = 'block'; document.getElementById('restart-section').style.display = 'block';
} }
// Hide the overlay after the request completes
document.querySelector('.upload-overlay').style.display = 'none';
}
});
// Add event listener for HTMX errors
document.getElementById('upload-form').addEventListener('htmx:error', function (evt) {
// Hide the overlay if there's an error
document.querySelector('.upload-overlay').style.display = 'none';
}); });
</script> </script>
{{end}} {{end}}
Loading…
Cancel
Save