inital commit

This commit is contained in:
Victor Broman 2026-02-06 17:35:29 +01:00
commit 5530cfcdfd
38 changed files with 3072 additions and 0 deletions

BIN
erp.db Normal file

Binary file not shown.

BIN
erp.db-shm Normal file

Binary file not shown.

BIN
erp.db-wal Normal file

Binary file not shown.

BIN
erp_system Executable file

Binary file not shown.

20
go.mod Normal file

@ -0,0 +1,20 @@
module erp_system
go 1.25.6
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/gorilla/sessions v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/sys v0.40.0 // indirect
modernc.org/libc v1.67.6 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.44.3 // indirect
)

29
go.sum Normal file

@ -0,0 +1,29 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.44.3 h1:+39JvV/HWMcYslAwRxHb8067w+2zowvFOUrOWIy9PjY=
modernc.org/sqlite v1.44.3/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=

@ -0,0 +1,33 @@
package database
import (
"database/sql"
"fmt"
"log"
_ "modernc.org/sqlite"
)
func Initialize(dbPath string) (*sql.DB, error) {
db, err := sql.Open("sqlite", dbPath+"?_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)")
if err != nil {
return nil, fmt.Errorf("opening database: %w", err)
}
if err := db.Ping(); err != nil {
return nil, fmt.Errorf("pinging database: %w", err)
}
db.SetMaxOpenConns(1) // SQLite handles one writer at a time
if err := runMigrations(db); err != nil {
return nil, fmt.Errorf("running migrations: %w", err)
}
if err := seedData(db); err != nil {
return nil, fmt.Errorf("seeding data: %w", err)
}
log.Println("Database initialized successfully")
return db, nil
}

@ -0,0 +1,164 @@
package database
import (
"database/sql"
"fmt"
"log"
"golang.org/x/crypto/bcrypt"
)
func runMigrations(db *sql.DB) error {
migrations := []string{
// Users table
`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
// Customers table
`CREATE TABLE IF NOT EXISTS customers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT NOT NULL DEFAULT '',
phone TEXT NOT NULL DEFAULT '',
address TEXT NOT NULL DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
// Orders table
`CREATE TABLE IF NOT EXISTS orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_id INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'draft',
order_date DATE NOT NULL DEFAULT (date('now')),
total_amount REAL NOT NULL DEFAULT 0,
notes TEXT NOT NULL DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (customer_id) REFERENCES customers(id)
)`,
// Order lines table
`CREATE TABLE IF NOT EXISTS order_lines (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER NOT NULL,
description TEXT NOT NULL,
quantity REAL NOT NULL DEFAULT 1,
unit_price REAL NOT NULL DEFAULT 0,
line_total REAL NOT NULL DEFAULT 0,
FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE
)`,
// Invoices table
`CREATE TABLE IF NOT EXISTS invoices (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER,
customer_id INTEGER NOT NULL,
invoice_number TEXT NOT NULL UNIQUE,
status TEXT NOT NULL DEFAULT 'pending',
amount REAL NOT NULL DEFAULT 0,
due_date DATE NOT NULL,
paid_date DATE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (order_id) REFERENCES orders(id),
FOREIGN KEY (customer_id) REFERENCES customers(id)
)`,
// GL Accounts table (Chart of Accounts)
`CREATE TABLE IF NOT EXISTS gl_accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
type TEXT NOT NULL,
balance REAL NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
// Journal Entries table
`CREATE TABLE IF NOT EXISTS journal_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entry_date DATE NOT NULL DEFAULT (date('now')),
description TEXT NOT NULL,
reference TEXT NOT NULL DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
// Journal Lines table
`CREATE TABLE IF NOT EXISTS journal_lines (
id INTEGER PRIMARY KEY AUTOINCREMENT,
journal_entry_id INTEGER NOT NULL,
account_id INTEGER NOT NULL,
debit REAL NOT NULL DEFAULT 0,
credit REAL NOT NULL DEFAULT 0,
FOREIGN KEY (journal_entry_id) REFERENCES journal_entries(id) ON DELETE CASCADE,
FOREIGN KEY (account_id) REFERENCES gl_accounts(id)
)`,
}
for i, m := range migrations {
if _, err := db.Exec(m); err != nil {
return fmt.Errorf("migration %d failed: %w", i, err)
}
}
log.Println("Migrations completed")
return nil
}
func seedData(db *sql.DB) error {
// Seed admin user if not exists
var count int
db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count)
if count == 0 {
hash, err := bcrypt.GenerateFromPassword([]byte("admin123"), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("hashing password: %w", err)
}
_, err = db.Exec("INSERT INTO users (username, password_hash) VALUES (?, ?)", "admin", string(hash))
if err != nil {
return fmt.Errorf("inserting admin user: %w", err)
}
log.Println("Seeded admin user (admin / admin123)")
}
// Seed Chart of Accounts if not exists
db.QueryRow("SELECT COUNT(*) FROM gl_accounts").Scan(&count)
if count == 0 {
accounts := []struct {
Code, Name, Type string
}{
// Assets
{"1000", "Cash", "asset"},
{"1100", "Accounts Receivable", "asset"},
{"1200", "Inventory", "asset"},
// Liabilities
{"2000", "Accounts Payable", "liability"},
{"2100", "Sales Tax Payable", "liability"},
// Equity
{"3000", "Owner's Equity", "equity"},
{"3100", "Retained Earnings", "equity"},
// Revenue
{"4000", "Sales Revenue", "revenue"},
{"4100", "Service Revenue", "revenue"},
// Expenses
{"5000", "Cost of Goods Sold", "expense"},
{"5100", "Salaries Expense", "expense"},
{"5200", "Rent Expense", "expense"},
{"5300", "Utilities Expense", "expense"},
}
for _, a := range accounts {
_, err := db.Exec("INSERT INTO gl_accounts (code, name, type) VALUES (?, ?, ?)", a.Code, a.Name, a.Type)
if err != nil {
return fmt.Errorf("inserting account %s: %w", a.Code, err)
}
}
log.Println("Seeded chart of accounts")
}
return nil
}

63
internal/handlers/auth.go Normal file

@ -0,0 +1,63 @@
package handlers
import (
"html/template"
"net/http"
"path/filepath"
"erp_system/internal/models"
)
func (h *Handler) LoginPage(w http.ResponseWriter, r *http.Request) {
// If already logged in, redirect to dashboard
session, _ := h.Store.Get(r, "erp-session")
if session.Values["user_id"] != nil {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
tmpl, err := template.ParseFiles(filepath.Join("templates", "login.html"))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
tmpl.Execute(w, map[string]interface{}{"Error": ""})
}
func (h *Handler) LoginSubmit(w http.ResponseWriter, r *http.Request) {
username := r.FormValue("username")
password := r.FormValue("password")
user, err := models.Authenticate(h.DB, username, password)
if err != nil {
tmpl, _ := template.ParseFiles(filepath.Join("templates", "login.html"))
tmpl.Execute(w, map[string]interface{}{"Error": "Invalid username or password"})
return
}
session, _ := h.Store.Get(r, "erp-session")
session.Values["user_id"] = user.ID
session.Values["username"] = user.Username
session.Save(r, w)
// HTMX redirect
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("HX-Redirect", "/")
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
session, _ := h.Store.Get(r, "erp-session")
session.Values["user_id"] = nil
session.Values["username"] = nil
session.Options.MaxAge = -1
session.Save(r, w)
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("HX-Redirect", "/login")
return
}
http.Redirect(w, r, "/login", http.StatusSeeOther)
}

@ -0,0 +1,175 @@
package handlers
import (
"net/http"
"strconv"
"erp_system/internal/models"
)
func (h *Handler) CustomerList(w http.ResponseWriter, r *http.Request) {
search := r.URL.Query().Get("search")
customers, err := models.CustomerGetAll(h.DB, search)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
data := map[string]interface{}{
"Title": "Customers",
"Username": h.getUsername(r),
"ActivePage": "customers",
"Customers": customers,
"Search": search,
}
// HTMX partial for search
if r.Header.Get("HX-Request") == "true" && r.URL.Query().Get("partial") == "true" {
h.renderPartial(w, "customers/list.html", "customer-table", data)
return
}
h.render(w, []string{"layout.html", "customers/list.html"}, data)
}
func (h *Handler) CustomerNew(w http.ResponseWriter, r *http.Request) {
data := map[string]interface{}{
"Title": "New Customer",
"Username": h.getUsername(r),
"ActivePage": "customers",
"Customer": &models.Customer{},
"IsNew": true,
}
h.render(w, []string{"layout.html", "customers/form.html"}, data)
}
func (h *Handler) CustomerCreate(w http.ResponseWriter, r *http.Request) {
c := &models.Customer{
Name: r.FormValue("name"),
Email: r.FormValue("email"),
Phone: r.FormValue("phone"),
Address: r.FormValue("address"),
}
if c.Name == "" {
data := map[string]interface{}{
"Title": "New Customer",
"Username": h.getUsername(r),
"ActivePage": "customers",
"Customer": c,
"IsNew": true,
"Error": "Name is required",
}
h.render(w, []string{"layout.html", "customers/form.html"}, data)
return
}
if err := models.CustomerInsert(h.DB, c); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("HX-Redirect", "/customers/"+strconv.Itoa(c.ID))
return
}
http.Redirect(w, r, "/customers/"+strconv.Itoa(c.ID), http.StatusSeeOther)
}
func (h *Handler) CustomerDetail(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.Atoi(r.PathValue("id"))
customer, err := models.CustomerGetByID(h.DB, id)
if err != nil {
http.Error(w, "Customer not found", http.StatusNotFound)
return
}
// Get customer's orders
orders, _ := models.OrderGetAll(h.DB, "")
var customerOrders []models.Order
for _, o := range orders {
if o.CustomerID == id {
customerOrders = append(customerOrders, o)
}
}
// Get customer's invoices
invoices, _ := models.InvoiceGetByCustomerID(h.DB, id)
data := map[string]interface{}{
"Title": customer.Name,
"Username": h.getUsername(r),
"ActivePage": "customers",
"Customer": customer,
"Orders": customerOrders,
"Invoices": invoices,
}
h.render(w, []string{"layout.html", "customers/detail.html"}, data)
}
func (h *Handler) CustomerEdit(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.Atoi(r.PathValue("id"))
customer, err := models.CustomerGetByID(h.DB, id)
if err != nil {
http.Error(w, "Customer not found", http.StatusNotFound)
return
}
data := map[string]interface{}{
"Title": "Edit " + customer.Name,
"Username": h.getUsername(r),
"ActivePage": "customers",
"Customer": customer,
"IsNew": false,
}
h.render(w, []string{"layout.html", "customers/form.html"}, data)
}
func (h *Handler) CustomerUpdate(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.Atoi(r.PathValue("id"))
c := &models.Customer{
ID: id,
Name: r.FormValue("name"),
Email: r.FormValue("email"),
Phone: r.FormValue("phone"),
Address: r.FormValue("address"),
}
if c.Name == "" {
data := map[string]interface{}{
"Title": "Edit Customer",
"Username": h.getUsername(r),
"ActivePage": "customers",
"Customer": c,
"IsNew": false,
"Error": "Name is required",
}
h.render(w, []string{"layout.html", "customers/form.html"}, data)
return
}
if err := models.CustomerUpdate(h.DB, c); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("HX-Redirect", "/customers/"+strconv.Itoa(id))
return
}
http.Redirect(w, r, "/customers/"+strconv.Itoa(id), http.StatusSeeOther)
}
func (h *Handler) CustomerDelete(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.Atoi(r.PathValue("id"))
if err := models.CustomerDelete(h.DB, id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("HX-Redirect", "/customers")
return
}
http.Redirect(w, r, "/customers", http.StatusSeeOther)
}

@ -0,0 +1,23 @@
package handlers
import (
"net/http"
"erp_system/internal/models"
)
func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
data := map[string]interface{}{
"Title": "Dashboard",
"Username": h.getUsername(r),
"ActivePage": "dashboard",
"CustomerCount": models.CustomerCount(h.DB),
"OpenOrderCount": models.OrderCountByStatus(h.DB, "draft") + models.OrderCountByStatus(h.DB, "confirmed"),
"PendingInvoices": models.InvoiceCountByStatus(h.DB, "pending"),
"OutstandingAmount": models.InvoiceTotalOutstanding(h.DB),
"RevenueThisMonth": models.RevenueThisMonth(h.DB),
"FulfilledOrders": models.OrderCountByStatus(h.DB, "fulfilled"),
}
h.render(w, []string{"layout.html", "dashboard.html"}, data)
}

