commit 5530cfcdfd6ec27e9451e25e6cd39302af359f7e Author: Victor Broman Date: Fri Feb 6 17:35:29 2026 +0100 inital commit diff --git a/erp.db b/erp.db new file mode 100644 index 0000000..159e90a Binary files /dev/null and b/erp.db differ diff --git a/erp.db-shm b/erp.db-shm new file mode 100644 index 0000000..2059ece Binary files /dev/null and b/erp.db-shm differ diff --git a/erp.db-wal b/erp.db-wal new file mode 100644 index 0000000..956ef45 Binary files /dev/null and b/erp.db-wal differ diff --git a/erp_system b/erp_system new file mode 100755 index 0000000..3f7cea7 Binary files /dev/null and b/erp_system differ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7e60caa --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..762f4ce --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/database/database.go b/internal/database/database.go new file mode 100644 index 0000000..5dff372 --- /dev/null +++ b/internal/database/database.go @@ -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 +} diff --git a/internal/database/migrations.go b/internal/database/migrations.go new file mode 100644 index 0000000..18d0759 --- /dev/null +++ b/internal/database/migrations.go @@ -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 +} diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go new file mode 100644 index 0000000..1720318 --- /dev/null +++ b/internal/handlers/auth.go @@ -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) +} diff --git a/internal/handlers/customers.go b/internal/handlers/customers.go new file mode 100644 index 0000000..a6abe20 --- /dev/null +++ b/internal/handlers/customers.go @@ -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) +} diff --git a/internal/handlers/dashboard.go b/internal/handlers/dashboard.go new file mode 100644 index 0000000..27d1811 --- /dev/null +++ b/internal/handlers/dashboard.go @@ -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) +} diff --git a/internal/handlers/handler.go b/internal/handlers/handler.go new file mode 100644 index 0000000..e7bb906 --- /dev/null +++ b/internal/handlers/handler.go @@ -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(`%s`, 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(`%s`, 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 "" +} diff --git a/internal/handlers/invoices.go b/internal/handlers/invoices.go new file mode 100644 index 0000000..a601857 --- /dev/null +++ b/internal/handlers/invoices.go @@ -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) +} diff --git a/internal/handlers/ledger.go b/internal/handlers/ledger.go new file mode 100644 index 0000000..d462a14 --- /dev/null +++ b/internal/handlers/ledger.go @@ -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) +} diff --git a/internal/handlers/orders.go b/internal/handlers/orders.go new file mode 100644 index 0000000..63a83c6 --- /dev/null +++ b/internal/handlers/orders.go @@ -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) +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go new file mode 100644 index 0000000..46f4173 --- /dev/null +++ b/internal/middleware/auth.go @@ -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) + }) + } +} diff --git a/internal/models/customer.go b/internal/models/customer.go new file mode 100644 index 0000000..b984f1e --- /dev/null +++ b/internal/models/customer.go @@ -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 +} diff --git a/internal/models/invoice.go b/internal/models/invoice.go new file mode 100644 index 0000000..4fc977d --- /dev/null +++ b/internal/models/invoice.go @@ -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 +} diff --git a/internal/models/ledger.go b/internal/models/ledger.go new file mode 100644 index 0000000..518d88a --- /dev/null +++ b/internal/models/ledger.go @@ -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 +} diff --git a/internal/models/order.go b/internal/models/order.go new file mode 100644 index 0000000..59d38cc --- /dev/null +++ b/internal/models/order.go @@ -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 +} diff --git a/internal/models/user.go b/internal/models/user.go new file mode 100644 index 0000000..8b164fb --- /dev/null +++ b/internal/models/user.go @@ -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 +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..8d4d468 --- /dev/null +++ b/main.go @@ -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) + } +} diff --git a/templates/customers/detail.html b/templates/customers/detail.html new file mode 100644 index 0000000..3e726ab --- /dev/null +++ b/templates/customers/detail.html @@ -0,0 +1,104 @@ +{{define "content"}} +
+
+

{{.Customer.Name}}

+
+ ← All Customers + Edit + +
+
+ + +
+
+
+
+
Email
+
{{if .Customer.Email}}{{.Customer.Email}}{{else}}-{{end}}
+
+
+
Phone
+
{{if .Customer.Phone}}{{.Customer.Phone}}{{else}}-{{end}}
+
+
+
Address
+
{{if .Customer.Address}}{{.Customer.Address}}{{else}}-{{end}}
+
+
+
Created
+
{{.Customer.CreatedAt.Format "Jan 02, 2006"}}
+
+
+
+
+ + +
+
+
+

Orders

