Browse Source

feat: creating basics of layout for document removal

document-upload-removal-layout-update
nic 10 months ago
parent
commit
f66f8001b3
  1. 7
      apps/web/main.go
  2. 37
      internal/api/attachments.go
  3. 408
      internal/handlers/web/document_remove.go
  4. 35
      internal/handlers/web/documents.go
  5. 6
      static/css/upload.css
  6. 3
      templates/dashboard.html
  7. 34
      templates/partials/document_remove.html
  8. 25
      templates/partials/document_remove_csv.html
  9. 28
      templates/partials/document_remove_form.html
  10. 2
      templates/partials/document_upload.html
  11. 54
      templates/partials/job_attachments.html
  12. 27
      templates/partials/job_list.html
  13. 51
      templates/partials/removal_results.html

7
apps/web/main.go

@ -65,6 +65,13 @@ func main() {
protected.HandleFunc("/document-field-add", web.DocumentFieldAddHandler).Methods("GET")
protected.HandleFunc("/document-field-remove", web.DocumentFieldRemoveHandler).Methods("GET")
// Document removal routes
protected.HandleFunc("/documents/remove", web.DocumentRemoveHandler).Methods("GET")
protected.HandleFunc("/documents/remove/process-csv", web.ProcessRemoveCSVHandler).Methods("POST")
protected.HandleFunc("/documents/remove/job-selection", web.JobSelectionHandler).Methods("POST")
protected.HandleFunc("/documents/remove/job/{jobID}", web.GetJobAttachmentsHandler).Methods("GET")
protected.HandleFunc("/documents/remove/job/{jobID}", web.RemoveJobAttachmentsHandler).Methods("POST")
log.Println("Server starting on :8080")
log.Fatal(http.ListenAndServe(":8080", r))
}

37
internal/api/attachments.go

