Browse Source

feat: added barebones invoice clock events report

production
nic 3 months ago
parent
commit
1b29acf523
  1. 3
      apps/web/main.go
  2. 44
      internal/handlers/web/generic.go
  3. 27
      internal/middleware/csrf_simple.go
  4. 73
      templates/generic.html

3
apps/web/main.go

@ -163,6 +163,9 @@ func main() {
protected.HandleFunc("/documents/remove/bulk", web.BulkRemoveDocumentsHandler).Methods("POST")
protected.HandleFunc("/documents/remove/results", web.RemovalResultsHandler).Methods("GET")
// Reports & utilities
protected.HandleFunc("/reports/invoice-clock", web.InvoiceClockReportHandler).Methods("POST")
port := os.Getenv("PORT")
if port == "" {
port = "8080"

44
internal/handlers/web/generic.go

@ -1,18 +1,54 @@
package web
import (
root "marmic/servicetrade-toolbox"
"bytes"
"html/template"
"net/http"
root "marmic/servicetrade-toolbox"
"marmic/servicetrade-toolbox/internal/api"
"marmic/servicetrade-toolbox/internal/middleware"
"github.com/gorilla/csrf"
)
func GenericHandler(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
var csrfCookie string
if c, err := r.Cookie("XSRF-TOKEN"); err == nil {
csrfCookie = c.Value
} else if c, err := r.Cookie("XSRF-TOKEN-VALUE"); err == nil {
csrfCookie = c.Value
}
data := map[string]interface{}{
"Title": "Generic",
"Title": "Generic",
"Session": session,
"CSRFField": csrf.TemplateField(r),
"CSRFToken": csrf.Token(r),
"CSRFCookie": csrfCookie,
}
if r.Header.Get("HX-Request") == "true" {
if err := tmpl.ExecuteTemplate(w, "generic_content", data); err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
return
}
var contentBuf bytes.Buffer
if err := tmpl.ExecuteTemplate(&contentBuf, "generic_content", data); err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
data["BodyContent"] = template.HTML(contentBuf.String())
err := tmpl.Execute(w, data)
if err != nil {
if err := tmpl.ExecuteTemplate(w, "layout.html", data); err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}

27
internal/middleware/csrf_simple.go

@ -3,6 +3,7 @@ package middleware
import (
"crypto/rand"
"encoding/base64"
"log"
"net/http"
"strings"
"time"
@ -40,9 +41,31 @@ func CSRFSimple(next http.Handler) http.Handler {
}
// For unsafe methods, require header matches cookie
headerToken := r.Header.Get("X-CSRF-Token")
token := r.Header.Get("X-CSRF-Token")
if token == "" {
contentType := r.Header.Get("Content-Type")
if strings.Contains(contentType, "multipart/form-data") {
if err := r.ParseMultipartForm(10 << 20); err == nil {
token = r.PostFormValue("csrfToken")
}
} else if err := r.ParseForm(); err == nil {
token = r.PostFormValue("csrfToken")
}
}
cookie, err := r.Cookie("XSRF-TOKEN")
if err != nil || headerToken == "" || cookie == nil || cookie.Value == "" || cookie.Value != headerToken {
if err != nil || token == "" || cookie == nil || cookie.Value == "" || cookie.Value != token {
if token == "" {
if headerToken := r.Header.Get("X-CSRF-Token"); headerToken != "" {
token = headerToken
}
}
log.Printf("CSRF validation failed (simple mode): header=%q form=%q cookie=%q err=%v", r.Header.Get("X-CSRF-Token"), token, func() string {
if cookie != nil {
return cookie.Value
}
return ""
}(), err)
http.Error(w, "Forbidden - CSRF", http.StatusForbidden)
return
}

73
templates/generic.html

@ -7,12 +7,73 @@
<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>
<section class="card">
<h4>Invoice Clock Events Report (Proof of Concept)</h4>
<p>
Upload a CSV containing invoice numbers to generate a downloadable report that aggregates
related job information, assigned technicians, and enroute/onsite clock events.
</p>
<form id="invoice-clock-form" class="form" action="/reports/invoice-clock" method="POST"
enctype="multipart/form-data">
{{if .CSRFField}}
{{.CSRFField}}
{{end}}
<input type="hidden" name="csrfToken" id="generic-csrf-token" value="{{.CSRFCookie}}">
<div class="form-group">
<label for="invoiceCsv">Select CSV file</label>
<input class="card-input" type="file" id="invoiceCsv" name="invoiceCsv" accept=".csv" required>
</div>
<p class="help-text">
The CSV may include a header named <code>invoice_number</code>; if no header is present, the first
column
is used. Each invoice number is processed individually.
</p>
<button class="btn-primary" type="submit">Generate Report</button>
</form>
</section>
</div>
</div>
<script>
(function () {
function readCookie(name) {
var re = new RegExp('(?:^|; )' + name.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&') + '=([^;]+)');
var m = document.cookie.match(re);
return m ? decodeURIComponent(m[1]) : null;
}
function applyToken(root) {
var form = root.querySelector ? root.querySelector('#invoice-clock-form') : document.getElementById('invoice-clock-form');
if (!form) { return; }
var field = form.querySelector('#generic-csrf-token');
if (!field) { return; }
var token = readCookie('XSRF-TOKEN') || readCookie('XSRF-TOKEN-VALUE');
if (token) {
field.value = token;
}
}
function ensureTokenBeforeSubmit(evt) {
if (!evt.target || evt.target.id !== 'invoice-clock-form') {
return;
}
applyToken(evt.target);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function () { applyToken(document); });
} else {
applyToken(document);
}
document.addEventListener('submit', ensureTokenBeforeSubmit, true);
if (document.body && document.body.addEventListener) {
document.body.addEventListener('htmx:load', function (evt) {
applyToken(evt.target || document);
});
}
})();
</script>
{{end}}
Loading…
Cancel
Save