Compare commits

...

2 Commits

Author SHA1 Message Date
812d38c8f1 adds 2026-02-08 14:20:18 +01:00
2dfe0a48ea fix(pagination): update URL on pagination and search
This commit addresses two issues with pagination and search:

1.  **Pagination URL Update**: Added `hx-push-url` to pagination buttons in the customer, order, and invoice list templates. This ensures the browser URL and history are updated when navigating pages via HTMX. The query parameters for `status` and `search` are now conditionally included, preventing empty parameters (e.g., `&status=`) in the URL.

2.  **Search URL Reset**: Updated the `CustomerList` handler to send the `HX-Push-Url` header when performing a search via HTMX. This ensures that when a search is executed (or cleared), the URL correctly reflects the new state (reseting the page to 1), fixing a mismatch where the URL could show page 4 while the content was reset to page 1.
2026-02-07 10:19:32 +01:00
18 changed files with 332 additions and 36 deletions

@ -15,6 +15,7 @@ func runMigrations(db *sql.DB) error {
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`, )`,
@ -105,6 +106,19 @@ func runMigrations(db *sql.DB) error {
} }
} }
// Manual migration for existing users table
var roleExists int
db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('users') WHERE name='role'").Scan(&roleExists)
if roleExists == 0 {
if _, err := db.Exec("ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'"); err != nil {
log.Printf("Failed to add role column: %v", err)
} else {
log.Println("Added role column to users table")
// Update existing admin user if exists
db.Exec("UPDATE users SET role = 'admin' WHERE username = 'admin'")
}
}
log.Println("Migrations completed") log.Println("Migrations completed")
return nil return nil
} }
@ -118,7 +132,7 @@ func seedData(db *sql.DB) error {
if err != nil { if err != nil {
return fmt.Errorf("hashing password: %w", err) return fmt.Errorf("hashing password: %w", err)
} }
_, err = db.Exec("INSERT INTO users (username, password_hash) VALUES (?, ?)", "admin", string(hash)) _, err = db.Exec("INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)", "admin", string(hash), "admin")
if err != nil { if err != nil {
return fmt.Errorf("inserting admin user: %w", err) return fmt.Errorf("inserting admin user: %w", err)
} }

@ -38,6 +38,7 @@ func (h *Handler) LoginSubmit(w http.ResponseWriter, r *http.Request) {
session, _ := h.Store.Get(r, "erp-session") session, _ := h.Store.Get(r, "erp-session")
session.Values["user_id"] = user.ID session.Values["user_id"] = user.ID
session.Values["username"] = user.Username session.Values["username"] = user.Username
session.Values["role"] = user.Role
session.Save(r, w) session.Save(r, w)
// HTMX redirect // HTMX redirect
@ -52,6 +53,7 @@ func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
session, _ := h.Store.Get(r, "erp-session") session, _ := h.Store.Get(r, "erp-session")
session.Values["user_id"] = nil session.Values["user_id"] = nil
session.Values["username"] = nil session.Values["username"] = nil
session.Values["role"] = nil
session.Options.MaxAge = -1 session.Options.MaxAge = -1
session.Save(r, w) session.Save(r, w)

@ -1,6 +1,7 @@
package handlers package handlers
import ( import (
"fmt"
"net/http" "net/http"
"strconv" "strconv"
@ -39,11 +40,18 @@ func (h *Handler) CustomerList(w http.ResponseWriter, r *http.Request) {
// HTMX partial for search // HTMX partial for search
if r.Header.Get("HX-Request") == "true" && r.URL.Query().Get("partial") == "true" { if r.Header.Get("HX-Request") == "true" && r.URL.Query().Get("partial") == "true" {
// Construct clean URL for history
u := fmt.Sprintf("/customers?page=%d", page)
if search != "" {
u += "&search=" + search
}
w.Header().Set("HX-Push-Url", u)
h.renderPartial(w, "customers/list.html", "customer-table", data) h.renderPartial(w, "customers/list.html", "customer-table", data)
return return
} }
h.render(w, []string{"layout.html", "customers/list.html"}, data) h.render(w, r, []string{"layout.html", "customers/list.html"}, data)
} }
func (h *Handler) CustomerNew(w http.ResponseWriter, r *http.Request) { func (h *Handler) CustomerNew(w http.ResponseWriter, r *http.Request) {
@ -54,7 +62,7 @@ func (h *Handler) CustomerNew(w http.ResponseWriter, r *http.Request) {
"Customer": &models.Customer{}, "Customer": &models.Customer{},
"IsNew": true, "IsNew": true,
} }
h.render(w, []string{"layout.html", "customers/form.html"}, data) h.render(w, r, []string{"layout.html", "customers/form.html"}, data)
} }
func (h *Handler) CustomerCreate(w http.ResponseWriter, r *http.Request) { func (h *Handler) CustomerCreate(w http.ResponseWriter, r *http.Request) {
@ -74,7 +82,7 @@ func (h *Handler) CustomerCreate(w http.ResponseWriter, r *http.Request) {
"IsNew": true, "IsNew": true,
"Error": "Name is required", "Error": "Name is required",
} }
h.render(w, []string{"layout.html", "customers/form.html"}, data) h.render(w, r, []string{"layout.html", "customers/form.html"}, data)
return return
} }
@ -118,7 +126,7 @@ func (h *Handler) CustomerDetail(w http.ResponseWriter, r *http.Request) {
"Orders": customerOrders, "Orders": customerOrders,
"Invoices": invoices, "Invoices": invoices,
} }
h.render(w, []string{"layout.html", "customers/detail.html"}, data) h.render(w, r, []string{"layout.html", "customers/detail.html"}, data)
} }
func (h *Handler) CustomerEdit(w http.ResponseWriter, r *http.Request) { func (h *Handler) CustomerEdit(w http.ResponseWriter, r *http.Request) {
@ -136,7 +144,7 @@ func (h *Handler) CustomerEdit(w http.ResponseWriter, r *http.Request) {
"Customer": customer, "Customer": customer,
"IsNew": false, "IsNew": false,
} }
h.render(w, []string{"layout.html", "customers/form.html"}, data) h.render(w, r, []string{"layout.html", "customers/form.html"}, data)
} }
func (h *Handler) CustomerUpdate(w http.ResponseWriter, r *http.Request) { func (h *Handler) CustomerUpdate(w http.ResponseWriter, r *http.Request) {
@ -158,7 +166,7 @@ func (h *Handler) CustomerUpdate(w http.ResponseWriter, r *http.Request) {
"IsNew": false, "IsNew": false,
"Error": "Name is required", "Error": "Name is required",
} }
h.render(w, []string{"layout.html", "customers/form.html"}, data) h.render(w, r, []string{"layout.html", "customers/form.html"}, data)
return return
} }