@ -237,3 +237,40 @@ func (s *Session) DeleteAttachment(attachmentID string) error {
return nil
}
// GetJobAttachments retrieves all attachments for a given job ID
func (s *Session) GetJobAttachments(jobID string) ([]map[string]interface{}, error) {
url := fmt.Sprintf("%s/job/%s/paperwork", BaseURL, jobID)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("error creating request: %v", err)
}
// Add authorization header
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()
// Check for error response
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("API returned error: %d %s - %s", resp.StatusCode, resp.Status, string(body))
}
// Parse the response
var result struct {
Attachments []map[string]interface{} `json:"objects"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("error parsing response: %v", err)
}
return result.Attachments, nil
}

408
internal/handlers/web/document_remove.go

@ -0,0 +1,408 @@
package web
import (
"bytes"
"encoding/csv"
"fmt"
"log"
"net/http"
"strconv"
"strings"
"sync"
"time"
root "marmic/servicetrade-toolbox"
"marmic/servicetrade-toolbox/internal/api"
"github.com/gorilla/mux"
)
// DocumentRemoveHandler handles the document removal page
func DocumentRemoveHandler(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 Removal",
"Session": session,
}
if r.Header.Get("HX-Request") == "true" {
// For HTMX requests, just send the document_remove partial
if err := tmpl.ExecuteTemplate(w, "document_remove", 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_remove into a buffer
var contentBuf bytes.Buffer
if err := tmpl.ExecuteTemplate(&contentBuf, "document_remove", 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
}
}
}
// ProcessRemoveCSVHandler processes a CSV file containing job IDs for document removal
func ProcessRemoveCSVHandler(w http.ResponseWriter, r *http.Request) {
// We don't use the session in the body but check it for auth
_, 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("csv")
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 IDs from CSV - first column only for simplicity
var jobIDs []string
for rowIndex, row := range csvData {
// Skip header row if present
if rowIndex == 0 && len(csvData) > 1 {
// Check if first row looks like a header (non-numeric content)
_, err := strconv.Atoi(strings.TrimSpace(row[0]))
if err != nil {
continue // Skip this row as it's likely a header
}
}
if len(row) > 0 && row[0] != "" {
jobID := strings.TrimSpace(row[0])
if jobID != "" {
jobIDs = append(jobIDs, jobID)
}
}
}
totalJobs := len(jobIDs)
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(jobIDs, ",")
jobSampleDisplay := getJobSampleDisplay(jobIDs)
// Generate HTML for the main response (hidden input for job-ids-removal-container)
var responseHTML bytes.Buffer
responseHTML.WriteString(fmt.Sprintf(`<input type="hidden" name="jobIDs" 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-removal" class="fade-me-out" style="display: block !important; margin-top: 1rem;" hx-swap-oob="true">
<h4>Detected Jobs</h4>
<div id="csv-preview-content-removal" class="job-list">
<div class="csv-sample">
<p>Sample job IDs: %s</p>
</div>
</div>
</div>
`, jobSampleDisplay))
// Send the response with the hidden input and preview
w.Header().Set("Content-Type", "text/html")
w.Write(responseHTML.Bytes())
}
// After the CSV is processed, a separate request should load job attachments
// This handler is for Step 2
func JobSelectionHandler(w http.ResponseWriter, r *http.Request) {
// We don't use the session directly but check it for auth
_, ok := r.Context().Value("session").(*api.Session)
if !ok {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
jobIDs := r.FormValue("jobIDs")
if jobIDs == "" {
http.Error(w, "No job IDs provided", http.StatusBadRequest)
return
}
jobs := strings.Split(jobIDs, ",")
if len(jobs) == 0 {
http.Error(w, "No valid job IDs found", http.StatusBadRequest)
return
}
var resultHTML bytes.Buffer
resultHTML.WriteString("<div class=\"job-list-container\">")
resultHTML.WriteString("<h4>Jobs from CSV</h4>")
resultHTML.WriteString("<p>Click a job to view and manage its documents.</p>")
resultHTML.WriteString("<div class=\"job-list\">")
for _, jobID := range jobs {
resultHTML.WriteString(fmt.Sprintf(`
<div class="job-item">
<a href="#job-%s" hx-get="/documents/remove/job/%s" hx-target="#job-%s-attachments"
hx-swap="outerHTML" hx-trigger="click">
Job #%s
</a>
<div id="job-%s-attachments"></div>
</div>
`, jobID, jobID, jobID, jobID, jobID))
}
resultHTML.WriteString("</div>") // End of job-list
resultHTML.WriteString("</div>") // End of job-list-container
w.Header().Set("Content-Type", "text/html")
w.Write(resultHTML.Bytes())
}
// GetJobAttachmentsHandler retrieves attachments for a specific job
func GetJobAttachmentsHandler(w http.ResponseWriter, r *http.Request) {
session, ok := r.Context().Value("session").(*api.Session)
if !ok {
vars := mux.Vars(r)
jobID := vars["jobID"]
renderErrorTemplate(w, "job_attachments", "You must be logged in to use this feature", jobID)
return
}
vars := mux.Vars(r)
jobID := vars["jobID"]
if jobID == "" {
renderErrorTemplate(w, "job_attachments", "Job ID is required", jobID)
return
}
// Get attachments for the job
attachments, err := session.GetJobAttachments(jobID)
if err != nil {
renderErrorTemplate(w, "job_attachments", fmt.Sprintf("Failed to get attachments: %v", err), jobID)
return
}
tmpl := root.WebTemplates
data := map[string]interface{}{
"JobID": jobID,
"Attachments": attachments,
"Session": session,
}
if err := tmpl.ExecuteTemplate(w, "job_attachments", data); err != nil {
log.Printf("Template execution error: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
}
// RemoveJobAttachmentsHandler handles the removal of attachments from a job
func RemoveJobAttachmentsHandler(w http.ResponseWriter, r *http.Request) {
session, ok := r.Context().Value("session").(*api.Session)
if !ok {
vars := mux.Vars(r)
jobID := vars["jobID"]
renderErrorTemplate(w, "job_attachments", "You must be logged in to use this feature", jobID)
return
}
vars := mux.Vars(r)
jobID := vars["jobID"]
if jobID == "" {
renderErrorTemplate(w, "job_attachments", "Job ID is required", jobID)
return
}
// Parse the form
if err := r.ParseForm(); err != nil {
renderErrorTemplate(w, "job_attachments", fmt.Sprintf("Error parsing form: %v", err), jobID)
return
}
// Get the attachment IDs to remove
attachmentIDs := r.PostForm["attachment_ids"]
if len(attachmentIDs) == 0 {
renderErrorTemplate(w, "job_attachments", "No attachments selected for deletion", jobID)
return
}
// Process deletion with rate limiting (max 5 concurrent requests)
results := struct {
Success bool
SuccessCount int
ErrorCount int
Files []struct {
Name string
Success bool
Error string
}
}{
Success: true,
Files: make([]struct {
Name string
Success bool
Error string
}, 0, len(attachmentIDs)),
}
// Set up rate limiting
semaphore := make(chan struct{}, 5) // Allow 5 concurrent requests
var wg sync.WaitGroup
var mu sync.Mutex // Mutex for updating results
for _, attachmentID := range attachmentIDs {
wg.Add(1)
semaphore <- struct{}{} // Acquire semaphore
go func(id string) {
defer wg.Done()
defer func() { <-semaphore }() // Release semaphore
// Get attachment info first to get the name
attachmentInfo, err := session.GetAttachmentInfo(id)
fileResult := struct {
Name string
Success bool
Error string
}{
Name: fmt.Sprintf("Attachment ID: %s", id),
}
if err == nil {
// Get description if available
if description, ok := attachmentInfo["description"].(string); ok {
fileResult.Name = description
}
}
// Delete the attachment
err = session.DeleteAttachment(id)
mu.Lock()
defer mu.Unlock()
if err != nil {
fileResult.Success = false
fileResult.Error = err.Error()
results.ErrorCount++
results.Success = false
} else {
fileResult.Success = true
results.SuccessCount++
}
results.Files = append(results.Files, fileResult)
// Add a slight delay to avoid overwhelming the API
time.Sleep(100 * time.Millisecond)
}(attachmentID)
}
wg.Wait() // Wait for all deletions to complete
tmpl := root.WebTemplates
data := map[string]interface{}{
"JobID": jobID,
"Session": session,
"SuccessCount": results.SuccessCount,
"ErrorCount": results.ErrorCount,
"JobsProcessed": 1,
"Results": []map[string]interface{}{
{
"JobID": jobID,
"Success": results.Success,
"Files": results.Files,
},
},
}
// Generate HTML for results that will go to the removal_results div
if err := tmpl.ExecuteTemplate(w, "removal_results", data); err != nil {
log.Printf("Template execution error: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
}
// JobListHandler renders the job list for document removal
func JobListHandler(w http.ResponseWriter, r *http.Request) {
session, ok := r.Context().Value("session").(*api.Session)
if !ok {
renderErrorTemplate(w, "job_list", "You must be logged in to use this feature")
return
}
if err := r.ParseForm(); err != nil {
renderErrorTemplate(w, "job_list", fmt.Sprintf("Error parsing form: %v", err))
return
}
tmpl := root.WebTemplates
data := map[string]interface{}{
"JobIDs": r.PostForm["job_ids"],
"Session": session,
}
if err := tmpl.ExecuteTemplate(w, "job_list", data); err != nil {
log.Printf("Template execution error: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
}
// Helper function to render error templates
func renderErrorTemplate(w http.ResponseWriter, templateName, errorMsg string, jobID ...string) {
tmpl := root.WebTemplates
data := map[string]interface{}{
"Error": errorMsg,
}
if len(jobID) > 0 && jobID[0] != "" {
data["JobID"] = jobID[0]
}
if err := tmpl.ExecuteTemplate(w, templateName, data); err != nil {
log.Printf("Template execution error: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}

35
internal/handlers/web/documents.go

@ -147,32 +147,29 @@ func ProcessCSVHandler(w http.ResponseWriter, r *http.Request) {
// Create a hidden input with the job IDs
jobsValue := strings.Join(jobNumbers, ",")
// Generate HTML for job preview - don't show all IDs for large datasets
var jobPreviewHTML string
if totalJobs > 0 {
jobPreviewHTML = fmt.Sprintf(`
<input type="hidden" name="jobNumbers" value="%s">
<style>
#csv-preview { display: block !important; }
</style>
<div id="csv-preview-content">
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>
`, jobsValue, totalJobs, getJobSampleDisplay(jobNumbers))
} else {
jobPreviewHTML = `
<p>No valid job numbers found in the CSV file.</p>
`
}
</div>
`, totalJobs, jobSampleDisplay))
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(jobPreviewHTML))
w.Write(responseHTML.Bytes())
}
// Helper function to show sample job IDs with a limit