@ -0,0 +1,108 @@
package handlers
import (
"database/sql"
"fmt"
"html/template"
"net/http"
"path/filepath"
"time"
"github.com/gorilla/sessions"
)
type Handler struct {
DB *sql.DB
Store *sessions.CookieStore
}
// Template helper functions
var funcMap = template.FuncMap{
"formatMoney": func(amount float64) string {
return fmt.Sprintf("$%.2f", amount)
},
"formatDate": func(date string) string {
t, err := time.Parse("2006-01-02", date)
if err != nil {
return date
}
return t.Format("Jan 02, 2006")
},
"statusBadge": func(status string) template.HTML {
colors := map[string]string{
"draft": "bg-gray-100 text-gray-800",
"confirmed": "bg-blue-100 text-blue-800",
"fulfilled": "bg-green-100 text-green-800",
"cancelled": "bg-red-100 text-red-800",
"pending": "bg-yellow-100 text-yellow-800",
"paid": "bg-green-100 text-green-800",
"overdue": "bg-red-100 text-red-800",
}
color, ok := colors[status]
if !ok {
color = "bg-gray-100 text-gray-800"
}
return template.HTML(fmt.Sprintf(`<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium %s">%s</span>`, color, status))
},
"capitalize": func(s string) string {
if len(s) == 0 {
return s
}
return string(s[0]-32) + s[1:]
},
"accountTypeBadge": func(t string) template.HTML {
colors := map[string]string{
"asset": "bg-blue-100 text-blue-800",
"liability": "bg-red-100 text-red-800",
"equity": "bg-purple-100 text-purple-800",
"revenue": "bg-green-100 text-green-800",
"expense": "bg-orange-100 text-orange-800",
}
color, ok := colors[t]
if !ok {
color = "bg-gray-100 text-gray-800"
}
return template.HTML(fmt.Sprintf(`<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium %s">%s</span>`, color, t))
},
"sub": func(a, b float64) float64 {
return a - b
},
}
func (h *Handler) render(w http.ResponseWriter, templates []string, data interface{}) {
paths := make([]string, len(templates))
for i, t := range templates {
paths[i] = filepath.Join("templates", t)
}
tmpl, err := template.New("").Funcs(funcMap).ParseFiles(paths...)
if err != nil {
http.Error(w, fmt.Sprintf("Template error: %v", err), http.StatusInternalServerError)
return
}
if err := tmpl.ExecuteTemplate(w, "layout", data); err != nil {
http.Error(w, fmt.Sprintf("Render error: %v", err), http.StatusInternalServerError)
}
}
func (h *Handler) renderPartial(w http.ResponseWriter, templateFile string, templateName string, data interface{}) {
path := filepath.Join("templates", templateFile)
tmpl, err := template.New("").Funcs(funcMap).ParseFiles(path)
if err != nil {
http.Error(w, fmt.Sprintf("Template error: %v", err), http.StatusInternalServerError)
return
}
if err := tmpl.ExecuteTemplate(w, templateName, data); err != nil {
http.Error(w, fmt.Sprintf("Render error: %v", err), http.StatusInternalServerError)
}
}
func (h *Handler) getUsername(r *http.Request) string {
session, _ := h.Store.Get(r, "erp-session")
if username, ok := session.Values["username"].(string); ok {
return username
}
return ""
}

@ -0,0 +1,64 @@
package handlers
import (
"fmt"
"net/http"
"strconv"
"erp_system/internal/models"
)
func (h *Handler) InvoiceList(w http.ResponseWriter, r *http.Request) {
status := r.URL.Query().Get("status")
invoices, err := models.InvoiceGetAll(h.DB, status)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
data := map[string]interface{}{
"Title": "Invoices",
"Username": h.getUsername(r),
"ActivePage": "invoices",
"Invoices": invoices,
"FilterStatus": status,
}
if r.Header.Get("HX-Request") == "true" && r.URL.Query().Get("partial") == "true" {
h.renderPartial(w, "invoices/list.html", "invoice-table", data)
return
}
h.render(w, []string{"layout.html", "invoices/list.html"}, data)
}
func (h *Handler) InvoiceDetail(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.Atoi(r.PathValue("id"))
invoice, err := models.InvoiceGetByID(h.DB, id)
if err != nil {
http.Error(w, "Invoice not found", http.StatusNotFound)
return
}
data := map[string]interface{}{
"Title": invoice.InvoiceNumber,
"Username": h.getUsername(r),
"ActivePage": "invoices",
"Invoice": invoice,
}
h.render(w, []string{"layout.html", "invoices/detail.html"}, data)
}
func (h *Handler) InvoicePay(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.Atoi(r.PathValue("id"))
if err := models.InvoiceMarkPaid(h.DB, id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("HX-Redirect", fmt.Sprintf("/invoices/%d", id))
return
}
http.Redirect(w, r, fmt.Sprintf("/invoices/%d", id), http.StatusSeeOther)
}

160
internal/handlers/ledger.go Normal file

@ -0,0 +1,160 @@
package handlers
import (
"net/http"
"strconv"
"time"
"erp_system/internal/models"
)
func (h *Handler) ChartOfAccounts(w http.ResponseWriter, r *http.Request) {
accounts, err := models.GLAccountGetAll(h.DB)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
data := map[string]interface{}{
"Title": "Chart of Accounts",
"Username": h.getUsername(r),
"ActivePage": "ledger",
"Accounts": accounts,
}
h.render(w, []string{"layout.html", "ledger/chart_of_accounts.html"}, data)
}
func (h *Handler) JournalEntries(w http.ResponseWriter, r *http.Request) {
entries, err := models.JournalEntryGetAll(h.DB)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
data := map[string]interface{}{
"Title": "Journal Entries",
"Username": h.getUsername(r),
"ActivePage": "ledger",
"Entries": entries,
}
h.render(w, []string{"layout.html", "ledger/journal_entries.html"}, data)
}
func (h *Handler) JournalEntryNew(w http.ResponseWriter, r *http.Request) {
accounts, _ := models.GLAccountGetAll(h.DB)
data := map[string]interface{}{
"Title": "New Journal Entry",
"Username": h.getUsername(r),
"ActivePage": "ledger",
"Accounts": accounts,
"Today": time.Now().Format("2006-01-02"),
}
h.render(w, []string{"layout.html", "ledger/journal_entry_form.html"}, data)
}
func (h *Handler) JournalEntryCreate(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
je := &models.JournalEntry{
EntryDate: r.FormValue("entry_date"),
Description: r.FormValue("description"),
Reference: r.FormValue("reference"),
}
// Parse lines from form
accountIDs := r.Form["account_id"]
debits := r.Form["debit"]
credits := r.Form["credit"]
for i := range accountIDs {
accID, _ := strconv.Atoi(accountIDs[i])
debit, _ := strconv.ParseFloat(debits[i], 64)
credit, _ := strconv.ParseFloat(credits[i], 64)
if accID == 0 || (debit == 0 && credit == 0) {
continue
}
je.Lines = append(je.Lines, models.JournalLine{
AccountID: accID,
Debit: debit,
Credit: credit,
})
}
if len(je.Lines) < 2 {
accounts, _ := models.GLAccountGetAll(h.DB)
data := map[string]interface{}{
"Title": "New Journal Entry",
"Username": h.getUsername(r),
"ActivePage": "ledger",
"Accounts": accounts,
"Today": time.Now().Format("2006-01-02"),
"Error": "At least two lines are required",
}
h.render(w, []string{"layout.html", "ledger/journal_entry_form.html"}, data)
return
}
if err := models.JournalEntryInsert(h.DB, je); err != nil {
accounts, _ := models.GLAccountGetAll(h.DB)
data := map[string]interface{}{
"Title": "New Journal Entry",
"Username": h.getUsername(r),
"ActivePage": "ledger",
"Accounts": accounts,
"Today": time.Now().Format("2006-01-02"),
"Error": err.Error(),
}
h.render(w, []string{"layout.html", "ledger/journal_entry_form.html"}, data)
return
}
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("HX-Redirect", "/ledger/journal")
return
}
http.Redirect(w, r, "/ledger/journal", http.StatusSeeOther)
}
func (h *Handler) JournalEntryDetail(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.Atoi(r.PathValue("id"))
entry, err := models.JournalEntryGetByID(h.DB, id)
if err != nil {
http.Error(w, "Journal entry not found", http.StatusNotFound)
return
}
data := map[string]interface{}{
"Title": "Journal Entry #" + strconv.Itoa(id),
"Username": h.getUsername(r),
"ActivePage": "ledger",
"Entry": entry,
}
h.render(w, []string{"layout.html", "ledger/journal_entry_detail.html"}, data)
}
func (h *Handler) TrialBalance(w http.ResponseWriter, r *http.Request) {
rows, err := models.GetTrialBalance(h.DB)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
var totalDebit, totalCredit float64
for _, row := range rows {
totalDebit += row.Debit
totalCredit += row.Credit
}
data := map[string]interface{}{
"Title": "Trial Balance",
"Username": h.getUsername(r),
"ActivePage": "ledger",
"Rows": rows,
"TotalDebit": totalDebit,
"TotalCredit": totalCredit,
}
h.render(w, []string{"layout.html", "ledger/trial_balance.html"}, data)
}

271
internal/handlers/orders.go Normal file