+ + New Order +
+ {{if .Orders}} + + + + + + + + + + + {{range .Orders}} + + + + + + + {{end}} + +
Order #DateStatusAmount
#{{.ID}}{{formatDate .OrderDate}}{{statusBadge .Status}}{{formatMoney .TotalAmount}}
+ {{else}} +

No orders yet.

+ {{end}} +
+
+ + +
+
+

Invoices

+ {{if .Invoices}} + + + + + + + + + + + {{range .Invoices}} + + + + + + + {{end}} + +
Invoice #StatusDue DateAmount
{{.InvoiceNumber}}{{statusBadge .Status}}{{formatDate .DueDate}}{{formatMoney .Amount}}
+ {{else}} +

No invoices yet.

+ {{end}} +
+
+
+{{end}} diff --git a/templates/customers/form.html b/templates/customers/form.html new file mode 100644 index 0000000..d8d7126 --- /dev/null +++ b/templates/customers/form.html @@ -0,0 +1,59 @@ +{{define "content"}} +
+
+

{{if .IsNew}}New Customer{{else}}Edit Customer{{end}}

+ ← Back +
+ + {{if .Error}} +
+
{{.Error}}
+
+ {{end}} + +
+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ + +
+ +
+ Cancel + +
+
+
+
+{{end}} diff --git a/templates/customers/list.html b/templates/customers/list.html new file mode 100644 index 0000000..7771e2a --- /dev/null +++ b/templates/customers/list.html @@ -0,0 +1,63 @@ +{{define "content"}} +
+
+

Customers

+ + + New Customer + +
+ + +
+ +
+ +
+ {{template "customer-table" .}} +
+
+{{end}} + +{{define "customer-table"}} +
+ + + + + + + + + + + {{if .Customers}} + {{range .Customers}} + + + + + + + {{end}} + {{else}} + + + + {{end}} + +
NameEmailPhoneActions
+ {{.Name}} + {{.Email}}{{.Phone}} + Edit + +
No customers found. Create one.
+
+{{end}} diff --git a/templates/dashboard.html b/templates/dashboard.html new file mode 100644 index 0000000..8061f3a --- /dev/null +++ b/templates/dashboard.html @@ -0,0 +1,76 @@ +{{define "content"}} +
+

Dashboard

+ + +
+ +
+
Total Customers
+
{{.CustomerCount}}
+ View all → +
+ + +
+
Open Orders
+
{{.OpenOrderCount}}
+ View all → +
+ + +
+
Pending Invoices
+
{{.PendingInvoices}}
+
{{formatMoney .OutstandingAmount}} outstanding
+ View all → +
+ + +
+
Revenue This Month
+
{{formatMoney .RevenueThisMonth}}
+ View ledger → +
+
+ + + + + +
+

Order Status Summary

+
+
+
{{.OpenOrderCount}}
+
Draft / Confirmed
+
+
+
{{.FulfilledOrders}}
+
Fulfilled
+
+
+
{{.PendingInvoices}}
+
Pending Invoices
+
+
+
{{formatMoney .RevenueThisMonth}}
+
Monthly Revenue
+
+
+
+
+{{end}} diff --git a/templates/invoices/detail.html b/templates/invoices/detail.html new file mode 100644 index 0000000..8021723 --- /dev/null +++ b/templates/invoices/detail.html @@ -0,0 +1,67 @@ +{{define "content"}} +
+
+
+

{{.Invoice.InvoiceNumber}}

+

{{.Invoice.CustomerName}}

+
+
+ ← All Invoices + {{if eq .Invoice.Status "pending"}} + + {{end}} +
+
+ +
+
+
+
+
Invoice Number
+
{{.Invoice.InvoiceNumber}}
+
+
+
Customer
+
+ {{.Invoice.CustomerName}} +
+
+
+
Status
+
{{statusBadge .Invoice.Status}}
+
+
+
Amount
+
{{formatMoney .Invoice.Amount}}
+
+
+
Due Date
+
{{formatDate .Invoice.DueDate}}
+
+ {{if .Invoice.PaidDate.Valid}} +
+
Paid Date
+
{{formatDate .Invoice.PaidDate.String}}
+
+ {{end}} + {{if .Invoice.OrderID.Valid}} + + {{end}} +
+
Created
+
{{.Invoice.CreatedAt.Format "Jan 02, 2006 3:04 PM"}}
+
+
+
+
+
+{{end}} diff --git a/templates/invoices/list.html b/templates/invoices/list.html new file mode 100644 index 0000000..7f5273d --- /dev/null +++ b/templates/invoices/list.html @@ -0,0 +1,67 @@ +{{define "content"}} +
+
+

Invoices