6
static/css/upload.css

@ -227,7 +227,8 @@
}
/* CSV Preview styles */
#csv-preview {
#csv-preview,
#csv-preview-removal {
margin-top: 1rem;
padding: 1rem;
background-color: var(--content-bg);
@ -243,7 +244,8 @@
box-shadow: var(--dashboard-shadow);
}
#csv-preview p {
#csv-preview p,
#csv-preview-removal p {
margin: 0.5rem 0;
color: var(--content-text);
}

3
templates/dashboard.html

@ -10,8 +10,7 @@
{{template "document_upload" .}}
</div>
<div class="dashboard-item">
<h3>Manage Companies</h3>
<a href="/companies" hx-get="/companies" hx-target="#content">View Companies</a>
{{template "document_remove" .}}
</div>
<!-- Add more dashboard items as needed -->
</div>

34
templates/partials/document_remove.html

@ -0,0 +1,34 @@
{{define "document_remove"}}
<h2>Document Removal</h2>
<div class="upload-container">
<!-- Step 1: CSV Upload -->
<div class="content">
<h3 class="submenu-header">Step 1: Upload CSV file with Job IDs</h3>
{{template "document_remove_csv" .}}
</div>
<!-- Step 2: Document Selection -->
<div class="content">
<h3 class="submenu-header">Step 2: Select Documents to Remove</h3>
{{template "document_remove_form" .}}
<!-- Job IDs container moved inside the form for better structure -->
<div id="job-ids-removal-container" style="display: none;">
<!-- Hidden input placeholder for job IDs -->
</div>
<div id="job-results" class="job-selection">
<!-- Jobs and document selection will appear here -->
</div>
</div>
<!-- Step 3: Results -->
<div class="content">
<h3 class="submenu-header">Step 3: Removal Results</h3>
<div id="removal-results" class="upload-results">
<!-- Results will appear here after removing documents -->
</div>
</div>
</div>
{{end}}