@ -19,5 +19,5 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
"FulfilledOrders": models.OrderCountByStatus(h.DB, "fulfilled"), "FulfilledOrders": models.OrderCountByStatus(h.DB, "fulfilled"),
} }
h.render(w, []string{"layout.html", "dashboard.html"}, data) h.render(w, r, []string{"layout.html", "dashboard.html"}, data)
} }

@ -21,12 +21,24 @@ var funcMap = template.FuncMap{
"formatMoney": func(amount float64) string { "formatMoney": func(amount float64) string {
return fmt.Sprintf("$%.2f", amount) return fmt.Sprintf("$%.2f", amount)
}, },
"formatDate": func(date string) string { "formatDate": func(v interface{}) string {
t, err := time.Parse("2006-01-02", date) if t, ok := v.(time.Time); ok {
if err != nil { return t.Format("Jan 02, 2006")
return date
} }
return t.Format("Jan 02, 2006") if date, ok := v.(string); ok {
t, err := time.Parse("2006-01-02", date)
if err != nil {
return date
}
return t.Format("Jan 02, 2006")
}
return ""
},
"formatDateTime": func(v interface{}) string {
if t, ok := v.(time.Time); ok {
return t.Format("Jan 02, 2006 15:04")
}
return ""
}, },
"statusBadge": func(status string) template.HTML { "statusBadge": func(status string) template.HTML {
colors := map[string]string{ colors := map[string]string{
@ -69,7 +81,12 @@ var funcMap = template.FuncMap{
}, },
} }
func (h *Handler) render(w http.ResponseWriter, templates []string, data interface{}) { func (h *Handler) render(w http.ResponseWriter, r *http.Request, templates []string, data interface{}) {
if d, ok := data.(map[string]interface{}); ok {
d["Username"] = h.getUsername(r)
d["IsAdmin"] = h.getRole(r) == "admin"
}
paths := make([]string, len(templates)) paths := make([]string, len(templates))
for i, t := range templates { for i, t := range templates {
paths[i] = filepath.Join("templates", t) paths[i] = filepath.Join("templates", t)
@ -106,3 +123,11 @@ func (h *Handler) getUsername(r *http.Request) string {
} }
return "" return ""
} }
func (h *Handler) getRole(r *http.Request) string {
session, _ := h.Store.Get(r, "erp-session")
if role, ok := session.Values["role"].(string); ok {
return role
}
return ""
}

@ -43,7 +43,7 @@ func (h *Handler) InvoiceList(w http.ResponseWriter, r *http.Request) {
return return
} }
h.render(w, []string{"layout.html", "invoices/list.html"}, data) h.render(w, r, []string{"layout.html", "invoices/list.html"}, data)
} }
func (h *Handler) InvoiceDetail(w http.ResponseWriter, r *http.Request) { func (h *Handler) InvoiceDetail(w http.ResponseWriter, r *http.Request) {
@ -60,7 +60,7 @@ func (h *Handler) InvoiceDetail(w http.ResponseWriter, r *http.Request) {
"ActivePage": "invoices", "ActivePage": "invoices",
"Invoice": invoice, "Invoice": invoice,
} }
h.render(w, []string{"layout.html", "invoices/detail.html"}, data) h.render(w, r, []string{"layout.html", "invoices/detail.html"}, data)
} }
func (h *Handler) InvoicePay(w http.ResponseWriter, r *http.Request) { func (h *Handler) InvoicePay(w http.ResponseWriter, r *http.Request) {

@ -21,7 +21,7 @@ func (h *Handler) ChartOfAccounts(w http.ResponseWriter, r *http.Request) {
"ActivePage": "ledger", "ActivePage": "ledger",
"Accounts": accounts, "Accounts": accounts,
} }
h.render(w, []string{"layout.html", "ledger/chart_of_accounts.html"}, data) h.render(w, r, []string{"layout.html", "ledger/chart_of_accounts.html"}, data)
} }
func (h *Handler) JournalEntries(w http.ResponseWriter, r *http.Request) { func (h *Handler) JournalEntries(w http.ResponseWriter, r *http.Request) {
@ -51,7 +51,7 @@ func (h *Handler) JournalEntries(w http.ResponseWriter, r *http.Request) {
"PrevPage": page - 1, "PrevPage": page - 1,
"NextPage": page + 1, "NextPage": page + 1,
} }
h.render(w, []string{"layout.html", "ledger/journal_entries.html"}, data) h.render(w, r, []string{"layout.html", "ledger/journal_entries.html"}, data)
} }
func (h *Handler) JournalEntryNew(w http.ResponseWriter, r *http.Request) { func (h *Handler) JournalEntryNew(w http.ResponseWriter, r *http.Request) {
@ -64,7 +64,7 @@ func (h *Handler) JournalEntryNew(w http.ResponseWriter, r *http.Request) {
"Accounts": accounts, "Accounts": accounts,
"Today": time.Now().Format("2006-01-02"), "Today": time.Now().Format("2006-01-02"),
} }
h.render(w, []string{"layout.html", "ledger/journal_entry_form.html"}, data) h.render(w, r, []string{"layout.html", "ledger/journal_entry_form.html"}, data)
} }
func (h *Handler) JournalEntryCreate(w http.ResponseWriter, r *http.Request) { func (h *Handler) JournalEntryCreate(w http.ResponseWriter, r *http.Request) {
@ -107,7 +107,7 @@ func (h *Handler) JournalEntryCreate(w http.ResponseWriter, r *http.Request) {
"Today": time.Now().Format("2006-01-02"), "Today": time.Now().Format("2006-01-02"),
"Error": "At least two lines are required", "Error": "At least two lines are required",
} }
h.render(w, []string{"layout.html", "ledger/journal_entry_form.html"}, data) h.render(w, r, []string{"layout.html", "ledger/journal_entry_form.html"}, data)
return return
} }
@ -121,7 +121,7 @@ func (h *Handler) JournalEntryCreate(w http.ResponseWriter, r *http.Request) {
"Today": time.Now().Format("2006-01-02"), "Today": time.Now().Format("2006-01-02"),
"Error": err.Error(), "Error": err.Error(),
} }
h.render(w, []string{"layout.html", "ledger/journal_entry_form.html"}, data) h.render(w, r, []string{"layout.html", "ledger/journal_entry_form.html"}, data)
return return
} }
@ -146,7 +146,7 @@ func (h *Handler) JournalEntryDetail(w http.ResponseWriter, r *http.Request) {
"ActivePage": "ledger", "ActivePage": "ledger",
"Entry": entry, "Entry": entry,
} }
h.render(w, []string{"layout.html", "ledger/journal_entry_detail.html"}, data) h.render(w, r, []string{"layout.html", "ledger/journal_entry_detail.html"}, data)
} }
func (h *Handler) TrialBalance(w http.ResponseWriter, r *http.Request) { func (h *Handler) TrialBalance(w http.ResponseWriter, r *http.Request) {
@ -170,5 +170,5 @@ func (h *Handler) TrialBalance(w http.ResponseWriter, r *http.Request) {
"TotalDebit": totalDebit, "TotalDebit": totalDebit,
"TotalCredit": totalCredit, "TotalCredit": totalCredit,
} }
h.render(w, []string{"layout.html", "ledger/trial_balance.html"}, data) h.render(w, r, []string{"layout.html", "ledger/trial_balance.html"}, data)
} }