+
+ + +
+ All + Pending + Paid +
+ +
+ {{template "invoice-table" .}} +
+
+{{end}} + +{{define "invoice-table"}} +
+ + + + + + + + + + + + + {{if .Invoices}} + {{range .Invoices}} + + + + + + + + + {{end}} + {{else}} + + + + {{end}} + +
Invoice #CustomerStatusDue DateAmountActions
+ {{.InvoiceNumber}} + + {{.CustomerName}} + {{statusBadge .Status}}{{formatDate .DueDate}}{{formatMoney .Amount}} + View + {{if eq .Status "pending"}} + + {{end}} +
No invoices found. Invoices are auto-generated when orders are fulfilled.
+
+{{end}} diff --git a/templates/layout.html b/templates/layout.html new file mode 100644 index 0000000..835bafd --- /dev/null +++ b/templates/layout.html @@ -0,0 +1,63 @@ +{{define "layout"}} + + + + + + {{.Title}} - ERP System + + + + + +
+ + + + +
+
+ {{template "content" .}} +
+
+
+ + +{{end}} diff --git a/templates/ledger/chart_of_accounts.html b/templates/ledger/chart_of_accounts.html new file mode 100644 index 0000000..1dd007b --- /dev/null +++ b/templates/ledger/chart_of_accounts.html @@ -0,0 +1,34 @@ +{{define "content"}} +
+
+

Chart of Accounts

+ +
+ +
+ + + + + + + + + + + {{range .Accounts}} + + + + + + + {{end}} + +
CodeNameTypeBalance
{{.Code}}{{.Name}}{{accountTypeBadge .Type}}{{formatMoney .Balance}}
+
+
+{{end}} diff --git a/templates/ledger/journal_entries.html b/templates/ledger/journal_entries.html new file mode 100644 index 0000000..72b411d --- /dev/null +++ b/templates/ledger/journal_entries.html @@ -0,0 +1,49 @@ +{{define "content"}} +
+
+

Journal Entries

+ +
+ +
+ + + + + + + + + + + + + {{if .Entries}} + {{range .Entries}} + + + + + + + + + {{end}} + {{else}} + + + + {{end}} + +
#DateDescriptionReferenceDebitCredit
+ {{.ID}} + {{formatDate .EntryDate}}{{.Description}}{{.Reference}}{{formatMoney .TotalDebit}}{{formatMoney .TotalCredit}}
No journal entries yet. Create one.
+
+
+{{end}} diff --git a/templates/ledger/journal_entry_detail.html b/templates/ledger/journal_entry_detail.html new file mode 100644 index 0000000..440384c --- /dev/null +++ b/templates/ledger/journal_entry_detail.html @@ -0,0 +1,63 @@ +{{define "content"}} +
+
+
+

Journal Entry #{{.Entry.ID}}

+

{{formatDate .Entry.EntryDate}}

+
+ ← All Entries +
+ +
+
+
+
+
Date
+
{{formatDate .Entry.EntryDate}}
+
+
+
Description
+
{{.Entry.Description}}
+
+
+
Reference
+
{{if .Entry.Reference}}{{.Entry.Reference}}{{else}}-{{end}}
+
+
+ +

Lines

+ + + + + + + + + + {{range .Entry.Lines}} + + + + + + {{end}} + + + + + + + + +
AccountDebitCredit
+ {{.AccountCode}} {{.AccountName}} + + {{if gt .Debit 0.0}}{{formatMoney .Debit}}{{else}}-{{end}} + + {{if gt .Credit 0.0}}{{formatMoney .Credit}}{{else}}-{{end}} +
Totals{{formatMoney .Entry.TotalDebit}}{{formatMoney .Entry.TotalCredit}}
+
+
+
+{{end}} diff --git a/templates/ledger/journal_entry_form.html b/templates/ledger/journal_entry_form.html new file mode 100644 index 0000000..fcfa9bb --- /dev/null +++ b/templates/ledger/journal_entry_form.html @@ -0,0 +1,106 @@ +{{define "content"}} +
+
+

New Journal Entry

+ ← Back +
+ + {{if .Error}} +
+
{{.Error}}
+
+ {{end}} + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+

Lines (debits must equal credits)

+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ + +
+ +
+ Cancel + +
+
+
+
+ + +{{end}} diff --git a/templates/ledger/trial_balance.html b/templates/ledger/trial_balance.html new file mode 100644 index 0000000..8b846eb --- /dev/null +++ b/templates/ledger/trial_balance.html @@ -0,0 +1,61 @@ +{{define "content"}} +
+
+

Trial Balance