@ -0,0 +1,271 @@
package handlers
import (
"fmt"
"net/http"
"strconv"
"time"
"erp_system/internal/models"
)
func (h *Handler) OrderList(w http.ResponseWriter, r *http.Request) {
status := r.URL.Query().Get("status")
orders, err := models.OrderGetAll(h.DB, status)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
data := map[string]interface{}{
"Title": "Orders",
"Username": h.getUsername(r),
"ActivePage": "orders",
"Orders": orders,
"FilterStatus": status,
}
if r.Header.Get("HX-Request") == "true" && r.URL.Query().Get("partial") == "true" {
h.renderPartial(w, "orders/list.html", "order-table", data)
return
}
h.render(w, []string{"layout.html", "orders/list.html"}, data)
}
func (h *Handler) OrderNew(w http.ResponseWriter, r *http.Request) {
customers, _ := models.CustomerGetAll(h.DB, "")
data := map[string]interface{}{
"Title": "New Order",
"Username": h.getUsername(r),
"ActivePage": "orders",
"Order": &models.Order{OrderDate: time.Now().Format("2006-01-02"), Status: "draft"},
"Customers": customers,
"IsNew": true,
}
h.render(w, []string{"layout.html", "orders/form.html"}, data)
}
func (h *Handler) OrderCreate(w http.ResponseWriter, r *http.Request) {
customerID, _ := strconv.Atoi(r.FormValue("customer_id"))
o := &models.Order{
CustomerID: customerID,
Status: "draft",
OrderDate: r.FormValue("order_date"),
Notes: r.FormValue("notes"),
}
if o.CustomerID == 0 {
customers, _ := models.CustomerGetAll(h.DB, "")
data := map[string]interface{}{
"Title": "New Order",
"Username": h.getUsername(r),
"ActivePage": "orders",
"Order": o,
"Customers": customers,
"IsNew": true,
"Error": "Customer is required",
}
h.render(w, []string{"layout.html", "orders/form.html"}, data)
return
}
if o.OrderDate == "" {
o.OrderDate = time.Now().Format("2006-01-02")
}
if err := models.OrderInsert(h.DB, o); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("HX-Redirect", fmt.Sprintf("/orders/%d", o.ID))
return
}
http.Redirect(w, r, fmt.Sprintf("/orders/%d", o.ID), http.StatusSeeOther)
}
func (h *Handler) OrderDetail(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.Atoi(r.PathValue("id"))
order, err := models.OrderGetByID(h.DB, id)
if err != nil {
http.Error(w, "Order not found", http.StatusNotFound)
return
}
data := map[string]interface{}{
"Title": fmt.Sprintf("Order #%d", order.ID),
"Username": h.getUsername(r),
"ActivePage": "orders",
"Order": order,
}
h.render(w, []string{"layout.html", "orders/detail.html"}, data)
}
func (h *Handler) OrderEdit(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.Atoi(r.PathValue("id"))
order, err := models.OrderGetByID(h.DB, id)
if err != nil {
http.Error(w, "Order not found", http.StatusNotFound)
return
}
if order.Status != "draft" {
http.Error(w, "Only draft orders can be edited", http.StatusBadRequest)
return
}
customers, _ := models.CustomerGetAll(h.DB, "")
data := map[string]interface{}{
"Title": fmt.Sprintf("Edit Order #%d", order.ID),
"Username": h.getUsername(r),
"ActivePage": "orders",
"Order": order,
"Customers": customers,
"IsNew": false,
}
h.render(w, []string{"layout.html", "orders/form.html"}, data)
}
func (h *Handler) OrderUpdate(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.Atoi(r.PathValue("id"))
customerID, _ := strconv.Atoi(r.FormValue("customer_id"))
o := &models.Order{
ID: id,
CustomerID: customerID,
OrderDate: r.FormValue("order_date"),
Notes: r.FormValue("notes"),
}
if err := models.OrderUpdate(h.DB, o); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("HX-Redirect", fmt.Sprintf("/orders/%d", id))
return
}
http.Redirect(w, r, fmt.Sprintf("/orders/%d", id), http.StatusSeeOther)
}
func (h *Handler) OrderConfirm(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.Atoi(r.PathValue("id"))
if err := models.OrderUpdateStatus(h.DB, id, "confirmed"); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("HX-Redirect", fmt.Sprintf("/orders/%d", id))
return
}
http.Redirect(w, r, fmt.Sprintf("/orders/%d", id), http.StatusSeeOther)
}
func (h *Handler) OrderFulfill(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.Atoi(r.PathValue("id"))
// Update status
if err := models.OrderUpdateStatus(h.DB, id, "fulfilled"); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Generate invoice and GL entries
if err := models.GenerateInvoiceFromOrder(h.DB, id); err != nil {
http.Error(w, fmt.Sprintf("Order fulfilled but invoice generation failed: %v", err), http.StatusInternalServerError)
return
}
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("HX-Redirect", fmt.Sprintf("/orders/%d", id))
return
}
http.Redirect(w, r, fmt.Sprintf("/orders/%d", id), http.StatusSeeOther)
}
func (h *Handler) OrderCancel(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.Atoi(r.PathValue("id"))
if err := models.OrderUpdateStatus(h.DB, id, "cancelled"); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("HX-Redirect", fmt.Sprintf("/orders/%d", id))
return
}
http.Redirect(w, r, fmt.Sprintf("/orders/%d", id), http.StatusSeeOther)
}
func (h *Handler) OrderDelete(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.Atoi(r.PathValue("id"))
if err := models.OrderDelete(h.DB, id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("HX-Redirect", "/orders")
return
}
http.Redirect(w, r, "/orders", http.StatusSeeOther)
}
// Order line handlers (HTMX partials)
func (h *Handler) OrderLineAdd(w http.ResponseWriter, r *http.Request) {
orderID, _ := strconv.Atoi(r.PathValue("id"))
qty, _ := strconv.ParseFloat(r.FormValue("quantity"), 64)
price, _ := strconv.ParseFloat(r.FormValue("unit_price"), 64)
line := &models.OrderLine{
OrderID: orderID,
Description: r.FormValue("description"),
Quantity: qty,
UnitPrice: price,
}
if line.Description == "" || line.Quantity <= 0 {
http.Error(w, "Description and quantity are required", http.StatusBadRequest)
return
}
if err := models.OrderLineInsert(h.DB, line); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Return updated order detail
order, err := models.OrderGetByID(h.DB, orderID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
h.renderPartial(w, "orders/detail.html", "order-lines", order)
}
func (h *Handler) OrderLineDelete(w http.ResponseWriter, r *http.Request) {
orderID, _ := strconv.Atoi(r.PathValue("id"))
lineID, _ := strconv.Atoi(r.PathValue("lineID"))
if err := models.OrderLineDelete(h.DB, lineID, orderID); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Return updated order detail
order, err := models.OrderGetByID(h.DB, orderID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
h.renderPartial(w, "orders/detail.html", "order-lines", order)
}

@ -0,0 +1,27 @@
package middleware
import (
"net/http"
"github.com/gorilla/sessions"
)
func RequireAuth(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")
userID, ok := session.Values["user_id"]
if !ok || userID == nil {
// Check if this is an HTMX request
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("HX-Redirect", "/login")
w.WriteHeader(http.StatusUnauthorized)
return
}
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
next.ServeHTTP(w, r)
})
}
}

@ -0,0 +1,86 @@
package models
import (
"database/sql"
"time"
)
type Customer struct {
ID int
Name string
Email string
Phone string
Address string
CreatedAt time.Time
UpdatedAt time.Time
}
func CustomerGetAll(db *sql.DB, search string) ([]Customer, error) {
query := "SELECT id, name, email, phone, address, created_at, updated_at FROM customers"
args := []interface{}{}
if search != "" {
query += " WHERE name LIKE ? OR email LIKE ?"
s := "%" + search + "%"
args = append(args, s, s)
}
query += " ORDER BY name"
rows, err := db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var customers []Customer
for rows.Next() {
var c Customer
if err := rows.Scan(&c.ID, &c.Name, &c.Email, &c.Phone, &c.Address, &c.CreatedAt, &c.UpdatedAt); err != nil {
return nil, err
}
customers = append(customers, c)
}
return customers, nil
}
func CustomerGetByID(db *sql.DB, id int) (*Customer, error) {
c := &Customer{}
err := db.QueryRow(
"SELECT id, name, email, phone, address, created_at, updated_at FROM customers WHERE id = ?", id,
).Scan(&c.ID, &c.Name, &c.Email, &c.Phone, &c.Address, &c.CreatedAt, &c.UpdatedAt)
if err != nil {
return nil, err
}
return c, nil
}
func CustomerInsert(db *sql.DB, c *Customer) error {
result, err := db.Exec(
"INSERT INTO customers (name, email, phone, address) VALUES (?, ?, ?, ?)",
c.Name, c.Email, c.Phone, c.Address,
)
if err != nil {
return err
}
id, _ := result.LastInsertId()
c.ID = int(id)
return nil
}
func CustomerUpdate(db *sql.DB, c *Customer) error {
_, err := db.Exec(
"UPDATE customers SET name = ?, email = ?, phone = ?, address = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
c.Name, c.Email, c.Phone, c.Address, c.ID,
)
return err
}
func CustomerDelete(db *sql.DB, id int) error {
_, err := db.Exec("DELETE FROM customers WHERE id = ?", id)
return err
}
func CustomerCount(db *sql.DB) int {
var count int
db.QueryRow("SELECT COUNT(*) FROM customers").Scan(&count)
return count
}

132
internal/models/invoice.go Normal file

@ -0,0 +1,132 @@
package models
import (
"database/sql"
"fmt"
"time"
)
type Invoice struct {
ID int
OrderID sql.NullInt64
CustomerID int
CustomerName string
InvoiceNumber string
Status string
Amount float64
DueDate string
PaidDate sql.NullString
CreatedAt time.Time
}
func InvoiceGetAll(db *sql.DB, status string) ([]Invoice, error) {
query := `SELECT i.id, i.order_id, i.customer_id, c.name, i.invoice_number, i.status, i.amount, i.due_date, i.paid_date, i.created_at
FROM invoices i JOIN customers c ON i.customer_id = c.id`
args := []interface{}{}
if status != "" {
query += " WHERE i.status = ?"
args = append(args, status)
}
query += " ORDER BY i.created_at DESC"
rows, err := db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var invoices []Invoice
for rows.Next() {
var inv Invoice
if err := rows.Scan(&inv.ID, &inv.OrderID, &inv.CustomerID, &inv.CustomerName, &inv.InvoiceNumber, &inv.Status, &inv.Amount, &inv.DueDate, &inv.PaidDate, &inv.CreatedAt); err != nil {
return nil, err
}
invoices = append(invoices, inv)
}
return invoices, nil
}
func InvoiceGetByID(db *sql.DB, id int) (*Invoice, error) {
inv := &Invoice{}
err := db.QueryRow(
`SELECT i.id, i.order_id, i.customer_id, c.name, i.invoice_number, i.status, i.amount, i.due_date, i.paid_date, i.created_at
FROM invoices i JOIN customers c ON i.customer_id = c.id WHERE i.id = ?`, id,
).Scan(&inv.ID, &inv.OrderID, &inv.CustomerID, &inv.CustomerName, &inv.InvoiceNumber, &inv.Status, &inv.Amount, &inv.DueDate, &inv.PaidDate, &inv.CreatedAt)
if err != nil {
return nil, err
}
return inv, nil
}
func InvoiceMarkPaid(db *sql.DB, id int) error {
inv, err := InvoiceGetByID(db, id)
if err != nil {
return err
}
// Update invoice status
_, err = db.Exec("UPDATE invoices SET status = 'paid', paid_date = date('now') WHERE id = ?", id)
if err != nil {
return err
}
// Create GL journal entry: Debit Cash, Credit AR
result, err := db.Exec(
"INSERT INTO journal_entries (entry_date, description, reference) VALUES (date('now'), ?, ?)",
fmt.Sprintf("Payment received for %s", inv.InvoiceNumber),
inv.InvoiceNumber,
)
if err != nil {
return err
}
jeID, _ := result.LastInsertId()
// Debit Cash (1000)
var cashID int
db.QueryRow("SELECT id FROM gl_accounts WHERE code = '1000'").Scan(&cashID)
db.Exec("INSERT INTO journal_lines (journal_entry_id, account_id, debit, credit) VALUES (?, ?, ?, 0)", jeID, cashID, inv.Amount)
// Credit AR (1100)
var arID int
db.QueryRow("SELECT id FROM gl_accounts WHERE code = '1100'").Scan(&arID)
db.Exec("INSERT INTO journal_lines (journal_entry_id, account_id, debit, credit) VALUES (?, ?, 0, ?)", jeID, arID, inv.Amount)
// Update account balances
db.Exec("UPDATE gl_accounts SET balance = balance + ? WHERE code = '1000'", inv.Amount) // Cash increases
db.Exec("UPDATE gl_accounts SET balance = balance - ? WHERE code = '1100'", inv.Amount) // AR decreases
return nil
}
func InvoiceCountByStatus(db *sql.DB, status string) int {
var count int
db.QueryRow("SELECT COUNT(*) FROM invoices WHERE status = ?", status).Scan(&count)
return count
}
func InvoiceTotalOutstanding(db *sql.DB) float64 {
var total float64
db.QueryRow("SELECT COALESCE(SUM(amount), 0) FROM invoices WHERE status = 'pending'").Scan(&total)
return total
}
func InvoiceGetByCustomerID(db *sql.DB, customerID int) ([]Invoice, error) {
rows, err := db.Query(
`SELECT i.id, i.order_id, i.customer_id, c.name, i.invoice_number, i.status, i.amount, i.due_date, i.paid_date, i.created_at
FROM invoices i JOIN customers c ON i.customer_id = c.id WHERE i.customer_id = ? ORDER BY i.created_at DESC`, customerID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var invoices []Invoice
for rows.Next() {
var inv Invoice
if err := rows.Scan(&inv.ID, &inv.OrderID, &inv.CustomerID, &inv.CustomerName, &inv.InvoiceNumber, &inv.Status, &inv.Amount, &inv.DueDate, &inv.PaidDate, &inv.CreatedAt); err != nil {
return nil, err
}
invoices = append(invoices, inv)
}
return invoices, nil
}

219
internal/models/ledger.go Normal file

@ -0,0 +1,219 @@
package models
import (
"database/sql"
"fmt"
"time"
)
type GLAccount struct {
ID int
Code string
Name string
Type string
Balance float64
CreatedAt time.Time
}
type JournalEntry struct {
ID int
EntryDate string
Description string
Reference string
CreatedAt time.Time
Lines []JournalLine
TotalDebit float64
TotalCredit float64
}
type JournalLine struct {
ID int
JournalEntryID int
AccountID int
AccountCode string // joined
AccountName string // joined
Debit float64
Credit float64
}
type TrialBalanceRow struct {
Code string
Name string
Type string
Debit float64
Credit float64
}
func GLAccountGetAll(db *sql.DB) ([]GLAccount, error) {
rows, err := db.Query("SELECT id, code, name, type, balance, created_at FROM gl_accounts ORDER BY code")
if err != nil {
return nil, err
}
defer rows.Close()
var accounts []GLAccount
for rows.Next() {
var a GLAccount
if err := rows.Scan(&a.ID, &a.Code, &a.Name, &a.Type, &a.Balance, &a.CreatedAt); err != nil {
return nil, err
}
accounts = append(accounts, a)
}
return accounts, nil
}
func GLAccountGetByID(db *sql.DB, id int) (*GLAccount, error) {
a := &GLAccount{}
err := db.QueryRow("SELECT id, code, name, type, balance, created_at FROM gl_accounts WHERE id = ?", id).
Scan(&a.ID, &a.Code, &a.Name, &a.Type, &a.Balance, &a.CreatedAt)
if err != nil {
return nil, err
}
return a, nil
}
func JournalEntryGetAll(db *sql.DB) ([]JournalEntry, error) {
rows, err := db.Query("SELECT id, entry_date, description, reference, created_at FROM journal_entries ORDER BY created_at DESC")
if err != nil {
return nil, err
}
defer rows.Close()
var entries []JournalEntry
for rows.Next() {
var je JournalEntry
if err := rows.Scan(&je.ID, &je.EntryDate, &je.Description, &je.Reference, &je.CreatedAt); err != nil {
return nil, err
}
// Get totals
db.QueryRow("SELECT COALESCE(SUM(debit), 0), COALESCE(SUM(credit), 0) FROM journal_lines WHERE journal_entry_id = ?", je.ID).
Scan(&je.TotalDebit, &je.TotalCredit)
entries = append(entries, je)
}
return entries, nil
}
func JournalEntryGetByID(db *sql.DB, id int) (*JournalEntry, error) {
je := &JournalEntry{}
err := db.QueryRow("SELECT id, entry_date, description, reference, created_at FROM journal_entries WHERE id = ?", id).
Scan(&je.ID, &je.EntryDate, &je.Description, &je.Reference, &je.CreatedAt)
if err != nil {
return nil, err
}
// Load lines
rows, err := db.Query(
`SELECT jl.id, jl.journal_entry_id, jl.account_id, a.code, a.name, jl.debit, jl.credit
FROM journal_lines jl JOIN gl_accounts a ON jl.account_id = a.id
WHERE jl.journal_entry_id = ? ORDER BY jl.id`, id,
)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var l JournalLine
if err := rows.Scan(&l.ID, &l.JournalEntryID, &l.AccountID, &l.AccountCode, &l.AccountName, &l.Debit, &l.Credit); err != nil {
return nil, err
}
je.TotalDebit += l.Debit
je.TotalCredit += l.Credit
je.Lines = append(je.Lines, l)
}
return je, nil
}
// JournalEntryInsert creates a journal entry with its lines. Validates debits == credits.
func JournalEntryInsert(db *sql.DB, je *JournalEntry) error {
var totalDebit, totalCredit float64
for _, l := range je.Lines {
totalDebit += l.Debit
totalCredit += l.Credit
}
// Allow small floating point differences
diff := totalDebit - totalCredit
if diff > 0.01 || diff < -0.01 {
return fmt.Errorf("debits (%.2f) must equal credits (%.2f)", totalDebit, totalCredit)
}
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
result, err := tx.Exec(
"INSERT INTO journal_entries (entry_date, description, reference) VALUES (?, ?, ?)",
je.EntryDate, je.Description, je.Reference,
)
if err != nil {
return err
}
jeID, _ := result.LastInsertId()
je.ID = int(jeID)
for _, l := range je.Lines {
_, err := tx.Exec(
"INSERT INTO journal_lines (journal_entry_id, account_id, debit, credit) VALUES (?, ?, ?, ?)",
jeID, l.AccountID, l.Debit, l.Credit,
)
if err != nil {
return err
}
// Update account balance
// For assets/expenses: balance increases with debit, decreases with credit
// For liabilities/equity/revenue: balance increases with credit, decreases with debit
netEffect := l.Debit - l.Credit
_, err = tx.Exec("UPDATE gl_accounts SET balance = balance + ? WHERE id = ?", netEffect, l.AccountID)
if err != nil {
return err
}
}
return tx.Commit()
}
func GetTrialBalance(db *sql.DB) ([]TrialBalanceRow, error) {
rows, err := db.Query(`
SELECT a.code, a.name, a.type,
COALESCE(SUM(jl.debit), 0) as total_debit,
COALESCE(SUM(jl.credit), 0) as total_credit
FROM gl_accounts a
LEFT JOIN journal_lines jl ON a.id = jl.account_id
GROUP BY a.id, a.code, a.name, a.type
ORDER BY a.code
`)
if err != nil {
return nil, err
}
defer rows.Close()
var result []TrialBalanceRow
for rows.Next() {
var r TrialBalanceRow
if err := rows.Scan(&r.Code, &r.Name, &r.Type, &r.Debit, &r.Credit); err != nil {
return nil, err
}
result = append(result, r)
}
return result, nil
}
func RevenueThisMonth(db *sql.DB) float64 {
var total float64
db.QueryRow(`
SELECT COALESCE(SUM(jl.credit), 0)
FROM journal_lines jl
JOIN gl_accounts a ON jl.account_id = a.id
JOIN journal_entries je ON jl.journal_entry_id = je.id
WHERE a.type = 'revenue'
AND je.entry_date >= date('now', 'start of month')
`).Scan(&total)
return total
}

