diff --git a/internal/api/attachments.go b/internal/api/attachments.go new file mode 100644 index 0000000..c9d7ce9 --- /dev/null +++ b/internal/api/attachments.go @@ -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 +} diff --git a/internal/handlers/web/documents.go b/internal/handlers/web/documents.go new file mode 100644 index 0000000..9f94371 --- /dev/null +++ b/internal/handlers/web/documents.go @@ -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("", strings.Join(validJobNumbers, ",")) + jobListHTML += "" + // Add JavaScript to make the CSV preview visible + jobListHTML += "" + } else { + jobListHTML = "

No valid job numbers found in the CSV file.

" + } + + 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("
") + resultHTML.WriteString("

Upload Results

") + + for jobID, jobResults := range results { + resultHTML.WriteString(fmt.Sprintf("
Job #%s
") + } + + resultHTML.WriteString("
") + + 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(` +
+
+ + +
+ +
+
+ + +
+ +
+ + +
+
+ + +
+ `, 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("")) +} diff --git a/static/js/htmx.min.js b/static/js/htmx.min.js new file mode 100644 index 0000000..6763086 --- /dev/null +++ b/static/js/htmx.min.js @@ -0,0 +1 @@ +Redirecting to https://unpkg.com/htmx.org@1.9.12/dist/htmx.min.js \ No newline at end of file