Compare commits
18 Commits
26886d9b31
...
3af9a2806c
| Author | SHA1 | Date |
|---|---|---|
|
|
3af9a2806c | 9 months ago |
|
|
0a169b8b8b | 9 months ago |
|
|
38da0d751e | 9 months ago |
|
|
54ecba5940 | 9 months ago |
|
|
c9ae2fbef1 | 10 months ago |
|
|
7362ec24bc | 10 months ago |
|
|
b2476ffedc | 10 months ago |
|
|
1ad95c6e7d | 10 months ago |
|
|
6baa7256aa | 10 months ago |
|
|
f83a344928 | 10 months ago |
|
|
4a75181359 | 10 months ago |
|
|
6e4e6d94f7 | 10 months ago |
|
|
c8edfdb56c | 10 months ago |
|
|
5f29eca7c2 | 10 months ago |
|
|
b8546c0eea | 10 months ago |
|
|
cb3fc7fb44 | 11 months ago |
|
|
c16789b099 | 11 months ago |
|
|
51093cbd07 | 11 months ago |
53 changed files with 4982 additions and 1892 deletions
@ -0,0 +1,33 @@ |
|||
name: Build and Deploy Toolbox |
|||
|
|||
on: |
|||
push: |
|||
branches: |
|||
- '**' |
|||
|
|||
jobs: |
|||
build-and-deploy-dev: |
|||
if: github.ref_name != 'prod' |
|||
runs-on: [self-hosted, Windows, X64] |
|||
|
|||
steps: |
|||
- name: Checkout code |
|||
uses: actions/checkout@v4 |
|||
|
|||
- name: Deploy to Dev |
|||
run: | |
|||
& 'C:\Scripts\github\servicetrade-toolbox\servicetrade-toolbox-deploy.ps1' -Target dev -ProjectRoot $env:GITHUB_WORKSPACE |
|||
shell: pwsh |
|||
|
|||
build-and-deploy-prod: |
|||
if: github.ref_name == 'prod' |
|||
runs-on: [self-hosted, Windows, X64] |
|||
|
|||
steps: |
|||
- name: Checkout code |
|||
uses: actions/checkout@v4 |
|||
|
|||
- name: Deploy to Prod |
|||
run: | |
|||
& 'C:\Scripts\github\servicetrade-toolbox\servicetrade-toolbox-deploy.ps1' -Target prod -ProjectRoot $env:GITHUB_WORKSPACE |
|||
shell: pwsh |
|||
Binary file not shown.
@ -1,29 +1,18 @@ |
|||
package web |
|||
|
|||
import ( |
|||
"log" |
|||
root "marmic/servicetrade-toolbox" |
|||
"marmic/servicetrade-toolbox/internal/api" |
|||
"marmic/servicetrade-toolbox/internal/middleware" |
|||
"net/http" |
|||
) |
|||
|
|||
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 |
|||
data := map[string]interface{}{ |
|||
"Title": "Admin Dashboard", |
|||
"Session": session, |
|||
"Title": "Admin", |
|||
} |
|||
|
|||
if err := tmpl.ExecuteTemplate(w, "admin.html", data); err != nil { |
|||
log.Printf("Template execution error: %v", err) |
|||
err := tmpl.Execute(w, data) |
|||
if err != nil { |
|||
http.Error(w, "Internal Server Error", http.StatusInternalServerError) |
|||
return |
|||
} |
|||
} |
|||
|
|||
@ -1,29 +1,18 @@ |
|||
package web |
|||
|
|||
import ( |
|||
"log" |
|||
root "marmic/servicetrade-toolbox" |
|||
"marmic/servicetrade-toolbox/internal/api" |
|||
"marmic/servicetrade-toolbox/internal/middleware" |
|||
"net/http" |
|||
) |
|||
|
|||
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 |
|||
data := map[string]interface{}{ |
|||
"Title": "Admin Dashboard", |
|||
"Session": session, |
|||
"Title": "Assets", |
|||
} |
|||
|
|||
if err := tmpl.ExecuteTemplate(w, "assets.html", data); err != nil { |
|||
log.Printf("Template execution error: %v", err) |
|||
err := tmpl.Execute(w, data) |
|||
if err != nil { |
|||
http.Error(w, "Internal Server Error", http.StatusInternalServerError) |
|||
return |
|||
} |
|||
} |
|||
|
|||
@ -1,37 +1,18 @@ |
|||
package web |
|||
|
|||
import ( |
|||
"log" |
|||
root "marmic/servicetrade-toolbox" |
|||
"marmic/servicetrade-toolbox/internal/api" |
|||
"marmic/servicetrade-toolbox/internal/middleware" |
|||
"net/http" |
|||
) |
|||
|
|||
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 |
|||
data := map[string]interface{}{ |
|||
"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) |
|||
"Title": "Companies", |
|||
} |
|||
|
|||
err := tmpl.Execute(w, data) |
|||
if err != nil { |
|||
log.Printf("Template execution error: %v", err) |
|||
http.Error(w, "Internal Server Error", http.StatusInternalServerError) |
|||
return |
|||
} |
|||
} |
|||
|
|||
@ -1,19 +1,18 @@ |
|||
package web |
|||
|
|||
import ( |
|||
"html/template" |
|||
root "marmic/servicetrade-toolbox" |
|||
"net/http" |
|||
) |
|||
|
|||
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{}{ |
|||
"Title": "Contacts", |
|||
} |
|||
|
|||
if r.Header.Get("HX-Request") == "true" { |
|||
tmpl.ExecuteTemplate(w, "content", data) |
|||
} else { |
|||
tmpl.Execute(w, data) |
|||
err := tmpl.Execute(w, data) |
|||
if err != nil { |
|||
http.Error(w, "Internal Server Error", http.StatusInternalServerError) |
|||
} |
|||
} |
|||
|
|||
@ -1,19 +1,18 @@ |
|||
package web |
|||
|
|||
import ( |
|||
"html/template" |
|||
root "marmic/servicetrade-toolbox" |
|||
"net/http" |
|||
) |
|||
|
|||
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{}{ |
|||
"Title": "Contracts", |
|||
} |
|||
|
|||
if r.Header.Get("HX-Request") == "true" { |
|||
tmpl.ExecuteTemplate(w, "content", data) |
|||
} else { |
|||
tmpl.Execute(w, data) |
|||
err := tmpl.Execute(w, data) |
|||
if err != nil { |
|||
http.Error(w, "Internal Server Error", http.StatusInternalServerError) |
|||
} |
|||
} |
|||
|
|||
File diff suppressed because it is too large
File diff suppressed because it is too large
@ -1,19 +1,18 @@ |
|||
package web |
|||
|
|||
import ( |
|||
"html/template" |
|||
root "marmic/servicetrade-toolbox" |
|||
"net/http" |
|||
) |
|||
|
|||
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{}{ |
|||
"Title": "Generic Tools", |
|||
"Title": "Generic", |
|||
} |
|||
|
|||
if r.Header.Get("HX-Request") == "true" { |
|||
tmpl.ExecuteTemplate(w, "content", data) |
|||
} else { |
|||
tmpl.Execute(w, data) |
|||
err := tmpl.Execute(w, data) |
|||
if err != nil { |
|||
http.Error(w, "Internal Server Error", http.StatusInternalServerError) |
|||
} |
|||
} |
|||
|
|||
@ -1,19 +1,18 @@ |
|||
package web |
|||
|
|||
import ( |
|||
"html/template" |
|||
root "marmic/servicetrade-toolbox" |
|||
"net/http" |
|||
) |
|||
|
|||
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{}{ |
|||
"Title": "Locations", |
|||
} |
|||
|
|||
if r.Header.Get("HX-Request") == "true" { |
|||
tmpl.ExecuteTemplate(w, "content", data) |
|||
} else { |
|||
tmpl.Execute(w, data) |
|||
err := tmpl.Execute(w, data) |
|||
if err != nil { |
|||
http.Error(w, "Internal Server Error", http.StatusInternalServerError) |
|||
} |
|||
} |
|||
|
|||
@ -1,19 +1,18 @@ |
|||
package web |
|||
|
|||
import ( |
|||
"html/template" |
|||
root "marmic/servicetrade-toolbox" |
|||
"net/http" |
|||
) |
|||
|
|||
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{}{ |
|||
"Title": "Notifications", |
|||
} |
|||
|
|||
if r.Header.Get("HX-Request") == "true" { |
|||
tmpl.ExecuteTemplate(w, "content", data) |
|||
} else { |
|||
tmpl.Execute(w, data) |
|||
err := tmpl.Execute(w, data) |
|||
if err != nil { |
|||
http.Error(w, "Internal Server Error", http.StatusInternalServerError) |
|||
} |
|||
} |
|||
|
|||
@ -1,19 +1,18 @@ |
|||
package web |
|||
|
|||
import ( |
|||
"html/template" |
|||
root "marmic/servicetrade-toolbox" |
|||
"net/http" |
|||
) |
|||
|
|||
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{}{ |
|||
"Title": "Quotes", |
|||
} |
|||
|
|||
if r.Header.Get("HX-Request") == "true" { |
|||
tmpl.ExecuteTemplate(w, "content", data) |
|||
} else { |
|||
tmpl.Execute(w, data) |
|||
err := tmpl.Execute(w, data) |
|||
if err != nil { |
|||
http.Error(w, "Internal Server Error", http.StatusInternalServerError) |
|||
} |
|||
} |
|||
|
|||
@ -1,19 +1,18 @@ |
|||
package web |
|||
|
|||
import ( |
|||
"html/template" |
|||
root "marmic/servicetrade-toolbox" |
|||
"net/http" |
|||
) |
|||
|
|||
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{}{ |
|||
"Title": "Services", |
|||
} |
|||
|
|||
if r.Header.Get("HX-Request") == "true" { |
|||
tmpl.ExecuteTemplate(w, "content", data) |
|||
} else { |
|||
tmpl.Execute(w, data) |
|||
err := tmpl.Execute(w, data) |
|||
if err != nil { |
|||
http.Error(w, "Internal Server Error", http.StatusInternalServerError) |
|||
} |
|||
} |
|||
|
|||
@ -1,19 +1,18 @@ |
|||
package web |
|||
|
|||
import ( |
|||
"html/template" |
|||
root "marmic/servicetrade-toolbox" |
|||
"net/http" |
|||
) |
|||
|
|||
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{}{ |
|||
"Title": "Tags", |
|||
} |
|||
|
|||
if r.Header.Get("HX-Request") == "true" { |
|||
tmpl.ExecuteTemplate(w, "content", data) |
|||
} else { |
|||
tmpl.Execute(w, data) |
|||
err := tmpl.Execute(w, data) |
|||
if err != nil { |
|||
http.Error(w, "Internal Server Error", http.StatusInternalServerError) |
|||
} |
|||
} |
|||
|
|||
@ -1,19 +1,18 @@ |
|||
package web |
|||
|
|||
import ( |
|||
"html/template" |
|||
root "marmic/servicetrade-toolbox" |
|||
"net/http" |
|||
) |
|||
|
|||
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{}{ |
|||
"Title": "Users", |
|||
} |
|||
|
|||
if r.Header.Get("HX-Request") == "true" { |
|||
tmpl.ExecuteTemplate(w, "content", data) |
|||
} else { |
|||
tmpl.Execute(w, data) |
|||
err := tmpl.Execute(w, data) |
|||
if err != nil { |
|||
http.Error(w, "Internal Server Error", http.StatusInternalServerError) |
|||
} |
|||
} |
|||
|
|||
@ -0,0 +1,88 @@ |
|||
package utils |
|||
|
|||
import ( |
|||
"math" |
|||
) |
|||
|
|||
// Default pagination constants
|
|||
const ( |
|||
DefaultPage = 1 |
|||
DefaultPageSize = 5 |
|||
MaxPageSize = 100 |
|||
) |
|||
|
|||
// PaginationInfo contains all the information needed for pagination
|
|||
type PaginationInfo struct { |
|||
TotalResults int |
|||
TotalPages int |
|||
CurrentPage int |
|||
Limit int |
|||
StartIndex int |
|||
EndIndex int |
|||
StartPage int |
|||
EndPage int |
|||
} |
|||
|
|||
// CalculatePagination calculates pagination information based on total results, current page, and limit
|
|||
func CalculatePagination(totalResults, currentPage, limit int) PaginationInfo { |
|||
if currentPage < 1 { |
|||
currentPage = 1 |
|||
} |
|||
if limit < 1 { |
|||
limit = DefaultPageSize |
|||
} |
|||
|
|||
totalPages := int(math.Ceil(float64(totalResults) / float64(limit))) |
|||
if totalPages < 1 { |
|||
totalPages = 1 |
|||
} |
|||
|
|||
// Ensure current page is within bounds
|
|||
if currentPage > totalPages { |
|||
currentPage = totalPages |
|||
} |
|||
|
|||
startIndex := (currentPage - 1) * limit |
|||
endIndex := startIndex + limit |
|||
if endIndex > totalResults { |
|||
endIndex = totalResults |
|||
} |
|||
|
|||
// Calculate pagination range for display
|
|||
startPage := 1 |
|||
endPage := totalPages |
|||
|
|||
// Show up to 10 page numbers, centered around current page when possible
|
|||
if totalPages > 10 { |
|||
if currentPage <= 5 { |
|||
endPage = 10 |
|||
} else if currentPage >= totalPages-4 { |
|||
startPage = totalPages - 9 |
|||
} else { |
|||
startPage = currentPage - 4 |
|||
endPage = currentPage + 5 |
|||
} |
|||
} |
|||
|
|||
return PaginationInfo{ |
|||
TotalResults: totalResults, |
|||
TotalPages: totalPages, |
|||
CurrentPage: currentPage, |
|||
Limit: limit, |
|||
StartIndex: startIndex + 1, // Convert to 1-based for display
|
|||
EndIndex: endIndex, |
|||
StartPage: startPage, |
|||
EndPage: endPage, |
|||
} |
|||
} |
|||
|
|||
// GetPageResults extracts the results for the current page from a slice
|
|||
func GetPageResults[T any](allResults []T, startIndex, endIndex int) []T { |
|||
if startIndex >= len(allResults) { |
|||
return []T{} |
|||
} |
|||
if endIndex > len(allResults) { |
|||
endIndex = len(allResults) |
|||
} |
|||
return allResults[startIndex:endIndex] |
|||
} |
|||
File diff suppressed because it is too large
File diff suppressed because it is too large
@ -0,0 +1,154 @@ |
|||
// Simple Dashboard Drag and Drop
|
|||
let isCustomizing = false; |
|||
let draggedCard = null; |
|||
|
|||
// Initialize on page load
|
|||
document.addEventListener('DOMContentLoaded', function () { |
|||
loadLayout(); |
|||
}); |
|||
|
|||
function toggleCustomize() { |
|||
const btn = document.getElementById('customize-btn'); |
|||
const help = document.getElementById('customization-help'); |
|||
const cards = document.querySelectorAll('.draggable-card'); |
|||
const handles = document.querySelectorAll('.drag-handle'); |
|||
|
|||
isCustomizing = !isCustomizing; |
|||
|
|||
if (isCustomizing) { |
|||
btn.textContent = '💾 Save Layout'; |
|||
btn.classList.add('saving'); |
|||
help.style.display = 'block'; |
|||
|
|||
// Enable dragging
|
|||
cards.forEach(card => { |
|||
card.draggable = true; |
|||
card.classList.add('customizable'); |
|||
card.addEventListener('dragstart', handleDragStart); |
|||
card.addEventListener('dragover', handleDragOver); |
|||
card.addEventListener('drop', handleDrop); |
|||
card.addEventListener('dragend', handleDragEnd); |
|||
}); |
|||
|
|||
// Show handles
|
|||
handles.forEach(handle => handle.style.display = 'block'); |
|||
|
|||
} else { |
|||
btn.textContent = '📝 Customize Layout'; |
|||
btn.classList.remove('saving'); |
|||
help.style.display = 'none'; |
|||
|
|||
// Disable dragging
|
|||
cards.forEach(card => { |
|||
card.draggable = false; |
|||
card.classList.remove('customizable'); |
|||
card.removeEventListener('dragstart', handleDragStart); |
|||
card.removeEventListener('dragover', handleDragOver); |
|||
card.removeEventListener('drop', handleDrop); |
|||
card.removeEventListener('dragend', handleDragEnd); |
|||
}); |
|||
|
|||
// Hide handles
|
|||
handles.forEach(handle => handle.style.display = 'none'); |
|||
|
|||
// Save the current layout
|
|||
saveLayout(); |
|||
showMessage('Layout saved!', 'success'); |
|||
} |
|||
} |
|||
|
|||
function handleDragStart(e) { |
|||
draggedCard = e.target.closest('.draggable-card'); |
|||
draggedCard.classList.add('dragging'); |
|||
e.dataTransfer.effectAllowed = 'move'; |
|||
} |
|||
|
|||
function handleDragOver(e) { |
|||
e.preventDefault(); |
|||
e.dataTransfer.dropEffect = 'move'; |
|||
|
|||
const card = e.target.closest('.draggable-card'); |
|||
if (card && card !== draggedCard) { |
|||
const grid = document.getElementById('dashboard-grid'); |
|||
const cards = Array.from(grid.children); |
|||
const draggedIndex = cards.indexOf(draggedCard); |
|||
const targetIndex = cards.indexOf(card); |
|||
|
|||
if (draggedIndex < targetIndex) { |
|||
grid.insertBefore(draggedCard, card.nextSibling); |
|||
} else { |
|||
grid.insertBefore(draggedCard, card); |
|||
} |
|||
} |
|||
} |
|||
|
|||
function handleDrop(e) { |
|||
e.preventDefault(); |
|||
} |
|||
|
|||
function handleDragEnd(e) { |
|||
if (draggedCard) { |
|||
draggedCard.classList.remove('dragging'); |
|||
draggedCard = null; |
|||
} |
|||
} |
|||
|
|||
function saveLayout() { |
|||
const cards = document.querySelectorAll('.draggable-card'); |
|||
const layout = Array.from(cards).map((card, index) => ({ |
|||
id: card.dataset.widgetId, |
|||
position: index |
|||
})); |
|||
localStorage.setItem('dashboard-layout', JSON.stringify(layout)); |
|||
} |
|||
|
|||
function loadLayout() { |
|||
const saved = localStorage.getItem('dashboard-layout'); |
|||
if (!saved) return; |
|||
|
|||
try { |
|||
const layout = JSON.parse(saved); |
|||
const grid = document.getElementById('dashboard-grid'); |
|||
|
|||
// Sort layout by position
|
|||
layout.sort((a, b) => a.position - b.position); |
|||
|
|||
// Reorder cards
|
|||
layout.forEach(item => { |
|||
const card = document.querySelector(`[data-widget-id="${item.id}"]`); |
|||
if (card) grid.appendChild(card); |
|||
}); |
|||
} catch (e) { |
|||
console.warn('Failed to load layout:', e); |
|||
} |
|||
} |
|||
|
|||
function showMessage(text, type) { |
|||
// Create message element
|
|||
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; |
|||
background: ${type === 'success' ? '#10b981' : '#ef4444'}; |
|||
transform: translateX(300px); |
|||
transition: transform 0.3s ease; |
|||
`;
|
|||
|
|||
document.body.appendChild(msg); |
|||
|
|||
// Show message
|
|||
setTimeout(() => msg.style.transform = 'translateX(0)', 100); |
|||
|
|||
// Hide message
|
|||
setTimeout(() => { |
|||
msg.style.transform = 'translateX(300px)'; |
|||
setTimeout(() => document.body.removeChild(msg), 300); |
|||
}, 2000); |
|||
} |
|||
@ -1 +0,0 @@ |
|||
Redirecting to https://unpkg.com/htmx.org@1.9.12/dist/htmx.min.js
|
|||
@ -0,0 +1,868 @@ |
|||
// 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'); |
|||
|
|||
// Store new search results separately
|
|||
const newSearchResults = []; |
|||
|
|||
invoiceCards.forEach(card => { |
|||
const invoiceData = extractInvoiceDataFromCard(card); |
|||
if (invoiceData) { |
|||
newSearchResults.push(invoiceData); |
|||
console.log('Extracted invoice data:', invoiceData); |
|||
} |
|||
}); |
|||
|
|||
console.log(`Captured ${newSearchResults.length} new invoices from search results`); |
|||
|
|||
// Add new results to existing results (avoid duplicates)
|
|||
newSearchResults.forEach(newInvoice => { |
|||
const exists = lastSearchResults.some(existing => |
|||
existing.id === newInvoice.id || |
|||
existing.invoiceNumber === newInvoice.invoiceNumber |
|||
); |
|||
|
|||
if (!exists) { |
|||
lastSearchResults.push(newInvoice); |
|||
console.log(`Added new invoice to collection: ${newInvoice.id}`); |
|||
} else { |
|||
console.log(`Invoice already exists in collection: ${newInvoice.id}`); |
|||
} |
|||
}); |
|||
|
|||
// If we're currently in board view, add the new invoices to the board
|
|||
if (currentView === 'board') { |
|||
addNewInvoicesToBoard(newSearchResults); |
|||
} |
|||
} |
|||
|
|||
// 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> |
|||
`;
|
|||
|
|||
// Don't clear board when clearing search - let users keep their board intact
|
|||
console.log('Search cleared, but board preserved'); |
|||
} |
|||
|
|||
// View switching functionality (modified to preserve board)
|
|||
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, only populate if board is empty
|
|||
if (viewName === 'board') { |
|||
const existingCards = document.querySelectorAll('.board-invoice-card'); |
|||
if (existingCards.length === 0 && lastSearchResults.length > 0) { |
|||
populateBoardWithSearchResults(); |
|||
} else if (existingCards.length > 0) { |
|||
console.log(`Board already has ${existingCards.length} invoices, keeping them`); |
|||
} else { |
|||
checkBoardState(); |
|||
} |
|||
} |
|||
|
|||
console.log(`Switched to ${viewName} view`); |
|||
} |
|||
|
|||
// Populate board with search results (modified to be explicit about clearing)
|
|||
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'); |
|||
} |
|||
|
|||
// 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(); |
|||
|
|||
console.log('Board cleared'); |
|||
} |
|||
|
|||
// 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('Sorry, this feature is not yet implemented', '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
|
|||
function showInvoiceDetails(invoiceId) { |
|||
console.log(`Showing details for invoice ${invoiceId}`); |
|||
|
|||
// Find the invoice data in our stored data
|
|||
let invoiceData = boardInvoices.get(invoiceId); |
|||
|
|||
// If not found in board, try searching in lastSearchResults
|
|||
if (!invoiceData) { |
|||
invoiceData = lastSearchResults.find(inv => |
|||
inv.id === invoiceId || inv.id === invoiceId.toString() || |
|||
inv.invoiceNumber === invoiceId |
|||
); |
|||
} |
|||
|
|||
if (!invoiceData) { |
|||
showMessage('Invoice details not found', 'error'); |
|||
return; |
|||
} |
|||
|
|||
// Create and show modal
|
|||
const modal = createInvoiceDetailsModal(invoiceData); |
|||
document.body.appendChild(modal); |
|||
|
|||
// Show modal with animation
|
|||
setTimeout(() => modal.classList.add('show'), 10); |
|||
|
|||
// Close modal when clicking outside or on close button
|
|||
modal.addEventListener('click', function (e) { |
|||
if (e.target === modal || e.target.classList.contains('modal-close')) { |
|||
closeInvoiceDetailsModal(modal); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
function createInvoiceDetailsModal(invoiceData) { |
|||
const modal = document.createElement('div'); |
|||
modal.className = 'invoice-details-modal'; |
|||
|
|||
modal.innerHTML = ` |
|||
<div class="modal-content"> |
|||
<div class="modal-header"> |
|||
<h3>Invoice Details</h3> |
|||
<button class="modal-close" type="button">✕</button> |
|||
</div> |
|||
|
|||
<div class="modal-body"> |
|||
<div class="invoice-detail-grid"> |
|||
<div class="detail-row"> |
|||
<span class="detail-label">Invoice #:</span> |
|||
<span class="detail-value">${invoiceData.invoiceNumber || 'N/A'}</span> |
|||
</div> |
|||
|
|||
<div class="detail-row"> |
|||
<span class="detail-label">Invoice ID:</span> |
|||
<span class="detail-value">${invoiceData.id || 'N/A'}</span> |
|||
</div> |
|||
|
|||
<div class="detail-row"> |
|||
<span class="detail-label">Status:</span> |
|||
<span class="detail-value"> |
|||
<span class="status-badge status-${invoiceData.status}">${invoiceData.status}</span> |
|||
</span> |
|||
</div> |
|||
|
|||
<div class="detail-row"> |
|||
<span class="detail-label">Customer:</span> |
|||
<span class="detail-value">${invoiceData.customer ? invoiceData.customer.name : 'N/A'}</span> |
|||
</div> |
|||
|
|||
<div class="detail-row"> |
|||
<span class="detail-label">Job:</span> |
|||
<span class="detail-value">${invoiceData.job ? invoiceData.job.name : 'N/A'}</span> |
|||
</div> |
|||
|
|||
<div class="detail-row"> |
|||
<span class="detail-label">Total Price:</span> |
|||
<span class="detail-value total-amount">$${invoiceData.totalPrice || '0.00'}</span> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="modal-footer"> |
|||
<button class="btn-secondary modal-close" type="button">Close</button> |
|||
</div> |
|||
</div> |
|||
`;
|
|||
|
|||
return modal; |
|||
} |
|||
|
|||
function closeInvoiceDetailsModal(modal) { |
|||
modal.classList.remove('show'); |
|||
setTimeout(() => { |
|||
if (document.body.contains(modal)) { |
|||
document.body.removeChild(modal); |
|||
} |
|||
}, 300); |
|||
} |
|||
|
|||
// 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; |
|||
|
|||
// Add new invoices to board without clearing existing ones
|
|||
function addNewInvoicesToBoard(newInvoices) { |
|||
console.log(`Adding ${newInvoices.length} new invoices to existing board`); |
|||
|
|||
let addedCount = 0; |
|||
newInvoices.forEach(invoiceData => { |
|||
// Check if invoice is already on the board
|
|||
const existingCard = document.getElementById(`board-invoice-${invoiceData.id}`); |
|||
if (!existingCard) { |
|||
addInvoiceToBoard(invoiceData); |
|||
addedCount++; |
|||
} else { |
|||
console.log(`Invoice ${invoiceData.id} already on board, skipping`); |
|||
} |
|||
}); |
|||
|
|||
if (addedCount > 0) { |
|||
showMessage(`Added ${addedCount} new invoices to board`, 'success'); |
|||
} else if (newInvoices.length > 0) { |
|||
showMessage(`All ${newInvoices.length} invoices already on board`, 'info'); |
|||
} |
|||
} |
|||
|
|||
// Clear board with confirmation
|
|||
function clearBoardAndConfirm() { |
|||
const existingCards = document.querySelectorAll('.board-invoice-card'); |
|||
|
|||
if (existingCards.length === 0) { |
|||
showMessage('Board is already empty', 'info'); |
|||
return; |
|||
} |
|||
|
|||
const confirmed = confirm(`Are you sure you want to clear all ${existingCards.length} invoices from the board? This will not affect the invoices themselves, only remove them from the board view.`); |
|||
|
|||
if (confirmed) { |
|||
clearBoard(); |
|||
showMessage('Board cleared successfully', 'success'); |
|||
} |
|||
} |
|||
@ -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}} |
|||
@ -1,17 +1,31 @@ |
|||
{{define "content"}} |
|||
<h2>Dashboard</h2> |
|||
{{define "dashboard_content"}} |
|||
<div class="dashboard-header"> |
|||
<h2>Dashboard</h2> |
|||
<button id="customize-btn" class="customize-btn" onclick="toggleCustomize()"> |
|||
📝 Customize Layout |
|||
</button> |
|||
</div> |
|||
|
|||
<p>Welcome to the ServiceTrade Tools Dashboard.</p> |
|||
|
|||
<div class="dashboard-grid"> |
|||
<div class="dashboard-item"> |
|||
<div id="customization-help" class="customization-help" style="display: none;"> |
|||
💡 <strong>Tip:</strong> Drag and drop the cards to arrange them in your preferred order. |
|||
</div> |
|||
|
|||
<div id="dashboard-grid" class="dashboard-grid"> |
|||
<div class="dashboard-item draggable-card" data-widget-id="invoice-search" data-widget-title="Update Invoice Status"> |
|||
<div class="drag-handle" style="display: none;">⋮⋮</div> |
|||
{{template "invoice_search" .}} |
|||
</div> |
|||
<div class="dashboard-item"> |
|||
|
|||
<div class="dashboard-item draggable-card" data-widget-id="document-upload" data-widget-title="Document Uploads"> |
|||
<div class="drag-handle" style="display: none;">⋮⋮</div> |
|||
{{template "document_upload" .}} |
|||
</div> |
|||
<div class="dashboard-item"> |
|||
|
|||
<div class="dashboard-item draggable-card" data-widget-id="document-remove" data-widget-title="Document Removal"> |
|||
<div class="drag-handle" style="display: none;">⋮⋮</div> |
|||
{{template "document_remove" .}} |
|||
</div> |
|||
<!-- Add more dashboard items as needed --> |
|||
</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,160 @@ |
|||
{{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 to add them to the |
|||
board. Invoices persist when switching between views.</p> |
|||
</div> |
|||
<div class="board-controls"> |
|||
<button class="clear-board-btn" onclick="clearBoardAndConfirm()"> |
|||
🗑️ Clear Board |
|||
</button> |
|||
<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}} |
|||
@ -1,166 +1,398 @@ |
|||
{{define "document_upload"}} |
|||
<h2>Document Uploads</h2> |
|||
|
|||
<form id="upload-form" hx-post="/upload-documents" hx-encoding="multipart/form-data" hx-target="#upload-results" |
|||
hx-indicator=".upload-overlay"> |
|||
|
|||
<div class="upload-container"> |
|||
<!-- Upload overlay --> |
|||
<div class="upload-overlay htmx-indicator"> |
|||
<div class="upload-overlay-content"> |
|||
<div class="overlay-spinner"></div> |
|||
<h3>Uploading Documents</h3> |
|||
<p>Please wait while your documents are being uploaded...</p> |
|||
</div> |
|||
<div class="upload-container"> |
|||
<!-- Upload overlay - moved outside the form --> |
|||
<div class="upload-overlay htmx-indicator"> |
|||
<div class="upload-overlay-content"> |
|||
<div class="overlay-spinner"></div> |
|||
<h3>Uploading Documents</h3> |
|||
<p>Please wait while your documents are being uploaded...</p> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Job numbers will be added here by the CSV process --> |
|||
<div id="job-ids-container" style="display: none;"> |
|||
<!-- Hidden input placeholder for job IDs --> |
|||
</div> |
|||
<!-- Job numbers will be added here by the CSV process --> |
|||
<div id="job-ids-container" style="display: none;"> |
|||
<!-- Hidden input placeholder for job IDs --> |
|||
</div> |
|||
|
|||
<!-- Step 1: CSV Upload --> |
|||
<div id="step1" class="content"> |
|||
<h3 class="submenu-header">Step 1: Upload CSV file with Job IDs</h3> |
|||
<div> |
|||
<label>Select CSV file with job IDs:</label> |
|||
<input class="card-input" type="file" id="csv-file" name="csvFile" accept=".csv" required> |
|||
|
|||
<button type="button" class="btn-primary" hx-post="/process-csv" hx-target="#job-ids-container" |
|||
hx-encoding="multipart/form-data" hx-include="#csv-file" hx-indicator="#csv-loading-indicator" |
|||
hx-on::after-request="if(event.detail.successful) { document.getElementById('step2').style.display = 'block'; }"> |
|||
Upload CSV |
|||
</button> |
|||
|
|||
<div id="csv-loading-indicator" class="htmx-indicator" style="display: none;"> |
|||
<span>Processing CSV...</span> |
|||
<div class="loading-indicator"></div> |
|||
</div> |
|||
</div> |
|||
<!-- Step 1: CSV Upload --> |
|||
<div id="step1" class="content"> |
|||
<h3 class="submenu-header">Step 1: Upload CSV file with Job IDs</h3> |
|||
<div> |
|||
<label>Select CSV file with job IDs:</label> |
|||
<input class="card-input" type="file" id="csv-file" name="csvFile" accept=".csv" required> |
|||
|
|||
<button type="button" class="btn-primary" hx-post="/process-csv" hx-target="#job-ids-container" |
|||
hx-encoding="multipart/form-data" hx-include="#csv-file" hx-indicator="#csv-loading-indicator" |
|||
hx-on::after-request="if(event.detail.successful) { document.getElementById('step2').style.display = 'block'; }"> |
|||
Upload CSV |
|||
</button> |
|||
|
|||
<div id="csv-preview" class="fade-me-out" style="display: none; margin-top: 1rem;"> |
|||
<h4>Detected Jobs</h4> |
|||
<div id="csv-preview-content" class="job-list"> |
|||
<!-- Job numbers will be displayed here --> |
|||
<p>No jobs loaded yet</p> |
|||
</div> |
|||
<div id="csv-loading-indicator" class="htmx-indicator" style="display: none;"> |
|||
<span>Processing CSV...</span> |
|||
<div class="loading-indicator"></div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Step 2: Document Upload --> |
|||
<div id="step2" class="content" style="display: none;"> |
|||
<h3 class="submenu-header">Step 2: Upload Documents</h3> |
|||
<div id="document-upload-container"> |
|||
<div class="document-row" id="document-row-1"> |
|||
<div class="document-field"> |
|||
<label>Select Document:</label> |
|||
<input class="card-input" type="file" id="document-file-1" name="document-file-1"> |
|||
</div> |
|||
|
|||
<div class="document-field-row"> |
|||
<div class="document-field document-name-field"> |
|||
<label>Document Name (optional):</label> |
|||
<input class="card-input" type="text" id="document-name-1" name="document-name-1" |
|||
placeholder="Document Name"> |
|||
</div> |
|||
|
|||
<div class="document-field document-type-field"> |
|||
<label>Document Type:</label> |
|||
<select class="card-input" id="document-type-1" name="document-type-1"> |
|||
<option value="">Select Document Type</option> |
|||
<option value="1" selected>Job Paperwork</option> |
|||
<option value="2">Job Vendor Bill</option> |
|||
<option value="7">Generic Attachment</option> |
|||
<option value="10">Blank Paperwork</option> |
|||
<option value="14">Job Invoice</option> |
|||
</select> |
|||
</div> |
|||
</div> |
|||
|
|||
<button type="button" class="remove-document warning-button" hx-get="/document-field-remove?id=1" |
|||
hx-target="#document-row-1" hx-swap="outerHTML" style="display: none;">Remove</button> |
|||
</div> |
|||
<div id="csv-preview" class="fade-me-out" style="display: none; margin-top: 1rem;"> |
|||
<h4>Detected Jobs</h4> |
|||
<div id="csv-preview-content" class="job-list"> |
|||
<!-- Job numbers will be displayed here --> |
|||
<p>No jobs loaded yet</p> |
|||
</div> |
|||
<button type="button" id="add-document" class="caution-button" hx-get="/document-field-add" |
|||
hx-target="#document-upload-container" hx-swap="beforeend">Add Another Document</button> |
|||
</div> |
|||
</div> |
|||
|
|||
<button type="button" class="btn-primary" |
|||
onclick="document.getElementById('step3').style.display = 'block';"> |
|||
Continue to Step 3 |
|||
</button> |
|||
<!-- Step 2: Document Upload --> |
|||
<div id="step2" class="content" style="display: none;"> |
|||
<h3 class="submenu-header">Step 2: Select Documents & Types</h3> |
|||
|
|||
<!-- Single file input for multiple documents --> |
|||
<div class="document-field"> |
|||
<label for="document-files">Select Document(s):</label> |
|||
<input class="card-input" type="file" id="document-files" name="documentFiles" multiple> |
|||
</div> |
|||
|
|||
<!-- Step 3: Submit --> |
|||
<div id="step3" class="content" style="display: none;"> |
|||
<h3 class="submenu-header">Step 3: Submit Uploads</h3> |
|||
<div> |
|||
<button type="submit" class="success-button" id="submit-button">Upload Documents to Jobs</button> |
|||
<!-- Area to display selected file chips --> |
|||
<div id="selected-files-area" class="selected-files-grid" style="margin-top: 1rem; margin-bottom: 1rem;"> |
|||
<!-- File chips will be dynamically inserted here by JavaScript --> |
|||
<p id="no-files-selected-placeholder">No files selected yet.</p> |
|||
</div> |
|||
|
|||
<div id="upload-loading-indicator" class="htmx-indicator"> |
|||
<span>Uploading...</span> |
|||
<div class="loading-indicator"></div> |
|||
</div> |
|||
<button type="button" class="btn-primary" id="continue-to-step3-button" disabled |
|||
onclick="if (!this.disabled) document.getElementById('step3').style.display = 'block';"> |
|||
Continue to Step 3 |
|||
</button> |
|||
</div> |
|||
|
|||
<!-- Step 3: Submit - form moved here --> |
|||
<div id="step3" class="content" style="display: none;"> |
|||
<h3 class="submenu-header">Step 3: Submit Uploads</h3> |
|||
<div> |
|||
<form id="upload-form" hx-post="/upload-documents" hx-encoding="multipart/form-data" |
|||
hx-include="[name='documentFiles']" hx-target="#upload-results" hx-indicator=".upload-overlay"> |
|||
|
|||
<input type="hidden" name="job-ids" id="job-ids-field"> |
|||
<button type="submit" class="success-button" id="final-submit-button">Upload Documents to Jobs</button> |
|||
</form> |
|||
|
|||
<div id="upload-results" class="upload-results"></div> |
|||
<div id="upload-loading-indicator" class="htmx-indicator"> |
|||
<span>Uploading...</span> |
|||
<div class="loading-indicator"></div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Restart Button (initially hidden) --> |
|||
<div id="restart-section" class="content" style="display: none;"> |
|||
<h3 class="submenu-header">Upload Complete</h3> |
|||
<button type="button" class="btn-primary" onclick="restartUpload()">Start New Upload</button> |
|||
<!-- Step 4: Results - moved outside the form --> |
|||
<div id="step4" class="content" style="display: none;"> |
|||
<h3 class="submenu-header">Step 4: Upload Results</h3> |
|||
<div id="upload-results" class="upload-results"> |
|||
<!-- Results will appear here after uploading documents --> |
|||
</div> |
|||
</div> |
|||
</form> |
|||
|
|||
<!-- Restart Button (initially hidden) --> |
|||
<div id="restart-section" class="content" style="display: none;"> |
|||
<h3 class="submenu-header">Upload Complete</h3> |
|||
<button type="button" class="btn-primary" hx-on:click="restartUpload()">Start New Upload</button> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Edit File Modal (Initially Hidden) --> |
|||
<div id="editFileModal" class="modal" style="display:none;"> |
|||
<div class="modal-content"> |
|||
<span class="close-button" onclick="closeEditModal()">×</span> |
|||
<h4>Edit File Details</h4> |
|||
<input type="hidden" id="editFileOriginalIndex"> |
|||
|
|||
<div class="form-group"> |
|||
<label for="editDisplayName">Display Name:</label> |
|||
<input type="text" id="editDisplayName" class="card-input"> |
|||
</div> |
|||
|
|||
<div class="form-group"> |
|||
<label for="editDocumentType">Document Type:</label> |
|||
<select id="editDocumentType" class="card-input"> |
|||
<option value="1">Job Paperwork</option> |
|||
<option value="2">Job Vendor Bill</option> |
|||
<option value="7">Generic Attachment</option> |
|||
<option value="10">Blank Paperwork</option> |
|||
<option value="14">Job Invoice</option> |
|||
</select> |
|||
</div> |
|||
|
|||
<div id="modal-preview-area" |
|||
style="margin-top: 1rem; margin-bottom: 1rem; max-height: 300px; overflow-y: auto;"> |
|||
<!-- Document preview will be attempted here later --> |
|||
<p>Document preview will be shown here in a future update.</p> |
|||
</div> |
|||
|
|||
<button type="button" class="btn-primary" onclick="saveFileChanges()">Save Changes</button> |
|||
<button type="button" class="btn-secondary" onclick="closeEditModal()">Cancel</button> |
|||
</div> |
|||
</div> |
|||
|
|||
<script> |
|||
// Check if variable already exists to avoid redeclaration error with HTMX |
|||
if (typeof selectedFilesData === 'undefined') { |
|||
var selectedFilesData = []; // To store {originalFile, displayName, documentType, isActive, originalIndex} |
|||
} |
|||
if (typeof MAX_FILENAME_LENGTH === 'undefined') { |
|||
var MAX_FILENAME_LENGTH = 30; // Max length for displayed filename on chip |
|||
} |
|||
|
|||
document.getElementById('document-files').addEventListener('change', handleFileSelectionChange); |
|||
var continueToStep3Button = document.getElementById('continue-to-step3-button'); |
|||
|
|||
function handleFileSelectionChange(event) { |
|||
selectedFilesData = []; // Reset |
|||
const filesArea = document.getElementById('selected-files-area'); |
|||
filesArea.innerHTML = ''; // Clear previous chips |
|||
const noFilesPlaceholder = document.getElementById('no-files-selected-placeholder'); |
|||
|
|||
const files = event.target.files; |
|||
if (files.length === 0) { |
|||
if (noFilesPlaceholder) noFilesPlaceholder.style.display = 'block'; |
|||
filesArea.innerHTML = '<p id="no-files-selected-placeholder">No files selected yet.</p>'; // Re-add if cleared |
|||
continueToStep3Button.disabled = true; |
|||
return; |
|||
} |
|||
|
|||
if (noFilesPlaceholder) noFilesPlaceholder.style.display = 'none'; |
|||
else { // if it was removed entirely, ensure it's not there |
|||
const existingPlaceholder = document.getElementById('no-files-selected-placeholder'); |
|||
if (existingPlaceholder) existingPlaceholder.remove(); |
|||
} |
|||
|
|||
|
|||
for (let i = 0; i < files.length; i++) { |
|||
const file = files[i]; |
|||
const fileMetadata = { |
|||
originalFile: file, |
|||
displayName: file.name, |
|||
documentType: "1", // Default to "Job Paperwork" |
|||
isActive: true, |
|||
originalIndex: i |
|||
}; |
|||
selectedFilesData.push(fileMetadata); |
|||
renderFileChip(fileMetadata, i); |
|||
} |
|||
continueToStep3Button.disabled = files.length === 0; |
|||
} |
|||
|
|||
function getFileIcon(filename) { |
|||
const extension = filename.split('.').pop().toLowerCase(); |
|||
// Simple icon logic, can be expanded |
|||
if (['jpg', 'jpeg', 'png', 'gif'].includes(extension)) return '🖼️'; // Image icon |
|||
if (extension === 'pdf') return '📄'; // PDF icon |
|||
if (['doc', 'docx'].includes(extension)) return '📝'; // Word doc |
|||
return '📁'; // Generic file icon |
|||
} |
|||
|
|||
function truncateFilename(filename, maxLength = MAX_FILENAME_LENGTH) { |
|||
if (filename.length <= maxLength) return filename; |
|||
const start = filename.substring(0, maxLength - 3 - Math.floor((maxLength - 3) / 3)); |
|||
const end = filename.substring(filename.length - Math.floor((maxLength - 3) / 3)); |
|||
return `${start}...${end}`; |
|||
} |
|||
|
|||
|
|||
function renderFileChip(fileMetadata, index) { |
|||
const filesArea = document.getElementById('selected-files-area'); |
|||
const chip = document.createElement('div'); |
|||
chip.className = `file-chip ${fileMetadata.isActive ? '' : 'removed'}`; |
|||
chip.dataset.index = index; |
|||
|
|||
const icon = getFileIcon(fileMetadata.displayName); |
|||
const truncatedName = truncateFilename(fileMetadata.displayName); |
|||
|
|||
chip.innerHTML = ` |
|||
<span class="file-chip-icon">${icon}</span> |
|||
<span class="file-chip-name" title="${fileMetadata.displayName}">${truncatedName}</span> |
|||
<span class="file-chip-doctype">Type: ${fileMetadata.documentType}</span> |
|||
<button type="button" class="file-chip-edit" onclick="openEditModal(${index})" title="Edit file details">✏️</button> |
|||
<button type="button" class="file-chip-remove" onclick="toggleFileActive(${index})" title="${fileMetadata.isActive ? 'Remove from upload' : 'Add back to upload'}">${fileMetadata.isActive ? '❌' : '➕'}</button> |
|||
`; |
|||
|
|||
filesArea.appendChild(chip); |
|||
} |
|||
|
|||
function toggleFileActive(index) { |
|||
if (index >= 0 && index < selectedFilesData.length) { |
|||
selectedFilesData[index].isActive = !selectedFilesData[index].isActive; |
|||
const chip = document.querySelector(`[data-index="${index}"]`); |
|||
if (chip) { |
|||
chip.className = `file-chip ${selectedFilesData[index].isActive ? '' : 'removed'}`; |
|||
const removeBtn = chip.querySelector('.file-chip-remove'); |
|||
if (removeBtn) { |
|||
removeBtn.innerHTML = selectedFilesData[index].isActive ? '❌' : '➕'; |
|||
removeBtn.title = selectedFilesData[index].isActive ? 'Remove from upload' : 'Add back to upload'; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
function openEditModal(index) { |
|||
if (index >= 0 && index < selectedFilesData.length) { |
|||
const fileData = selectedFilesData[index]; |
|||
document.getElementById('editFileOriginalIndex').value = index; |
|||
document.getElementById('editDisplayName').value = fileData.displayName; |
|||
document.getElementById('editDocumentType').value = fileData.documentType; |
|||
document.getElementById('editFileModal').style.display = 'block'; |
|||
} |
|||
} |
|||
|
|||
function closeEditModal() { |
|||
document.getElementById('editFileModal').style.display = 'none'; |
|||
} |
|||
|
|||
function saveFileChanges() { |
|||
const index = parseInt(document.getElementById('editFileOriginalIndex').value); |
|||
if (index >= 0 && index < selectedFilesData.length) { |
|||
selectedFilesData[index].displayName = document.getElementById('editDisplayName').value; |
|||
selectedFilesData[index].documentType = document.getElementById('editDocumentType').value; |
|||
|
|||
// Re-render the chip |
|||
const chip = document.querySelector(`[data-index="${index}"]`); |
|||
if (chip) { |
|||
const icon = getFileIcon(selectedFilesData[index].displayName); |
|||
const truncatedName = truncateFilename(selectedFilesData[index].displayName); |
|||
|
|||
chip.innerHTML = ` |
|||
<span class="file-chip-icon">${icon}</span> |
|||
<span class="file-chip-name" title="${selectedFilesData[index].displayName}">${truncatedName}</span> |
|||
<span class="file-chip-doctype">Type: ${selectedFilesData[index].documentType}</span> |
|||
<button type="button" class="file-chip-edit" onclick="openEditModal(${index})" title="Edit file details">✏️</button> |
|||
<button type="button" class="file-chip-remove" onclick="toggleFileActive(${index})" title="${selectedFilesData[index].isActive ? 'Remove from upload' : 'Add back to upload'}">${selectedFilesData[index].isActive ? '❌' : '➕'}</button> |
|||
`; |
|||
} |
|||
} |
|||
closeEditModal(); |
|||
} |
|||
|
|||
// Function to restart the upload process |
|||
function restartUpload() { |
|||
// Reset form |
|||
document.getElementById('upload-form').reset(); |
|||
|
|||
// Hide all sections except step 1 |
|||
document.getElementById('step2').style.display = 'none'; |
|||
document.getElementById('step3').style.display = 'none'; |
|||
document.getElementById('step4').style.display = 'none'; |
|||
document.getElementById('restart-section').style.display = 'none'; |
|||
|
|||
// Clear results |
|||
document.getElementById('upload-results').innerHTML = ''; |
|||
|
|||
// Hide and reset CSV preview |
|||
document.getElementById('csv-preview').style.display = 'none'; |
|||
document.getElementById('csv-preview-content').innerHTML = '<p>No jobs loaded yet</p>'; |
|||
// Reset CSV preview if it exists |
|||
const csvPreview = document.getElementById('csv-preview'); |
|||
if (csvPreview) { |
|||
csvPreview.style.display = 'none'; |
|||
} |
|||
|
|||
const csvPreviewContent = document.getElementById('csv-preview-content'); |
|||
if (csvPreviewContent) { |
|||
csvPreviewContent.innerHTML = '<p>No jobs loaded yet</p>'; |
|||
} |
|||
|
|||
// Reset job IDs container |
|||
document.getElementById('job-ids-container').innerHTML = ''; |
|||
|
|||
// Show step 1 |
|||
document.getElementById('step1').style.display = 'block'; |
|||
} |
|||
|
|||
// Add event listener for form submission |
|||
document.getElementById('upload-form').addEventListener('htmx:beforeRequest', function (evt) { |
|||
// Only show the overlay for document uploads, not CSV processing |
|||
if (evt.detail.pathInfo.requestPath === '/upload-documents') { |
|||
document.querySelector('.upload-overlay').style.display = 'flex'; |
|||
// Reset any file inputs |
|||
const fileInput = document.getElementById('csv-file'); |
|||
if (fileInput) { |
|||
fileInput.value = ''; |
|||
} |
|||
}); |
|||
|
|||
document.getElementById('upload-form').addEventListener('htmx:afterRequest', function (evt) { |
|||
if (evt.detail.pathInfo.requestPath === '/upload-documents') { |
|||
if (evt.detail.successful) { |
|||
// Show restart section after successful upload |
|||
document.getElementById('restart-section').style.display = 'block'; |
|||
} |
|||
// Hide the overlay after the request completes |
|||
document.querySelector('.upload-overlay').style.display = 'none'; |
|||
const documentFilesInput = document.getElementById('document-files'); |
|||
if (documentFilesInput) { |
|||
documentFilesInput.value = ''; |
|||
} |
|||
}); |
|||
|
|||
// Add event listener for HTMX errors |
|||
document.getElementById('upload-form').addEventListener('htmx:error', function (evt) { |
|||
// Hide the overlay if there's an error |
|||
document.querySelector('.upload-overlay').style.display = 'none'; |
|||
// Reset selected files |
|||
selectedFilesData = []; |
|||
const filesArea = document.getElementById('selected-files-area'); |
|||
if (filesArea) { |
|||
filesArea.innerHTML = '<p id="no-files-selected-placeholder">No files selected yet.</p>'; |
|||
} |
|||
|
|||
// Reset continue button |
|||
if (continueToStep3Button) { |
|||
continueToStep3Button.disabled = true; |
|||
} |
|||
} |
|||
|
|||
// Add event listeners for the upload overlay |
|||
document.addEventListener('DOMContentLoaded', function () { |
|||
document.querySelectorAll('form').forEach(form => { |
|||
form.addEventListener('htmx:beforeRequest', function (evt) { |
|||
if (evt.detail.pathInfo.requestPath.includes('/upload-documents')) { |
|||
document.querySelector('.upload-overlay').style.display = 'flex'; |
|||
} |
|||
}); |
|||
|
|||
form.addEventListener('htmx:afterRequest', function (evt) { |
|||
if (evt.detail.pathInfo.requestPath.includes('/upload-documents')) { |
|||
document.querySelector('.upload-overlay').style.display = 'none'; |
|||
|
|||
// If it's an upload action and successful, show step 4 and restart section |
|||
if (evt.detail.successful) { |
|||
document.getElementById('step4').style.display = 'block'; |
|||
document.getElementById('restart-section').style.display = 'block'; |
|||
} |
|||
} |
|||
}); |
|||
|
|||
form.addEventListener('htmx:error', function (evt) { |
|||
document.querySelector('.upload-overlay').style.display = 'none'; |
|||
}); |
|||
}); |
|||
|
|||
// Prepare metadata for backend on form submission |
|||
document.getElementById('upload-form').addEventListener('htmx:configRequest', function (evt) { |
|||
const displayNamesArr = []; |
|||
const documentTypesArr = []; |
|||
const isActiveArr = []; |
|||
|
|||
const documentFilesInput = document.getElementById('document-files'); |
|||
const filesFromInput = documentFilesInput.files; // These are the files the browser will send by default |
|||
|
|||
for (let i = 0; i < filesFromInput.length; i++) { |
|||
// Find the corresponding metadata in selectedFilesData. |
|||
// selectedFilesData is built with originalIndex matching the input files' index. |
|||
const fileMetadata = selectedFilesData.find(meta => meta.originalIndex === i); |
|||
|
|||
if (fileMetadata) { |
|||
displayNamesArr.push(fileMetadata.displayName); |
|||
documentTypesArr.push(fileMetadata.documentType); |
|||
isActiveArr.push(fileMetadata.isActive); |
|||
} else { |
|||
// This case should ideally not happen if selectedFilesData is managed correctly. |
|||
// Log an error and send fallback data to prevent backend length mismatch errors. |
|||
console.error(`Client-side warning: No metadata in selectedFilesData for file at originalIndex ${i}. Filename: ${filesFromInput[i].name}. Sending defaults.`); |
|||
displayNamesArr.push(filesFromInput[i].name); // Fallback to original name |
|||
documentTypesArr.push("1"); // Default type "Job Paperwork" |
|||
isActiveArr.push(false); // Default to not active to be safe, it won't be processed |
|||
} |
|||
} |
|||
|
|||
// Add these arrays as JSON strings to the request parameters. |
|||
// HTMX will include these in the multipart/form-data POST request. |
|||
evt.detail.parameters['file_display_names'] = JSON.stringify(displayNamesArr); |
|||
evt.detail.parameters['file_document_types'] = JSON.stringify(documentTypesArr); |
|||
evt.detail.parameters['file_is_active_flags'] = JSON.stringify(isActiveArr); |
|||
|
|||
// Set the job numbers from the hidden input created by CSV processing |
|||
const jobIdsInput = document.querySelector('input[name="jobNumbers"]'); |
|||
if (jobIdsInput) { |
|||
evt.detail.parameters['jobNumbers'] = jobIdsInput.value; |
|||
} else { |
|||
console.error('No jobNumbers input found. Make sure CSV was processed first.'); |
|||
} |
|||
|
|||
// 'documentFiles' will be sent by the browser from the <input type="file" name="documentFiles"> |
|||
}); |
|||
}); |
|||
</script> |
|||
{{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,73 @@ |
|||
{{define "removal_result_card"}} |
|||
<div id="removal-card-{{.JobID}}" class="upload-result-card" data-job-id="{{.JobID}}"> |
|||
<div class="upload-header"> |
|||
<h4>Job #{{.JobID}}</h4> |
|||
<div class="upload-status {{if .Success}}success{{else}}error{{end}}"> |
|||
{{if .Success}}✓ Success{{else}}✗ Failed{{end}} |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="upload-details"> |
|||
<div class="upload-info"> |
|||
<p><strong>Files Found:</strong> {{.FilesFound}}</p> |
|||
<p><strong>Files Removed:</strong> {{.FilesRemoved}}</p> |
|||
{{if .Success}} |
|||
<p class="success-text">Successfully processed</p> |
|||
{{else}} |
|||
<p class="error-text">{{.ErrorMsg}}</p> |
|||
{{end}} |
|||
</div> |
|||
|
|||
<div class="upload-actions"> |
|||
{{if .Success}} |
|||
<div class="success-indicator"> |
|||
<span class="icon">✓</span> |
|||
<span>Removal Complete</span> |
|||
</div> |
|||
{{else}} |
|||
<div class="error-indicator"> |
|||
<span class="icon">✗</span> |
|||
<span>Removal Failed</span> |
|||
</div> |
|||
{{end}} |
|||
</div> |
|||
</div> |
|||
|
|||
{{if .Files}} |
|||
<div class="file-results"> |
|||
{{with index .Files 0}} |
|||
<div class="file-result {{if .Success}}success{{else}}error{{end}}"> |
|||
<span class="file-name">{{.Name}}</span> |
|||
{{if .Success}} |
|||
<span class="success-icon">✓</span> |
|||
{{else}} |
|||
<span class="error-icon">✗</span> |
|||
<span class="error-message">{{.Error}}</span> |
|||
{{end}} |
|||
</div> |
|||
{{end}} |
|||
<div class="file-pagination-controls"> |
|||
<span>File {{.FilePage}} of {{.TotalFiles}}</span> |
|||
<div class="file-pagination-buttons"> |
|||
{{if gt .FilePage 1}} |
|||
<button |
|||
hx-get="/documents/remove/job/file?job_id={{.JobID}}&session_id={{.SessionID}}&file_page={{subtract .FilePage 1}}" |
|||
hx-target="#removal-card-{{.JobID}}" hx-swap="outerHTML" hx-indicator="false" |
|||
class="pagination-btn"> |
|||
← Previous File |
|||
</button> |
|||
{{end}} |
|||
{{if lt .FilePage .TotalFiles}} |
|||
<button |
|||
hx-get="/documents/remove/job/file?job_id={{.JobID}}&session_id={{.SessionID}}&file_page={{add .FilePage 1}}" |
|||
hx-target="#removal-card-{{.JobID}}" hx-swap="outerHTML" hx-indicator="false" |
|||
class="pagination-btn"> |
|||
Next File → |
|||
</button> |
|||
{{end}} |
|||
</div> |
|||
</div> |
|||
</div> |
|||
{{end}} |
|||
</div> |
|||
{{end}} |
|||
@ -1,51 +1,55 @@ |
|||
{{define "removal_results"}} |
|||
<div class="upload-summary"> |
|||
<h3>Document Removal Results</h3> |
|||
<div class="upload-results-container"> |
|||
<div class="upload-results-header"> |
|||
<h3>Document Removal Results</h3> |
|||
{{template "removal_stats" .}} |
|||
</div> |
|||
|
|||
{{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> |
|||
{{if gt .SuccessCount 0}} |
|||
<p>Successfully removed {{.SuccessCount}} document(s) from ServiceTrade in {{formatDuration .TotalTime}}!</p> |
|||
{{end}} |
|||
{{if gt .ErrorCount 0}} |
|||
<p class="text-warning">Failed to remove {{.ErrorCount}} document(s). See details below.</p> |
|||
{{end}} |
|||
|
|||
<div class="upload-results-grid"> |
|||
{{range .Results}} |
|||
{{template "removal_result_card" .}} |
|||
{{end}} |
|||
</div> |
|||
|
|||
{{if .Results}} |
|||
<div class="job-results"> |
|||
{{range $job := .Results}} |
|||
<div class="job-result"> |
|||
<h4>Job #{{$job.JobID}}</h4> |
|||
{{if gt .TotalPages 1}} |
|||
<div class="pagination-controls"> |
|||
<div class="pagination-info"> |
|||
Showing {{.StartIndex}}-{{.EndIndex}} of {{.TotalResults}} results |
|||
</div> |
|||
|
|||
<div class="pagination-buttons"> |
|||
{{if gt .CurrentPage 1}} |
|||
<button |
|||
hx-get="/documents/remove/results?page={{subtract .CurrentPage 1}}&limit={{.Limit}}&session_id={{.SessionID}}" |
|||
hx-target="#removal-results" hx-indicator="false" class="pagination-btn"> |
|||
← Previous |
|||
</button> |
|||
{{end}} |
|||
|
|||
{{if $job.Success}} |
|||
<div class="success-message">Successfully processed</div> |
|||
{{else}} |
|||
<div class="error-message">Error: {{$job.Error}}</div> |
|||
{{range $i := sequence .StartPage .EndPage}} |
|||
<button hx-get="/documents/remove/results?page={{$i}}&limit={{$.Limit}}&session_id={{$.SessionID}}" |
|||
hx-target="#removal-results" hx-indicator="false" |
|||
class="pagination-btn {{if eq $i $.CurrentPage}}active{{end}}"> |
|||
{{$i}} |
|||
</button> |
|||
{{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> |
|||
{{if lt .CurrentPage .TotalPages}} |
|||
<button |
|||
hx-get="/documents/remove/results?page={{add .CurrentPage 1}}&limit={{.Limit}}&session_id={{.SessionID}}" |
|||
hx-target="#removal-results" hx-indicator="false" class="pagination-btn"> |
|||
Next → |
|||
</button> |
|||
{{end}} |
|||
</div> |
|||
{{end}} |
|||
</div> |
|||
{{end}} |
|||
{{end}} |
|||
</div> |
|||
{{end}} |
|||
@ -0,0 +1,24 @@ |
|||
{{define "removal_stats"}} |
|||
<div class="upload-stats"> |
|||
<div class="stat-item"> |
|||
<span class="stat-value">{{.TotalJobs}}</span> |
|||
<span class="stat-label">Total Jobs</span> |
|||
</div> |
|||
<div class="stat-item success-stat"> |
|||
<span class="stat-value">{{.SuccessCount}}</span> |
|||
<span class="stat-label">Successful</span> |
|||
</div> |
|||
<div class="stat-item error-stat"> |
|||
<span class="stat-value">{{.ErrorCount}}</span> |
|||
<span class="stat-label">Failed</span> |
|||
</div> |
|||
<div class="stat-item"> |
|||
<span class="stat-value">{{.TotalFiles}}</span> |
|||
<span class="stat-label">Files Processed</span> |
|||
</div> |
|||
<div class="stat-item"> |
|||
<span class="stat-value">{{formatDuration .TotalTime}}</span> |
|||
<span class="stat-label">Total Time</span> |
|||
</div> |
|||
</div> |
|||
{{end}} |
|||
@ -0,0 +1,149 @@ |
|||
{{define "upload_result_card"}} |
|||
<div id="upload-card-{{.JobID}}" class="upload-result-card" data-job-id="{{.JobID}}"> |
|||
<div class="upload-header"> |
|||
<h4>Job #{{.JobID}}</h4> |
|||
<div class="upload-status {{if .Success}}success{{else}}error{{end}}"> |
|||
{{if .Success}}✓ Success{{else}}✗ Failed{{end}} |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="upload-details"> |
|||
<div class="upload-info"> |
|||
<p><strong>Files Found:</strong> {{.FilesFound}}</p> |
|||
<p><strong>Files Uploaded:</strong> {{.FilesUploaded}}</p> |
|||
{{if .Success}} |
|||
<p class="success-text">Successfully processed</p> |
|||
{{else}} |
|||
<p class="error-text">{{.ErrorMsg}}</p> |
|||
{{end}} |
|||
</div> |
|||
|
|||
<div class="upload-actions"> |
|||
{{if .Success}} |
|||
<div class="success-indicator"> |
|||
<span class="icon">✓</span> |
|||
<span>Upload Complete</span> |
|||
</div> |
|||
{{else}} |
|||
<div class="error-indicator"> |
|||
<span class="icon">✗</span> |
|||
<span>Upload Failed</span> |
|||
</div> |
|||
{{end}} |
|||
</div> |
|||
</div> |
|||
|
|||
{{if .Files}} |
|||
<div class="file-results"> |
|||
{{with index .Files 0}} |
|||
<div class="file-result {{if .Success}}success{{else}}error{{end}}"> |
|||
<span class="file-name">{{.Name}}</span> |
|||
<span class="file-size">({{printf "%.2f MB" (div .FileSize 1048576.0)}})</span> |
|||
{{if .Success}} |
|||
<span class="success-icon">✓</span> |
|||
{{else}} |
|||
<span class="error-icon">✗</span> |
|||
<span class="error-message">{{.Error}}</span> |
|||
{{end}} |
|||
</div> |
|||
{{end}} |
|||
<div class="file-pagination-controls"> |
|||
<span>File {{.FilePage}} of {{.TotalFiles}}</span> |
|||
<div class="file-pagination-buttons"> |
|||
{{if gt .FilePage 1}} |
|||
<button |
|||
hx-get="/documents/upload/job/file?job_id={{.JobID}}&session_id={{.SessionID}}&file_page={{subtract .FilePage 1}}" |
|||
hx-target="#upload-card-{{.JobID}}" hx-swap="outerHTML" hx-indicator="false" class="pagination-btn"> |
|||
← Previous File |
|||
</button> |
|||
{{end}} |
|||
{{if lt .FilePage .TotalFiles}} |
|||
<button |
|||
hx-get="/documents/upload/job/file?job_id={{.JobID}}&session_id={{.SessionID}}&file_page={{add .FilePage 1}}" |
|||
hx-target="#upload-card-{{.JobID}}" hx-swap="outerHTML" hx-indicator="false" class="pagination-btn"> |
|||
Next File → |
|||
</button> |
|||
{{end}} |
|||
</div> |
|||
</div> |
|||
</div> |
|||
{{end}} |
|||
</div> |
|||
{{end}} |
|||
|
|||
{{define "upload_results_pagination"}} |
|||
<div class="upload-results-container"> |
|||
<div class="upload-results-header"> |
|||
<h3>Upload Results</h3> |
|||
<div class="upload-stats"> |
|||
<div class="stat-item"> |
|||
<span class="stat-value">{{.TotalJobs}}</span> |
|||
<span class="stat-label">Total Jobs</span> |
|||
</div> |
|||
<div class="stat-item success-stat"> |
|||
<span class="stat-value">{{.TotalSuccess}}</span> |
|||
<span class="stat-label">Successful</span> |
|||
</div> |
|||
<div class="stat-item error-stat"> |
|||
<span class="stat-value">{{.TotalFailure}}</span> |
|||
<span class="stat-label">Failed</span> |
|||
</div> |
|||
<div class="stat-item"> |
|||
<span class="stat-value">{{printf "%.1f" (div .TotalBytesUploaded 1048576.0)}}</span> |
|||
<span class="stat-label">MB Uploaded</span> |
|||
</div> |
|||
<div class="stat-item"> |
|||
<span class="stat-value">{{.TotalTime}}</span> |
|||
<span class="stat-label">Total Time</span> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
{{if gt .TotalSuccess 0}} |
|||
<p>Successfully uploaded {{.TotalSuccess}} document(s) to ServiceTrade in {{.TotalTime}}!</p> |
|||
{{end}} |
|||
{{if gt .TotalFailure 0}} |
|||
<p class="text-warning">Failed to upload {{.TotalFailure}} document(s). See details below.</p> |
|||
{{end}} |
|||
|
|||
<div class="upload-results-grid"> |
|||
{{range .Results}} |
|||
{{template "upload_result_card" .}} |
|||
{{end}} |
|||
</div> |
|||
|
|||
{{if gt .TotalPages 1}} |
|||
<div class="pagination-controls"> |
|||
<div class="pagination-info"> |
|||
Showing {{.StartIndex}}-{{.EndIndex}} of {{.TotalResults}} results |
|||
</div> |
|||
|
|||
<div class="pagination-buttons"> |
|||
{{if gt .CurrentPage 1}} |
|||
<button |
|||
hx-get="/documents/upload/results?page={{subtract .CurrentPage 1}}&limit={{.Limit}}&session_id={{.SessionID}}" |
|||
hx-target="#upload-results" hx-indicator="false" class="pagination-btn"> |
|||
← Previous |
|||
</button> |
|||
{{end}} |
|||
|
|||
{{range $i := sequence .StartPage .EndPage}} |
|||
<button hx-get="/documents/upload/results?page={{$i}}&limit={{$.Limit}}&session_id={{$.SessionID}}" |
|||
hx-target="#upload-results" hx-indicator="false" |
|||
class="pagination-btn {{if eq $i $.CurrentPage}}active{{end}}"> |
|||
{{$i}} |
|||
</button> |
|||
{{end}} |
|||
|
|||
{{if lt .CurrentPage .TotalPages}} |
|||
<button |
|||
hx-get="/documents/upload/results?page={{add .CurrentPage 1}}&limit={{.Limit}}&session_id={{.SessionID}}" |
|||
hx-target="#upload-results" hx-indicator="false" class="pagination-btn"> |
|||
Next → |
|||
</button> |
|||
{{end}} |
|||
</div> |
|||
</div> |
|||
{{end}} |
|||
</div> |
|||
{{end}} |
|||
@ -0,0 +1,55 @@ |
|||
{{define "upload_results_pagination"}} |
|||
<div class="upload-results-container"> |
|||
<div class="upload-results-header"> |
|||
<h3>Upload Results</h3> |
|||
{{template "upload_stats" .}} |
|||
</div> |
|||
|
|||
{{if gt .TotalSuccess 0}} |
|||
<p>Successfully uploaded {{.TotalSuccess}} document(s) to ServiceTrade in {{formatDuration .TotalTime}}!</p> |
|||
{{end}} |
|||
{{if gt .TotalFailure 0}} |
|||
<p class="text-warning">Failed to upload {{.TotalFailure}} document(s). See details below.</p> |
|||
{{end}} |
|||
|
|||
<div class="upload-results-grid"> |
|||
{{range .Results}} |
|||
{{template "upload_result_card" .}} |
|||
{{end}} |
|||
</div> |
|||
|
|||
{{if gt .TotalPages 1}} |
|||
<div class="pagination-controls"> |
|||
<div class="pagination-info"> |
|||
Showing {{.StartIndex}}-{{.EndIndex}} of {{.TotalResults}} results |
|||
</div> |
|||
|
|||
<div class="pagination-buttons"> |
|||
{{if gt .CurrentPage 1}} |
|||
<button |
|||
hx-get="/documents/upload/results?page={{subtract .CurrentPage 1}}&limit={{.Limit}}&session_id={{.SessionID}}" |
|||
hx-target="#upload-results" hx-indicator="false" class="pagination-btn"> |
|||
← Previous |
|||
</button> |
|||
{{end}} |
|||
|
|||
{{range $i := sequence .StartPage .EndPage}} |
|||
<button hx-get="/documents/upload/results?page={{$i}}&limit={{$.Limit}}&session_id={{$.SessionID}}" |
|||
hx-target="#upload-results" hx-indicator="false" |
|||
class="pagination-btn {{if eq $i $.CurrentPage}}active{{end}}"> |
|||
{{$i}} |
|||
</button> |
|||
{{end}} |
|||
|
|||
{{if lt .CurrentPage .TotalPages}} |
|||
<button |
|||
hx-get="/documents/upload/results?page={{add .CurrentPage 1}}&limit={{.Limit}}&session_id={{.SessionID}}" |
|||
hx-target="#upload-results" hx-indicator="false" class="pagination-btn"> |
|||
Next → |
|||
</button> |
|||
{{end}} |
|||
</div> |
|||
</div> |
|||
{{end}} |
|||
</div> |
|||
{{end}} |
|||
@ -0,0 +1,24 @@ |
|||
{{define "upload_stats"}} |
|||
<div class="upload-stats"> |
|||
<div class="stat-item"> |
|||
<span class="stat-value">{{.TotalJobs}}</span> |
|||
<span class="stat-label">Total Jobs</span> |
|||
</div> |
|||
<div class="stat-item success-stat"> |
|||
<span class="stat-value">{{.TotalSuccess}}</span> |
|||
<span class="stat-label">Successful</span> |
|||
</div> |
|||
<div class="stat-item error-stat"> |
|||
<span class="stat-value">{{.TotalFailure}}</span> |
|||
<span class="stat-label">Failed</span> |
|||
</div> |
|||
<div class="stat-item"> |
|||
<span class="stat-value">{{printf "%.1f" (div .TotalBytesUploaded 1048576.0)}}</span> |
|||
<span class="stat-label">MB Uploaded</span> |
|||
</div> |
|||
<div class="stat-item"> |
|||
<span class="stat-value">{{formatDuration .TotalTime}}</span> |
|||
<span class="stat-label">Total Time</span> |
|||
</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