inital commit
This commit is contained in:
commit
5530cfcdfd
BIN
erp.db
Normal file
BIN
erp.db
Normal file
Binary file not shown.
BIN
erp.db-shm
Normal file
BIN
erp.db-shm
Normal file
Binary file not shown.
BIN
erp.db-wal
Normal file
BIN
erp.db-wal
Normal file
Binary file not shown.
BIN
erp_system
Executable file
BIN
erp_system
Executable file
Binary file not shown.
20
go.mod
Normal file
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
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=
|
||||
33
internal/database/database.go
Normal file
33
internal/database/database.go
Normal file
@ -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
|
||||
}
|
||||
164
internal/database/migrations.go
Normal file
164
internal/database/migrations.go
Normal file
@ -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
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)
|
||||
}
|
||||
175
internal/handlers/customers.go
Normal file
175
internal/handlers/customers.go
Normal file
@ -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)
|
||||
}
|
||||
23
internal/handlers/dashboard.go
Normal file
23
internal/handlers/dashboard.go
Normal file
@ -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)
|
||||
}
|
||||
108
internal/handlers/handler.go
Normal file
108
internal/handlers/handler.go
Normal file
@ -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 ""
|
||||
}
|
||||
64
internal/handlers/invoices.go
Normal file
64
internal/handlers/invoices.go
Normal file
@ -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
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
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)
|
||||
}
|
||||
27
internal/middleware/auth.go
Normal file
27
internal/middleware/auth.go
Normal file
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
86
internal/models/customer.go
Normal file
86
internal/models/customer.go
Normal file
@ -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
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
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
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
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
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)
|
||||
}
|
||||
}
|
||||
104
templates/customers/detail.html
Normal file
104
templates/customers/detail.html
Normal file
@ -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">← 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}}
|
||||
59
templates/customers/form.html
Normal file
59
templates/customers/form.html
Normal file
@ -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">← 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}}
|
||||
63
templates/customers/list.html
Normal file
63
templates/customers/list.html
Normal file
@ -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
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 →</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 →</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 →</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 →</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}}
|
||||
67
templates/invoices/detail.html
Normal file
67
templates/invoices/detail.html
Normal file
@ -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">← 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}}
|
||||
67
templates/invoices/list.html
Normal file
67
templates/invoices/list.html
Normal file
@ -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
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}}
|
||||
34
templates/ledger/chart_of_accounts.html
Normal file
34
templates/ledger/chart_of_accounts.html
Normal file
@ -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}}
|
||||
49
templates/ledger/journal_entries.html
Normal file
49
templates/ledger/journal_entries.html
Normal file
@ -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}}
|
||||
63
templates/ledger/journal_entry_detail.html
Normal file
63
templates/ledger/journal_entry_detail.html
Normal file
@ -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">← 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}}
|
||||
106
templates/ledger/journal_entry_form.html
Normal file
106
templates/ledger/journal_entry_form.html
Normal file
@ -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">← 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}}
|
||||
61
templates/ledger/trial_balance.html
Normal file
61
templates/ledger/trial_balance.html
Normal file
@ -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
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>
|
||||
147
templates/orders/detail.html
Normal file
147
templates/orders/detail.html
Normal file
@ -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}} · {{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">← 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}}
|
||||
60
templates/orders/form.html
Normal file
60
templates/orders/form.html
Normal file
@ -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">← 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}}
|
||||
69
templates/orders/list.html
Normal file
69
templates/orders/list.html
Normal file
@ -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}}
|
||||
Loading…
x
Reference in New Issue
Block a user