224
internal/models/order.go Normal file

@ -0,0 +1,224 @@
package models
import (
"database/sql"
"fmt"
"time"
)
type Order struct {
ID int
CustomerID int
CustomerName string // joined field
Status string
OrderDate string
TotalAmount float64
Notes string
CreatedAt time.Time
UpdatedAt time.Time
Lines []OrderLine
}
type OrderLine struct {
ID int
OrderID int
Description string
Quantity float64
UnitPrice float64
LineTotal float64
}
func OrderGetAll(db *sql.DB, status string) ([]Order, error) {
query := `SELECT o.id, o.customer_id, c.name, o.status, o.order_date, o.total_amount, o.notes, o.created_at, o.updated_at
FROM orders o JOIN customers c ON o.customer_id = c.id`
args := []interface{}{}
if status != "" {
query += " WHERE o.status = ?"
args = append(args, status)
}
query += " ORDER BY o.created_at DESC"
rows, err := db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var orders []Order
for rows.Next() {
var o Order
if err := rows.Scan(&o.ID, &o.CustomerID, &o.CustomerName, &o.Status, &o.OrderDate, &o.TotalAmount, &o.Notes, &o.CreatedAt, &o.UpdatedAt); err != nil {
return nil, err
}
orders = append(orders, o)
}
return orders, nil
}
func OrderGetByID(db *sql.DB, id int) (*Order, error) {
o := &Order{}
err := db.QueryRow(
`SELECT o.id, o.customer_id, c.name, o.status, o.order_date, o.total_amount, o.notes, o.created_at, o.updated_at
FROM orders o JOIN customers c ON o.customer_id = c.id WHERE o.id = ?`, id,
).Scan(&o.ID, &o.CustomerID, &o.CustomerName, &o.Status, &o.OrderDate, &o.TotalAmount, &o.Notes, &o.CreatedAt, &o.UpdatedAt)
if err != nil {
return nil, err
}
// Load lines
lines, err := OrderLinesGetByOrderID(db, id)
if err != nil {
return nil, err
}
o.Lines = lines
return o, nil
}
func OrderInsert(db *sql.DB, o *Order) error {
result, err := db.Exec(
"INSERT INTO orders (customer_id, status, order_date, total_amount, notes) VALUES (?, ?, ?, ?, ?)",
o.CustomerID, o.Status, o.OrderDate, o.TotalAmount, o.Notes,
)
if err != nil {
return err
}
id, _ := result.LastInsertId()
o.ID = int(id)
return nil
}
func OrderUpdate(db *sql.DB, o *Order) error {
_, err := db.Exec(
"UPDATE orders SET customer_id = ?, order_date = ?, notes = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
o.CustomerID, o.OrderDate, o.Notes, o.ID,
)
return err
}
func OrderUpdateStatus(db *sql.DB, id int, status string) error {
_, err := db.Exec("UPDATE orders SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", status, id)
return err
}
func OrderRecalcTotal(db *sql.DB, orderID int) error {
_, err := db.Exec(
"UPDATE orders SET total_amount = (SELECT COALESCE(SUM(line_total), 0) FROM order_lines WHERE order_id = ?), updated_at = CURRENT_TIMESTAMP WHERE id = ?",
orderID, orderID,
)
return err
}
func OrderDelete(db *sql.DB, id int) error {
_, err := db.Exec("DELETE FROM orders WHERE id = ?", id)
return err
}
func OrderCount(db *sql.DB) int {
var count int
db.QueryRow("SELECT COUNT(*) FROM orders WHERE status NOT IN ('cancelled')").Scan(&count)
return count
}
func OrderCountByStatus(db *sql.DB, status string) int {
var count int
db.QueryRow("SELECT COUNT(*) FROM orders WHERE status = ?", status).Scan(&count)
return count
}
// Order Lines
func OrderLinesGetByOrderID(db *sql.DB, orderID int) ([]OrderLine, error) {
rows, err := db.Query("SELECT id, order_id, description, quantity, unit_price, line_total FROM order_lines WHERE order_id = ? ORDER BY id", orderID)
if err != nil {
return nil, err
}
defer rows.Close()
var lines []OrderLine
for rows.Next() {
var l OrderLine
if err := rows.Scan(&l.ID, &l.OrderID, &l.Description, &l.Quantity, &l.UnitPrice, &l.LineTotal); err != nil {
return nil, err
}
lines = append(lines, l)
}
return lines, nil
}
func OrderLineInsert(db *sql.DB, l *OrderLine) error {
l.LineTotal = l.Quantity * l.UnitPrice
result, err := db.Exec(
"INSERT INTO order_lines (order_id, description, quantity, unit_price, line_total) VALUES (?, ?, ?, ?, ?)",
l.OrderID, l.Description, l.Quantity, l.UnitPrice, l.LineTotal,
)
if err != nil {
return err
}
id, _ := result.LastInsertId()
l.ID = int(id)
return OrderRecalcTotal(db, l.OrderID)
}
func OrderLineDelete(db *sql.DB, lineID, orderID int) error {
_, err := db.Exec("DELETE FROM order_lines WHERE id = ?", lineID)
if err != nil {
return err
}
return OrderRecalcTotal(db, orderID)
}
// GenerateInvoiceFromOrder creates an invoice and GL entries when an order is fulfilled
func GenerateInvoiceFromOrder(db *sql.DB, orderID int) error {
order, err := OrderGetByID(db, orderID)
if err != nil {
return err
}
// Generate invoice number
var maxID int
db.QueryRow("SELECT COALESCE(MAX(id), 0) FROM invoices").Scan(&maxID)
invoiceNumber := fmt.Sprintf("INV-%05d", maxID+1)
// Create invoice (due in 30 days)
_, err = db.Exec(
`INSERT INTO invoices (order_id, customer_id, invoice_number, status, amount, due_date)
VALUES (?, ?, ?, 'pending', ?, date('now', '+30 days'))`,
order.ID, order.CustomerID, invoiceNumber, order.TotalAmount,
)
if err != nil {
return fmt.Errorf("creating invoice: %w", err)
}
// Create GL journal entry: Debit AR, Credit Revenue
result, err := db.Exec(
"INSERT INTO journal_entries (entry_date, description, reference) VALUES (date('now'), ?, ?)",
fmt.Sprintf("Invoice %s - Order #%d fulfilled", invoiceNumber, orderID),
invoiceNumber,
)
if err != nil {
return fmt.Errorf("creating journal entry: %w", err)
}
jeID, _ := result.LastInsertId()
// Debit Accounts Receivable (1100)
var arID int
db.QueryRow("SELECT id FROM gl_accounts WHERE code = '1100'").Scan(&arID)
_, err = db.Exec("INSERT INTO journal_lines (journal_entry_id, account_id, debit, credit) VALUES (?, ?, ?, 0)", jeID, arID, order.TotalAmount)
if err != nil {
return fmt.Errorf("creating AR debit line: %w", err)
}
// Credit Sales Revenue (4000)
var revID int
db.QueryRow("SELECT id FROM gl_accounts WHERE code = '4000'").Scan(&revID)
_, err = db.Exec("INSERT INTO journal_lines (journal_entry_id, account_id, debit, credit) VALUES (?, ?, 0, ?)", jeID, revID, order.TotalAmount)
if err != nil {
return fmt.Errorf("creating revenue credit line: %w", err)
}
// Update account balances
db.Exec("UPDATE gl_accounts SET balance = balance + ? WHERE code = '1100'", order.TotalAmount) // AR increases (debit)
db.Exec("UPDATE gl_accounts SET balance = balance + ? WHERE code = '4000'", order.TotalAmount) // Revenue increases (credit)
return nil
}