@ -44,7 +44,7 @@ func (h *Handler) OrderList(w http.ResponseWriter, r *http.Request) {
return return
} }
h.render(w, []string{"layout.html", "orders/list.html"}, data) h.render(w, r, []string{"layout.html", "orders/list.html"}, data)
} }
func (h *Handler) OrderNew(w http.ResponseWriter, r *http.Request) { func (h *Handler) OrderNew(w http.ResponseWriter, r *http.Request) {
@ -58,7 +58,7 @@ func (h *Handler) OrderNew(w http.ResponseWriter, r *http.Request) {
"Customers": customers, "Customers": customers,
"IsNew": true, "IsNew": true,
} }
h.render(w, []string{"layout.html", "orders/form.html"}, data) h.render(w, r, []string{"layout.html", "orders/form.html"}, data)
} }
func (h *Handler) OrderCreate(w http.ResponseWriter, r *http.Request) { func (h *Handler) OrderCreate(w http.ResponseWriter, r *http.Request) {
@ -81,7 +81,7 @@ func (h *Handler) OrderCreate(w http.ResponseWriter, r *http.Request) {
"IsNew": true, "IsNew": true,
"Error": "Customer is required", "Error": "Customer is required",
} }
h.render(w, []string{"layout.html", "orders/form.html"}, data) h.render(w, r, []string{"layout.html", "orders/form.html"}, data)
return return
} }
@ -115,7 +115,7 @@ func (h *Handler) OrderDetail(w http.ResponseWriter, r *http.Request) {
"ActivePage": "orders", "ActivePage": "orders",
"Order": order, "Order": order,
} }
h.render(w, []string{"layout.html", "orders/detail.html"}, data) h.render(w, r, []string{"layout.html", "orders/detail.html"}, data)
} }
func (h *Handler) OrderEdit(w http.ResponseWriter, r *http.Request) { func (h *Handler) OrderEdit(w http.ResponseWriter, r *http.Request) {
@ -141,7 +141,7 @@ func (h *Handler) OrderEdit(w http.ResponseWriter, r *http.Request) {
"Customers": customers, "Customers": customers,
"IsNew": false, "IsNew": false,
} }
h.render(w, []string{"layout.html", "orders/form.html"}, data) h.render(w, r, []string{"layout.html", "orders/form.html"}, data)
} }
func (h *Handler) OrderUpdate(w http.ResponseWriter, r *http.Request) { func (h *Handler) OrderUpdate(w http.ResponseWriter, r *http.Request) {

@ -0,0 +1,90 @@
package handlers
import (
"fmt"
"net/http"
"strconv"
"erp_system/internal/models"
)
func (h *Handler) UserList(w http.ResponseWriter, r *http.Request) {
users, err := models.UserGetAll(h.DB)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
data := map[string]interface{}{
"Title": "Users",
"ActivePage": "users",
"Users": users,
}
h.render(w, r, []string{"layout.html", "users/list.html"}, data)
}
func (h *Handler) UserNew(w http.ResponseWriter, r *http.Request) {
data := map[string]interface{}{
"Title": "New User",
"ActivePage": "users",
"User": &models.User{},
"IsNew": true,
}
h.render(w, r, []string{"layout.html", "users/form.html"}, data)
}
func (h *Handler) UserCreate(w http.ResponseWriter, r *http.Request) {
username := r.FormValue("username")
password := r.FormValue("password")
role := r.FormValue("role")
if username == "" || password == "" {
data := map[string]interface{}{
"Title": "New User",
"ActivePage": "users",
"User": &models.User{Username: username, Role: role},
"IsNew": true,
"Error": "Username and Password are required",
}
h.render(w, r, []string{"layout.html", "users/form.html"}, data)
return
}
if err := models.UserCreate(h.DB, username, password, role); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("HX-Redirect", "/users")
return
}
http.Redirect(w, r, "/users", http.StatusSeeOther)
}
func (h *Handler) UserDelete(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.Atoi(r.PathValue("id"))
// Prevent deleting yourself
session, _ := h.Store.Get(r, "erp-session")
currentUserID := session.Values["user_id"].(int)
if id == currentUserID {
w.Header().Set("HX-Trigger", `{"showMessage": "Cannot delete yourself"}`)
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, "Cannot delete yourself")
return
}
if err := models.UserDelete(h.DB, id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if r.Header.Get("HX-Request") == "true" {
// Just remove the row from the table or reload
// Since it's a delete action, usually redirect or reload list
w.Header().Set("HX-Refresh", "true")
return
}
http.Redirect(w, r, "/users", http.StatusSeeOther)
}

@ -25,3 +25,17 @@ func RequireAuth(store *sessions.CookieStore) func(http.Handler) http.Handler {
}) })
} }
} }
func RequireAdmin(store *sessions.CookieStore) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session, _ := store.Get(r, "erp-session")
role, ok := session.Values["role"].(string)
if !ok || role != "admin" {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}

