Compare commits

...

18 Commits

Author SHA1 Message Date
nic 3af9a2806c fix: updated pagination to be reuseable; lots of styling/formatting work 9 months ago
nic 0a169b8b8b feat: added pagination to results 9 months ago
nic 38da0d751e feat: added timer for uploads and removals to show how long it takes 9 months ago
nic 54ecba5940 fix: updated document removal for better efficiency hopefully 9 months ago
nic c9ae2fbef1 fix: changed scripting to hopefully fix the error but I don't know anymore 10 months ago
nic 7362ec24bc fix: runner permissions issue, changed script location 10 months ago
nic b2476ffedc fix: I hate yaml 10 months ago
nic 1ad95c6e7d fix: yaml error 10 months ago
nic 6baa7256aa fix: powershell run error 10 months ago
nic f83a344928 fix: labels were incorrect, hopefully this one is correct 10 months ago
nic 4a75181359 chore: added github build and deploy script 10 months ago
nic 6e4e6d94f7 chore: finally added custom port for dev vs prod builds 10 months ago
nic c8edfdb56c chore: updated css for better input visibility and changed details icon from eye to a magnifying glass 10 months ago
nic 5f29eca7c2 feat: added invoice drag and drop and stubbed out routes/pages for sidebar 10 months ago
nic b8546c0eea feat: basic dashboard drag'n'drop 10 months ago
nic cb3fc7fb44 feat: multi-upload modal backend now complete 11 months ago
nic c16789b099 feat: multi-upload modal setup, need to js my way to client-side handing this info off to the backend now 11 months ago
nic 51093cbd07 feat: added multi-upload field for files instead of continually adding single new fields 11 months ago
  1. 33
      .github/workflows/build_and_deploy.yaml
  2. 15
      apps/web/main.go
  3. BIN
      apps/web/web-app
  4. 17
      internal/handlers/web/admin.go
  5. 17
      internal/handlers/web/assets.go
  6. 23
      internal/handlers/web/companies.go
  7. 11
      internal/handlers/web/contacts.go
  8. 11
      internal/handlers/web/contracts.go
  9. 20
      internal/handlers/web/dashboard.go
  10. 1214
      internal/handlers/web/document_remove.go
  11. 838
      internal/handlers/web/documents.go
  12. 13
      internal/handlers/web/generic.go
  13. 34
      internal/handlers/web/invoices.go
  14. 8
      internal/handlers/web/jobs.go
  15. 11
      internal/handlers/web/locations.go
  16. 11
      internal/handlers/web/notifications.go
  17. 11
      internal/handlers/web/quotes.go
  18. 11
      internal/handlers/web/services.go
  19. 11
      internal/handlers/web/tags.go
  20. 11
      internal/handlers/web/users.go
  21. 88
      internal/utils/pagination.go
  22. 1057
      static/css/styles.css
  23. 995
      static/css/upload.css
  24. 154
      static/js/dashboard-drag.js
  25. 1
      static/js/htmx.min.js
  26. 868
      static/js/invoices.js
  27. 10
      templates/admin.html
  28. 10
      templates/assets.html
  29. 10
      templates/companies.html
  30. 18
      templates/contacts.html
  31. 18
      templates/contracts.html
  32. 28
      templates/dashboard.html
  33. 18
      templates/generic.html
  34. 160
      templates/invoices.html
  35. 10
      templates/jobs.html
  36. 62
      templates/layout.html
  37. 18
      templates/locations.html
  38. 18
      templates/notifications.html
  39. 5
      templates/partials/document_remove.html
  40. 470
      templates/partials/document_upload.html
  41. 23
      templates/partials/invoice_board_card.html
  42. 2
      templates/partials/invoice_card.html
  43. 73
      templates/partials/removal_result_card.html
  44. 78
      templates/partials/removal_results.html
  45. 24
      templates/partials/removal_stats.html
  46. 149
      templates/partials/upload_result_card.html
  47. 55
      templates/partials/upload_results_pagination.html
  48. 24
      templates/partials/upload_stats.html
  49. 18
      templates/quotes.html
  50. 18
      templates/services.html
  51. 18
      templates/tags.html
  52. 18
      templates/users.html
  53. 36
      web_templates.go

33
.github/workflows/build_and_deploy.yaml

@ -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

15
apps/web/main.go