33
internal/models/user.go Normal file

@ -0,0 +1,33 @@
package models
import (
"database/sql"
"fmt"
"time"
"golang.org/x/crypto/bcrypt"
)
type User struct {
ID int
Username string
PasswordHash string
CreatedAt time.Time
}
func Authenticate(db *sql.DB, username, password string) (*User, error) {
u := &User{}
err := db.QueryRow(
"SELECT id, username, password_hash, created_at FROM users WHERE username = ?",
username,
).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.CreatedAt)
if err != nil {
return nil, fmt.Errorf("user not found")
}
if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)); err != nil {
return nil, fmt.Errorf("invalid password")
}
return u, nil
}

103
main.go Normal file

@ -0,0 +1,103 @@
package main
import (
"log"
"net/http"
"os"
"erp_system/internal/database"
"erp_system/internal/handlers"
"erp_system/internal/middleware"
"github.com/gorilla/sessions"
)
func main() {
// Initialize database
db, err := database.Initialize("erp.db")
if err != nil {
log.Fatalf("Failed to initialize database: %v", err)
}
defer db.Close()
// Session store
sessionKey := os.Getenv("SESSION_KEY")
if sessionKey == "" {
sessionKey = "erp-system-dev-secret-key-change-in-prod"
}
store := sessions.NewCookieStore([]byte(sessionKey))
store.Options = &sessions.Options{
Path: "/",
MaxAge: 86400, // 24 hours
HttpOnly: true,
}
// Create handler context
h := &handlers.Handler{
DB: db,
Store: store,
}
// Auth middleware
authMw := middleware.RequireAuth(store)
// Router
mux := http.NewServeMux()
// Static/public routes
mux.HandleFunc("GET /login", h.LoginPage)
mux.HandleFunc("POST /login", h.LoginSubmit)
mux.HandleFunc("POST /logout", h.Logout)
// Protected routes
mux.Handle("GET /{$}", authMw(http.HandlerFunc(h.Dashboard)))
// Customers
mux.Handle("GET /customers", authMw(http.HandlerFunc(h.CustomerList)))
mux.Handle("GET /customers/new", authMw(http.HandlerFunc(h.CustomerNew)))
mux.Handle("POST /customers", authMw(http.HandlerFunc(h.CustomerCreate)))
mux.Handle("GET /customers/{id}", authMw(http.HandlerFunc(h.CustomerDetail)))
mux.Handle("GET /customers/{id}/edit", authMw(http.HandlerFunc(h.CustomerEdit)))
mux.Handle("PUT /customers/{id}", authMw(http.HandlerFunc(h.CustomerUpdate)))
mux.Handle("DELETE /customers/{id}", authMw(http.HandlerFunc(h.CustomerDelete)))
// Orders
mux.Handle("GET /orders", authMw(http.HandlerFunc(h.OrderList)))
mux.Handle("GET /orders/new", authMw(http.HandlerFunc(h.OrderNew)))
mux.Handle("POST /orders", authMw(http.HandlerFunc(h.OrderCreate)))
mux.Handle("GET /orders/{id}", authMw(http.HandlerFunc(h.OrderDetail)))
mux.Handle("GET /orders/{id}/edit", authMw(http.HandlerFunc(h.OrderEdit)))
mux.Handle("PUT /orders/{id}", authMw(http.HandlerFunc(h.OrderUpdate)))
mux.Handle("POST /orders/{id}/confirm", authMw(http.HandlerFunc(h.OrderConfirm)))
mux.Handle("POST /orders/{id}/fulfill", authMw(http.HandlerFunc(h.OrderFulfill)))
mux.Handle("POST /orders/{id}/cancel", authMw(http.HandlerFunc(h.OrderCancel)))
mux.Handle("DELETE /orders/{id}", authMw(http.HandlerFunc(h.OrderDelete)))
// Order lines (HTMX partials)
mux.Handle("POST /orders/{id}/lines", authMw(http.HandlerFunc(h.OrderLineAdd)))
mux.Handle("DELETE /orders/{id}/lines/{lineID}", authMw(http.HandlerFunc(h.OrderLineDelete)))
// Invoices
mux.Handle("GET /invoices", authMw(http.HandlerFunc(h.InvoiceList)))
mux.Handle("GET /invoices/{id}", authMw(http.HandlerFunc(h.InvoiceDetail)))
mux.Handle("POST /invoices/{id}/pay", authMw(http.HandlerFunc(h.InvoicePay)))
// General Ledger
mux.Handle("GET /ledger/accounts", authMw(http.HandlerFunc(h.ChartOfAccounts)))
mux.Handle("GET /ledger/journal", authMw(http.HandlerFunc(h.JournalEntries)))
mux.Handle("GET /ledger/journal/new", authMw(http.HandlerFunc(h.JournalEntryNew)))
mux.Handle("POST /ledger/journal", authMw(http.HandlerFunc(h.JournalEntryCreate)))
mux.Handle("GET /ledger/journal/{id}", authMw(http.HandlerFunc(h.JournalEntryDetail)))
mux.Handle("GET /ledger/trial-balance", authMw(http.HandlerFunc(h.TrialBalance)))
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
log.Printf("ERP System starting on http://localhost:%s", port)
log.Printf("Default login: admin / admin123")
if err := http.ListenAndServe(":"+port, mux); err != nil {
log.Fatalf("Server failed: %v", err)
}
}

@ -0,0 +1,104 @@
{{define "content"}}
<div class="space-y-6">
<div class="sm:flex sm:items-center sm:justify-between">
<h1 class="text-2xl font-bold text-gray-900">{{.Customer.Name}}</h1>
<div class="flex space-x-3">
<a href="/customers" class="text-sm text-gray-500 hover:text-gray-700">&larr; All Customers</a>
<a href="/customers/{{.Customer.ID}}/edit"
class="rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50">Edit</a>
<button class="rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500"
hx-delete="/customers/{{.Customer.ID}}"
hx-confirm="Are you sure you want to delete this customer?">Delete</button>
</div>
</div>
<!-- Customer Info -->
<div class="bg-white shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
<div>
<dt class="text-sm font-medium text-gray-500">Email</dt>
<dd class="mt-1 text-sm text-gray-900">{{if .Customer.Email}}{{.Customer.Email}}{{else}}-{{end}}</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Phone</dt>
<dd class="mt-1 text-sm text-gray-900">{{if .Customer.Phone}}{{.Customer.Phone}}{{else}}-{{end}}</dd>
</div>
<div class="sm:col-span-2">
<dt class="text-sm font-medium text-gray-500">Address</dt>
<dd class="mt-1 text-sm text-gray-900">{{if .Customer.Address}}{{.Customer.Address}}{{else}}-{{end}}</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Created</dt>
<dd class="mt-1 text-sm text-gray-900">{{.Customer.CreatedAt.Format "Jan 02, 2006"}}</dd>
</div>
</dl>
</div>
</div>
<!-- Customer Orders -->
<div class="bg-white shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<div class="sm:flex sm:items-center sm:justify-between mb-4">
<h2 class="text-lg font-medium text-gray-900">Orders</h2>
<a href="/orders/new" class="text-sm text-indigo-600 hover:text-indigo-500">+ New Order</a>
</div>
{{if .Orders}}
<table class="min-w-full divide-y divide-gray-300">
<thead>
<tr>
<th class="py-2 pr-3 text-left text-sm font-semibold text-gray-900">Order #</th>
<th class="px-3 py-2 text-left text-sm font-semibold text-gray-900">Date</th>
<th class="px-3 py-2 text-left text-sm font-semibold text-gray-900">Status</th>
<th class="px-3 py-2 text-right text-sm font-semibold text-gray-900">Amount</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
{{range .Orders}}
<tr>
<td class="py-2 pr-3 text-sm"><a href="/orders/{{.ID}}" class="text-indigo-600 hover:text-indigo-900">#{{.ID}}</a></td>
<td class="px-3 py-2 text-sm text-gray-500">{{formatDate .OrderDate}}</td>
<td class="px-3 py-2 text-sm">{{statusBadge .Status}}</td>
<td class="px-3 py-2 text-sm text-gray-900 text-right">{{formatMoney .TotalAmount}}</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="text-sm text-gray-500">No orders yet.</p>
{{end}}
</div>
</div>
<!-- Customer Invoices -->
<div class="bg-white shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h2 class="text-lg font-medium text-gray-900 mb-4">Invoices</h2>
{{if .Invoices}}
<table class="min-w-full divide-y divide-gray-300">
<thead>
<tr>
<th class="py-2 pr-3 text-left text-sm font-semibold text-gray-900">Invoice #</th>
<th class="px-3 py-2 text-left text-sm font-semibold text-gray-900">Status</th>
<th class="px-3 py-2 text-left text-sm font-semibold text-gray-900">Due Date</th>
<th class="px-3 py-2 text-right text-sm font-semibold text-gray-900">Amount</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
{{range .Invoices}}
<tr>
<td class="py-2 pr-3 text-sm"><a href="/invoices/{{.ID}}" class="text-indigo-600 hover:text-indigo-900">{{.InvoiceNumber}}</a></td>
<td class="px-3 py-2 text-sm">{{statusBadge .Status}}</td>
<td class="px-3 py-2 text-sm text-gray-500">{{formatDate .DueDate}}</td>
<td class="px-3 py-2 text-sm text-gray-900 text-right">{{formatMoney .Amount}}</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="text-sm text-gray-500">No invoices yet.</p>
{{end}}
</div>
</div>
</div>
{{end}}

@ -0,0 +1,59 @@
{{define "content"}}
<div class="space-y-6">
<div class="sm:flex sm:items-center sm:justify-between">
<h1 class="text-2xl font-bold text-gray-900">{{if .IsNew}}New Customer{{else}}Edit Customer{{end}}</h1>
<a href="{{if .IsNew}}/customers{{else}}/customers/{{.Customer.ID}}{{end}}" class="text-sm text-gray-500 hover:text-gray-700">&larr; Back</a>
</div>
{{if .Error}}
<div class="rounded-md bg-red-50 p-4">
<div class="text-sm text-red-700">{{.Error}}</div>
</div>
{{end}}
<div class="bg-white shadow sm:rounded-lg">
<form class="space-y-6 p-6"
{{if .IsNew}}
method="POST" action="/customers" hx-post="/customers"
{{else}}
method="POST" action="/customers/{{.Customer.ID}}" hx-put="/customers/{{.Customer.ID}}"
{{end}}>
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2">
<div>
<label for="name" class="block text-sm font-medium text-gray-700">Name *</label>
<input type="text" name="name" id="name" required value="{{.Customer.Name}}"
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-indigo-500 sm:text-sm">
</div>
<div>
<label for="email" class="block text-sm font-medium text-gray-700">Email</label>
<input type="email" name="email" id="email" value="{{.Customer.Email}}"
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-indigo-500 sm:text-sm">
</div>
<div>
<label for="phone" class="block text-sm font-medium text-gray-700">Phone</label>
<input type="tel" name="phone" id="phone" value="{{.Customer.Phone}}"
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-indigo-500 sm:text-sm">
</div>
</div>
<div>
<label for="address" class="block text-sm font-medium text-gray-700">Address</label>
<textarea name="address" id="address" rows="3"
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-indigo-500 sm:text-sm">{{.Customer.Address}}</textarea>
</div>
<div class="flex justify-end space-x-3">
<a href="{{if .IsNew}}/customers{{else}}/customers/{{.Customer.ID}}{{end}}"
class="rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50">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">
{{if .IsNew}}Create Customer{{else}}Update Customer{{end}}
</button>
</div>
</form>
</div>
</div>
{{end}}