@ -12,15 +12,16 @@ type User struct {
ID int ID int
Username string Username string
PasswordHash string PasswordHash string
Role string
CreatedAt time.Time CreatedAt time.Time
} }
func Authenticate(db *sql.DB, username, password string) (*User, error) { func Authenticate(db *sql.DB, username, password string) (*User, error) {
u := &User{} u := &User{}
err := db.QueryRow( err := db.QueryRow(
"SELECT id, username, password_hash, created_at FROM users WHERE username = ?", "SELECT id, username, password_hash, role, created_at FROM users WHERE username = ?",
username, username,
).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.CreatedAt) ).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.CreatedAt)
if err != nil { if err != nil {
return nil, fmt.Errorf("user not found") return nil, fmt.Errorf("user not found")
} }
@ -31,3 +32,35 @@ func Authenticate(db *sql.DB, username, password string) (*User, error) {
return u, nil return u, nil
} }
func UserGetAll(db *sql.DB) ([]User, error) {
rows, err := db.Query("SELECT id, username, role, created_at FROM users ORDER BY username")
if err != nil {
return nil, err
}
defer rows.Close()
var users []User
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.Username, &u.Role, &u.CreatedAt); err != nil {
return nil, err
}
users = append(users, u)
}
return users, nil
}
func UserCreate(db *sql.DB, username, password, role string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
_, err = db.Exec("INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)", username, string(hash), role)
return err
}
func UserDelete(db *sql.DB, id int) error {
_, err := db.Exec("DELETE FROM users WHERE id = ?", id)
return err
}