+ +
+ +
+ + + + + + + + + + + + {{range .Rows}} + {{if or (gt .Debit 0.0) (gt .Credit 0.0)}} + + + + + + + + {{end}} + {{end}} + + + + + + + + + + + + +
CodeAccount NameTypeDebitCredit
{{.Code}}{{.Name}}{{accountTypeBadge .Type}} + {{if gt .Debit 0.0}}{{formatMoney .Debit}}{{else}}-{{end}} + + {{if gt .Credit 0.0}}{{formatMoney .Credit}}{{else}}-{{end}} +
Totals{{formatMoney .TotalDebit}}{{formatMoney .TotalCredit}}
Difference (should be $0.00) + {{formatMoney (sub .TotalDebit .TotalCredit)}} +
+
+ + {{if not .Rows}} +
+ No transactions recorded yet. The trial balance will populate as journal entries are created. +
+ {{end}} +
+{{end}} diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..70eaa8c --- /dev/null +++ b/templates/login.html @@ -0,0 +1,50 @@ + + + + + + Login - ERP System + + + + +
+
+
+

ERP System

+

Sign in to your account

+
+ + {{if .Error}} +
+
+
{{.Error}}
+
+
+ {{end}} + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + diff --git a/templates/orders/detail.html b/templates/orders/detail.html new file mode 100644 index 0000000..dee6aa1 --- /dev/null +++ b/templates/orders/detail.html @@ -0,0 +1,147 @@ +{{define "content"}} +
+
+
+

Order #{{.Order.ID}}

+

{{.Order.CustomerName}} · {{formatDate .Order.OrderDate}}

+
+
+ ← All Orders + {{if eq .Order.Status "draft"}} + Edit + + + {{end}} + {{if eq .Order.Status "confirmed"}} + + + {{end}} +
+
+ + +
+
+
+
+
Customer
+
{{.Order.CustomerName}}
+
+
+
Date
+
{{formatDate .Order.OrderDate}}
+
+
+
Status
+
{{statusBadge .Order.Status}}
+
+
+
Total
+
{{formatMoney .Order.TotalAmount}}
+
+ {{if .Order.Notes}} +
+
Notes
+
{{.Order.Notes}}
+
+ {{end}} +
+
+
+ + +
+ {{template "order-lines" .Order}} +
+
+{{end}} + +{{define "order-lines"}} +
+
+

Line Items

+ + + + + + + + + {{if eq .Status "draft"}} + + {{end}} + + + + {{range .Lines}} + + + + + + {{if eq $.Status "draft"}} + + {{end}} + + {{else}} + + + + {{end}} + + + + + + {{if eq .Status "draft"}}{{end}} + + +
DescriptionQtyUnit PriceTotal
{{.Description}}{{printf "%.0f" .Quantity}}{{formatMoney .UnitPrice}}{{formatMoney .LineTotal}} + +
No line items yet.
Total{{formatMoney .TotalAmount}}
+ + {{if eq .Status "draft"}} + +
+

Add Line Item

+
+
+ +
+
+ +
+
+ +
+
+
+ +
+
+ {{end}} +
+
+{{end}} diff --git a/templates/orders/form.html b/templates/orders/form.html new file mode 100644 index 0000000..aa2fac0 --- /dev/null +++ b/templates/orders/form.html @@ -0,0 +1,60 @@ +{{define "content"}} +
+
+

{{if .IsNew}}New Order{{else}}Edit Order #{{.Order.ID}}{{end}}

+ ← Back +
+ + {{if .Error}} +
+
{{.Error}}
+
+ {{end}} + +
+
+ +
+
+ + +
+ +
+ + +
+
+ +
+ + +
+ +

{{if .IsNew}}You can add line items after creating the order.{{else}}Line items can be managed from the order detail page.{{end}}

+ +
+ Cancel + +
+
+
+
+{{end}} diff --git a/templates/orders/list.html b/templates/orders/list.html new file mode 100644 index 0000000..1382f19 --- /dev/null +++ b/templates/orders/list.html @@ -0,0 +1,69 @@ +{{define "content"}} +
+
+

Orders

+ + + New Order + +
+ + + + +
+ {{template "order-table" .}} +
+
+{{end}} + +{{define "order-table"}} +
+ + + + + + + + + + + + + {{if .Orders}} + {{range .Orders}} + + + + + + + + + {{end}} + {{else}} + + + + {{end}} + +
Order #CustomerDateStatusAmountActions
+ #{{.ID}} + + {{.CustomerName}} + {{formatDate .OrderDate}}{{statusBadge .Status}}{{formatMoney .TotalAmount}} + View +
No orders found. Create one.
+
+{{end}}