Browse Source

feat: completed upload ability for jobs

document-upload-removal-layout-update
nic 12 months ago
parent
commit
348c896e89
  1. 107
      internal/api/attachments.go
  2. 169
      internal/handlers/web/documents.go
  3. 2
      templates/partials/csv_upload.html
  4. 35
      templates/partials/document_upload.html
  5. 12
      templates/partials/document_upload_form.html
  6. 3
      templates/partials/upload_actions.html

107
internal/api/attachments.go

@ -8,7 +8,44 @@ import (
"log" "log"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"path/filepath" "strconv"
"strings"
)
// ServiceTrade attachment purpose constants with file type restrictions
const (
// AttachmentPurposeJobPaperwork - PDF documents accepted
AttachmentPurposeJobPaperwork = 1
// AttachmentPurposeJobVendorBill - PDF documents accepted
AttachmentPurposeJobVendorBill = 2
// AttachmentPurposeJobPicture - Only image files: *.gif, *.jpeg, *.jpg, *.png, *.tif, *.tiff
AttachmentPurposeJobPicture = 3
// AttachmentPurposeDeficiencyRepairProposal - PDF documents accepted
AttachmentPurposeDeficiencyRepairProposal = 5
// AttachmentPurposeGenericAttachment - PDF documents accepted
AttachmentPurposeGenericAttachment = 7
// AttachmentPurposeAvatarImage - Only image files: *.gif, *.jpeg, *.jpg, *.png, *.tif, *.tiff
AttachmentPurposeAvatarImage = 8
// AttachmentPurposeImport - Only text files: *.csv, *.txt
AttachmentPurposeImport = 9
// AttachmentPurposeBlankPaperwork - PDF documents accepted
AttachmentPurposeBlankPaperwork = 10
// AttachmentPurposeWorkAcknowledgement - Only JSON files: *.json
AttachmentPurposeWorkAcknowledgement = 11
// AttachmentPurposeLogo - Only image files: *.gif, *.jpeg, *.jpg, *.png, *.tif, *.tiff
AttachmentPurposeLogo = 12
// AttachmentPurposeJobInvoice - PDF documents accepted
AttachmentPurposeJobInvoice = 14
) )
// UploadAttachment uploads a file as an attachment to a job // UploadAttachment uploads a file as an attachment to a job
@ -19,25 +56,69 @@ func (s *Session) UploadAttachment(jobID, filename, purpose string, fileContent
var b bytes.Buffer var b bytes.Buffer
w := multipart.NewWriter(&b) w := multipart.NewWriter(&b)
// Add the purpose (attachment type) // Log received values
if err := w.WriteField("purpose", purpose); err != nil { log.Printf("Uploading attachment to job ID: %s", jobID)
return nil, fmt.Errorf("error writing purpose field: %v", err) log.Printf("Filename: %s", filename)
log.Printf("Purpose value: '%s'", purpose)
log.Printf("File content length: %d bytes", len(fileContent))
// The ServiceTrade API expects the purpose ID as an integer
purposeStr := strings.TrimSpace(purpose)
// Try to parse the purpose as an integer, removing any leading zeros first
purposeStr = strings.TrimLeft(purposeStr, "0")
if purposeStr == "" {
purposeStr = "0" // If only zeros were provided
}
purposeInt, err := strconv.Atoi(purposeStr)
if err != nil {
return nil, fmt.Errorf("invalid purpose value '%s': must be a valid integer: %v", purpose, err)
}
log.Printf("Using purpose value: %d for job: %s", purposeInt, jobID)
// Add the purposeId (attachment type) as an integer
// NOTE: The API expects "purposeId", not "purpose"
if err := w.WriteField("purposeId", fmt.Sprintf("%d", purposeInt)); err != nil {
return nil, fmt.Errorf("error writing purposeId field: %v", err)
}
// Add the entityType (3 for Job) and entityId (jobID)
if err := w.WriteField("entityType", "3"); err != nil { // 3 = Job
return nil, fmt.Errorf("error writing entityType field: %v", err)
} }
// Add the job ID if err := w.WriteField("entityId", jobID); err != nil {
if err := w.WriteField("job", jobID); err != nil { return nil, fmt.Errorf("error writing entityId field: %v", err)
return nil, fmt.Errorf("error writing job field: %v", err)
} }
// Add the file // Ensure we have a file with content to upload
fw, err := w.CreateFormFile("filename", filepath.Base(filename)) if len(fileContent) == 0 {
return nil, fmt.Errorf("no file content provided for upload")
}
// Add a description field with the filename for better identification
if err := w.WriteField("description", filename); err != nil {
return nil, fmt.Errorf("error writing description field: %v", err)
}
// Add the file - make sure we use the real filename with extension for content-type detection
// The API requires the file extension to determine the content type
if !strings.Contains(filename, ".") {
return nil, fmt.Errorf("filename must include an extension (e.g. .pdf, .docx) for API content type detection")
}
fw, err := w.CreateFormFile("uploadedFile", filename)
if err != nil { if err != nil {
return nil, fmt.Errorf("error creating form file: %v", err) return nil, fmt.Errorf("error creating form file: %v", err)
} }
if _, err := io.Copy(fw, bytes.NewReader(fileContent)); err != nil { bytesWritten, err := io.Copy(fw, bytes.NewReader(fileContent))
if err != nil {
return nil, fmt.Errorf("error copying file content: %v", err) return nil, fmt.Errorf("error copying file content: %v", err)
} }
log.Printf("Wrote %d bytes of file content to the form", bytesWritten)
// Close the writer // Close the writer
if err := w.Close(); err != nil { if err := w.Close(); err != nil {
@ -54,6 +135,11 @@ func (s *Session) UploadAttachment(jobID, filename, purpose string, fileContent
req.Header.Set("Content-Type", w.FormDataContentType()) req.Header.Set("Content-Type", w.FormDataContentType())
req.Header.Set("Cookie", s.Cookie) req.Header.Set("Cookie", s.Cookie)
// Debug information
log.Printf("Sending request to: %s", url)
log.Printf("Content-Type: %s", w.FormDataContentType())
log.Printf("Request body fields: purposeId=%d, entityType=3, entityId=%s, filename=%s", purposeInt, jobID, filename)
// Send the request // Send the request
resp, err := s.Client.Do(req) resp, err := s.Client.Do(req)
if err != nil { if err != nil {
@ -69,6 +155,7 @@ func (s *Session) UploadAttachment(jobID, filename, purpose string, fileContent
// Check for errors // Check for errors
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
log.Printf("API error response: %s - %s", resp.Status, string(body))
return nil, fmt.Errorf("API returned error: %s - %s", resp.Status, string(body)) return nil, fmt.Errorf("API returned error: %s - %s", resp.Status, string(body))
} }

169
internal/handlers/web/documents.go

@ -8,6 +8,7 @@ import (
"log" "log"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"path/filepath"
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
@ -94,42 +95,100 @@ func ProcessCSVHandler(w http.ResponseWriter, r *http.Request) {
return 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 // Extract job numbers from the CSV
var jobNumbers []string var jobNumbers []string
for _, row := range csvData { for rowIndex, row := range csvData {
if len(row) > 0 && row[0] != "" { // Skip header row
// Trim whitespace and skip headers or empty lines if rowIndex == 0 {
jobNum := strings.TrimSpace(row[0]) continue
if jobNum != "Job Number" && jobNum != "JobNumber" && jobNum != "Job" && jobNum != "" { }
jobNumbers = append(jobNumbers, jobNum)
if len(row) > idColumnIndex {
// Extract and clean up the job ID
jobID := strings.TrimSpace(row[idColumnIndex])
if jobID != "" {
jobNumbers = append(jobNumbers, jobID)
log.Printf("Added job ID: %s", jobID)
} }
} }
} }
// Validate each job number (optional: could make API calls to verify they exist) log.Printf("Extracted %d job IDs from CSV", len(jobNumbers))
// Create a list of valid job numbers
var validJobNumbers []string var validJobNumbers []string
validJobNumbers = append(validJobNumbers, jobNumbers...) validJobNumbers = append(validJobNumbers, jobNumbers...)
// Generate HTML for job list // Generate HTML for job list
var jobListHTML string var jobListHTML string
if len(validJobNumbers) > 0 { if len(validJobNumbers) > 0 {
// Add a hidden input with comma-separated job IDs for form submission // Create a hidden input with the job IDs
jobListHTML = fmt.Sprintf("<input type='hidden' name='jobNumbers' value='%s'>", strings.Join(validJobNumbers, ",")) jobsValue := strings.Join(validJobNumbers, ",")
jobListHTML += "<ul class='job-list'>"
for _, jobNum := range validJobNumbers { // Insert a hidden input for job numbers and show the job list
jobListHTML += fmt.Sprintf("<li data-job-id='%s'>Job #%s</li>", jobNum, jobNum) jobListHTML = fmt.Sprintf(`
} <input type="hidden" name="jobNumbers" value="%s">
jobListHTML += "</ul>"
// Add JavaScript to make the CSV preview visible <style>
jobListHTML += "<script>document.getElementById('csv-preview').style.display = 'block';</script>" #csv-preview { display: block !important; }
</style>
<script>
// Update the job list display
document.getElementById("csv-preview-content").innerHTML = '';
var ul = document.createElement("ul");
ul.className = "job-list";
%s
document.getElementById("csv-preview-content").appendChild(ul);
</script>
`, jobsValue, buildJobListJS(validJobNumbers))
} else { } else {
jobListHTML = "<p>No valid job numbers found in the CSV file.</p>" jobListHTML = `
<p>No valid job numbers found in the CSV file.</p>
`
} }
w.Header().Set("Content-Type", "text/html") w.Header().Set("Content-Type", "text/html")
w.Write([]byte(jobListHTML)) w.Write([]byte(jobListHTML))
} }
// Helper function to build JavaScript for job list
func buildJobListJS(jobIDs []string) string {
var js strings.Builder
for _, id := range jobIDs {
js.WriteString(fmt.Sprintf(`
var li = document.createElement("li");
li.setAttribute("data-job-id", "%s");
li.textContent = "Job #%s";
ul.appendChild(li);
`, id, id))
}
return js.String()
}
// UploadDocumentsHandler handles document uploads to jobs // UploadDocumentsHandler handles document uploads to jobs
func UploadDocumentsHandler(w http.ResponseWriter, r *http.Request) { func UploadDocumentsHandler(w http.ResponseWriter, r *http.Request) {
session, ok := r.Context().Value("session").(*api.Session) session, ok := r.Context().Value("session").(*api.Session)
@ -146,17 +205,25 @@ func UploadDocumentsHandler(w http.ResponseWriter, r *http.Request) {
// Parse the multipart form with a 30MB limit // Parse the multipart form with a 30MB limit
if err := r.ParseMultipartForm(30 << 20); err != nil { if err := r.ParseMultipartForm(30 << 20); err != nil {
http.Error(w, "Unable to parse form: "+err.Error(), http.StatusBadRequest) http.Error(w, fmt.Sprintf("Unable to parse form: %v", err), http.StatusBadRequest)
return return
} }
// Get the job numbers // Get the job numbers from either of the possible form fields
jobNumbers := r.FormValue("jobNumbers") jobNumbers := r.FormValue("jobNumbers")
if jobNumbers == "" { if jobNumbers == "" {
http.Error(w, "No job numbers provided", http.StatusBadRequest) jobNumbers = r.FormValue("job-ids")
return if jobNumbers == "" {
log.Printf("No job numbers provided. Form data: %+v", r.Form)
http.Error(w, "No job numbers provided", http.StatusBadRequest)
return
}
} }
// Log the form data for debugging
log.Printf("Form data: %+v", r.Form)
log.Printf("Job numbers: %s", jobNumbers)
// Split the job numbers // Split the job numbers
jobs := strings.Split(jobNumbers, ",") jobs := strings.Split(jobNumbers, ",")
if len(jobs) == 0 { if len(jobs) == 0 {
@ -210,8 +277,18 @@ func UploadDocumentsHandler(w http.ResponseWriter, r *http.Request) {
documentName := r.FormValue(nameKey) documentName := r.FormValue(nameKey)
if documentName == "" { if documentName == "" {
documentName = fileHeader.Filename documentName = fileHeader.Filename
} else {
// If a custom name is provided without extension, add the original file extension
if !strings.Contains(documentName, ".") {
extension := filepath.Ext(fileHeader.Filename)
if extension != "" {
documentName = documentName + extension
}
}
} }
log.Printf("Using document name: %s (original filename: %s)", documentName, fileHeader.Filename)
// Get document type // Get document type
documentType := r.FormValue(typeKey) documentType := r.FormValue(typeKey)
if documentType == "" { if documentType == "" {
@ -219,6 +296,9 @@ func UploadDocumentsHandler(w http.ResponseWriter, r *http.Request) {
continue continue
} }
// Log the document type for debugging
log.Printf("Document type for %s: '%s'", documentName, documentType)
documents = append(documents, DocumentData{ documents = append(documents, DocumentData{
File: file, File: file,
Header: fileHeader, Header: fileHeader,
@ -275,26 +355,33 @@ func UploadDocumentsHandler(w http.ResponseWriter, r *http.Request) {
resultHTML.WriteString("<div class='upload-results'>") resultHTML.WriteString("<div class='upload-results'>")
resultHTML.WriteString("<h4>Upload Results</h4>") resultHTML.WriteString("<h4>Upload Results</h4>")
for jobID, jobResults := range results { if len(results) == 0 {
resultHTML.WriteString(fmt.Sprintf("<div class='job-result'><h5>Job #%s</h5><ul>", jobID)) resultHTML.WriteString("<p class='error'>No documents were uploaded. Please check that you have selected files and document types.</p>")
} else {
for _, result := range jobResults { for jobID, jobResults := range results {
filename := result["filename"].(string) resultHTML.WriteString(fmt.Sprintf("<div class='job-result'><h5>Job #%s</h5><ul>", jobID))
success := result["success"].(bool)
for _, result := range jobResults {
if success { filename := result["filename"].(string)
resultHTML.WriteString(fmt.Sprintf("<li class='success'>%s: Uploaded successfully</li>", filename)) success := result["success"].(bool)
} else {
errorMsg := result["error"].(string) if success {
resultHTML.WriteString(fmt.Sprintf("<li class='error'>%s: %s</li>", filename, errorMsg)) resultHTML.WriteString(fmt.Sprintf("<li class='success'>%s: Uploaded successfully</li>", filename))
} else {
errorMsg := result["error"].(string)
resultHTML.WriteString(fmt.Sprintf("<li class='error'>%s: %s</li>", filename, errorMsg))
}
} }
}
resultHTML.WriteString("</ul></div>") resultHTML.WriteString("</ul></div>")
}
} }
resultHTML.WriteString("</div>") resultHTML.WriteString("</div>")
// Add JavaScript to scroll to results
resultHTML.WriteString("<script>document.getElementById('upload-results').scrollIntoView({behavior: 'smooth'});</script>")
w.Header().Set("Content-Type", "text/html") w.Header().Set("Content-Type", "text/html")
w.Write(resultHTML.Bytes()) w.Write(resultHTML.Bytes())
} }
@ -323,16 +410,10 @@ func DocumentFieldAddHandler(w http.ResponseWriter, r *http.Request) {
<label>Document Type:</label> <label>Document Type:</label>
<select class="card-input" id="document-type-%s" name="document-type-%s"> <select class="card-input" id="document-type-%s" name="document-type-%s">
<option value="">Select Document Type</option> <option value="">Select Document Type</option>
<option value="1">Job Paperwork</option> <option value="01" selected>Job Paperwork</option>
<option value="2">Job Vendor Bill</option> <option value="02">Job Vendor Bill</option>
<option value="3">Job Quality Control Picture</option> <option value="07">Generic Attachment</option>
<option value="5">Deficiency Repair Proposal</option>
<option value="7">Generic Attachment</option>
<option value="8">Avatar Image</option>
<option value="9">Import</option>
<option value="10">Blank Paperwork</option> <option value="10">Blank Paperwork</option>
<option value="11">Work Acknowledgement</option>
<option value="12">Logo</option>
<option value="14">Job Invoice</option> <option value="14">Job Invoice</option>
</select> </select>
</div> </div>

2
templates/partials/csv_upload.html

@ -5,7 +5,7 @@
<label>Select CSV file with job numbers:</label> <label>Select CSV file with job numbers:</label>
<input class="card-input" type="file" id="csv-file" name="csvFile" accept=".csv" required> <input class="card-input" type="file" id="csv-file" name="csvFile" accept=".csv" required>
<button class="btn-primary" hx-post="/process-csv" hx-target="#csv-preview-content" <button type="button" class="btn-primary" hx-post="/process-csv" hx-target="#job-ids-container"
hx-encoding="multipart/form-data" hx-include="#csv-file" hx-indicator="#csv-loading-indicator"> hx-encoding="multipart/form-data" hx-include="#csv-file" hx-indicator="#csv-loading-indicator">
Upload CSV Upload CSV
</button> </button>

35
templates/partials/document_upload.html

@ -1,6 +1,35 @@
{{define "document_upload"}} {{define "document_upload"}}
<h2>Document Uploads</h2> <h2>Document Uploads</h2>
{{template "csv_upload"}}
{{template "document_upload_form"}} <form id="upload-form" hx-post="/upload-documents" hx-encoding="multipart/form-data" hx-target="#upload-results"
{{template "upload_actions"}} hx-indicator="#upload-loading-indicator">
<!-- Job numbers will be added here by the CSV process -->
<div id="job-ids-container">
<!-- Hidden input placeholder for job IDs -->
</div>
<div class="upload-container">
<!-- Step 1: CSV Upload -->
{{template "csv_upload" .}}
<!-- Step 2: Document Upload -->
{{template "document_upload_form" .}}
<!-- Step 3: Submit -->
<div class="content">
<h3 class="submenu-header">Step 3: Submit Uploads</h3>
<div>
<button type="submit" class="success-button" id="submit-button">Upload Documents to Jobs</button>
<div id="upload-loading-indicator" class="htmx-indicator">
<span>Uploading...</span>
<div class="loading-indicator"></div>
</div>
<div id="upload-results" class="upload-results"></div>
</div>
</div>
</div>
</form>
{{end}} {{end}}

12
templates/partials/document_upload_form.html

@ -19,16 +19,10 @@
<label>Document Type:</label> <label>Document Type:</label>
<select class="card-input" id="document-type-1" name="document-type-1"> <select class="card-input" id="document-type-1" name="document-type-1">
<option value="">Select Document Type</option> <option value="">Select Document Type</option>
<option value="1">Job Paperwork</option> <option value="01" selected>Job Paperwork</option>
<option value="2">Job Vendor Bill</option> <option value="02">Job Vendor Bill</option>
<option value="3">Job Quality Control Picture</option> <option value="07">Generic Attachment</option>
<option value="5">Deficiency Repair Proposal</option>
<option value="7">Generic Attachment</option>
<option value="8">Avatar Image</option>
<option value="9">Import</option>
<option value="10">Blank Paperwork</option> <option value="10">Blank Paperwork</option>
<option value="11">Work Acknowledgement</option>
<option value="12">Logo</option>
<option value="14">Job Invoice</option> <option value="14">Job Invoice</option>
</select> </select>
</div> </div>

3
templates/partials/upload_actions.html

@ -3,9 +3,10 @@
<h3 class="submenu-header">Step 3: Submit Uploads</h3> <h3 class="submenu-header">Step 3: Submit Uploads</h3>
<div> <div>
<form id="upload-form" hx-post="/upload-documents" hx-encoding="multipart/form-data" <form id="upload-form" hx-post="/upload-documents" hx-encoding="multipart/form-data"
hx-include="#csv-preview-content,[name^='document-file'],[name^='document-name'],[name^='document-type']" hx-include="[name='jobNumbers'],[name^='document-file'],[name^='document-name'],[name^='document-type']"
hx-target="#upload-results" hx-indicator="#upload-loading-indicator"> hx-target="#upload-results" hx-indicator="#upload-loading-indicator">
<input type="hidden" name="job-ids" id="job-ids-field">
<button type="submit" class="success-button">Upload Documents to Jobs</button> <button type="submit" class="success-button">Upload Documents to Jobs</button>
<div id="upload-progress" style="display: none; margin-top: 1rem;"> <div id="upload-progress" style="display: none; margin-top: 1rem;">

Loading…
Cancel
Save