@ -40,6 +40,7 @@ func main() {
// Auth middleware // Auth middleware
authMw := middleware.RequireAuth(store) authMw := middleware.RequireAuth(store)
adminMw := middleware.RequireAdmin(store)
// Router // Router
mux := http.NewServeMux() mux := http.NewServeMux()
@ -94,6 +95,12 @@ func main() {
mux.Handle("GET /ledger/journal/{id}", authMw(http.HandlerFunc(h.JournalEntryDetail))) mux.Handle("GET /ledger/journal/{id}", authMw(http.HandlerFunc(h.JournalEntryDetail)))
mux.Handle("GET /ledger/trial-balance", authMw(http.HandlerFunc(h.TrialBalance))) mux.Handle("GET /ledger/trial-balance", authMw(http.HandlerFunc(h.TrialBalance)))
// User Management (Admin only)
mux.Handle("GET /users", authMw(adminMw(http.HandlerFunc(h.UserList))))
mux.Handle("GET /users/new", authMw(adminMw(http.HandlerFunc(h.UserNew))))
mux.Handle("POST /users", authMw(adminMw(http.HandlerFunc(h.UserCreate))))
mux.Handle("DELETE /users/{id}", authMw(adminMw(http.HandlerFunc(h.UserDelete))))
port := os.Getenv("PORT") port := os.Getenv("PORT")
if port == "" { if port == "" {
port = "1337" port = "1337"

@ -72,7 +72,8 @@
<nav class="isolate inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination"> <nav class="isolate inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination">
{{if .HasPrev}} {{if .HasPrev}}
<button <button
hx-get="/customers?page={{.PrevPage}}&search={{.Search}}&partial=true" hx-get="/customers?page={{.PrevPage}}{{if .Search}}&search={{.Search}}{{end}}&partial=true"
hx-push-url="/customers?page={{.PrevPage}}{{if .Search}}&search={{.Search}}{{end}}"
hx-target="#customer-table" hx-target="#customer-table"
class="relative inline-flex items-center rounded-l-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0"> class="relative inline-flex items-center rounded-l-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0">
<span class="sr-only">Previous</span> <span class="sr-only">Previous</span>
@ -83,7 +84,8 @@
{{end}} {{end}}
{{if .HasNext}} {{if .HasNext}}
<button <button
hx-get="/customers?page={{.NextPage}}&search={{.Search}}&partial=true" hx-get="/customers?page={{.NextPage}}{{if .Search}}&search={{.Search}}{{end}}&partial=true"
hx-push-url="/customers?page={{.NextPage}}{{if .Search}}&search={{.Search}}{{end}}"
hx-target="#customer-table" hx-target="#customer-table"
class="relative inline-flex items-center rounded-r-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0"> class="relative inline-flex items-center rounded-r-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0">
<span class="sr-only">Next</span> <span class="sr-only">Next</span>

@ -76,7 +76,8 @@
<nav class="isolate inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination"> <nav class="isolate inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination">
{{if .HasPrev}} {{if .HasPrev}}
<button <button
hx-get="/invoices?page={{.PrevPage}}&status={{.FilterStatus}}&partial=true" hx-get="/invoices?page={{.PrevPage}}{{if .FilterStatus}}&status={{.FilterStatus}}{{end}}&partial=true"
hx-push-url="/invoices?page={{.PrevPage}}{{if .FilterStatus}}&status={{.FilterStatus}}{{end}}"
hx-target="#invoice-table" hx-target="#invoice-table"
class="relative inline-flex items-center rounded-l-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0"> class="relative inline-flex items-center rounded-l-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0">
<span class="sr-only">Previous</span> <span class="sr-only">Previous</span>
@ -87,7 +88,8 @@
{{end}} {{end}}
{{if .HasNext}} {{if .HasNext}}
<button <button
hx-get="/invoices?page={{.NextPage}}&status={{.FilterStatus}}&partial=true" hx-get="/invoices?page={{.NextPage}}{{if .FilterStatus}}&status={{.FilterStatus}}{{end}}&partial=true"
hx-push-url="/invoices?page={{.NextPage}}{{if .FilterStatus}}&status={{.FilterStatus}}{{end}}"
hx-target="#invoice-table" hx-target="#invoice-table"
class="relative inline-flex items-center rounded-r-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0"> class="relative inline-flex items-center rounded-r-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0">
<span class="sr-only">Next</span> <span class="sr-only">Next</span>

@ -28,6 +28,9 @@
<a href="/customers" class="{{if eq .ActivePage "customers"}}bg-indigo-700 text-white{{else}}text-indigo-200 hover:bg-indigo-500 hover:text-white{{end}} rounded-md px-3 py-2 text-sm font-medium">Customers</a> <a href="/customers" class="{{if eq .ActivePage "customers"}}bg-indigo-700 text-white{{else}}text-indigo-200 hover:bg-indigo-500 hover:text-white{{end}} rounded-md px-3 py-2 text-sm font-medium">Customers</a>
<a href="/orders" class="{{if eq .ActivePage "orders"}}bg-indigo-700 text-white{{else}}text-indigo-200 hover:bg-indigo-500 hover:text-white{{end}} rounded-md px-3 py-2 text-sm font-medium">Orders</a> <a href="/orders" class="{{if eq .ActivePage "orders"}}bg-indigo-700 text-white{{else}}text-indigo-200 hover:bg-indigo-500 hover:text-white{{end}} rounded-md px-3 py-2 text-sm font-medium">Orders</a>
<a href="/invoices" class="{{if eq .ActivePage "invoices"}}bg-indigo-700 text-white{{else}}text-indigo-200 hover:bg-indigo-500 hover:text-white{{end}} rounded-md px-3 py-2 text-sm font-medium">Invoices</a> <a href="/invoices" class="{{if eq .ActivePage "invoices"}}bg-indigo-700 text-white{{else}}text-indigo-200 hover:bg-indigo-500 hover:text-white{{end}} rounded-md px-3 py-2 text-sm font-medium">Invoices</a>
{{if .IsAdmin}}
<a href="/users" class="{{if eq .ActivePage "users"}}bg-indigo-700 text-white{{else}}text-indigo-200 hover:bg-indigo-500 hover:text-white{{end}} rounded-md px-3 py-2 text-sm font-medium">Users</a>
{{end}}
<div class="relative"> <div class="relative">
<button type="button" onclick="toggleDropdown(event, 'ledger-dropdown')" class="{{if eq .ActivePage "ledger"}}bg-indigo-700 text-white{{else}}text-indigo-200 hover:bg-indigo-500 hover:text-white{{end}} rounded-md px-3 py-2 text-sm font-medium inline-flex items-center"> <button type="button" onclick="toggleDropdown(event, 'ledger-dropdown')" class="{{if eq .ActivePage "ledger"}}bg-indigo-700 text-white{{else}}text-indigo-200 hover:bg-indigo-500 hover:text-white{{end}} rounded-md px-3 py-2 text-sm font-medium inline-flex items-center">
General Ledger General Ledger
@ -66,6 +69,9 @@
<a href="/customers" class="{{if eq .ActivePage "customers"}}bg-indigo-700 text-white{{else}}text-indigo-200 hover:bg-indigo-500 hover:text-white{{end}} block rounded-md px-3 py-2 text-base font-medium">Customers</a> <a href="/customers" class="{{if eq .ActivePage "customers"}}bg-indigo-700 text-white{{else}}text-indigo-200 hover:bg-indigo-500 hover:text-white{{end}} block rounded-md px-3 py-2 text-base font-medium">Customers</a>
<a href="/orders" class="{{if eq .ActivePage "orders"}}bg-indigo-700 text-white{{else}}text-indigo-200 hover:bg-indigo-500 hover:text-white{{end}} block rounded-md px-3 py-2 text-base font-medium">Orders</a> <a href="/orders" class="{{if eq .ActivePage "orders"}}bg-indigo-700 text-white{{else}}text-indigo-200 hover:bg-indigo-500 hover:text-white{{end}} block rounded-md px-3 py-2 text-base font-medium">Orders</a>
<a href="/invoices" class="{{if eq .ActivePage "invoices"}}bg-indigo-700 text-white{{else}}text-indigo-200 hover:bg-indigo-500 hover:text-white{{end}} block rounded-md px-3 py-2 text-base font-medium">Invoices</a> <a href="/invoices" class="{{if eq .ActivePage "invoices"}}bg-indigo-700 text-white{{else}}text-indigo-200 hover:bg-indigo-500 hover:text-white{{end}} block rounded-md px-3 py-2 text-base font-medium">Invoices</a>
{{if .IsAdmin}}
<a href="/users" class="{{if eq .ActivePage "users"}}bg-indigo-700 text-white{{else}}text-indigo-200 hover:bg-indigo-500 hover:text-white{{end}} block rounded-md px-3 py-2 text-base font-medium">Users</a>
{{end}}
<div> <div>
<button type="button" onclick="toggleDropdown(event, 'ledger-mobile-dropdown')" class="{{if eq .ActivePage "ledger"}}bg-indigo-700 text-white{{else}}text-indigo-200 hover:bg-indigo-500 hover:text-white{{end}} w-full text-left block rounded-md px-3 py-2 text-base font-medium flex justify-between items-center"> <button type="button" onclick="toggleDropdown(event, 'ledger-mobile-dropdown')" class="{{if eq .ActivePage "ledger"}}bg-indigo-700 text-white{{else}}text-indigo-200 hover:bg-indigo-500 hover:text-white{{end}} w-full text-left block rounded-md px-3 py-2 text-base font-medium flex justify-between items-center">
General Ledger General Ledger

@ -78,7 +78,8 @@
<nav class="isolate inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination"> <nav class="isolate inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination">
{{if .HasPrev}} {{if .HasPrev}}
<button <button
hx-get="/orders?page={{.PrevPage}}&status={{.FilterStatus}}&partial=true" hx-get="/orders?page={{.PrevPage}}{{if .FilterStatus}}&status={{.FilterStatus}}{{end}}&partial=true"
hx-push-url="/orders?page={{.PrevPage}}{{if .FilterStatus}}&status={{.FilterStatus}}{{end}}"
hx-target="#order-table" hx-target="#order-table"
class="relative inline-flex items-center rounded-l-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0"> class="relative inline-flex items-center rounded-l-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0">
<span class="sr-only">Previous</span> <span class="sr-only">Previous</span>
@ -89,7 +90,8 @@
{{end}} {{end}}
{{if .HasNext}} {{if .HasNext}}
<button <button
hx-get="/orders?page={{.NextPage}}&status={{.FilterStatus}}&partial=true" hx-get="/orders?page={{.NextPage}}{{if .FilterStatus}}&status={{.FilterStatus}}{{end}}&partial=true"
hx-push-url="/orders?page={{.NextPage}}{{if .FilterStatus}}&status={{.FilterStatus}}{{end}}"
hx-target="#order-table" hx-target="#order-table"
class="relative inline-flex items-center rounded-r-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0"> class="relative inline-flex items-center rounded-r-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0">
<span class="sr-only">Next</span> <span class="sr-only">Next</span>

54
templates/users/form.html Normal file

@ -0,0 +1,54 @@
{{define "content"}}
<div class="md:flex md:items-center md:justify-between mb-8">
<div class="min-w-0 flex-1">
<h2 class="text-2xl font-bold leading-7 text-gray-900 sm:truncate sm:text-3xl sm:tracking-tight">
{{if .IsNew}}New User{{else}}Edit User{{end}}
</h2>
</div>
</div>
<div class="overflow-hidden bg-white shadow sm:rounded-lg p-6 max-w-lg">
<form method="POST" action="/users{{if not .IsNew}}/{{.User.ID}}{{end}}">
{{if .Error}}
<div class="rounded-md bg-red-50 p-4 mb-6">
<div class="flex">
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">{{.Error}}</h3>
</div>
</div>
</div>
{{end}}
<div class="space-y-6">
<div>
<label for="username" class="block text-sm font-medium leading-6 text-gray-900">Username</label>
<div class="mt-2">
<input type="text" name="username" id="username" value="{{.User.Username}}" required class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6">
</div>
</div>
<div>
<label for="password" class="block text-sm font-medium leading-6 text-gray-900">Password</label>
<div class="mt-2">
<input type="password" name="password" id="password" required class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6">
</div>
</div>
<div>
<label for="role" class="block text-sm font-medium leading-6 text-gray-900">Role</label>
<div class="mt-2">
<select id="role" name="role" class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6">
<option value="user" {{if eq .User.Role "user"}}selected{{end}}>User</option>
<option value="admin" {{if eq .User.Role "admin"}}selected{{end}}>Admin</option>
</select>
</div>
</div>
</div>
<div class="mt-6 flex items-center justify-end gap-x-6">
<a href="/users" class="text-sm font-semibold leading-6 text-gray-900">Cancel</a>
<button type="submit" class="rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">Save</button>
</div>
</form>
</div>
{{end}}

37
templates/users/list.html Normal file

@ -0,0 +1,37 @@
{{define "content"}}
<div class="md:flex md:items-center md:justify-between mb-8">
<div class="min-w-0 flex-1">
<h2 class="text-2xl font-bold leading-7 text-gray-900 sm:truncate sm:text-3xl sm:tracking-tight">Users</h2>
</div>
<div class="mt-4 flex md:ml-4 md:mt-0">
<a href="/users/new" class="ml-3 inline-flex items-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">New User</a>
</div>
</div>
<div class="overflow-hidden bg-white shadow sm:rounded-lg">
<table class="min-w-full divide-y divide-gray-300">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6">Username</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Role</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Created At</th>
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6">
<span class="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{{range .Users}}
<tr>
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6">{{.Username}}</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">{{.Role}}</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">{{.CreatedAt | formatDateTime}}</td>
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
<button hx-delete="/users/{{.ID}}" hx-confirm="Are you sure you want to delete this user?" class="text-red-600 hover:text-red-900">Delete</button>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{end}}