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