13 changed files with 693 additions and 24 deletions
@ -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) |
|||
} |
|||
} |
|||
@ -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}} |
|||
@ -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}} |
|||
@ -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}} |
|||
@ -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}} |
|||
@ -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}} |
|||
@ -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…
Reference in new issue