36 changed files with 1790 additions and 151 deletions
@ -1,29 +1,18 @@ |
|||||
package web |
package web |
||||
|
|
||||
import ( |
import ( |
||||
"log" |
|
||||
root "marmic/servicetrade-toolbox" |
root "marmic/servicetrade-toolbox" |
||||
"marmic/servicetrade-toolbox/internal/api" |
|
||||
"marmic/servicetrade-toolbox/internal/middleware" |
|
||||
"net/http" |
"net/http" |
||||
) |
) |
||||
|
|
||||
func AdminHandler(w http.ResponseWriter, r *http.Request) { |
func AdminHandler(w http.ResponseWriter, r *http.Request) { |
||||
session, ok := r.Context().Value(middleware.SessionKey).(*api.Session) |
|
||||
if !ok { |
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized) |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
tmpl := root.WebTemplates |
tmpl := root.WebTemplates |
||||
data := map[string]interface{}{ |
data := map[string]interface{}{ |
||||
"Title": "Admin Dashboard", |
"Title": "Admin", |
||||
"Session": session, |
|
||||
} |
} |
||||
|
|
||||
if err := tmpl.ExecuteTemplate(w, "admin.html", data); err != nil { |
err := tmpl.Execute(w, data) |
||||
log.Printf("Template execution error: %v", err) |
if err != nil { |
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError) |
http.Error(w, "Internal Server Error", http.StatusInternalServerError) |
||||
return |
|
||||
} |
} |
||||
} |
} |
||||
|
|||||
@ -1,29 +1,18 @@ |
|||||
package web |
package web |
||||
|
|
||||
import ( |
import ( |
||||
"log" |
|
||||
root "marmic/servicetrade-toolbox" |
root "marmic/servicetrade-toolbox" |
||||
"marmic/servicetrade-toolbox/internal/api" |
|
||||
"marmic/servicetrade-toolbox/internal/middleware" |
|
||||
"net/http" |
"net/http" |
||||
) |
) |
||||
|
|
||||
func AssetsHandler(w http.ResponseWriter, r *http.Request) { |
func AssetsHandler(w http.ResponseWriter, r *http.Request) { |
||||
session, ok := r.Context().Value(middleware.SessionKey).(*api.Session) |
|
||||
if !ok { |
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized) |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
tmpl := root.WebTemplates |
tmpl := root.WebTemplates |
||||
data := map[string]interface{}{ |
data := map[string]interface{}{ |
||||
"Title": "Admin Dashboard", |
"Title": "Assets", |
||||
"Session": session, |
|
||||
} |
} |
||||
|
|
||||
if err := tmpl.ExecuteTemplate(w, "assets.html", data); err != nil { |
err := tmpl.Execute(w, data) |
||||
log.Printf("Template execution error: %v", err) |
if err != nil { |
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError) |
http.Error(w, "Internal Server Error", http.StatusInternalServerError) |
||||
return |
|
||||
} |
} |
||||
} |
} |
||||
|
|||||
@ -1,37 +1,18 @@ |
|||||
package web |
package web |
||||
|
|
||||
import ( |
import ( |
||||
"log" |
|
||||
root "marmic/servicetrade-toolbox" |
root "marmic/servicetrade-toolbox" |
||||
"marmic/servicetrade-toolbox/internal/api" |
|
||||
"marmic/servicetrade-toolbox/internal/middleware" |
|
||||
"net/http" |
"net/http" |
||||
) |
) |
||||
|
|
||||
func CompaniesHandler(w http.ResponseWriter, r *http.Request) { |
func CompaniesHandler(w http.ResponseWriter, r *http.Request) { |
||||
session, ok := r.Context().Value(middleware.SessionKey).(*api.Session) |
|
||||
if !ok { |
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized) |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
tmpl := root.WebTemplates |
tmpl := root.WebTemplates |
||||
data := map[string]interface{}{ |
data := map[string]interface{}{ |
||||
"Title": "Companies", |
"Title": "Companies", |
||||
"Session": session, |
|
||||
"View": "company_content", |
|
||||
} |
|
||||
|
|
||||
var err error |
|
||||
if r.Header.Get("HX-Request") == "true" { |
|
||||
err = tmpl.ExecuteTemplate(w, "companies.html", data) |
|
||||
} else { |
|
||||
err = tmpl.Execute(w, data) |
|
||||
} |
} |
||||
|
|
||||
|
err := tmpl.Execute(w, data) |
||||
if err != nil { |
if err != nil { |
||||
log.Printf("Template execution error: %v", err) |
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError) |
http.Error(w, "Internal Server Error", http.StatusInternalServerError) |
||||
return |
|
||||
} |
} |
||||
} |
} |
||||
|
|||||
@ -1,19 +1,18 @@ |
|||||
package web |
package web |
||||
|
|
||||
import ( |
import ( |
||||
"html/template" |
root "marmic/servicetrade-toolbox" |
||||
"net/http" |
"net/http" |
||||
) |
) |
||||
|
|
||||
func ContactsHandler(w http.ResponseWriter, r *http.Request) { |
func ContactsHandler(w http.ResponseWriter, r *http.Request) { |
||||
tmpl := template.Must(template.ParseFiles("templates/layout.html", "templates/partials/contacts.html")) |
tmpl := root.WebTemplates |
||||
data := map[string]interface{}{ |
data := map[string]interface{}{ |
||||
"Title": "Contacts", |
"Title": "Contacts", |
||||
} |
} |
||||
|
|
||||
if r.Header.Get("HX-Request") == "true" { |
err := tmpl.Execute(w, data) |
||||
tmpl.ExecuteTemplate(w, "content", data) |
if err != nil { |
||||
} else { |
http.Error(w, "Internal Server Error", http.StatusInternalServerError) |
||||
tmpl.Execute(w, data) |
|
||||
} |
} |
||||
} |
} |
||||
|
|||||
@ -1,19 +1,18 @@ |
|||||
package web |
package web |
||||
|
|
||||
import ( |
import ( |
||||
"html/template" |
root "marmic/servicetrade-toolbox" |
||||
"net/http" |
"net/http" |
||||
) |
) |
||||
|
|
||||
func ContractsHandler(w http.ResponseWriter, r *http.Request) { |
func ContractsHandler(w http.ResponseWriter, r *http.Request) { |
||||
tmpl := template.Must(template.ParseFiles("templates/layout.html", "templates/partials/contracts.html")) |
tmpl := root.WebTemplates |
||||
data := map[string]interface{}{ |
data := map[string]interface{}{ |
||||
"Title": "Contracts", |
"Title": "Contracts", |
||||
} |
} |
||||
|
|
||||
if r.Header.Get("HX-Request") == "true" { |
err := tmpl.Execute(w, data) |
||||
tmpl.ExecuteTemplate(w, "content", data) |
if err != nil { |
||||
} else { |
http.Error(w, "Internal Server Error", http.StatusInternalServerError) |
||||
tmpl.Execute(w, data) |
|
||||
} |
} |
||||
} |
} |
||||
|
|||||
@ -1,19 +1,18 @@ |
|||||
package web |
package web |
||||
|
|
||||
import ( |
import ( |
||||
"html/template" |
root "marmic/servicetrade-toolbox" |
||||
"net/http" |
"net/http" |
||||
) |
) |
||||
|
|
||||
func GenericHandler(w http.ResponseWriter, r *http.Request) { |
func GenericHandler(w http.ResponseWriter, r *http.Request) { |
||||
tmpl := template.Must(template.ParseFiles("templates/layout.html", "templates/partials/generic.html")) |
tmpl := root.WebTemplates |
||||
data := map[string]interface{}{ |
data := map[string]interface{}{ |
||||
"Title": "Generic Tools", |
"Title": "Generic", |
||||
} |
} |
||||
|
|
||||
if r.Header.Get("HX-Request") == "true" { |
err := tmpl.Execute(w, data) |
||||
tmpl.ExecuteTemplate(w, "content", data) |
if err != nil { |
||||
} else { |
http.Error(w, "Internal Server Error", http.StatusInternalServerError) |
||||
tmpl.Execute(w, data) |
|
||||
} |
} |
||||
} |
} |
||||
|
|||||
@ -1,19 +1,18 @@ |
|||||
package web |
package web |
||||
|
|
||||
import ( |
import ( |
||||
"html/template" |
root "marmic/servicetrade-toolbox" |
||||
"net/http" |
"net/http" |
||||
) |
) |
||||
|
|
||||
func LocationsHandler(w http.ResponseWriter, r *http.Request) { |
func LocationsHandler(w http.ResponseWriter, r *http.Request) { |
||||
tmpl := template.Must(template.ParseFiles("templates/layout.html", "templates/partials/locations.html")) |
tmpl := root.WebTemplates |
||||
data := map[string]interface{}{ |
data := map[string]interface{}{ |
||||
"Title": "Locations", |
"Title": "Locations", |
||||
} |
} |
||||
|
|
||||
if r.Header.Get("HX-Request") == "true" { |
err := tmpl.Execute(w, data) |
||||
tmpl.ExecuteTemplate(w, "content", data) |
if err != nil { |
||||
} else { |
http.Error(w, "Internal Server Error", http.StatusInternalServerError) |
||||
tmpl.Execute(w, data) |
|
||||
} |
} |
||||
} |
} |
||||
|
|||||
@ -1,19 +1,18 @@ |
|||||
package web |
package web |
||||
|
|
||||
import ( |
import ( |
||||
"html/template" |
root "marmic/servicetrade-toolbox" |
||||
"net/http" |
"net/http" |
||||
) |
) |
||||
|
|
||||
func NotificationsHandler(w http.ResponseWriter, r *http.Request) { |
func NotificationsHandler(w http.ResponseWriter, r *http.Request) { |
||||
tmpl := template.Must(template.ParseFiles("templates/layout.html", "templates/partials/notifications.html")) |
tmpl := root.WebTemplates |
||||
data := map[string]interface{}{ |
data := map[string]interface{}{ |
||||
"Title": "Notifications", |
"Title": "Notifications", |
||||
} |
} |
||||
|
|
||||
if r.Header.Get("HX-Request") == "true" { |
err := tmpl.Execute(w, data) |
||||
tmpl.ExecuteTemplate(w, "content", data) |
if err != nil { |
||||
} else { |
http.Error(w, "Internal Server Error", http.StatusInternalServerError) |
||||
tmpl.Execute(w, data) |
|
||||
} |
} |
||||
} |
} |
||||
|
|||||
@ -1,19 +1,18 @@ |
|||||
package web |
package web |
||||
|
|
||||
import ( |
import ( |
||||
"html/template" |
root "marmic/servicetrade-toolbox" |
||||
"net/http" |
"net/http" |
||||
) |
) |
||||
|
|
||||
func QuotesHandler(w http.ResponseWriter, r *http.Request) { |
func QuotesHandler(w http.ResponseWriter, r *http.Request) { |
||||
tmpl := template.Must(template.ParseFiles("templates/layout.html", "templates/partials/quotes.html")) |
tmpl := root.WebTemplates |
||||
data := map[string]interface{}{ |
data := map[string]interface{}{ |
||||
"Title": "Quotes", |
"Title": "Quotes", |
||||
} |
} |
||||
|
|
||||
if r.Header.Get("HX-Request") == "true" { |
err := tmpl.Execute(w, data) |
||||
tmpl.ExecuteTemplate(w, "content", data) |
if err != nil { |
||||
} else { |
http.Error(w, "Internal Server Error", http.StatusInternalServerError) |
||||
tmpl.Execute(w, data) |
|
||||
} |
} |
||||
} |
} |
||||
|
|||||
@ -1,19 +1,18 @@ |
|||||
package web |
package web |
||||
|
|
||||
import ( |
import ( |
||||
"html/template" |
root "marmic/servicetrade-toolbox" |
||||
"net/http" |
"net/http" |
||||
) |
) |
||||
|
|
||||
func ServicesHandler(w http.ResponseWriter, r *http.Request) { |
func ServicesHandler(w http.ResponseWriter, r *http.Request) { |
||||
tmpl := template.Must(template.ParseFiles("templates/layout.html", "templates/partials/services.html")) |
tmpl := root.WebTemplates |
||||
data := map[string]interface{}{ |
data := map[string]interface{}{ |
||||
"Title": "Services", |
"Title": "Services", |
||||
} |
} |
||||
|
|
||||
if r.Header.Get("HX-Request") == "true" { |
err := tmpl.Execute(w, data) |
||||
tmpl.ExecuteTemplate(w, "content", data) |
if err != nil { |
||||
} else { |
http.Error(w, "Internal Server Error", http.StatusInternalServerError) |
||||
tmpl.Execute(w, data) |
|
||||
} |
} |
||||
} |
} |
||||
|
|||||
@ -1,19 +1,18 @@ |
|||||
package web |
package web |
||||
|
|
||||
import ( |
import ( |
||||
"html/template" |
root "marmic/servicetrade-toolbox" |
||||
"net/http" |
"net/http" |
||||
) |
) |
||||
|
|
||||
func TagsHandler(w http.ResponseWriter, r *http.Request) { |
func TagsHandler(w http.ResponseWriter, r *http.Request) { |
||||
tmpl := template.Must(template.ParseFiles("templates/layout.html", "templates/partials/tags.html")) |
tmpl := root.WebTemplates |
||||
data := map[string]interface{}{ |
data := map[string]interface{}{ |
||||
"Title": "Tags", |
"Title": "Tags", |
||||
} |
} |
||||
|
|
||||
if r.Header.Get("HX-Request") == "true" { |
err := tmpl.Execute(w, data) |
||||
tmpl.ExecuteTemplate(w, "content", data) |
if err != nil { |
||||
} else { |
http.Error(w, "Internal Server Error", http.StatusInternalServerError) |
||||
tmpl.Execute(w, data) |
|
||||
} |
} |
||||
} |
} |
||||
|
|||||
@ -1,19 +1,18 @@ |
|||||
package web |
package web |
||||
|
|
||||
import ( |
import ( |
||||
"html/template" |
root "marmic/servicetrade-toolbox" |
||||
"net/http" |
"net/http" |
||||
) |
) |
||||
|
|
||||
func UsersHandler(w http.ResponseWriter, r *http.Request) { |
func UsersHandler(w http.ResponseWriter, r *http.Request) { |
||||
tmpl := template.Must(template.ParseFiles("templates/layout.html", "templates/partials/users.html")) |
tmpl := root.WebTemplates |
||||
data := map[string]interface{}{ |
data := map[string]interface{}{ |
||||
"Title": "Users", |
"Title": "Users", |
||||
} |
} |
||||
|
|
||||
if r.Header.Get("HX-Request") == "true" { |
err := tmpl.Execute(w, data) |
||||
tmpl.ExecuteTemplate(w, "content", data) |
if err != nil { |
||||
} else { |
http.Error(w, "Internal Server Error", http.StatusInternalServerError) |
||||
tmpl.Execute(w, data) |
|
||||
} |
} |
||||
} |
} |
||||
|
|||||
@ -0,0 +1,716 @@ |
|||||
|
// Enhanced Invoices Page Functionality
|
||||
|
let currentView = 'search'; |
||||
|
let boardInvoices = new Map(); // Store invoice data for board view
|
||||
|
let draggedInvoice = null; |
||||
|
let lastSearchResults = []; // Store the most recent search results
|
||||
|
|
||||
|
// Initialize when page loads
|
||||
|
document.addEventListener('DOMContentLoaded', function () { |
||||
|
initializeInvoicesPage(); |
||||
|
}); |
||||
|
|
||||
|
function initializeInvoicesPage() { |
||||
|
// Setup search input enhancements
|
||||
|
const searchInput = document.getElementById('invoice-search-input'); |
||||
|
if (searchInput) { |
||||
|
setupSearchEnhancements(searchInput); |
||||
|
} |
||||
|
|
||||
|
// Initialize drag and drop for board view
|
||||
|
initializeBoardDragDrop(); |
||||
|
|
||||
|
// Setup HTMX event listeners to capture search results
|
||||
|
setupSearchResultCapture(); |
||||
|
|
||||
|
console.log('Invoices page initialized'); |
||||
|
} |
||||
|
|
||||
|
// Setup listeners to capture search results from HTMX
|
||||
|
function setupSearchResultCapture() { |
||||
|
// Listen for HTMX after swap events to capture search results
|
||||
|
document.body.addEventListener('htmx:afterSwap', function (e) { |
||||
|
if (e.detail.target && e.detail.target.id === 'invoice-search-results') { |
||||
|
console.log('Search results updated, extracting invoice data...'); |
||||
|
extractInvoiceDataFromSearchResults(); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
// Extract invoice data from search results HTML
|
||||
|
function extractInvoiceDataFromSearchResults() { |
||||
|
const resultsDiv = document.getElementById('invoice-search-results'); |
||||
|
const invoiceCards = resultsDiv.querySelectorAll('.invoice-card'); |
||||
|
|
||||
|
lastSearchResults = []; |
||||
|
|
||||
|
invoiceCards.forEach(card => { |
||||
|
const invoiceData = extractInvoiceDataFromCard(card); |
||||
|
if (invoiceData) { |
||||
|
lastSearchResults.push(invoiceData); |
||||
|
console.log('Extracted invoice data:', invoiceData); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
console.log(`Captured ${lastSearchResults.length} invoices from search results`); |
||||
|
|
||||
|
// If we're currently in board view, update it immediately
|
||||
|
if (currentView === 'board') { |
||||
|
populateBoardWithSearchResults(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Extract invoice data from a single invoice card element
|
||||
|
function extractInvoiceDataFromCard(card) { |
||||
|
try { |
||||
|
// Get invoice number from header
|
||||
|
const headerElement = card.querySelector('.invoice-header h4'); |
||||
|
const invoiceNumberMatch = headerElement ? headerElement.textContent.match(/Invoice #(.+)/) : null; |
||||
|
const invoiceNumber = invoiceNumberMatch ? invoiceNumberMatch[1] : null; |
||||
|
|
||||
|
// Get status
|
||||
|
const statusElement = card.querySelector('.invoice-status'); |
||||
|
const status = statusElement ? statusElement.textContent.trim() : null; |
||||
|
|
||||
|
// Get the real invoice ID from data attribute (this is the numeric ID we need for API calls)
|
||||
|
const realInvoiceId = card.dataset.invoiceId; |
||||
|
|
||||
|
// If we don't have the real ID from data attribute, try to extract from card ID
|
||||
|
let id = realInvoiceId; |
||||
|
if (!id) { |
||||
|
const cardId = card.id; |
||||
|
const idMatch = cardId.match(/invoice-card-(.+)/); |
||||
|
id = idMatch ? idMatch[1] : invoiceNumber; |
||||
|
} |
||||
|
|
||||
|
console.log(`Extracted invoice data: ID=${id}, Number=${invoiceNumber}, Status=${status}`); |
||||
|
|
||||
|
// Extract customer name
|
||||
|
const customerElement = Array.from(card.querySelectorAll('.invoice-info p')).find(p => |
||||
|
p.innerHTML.includes('<strong>Customer:</strong>') |
||||
|
); |
||||
|
const customerName = customerElement ? |
||||
|
customerElement.innerHTML.replace('<strong>Customer:</strong>', '').trim() : null; |
||||
|
|
||||
|
// Extract job name
|
||||
|
const jobElement = Array.from(card.querySelectorAll('.invoice-info p')).find(p => |
||||
|
p.innerHTML.includes('<strong>Job:</strong>') |
||||
|
); |
||||
|
const jobName = jobElement ? |
||||
|
jobElement.innerHTML.replace('<strong>Job:</strong>', '').trim() : null; |
||||
|
|
||||
|
// Extract total price
|
||||
|
const totalElement = Array.from(card.querySelectorAll('.invoice-info p')).find(p => |
||||
|
p.innerHTML.includes('<strong>Total:</strong>') |
||||
|
); |
||||
|
const totalMatch = totalElement ? |
||||
|
totalElement.innerHTML.match(/<strong>Total:<\/strong>\s*\$(.+)/) : null; |
||||
|
const totalPrice = totalMatch ? totalMatch[1].trim() : '0.00'; |
||||
|
|
||||
|
return { |
||||
|
id: id, |
||||
|
invoiceNumber: invoiceNumber, |
||||
|
status: status, |
||||
|
customer: customerName ? { name: customerName } : null, |
||||
|
job: jobName ? { name: jobName } : null, |
||||
|
totalPrice: totalPrice |
||||
|
}; |
||||
|
} catch (error) { |
||||
|
console.error('Error extracting invoice data from card:', error); |
||||
|
return null; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Search functionality enhancements
|
||||
|
function setupSearchEnhancements(searchInput) { |
||||
|
const clearBtn = document.querySelector('.search-clear-btn'); |
||||
|
|
||||
|
searchInput.addEventListener('input', function () { |
||||
|
if (this.value.length > 0) { |
||||
|
clearBtn.style.display = 'block'; |
||||
|
} else { |
||||
|
clearBtn.style.display = 'none'; |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
// Add keyboard shortcuts
|
||||
|
searchInput.addEventListener('keydown', function (e) { |
||||
|
if (e.key === 'Escape') { |
||||
|
clearSearch(); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
function clearSearch() { |
||||
|
const searchInput = document.getElementById('invoice-search-input'); |
||||
|
const clearBtn = document.querySelector('.search-clear-btn'); |
||||
|
const resultsDiv = document.getElementById('invoice-search-results'); |
||||
|
|
||||
|
searchInput.value = ''; |
||||
|
clearBtn.style.display = 'none'; |
||||
|
|
||||
|
// Clear stored search results
|
||||
|
lastSearchResults = []; |
||||
|
|
||||
|
// Reset to no search state
|
||||
|
resultsDiv.innerHTML = ` |
||||
|
<div class="no-search-state"> |
||||
|
<div class="no-search-icon">📋</div> |
||||
|
<h4>Start searching for invoices</h4> |
||||
|
<p>Enter an invoice ID, customer name, or job number above to begin</p> |
||||
|
</div> |
||||
|
`;
|
||||
|
|
||||
|
// Clear board if currently in board view
|
||||
|
if (currentView === 'board') { |
||||
|
clearBoard(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// View switching functionality
|
||||
|
function switchView(viewName) { |
||||
|
const searchView = document.getElementById('search-view'); |
||||
|
const boardView = document.getElementById('board-view'); |
||||
|
const searchBtn = document.getElementById('search-view-btn'); |
||||
|
const boardBtn = document.getElementById('board-view-btn'); |
||||
|
|
||||
|
// Update active states
|
||||
|
searchBtn.classList.toggle('active', viewName === 'search'); |
||||
|
boardBtn.classList.toggle('active', viewName === 'board'); |
||||
|
|
||||
|
// Show/hide views
|
||||
|
searchView.classList.toggle('active', viewName === 'search'); |
||||
|
boardView.classList.toggle('active', viewName === 'board'); |
||||
|
|
||||
|
currentView = viewName; |
||||
|
|
||||
|
// If switching to board view, populate it with search results
|
||||
|
if (viewName === 'board') { |
||||
|
if (lastSearchResults.length > 0) { |
||||
|
populateBoardWithSearchResults(); |
||||
|
} else { |
||||
|
checkBoardState(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
console.log(`Switched to ${viewName} view`); |
||||
|
} |
||||
|
|
||||
|
// Clear all invoices from the board
|
||||
|
function clearBoard() { |
||||
|
document.querySelectorAll('.invoice-drop-zone').forEach(zone => { |
||||
|
// Remove all invoice cards
|
||||
|
const cards = zone.querySelectorAll('.board-invoice-card'); |
||||
|
cards.forEach(card => card.remove()); |
||||
|
|
||||
|
// Show empty message
|
||||
|
const emptyMessage = zone.querySelector('.empty-column-message'); |
||||
|
if (emptyMessage) { |
||||
|
emptyMessage.style.display = 'block'; |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
// Clear stored data
|
||||
|
boardInvoices.clear(); |
||||
|
|
||||
|
// Update counts
|
||||
|
updateColumnCounts(); |
||||
|
} |
||||
|
|
||||
|
// Populate board with search results
|
||||
|
function populateBoardWithSearchResults() { |
||||
|
console.log(`Populating board with ${lastSearchResults.length} search results`); |
||||
|
|
||||
|
// Clear existing board content
|
||||
|
clearBoard(); |
||||
|
|
||||
|
// Add each search result to the board
|
||||
|
lastSearchResults.forEach(invoiceData => { |
||||
|
addInvoiceToBoard(invoiceData); |
||||
|
}); |
||||
|
|
||||
|
// Debug the board state after population
|
||||
|
debugBoardState(); |
||||
|
|
||||
|
showMessage(`Added ${lastSearchResults.length} invoices to board`, 'success'); |
||||
|
} |
||||
|
|
||||
|
// Board functionality
|
||||
|
function refreshBoard() { |
||||
|
console.log('Refreshing board...'); |
||||
|
|
||||
|
// If we have search results, refresh from those
|
||||
|
if (lastSearchResults.length > 0) { |
||||
|
showBoardLoading(true); |
||||
|
setTimeout(() => { |
||||
|
populateBoardWithSearchResults(); |
||||
|
showBoardLoading(false); |
||||
|
showMessage('Board refreshed with search results', 'success'); |
||||
|
}, 500); |
||||
|
} else { |
||||
|
showBoardLoading(true); |
||||
|
setTimeout(() => { |
||||
|
showBoardLoading(false); |
||||
|
showMessage('No search results to refresh. Search for invoices first.', 'info'); |
||||
|
}, 500); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function loadRecentInvoices() { |
||||
|
console.log('Loading recent invoices...'); |
||||
|
showBoardLoading(true); |
||||
|
|
||||
|
// TODO: Implement actual API call to load recent invoices
|
||||
|
// For now, show a helpful message
|
||||
|
setTimeout(() => { |
||||
|
showBoardLoading(false); |
||||
|
showMessage('Search for invoices in the Search View to populate the board', 'info'); |
||||
|
}, 1000); |
||||
|
} |
||||
|
|
||||
|
function checkBoardState() { |
||||
|
const board = document.getElementById('invoice-board'); |
||||
|
const invoiceCards = board.querySelectorAll('.board-invoice-card'); |
||||
|
|
||||
|
if (invoiceCards.length === 0) { |
||||
|
console.log('Board is empty, showing helpful message'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function showBoardLoading(show) { |
||||
|
const loading = document.getElementById('board-loading'); |
||||
|
loading.style.display = show ? 'flex' : 'none'; |
||||
|
} |
||||
|
|
||||
|
// Kanban drag and drop functionality
|
||||
|
function initializeBoardDragDrop() { |
||||
|
console.log('Initializing board drag and drop'); |
||||
|
|
||||
|
// Setup drop zones
|
||||
|
const dropZones = document.querySelectorAll('.invoice-drop-zone'); |
||||
|
dropZones.forEach(zone => { |
||||
|
zone.addEventListener('dragover', handleDragOver); |
||||
|
zone.addEventListener('drop', handleDrop); |
||||
|
zone.addEventListener('dragenter', handleDragEnter); |
||||
|
zone.addEventListener('dragleave', handleDragLeave); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
// Drag and drop event handlers
|
||||
|
function handleDragStart(e) { |
||||
|
draggedInvoice = e.target.closest('.board-invoice-card'); |
||||
|
|
||||
|
// Only handle drag start for invoice board cards
|
||||
|
if (!draggedInvoice) { |
||||
|
console.log('Drag start: No board invoice card found'); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
draggedInvoice.classList.add('dragging'); |
||||
|
e.dataTransfer.effectAllowed = 'move'; |
||||
|
e.dataTransfer.setData('text/plain', draggedInvoice.dataset.invoiceId); |
||||
|
|
||||
|
console.log(`Started dragging invoice ${draggedInvoice.dataset.invoiceId} with status ${draggedInvoice.dataset.status}`); |
||||
|
} |
||||
|
|
||||
|
function handleDragOver(e) { |
||||
|
e.preventDefault(); |
||||
|
e.dataTransfer.dropEffect = 'move'; |
||||
|
} |
||||
|
|
||||
|
function handleDragEnter(e) { |
||||
|
e.preventDefault(); |
||||
|
const dropZone = e.target.closest('.invoice-drop-zone'); |
||||
|
|
||||
|
// Only handle if this is an invoice drop zone and we have a dragged invoice
|
||||
|
if (dropZone && draggedInvoice && draggedInvoice.classList.contains('board-invoice-card')) { |
||||
|
dropZone.classList.add('drag-over'); |
||||
|
console.log(`Drag enter: ${dropZone.dataset.status} zone`); |
||||
|
|
||||
|
// Hide empty message when dragging over
|
||||
|
const emptyMessage = dropZone.querySelector('.empty-column-message'); |
||||
|
if (emptyMessage) { |
||||
|
emptyMessage.style.display = 'none'; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function handleDragLeave(e) { |
||||
|
const dropZone = e.target.closest('.invoice-drop-zone'); |
||||
|
|
||||
|
// Only handle if this is an invoice drop zone
|
||||
|
if (dropZone) { |
||||
|
// Only remove drag-over if we're actually leaving the drop zone
|
||||
|
const rect = dropZone.getBoundingClientRect(); |
||||
|
const x = e.clientX; |
||||
|
const y = e.clientY; |
||||
|
|
||||
|
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) { |
||||
|
dropZone.classList.remove('drag-over'); |
||||
|
|
||||
|
// Show empty message again if no cards in zone
|
||||
|
const cards = dropZone.querySelectorAll('.board-invoice-card'); |
||||
|
const emptyMessage = dropZone.querySelector('.empty-column-message'); |
||||
|
if (cards.length === 0 && emptyMessage) { |
||||
|
emptyMessage.style.display = 'block'; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function handleDrop(e) { |
||||
|
e.preventDefault(); |
||||
|
|
||||
|
const dropZone = e.target.closest('.invoice-drop-zone'); |
||||
|
|
||||
|
// If this isn't an invoice drop zone, ignore the event (let other handlers process it)
|
||||
|
if (!dropZone) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const newStatus = dropZone.dataset.status; |
||||
|
|
||||
|
if (!draggedInvoice || !newStatus) { |
||||
|
resetDragState(); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const invoiceId = draggedInvoice.dataset.invoiceId; |
||||
|
const currentStatus = draggedInvoice.dataset.status; |
||||
|
|
||||
|
console.log(`Dropping invoice ${invoiceId} into ${newStatus} column`); |
||||
|
|
||||
|
// Check if status change is valid
|
||||
|
if (!isValidStatusTransition(currentStatus, newStatus)) { |
||||
|
showMessage(`Cannot move invoice from ${currentStatus} to ${newStatus}`, 'error'); |
||||
|
resetDragState(); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// If same status, just move visually
|
||||
|
if (currentStatus === newStatus) { |
||||
|
moveInvoiceToColumn(draggedInvoice, dropZone); |
||||
|
resetDragState(); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// Update invoice status via API (resetDragState is called in the finally block)
|
||||
|
updateInvoiceStatus(invoiceId, newStatus, dropZone); |
||||
|
} |
||||
|
|
||||
|
function handleDragEnd(e) { |
||||
|
resetDragState(); |
||||
|
} |
||||
|
|
||||
|
function resetDragState() { |
||||
|
if (draggedInvoice) { |
||||
|
draggedInvoice.classList.remove('dragging'); |
||||
|
draggedInvoice = null; |
||||
|
} |
||||
|
|
||||
|
// Remove drag-over classes
|
||||
|
document.querySelectorAll('.invoice-drop-zone').forEach(zone => { |
||||
|
zone.classList.remove('drag-over'); |
||||
|
|
||||
|
// Show empty messages where appropriate
|
||||
|
const cards = zone.querySelectorAll('.board-invoice-card'); |
||||
|
const emptyMessage = zone.querySelector('.empty-column-message'); |
||||
|
if (cards.length === 0 && emptyMessage) { |
||||
|
emptyMessage.style.display = 'block'; |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
function moveInvoiceToColumn(invoiceCard, dropZone) { |
||||
|
// Hide empty message
|
||||
|
const emptyMessage = dropZone.querySelector('.empty-column-message'); |
||||
|
if (emptyMessage) { |
||||
|
emptyMessage.style.display = 'none'; |
||||
|
} |
||||
|
|
||||
|
// Move card to new column
|
||||
|
dropZone.appendChild(invoiceCard); |
||||
|
|
||||
|
// Update column counts
|
||||
|
updateColumnCounts(); |
||||
|
|
||||
|
console.log(`Moved invoice card to column ${dropZone.dataset.status}`); |
||||
|
} |
||||
|
|
||||
|
function updateInvoiceStatus(invoiceId, newStatus, dropZone) { |
||||
|
console.log(`Updating invoice ${invoiceId} to status ${newStatus}`); |
||||
|
|
||||
|
// Store reference to dragged invoice before any async operations
|
||||
|
const invoiceCard = draggedInvoice; |
||||
|
|
||||
|
// Show loading state on the card
|
||||
|
const loadingOverlay = document.createElement('div'); |
||||
|
loadingOverlay.className = 'card-loading-overlay'; |
||||
|
loadingOverlay.innerHTML = '<div class="loading-spinner"></div>'; |
||||
|
invoiceCard.appendChild(loadingOverlay); |
||||
|
|
||||
|
// Make API call to update status
|
||||
|
fetch(`/${newStatus}-invoice/${invoiceId}`, { |
||||
|
method: 'PUT', |
||||
|
headers: { |
||||
|
'Content-Type': 'application/json', |
||||
|
} |
||||
|
}) |
||||
|
.then(response => { |
||||
|
console.log(`API response status: ${response.status}`); |
||||
|
if (response.ok) { |
||||
|
// Update successful - update the card's data and visual elements
|
||||
|
invoiceCard.dataset.status = newStatus; |
||||
|
|
||||
|
// Update the status indicator inside the card
|
||||
|
const statusIndicator = invoiceCard.querySelector('.status-indicator'); |
||||
|
if (statusIndicator) { |
||||
|
statusIndicator.className = `status-indicator status-${newStatus}`; |
||||
|
statusIndicator.textContent = newStatus; |
||||
|
} |
||||
|
|
||||
|
// Move card to new column
|
||||
|
moveInvoiceToColumn(invoiceCard, dropZone); |
||||
|
|
||||
|
// Update the stored search results to reflect the status change
|
||||
|
updateStoredSearchResults(invoiceId, newStatus); |
||||
|
|
||||
|
// Update the stored board invoice data
|
||||
|
let boardInvoiceData = boardInvoices.get(invoiceId); |
||||
|
if (!boardInvoiceData) { |
||||
|
// Try looking up by invoice number if ID lookup failed
|
||||
|
const invoiceNumber = invoiceCard.querySelector('.invoice-number')?.textContent?.replace('#', ''); |
||||
|
boardInvoiceData = boardInvoices.get(invoiceNumber); |
||||
|
} |
||||
|
|
||||
|
if (boardInvoiceData) { |
||||
|
boardInvoiceData.status = newStatus; |
||||
|
boardInvoices.set(invoiceId, boardInvoiceData); |
||||
|
// Also update by invoice number if we have it
|
||||
|
if (boardInvoiceData.invoiceNumber) { |
||||
|
boardInvoices.set(boardInvoiceData.invoiceNumber, boardInvoiceData); |
||||
|
} |
||||
|
console.log(`Updated board invoice data for ${invoiceId} to status ${newStatus}`); |
||||
|
} else { |
||||
|
console.log(`Warning: Could not find board invoice data for ${invoiceId}`); |
||||
|
} |
||||
|
|
||||
|
showMessage(`Invoice moved to ${newStatus}`, 'success'); |
||||
|
console.log(`Successfully updated invoice ${invoiceId} to ${newStatus}`); |
||||
|
|
||||
|
// Reset drag state after successful update
|
||||
|
resetDragState(); |
||||
|
} else { |
||||
|
// Try to get error details from response
|
||||
|
return response.text().then(errorText => { |
||||
|
console.error(`API error: ${response.status} - ${errorText}`); |
||||
|
throw new Error(`Server error: ${response.status} - ${errorText}`); |
||||
|
}); |
||||
|
} |
||||
|
}) |
||||
|
.catch(error => { |
||||
|
console.error('Error updating invoice status:', error); |
||||
|
showMessage(`Failed to update invoice status: ${error.message}`, 'error'); |
||||
|
// Reset drag state on error
|
||||
|
resetDragState(); |
||||
|
}) |
||||
|
.finally(() => { |
||||
|
// Remove loading overlay
|
||||
|
if (loadingOverlay && loadingOverlay.parentNode) { |
||||
|
loadingOverlay.parentNode.removeChild(loadingOverlay); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
// Update stored search results when status changes
|
||||
|
function updateStoredSearchResults(invoiceId, newStatus) { |
||||
|
console.log(`Searching for invoice ${invoiceId} in stored search results...`); |
||||
|
|
||||
|
for (let i = 0; i < lastSearchResults.length; i++) { |
||||
|
const result = lastSearchResults[i]; |
||||
|
// Check both ID and invoice number for match
|
||||
|
if (result.id === invoiceId || result.id === invoiceId.toString() || |
||||
|
result.invoiceNumber === invoiceId || result.invoiceNumber === invoiceId.toString()) { |
||||
|
lastSearchResults[i].status = newStatus; |
||||
|
console.log(`Updated stored search result for invoice ${invoiceId} to status ${newStatus}`); |
||||
|
return; |
||||
|
} |
||||
|
} |
||||
|
console.log(`Warning: Could not find invoice ${invoiceId} in stored search results`); |
||||
|
} |
||||
|
|
||||
|
// Status transition validation
|
||||
|
function isValidStatusTransition(from, to) { |
||||
|
const transitions = { |
||||
|
'draft': ['ok', 'void'], |
||||
|
'ok': ['draft', 'failed', 'pending_accounting', 'processed', 'void'], |
||||
|
'failed': ['draft', 'ok', 'void'], |
||||
|
'pending_accounting': ['failed', 'processed', 'void'], |
||||
|
'processed': ['void'], |
||||
|
'void': [] // Cannot transition from void
|
||||
|
}; |
||||
|
|
||||
|
return transitions[from] && transitions[from].includes(to); |
||||
|
} |
||||
|
|
||||
|
function updateColumnCounts() { |
||||
|
document.querySelectorAll('.kanban-column').forEach(column => { |
||||
|
const status = column.dataset.status; |
||||
|
const cards = column.querySelectorAll('.board-invoice-card'); |
||||
|
const countElement = column.querySelector('.invoice-count'); |
||||
|
|
||||
|
if (countElement) { |
||||
|
countElement.textContent = cards.length; |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
// Add invoice to board (called when loading invoices)
|
||||
|
function addInvoiceToBoard(invoiceData) { |
||||
|
const status = invoiceData.status; |
||||
|
const dropZone = document.querySelector(`[data-status="${status}"] .invoice-drop-zone`); |
||||
|
|
||||
|
if (!dropZone) { |
||||
|
console.error(`No drop zone found for status: ${status}`); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// Create invoice card element
|
||||
|
const cardHTML = createBoardCardHTML(invoiceData); |
||||
|
const tempDiv = document.createElement('div'); |
||||
|
tempDiv.innerHTML = cardHTML; |
||||
|
const card = tempDiv.firstElementChild; |
||||
|
|
||||
|
// Add drag event listeners
|
||||
|
card.addEventListener('dragstart', handleDragStart); |
||||
|
card.addEventListener('dragend', handleDragEnd); |
||||
|
|
||||
|
console.log(`Added drag event listeners to invoice card ${invoiceData.id}`); |
||||
|
console.log(`Card draggable attribute: ${card.getAttribute('draggable')}`); |
||||
|
console.log(`Card data-invoice-id: ${card.dataset.invoiceId}`); |
||||
|
console.log(`Card data-status: ${card.dataset.status}`); |
||||
|
|
||||
|
// Hide empty message
|
||||
|
const emptyMessage = dropZone.querySelector('.empty-column-message'); |
||||
|
if (emptyMessage) { |
||||
|
emptyMessage.style.display = 'none'; |
||||
|
} |
||||
|
|
||||
|
// Add to drop zone
|
||||
|
dropZone.appendChild(card); |
||||
|
|
||||
|
// Store invoice data with both ID and invoice number as keys for easier lookup
|
||||
|
const invoiceId = invoiceData.id; |
||||
|
boardInvoices.set(invoiceId, invoiceData); |
||||
|
|
||||
|
// Also store by invoice number if different from ID
|
||||
|
if (invoiceData.invoiceNumber && invoiceData.invoiceNumber !== invoiceId) { |
||||
|
boardInvoices.set(invoiceData.invoiceNumber, invoiceData); |
||||
|
} |
||||
|
|
||||
|
console.log(`Stored board invoice data for ID: ${invoiceId}, Number: ${invoiceData.invoiceNumber}`); |
||||
|
|
||||
|
// Update counts
|
||||
|
updateColumnCounts(); |
||||
|
} |
||||
|
|
||||
|
function createBoardCardHTML(invoice) { |
||||
|
return ` |
||||
|
<div id="board-invoice-${invoice.id}" class="board-invoice-card" draggable="true" data-invoice-id="${invoice.id}" data-status="${invoice.status}"> |
||||
|
<div class="board-card-header"> |
||||
|
<div class="invoice-number">#${invoice.invoiceNumber}</div> |
||||
|
<div class="card-drag-handle">⋮⋮</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="board-card-content"> |
||||
|
${invoice.customer ? `<div class="card-customer">👤 ${invoice.customer.name}</div>` : ''} |
||||
|
${invoice.job ? `<div class="card-job">🔧 ${invoice.job.name}</div>` : ''} |
||||
|
<div class="card-total">💰 $${invoice.totalPrice}</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="board-card-footer"> |
||||
|
<div class="status-indicator status-${invoice.status}">${invoice.status}</div> |
||||
|
<button class="card-details-btn" onclick="showInvoiceDetails('${invoice.id}')" title="View Details"> |
||||
|
👁️ |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
`;
|
||||
|
} |
||||
|
|
||||
|
// Invoice details modal (placeholder)
|
||||
|
function showInvoiceDetails(invoiceId) { |
||||
|
console.log(`Showing details for invoice ${invoiceId}`); |
||||
|
// TODO: Implement invoice details modal
|
||||
|
showMessage(`Details for invoice ${invoiceId}`, 'info'); |
||||
|
} |
||||
|
|
||||
|
// Utility function for showing messages
|
||||
|
function showMessage(text, type = 'info') { |
||||
|
const msg = document.createElement('div'); |
||||
|
msg.textContent = text; |
||||
|
msg.style.cssText = ` |
||||
|
position: fixed; |
||||
|
top: 20px; |
||||
|
right: 20px; |
||||
|
padding: 12px 20px; |
||||
|
border-radius: 4px; |
||||
|
color: white; |
||||
|
font-weight: bold; |
||||
|
z-index: 10000; |
||||
|
transform: translateX(300px); |
||||
|
transition: transform 0.3s ease; |
||||
|
background: ${getMessageColor(type)}; |
||||
|
`;
|
||||
|
|
||||
|
document.body.appendChild(msg); |
||||
|
|
||||
|
// Show message
|
||||
|
setTimeout(() => msg.style.transform = 'translateX(0)', 100); |
||||
|
|
||||
|
// Hide message
|
||||
|
setTimeout(() => { |
||||
|
msg.style.transform = 'translateX(300px)'; |
||||
|
setTimeout(() => { |
||||
|
if (document.body.contains(msg)) { |
||||
|
document.body.removeChild(msg); |
||||
|
} |
||||
|
}, 300); |
||||
|
}, 3000); |
||||
|
} |
||||
|
|
||||
|
function getMessageColor(type) { |
||||
|
const colors = { |
||||
|
'success': '#10b981', |
||||
|
'error': '#ef4444', |
||||
|
'warning': '#f59e0b', |
||||
|
'info': '#3b82f6' |
||||
|
}; |
||||
|
return colors[type] || colors.info; |
||||
|
} |
||||
|
|
||||
|
// Debug function to check board state
|
||||
|
function debugBoardState() { |
||||
|
console.log('=== BOARD DEBUG INFO ==='); |
||||
|
console.log(`Current view: ${currentView}`); |
||||
|
console.log(`Last search results count: ${lastSearchResults.length}`); |
||||
|
console.log(`Board invoices stored: ${boardInvoices.size}`); |
||||
|
|
||||
|
const allCards = document.querySelectorAll('.board-invoice-card'); |
||||
|
console.log(`Board cards in DOM: ${allCards.length}`); |
||||
|
|
||||
|
allCards.forEach((card, index) => { |
||||
|
console.log(`Card ${index}: ID=${card.dataset.invoiceId}, Status=${card.dataset.status}, Draggable=${card.getAttribute('draggable')}`); |
||||
|
}); |
||||
|
|
||||
|
const dropZones = document.querySelectorAll('.invoice-drop-zone'); |
||||
|
dropZones.forEach(zone => { |
||||
|
const status = zone.dataset.status; |
||||
|
const cardsInZone = zone.querySelectorAll('.board-invoice-card').length; |
||||
|
console.log(`Drop zone ${status}: ${cardsInZone} cards`); |
||||
|
}); |
||||
|
|
||||
|
console.log('========================'); |
||||
|
} |
||||
|
|
||||
|
// Make debug function globally accessible for browser console
|
||||
|
window.debugBoardState = debugBoardState; |
||||
@ -0,0 +1,10 @@ |
|||||
|
{{define "admin_content"}} |
||||
|
<div class="page-header"> |
||||
|
<h2>Administration</h2> |
||||
|
<p>Administrative tools and system management.</p> |
||||
|
</div> |
||||
|
|
||||
|
<div class="page-content"> |
||||
|
{{template "admin" .}} |
||||
|
</div> |
||||
|
{{end}} |
||||
@ -0,0 +1,10 @@ |
|||||
|
{{define "assets_content"}} |
||||
|
<div class="page-header"> |
||||
|
<h2>Assets Management</h2> |
||||
|
<p>Manage customer assets and equipment in the ServiceTrade system.</p> |
||||
|
</div> |
||||
|
|
||||
|
<div class="page-content"> |
||||
|
{{template "assets" .}} |
||||
|
</div> |
||||
|
{{end}} |
||||
@ -0,0 +1,10 @@ |
|||||
|
{{define "companies_content"}} |
||||
|
<div class="page-header"> |
||||
|
<h2>Companies Management</h2> |
||||
|
<p>Manage customer companies and their information.</p> |
||||
|
</div> |
||||
|
|
||||
|
<div class="page-content"> |
||||
|
{{template "companies" .}} |
||||
|
</div> |
||||
|
{{end}} |
||||
@ -0,0 +1,18 @@ |
|||||
|
{{define "contacts_content"}} |
||||
|
<div class="page-header"> |
||||
|
<h2>Contacts Management</h2> |
||||
|
<p>Manage customer contacts and their information.</p> |
||||
|
</div> |
||||
|
|
||||
|
<div class="page-content"> |
||||
|
<div class="content"> |
||||
|
<h3 class="submenu-header">Contact Search & Management</h3> |
||||
|
<p>Contact management functionality will be implemented here.</p> |
||||
|
<div class="placeholder-content"> |
||||
|
<div class="placeholder-icon">👥</div> |
||||
|
<h4>Coming Soon</h4> |
||||
|
<p>Contact management features are under development.</p> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
{{end}} |
||||
@ -0,0 +1,18 @@ |
|||||
|
{{define "contracts_content"}} |
||||
|
<div class="page-header"> |
||||
|
<h2>Contracts Management</h2> |
||||
|
<p>Manage service contracts and agreements.</p> |
||||
|
</div> |
||||
|
|
||||
|
<div class="page-content"> |
||||
|
<div class="content"> |
||||
|
<h3 class="submenu-header">Contract Search & Management</h3> |
||||
|
<p>Contract management functionality will be implemented here.</p> |
||||
|
<div class="placeholder-content"> |
||||
|
<div class="placeholder-icon">📋</div> |
||||
|
<h4>Coming Soon</h4> |
||||
|
<p>Contract management features are under development.</p> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
{{end}} |
||||
@ -0,0 +1,18 @@ |
|||||
|
{{define "generic_content"}} |
||||
|
<div class="page-header"> |
||||
|
<h2>Generic Tools</h2> |
||||
|
<p>Miscellaneous tools and utilities for ServiceTrade operations.</p> |
||||
|
</div> |
||||
|
|
||||
|
<div class="page-content"> |
||||
|
<div class="content"> |
||||
|
<h3 class="submenu-header">Generic Tools & Utilities</h3> |
||||
|
<p>Generic tools and utilities will be implemented here.</p> |
||||
|
<div class="placeholder-content"> |
||||
|
<div class="placeholder-icon">🔧</div> |
||||
|
<h4>Coming Soon</h4> |
||||
|
<p>Generic tools and utilities are under development.</p> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
{{end}} |
||||
@ -0,0 +1,157 @@ |
|||||
|
{{define "invoices_content"}} |
||||
|
<div class="invoices-page"> |
||||
|
<div class="invoices-header"> |
||||
|
<h2>Invoice Management</h2> |
||||
|
<div class="view-controls"> |
||||
|
<button id="search-view-btn" class="view-btn active" onclick="switchView('search')"> |
||||
|
🔍 Search View |
||||
|
</button> |
||||
|
<button id="board-view-btn" class="view-btn" onclick="switchView('board')"> |
||||
|
📋 Board View |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- Search View --> |
||||
|
<div id="search-view" class="view-section active"> |
||||
|
<div class="enhanced-search"> |
||||
|
<div class="search-header"> |
||||
|
<h3>Search Invoices</h3> |
||||
|
<div class="search-tips"> |
||||
|
💡 <strong>Tips:</strong> Search by invoice ID, customer name, or job number. Use commas or spaces |
||||
|
for multiple invoices. |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="search-container"> |
||||
|
<div class="search-input-group"> |
||||
|
<input id="invoice-search-input" class="enhanced-search-input" type="text" name="search" |
||||
|
placeholder="Enter invoice numbers, customer names, or job numbers..." hx-get="/invoices" |
||||
|
hx-trigger="keyup changed delay:500ms" hx-target="#invoice-search-results" |
||||
|
hx-indicator="#search-loading" /> |
||||
|
<button class="search-clear-btn" onclick="clearSearch()" style="display: none;">✕</button> |
||||
|
</div> |
||||
|
|
||||
|
<div id="search-loading" class="search-loading htmx-indicator"> |
||||
|
<div class="loading-spinner"></div> |
||||
|
<span>Searching invoices...</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div id="invoice-search-results" class="search-results"> |
||||
|
<div class="no-search-state"> |
||||
|
<div class="no-search-icon">📋</div> |
||||
|
<h4>Start searching for invoices</h4> |
||||
|
<p>Enter an invoice ID, customer name, or job number above to begin</p> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- Board View --> |
||||
|
<div id="board-view" class="view-section"> |
||||
|
<div class="board-header"> |
||||
|
<h3>Invoice Status Board</h3> |
||||
|
<div class="board-info"> |
||||
|
<p class="board-help">💡 <strong>Tip:</strong> Search for invoices in the Search View first, then switch |
||||
|
to Board View to see them organized by status.</p> |
||||
|
</div> |
||||
|
<div class="board-controls"> |
||||
|
<button class="refresh-board-btn" onclick="refreshBoard()"> |
||||
|
🔄 Refresh Board |
||||
|
</button> |
||||
|
<button class="load-recent-btn" onclick="loadRecentInvoices()"> |
||||
|
📥 Load Recent Invoices |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div id="invoice-board" class="invoice-kanban-board"> |
||||
|
<div class="kanban-column" data-status="draft"> |
||||
|
<div class="column-header"> |
||||
|
<h4>📝 Draft</h4> |
||||
|
<span class="invoice-count">0</span> |
||||
|
</div> |
||||
|
<div class="invoice-drop-zone" data-status="draft"> |
||||
|
<div class="empty-column-message"> |
||||
|
<div class="empty-icon">📝</div> |
||||
|
<p>No draft invoices</p> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="kanban-column" data-status="ok"> |
||||
|
<div class="column-header"> |
||||
|
<h4>✅ OK</h4> |
||||
|
<span class="invoice-count">0</span> |
||||
|
</div> |
||||
|
<div class="invoice-drop-zone" data-status="ok"> |
||||
|
<div class="empty-column-message"> |
||||
|
<div class="empty-icon">✅</div> |
||||
|
<p>No OK invoices</p> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="kanban-column" data-status="pending_accounting"> |
||||
|
<div class="column-header"> |
||||
|
<h4>🧾 Pending</h4> |
||||
|
<span class="invoice-count">0</span> |
||||
|
</div> |
||||
|
<div class="invoice-drop-zone" data-status="pending_accounting"> |
||||
|
<div class="empty-column-message"> |
||||
|
<div class="empty-icon">🧾</div> |
||||
|
<p>No pending invoices</p> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="kanban-column" data-status="processed"> |
||||
|
<div class="column-header"> |
||||
|
<h4>💰 Processed</h4> |
||||
|
<span class="invoice-count">0</span> |
||||
|
</div> |
||||
|
<div class="invoice-drop-zone" data-status="processed"> |
||||
|
<div class="empty-column-message"> |
||||
|
<div class="empty-icon">💰</div> |
||||
|
<p>No processed invoices</p> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="kanban-column" data-status="failed"> |
||||
|
<div class="column-header"> |
||||
|
<h4>❌ Failed</h4> |
||||
|
<span class="invoice-count">0</span> |
||||
|
</div> |
||||
|
<div class="invoice-drop-zone" data-status="failed"> |
||||
|
<div class="empty-column-message"> |
||||
|
<div class="empty-icon">❌</div> |
||||
|
<p>No failed invoices</p> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="kanban-column" data-status="void"> |
||||
|
<div class="column-header"> |
||||
|
<h4>💣 Void</h4> |
||||
|
<span class="invoice-count">0</span> |
||||
|
</div> |
||||
|
<div class="invoice-drop-zone" data-status="void"> |
||||
|
<div class="empty-column-message"> |
||||
|
<div class="empty-icon">💣</div> |
||||
|
<p>No void invoices</p> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div id="board-loading" class="board-loading" style="display: none;"> |
||||
|
<div class="loading-spinner"></div> |
||||
|
<span>Loading invoices...</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<script src="/static/js/invoices.js"></script> |
||||
|
{{end}} |
||||
@ -0,0 +1,10 @@ |
|||||
|
{{define "jobs_content"}} |
||||
|
<div class="page-header"> |
||||
|
<h2>Jobs Management</h2> |
||||
|
<p>Manage and search for jobs in the ServiceTrade system.</p> |
||||
|
</div> |
||||
|
|
||||
|
<div class="page-content"> |
||||
|
{{template "jobs" .}} |
||||
|
</div> |
||||
|
{{end}} |
||||
@ -0,0 +1,18 @@ |
|||||
|
{{define "locations_content"}} |
||||
|
<div class="page-header"> |
||||
|
<h2>Locations Management</h2> |
||||
|
<p>Manage customer locations and site information.</p> |
||||
|
</div> |
||||
|
|
||||
|
<div class="page-content"> |
||||
|
<div class="content"> |
||||
|
<h3 class="submenu-header">Location Search & Management</h3> |
||||
|
<p>Location management functionality will be implemented here.</p> |
||||
|
<div class="placeholder-content"> |
||||
|
<div class="placeholder-icon">📍</div> |
||||
|
<h4>Coming Soon</h4> |
||||
|
<p>Location management features are under development.</p> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
{{end}} |
||||
@ -0,0 +1,18 @@ |
|||||
|
{{define "notifications_content"}} |
||||
|
<div class="page-header"> |
||||
|
<h2>Notifications Management</h2> |
||||
|
<p>Manage system notifications and alerts.</p> |
||||
|
</div> |
||||
|
|
||||
|
<div class="page-content"> |
||||
|
<div class="content"> |
||||
|
<h3 class="submenu-header">Notification Settings & History</h3> |
||||
|
<p>Notification management functionality will be implemented here.</p> |
||||
|
<div class="placeholder-content"> |
||||
|
<div class="placeholder-icon">🔔</div> |
||||
|
<h4>Coming Soon</h4> |
||||
|
<p>Notification management features are under development.</p> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
{{end}} |
||||
@ -0,0 +1,23 @@ |
|||||
|
{{define "invoice_board_card"}} |
||||
|
{{$cardID := or .invoiceNumber .id}} |
||||
|
<div id="board-invoice-{{$cardID}}" class="board-invoice-card" draggable="true" data-invoice-id="{{.id}}" |
||||
|
data-status="{{.status}}"> |
||||
|
<div class="board-card-header"> |
||||
|
<div class="invoice-number">#{{.invoiceNumber}}</div> |
||||
|
<div class="card-drag-handle">⋮⋮</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="board-card-content"> |
||||
|
{{with .customer}}<div class="card-customer">👤 {{.name}}</div>{{end}} |
||||
|
{{with .job}}<div class="card-job">🔧 {{.name}}</div>{{end}} |
||||
|
<div class="card-total">💰 ${{.totalPrice}}</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="board-card-footer"> |
||||
|
<div class="status-indicator status-{{.status}}">{{.status}}</div> |
||||
|
<button class="card-details-btn" onclick="showInvoiceDetails('{{.id}}')" title="View Details"> |
||||
|
👁️ |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
{{end}} |
||||
@ -0,0 +1,18 @@ |
|||||
|
{{define "quotes_content"}} |
||||
|
<div class="page-header"> |
||||
|
<h2>Quotes Management</h2> |
||||
|
<p>Manage customer quotes and estimates.</p> |
||||
|
</div> |
||||
|
|
||||
|
<div class="page-content"> |
||||
|
<div class="content"> |
||||
|
<h3 class="submenu-header">Quote Search & Management</h3> |
||||
|
<p>Quote management functionality will be implemented here.</p> |
||||
|
<div class="placeholder-content"> |
||||
|
<div class="placeholder-icon">💰</div> |
||||
|
<h4>Coming Soon</h4> |
||||
|
<p>Quote management features are under development.</p> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
{{end}} |
||||
@ -0,0 +1,18 @@ |
|||||
|
{{define "services_content"}} |
||||
|
<div class="page-header"> |
||||
|
<h2>Services Management</h2> |
||||
|
<p>Manage service offerings and service definitions.</p> |
||||
|
</div> |
||||
|
|
||||
|
<div class="page-content"> |
||||
|
<div class="content"> |
||||
|
<h3 class="submenu-header">Service Search & Management</h3> |
||||
|
<p>Service management functionality will be implemented here.</p> |
||||
|
<div class="placeholder-content"> |
||||
|
<div class="placeholder-icon">🛠️</div> |
||||
|
<h4>Coming Soon</h4> |
||||
|
<p>Service management features are under development.</p> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
{{end}} |
||||
@ -0,0 +1,18 @@ |
|||||
|
{{define "tags_content"}} |
||||
|
<div class="page-header"> |
||||
|
<h2>Tags Management</h2> |
||||
|
<p>Manage tags and labels for organizing data.</p> |
||||
|
</div> |
||||
|
|
||||
|
<div class="page-content"> |
||||
|
<div class="content"> |
||||
|
<h3 class="submenu-header">Tag Search & Management</h3> |
||||
|
<p>Tag management functionality will be implemented here.</p> |
||||
|
<div class="placeholder-content"> |
||||
|
<div class="placeholder-icon">🏷️</div> |
||||
|
<h4>Coming Soon</h4> |
||||
|
<p>Tag management features are under development.</p> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
{{end}} |
||||
@ -0,0 +1,18 @@ |
|||||
|
{{define "users_content"}} |
||||
|
<div class="page-header"> |
||||
|
<h2>Users Management</h2> |
||||
|
<p>Manage user accounts and permissions.</p> |
||||
|
</div> |
||||
|
|
||||
|
<div class="page-content"> |
||||
|
<div class="content"> |
||||
|
<h3 class="submenu-header">User Search & Management</h3> |
||||
|
<p>User management functionality will be implemented here.</p> |
||||
|
<div class="placeholder-content"> |
||||
|
<div class="placeholder-icon">👤</div> |
||||
|
<h4>Coming Soon</h4> |
||||
|
<p>User management features are under development.</p> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
{{end}} |
||||
Loading…
Reference in new issue