3 changed files with 516 additions and 0 deletions
@ -0,0 +1,152 @@ |
|||||
|
package api |
||||
|
|
||||
|
import ( |
||||
|
"bytes" |
||||
|
"encoding/json" |
||||
|
"fmt" |
||||
|
"io" |
||||
|
"log" |
||||
|
"mime/multipart" |
||||
|
"net/http" |
||||
|
"path/filepath" |
||||
|
) |
||||
|
|
||||
|
// UploadAttachment uploads a file as an attachment to a job
|
||||
|
func (s *Session) UploadAttachment(jobID, filename, purpose string, fileContent []byte) (map[string]interface{}, error) { |
||||
|
url := fmt.Sprintf("%s/attachment", BaseURL) |
||||
|
|
||||
|
// Create a buffer to hold the form data
|
||||
|
var b bytes.Buffer |
||||
|
w := multipart.NewWriter(&b) |
||||
|
|
||||
|
// Add the purpose (attachment type)
|
||||
|
if err := w.WriteField("purpose", purpose); err != nil { |
||||
|
return nil, fmt.Errorf("error writing purpose field: %v", err) |
||||
|
} |
||||
|
|
||||
|
// Add the job ID
|
||||
|
if err := w.WriteField("job", jobID); err != nil { |
||||
|
return nil, fmt.Errorf("error writing job field: %v", err) |
||||
|
} |
||||
|
|
||||
|
// Add the file
|
||||
|
fw, err := w.CreateFormFile("filename", filepath.Base(filename)) |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("error creating form file: %v", err) |
||||
|
} |
||||
|
|
||||
|
if _, err := io.Copy(fw, bytes.NewReader(fileContent)); err != nil { |
||||
|
return nil, fmt.Errorf("error copying file content: %v", err) |
||||
|
} |
||||
|
|
||||
|
// Close the writer
|
||||
|
if err := w.Close(); err != nil { |
||||
|
return nil, fmt.Errorf("error closing multipart writer: %v", err) |
||||
|
} |
||||
|
|
||||
|
// Create the request
|
||||
|
req, err := http.NewRequest("POST", url, &b) |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("error creating request: %v", err) |
||||
|
} |
||||
|
|
||||
|
// Set headers
|
||||
|
req.Header.Set("Content-Type", w.FormDataContentType()) |
||||
|
req.Header.Set("Cookie", s.Cookie) |
||||
|
|
||||
|
// Send the request
|
||||
|
resp, err := s.Client.Do(req) |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("error sending request: %v", err) |
||||
|
} |
||||
|
defer resp.Body.Close() |
||||
|
|
||||
|
// Read the response
|
||||
|
body, err := io.ReadAll(resp.Body) |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("error reading response body: %v", err) |
||||
|
} |
||||
|
|
||||
|
// Check for errors
|
||||
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { |
||||
|
return nil, fmt.Errorf("API returned error: %s - %s", resp.Status, string(body)) |
||||
|
} |
||||
|
|
||||
|
// Parse the response
|
||||
|
var result map[string]interface{} |
||||
|
if err := json.Unmarshal(body, &result); err != nil { |
||||
|
return nil, fmt.Errorf("error parsing response: %v", err) |
||||
|
} |
||||
|
|
||||
|
log.Printf("Successfully uploaded attachment %s to job %s", filename, jobID) |
||||
|
return result, nil |
||||
|
} |
||||
|
|
||||
|
// GetAttachmentInfo gets information about a specific attachment
|
||||
|
func (s *Session) GetAttachmentInfo(attachmentID string) (map[string]interface{}, error) { |
||||
|
url := fmt.Sprintf("%s/attachment/%s", BaseURL, attachmentID) |
||||
|
|
||||
|
// Create the request
|
||||
|
req, err := http.NewRequest("GET", url, nil) |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("error creating request: %v", err) |
||||
|
} |
||||
|
|
||||
|
// Set headers
|
||||
|
req.Header.Set("Cookie", s.Cookie) |
||||
|
|
||||
|
// Send the request
|
||||
|
resp, err := s.Client.Do(req) |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("error sending request: %v", err) |
||||
|
} |
||||
|
defer resp.Body.Close() |
||||
|
|
||||
|
// Read the response
|
||||
|
body, err := io.ReadAll(resp.Body) |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("error reading response body: %v", err) |
||||
|
} |
||||
|
|
||||
|
// Check for errors
|
||||
|
if resp.StatusCode != http.StatusOK { |
||||
|
return nil, fmt.Errorf("API returned error: %s - %s", resp.Status, string(body)) |
||||
|
} |
||||
|
|
||||
|
// Parse the response
|
||||
|
var result map[string]interface{} |
||||
|
if err := json.Unmarshal(body, &result); err != nil { |
||||
|
return nil, fmt.Errorf("error parsing response: %v", err) |
||||
|
} |
||||
|
|
||||
|
return result, nil |
||||
|
} |
||||
|
|
||||
|
// DeleteAttachment deletes an attachment
|
||||
|
func (s *Session) DeleteAttachment(attachmentID string) error { |
||||
|
url := fmt.Sprintf("%s/attachment/%s", BaseURL, attachmentID) |
||||
|
|
||||
|
// Create the request
|
||||
|
req, err := http.NewRequest("DELETE", url, nil) |
||||
|
if err != nil { |
||||
|
return fmt.Errorf("error creating request: %v", err) |
||||
|
} |
||||
|
|
||||
|
// Set headers
|
||||
|
req.Header.Set("Cookie", s.Cookie) |
||||
|
|
||||
|
// Send the request
|
||||
|
resp, err := s.Client.Do(req) |
||||
|
if err != nil { |
||||
|
return fmt.Errorf("error sending request: %v", err) |
||||
|
} |
||||
|
defer resp.Body.Close() |
||||
|
|
||||
|
// Check for errors
|
||||
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { |
||||
|
body, _ := io.ReadAll(resp.Body) |
||||
|
return fmt.Errorf("API returned error: %s - %s", resp.Status, string(body)) |
||||
|
} |
||||
|
|
||||
|
return nil |
||||
|
} |
||||
@ -0,0 +1,363 @@ |
|||||
|
package web |
||||
|
|
||||
|
import ( |
||||
|
"bytes" |
||||
|
"encoding/csv" |
||||
|
"fmt" |
||||
|
"io" |
||||
|
"log" |
||||
|
"mime/multipart" |
||||
|
"net/http" |
||||
|
"regexp" |
||||
|
"strconv" |
||||
|
"strings" |
||||
|
"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 |
||||
|
} |
||||
|
|
||||
|
// Extract job numbers from the CSV
|
||||
|
var jobNumbers []string |
||||
|
for _, row := range csvData { |
||||
|
if len(row) > 0 && row[0] != "" { |
||||
|
// Trim whitespace and skip headers or empty lines
|
||||
|
jobNum := strings.TrimSpace(row[0]) |
||||
|
if jobNum != "Job Number" && jobNum != "JobNumber" && jobNum != "Job" && jobNum != "" { |
||||
|
jobNumbers = append(jobNumbers, jobNum) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Validate each job number (optional: could make API calls to verify they exist)
|
||||
|
var validJobNumbers []string |
||||
|
validJobNumbers = append(validJobNumbers, jobNumbers...) |
||||
|
|
||||
|
// Generate HTML for job list
|
||||
|
var jobListHTML string |
||||
|
if len(validJobNumbers) > 0 { |
||||
|
// Add a hidden input with comma-separated job IDs for form submission
|
||||
|
jobListHTML = fmt.Sprintf("<input type='hidden' name='jobNumbers' value='%s'>", strings.Join(validJobNumbers, ",")) |
||||
|
jobListHTML += "<ul class='job-list'>" |
||||
|
for _, jobNum := range validJobNumbers { |
||||
|
jobListHTML += fmt.Sprintf("<li data-job-id='%s'>Job #%s</li>", jobNum, jobNum) |
||||
|
} |
||||
|
jobListHTML += "</ul>" |
||||
|
// Add JavaScript to make the CSV preview visible
|
||||
|
jobListHTML += "<script>document.getElementById('csv-preview').style.display = 'block';</script>" |
||||
|
} else { |
||||
|
jobListHTML = "<p>No valid job numbers found in the CSV file.</p>" |
||||
|
} |
||||
|
|
||||
|
w.Header().Set("Content-Type", "text/html") |
||||
|
w.Write([]byte(jobListHTML)) |
||||
|
} |
||||
|
|
||||
|
// 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 |
||||
|
} |
||||
|
|
||||
|
// Parse the multipart form with a 30MB limit
|
||||
|
if err := r.ParseMultipartForm(30 << 20); err != nil { |
||||
|
http.Error(w, "Unable to parse form: "+err.Error(), http.StatusBadRequest) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// Get the job numbers
|
||||
|
jobNumbers := r.FormValue("jobNumbers") |
||||
|
if jobNumbers == "" { |
||||
|
http.Error(w, "No job numbers provided", http.StatusBadRequest) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// Split the job numbers
|
||||
|
jobs := strings.Split(jobNumbers, ",") |
||||
|
if len(jobs) == 0 { |
||||
|
http.Error(w, "No valid job numbers provided", http.StatusBadRequest) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// Regular expression to match file field patterns
|
||||
|
filePattern := regexp.MustCompile(`document-file-(\d+)`) |
||||
|
|
||||
|
// Collect document data
|
||||
|
type DocumentData struct { |
||||
|
File multipart.File |
||||
|
Header *multipart.FileHeader |
||||
|
Name string |
||||
|
Type string |
||||
|
Index int |
||||
|
} |
||||
|
|
||||
|
var documents []DocumentData |
||||
|
|
||||
|
// First, identify all available indices
|
||||
|
var indices []int |
||||
|
for key := range r.MultipartForm.File { |
||||
|
if matches := filePattern.FindStringSubmatch(key); len(matches) > 1 { |
||||
|
if index, err := strconv.Atoi(matches[1]); err == nil { |
||||
|
indices = append(indices, index) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Process each document
|
||||
|
for _, index := range indices { |
||||
|
fileKey := fmt.Sprintf("document-file-%d", index) |
||||
|
nameKey := fmt.Sprintf("document-name-%d", index) |
||||
|
typeKey := fmt.Sprintf("document-type-%d", index) |
||||
|
|
||||
|
fileHeaders := r.MultipartForm.File[fileKey] |
||||
|
if len(fileHeaders) == 0 { |
||||
|
continue // Skip if no file uploaded
|
||||
|
} |
||||
|
|
||||
|
fileHeader := fileHeaders[0] |
||||
|
file, err := fileHeader.Open() |
||||
|
if err != nil { |
||||
|
log.Printf("Error opening file %s: %v", fileHeader.Filename, err) |
||||
|
continue |
||||
|
} |
||||
|
|
||||
|
// Get document name (use filename if not provided)
|
||||
|
documentName := r.FormValue(nameKey) |
||||
|
if documentName == "" { |
||||
|
documentName = fileHeader.Filename |
||||
|
} |
||||
|
|
||||
|
// Get document type
|
||||
|
documentType := r.FormValue(typeKey) |
||||
|
if documentType == "" { |
||||
|
log.Printf("No document type for file %s", fileHeader.Filename) |
||||
|
continue |
||||
|
} |
||||
|
|
||||
|
documents = append(documents, DocumentData{ |
||||
|
File: file, |
||||
|
Header: fileHeader, |
||||
|
Name: documentName, |
||||
|
Type: documentType, |
||||
|
Index: index, |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
// Process each file and upload to each job
|
||||
|
results := make(map[string][]map[string]interface{}) |
||||
|
for _, doc := range documents { |
||||
|
defer doc.File.Close() |
||||
|
|
||||
|
// Read file content
|
||||
|
fileContent, err := io.ReadAll(doc.File) |
||||
|
if err != nil { |
||||
|
http.Error(w, fmt.Sprintf("Error reading file %s: %v", doc.Header.Filename, err), http.StatusInternalServerError) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// Upload to each job
|
||||
|
for _, jobID := range jobs { |
||||
|
// Call the ServiceTrade API
|
||||
|
result, err := session.UploadAttachment(jobID, doc.Name, doc.Type, fileContent) |
||||
|
if err != nil { |
||||
|
log.Printf("Error uploading %s to job %s: %v", doc.Name, jobID, err) |
||||
|
if _, exists := results[jobID]; !exists { |
||||
|
results[jobID] = []map[string]interface{}{} |
||||
|
} |
||||
|
results[jobID] = append(results[jobID], map[string]interface{}{ |
||||
|
"filename": doc.Name, |
||||
|
"success": false, |
||||
|
"error": err.Error(), |
||||
|
}) |
||||
|
continue |
||||
|
} |
||||
|
|
||||
|
// Record the success
|
||||
|
if _, exists := results[jobID]; !exists { |
||||
|
results[jobID] = []map[string]interface{}{} |
||||
|
} |
||||
|
results[jobID] = append(results[jobID], map[string]interface{}{ |
||||
|
"filename": doc.Name, |
||||
|
"success": true, |
||||
|
"data": result, |
||||
|
}) |
||||
|
log.Printf("Successfully uploaded %s to job %s", doc.Name, jobID) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Generate HTML for results
|
||||
|
var resultHTML bytes.Buffer |
||||
|
resultHTML.WriteString("<div class='upload-results'>") |
||||
|
resultHTML.WriteString("<h4>Upload Results</h4>") |
||||
|
|
||||
|
for jobID, jobResults := range results { |
||||
|
resultHTML.WriteString(fmt.Sprintf("<div class='job-result'><h5>Job #%s</h5><ul>", jobID)) |
||||
|
|
||||
|
for _, result := range jobResults { |
||||
|
filename := result["filename"].(string) |
||||
|
success := result["success"].(bool) |
||||
|
|
||||
|
if success { |
||||
|
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("</div>") |
||||
|
|
||||
|
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">Job Paperwork</option> |
||||
|
<option value="2">Job Vendor Bill</option> |
||||
|
<option value="3">Job Quality Control Picture</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="11">Work Acknowledgement</option> |
||||
|
<option value="12">Logo</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("")) |
||||
|
} |
||||
@ -0,0 +1 @@ |
|||||
|
Redirecting to https://unpkg.com/htmx.org@1.9.12/dist/htmx.min.js
|
||||
Loading…
Reference in new issue