25
templates/partials/document_remove_csv.html

@ -0,0 +1,25 @@
{{define "document_remove_csv"}}
<div>
<label>Select CSV file with job IDs:</label>
<input class="card-input" type="file" id="csv-file-removal" name="csv" accept=".csv" required>
<button type="button" class="btn-primary" hx-post="/documents/remove/process-csv"
hx-target="#job-ids-removal-container" hx-encoding="multipart/form-data" hx-include="#csv-file-removal"
hx-indicator="#csv-loading-indicator-removal">
Process CSV
</button>
<div id="csv-loading-indicator-removal" class="htmx-indicator" style="display: none;">
<span>Processing CSV...</span>
<div class="loading-indicator"></div>
</div>
</div>
<div id="csv-preview-removal" class="fade-me-out" style="display: none; margin-top: 1rem;">
<h4>Detected Jobs</h4>
<div id="csv-preview-content-removal" class="job-list">
<!-- Job numbers will be displayed here -->
<p>No jobs loaded yet</p>
</div>
</div>
{{end}}

28
templates/partials/document_remove_form.html

@ -0,0 +1,28 @@
{{define "document_remove_form"}}
<div id="document-selection-container">
<form id="job-selection-form" hx-post="/documents/remove/job-selection" hx-target="#job-results"
hx-indicator="#job-loading-indicator" hx-include="#job-ids-removal-container">
<!-- The hidden input with jobIDs will be inserted into job-ids-removal-container by the CSV process -->
<button type="submit" id="load-jobs-btn" class="btn-primary">
Load Jobs
</button>
<div id="job-loading-indicator" class="htmx-indicator" style="display: none;">
<span>Loading jobs...</span>
<div class="loading-indicator"></div>
</div>
</form>
<div id="document-rows-container" class="document-rows">
<!-- The job list will be loaded here after clicking "Load Jobs" -->
<p>After uploading a CSV and clicking "Load Jobs", you'll see jobs here.</p>
</div>
<div class="document-actions">
<button type="button" id="remove-selected-btn" class="btn warning-button" disabled>
Remove Selected Documents
</button>
</div>
</div>
{{end}}

2
templates/partials/document_upload.html

@ -5,7 +5,7 @@
hx-indicator="#upload-loading-indicator">
<!-- Job numbers will be added here by the CSV process -->
<div id="job-ids-container">
<div id="job-ids-container" style="display: none;">
<!-- Hidden input placeholder for job IDs -->
</div>

54
templates/partials/job_attachments.html