@ -0,0 +1,63 @@
{{define "content"}}
<div class="space-y-6">
<div class="sm:flex sm:items-center sm:justify-between">
<h1 class="text-2xl font-bold text-gray-900">Customers</h1>
<a href="/customers/new" class="inline-flex items-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500">
+ New Customer
</a>
</div>
<!-- Search -->
<div class="max-w-md">
<input type="search" name="search" placeholder="Search customers..."
value="{{.Search}}"
class="block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-indigo-500 sm:text-sm"
hx-get="/customers?partial=true"
hx-trigger="input changed delay:300ms, search"
hx-target="#customer-table"
hx-include="this">
</div>
<div id="customer-table">
{{template "customer-table" .}}
</div>
</div>
{{end}}
{{define "customer-table"}}
<div class="overflow-hidden bg-white shadow ring-1 ring-black ring-opacity-5 sm:rounded-lg">
<table class="min-w-full divide-y divide-gray-300">
<thead class="bg-gray-50">
<tr>
<th class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900">Name</th>
<th class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Email</th>
<th class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Phone</th>
<th class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{{if .Customers}}
{{range .Customers}}
<tr class="hover:bg-gray-50">
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-indigo-600">
<a href="/customers/{{.ID}}">{{.Name}}</a>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">{{.Email}}</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">{{.Phone}}</td>
<td class="whitespace-nowrap px-3 py-4 text-sm space-x-2">
<a href="/customers/{{.ID}}/edit" class="text-indigo-600 hover:text-indigo-900">Edit</a>
<button class="text-red-600 hover:text-red-900"
hx-delete="/customers/{{.ID}}"
hx-confirm="Are you sure you want to delete this customer?">Delete</button>
</td>
</tr>
{{end}}
{{else}}
<tr>
<td colspan="4" class="px-3 py-8 text-center text-sm text-gray-500">No customers found. <a href="/customers/new" class="text-indigo-600 hover:text-indigo-500">Create one</a>.</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{end}}

76
templates/dashboard.html Normal file

@ -0,0 +1,76 @@
{{define "content"}}
<div class="space-y-6">
<h1 class="text-2xl font-bold text-gray-900">Dashboard</h1>
<!-- Summary Cards -->
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
<!-- Customers -->
<div class="overflow-hidden rounded-lg bg-white px-4 py-5 shadow sm:p-6">
<dt class="truncate text-sm font-medium text-gray-500">Total Customers</dt>
<dd class="mt-1 text-3xl font-semibold tracking-tight text-gray-900">{{.CustomerCount}}</dd>
<a href="/customers" class="mt-2 inline-block text-sm text-indigo-600 hover:text-indigo-500">View all &rarr;</a>
</div>
<!-- Open Orders -->
<div class="overflow-hidden rounded-lg bg-white px-4 py-5 shadow sm:p-6">
<dt class="truncate text-sm font-medium text-gray-500">Open Orders</dt>
<dd class="mt-1 text-3xl font-semibold tracking-tight text-gray-900">{{.OpenOrderCount}}</dd>
<a href="/orders" class="mt-2 inline-block text-sm text-indigo-600 hover:text-indigo-500">View all &rarr;</a>
</div>
<!-- Pending Invoices -->
<div class="overflow-hidden rounded-lg bg-white px-4 py-5 shadow sm:p-6">
<dt class="truncate text-sm font-medium text-gray-500">Pending Invoices</dt>
<dd class="mt-1 text-3xl font-semibold tracking-tight text-yellow-600">{{.PendingInvoices}}</dd>
<dd class="mt-1 text-sm text-gray-500">{{formatMoney .OutstandingAmount}} outstanding</dd>
<a href="/invoices?status=pending" class="mt-2 inline-block text-sm text-indigo-600 hover:text-indigo-500">View all &rarr;</a>
</div>
<!-- Revenue This Month -->
<div class="overflow-hidden rounded-lg bg-white px-4 py-5 shadow sm:p-6">
<dt class="truncate text-sm font-medium text-gray-500">Revenue This Month</dt>
<dd class="mt-1 text-3xl font-semibold tracking-tight text-green-600">{{formatMoney .RevenueThisMonth}}</dd>
<a href="/ledger/trial-balance" class="mt-2 inline-block text-sm text-indigo-600 hover:text-indigo-500">View ledger &rarr;</a>
</div>
</div>
<!-- Quick Actions -->
<div class="rounded-lg bg-white shadow p-6">
<h2 class="text-lg font-medium text-gray-900 mb-4">Quick Actions</h2>
<div class="flex flex-wrap gap-3">
<a href="/customers/new" class="inline-flex items-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500">
+ New Customer
</a>
<a href="/orders/new" class="inline-flex items-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500">
+ New Order
</a>
<a href="/ledger/journal/new" class="inline-flex items-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500">
+ Journal Entry
</a>
</div>
</div>
<!-- Order Status Summary -->
<div class="rounded-lg bg-white shadow p-6">
<h2 class="text-lg font-medium text-gray-900 mb-4">Order Status Summary</h2>
<div class="grid grid-cols-2 gap-4 sm:grid-cols-4">
<div class="text-center">
<div class="text-2xl font-bold text-gray-500">{{.OpenOrderCount}}</div>
<div class="text-sm text-gray-500">Draft / Confirmed</div>
</div>
<div class="text-center">
<div class="text-2xl font-bold text-green-600">{{.FulfilledOrders}}</div>
<div class="text-sm text-gray-500">Fulfilled</div>
</div>
<div class="text-center">
<div class="text-2xl font-bold text-yellow-600">{{.PendingInvoices}}</div>
<div class="text-sm text-gray-500">Pending Invoices</div>
</div>
<div class="text-center">
<div class="text-2xl font-bold text-green-600">{{formatMoney .RevenueThisMonth}}</div>
<div class="text-sm text-gray-500">Monthly Revenue</div>
</div>
</div>
</div>
</div>
{{end}}

@ -0,0 +1,67 @@
{{define "content"}}
<div class="space-y-6">
<div class="sm:flex sm:items-center sm:justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900">{{.Invoice.InvoiceNumber}}</h1>
<p class="mt-1 text-sm text-gray-500">{{.Invoice.CustomerName}}</p>
</div>
<div class="flex items-center space-x-3">
<a href="/invoices" class="text-sm text-gray-500 hover:text-gray-700">&larr; All Invoices</a>
{{if eq .Invoice.Status "pending"}}
<button class="rounded-md bg-green-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-green-500"
hx-post="/invoices/{{.Invoice.ID}}/pay"
hx-confirm="Mark this invoice as paid? This will create a GL journal entry.">
Mark as Paid
</button>
{{end}}
</div>
</div>
<div class="bg-white shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-3">
<div>
<dt class="text-sm font-medium text-gray-500">Invoice Number</dt>
<dd class="mt-1 text-sm text-gray-900">{{.Invoice.InvoiceNumber}}</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Customer</dt>
<dd class="mt-1 text-sm text-gray-900">
<a href="/customers/{{.Invoice.CustomerID}}" class="text-indigo-600 hover:text-indigo-900">{{.Invoice.CustomerName}}</a>
</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Status</dt>
<dd class="mt-1">{{statusBadge .Invoice.Status}}</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Amount</dt>
<dd class="mt-1 text-2xl font-semibold text-gray-900">{{formatMoney .Invoice.Amount}}</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Due Date</dt>
<dd class="mt-1 text-sm text-gray-900">{{formatDate .Invoice.DueDate}}</dd>
</div>
{{if .Invoice.PaidDate.Valid}}
<div>
<dt class="text-sm font-medium text-gray-500">Paid Date</dt>
<dd class="mt-1 text-sm text-gray-900">{{formatDate .Invoice.PaidDate.String}}</dd>
</div>
{{end}}
{{if .Invoice.OrderID.Valid}}
<div>
<dt class="text-sm font-medium text-gray-500">Order</dt>
<dd class="mt-1 text-sm text-gray-900">
<a href="/orders/{{.Invoice.OrderID.Int64}}" class="text-indigo-600 hover:text-indigo-900">Order #{{.Invoice.OrderID.Int64}}</a>
</dd>
</div>
{{end}}
<div>
<dt class="text-sm font-medium text-gray-500">Created</dt>
<dd class="mt-1 text-sm text-gray-900">{{.Invoice.CreatedAt.Format "Jan 02, 2006 3:04 PM"}}</dd>
</div>
</dl>
</div>
</div>
</div>
{{end}}

@ -0,0 +1,67 @@
{{define "content"}}
<div class="space-y-6">
<div class="sm:flex sm:items-center sm:justify-between">
<h1 class="text-2xl font-bold text-gray-900">Invoices</h1>
</div>
<!-- Status Filter -->
<div class="flex space-x-2">
<a href="/invoices"
class="{{if eq .FilterStatus ""}}bg-indigo-100 text-indigo-700{{else}}bg-white text-gray-700 hover:bg-gray-50{{end}} rounded-md px-3 py-1.5 text-sm font-medium ring-1 ring-inset ring-gray-300">All</a>
<a href="/invoices?status=pending"
class="{{if eq .FilterStatus "pending"}}bg-indigo-100 text-indigo-700{{else}}bg-white text-gray-700 hover:bg-gray-50{{end}} rounded-md px-3 py-1.5 text-sm font-medium ring-1 ring-inset ring-gray-300">Pending</a>
<a href="/invoices?status=paid"
class="{{if eq .FilterStatus "paid"}}bg-indigo-100 text-indigo-700{{else}}bg-white text-gray-700 hover:bg-gray-50{{end}} rounded-md px-3 py-1.5 text-sm font-medium ring-1 ring-inset ring-gray-300">Paid</a>
</div>
<div id="invoice-table">
{{template "invoice-table" .}}
</div>
</div>
{{end}}
{{define "invoice-table"}}
<div class="overflow-hidden bg-white shadow ring-1 ring-black ring-opacity-5 sm:rounded-lg">
<table class="min-w-full divide-y divide-gray-300">
<thead class="bg-gray-50">
<tr>
<th class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900">Invoice #</th>
<th class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Customer</th>
<th class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Status</th>
<th class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Due Date</th>
<th class="px-3 py-3.5 text-right text-sm font-semibold text-gray-900">Amount</th>
<th class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{{if .Invoices}}
{{range .Invoices}}
<tr class="hover:bg-gray-50">
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-indigo-600">
<a href="/invoices/{{.ID}}">{{.InvoiceNumber}}</a>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-900">
<a href="/customers/{{.CustomerID}}" class="hover:text-indigo-600">{{.CustomerName}}</a>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm">{{statusBadge .Status}}</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">{{formatDate .DueDate}}</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-900 text-right">{{formatMoney .Amount}}</td>
<td class="whitespace-nowrap px-3 py-4 text-sm">
<a href="/invoices/{{.ID}}" class="text-indigo-600 hover:text-indigo-900">View</a>
{{if eq .Status "pending"}}
<button class="ml-2 text-green-600 hover:text-green-900"
hx-post="/invoices/{{.ID}}/pay"
hx-confirm="Mark this invoice as paid?">Mark Paid</button>
{{end}}
</td>
</tr>
{{end}}
{{else}}
<tr>
<td colspan="6" class="px-3 py-8 text-center text-sm text-gray-500">No invoices found. Invoices are auto-generated when orders are fulfilled.</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{end}}

63
templates/layout.html Normal file

@ -0,0 +1,63 @@
{{define "layout"}}
<!DOCTYPE html>
<html lang="en" class="h-full bg-gray-50">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}} - ERP System</title>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script src="https://cdn.tailwindcss.com"></script>
<style>
.htmx-indicator { display: none; }
.htmx-request .htmx-indicator { display: inline-block; }
.htmx-request.htmx-indicator { display: inline-block; }
</style>
</head>
<body class="h-full" hx-boost="true">
<div class="min-h-full">
<!-- Navigation -->
<nav class="bg-indigo-600 shadow-lg">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="flex h-16 items-center justify-between">
<div class="flex items-center">
<a href="/" class="text-white font-bold text-xl tracking-tight">ERP System</a>
<div class="ml-10 flex items-baseline space-x-4">
<a href="/" class="{{if eq .ActivePage "dashboard"}}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">Dashboard</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="/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>
<div class="relative group">
<button 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
<svg class="ml-1 h-4 w-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>
</button>
<div class="absolute left-0 z-10 mt-1 w-48 origin-top-left rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 hidden group-hover:block">
<div class="py-1">
<a href="/ledger/accounts" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" hx-boost="true">Chart of Accounts</a>
<a href="/ledger/journal" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" hx-boost="true">Journal Entries</a>
<a href="/ledger/trial-balance" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" hx-boost="true">Trial Balance</a>
</div>
</div>
</div>
</div>
</div>
<div class="flex items-center space-x-4">
<span class="text-indigo-200 text-sm">{{.Username}}</span>
<form method="POST" action="/logout" hx-post="/logout">
<button type="submit" class="text-indigo-200 hover:text-white text-sm font-medium">Logout</button>
</form>
</div>
</div>
</div>
</nav>
<!-- Main content -->
<main>
<div class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
{{template "content" .}}
</div>
</main>
</div>
</body>
</html>
{{end}}

