an updated and hopefully faster version of the ST Toolbox
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

586 lines
17 KiB

package web
import (
"bytes"
"encoding/csv"
"fmt"
"io"
"log"
"net/http"
"path/filepath"
"sort"
"strings"
"sync"
"time"
root "marmic/servicetrade-toolbox"
"marmic/servicetrade-toolbox/internal/api"
)
// DocumentsHandler handles the document upload page
func DocumentsHandler(w http.ResponseWriter, r *http.Request) {
session, ok := r.Context().Value("session").(*api.Session)
if !ok {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
tmpl := root.WebTemplates
data := map[string]interface{}{
"Title": "Document Uploads",
"Session": session,
}
if r.Header.Get("HX-Request") == "true" {
// For HTMX requests, just send the document_upload partial
if err := tmpl.ExecuteTemplate(w, "document_upload", data); err != nil {
log.Printf("Template execution error: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
} else {
// For full page requests, first render document_upload into a buffer
var contentBuf bytes.Buffer
if err := tmpl.ExecuteTemplate(&contentBuf, "document_upload", data); err != nil {
log.Printf("Template execution error: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
// Add the rendered content to the data for the layout
data["BodyContent"] = contentBuf.String()
// Now render the layout with our content
if err := tmpl.ExecuteTemplate(w, "layout.html", data); err != nil {
log.Printf("Template execution error: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
}
}
// ProcessCSVHandler processes a CSV file with job numbers
func ProcessCSVHandler(w http.ResponseWriter, r *http.Request) {
_, ok := r.Context().Value("session").(*api.Session)
if !ok {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Check if the request method is POST
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Parse the multipart form data with a 10MB limit
if err := r.ParseMultipartForm(10 << 20); err != nil {
http.Error(w, "Unable to parse form: "+err.Error(), http.StatusBadRequest)
return
}
// Get the file from the form
file, _, err := r.FormFile("csvFile")
if err != nil {
http.Error(w, "Error retrieving file: "+err.Error(), http.StatusBadRequest)
return
}
defer file.Close()
// Read the CSV data
csvData, err := csv.NewReader(file).ReadAll()
if err != nil {
http.Error(w, "Error reading CSV file: "+err.Error(), http.StatusBadRequest)
return
}
if len(csvData) < 2 {
http.Error(w, "CSV file must contain at least a header row and one data row", http.StatusBadRequest)
return
}
// Find the index of the 'id' column
headerRow := csvData[0]
idColumnIndex := -1
for i, header := range headerRow {
if strings.ToLower(strings.TrimSpace(header)) == "id" {
idColumnIndex = i
break
}
}
// If 'id' column not found, try the first column
if idColumnIndex == -1 {
idColumnIndex = 0
log.Printf("No 'id' column found in CSV, using first column (header: %s)", headerRow[0])
} else {
log.Printf("Found 'id' column at index %d", idColumnIndex)
}
// Extract job numbers from the CSV
var jobNumbers []string
for rowIndex, row := range csvData {
// Skip header row
if rowIndex == 0 {
continue
}
if len(row) > idColumnIndex {
// Extract and clean up the job ID
jobID := strings.TrimSpace(row[idColumnIndex])
if jobID != "" {
jobNumbers = append(jobNumbers, jobID)
}
}
}
totalJobs := len(jobNumbers)
log.Printf("Extracted %d job IDs from CSV", totalJobs)
if totalJobs == 0 {
http.Error(w, "No valid job IDs found in the CSV file", http.StatusBadRequest)
return
}
// Create a hidden input with the job IDs
jobsValue := strings.Join(jobNumbers, ",")
jobSampleDisplay := getJobSampleDisplay(jobNumbers)
// Generate HTML for the main response (hidden input for job-ids-container)
var responseHTML bytes.Buffer
responseHTML.WriteString(fmt.Sprintf(`<input type="hidden" name="jobNumbers" value="%s">`, jobsValue))
responseHTML.WriteString(fmt.Sprintf(`<p>Found <strong>%d</strong> job(s) in the CSV file</p>`, totalJobs))
responseHTML.WriteString(fmt.Sprintf(`<div class="csv-sample"><p>Sample job IDs: %s</p></div>`, jobSampleDisplay))
// Generate out-of-band swap for the preview section
responseHTML.WriteString(fmt.Sprintf(`
<div id="csv-preview" class="fade-me-out" style="display: block !important; margin-top: 1rem;" hx-swap-oob="true">
<h4>Detected Jobs</h4>
<div id="csv-preview-content" class="job-list">
<p>Found <strong>%d</strong> job(s) in the CSV file</p>
<div class="csv-sample">
<p>Sample job IDs: %s</p>
</div>
</div>
</div>
`, totalJobs, jobSampleDisplay))
w.Header().Set("Content-Type", "text/html")
w.Write(responseHTML.Bytes())
}
// Helper function to show sample job IDs with a limit
func getJobSampleDisplay(jobIDs []string) string {
const maxSamples = 5
if len(jobIDs) <= maxSamples {
return strings.Join(jobIDs, ", ")
}
sample := append([]string{}, jobIDs[:maxSamples]...)
return strings.Join(sample, ", ") + fmt.Sprintf(" and %d more...", len(jobIDs)-maxSamples)
}
// UploadDocumentsHandler handles document uploads to jobs
func UploadDocumentsHandler(w http.ResponseWriter, r *http.Request) {
session, ok := r.Context().Value("session").(*api.Session)
if !ok {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Check if the request method is POST
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Custom multipart form processing for 32-bit systems
reader, err := r.MultipartReader()
if err != nil {
http.Error(w, "Unable to get multipart reader: "+err.Error(), http.StatusBadRequest)
return
}
// Store form values and file parts
formValues := make(map[string]string)
// Read all file contents
type DocumentWithContent struct {
Name string
Type string
FileContent []byte
FormField string // Store the original form field name
}
var docsWithContent []DocumentWithContent
// First pass: collect all form fields and files
log.Printf("--- Starting multipart form processing ---")
for {
part, err := reader.NextPart()
if err == io.EOF {
break
}
if err != nil {
log.Printf("Error reading multipart part: %v", err)
break
}
formName := part.FormName()
fileName := part.FileName()
// If not a file, it's a regular form value
if fileName == "" {
// Read the form field value
valueBytes, err := io.ReadAll(part)
if err != nil {
log.Printf("Error reading form field %s: %v", formName, err)
continue
}
value := string(valueBytes)
formValues[formName] = value
log.Printf("Form field: %s = %s", formName, value)
} else if strings.HasPrefix(formName, "document-file-") {
// It's a file upload field
// Read file content
fileContent, err := io.ReadAll(part)
if err != nil {
log.Printf("Error reading file content for %s: %v", fileName, err)
continue
}
log.Printf("Found file: %s (size: %d bytes) in field: %s",
fileName, len(fileContent), formName)
// Store the file with its original field name for later processing
docsWithContent = append(docsWithContent, DocumentWithContent{
Name: fileName, // Default to original filename, will be updated with form values
Type: "", // Will be set from form values
FileContent: fileContent,
FormField: formName,
})
}
}
// Get job numbers from form values
jobNumbers := formValues["jobNumbers"]
if jobNumbers == "" {
jobNumbers = formValues["job-ids"]
if jobNumbers == "" {
log.Printf("No job numbers found in form values: %+v", formValues)
http.Error(w, "No job numbers provided", http.StatusBadRequest)
return
}
}
log.Printf("Job numbers: %s", jobNumbers)
// Split the job numbers
jobs := strings.Split(jobNumbers, ",")
if len(jobs) == 0 {
http.Error(w, "No valid job numbers provided", http.StatusBadRequest)
return
}
// Second pass: process document metadata
for i, doc := range docsWithContent {
suffix := strings.TrimPrefix(doc.FormField, "document-file-")
nameField := "document-name-" + suffix
typeField := "document-type-" + suffix
// Get custom document name if provided
customName := formValues[nameField]
if customName != "" {
// If a custom name is provided without extension, add the original file extension
if !strings.Contains(customName, ".") {
extension := filepath.Ext(doc.Name)
if extension != "" {
customName = customName + extension
}
}
docsWithContent[i].Name = customName
}
// Get document type
docType := formValues[typeField]
if docType == "" {
log.Printf("No document type for file %s, skipping", doc.Name)
continue
}
docsWithContent[i].Type = docType
log.Printf("Processing document: %s (type: %s) from field: %s",
docsWithContent[i].Name, docType, doc.FormField)
}
// Filter out documents with no type
var validDocs []DocumentWithContent
for _, doc := range docsWithContent {
if doc.Type != "" {
validDocs = append(validDocs, doc)
}
}
docsWithContent = validDocs
log.Printf("Total valid documents to upload: %d", len(docsWithContent))
if len(docsWithContent) == 0 {
http.Error(w, "No valid documents selected for upload", http.StatusBadRequest)
return
}
// 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 {
JobID string
DocName string
Success bool
Error string
Data map[string]interface{}
}
totalUploads := len(jobs) * len(docsWithContent)
resultsChan := make(chan UploadResult, totalUploads)
// Create a wait group to track when all uploads are done
var wg sync.WaitGroup
// Create a semaphore channel to limit concurrent uploads
semaphore := make(chan struct{}, maxConcurrent)
// Start the upload workers
for _, jobID := range jobs {
for _, doc := range docsWithContent {
wg.Add(1)
// Launch a goroutine for each job+document combination
go func(jobID string, doc DocumentWithContent) {
defer wg.Done()
// Acquire a semaphore slot
semaphore <- struct{}{}
defer func() { <-semaphore }() // Release when done
// Add a small delay to avoid overwhelming the API
time.Sleep(requestDelay)
// Call the ServiceTrade API
result, err := session.UploadAttachment(jobID, doc.Name, doc.Type, doc.FileContent)
if err != nil {
log.Printf("Error uploading %s to job %s: %v", doc.Name, jobID, err)
resultsChan <- UploadResult{
JobID: jobID,
DocName: doc.Name,
Success: false,
Error: err.Error(),
}
} else {
log.Printf("Successfully uploaded %s to job %s", doc.Name, jobID)
resultsChan <- UploadResult{
JobID: jobID,
DocName: doc.Name,
Success: true,
Data: result,
}
}
}(jobID, doc)
}
}
// Close the results channel when all uploads are done
go func() {
wg.Wait()
close(resultsChan)
}()
// Collect results
results := make(map[string][]UploadResult)
for result := range resultsChan {
if _, exists := results[result.JobID]; !exists {
results[result.JobID] = []UploadResult{}
}
results[result.JobID] = append(results[result.JobID], result)
}
// Generate HTML for results
var resultHTML bytes.Buffer
// Count successes and failures
var totalSuccess, totalFailure int
for _, jobResults := range results {
for _, result := range jobResults {
if result.Success {
totalSuccess++
} else {
totalFailure++
}
}
}
// Add summary section
resultHTML.WriteString("<div class=\"upload-summary\">")
resultHTML.WriteString("<h3>Upload Results</h3>")
resultHTML.WriteString("<div class=\"upload-stats\">")
// Total jobs stat
resultHTML.WriteString("<div class=\"stat-box\">")
resultHTML.WriteString(fmt.Sprintf("<div class=\"stat-value\">%d</div>", len(results)))
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>")
// File count stat
resultHTML.WriteString("<div class=\"stat-box\">")
resultHTML.WriteString(fmt.Sprintf("<div class=\"stat-value\">%d</div>", totalSuccess+totalFailure))
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 {
resultHTML.WriteString("<p>All documents were successfully uploaded to ServiceTrade!</p>")
} else {
resultHTML.WriteString("<p>Some documents failed to upload. See details below.</p>")
}
resultHTML.WriteString("</div>") // End of upload-summary
// Add detailed job results
resultHTML.WriteString("<div class=\"job-results\">")
// Sort job IDs for consistent display
sortedJobs := make([]string, 0, len(results))
for jobID := range results {
sortedJobs = append(sortedJobs, jobID)
}
sort.Strings(sortedJobs)
for _, jobID := range sortedJobs {
jobResults := results[jobID]
// Determine job success status
jobSuccess := true
for _, result := range jobResults {
if !result.Success {
jobSuccess = false
break
}
}
// Job result row
jobClass := "success"
if !jobSuccess {
jobClass = "error"
}
resultHTML.WriteString(fmt.Sprintf("<div class=\"job-result %s\">", jobClass))
resultHTML.WriteString(fmt.Sprintf("<span class=\"job-id\">Job ID: %s</span>", jobID))
// File results
if len(jobResults) > 0 {
resultHTML.WriteString("<div class=\"file-results\">")
for _, result := range jobResults {
fileClass := "success"
icon := "✓"
message := "Successfully uploaded"
if !result.Success {
fileClass = "error"
icon = "✗"
message = result.Error
}
resultHTML.WriteString(fmt.Sprintf("<div class=\"file-result %s\">", fileClass))
resultHTML.WriteString(fmt.Sprintf("<span class=\"status-icon\">%s</span>", icon))
resultHTML.WriteString(fmt.Sprintf("<span class=\"file-name\">%s:</span>", result.DocName))
resultHTML.WriteString(fmt.Sprintf("<span class=\"file-message\">%s</span>", message))
resultHTML.WriteString("</div>")
}
resultHTML.WriteString("</div>") // End of file-results
} else {
resultHTML.WriteString("<p>No files processed for this job.</p>")
}
resultHTML.WriteString("</div>") // End of job-result
}
resultHTML.WriteString("</div>") // End of job-results
w.Header().Set("Content-Type", "text/html")
w.Write(resultHTML.Bytes())
}
// DocumentFieldAddHandler generates a new document field for the form
func DocumentFieldAddHandler(w http.ResponseWriter, r *http.Request) {
// Generate a random ID for the new field
newId := fmt.Sprintf("%d", time.Now().UnixNano())
// Create HTML for a new document row
html := fmt.Sprintf(`
<div class="document-row" id="document-row-%s">
<div class="document-field">
<label>Select Document:</label>
<input class="card-input" type="file" id="document-file-%s" name="document-file-%s">
</div>
<div class="document-field-row">
<div class="document-field document-name-field">
<label>Document Name (optional):</label>
<input class="card-input" type="text" id="document-name-%s" name="document-name-%s"
placeholder="Document Name">
</div>
<div class="document-field document-type-field">
<label>Document Type:</label>
<select class="card-input" id="document-type-%s" name="document-type-%s">
<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>
<button type="button" class="remove-document warning-button"
hx-get="/document-field-remove?id=%s"
hx-target="#document-row-%s"
hx-swap="outerHTML">Remove</button>
</div>
`, newId, newId, newId, newId, newId, newId, newId, newId, newId)
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(html))
}
// DocumentFieldRemoveHandler handles the removal of a document field
func DocumentFieldRemoveHandler(w http.ResponseWriter, r *http.Request) {
// We read the ID but don't need to use it for simple removal
_ = r.URL.Query().Get("id")
// Count how many document rows exist
// For simplicity, we'll just return an empty response to remove the field
// In a complete implementation, we'd check if this is the last field and handle that case
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(""))
}