Browse Source

feat: added multi-invoice searching

document-upload-removal-layout-update
nic 11 months ago
parent
commit
cfc1d6af61
  1. 109
      internal/handlers/web/invoices.go
  2. 214
      static/css/styles.css
  3. 7
      templates/partials/invoice_search.html
  4. 135
      templates/partials/invoice_search_results.html

109
internal/handlers/web/invoices.go

@ -4,6 +4,7 @@ import (
"bytes"
"encoding/json"
"fmt"
"html/template"
"io"
"log"
root "marmic/servicetrade-toolbox"
@ -76,34 +77,78 @@ func handleInvoiceSearch(w http.ResponseWriter, r *http.Request, session *api.Se
return
}
log.Printf("Searching for invoice with term: %s", searchTerm)
// Parse the search term for multiple invoice IDs
invoiceIDs := parseInvoiceIDs(searchTerm)
log.Printf("Processing %d invoice IDs from search term: %s", len(invoiceIDs), searchTerm)
invoice, err := session.GetInvoice(searchTerm)
if len(invoiceIDs) == 0 {
log.Println("No valid invoice IDs found")
w.WriteHeader(http.StatusOK)
tmpl.ExecuteTemplate(w, "invoice_search_results", map[string]interface{}{
"NotFound": true,
"ErrorMsg": "No valid invoice IDs found",
"SearchTerm": searchTerm,
})
return
}
// log.Printf("GetInvoice result - invoice: %+v, err: %v", invoice, err)
// For a single invoice ID, use the original logic
if len(invoiceIDs) == 1 {
handleSingleInvoice(w, invoiceIDs[0], session, tmpl)
return
}
// For multiple invoice IDs, fetch them in parallel
handleMultipleInvoices(w, invoiceIDs, session, tmpl, searchTerm)
}
// parseInvoiceIDs extracts invoice IDs from a comma or space separated string
func parseInvoiceIDs(input string) []string {
// Replace commas with spaces for uniform splitting
spaceSeparated := strings.Replace(input, ",", " ", -1)
// Split by spaces and filter empty strings
parts := strings.Fields(spaceSeparated)
var invoiceIDs []string
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed != "" {
invoiceIDs = append(invoiceIDs, trimmed)
}
}
return invoiceIDs
}
// handleSingleInvoice processes a search for a single invoice ID
func handleSingleInvoice(w http.ResponseWriter, invoiceID string, session *api.Session, tmpl *template.Template) {
log.Printf("Searching for single invoice with ID: %s", invoiceID)
invoice, err := session.GetInvoice(invoiceID)
if err != nil {
log.Printf("Error fetching invoice: %v", err)
w.WriteHeader(http.StatusOK)
errorMsg := fmt.Sprintf("No invoice found for: %s", searchTerm)
errorMsg := fmt.Sprintf("No invoice found for: %s", invoiceID)
if strings.Contains(err.Error(), "access forbidden") {
errorMsg = "You do not have permission to view this invoice."
}
tmpl.ExecuteTemplate(w, "invoice_search_results", map[string]interface{}{
"Error": true,
"ErrorMsg": errorMsg,
"SearchTerm": searchTerm,
"SearchTerm": invoiceID,
})
return
}
if invoice == nil {
log.Printf("No invoice found for: %s", searchTerm)
log.Printf("No invoice found for: %s", invoiceID)
w.WriteHeader(http.StatusOK)
tmpl.ExecuteTemplate(w, "invoice_search_results", map[string]interface{}{
"NotFound": true,
"ErrorMsg": fmt.Sprintf("No invoice found for: %s", searchTerm),
"SearchTerm": searchTerm,
"ErrorMsg": fmt.Sprintf("No invoice found for: %s", invoiceID),
"SearchTerm": invoiceID,
})
return
}
@ -122,6 +167,53 @@ func handleInvoiceSearch(w http.ResponseWriter, r *http.Request, session *api.Se
}
}
// handleMultipleInvoices processes a search for multiple invoice IDs
func handleMultipleInvoices(w http.ResponseWriter, invoiceIDs []string, session *api.Session, tmpl *template.Template, originalSearchTerm string) {
log.Printf("Searching for %d invoices", len(invoiceIDs))
var invoices []map[string]interface{}
var failedIDs []string
// Fetch each invoice
for _, id := range invoiceIDs {
invoice, err := session.GetInvoice(id)
if err != nil || invoice == nil {
log.Printf("Could not fetch invoice %s: %v", id, err)
failedIDs = append(failedIDs, id)
continue
}
// Format the ID
if numID, ok := invoice["id"].(float64); ok {
invoice["id"] = fmt.Sprintf("%.0f", numID)
}
// Add status buttons
invoice["buttons"] = getInvoiceStatusButtons(invoice["id"].(string), invoice["status"].(string))
invoices = append(invoices, invoice)
}
// Prepare the response data
data := map[string]interface{}{
"MultipleInvoices": true,
"Invoices": invoices,
"TotalFound": len(invoices),
"TotalSearched": len(invoiceIDs),
"FailedCount": len(failedIDs),
"FailedIDs": failedIDs,
"SearchTerm": originalSearchTerm,
}
// Render the results
err := tmpl.ExecuteTemplate(w, "invoice_search_results", data)
if err != nil {
log.Printf("Error executing template: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}
func getInvoiceStatusButtons(invoiceID, currentStatus string) []map[string]string {
var buttons []map[string]string
@ -168,6 +260,7 @@ func getInvoiceStatusButtons(invoiceID, currentStatus string) []map[string]strin
"Label": button.Label,
"Class": button.Class,
"ConfirmText": button.ConfirmText,
"Status": button.Status,
})
}
}