@ -0,0 +1,34 @@
{{define "content"}}
<div class="space-y-6">
<div class="sm:flex sm:items-center sm:justify-between">
<h1 class="text-2xl font-bold text-gray-900">Chart of Accounts</h1>
<div class="flex space-x-3">
<a href="/ledger/journal" class="text-sm text-indigo-600 hover:text-indigo-500">Journal Entries</a>
<a href="/ledger/trial-balance" class="text-sm text-indigo-600 hover:text-indigo-500">Trial Balance</a>
</div>
</div>
<div class="overflow-hidden bg-white shadow ring-1 ring-black ring-opacity-5 sm:rounded-lg">
<table class="min-w-full divide-y divide-gray-300">
<thead class="bg-gray-50">
<tr>
<th class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900">Code</th>
<th class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Name</th>
<th class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Type</th>
<th class="px-3 py-3.5 text-right text-sm font-semibold text-gray-900">Balance</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{{range .Accounts}}
<tr class="hover:bg-gray-50">
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-mono font-medium text-gray-900">{{.Code}}</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-900">{{.Name}}</td>
<td class="whitespace-nowrap px-3 py-4 text-sm">{{accountTypeBadge .Type}}</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-900 text-right font-mono">{{formatMoney .Balance}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
{{end}}

@ -0,0 +1,49 @@
{{define "content"}}
<div class="space-y-6">
<div class="sm:flex sm:items-center sm:justify-between">
<h1 class="text-2xl font-bold text-gray-900">Journal Entries</h1>
<div class="flex space-x-3">
<a href="/ledger/accounts" class="text-sm text-indigo-600 hover:text-indigo-500">Chart of Accounts</a>
<a href="/ledger/trial-balance" class="text-sm text-indigo-600 hover:text-indigo-500">Trial Balance</a>
<a href="/ledger/journal/new" class="inline-flex items-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500">
+ New Entry
</a>
</div>
</div>
<div class="overflow-hidden bg-white shadow ring-1 ring-black ring-opacity-5 sm:rounded-lg">
<table class="min-w-full divide-y divide-gray-300">
<thead class="bg-gray-50">
<tr>
<th class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900">#</th>
<th class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Date</th>
<th class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Description</th>
<th class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Reference</th>
<th class="px-3 py-3.5 text-right text-sm font-semibold text-gray-900">Debit</th>
<th class="px-3 py-3.5 text-right text-sm font-semibold text-gray-900">Credit</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{{if .Entries}}
{{range .Entries}}
<tr class="hover:bg-gray-50">
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-indigo-600">
<a href="/ledger/journal/{{.ID}}">{{.ID}}</a>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">{{formatDate .EntryDate}}</td>
<td class="px-3 py-4 text-sm text-gray-900">{{.Description}}</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">{{.Reference}}</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-900 text-right font-mono">{{formatMoney .TotalDebit}}</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-900 text-right font-mono">{{formatMoney .TotalCredit}}</td>
</tr>
{{end}}
{{else}}
<tr>
<td colspan="6" class="px-3 py-8 text-center text-sm text-gray-500">No journal entries yet. <a href="/ledger/journal/new" class="text-indigo-600 hover:text-indigo-500">Create one</a>.</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
{{end}}

@ -0,0 +1,63 @@
{{define "content"}}
<div class="space-y-6">
<div class="sm:flex sm:items-center sm:justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900">Journal Entry #{{.Entry.ID}}</h1>
<p class="mt-1 text-sm text-gray-500">{{formatDate .Entry.EntryDate}}</p>
</div>
<a href="/ledger/journal" class="text-sm text-gray-500 hover:text-gray-700">&larr; All Entries</a>
</div>
<div class="bg-white shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-3 mb-6">
<div>
<dt class="text-sm font-medium text-gray-500">Date</dt>
<dd class="mt-1 text-sm text-gray-900">{{formatDate .Entry.EntryDate}}</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Description</dt>
<dd class="mt-1 text-sm text-gray-900">{{.Entry.Description}}</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Reference</dt>
<dd class="mt-1 text-sm text-gray-900">{{if .Entry.Reference}}{{.Entry.Reference}}{{else}}-{{end}}</dd>
</div>
</dl>
<h3 class="text-sm font-medium text-gray-900 mb-3">Lines</h3>
<table class="min-w-full divide-y divide-gray-300">
<thead>
<tr>
<th class="py-2 pr-3 text-left text-sm font-semibold text-gray-900">Account</th>
<th class="px-3 py-2 text-right text-sm font-semibold text-gray-900">Debit</th>
<th class="px-3 py-2 text-right text-sm font-semibold text-gray-900">Credit</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
{{range .Entry.Lines}}
<tr>
<td class="py-2 pr-3 text-sm text-gray-900">
<span class="font-mono text-gray-500">{{.AccountCode}}</span> {{.AccountName}}
</td>
<td class="px-3 py-2 text-sm text-gray-900 text-right font-mono">
{{if gt .Debit 0.0}}{{formatMoney .Debit}}{{else}}-{{end}}
</td>
<td class="px-3 py-2 text-sm text-gray-900 text-right font-mono">
{{if gt .Credit 0.0}}{{formatMoney .Credit}}{{else}}-{{end}}
</td>
</tr>
{{end}}
</tbody>
<tfoot>
<tr class="border-t-2 border-gray-900">
<td class="py-2 pr-3 text-sm font-semibold text-gray-900">Totals</td>
<td class="px-3 py-2 text-sm font-semibold text-gray-900 text-right font-mono">{{formatMoney .Entry.TotalDebit}}</td>
<td class="px-3 py-2 text-sm font-semibold text-gray-900 text-right font-mono">{{formatMoney .Entry.TotalCredit}}</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
{{end}}

@ -0,0 +1,106 @@
{{define "content"}}
<div class="space-y-6">
<div class="sm:flex sm:items-center sm:justify-between">
<h1 class="text-2xl font-bold text-gray-900">New Journal Entry</h1>
<a href="/ledger/journal" class="text-sm text-gray-500 hover:text-gray-700">&larr; Back</a>
</div>
{{if .Error}}
<div class="rounded-md bg-red-50 p-4">
<div class="text-sm text-red-700">{{.Error}}</div>
</div>
{{end}}
<div class="bg-white shadow sm:rounded-lg">
<form class="space-y-6 p-6" method="POST" action="/ledger/journal" hx-post="/ledger/journal" id="je-form">
<div class="grid grid-cols-1 gap-6 sm:grid-cols-3">
<div>
<label for="entry_date" class="block text-sm font-medium text-gray-700">Date *</label>
<input type="date" name="entry_date" id="entry_date" required value="{{.Today}}"
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-indigo-500 sm:text-sm">
</div>
<div>
<label for="description" class="block text-sm font-medium text-gray-700">Description *</label>
<input type="text" name="description" id="description" required
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-indigo-500 sm:text-sm">
</div>
<div>
<label for="reference" class="block text-sm font-medium text-gray-700">Reference</label>
<input type="text" name="reference" id="reference"
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-indigo-500 sm:text-sm">
</div>
</div>
<!-- Journal Lines -->
<div>
<h3 class="text-sm font-medium text-gray-700 mb-3">Lines (debits must equal credits)</h3>
<div id="journal-lines" class="space-y-3">
<!-- Line 1 -->
<div class="grid grid-cols-1 gap-3 sm:grid-cols-3 journal-line">
<div>
<select name="account_id" required
class="block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-indigo-500 sm:text-sm">
<option value="">Select Account</option>
{{range .Accounts}}
<option value="{{.ID}}">{{.Code}} - {{.Name}}</option>
{{end}}
</select>
</div>
<div>
<input type="number" name="debit" placeholder="Debit" step="0.01" min="0" value="0"
class="block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-indigo-500 sm:text-sm">
</div>
<div>
<input type="number" name="credit" placeholder="Credit" step="0.01" min="0" value="0"
class="block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-indigo-500 sm:text-sm">
</div>
</div>
<!-- Line 2 -->
<div class="grid grid-cols-1 gap-3 sm:grid-cols-3 journal-line">
<div>
<select name="account_id" required
class="block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-indigo-500 sm:text-sm">
<option value="">Select Account</option>
{{range .Accounts}}
<option value="{{.ID}}">{{.Code}} - {{.Name}}</option>
{{end}}
</select>
</div>
<div>
<input type="number" name="debit" placeholder="Debit" step="0.01" min="0" value="0"
class="block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-indigo-500 sm:text-sm">
</div>
<div>
<input type="number" name="credit" placeholder="Credit" step="0.01" min="0" value="0"
class="block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-indigo-500 sm:text-sm">
</div>
</div>
</div>
<button type="button" onclick="addLine()" class="mt-3 text-sm text-indigo-600 hover:text-indigo-500">+ Add another line</button>
</div>
<div class="flex justify-end space-x-3">
<a href="/ledger/journal"
class="rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50">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">
Create Entry
</button>
</div>
</form>
</div>
</div>
<script>
function addLine() {
const container = document.getElementById('journal-lines');
const firstLine = container.querySelector('.journal-line');
const newLine = firstLine.cloneNode(true);
// Reset values
newLine.querySelectorAll('select').forEach(s => s.value = '');
newLine.querySelectorAll('input').forEach(i => i.value = '0');
container.appendChild(newLine);
}
</script>
{{end}}

@ -0,0 +1,61 @@
{{define "content"}}
<div class="space-y-6">
<div class="sm:flex sm:items-center sm:justify-between">
<h1 class="text-2xl font-bold text-gray-900">Trial Balance</h1>
<div class="flex space-x-3">
<a href="/ledger/accounts" class="text-sm text-indigo-600 hover:text-indigo-500">Chart of Accounts</a>
<a href="/ledger/journal" class="text-sm text-indigo-600 hover:text-indigo-500">Journal Entries</a>
</div>
</div>
<div class="overflow-hidden bg-white shadow ring-1 ring-black ring-opacity-5 sm:rounded-lg">
<table class="min-w-full divide-y divide-gray-300">
<thead class="bg-gray-50">
<tr>
<th class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900">Code</th>
<th class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Account Name</th>
<th class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Type</th>
<th class="px-3 py-3.5 text-right text-sm font-semibold text-gray-900">Debit</th>
<th class="px-3 py-3.5 text-right text-sm font-semibold text-gray-900">Credit</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{{range .Rows}}
{{if or (gt .Debit 0.0) (gt .Credit 0.0)}}
<tr class="hover:bg-gray-50">
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-mono font-medium text-gray-900">{{.Code}}</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-900">{{.Name}}</td>
<td class="whitespace-nowrap px-3 py-4 text-sm">{{accountTypeBadge .Type}}</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-900 text-right font-mono">
{{if gt .Debit 0.0}}{{formatMoney .Debit}}{{else}}-{{end}}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-900 text-right font-mono">
{{if gt .Credit 0.0}}{{formatMoney .Credit}}{{else}}-{{end}}
</td>
</tr>
{{end}}
{{end}}
</tbody>
<tfoot>
<tr class="bg-gray-50 border-t-2 border-gray-900">
<td colspan="3" class="py-4 pl-4 pr-3 text-sm font-semibold text-gray-900">Totals</td>
<td class="px-3 py-4 text-sm font-semibold text-gray-900 text-right font-mono">{{formatMoney .TotalDebit}}</td>
<td class="px-3 py-4 text-sm font-semibold text-gray-900 text-right font-mono">{{formatMoney .TotalCredit}}</td>
</tr>
<tr class="bg-gray-50">
<td colspan="3" class="py-2 pl-4 pr-3 text-sm text-gray-500">Difference (should be $0.00)</td>
<td colspan="2" class="px-3 py-2 text-sm text-right font-mono {{if eq .TotalDebit .TotalCredit}}text-green-600{{else}}text-red-600 font-bold{{end}}">
{{formatMoney (sub .TotalDebit .TotalCredit)}}
</td>
</tr>
</tfoot>
</table>
</div>
{{if not .Rows}}
<div class="text-center py-8 text-sm text-gray-500">
No transactions recorded yet. The trial balance will populate as journal entries are created.
</div>
{{end}}
</div>
{{end}}

50
templates/login.html Normal file