@ -3,6 +3,7 @@ package main
import (
"log"
"net/http"
"os"
"time"
root "marmic/servicetrade-toolbox"
@ -63,22 +64,28 @@ func main() {
protected.HandleFunc("/documents", web.DocumentsHandler).Methods("GET")
protected.HandleFunc("/process-csv", web.ProcessCSVHandler).Methods("POST")
protected.HandleFunc("/upload-documents", web.UploadDocumentsHandler).Methods("POST")
protected.HandleFunc("/document-field-add", web.DocumentFieldAddHandler).Methods("GET")
protected.HandleFunc("/document-field-remove", web.DocumentFieldRemoveHandler).Methods("GET")
protected.HandleFunc("/documents/upload/results", web.UploadResultsHandler).Methods("GET")
protected.HandleFunc("/documents/upload/job/file", web.UploadJobFileHandler).Methods("GET")
// Document removal routes
protected.HandleFunc("/documents/remove", web.DocumentRemoveHandler).Methods("GET")
protected.HandleFunc("/documents/remove/process-csv", web.ProcessRemoveCSVHandler).Methods("POST")
protected.HandleFunc("/documents/remove/job-selection", web.JobSelectionHandler).Methods("POST")
protected.HandleFunc("/documents/remove/job/file", web.RemovalJobFileHandler).Methods("GET")
protected.HandleFunc("/documents/remove/job/{jobID}", web.GetJobAttachmentsHandler).Methods("GET")
protected.HandleFunc("/documents/remove/attachments/{jobID}", web.RemoveJobAttachmentsHandler).Methods("POST")
protected.HandleFunc("/documents/remove/bulk", web.BulkRemoveDocumentsHandler).Methods("POST")
protected.HandleFunc("/documents/remove/results", web.RemovalResultsHandler).Methods("GET")
log.Println("Server starting on :8080")
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
log.Println("Server starting on :" + port)
// Create a custom server with appropriate timeouts
server := &http.Server{
Addr: ":8080",
Addr: ":" + port,
Handler: r,
ReadTimeout: 30 * time.Minute, // Large timeout for big file uploads
WriteTimeout: 30 * time.Minute, // Large timeout for big file responses

BIN
apps/web/web-app

Binary file not shown.

17
internal/handlers/web/admin.go

@ -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
}
}

17
internal/handlers/web/assets.go

@ -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
}
}

23
internal/handlers/web/companies.go

@ -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
}
}

11
internal/handlers/web/contacts.go

@ -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)
}
}

11
internal/handlers/web/contracts.go

@ -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)
}
}

20
internal/handlers/web/dashboard.go

@ -12,20 +12,10 @@ func DashboardHandler(w http.ResponseWriter, r *http.Request) {
"Title": "Dashboard",
}
if r.Header.Get("HX-Request") == "true" {
// For HTMX requests, execute the dashboard template directly
if err := tmpl.ExecuteTemplate(w, "dashboard.html", data); err != nil {
log.Printf("Template execution error: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
} else {
// For full page requests, we'll use the layout template
// which will include the content template
if err := tmpl.ExecuteTemplate(w, "layout.html", data); err != nil {
log.Printf("Template execution error: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
// Always render the full layout with dashboard content
if err := tmpl.ExecuteTemplate(w, "layout.html", data); err != nil {
log.Printf("Template execution error: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
}

1214
internal/handlers/web/document_remove.go

File diff suppressed because it is too large

838
internal/handlers/web/documents.go

File diff suppressed because it is too large

13
internal/handlers/web/generic.go

@ -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)
}
}

34
internal/handlers/web/invoices.go

@ -43,24 +43,36 @@ func InvoicesHandler(w http.ResponseWriter, r *http.Request) {
}
if r.Method == "GET" {
handleInvoiceSearch(w, r, session)
// Check if this is a search request
searchTerm := strings.TrimSpace(r.URL.Query().Get("search"))
if searchTerm != "" {
handleInvoiceSearch(w, r, session)
return
}
// This is a request for the main invoices page
tmpl := root.WebTemplates
data := map[string]interface{}{
"Title": "Invoice Management",
}
// Always render the full layout with invoices content
if err := tmpl.ExecuteTemplate(w, "layout.html", data); err != nil {
log.Printf("Template execution error: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
return
}
// Handle other HTTP methods if needed
tmpl := root.WebTemplates
data := map[string]interface{}{
"Title": "Invoices",
"Title": "Invoice Management",
}
var err error
if r.Header.Get("HX-Request") == "true" {
err = tmpl.ExecuteTemplate(w, "content", data)
} else {
err = tmpl.ExecuteTemplate(w, "layout.html", data)
}
if err != nil {
// Always render the full layout
if err := tmpl.ExecuteTemplate(w, "layout.html", data); err != nil {
log.Printf("Template execution error: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return

8
internal/handlers/web/jobs.go

@ -26,6 +26,14 @@ func JobsHandler(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/jobs":
// For the main jobs page, render the full layout
err := tmpl.Execute(w, data)
if err != nil {
log.Printf("Template execution error: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
case "/jobs/search":
// Fetch and display search results only
jobs, err := handleJobSearch(r, session)
if err != nil {

11
internal/handlers/web/locations.go

@ -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)
}
}

11
internal/handlers/web/notifications.go

@ -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)
}
}

11
internal/handlers/web/quotes.go

@ -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)
}
}

11
internal/handlers/web/services.go

@ -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)
}
}