@ -0,0 +1,54 @@
{{define "job_attachments"}}
<div class="job-attachments" id="job-{{.JobID}}-attachments">
<h4>Job #{{.JobID}}</h4>
{{if .Error}}
<div class="error-message">Error: {{.Error}}</div>
{{else}}
{{if len .Attachments}}
<form hx-post="/documents/remove/job/{{.JobID}}" hx-target="#removal-results"
hx-indicator="#delete-loading-indicator-{{.JobID}}">
<div class="attachments-list">
{{range .Attachments}}
<div class="attachment-item">
<label>
<input type="checkbox" name="attachment_ids" value="{{.id}}">
<span class="attachment-name">{{.description}}</span>
<span class="attachment-info">
<span class="attachment-type">{{template "document_type" .purposeId}}</span>
<span class="attachment-date">{{.lastModified}}</span>
</span>
</label>
</div>
{{end}}
</div>
<button type="submit" class="btn btn-danger">Delete Selected Documents</button>
<div id="delete-loading-indicator-{{.JobID}}" class="htmx-indicator">
<div class="spinner"></div>
<span>Deleting documents...</span>
</div>
</form>
{{else}}
<div class="info-message">No attachments found for this job.</div>
{{end}}
{{end}}
</div>
{{end}}
{{define "document_type"}}
{{if eq . 0}}Job Paperwork
{{else if eq . 1}}Job Vendor Bill
{{else if eq . 2}}Job Picture
{{else if eq . 3}}Deficiency Repair Proposal
{{else if eq . 4}}Generic Attachment
{{else if eq . 5}}Avatar Image
{{else if eq . 6}}Import
{{else if eq . 7}}Blank Paperwork
{{else if eq . 8}}Work Acknowledgement
{{else if eq . 9}}Logo
{{else if eq . 10}}Job Invoice
{{else}}Unknown ({{.}})
{{end}}
{{end}}

27
templates/partials/job_list.html

@ -0,0 +1,27 @@
{{define "job_list"}}
<div class="job-list-container">
<h3>Jobs from CSV</h3>
{{if .Error}}
<div class="error-message">Error: {{.Error}}</div>
{{else}}
{{if .JobIDs}}
<p>Found {{len .JobIDs}} job(s) in the CSV file. Click a job to view and manage its documents.</p>
<div class="job-list">
{{range .JobIDs}}
<div class="job-item">
<a href="#job-{{.}}" hx-get="/documents/remove/job/{{.}}" hx-target="#job-{{.}}-attachments"
hx-swap="outerHTML" hx-trigger="click">
Job #{{.}}
</a>
<div id="job-{{.}}-attachments"></div>
</div>
{{end}}
</div>
{{else}}
<div class="info-message">No jobs found. Please upload a CSV file with job IDs.</div>
{{end}}
{{end}}
</div>
{{end}}

51
templates/partials/removal_results.html

@ -0,0 +1,51 @@
{{define "removal_results"}}
<div class="upload-summary">
<h3>Document Removal Results</h3>
{{if .Error}}
<div class="error-message">Error: {{.Error}}</div>
{{else}}
<div class="results-summary">
<p>Successfully removed {{.SuccessCount}} document(s).</p>
{{if gt .ErrorCount 0}}
<p class="text-warning">Failed to remove {{.ErrorCount}} document(s).</p>
{{end}}
{{if gt .JobsProcessed 0}}
<p>Processed {{.JobsProcessed}} job(s).</p>
{{end}}
</div>
{{if .Results}}
<div class="job-results">
{{range $job := .Results}}
<div class="job-result">
<h4>Job #{{$job.JobID}}</h4>
{{if $job.Success}}
<div class="success-message">Successfully processed</div>
{{else}}
<div class="error-message">Error: {{$job.Error}}</div>
{{end}}
{{if $job.Files}}
<div class="file-results">
{{range $file := $job.Files}}
<div class="file-result {{if $file.Success}}success{{else}}error{{end}}">
<span class="file-name">{{$file.Name}}</span>
{{if $file.Success}}
<span class="success-icon"></span>
{{else}}
<span class="error-icon"></span>
<span class="error-message">{{$file.Error}}</span>
{{end}}
</div>
{{end}}
</div>
{{end}}
</div>
{{end}}
</div>
{{end}}
{{end}}
</div>
{{end}}
Loading…
Cancel
Save