@ -0,0 +1,50 @@
<!DOCTYPE html>
<html lang="en" class="h-full bg-gray-50">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - ERP System</title>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="h-full">
<div class="flex min-h-full items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div class="w-full max-w-md space-y-8">
<div>
<h1 class="text-center text-3xl font-bold tracking-tight text-indigo-600">ERP System</h1>
<h2 class="mt-2 text-center text-lg text-gray-600">Sign in to your account</h2>
</div>
{{if .Error}}
<div class="rounded-md bg-red-50 p-4">
<div class="flex">
<div class="text-sm text-red-700">{{.Error}}</div>
</div>
</div>
{{end}}
<form class="mt-8 space-y-6" method="POST" action="/login" hx-post="/login">
<div class="space-y-4 rounded-md shadow-sm">
<div>
<label for="username" class="block text-sm font-medium text-gray-700">Username</label>
<input id="username" name="username" type="text" required
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-indigo-500 sm:text-sm"
placeholder="admin">
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700">Password</label>
<input id="password" name="password" type="password" required
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-indigo-500 sm:text-sm"
placeholder="admin123">
</div>
</div>
<button type="submit"
class="group relative flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">
Sign in
</button>
</form>
</div>
</div>
</body>
</html>

@ -0,0 +1,147 @@
{{define "content"}}
<div class="space-y-6">
<div class="sm:flex sm:items-center sm:justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900">Order #{{.Order.ID}}</h1>
<p class="mt-1 text-sm text-gray-500">{{.Order.CustomerName}} &middot; {{formatDate .Order.OrderDate}}</p>
</div>
<div class="flex items-center space-x-3">
<a href="/orders" class="text-sm text-gray-500 hover:text-gray-700">&larr; All Orders</a>
{{if eq .Order.Status "draft"}}
<a href="/orders/{{.Order.ID}}/edit"
class="rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50">Edit</a>
<button class="rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500"
hx-post="/orders/{{.Order.ID}}/confirm"
hx-confirm="Confirm this order?">Confirm</button>
<button class="rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500"
hx-post="/orders/{{.Order.ID}}/cancel"
hx-confirm="Cancel this order?">Cancel</button>
{{end}}
{{if eq .Order.Status "confirmed"}}
<button class="rounded-md bg-green-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-green-500"
hx-post="/orders/{{.Order.ID}}/fulfill"
hx-confirm="Fulfill this order? This will generate an invoice and GL entries.">Fulfill</button>
<button class="rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500"
hx-post="/orders/{{.Order.ID}}/cancel"
hx-confirm="Cancel this order?">Cancel</button>
{{end}}
</div>
</div>
<!-- Order Info -->
<div class="bg-white shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-4">
<div>
<dt class="text-sm font-medium text-gray-500">Customer</dt>
<dd class="mt-1 text-sm text-gray-900"><a href="/customers/{{.Order.CustomerID}}" class="text-indigo-600 hover:text-indigo-900">{{.Order.CustomerName}}</a></dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Date</dt>
<dd class="mt-1 text-sm text-gray-900">{{formatDate .Order.OrderDate}}</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Status</dt>
<dd class="mt-1">{{statusBadge .Order.Status}}</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Total</dt>
<dd class="mt-1 text-lg font-semibold text-gray-900">{{formatMoney .Order.TotalAmount}}</dd>
</div>
{{if .Order.Notes}}
<div class="sm:col-span-4">
<dt class="text-sm font-medium text-gray-500">Notes</dt>
<dd class="mt-1 text-sm text-gray-900">{{.Order.Notes}}</dd>
</div>
{{end}}
</dl>
</div>
</div>
<!-- Order Lines -->
<div id="order-lines-section">
{{template "order-lines" .Order}}
</div>
</div>
{{end}}
{{define "order-lines"}}
<div class="bg-white shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h2 class="text-lg font-medium text-gray-900 mb-4">Line Items</h2>
<table class="min-w-full divide-y divide-gray-300 mb-4">
<thead>
<tr>
<th class="py-2 pr-3 text-left text-sm font-semibold text-gray-900">Description</th>
<th class="px-3 py-2 text-right text-sm font-semibold text-gray-900">Qty</th>
<th class="px-3 py-2 text-right text-sm font-semibold text-gray-900">Unit Price</th>
<th class="px-3 py-2 text-right text-sm font-semibold text-gray-900">Total</th>
{{if eq .Status "draft"}}
<th class="px-3 py-2 text-sm font-semibold text-gray-900"></th>
{{end}}
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
{{range .Lines}}
<tr>
<td class="py-2 pr-3 text-sm text-gray-900">{{.Description}}</td>
<td class="px-3 py-2 text-sm text-gray-900 text-right">{{printf "%.0f" .Quantity}}</td>
<td class="px-3 py-2 text-sm text-gray-900 text-right">{{formatMoney .UnitPrice}}</td>
<td class="px-3 py-2 text-sm text-gray-900 text-right">{{formatMoney .LineTotal}}</td>
{{if eq $.Status "draft"}}
<td class="px-3 py-2 text-sm text-right">
<button class="text-red-600 hover:text-red-900"
hx-delete="/orders/{{$.ID}}/lines/{{.ID}}"
hx-target="#order-lines-section"
hx-confirm="Remove this line?">Remove</button>
</td>
{{end}}
</tr>
{{else}}
<tr>
<td colspan="5" class="py-4 text-center text-sm text-gray-500">No line items yet.</td>
</tr>
{{end}}
</tbody>
<tfoot>
<tr class="border-t-2 border-gray-900">
<td colspan="3" class="py-2 pr-3 text-sm font-semibold text-gray-900 text-right">Total</td>
<td class="px-3 py-2 text-sm font-semibold text-gray-900 text-right">{{formatMoney .TotalAmount}}</td>
{{if eq .Status "draft"}}<td></td>{{end}}
</tr>
</tfoot>
</table>
{{if eq .Status "draft"}}
<!-- Add Line Form -->
<form class="mt-4 p-4 bg-gray-50 rounded-lg"
hx-post="/orders/{{.ID}}/lines"
hx-target="#order-lines-section"
hx-swap="innerHTML">
<h3 class="text-sm font-medium text-gray-700 mb-3">Add Line Item</h3>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-4">
<div class="sm:col-span-2">
<input type="text" name="description" placeholder="Description" required
class="block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-indigo-500 sm:text-sm">
</div>
<div>
<input type="number" name="quantity" placeholder="Qty" value="1" min="1" step="1" required
class="block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-indigo-500 sm:text-sm">
</div>
<div>
<input type="number" name="unit_price" placeholder="Unit Price" value="0.00" min="0" step="0.01" required
class="block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-indigo-500 sm:text-sm">
</div>
</div>
<div class="mt-3">
<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">
Add Line
</button>
</div>
</form>
{{end}}
</div>
</div>
{{end}}

@ -0,0 +1,60 @@
{{define "content"}}
<div class="space-y-6">
<div class="sm:flex sm:items-center sm:justify-between">
<h1 class="text-2xl font-bold text-gray-900">{{if .IsNew}}New Order{{else}}Edit Order #{{.Order.ID}}{{end}}</h1>
<a href="{{if .IsNew}}/orders{{else}}/orders/{{.Order.ID}}{{end}}" class="text-sm text-gray-500 hover:text-gray-700">&larr; Back</a>
</div>
{{if .Error}}
<div class="rounded-md bg-red-50 p-4">
<div class="text-sm text-red-700">{{.Error}}</div>
</div>
{{end}}
<div class="bg-white shadow sm:rounded-lg">
<form class="space-y-6 p-6"
{{if .IsNew}}
method="POST" action="/orders" hx-post="/orders"
{{else}}
method="POST" action="/orders/{{.Order.ID}}" hx-put="/orders/{{.Order.ID}}"
{{end}}>
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2">
<div>
<label for="customer_id" class="block text-sm font-medium text-gray-700">Customer *</label>
<select name="customer_id" id="customer_id" required
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-indigo-500 sm:text-sm">
<option value="">Select a customer</option>
{{range .Customers}}
<option value="{{.ID}}" {{if eq .ID $.Order.CustomerID}}selected{{end}}>{{.Name}}</option>
{{end}}
</select>
</div>
<div>
<label for="order_date" class="block text-sm font-medium text-gray-700">Order Date</label>
<input type="date" name="order_date" id="order_date" value="{{.Order.OrderDate}}"
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-indigo-500 sm:text-sm">
</div>
</div>
<div>
<label for="notes" class="block text-sm font-medium text-gray-700">Notes</label>
<textarea name="notes" id="notes" rows="3"
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-indigo-500 sm:text-sm">{{.Order.Notes}}</textarea>
</div>
<p class="text-sm text-gray-500">{{if .IsNew}}You can add line items after creating the order.{{else}}Line items can be managed from the order detail page.{{end}}</p>
<div class="flex justify-end space-x-3">
<a href="{{if .IsNew}}/orders{{else}}/orders/{{.Order.ID}}{{end}}"
class="rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50">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">
{{if .IsNew}}Create Order{{else}}Update Order{{end}}
</button>
</div>
</form>
</div>
</div>
{{end}}

@ -0,0 +1,69 @@
{{define "content"}}
<div class="space-y-6">
<div class="sm:flex sm:items-center sm:justify-between">
<h1 class="text-2xl font-bold text-gray-900">Orders</h1>
<a href="/orders/new" class="inline-flex items-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500">
+ New Order
</a>
</div>
<!-- Status Filter -->
<div class="flex space-x-2">
<a href="/orders"
class="{{if eq .FilterStatus ""}}bg-indigo-100 text-indigo-700{{else}}bg-white text-gray-700 hover:bg-gray-50{{end}} rounded-md px-3 py-1.5 text-sm font-medium ring-1 ring-inset ring-gray-300">All</a>
<a href="/orders?status=draft"
class="{{if eq .FilterStatus "draft"}}bg-indigo-100 text-indigo-700{{else}}bg-white text-gray-700 hover:bg-gray-50{{end}} rounded-md px-3 py-1.5 text-sm font-medium ring-1 ring-inset ring-gray-300">Draft</a>
<a href="/orders?status=confirmed"
class="{{if eq .FilterStatus "confirmed"}}bg-indigo-100 text-indigo-700{{else}}bg-white text-gray-700 hover:bg-gray-50{{end}} rounded-md px-3 py-1.5 text-sm font-medium ring-1 ring-inset ring-gray-300">Confirmed</a>
<a href="/orders?status=fulfilled"
class="{{if eq .FilterStatus "fulfilled"}}bg-indigo-100 text-indigo-700{{else}}bg-white text-gray-700 hover:bg-gray-50{{end}} rounded-md px-3 py-1.5 text-sm font-medium ring-1 ring-inset ring-gray-300">Fulfilled</a>
<a href="/orders?status=cancelled"
class="{{if eq .FilterStatus "cancelled"}}bg-indigo-100 text-indigo-700{{else}}bg-white text-gray-700 hover:bg-gray-50{{end}} rounded-md px-3 py-1.5 text-sm font-medium ring-1 ring-inset ring-gray-300">Cancelled</a>
</div>
<div id="order-table">
{{template "order-table" .}}
</div>
</div>
{{end}}
{{define "order-table"}}
<div class="overflow-hidden bg-white shadow ring-1 ring-black ring-opacity-5 sm:rounded-lg">
<table class="min-w-full divide-y divide-gray-300">
<thead class="bg-gray-50">
<tr>
<th class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900">Order #</th>
<th class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Customer</th>
<th class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Date</th>
<th class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Status</th>
<th class="px-3 py-3.5 text-right text-sm font-semibold text-gray-900">Amount</th>
<th class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{{if .Orders}}
{{range .Orders}}
<tr class="hover:bg-gray-50">
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-indigo-600">
<a href="/orders/{{.ID}}">#{{.ID}}</a>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-900">
<a href="/customers/{{.CustomerID}}" class="hover:text-indigo-600">{{.CustomerName}}</a>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">{{formatDate .OrderDate}}</td>
<td class="whitespace-nowrap px-3 py-4 text-sm">{{statusBadge .Status}}</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-900 text-right">{{formatMoney .TotalAmount}}</td>
<td class="whitespace-nowrap px-3 py-4 text-sm">
<a href="/orders/{{.ID}}" class="text-indigo-600 hover:text-indigo-900">View</a>
</td>
</tr>
{{end}}
{{else}}
<tr>
<td colspan="6" class="px-3 py-8 text-center text-sm text-gray-500">No orders found. <a href="/orders/new" class="text-indigo-600 hover:text-indigo-500">Create one</a>.</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{end}}