11
internal/handlers/web/tags.go

@ -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)
}
}

11
internal/handlers/web/users.go

@ -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)
}
}

88
internal/utils/pagination.go

@ -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]
}

1057
static/css/styles.css

File diff suppressed because it is too large

995
static/css/upload.css

File diff suppressed because it is too large

154
static/js/dashboard-drag.js

@ -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
static/js/htmx.min.js

@ -1 +0,0 @@
Redirecting to https://unpkg.com/htmx.org@1.9.12/dist/htmx.min.js

868
static/js/invoices.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');
}
}

10
templates/admin.html

@ -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}}

10
templates/assets.html

@ -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}}

10
templates/companies.html

@ -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}}

18
templates/contacts.html

@ -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}}

18
templates/contracts.html

@ -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}}

28
templates/dashboard.html

@ -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}}

18
templates/generic.html

@ -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}}

160
templates/invoices.html

@ -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}}

10
templates/jobs.html

@ -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}}

62
templates/layout.html

@ -8,6 +8,7 @@
<script src="https://unpkg.com/htmx.org@1.9.6"></script>
<link rel="stylesheet" href="/static/css/styles.css" />
<link rel="stylesheet" href="/static/css/upload.css" />
<script src="/static/js/dashboard-drag.js"></script>
</head>
<body class="flex h-screen bg-gray-100">
@ -16,21 +17,21 @@
<h1 class="title">ServiceTrade Tools</h1>
<nav>
<ul>
<li><a href="/" hx-get="/" hx-target="#content">Dashboard</a></li>
<li><a href="/jobs" hx-get="/jobs" hx-target="#content">Jobs</a></li>
<li><a href="/assets" hx-get="/assets" hx-target="#content">Assets</a></li>
<li><a href="/companies" hx-get="/companies" hx-target="#content">Companies</a></li>
<li><a href="/contacts" hx-get="/contacts" hx-target="#content">Contacts</a></li>
<li><a href="/contracts" hx-get="/contracts" hx-target="#content">Contracts</a></li>
<li><a href="/generic" hx-get="/generic" hx-target="#content">Generic Tools</a></li>
<li><a href="/invoices" hx-get="/invoices" hx-target="#content">Invoices</a></li>
<li><a href="/locations" hx-get="/locations" hx-target="#content">Locations</a></li>
<li><a href="/notifications" hx-get="/notifications" hx-target="#content">Notifications</a></li>
<li><a href="/quotes" hx-get="/quotes" hx-target="#content">Quotes</a></li>
<li><a href="/services" hx-get="/services" hx-target="#content">Services</a></li>
<li><a href="/tags" hx-get="/tags" hx-target="#content">Tags</a></li>
<li><a href="/users" hx-get="/users" hx-target="#content">Users</a></li>
<li><a href="/admin" hx-get="/admin" hx-target="#content">Admin</a></li>
<li><a href="/">Dashboard</a></li>
<li><a href="/jobs">Jobs</a></li>
<li><a href="/assets">Assets</a></li>
<li><a href="/companies">Companies</a></li>
<li><a href="/contacts">Contacts</a></li>
<li><a href="/contracts">Contracts</a></li>
<li><a href="/generic">Generic Tools</a></li>
<li><a href="/invoices">Invoices</a></li>
<li><a href="/locations">Locations</a></li>
<li><a href="/notifications">Notifications</a></li>
<li><a href="/quotes">Quotes</a></li>
<li><a href="/services">Services</a></li>
<li><a href="/tags">Tags</a></li>
<li><a href="/users">Users</a></li>
<li><a href="/admin">Admin</a></li>
</ul>
</nav>
</aside>
@ -46,13 +47,40 @@
<div id="content" class="content">
{{if .BodyContent}}
{{.BodyContent}}
{{else if eq .Title "Invoice Management"}}
{{template "invoices_content" .}}
{{else if eq .Title "Jobs"}}
{{template "jobs_content" .}}
{{else if eq .Title "Assets"}}
{{template "assets_content" .}}
{{else if eq .Title "Companies"}}
{{template "companies_content" .}}
{{else if eq .Title "Contacts"}}
{{template "contacts_content" .}}
{{else if eq .Title "Contracts"}}
{{template "contracts_content" .}}
{{else if eq .Title "Generic"}}
{{template "generic_content" .}}
{{else if eq .Title "Locations"}}
{{template "locations_content" .}}
{{else if eq .Title "Notifications"}}
{{template "notifications_content" .}}
{{else if eq .Title "Quotes"}}
{{template "quotes_content" .}}
{{else if eq .Title "Services"}}
{{template "services_content" .}}
{{else if eq .Title "Tags"}}
{{template "tags_content" .}}
{{else if eq .Title "Users"}}
{{template "users_content" .}}
{{else if eq .Title "Admin"}}
{{template "admin_content" .}}
{{else}}
{{template "content" .}}
{{template "dashboard_content" .}}
{{end}}
</div>
</main>
<script src="/static/js/htmx.min.js"></script>
</body>
</html>