214
static/css/styles.css

@ -649,3 +649,217 @@ html {
background-color: var(--btn-disabled);
cursor: not-allowed;
}
/* Multiple Invoice Styles */
.multiple-invoices {
margin-top: 20px;
}
.invoice-summary {
background-color: var(--dashboard-bg);
padding: 10px 15px;
border-radius: 4px;
margin-bottom: 20px;
border: var(--content-border);
}
.invoice-card {
border: var(--content-border);
border-radius: 6px;
margin-bottom: 15px;
box-shadow: var(--dashboard-shadow);
overflow: hidden;
background-color: var(--dashboard-bg);
}
.invoice-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 15px;
background-color: var(--content-bg);
border-bottom: var(--content-border);
}
.invoice-header h4 {
margin: 0;
font-size: 16px;
color: var(--dashboard-header-color);
}
.invoice-status {
padding: 4px 8px;
border-radius: 3px;
font-size: 12px;
font-weight: bold;
text-transform: uppercase;
}
.invoice-status.ok {
background-color: var(--btn-success-bg);
color: white;
opacity: 0.8;
}
.invoice-status.draft {
background-color: var(--btn-primary-bg);
color: white;
opacity: 0.8;
}
.invoice-status.failed {
background-color: var(--btn-warning-bg);
color: white;
opacity: 0.8;
}
.invoice-status.void {
background-color: var(--btn-disabled);
color: white;
opacity: 0.8;
}
.invoice-status.pending_accounting {
background-color: var(--btn-caution-bg);
color: var(--text-color);
opacity: 0.8;
}
.invoice-status.processed {
background-color: var(--btn-primary-bg);
color: white;
opacity: 0.7;
}
.invoice-details {
display: flex;
padding: 15px;
justify-content: space-between;
align-items: flex-start;
}
.invoice-info {
flex: 3;
color: var(--content-text);
}
.invoice-info p {
margin: 5px 0;
font-size: 14px;
}
.invoice-actions {
flex: 1;
display: flex;
justify-content: flex-end;
padding-left: 10px;
}
.button-group {
display: flex;
flex-direction: column;
gap: 5px;
}
.small-button {
font-size: 12px;
padding: 4px 8px;
}
.error-text {
color: var(--btn-warning-bg);
}
.compact-button {
width: 36px;
height: 36px;
border-radius: 50%;
border: none;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
color: white;
font-size: 16px;
padding: 0;
}
.compact-button:hover {
transform: scale(1.1);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.compact-button:active {
transform: scale(1);
}
.compact-button.success-button {
background-color: var(--btn-success-bg);
}
.compact-button.warning-button {
background-color: var(--btn-warning-bg);
}
.compact-button.caution-button {
background-color: var(--btn-caution-bg);
color: var(--text-color);
}
.invoice-items {
margin-top: 15px;
border-top: var(--content-border);
padding-top: 10px;
}
.invoice-items h5 {
margin: 0 0 8px 0;
font-size: 14px;
color: var(--dashboard-header-color);
}
.invoice-items ul {
margin: 0;
padding-left: 20px;
font-size: 13px;
}
/* Responsive layout for mobile */
@media (max-width: 768px) {
.invoice-details {
flex-direction: column;
}
.invoice-actions {
margin-top: 15px;
justify-content: flex-start;
padding-left: 0;
}
.button-group {
flex-direction: row;
flex-wrap: wrap;
}
}
/* Mobile optimization */
@media (max-width: 768px) {
.invoice-footer {
justify-content: center;
}
.invoice-button {
flex: 1 1 calc(50% - 8px);
justify-content: center;
}
.invoice-button .label {
display: none;
}
.invoice-button .icon {
font-size: 18px;
margin: 0;
}
}

7
templates/partials/invoice_search.html

@ -3,9 +3,14 @@
<div class="content">
<h3 class="submenu-header">Search Invoice</h3>
<div id="search-container">
<input class="card-input" type="text" name="search" placeholder="Enter invoice number or id" hx-get="/invoices"
<input class="card-input" type="text" name="search"
placeholder="Enter invoice numbers/IDs (comma or space separated)" hx-get="/invoices"
hx-trigger="keyup changed delay:500ms" hx-target="#invoice-search-results" hx-indicator="#loading-indicator" />
<div class="help-text">
<small>For multiple invoices, separate with commas or spaces (e.g., "123456, 789012" or "123456 789012")</small>
</div>
<div id="loading-indicator" class="htmx-indicator"
style="display: flex; align-items: center; gap: 10px; margin-top: 10px;">
<div class="loading-indicator"></div>

135
templates/partials/invoice_search_results.html

@ -1,43 +1,116 @@
{{define "invoice_search_results"}} {{if .Error}}
{{define "invoice_search_results"}}
{{if .Error}}
<div class="not-found">
{{.ErrorMsg}}
<p>Search term: "{{.SearchTerm}}"</p>
</div>
{{else if .NotFound}}
<div class="not-found">
{{.ErrorMsg}}
<p>Search term: "{{.SearchTerm}}"</p>
</div>
{{else if .invoiceNumber}}
<h3>Invoice Details</h3>
{{range .buttons}}
<button
hx-put="{{.Action}}"
hx-confirm="{{.ConfirmText}}"
hx-target="#invoice-search-results"
class="{{.Class}}">
{{.Label}}
</button>
{{end}}
<p><strong>Invoice Number:</strong> {{.invoiceNumber}}</p>
<p><strong>Total Price:</strong> ${{.totalPrice}}</p>
<p><strong>Status:</strong> {{.status}}</p>
{{with .customer}}
<p><strong>Customer:</strong> {{.name}}</p>
{{end}} {{with .job}}
<p><strong>Job:</strong> {{.name}}</p>
{{end}} {{with .location}}
<p><strong>Location:</strong> {{.name}}</p>
{{end}} {{if .items}}
<h4>Items:</h4>
<ul>
{{range .items}}
<li>{{.description}} - ${{.totalPrice}}</li>
{{else if .MultipleInvoices}}
<div class="multiple-invoices">
<h3>Multiple Invoices Found</h3>
<div class="invoice-summary">
<p>Found <strong>{{.TotalFound}}</strong> of {{.TotalSearched}} invoices for: "{{.SearchTerm}}"</p>
{{if gt .FailedCount 0}}
<p class="error-text">Failed to find {{.FailedCount}} invoices: {{range $i, $id := .FailedIDs}}{{if $i}},
{{end}}{{$id}}{{end}}</p>
{{end}}
</div>
{{range .Invoices}}
<div class="invoice-card">
<div class="invoice-header">
<h4>Invoice #{{.invoiceNumber}}</h4>
<div class="invoice-status {{.status}}">{{.status}}</div>
</div>
<div class="invoice-details">
<div class="invoice-info">
{{with .customer}}<p><strong>Customer:</strong> {{.name}}</p>{{end}}
{{with .job}}<p><strong>Job:</strong> {{.name}}</p>{{end}}
<p><strong>Total:</strong> ${{.totalPrice}}</p>
</div>
<div class="invoice-actions">
<div class="button-group">
{{range .buttons}}
<button hx-put="{{.Action}}" hx-confirm="{{.ConfirmText}}" hx-target="#invoice-search-results"
class="compact-button {{.Class}}" title="{{.Label}}" data-status="{{.Status}}">
<span class="icon">
{{if eq .Status "draft"}}📝
{{else if eq .Status "ok"}}✅
{{else if eq .Status "failed"}}❌
{{else if eq .Status "pending_accounting"}}⏳
{{else if eq .Status "processed"}}📊
{{else if eq .Status "void"}}🚫
{{end}}
</span>
</button>
{{end}}
</div>
</div>
</div>
</div>
{{end}}
</ul>
{{end}} {{else}}
</div>
{{else if .invoiceNumber}}
<div class="multiple-invoices">
<h3>Invoice Details</h3>
<div class="invoice-card">
<div class="invoice-header">
<h4>Invoice #{{.invoiceNumber}}</h4>
<div class="invoice-status {{.status}}">{{.status}}</div>
</div>
<div class="invoice-details">
<div class="invoice-info">
{{with .customer}}<p><strong>Customer:</strong> {{.name}}</p>{{end}}
{{with .job}}<p><strong>Job:</strong> {{.name}}</p>{{end}}
{{with .location}}<p><strong>Location:</strong> {{.name}}</p>{{end}}
<p><strong>Total Price:</strong> ${{.totalPrice}}</p>
{{if .items}}
<div class="invoice-items">
<h5>Items</h5>
<ul>
{{range .items}}
<li>{{.description}} - ${{.totalPrice}}</li>
{{end}}
</ul>
</div>
{{end}}
</div>
<div class="invoice-actions">
<div class="button-group">
{{range .buttons}}
<button hx-put="{{.Action}}" hx-confirm="{{.ConfirmText}}" hx-target="#invoice-search-results"
class="compact-button {{.Class}}" title="{{.Label}}" data-status="{{.Status}}">
<span class="icon">
{{if eq .Status "draft"}}📝
{{else if eq .Status "ok"}}✅
{{else if eq .Status "failed"}}❌
{{else if eq .Status "pending_accounting"}}⏳
{{else if eq .Status "processed"}}📊
{{else if eq .Status "void"}}🚫
{{end}}
</span>
</button>
{{end}}
</div>
</div>
</div>
</div>
</div>
{{else}}
<p>Unexpected response. Please try again.</p>
{{end}} {{end}}
{{end}}
{{end}}
Loading…
Cancel
Save