18
templates/locations.html

@ -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}}

18
templates/notifications.html

@ -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}}

5
templates/partials/document_remove.html

@ -93,7 +93,7 @@
<!-- Restart Button (initially hidden) -->
<div id="restart-section-removal" class="content" style="display: none;">
<h3 class="submenu-header">Removal Complete</h3>
<button type="button" class="btn-primary" onclick="restartRemoval()">Start New Removal</button>
<button type="button" class="btn-primary" hx-on:click="restartRemoval()">Start New Removal</button>
</div>
</div>
@ -182,6 +182,9 @@
if (fileInput) {
fileInput.value = '';
}
// Reset form
document.getElementById('bulk-remove-form').reset();
}
</script>
{{end}}

470
templates/partials/document_upload.html

@ -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()">&times;</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}}

23
templates/partials/invoice_board_card.html

@ -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}}

2
templates/partials/invoice_card.html

@ -1,6 +1,6 @@
{{define "invoice_card"}}
{{$cardID := or .invoiceNumber .id}}
<div id="invoice-card-{{$cardID}}" class="invoice-card">
<div id="invoice-card-{{$cardID}}" class="invoice-card" data-invoice-id="{{.id}}">
<div class="invoice-header">
<h4>Invoice #{{.invoiceNumber}}</h4>
<div class="invoice-status {{.status}}">{{.status}}</div>

73
templates/partials/removal_result_card.html

@ -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}}

78
templates/partials/removal_results.html

@ -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}}

24
templates/partials/removal_stats.html

@ -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}}

149
templates/partials/upload_result_card.html

@ -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}}

55
templates/partials/upload_results_pagination.html

@ -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}}

24
templates/partials/upload_stats.html

@ -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}}

18
templates/quotes.html

@ -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}}

18
templates/services.html

@ -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}}

18
templates/tags.html

@ -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}}

18
templates/users.html

@ -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}}

36
web_templates.go

@ -2,10 +2,12 @@ package root
import (
"embed"
"fmt"
"html/template"
"io/fs"
"log"
"path/filepath"
"time"
)
//go:embed templates static/*
@ -19,6 +21,40 @@ var funcMap = template.FuncMap{
// This allows us to reference specific content blocks
return "{{template \"" + name + "-content\" .}}"
},
"add": func(a, b int) int {
return a + b
},
"subtract": func(a, b int) int {
return a - b
},
"div": func(a int64, b float64) float64 {
return float64(a) / b
},
"sequence": func(start, end int) []int {
if start > end {
return []int{}
}
result := make([]int, end-start+1)
for i := range result {
result[i] = start + i
}
return result
},
"formatDuration": func(d time.Duration) string {
seconds := d.Seconds()
if seconds < 60 {
return fmt.Sprintf("%.2fs", seconds)
} else if seconds < 3600 {
minutes := int(seconds) / 60
remainingSeconds := seconds - float64(minutes*60)
return fmt.Sprintf("%dm %.2fs", minutes, remainingSeconds)
} else {
hours := int(seconds) / 3600
remainingMinutes := int(seconds) % 3600 / 60
remainingSeconds := seconds - float64(hours*3600) - float64(remainingMinutes*60)
return fmt.Sprintf("%dh %dm %.2fs", hours, remainingMinutes, remainingSeconds)
}
},
}
// InitializeWebTemplates parses all HTML templates in the embedded filesystem

Loading…
Cancel
Save