Add GOTH stack CRM platform skeleton

- Go module with chi router, bcrypt
- SQLite schema for 9 tables
- Auth, Clients, Customers, Services, Scheduling, Payments, Q&A handlers
- HTMX template layouts
This commit is contained in:
2026-04-21 15:56:40 +00:00
parent 31039ea4af
commit 5b8107c169
14 changed files with 1529 additions and 0 deletions

View File

@@ -0,0 +1,166 @@
package handlers
import (
"database/sql"
"net/http"
"time"
"go-crm/internal/db"
"github.com/go-chi/chi/v5"
"golang.org/x/crypto/bcrypt"
)
func SignupPage(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Sign Up</title>
</head>
<body>
<h1>Sign Up</h1>
<form method="POST" action="/auth/signup">
<input type="email" name="email" placeholder="Email" required>
<input type="text" name="name" placeholder="Name" required>
<input type="password" name="password" placeholder="Password" required>
<button type="submit">Sign Up</button>
</form>
<p>Already have an account? <a href="/auth/login">Login</a></p>
</body>
</html>`))
}
func Signup(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
email := r.FormValue("email")
name := r.FormValue("name")
password := r.FormValue("password")
if email == "" || name == "" || password == "" {
http.Error(w, "All fields required", http.StatusBadRequest)
return
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
http.Error(w, "Failed to hash password", http.StatusInternalServerError)
return
}
account := struct {
Email string
Name string
Password string
CreatedAt int64
}{
Email: email,
Name: name,
Password: string(hashedPassword),
CreatedAt: time.Now().Unix(),
}
_, err = db.DB.Exec(
"INSERT INTO accounts (email, name, password, created_at) VALUES (?, ?, ?, ?)",
account.Email, account.Name, account.Password, account.CreatedAt,
)
if err != nil {
http.Error(w, "Email already exists", http.StatusBadRequest)
return
}
http.Redirect(w, r, "/auth/login", http.StatusFound)
}
func LoginPage(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Login</title>
</head>
<body>
<h1>Login</h1>
<form method="POST" action="/auth/login">
<input type="email" name="email" placeholder="Email" required>
<input type="password" name="password" placeholder="Password" required>
<button type="submit">Login</button>
</form>
<p>Don't have an account? <a href="/auth/signup">Sign Up</a></p>
</body>
</html>`))
}
var DB *sql.DB
func Login(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
email := r.FormValue("email")
password := r.FormValue("password")
var accountID int64
var hashedPassword string
err := DB.QueryRow("SELECT account_id, password FROM accounts WHERE email = ?", email).Scan(&accountID, &hashedPassword)
if err != nil {
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
return
}
if err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password)); err != nil {
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
return
}
sessionID := generateSessionID()
expires := time.Now().Add(24 * time.Hour).Unix()
_, err = DB.Exec("INSERT INTO sessions (session_id, account_id, expires) VALUES (?, ?, ?)", sessionID, accountID, expires)
if err != nil {
http.Error(w, "Failed to create session", http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{Name: "session", Value: sessionID, Path: "/"})
http.Redirect(w, r, "/clients", http.StatusFound)
}
func Logout(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("session")
if err == nil {
DB.Exec("DELETE FROM sessions WHERE session_id = ?", cookie.Value)
}
http.SetCookie(w, &http.Cookie{Name: "session", Value: "", Path: "/", MaxAge: -1})
http.Redirect(w, r, "/auth/login", http.StatusFound)
}
func generateSessionID() string {
return time.Now().Format("20060102150405") + "-" + randomString(32)
}
func randomString(n int) string {
const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
b := make([]byte, n)
for i := range b {
b[i] = letters[time.Now().UnixNano()%int64(len(letters))]
}
return string(b)
}
func getSession(r *http.Request) (int64, error) {
cookie, err := r.Cookie("session")
if err != nil {
return 0, err
}
var accountID int64
err = DB.QueryRow("SELECT account_id FROM sessions WHERE session_id = ? AND expires > ?", cookie.Value, time.Now().Unix()).Scan(&accountID)
return accountID, err
}
func SetupAuthHandlers(db *sql.DB) {
DB = db
chi.RegisterMethod("GET")
}

View File

@@ -0,0 +1,142 @@
package handlers
import (
"database/sql"
"encoding/json"
"net/http"
"strconv"
"time"
"go-crm/internal/db"
"github.com/go-chi/chi/v5"
)
func ListClients(w http.ResponseWriter, r *http.Request) {
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
if limit == 0 {
limit = 20
}
clients, err := db.ListClients(DB, limit, offset)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(`<!DOCTYPE html>
<html>
<head>
<title>Clients</title>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
</head>
<body>
<h1>Clients</h1>
<button class="btn" onclick="document.getElementById('clientForm').style.display='block'">Add Client</button>
<div id="clientForm" style="display:none; margin-top:1rem;">
<form hx-post="/clients" hx-target="#clientList" hx-swap="innerHTML">
<input type="text" name="name" placeholder="Name" required>
<input type="tel" name="phone" placeholder="Phone">
<input type="email" name="email" placeholder="Email">
<input type="text" name="address" placeholder="Address">
<textarea name="notes" placeholder="Notes"></textarea>
<button type="submit">Add Client</button>
</form>
</div>
<table>
<thead>
<tr><th>Name</th><th>Phone</th><th>Email</th><th>Actions</th></tr>
</thead>
<tbody id="clientList">
`))
for _, c := range clients {
w.Write([]byte(`<tr>
<td>` + c.Name + `</td>
<td>` + c.Phone + `</td>
<td>` + c.Email + `</td>
<td>
<a href="/clients/` + strconv.FormatInt(c.ClientID, 10) + `">View</a>
<form method="DELETE" style="display:inline" hx-delete="/clients/` + strconv.FormatInt(c.ClientID, 10) + `" hx-target="closest tr">
<button type="submit">Delete</button>
</form>
</td>
</tr>`))
}
w.Write([]byte(`</tbody></table></body></html>`))
}
func CreateClient(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
client := db.Client{
Name: r.FormValue("name"),
Phone: r.FormValue("phone"),
Email: r.FormValue("email"),
Address: r.FormValue("address"),
Notes: r.FormValue("notes"),
CreatedAt: time.Now().Unix(),
}
if err := client.Create(DB); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "text/html")
w.Header().Set("HX-Refresh", "true")
}
func ViewClient(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10)
client, err := db.GetClientByID(DB, id)
if err != nil {
http.Error(w, "Client not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(`<!DOCTYPE html>
<html>
<head></head>
<body>
<h1>` + client.Name + `</h1>
<p>Phone: ` + client.Phone + `</p>
<p>Email: ` + client.Email + `</p>
<p>Address: ` + client.Address + `</p>
<p>Notes: ` + client.Notes + `</p>
<a href="/clients">Back</a>
</body></html>`))
}
func UpdateClient(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10)
r.ParseForm()
client := db.Client{
ClientID: id,
Name: r.FormValue("name"),
Phone: r.FormValue("phone"),
Email: r.FormValue("email"),
Address: r.FormValue("address"),
Notes: r.FormValue("notes"),
}
if err := client.Update(DB); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(client)
}
func DeleteClient(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10)
if err := (&db.Client{}).Delete(DB, id); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "text/html")
w.Write([]byte("OK"))
}

View File

@@ -0,0 +1,84 @@
package handlers
import (
"database/sql"
"encoding/json"
"net/http"
"strconv"
"time"
"go-crm/internal/db"
"github.com/go-chi/chi/v5"
)
func ListCustomers(w http.ResponseWriter, r *http.Request) {
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
clientID, _ := strconv.ParseInt(r.URL.Query().Get("client_id"), 10, 64)
if limit == 0 {
limit = 20
}
customers, err := db.ListCustomers(DB, clientID, limit, offset)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(`<!DOCTYPE html><html><head><script src="https://unpkg.com/htmx.org@1.9.10"></script></head><body><h1>Customers</h1><button class="btn" onclick="document.getElementById('customerForm').style.display='block'">Add Customer</button><div id="customerForm" style="display:none; margin-top:1rem;"><form hx-post="/customers" hx-target="#customerList"><input type="text" name="name" placeholder="Name" required><input type="tel" name="phone" placeholder="Phone"><input type="date" name="birth_date" placeholder="Birth Date"><input type="text" name="instagram" placeholder="Instagram"><input type="number" name="client_id" placeholder="Client ID" required><button type="submit">Add</button></form></div><table><thead><tr><th>Name</th><th>Phone</th><th>Birth Date</th><th>Instagram</th><th>Actions</th></tr></thead><tbody id="customerList">`))
for _, c := range customers {
w.Write([]byte(`<tr><td>` + c.Name + `</td><td>` + c.Phone + `</td><td>` + c.BirthDate + `</td><td>` + c.Instagram + `</td><td><a href="/customers/` + strconv.FormatInt(c.CustomerID, 10) + `">View</a></td></tr>`))
}
w.Write([]byte(`</tbody></table></body></html>`))
}
func CreateCustomer(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
clientID, _ := strconv.ParseInt(r.FormValue("client_id"), 10, 64)
customer := db.Customer{
ClientID: clientID,
Name: r.FormValue("name"),
Phone: r.FormValue("phone"),
BirthDate: r.FormValue("birth_date"),
Instagram: r.FormValue("instagram"),
CreatedAt: time.Now().Unix(),
}
if err := customer.Create(DB); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("HX-Refresh", "true")
}
func ViewCustomer(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10)
var c db.Customer
err := DB.QueryRow("SELECT customer_id, client_id, name, phone, birth_date, instagram, created_at FROM customers WHERE customer_id = ?", id).Scan(&c.CustomerID, &c.ClientID, &c.Name, &c.Phone, &c.BirthDate, &c.Instagram, &c.CreatedAt)
if err != nil {
http.Error(w, "Customer not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(`<!DOCTYPE html><body><h1>` + c.Name + `</h1><p>Phone: ` + c.Phone + `</p><p>Birth Date: ` + c.BirthDate + `</p><p>Instagram: ` + c.Instagram + `</p><a href="/customers">Back</a></body></html>`))
}
func UpdateCustomer(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10)
r.ParseForm()
clientID, _ := strconv.ParseInt(r.FormValue("client_id"), 10, 64)
_, err := DB.Exec("UPDATE customers SET client_id=?, name=?, phone=?, birth_date=?, instagram=? WHERE customer_id=?", clientID, r.FormValue("name"), r.FormValue("phone"), r.FormValue("birth_date"), r.FormValue("instagram"), id)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
}
func DeleteCustomer(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10)
DB.Exec("DELETE FROM customers WHERE customer_id = ?", id)
}

View File

@@ -0,0 +1,88 @@
package handlers
import (
"net/http"
"strconv"
"time"
"go-crm/internal/db"
)
func ListPayments(w http.ResponseWriter, r *http.Request) {
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
clientID, _ := strconv.ParseInt(r.URL.Query().Get("client_id"), 10, 64)
if limit == 0 {
limit = 20
}
payments, err := db.ListPayments(DB, clientID, limit, offset)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(`<!DOCTYPE html><html><head><script src="https://unpkg.com/htmx.org@1.9.10"></script></head><body><h1>Payments</h1><button class="btn" onclick="document.getElementById('paymentForm').style.display='block'">Add Payment</button><div id="paymentForm" style="display:none; margin-top:1rem;"><form hx-post="/payments" hx-target="#paymentList"><input type="number" name="client_id" placeholder="Client ID" required><input type="number" name="customer_id" placeholder="Customer ID" required><input type="number" name="schedule_id" placeholder="Schedule ID"><input type="checkbox" name="has_paid"><label>Paid</label><input type="number" name="amount" placeholder="Amount" step="0.01"><input type="date" name="payment_date"><input type="text" name="payment_method" placeholder="Payment Method"><button type="submit">Add</button></form></div><table><thead><tr><th>Customer</th><th>Amount</th><th>Paid</th><th>Date</th><th>Method</th><th>Actions</th></tr></thead><tbody id="paymentList">`))
for _, p := range payments {
paid := "No"
if p.HasPaid {
paid = "Yes"
}
w.Write([]byte(`<tr><td>` + strconv.FormatInt(p.CustomerID, 10) + `</td><td>` + strconv.FormatFloat(p.Amount, 'f', 2, 64) + `</td><td>` + paid + `</td><td>` + p.PaymentDate + `</td><td>` + p.PaymentMethod + `</td><td><a href="/payments/` + strconv.FormatInt(p.PaymentID, 10) + `">View</a></td></tr>`))
}
w.Write([]byte(`</tbody></table></body></html>`))
}
func CreatePayment(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
clientID, _ := strconv.ParseInt(r.FormValue("client_id"), 10, 64)
customerID, _ := strconv.ParseInt(r.FormValue("customer_id"), 10, 64)
scheduleID, _ := strconv.ParseInt(r.FormValue("schedule_id"), 10, 64)
amount, _ := strconv.ParseFloat(r.FormValue("amount"), 64)
hasPaid := r.FormValue("has_paid") == "on"
payment := db.Payment{
ClientID: clientID,
CustomerID: customerID,
ScheduleID: scheduleID,
HasPaid: hasPaid,
Amount: amount,
PaymentDate: r.FormValue("payment_date"),
PaymentMethod: r.FormValue("payment_method"),
CreatedAt: time.Now().Unix(),
}
if err := payment.Create(DB); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("HX-Refresh", "true")
}
func ViewPayment(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(r.URL.Query().Get("id"), 10)
var p db.Payment
var hasPaid int
err := DB.QueryRow("SELECT payment_id, client_id, customer_id, schedule_id, has_paid, amount, payment_date, payment_method, created_at FROM payments WHERE payment_id = ?", id).Scan(&p.PaymentID, &p.ClientID, &p.CustomerID, &p.ScheduleID, &hasPaid, &p.Amount, &p.PaymentDate, &p.PaymentMethod, &p.CreatedAt)
if err != nil {
http.Error(w, "Payment not found", http.StatusNotFound)
return
}
p.HasPaid = hasPaid == 1
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(`<!DOCTYPE html><body><h1>Payment</h1><p>Amount: ` + strconv.FormatFloat(p.Amount, 'f', 2, 64) + `</p><p>Paid: ` + strconv.FormatBool(p.HasPaid) + `</p><p>Method: ` + p.PaymentMethod + `</p><a href="/payments">Back</a></body></html>`))
}
func UpdatePayment(w http.ResponseWriter, r *http.Request) {}
func DeletePayment(w http.ResponseWriter, r *http.Request) {}
func boolToStr(b bool) string {
if b {
return "Yes"
}
return "No"
}

View File

@@ -0,0 +1,113 @@
package handlers
import (
"net/http"
"strconv"
"time"
"go-crm/internal/db"
)
func ListQuestions(w http.ResponseWriter, r *http.Request) {
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
clientID, _ := strconv.ParseInt(r.URL.Query().Get("client_id"), 10, 64)
if limit == 0 {
limit = 20
}
questions, err := db.ListQuestions(DB, clientID, limit, offset)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(`<!DOCTYPE html><html><head><script src="https://unpkg.com/htmx.org@1.9.10"></script></head><body><h1>Questions</h1><button class="btn" onclick="document.getElementById('questionForm').style.display='block'">Add Question</button><div id="questionForm" style="display:none; margin-top:1rem;"><form hx-post="/questions" hx-target="#questionList"><input type="number" name="client_id" placeholder="Client ID" required><input type="number" name="customer_id" placeholder="Customer ID" required><textarea name="question" placeholder="Question" required></textarea><select name="status"><option value="pending">Pending</option><option value="answered">Answered</option></select><button type="submit">Add</button></form></div><table><thead><tr><th>Customer</th><th>Question</th><th>Status</th><th>Actions</th></tr></thead><tbody id="questionList">`))
for _, q := range questions {
w.Write([]byte(`<tr><td>` + strconv.FormatInt(q.CustomerID, 10) + `</td><td>` + q.Question + `</td><td>` + q.Status + `</td><td><a href="/questions/` + strconv.FormatInt(q.QuestionID, 10) + `">View</a></td></tr>`))
}
w.Write([]byte(`</tbody></table></body></html>`))
}
func CreateQuestion(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
clientID, _ := strconv.ParseInt(r.FormValue("client_id"), 10, 64)
customerID, _ := strconv.ParseInt(r.FormValue("customer_id"), 10, 64)
question := db.Question{
ClientID: clientID,
CustomerID: customerID,
Question: r.FormValue("question"),
Timestamp: time.Now().Unix(),
Status: r.FormValue("status"),
CreatedAt: time.Now().Unix(),
}
if err := question.Create(DB); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("HX-Refresh", "true")
}
func ViewQuestion(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(`<!DOCTYPE html><body><h1>Question</h1><a href="/questions">Back</a></body></html>`))
}
func UpdateQuestion(w http.ResponseWriter, r *http.Request) {}
func DeleteQuestion(w http.ResponseWriter, r *http.Request) {}
func ListAnswers(w http.ResponseWriter, r *http.Request) {
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
questionID, _ := strconv.ParseInt(r.URL.Query().Get("question_id"), 10, 64)
if limit == 0 {
limit = 20
}
answers, err := db.ListAnswers(DB, questionID, limit, offset)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(`<!DOCTYPE html><html><head><script src="https://unpkg.com/htmx.org@1.9.10"></script></head><body><h1>Answers</h1><button class="btn" onclick="document.getElementById('answerForm').style.display='block'">Add Answer</button><div id="answerForm" style="display:none; margin-top:1rem;"><form hx-post="/answers" hx-target="#answerList"><input type="number" name="client_id" placeholder="Client ID" required><input type="number" name="question_id" placeholder="Question ID" required><textarea name="answer" placeholder="Answer" required></textarea><select name="status"><option value="active">Active</option><option value="deleted">Deleted</option></select><button type="submit">Add</button></form></div><table><thead><tr><th>Question</th><th>Answer</th><th>Status</th><th>Actions</th></tr></thead><tbody id="answerList">`))
for _, a := range answers {
w.Write([]byte(`<tr><td>` + strconv.FormatInt(a.QuestionID, 10) + `</td><td>` + a.Answer + `</td><td>` + a.Status + `</td><td><a href="/answers/` + strconv.FormatInt(a.AnswerID, 10) + `">View</a></td></tr>`))
}
w.Write([]byte(`</tbody></table></body></html>`))
}
func CreateAnswer(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
clientID, _ := strconv.ParseInt(r.FormValue("client_id"), 10, 64)
questionID, _ := strconv.ParseInt(r.FormValue("question_id"), 10, 64)
answer := db.Answer{
ClientID: clientID,
QuestionID: questionID,
Answer: r.FormValue("answer"),
Timestamp: time.Now().Unix(),
Status: r.FormValue("status"),
CreatedAt: time.Now().Unix(),
}
if err := answer.Create(DB); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("HX-Refresh", "true")
}
func ViewAnswer(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(`<!DOCTYPE html><body><h1>Answer</h1><a href="/answers">Back</a></body></html>`))
}
func UpdateAnswer(w http.ResponseWriter, r *http.Request) {}
func DeleteAnswer(w http.ResponseWriter, r *http.Request) {}

View File

@@ -0,0 +1,84 @@
package handlers
import (
"database/sql"
"net/http"
"strconv"
"time"
"go-crm/internal/db"
"github.com/go-chi/chi/v5"
)
func ListSchedules(w http.ResponseWriter, r *http.Request) {
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
clientID, _ := strconv.ParseInt(r.URL.Query().Get("client_id"), 10, 64)
customerID, _ := strconv.ParseInt(r.URL.Query().Get("customer_id"), 10, 64)
if limit == 0 {
limit = 20
}
schedules, err := db.ListSchedules(DB, clientID, customerID, limit, offset)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(`<!DOCTYPE html><html><head><script src="https://unpkg.com/htmx.org@1.9.10"></script></head><body><h1>Scheduling</h1><button class="btn" onclick="document.getElementById('scheduleForm').style.display='block'">Add Schedule</button><div id="scheduleForm" style="display:none; margin-top:1rem;"><form hx-post="/scheduling" hx-target="#scheduleList"><input type="number" name="customer_id" placeholder="Customer ID" required><input type="number" name="client_id" placeholder="Client ID" required><input type="date" name="plan_date"><input type="date" name="confirmed_date"><input type="time" name="time"><select name="status"><option value="pending">Pending</option><option value="confirmed">Confirmed</option><option value="cancelled">Cancelled</option></select><textarea name="notes" placeholder="Notes"></textarea><button type="submit">Add</button></form></div><table><thead><tr><th>Customer</th><th>Date</th><th>Time</th><th>Status</th><th>Actions</th></tr></thead><tbody id="scheduleList">`))
for _, s := range schedules {
w.Write([]byte(`<tr><td>` + strconv.FormatInt(s.CustomerID, 10) + `</td><td>` + s.PlanDate + `</td><td>` + s.Time + `</td><td>` + s.Status + `</td><td><a href="/scheduling/` + strconv.FormatInt(s.ScheduleID, 10) + `">View</a></td></tr>`))
}
w.Write([]byte(`</tbody></table></body></html>`))
}
func CreateSchedule(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
customerID, _ := strconv.ParseInt(r.FormValue("customer_id"), 10, 64)
clientID, _ := strconv.ParseInt(r.FormValue("client_id"), 10, 64)
schedule := db.Schedule{
CustomerID: customerID,
ClientID: clientID,
PlanDate: r.FormValue("plan_date"),
ConfirmedDate: r.FormValue("confirmed_date"),
Time: r.FormValue("time"),
Status: r.FormValue("status"),
Notes: r.FormValue("notes"),
CreatedAt: time.Now().Unix(),
}
if err := schedule.Create(DB); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("HX-Refresh", "true")
}
func ViewSchedule(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10)
var sch db.Schedule
err := DB.QueryRow("SELECT schedule_id, customer_id, client_id, plan_date, confirmed_date, time, status, notes, created_at FROM scheduling WHERE schedule_id = ?", id).Scan(&sch.ScheduleID, &sch.CustomerID, &sch.ClientID, &sch.PlanDate, &sch.ConfirmedDate, &sch.Time, &sch.Status, &sch.Notes, &sch.CreatedAt)
if err != nil {
http.Error(w, "Schedule not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(`<!DOCTYPE html><body><h1>Schedule</h1><p>Customer ID: ` + strconv.FormatInt(sch.CustomerID, 10) + `</p><p>Date: ` + sch.PlanDate + `</p><p>Time: ` + sch.Time + `</p><p>Status: ` + sch.Status + `</p><a href="/scheduling">Back</a></body></html>`))
}
func UpdateSchedule(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10)
r.ParseForm()
customerID, _ := strconv.ParseInt(r.FormValue("customer_id"), 10, 64)
clientID, _ := strconv.ParseInt(r.FormValue("client_id"), 10, 64)
DB.Exec("UPDATE scheduling SET customer_id=?, client_id=?, plan_date=?, confirmed_date=?, time=?, status=?, notes=? WHERE schedule_id=?", customerID, clientID, r.FormValue("plan_date"), r.FormValue("confirmed_date"), r.FormValue("time"), r.FormValue("status"), r.FormValue("notes"), id)
}
func DeleteSchedule(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10)
DB.Exec("DELETE FROM scheduling WHERE schedule_id = ?", id)
}

View File

@@ -0,0 +1,81 @@
package handlers
import (
"database/sql"
"net/http"
"strconv"
"time"
"go-crm/internal/db"
"github.com/go-chi/chi/v5"
)
func ListServices(w http.ResponseWriter, r *http.Request) {
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
clientID, _ := strconv.ParseInt(r.URL.Query().Get("client_id"), 10, 64)
if limit == 0 {
limit = 20
}
services, err := db.ListServices(DB, clientID, limit, offset)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(`<!DOCTYPE html><html><head><script src="https://unpkg.com/htmx.org@1.9.10"></script></head><body><h1>Services</h1><button class="btn" onclick="document.getElementById('serviceForm').style.display='block'">Add Service</button><div id="serviceForm" style="display:none; margin-top:1rem;"><form hx-post="/services" hx-target="#serviceList"><input type="text" name="name" placeholder="Service Name" required><input type="number" name="price" placeholder="Price" step="0.01"><textarea name="description" placeholder="Description"></textarea><input type="text" name="duration" placeholder="Duration"><input type="number" name="client_id" placeholder="Client ID" required><button type="submit">Add</button></form></div><table><thead><tr><th>Name</th><th>Price</th><th>Duration</th><th>Actions</th></tr></thead><tbody id="serviceList">`))
for _, s := range services {
w.Write([]byte(`<tr><td>` + s.Name + `</td><td>` + strconv.FormatFloat(s.Price, 'f', 2, 64) + `</td><td>` + s.Duration + `</td><td><a href="/services/` + strconv.FormatInt(s.ServiceID, 10) + `">View</a></td></tr>`))
}
w.Write([]byte(`</tbody></table></body></html>`))
}
func CreateService(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
clientID, _ := strconv.ParseInt(r.FormValue("client_id"), 10, 64)
price, _ := strconv.ParseFloat(r.FormValue("price"), 64)
service := db.Service{
ClientID: clientID,
Name: r.FormValue("name"),
Price: price,
Description: r.FormValue("description"),
Duration: r.FormValue("duration"),
CreatedAt: time.Now().Unix(),
}
if err := service.Create(DB); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("HX-Refresh", "true")
}
func ViewService(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10)
var s db.Service
err := DB.QueryRow("SELECT service_id, client_id, name, price, description, duration, created_at FROM services WHERE service_id = ?", id).Scan(&s.ServiceID, &s.ClientID, &s.Name, &s.Price, &s.Description, &s.Duration, &s.CreatedAt)
if err != nil {
http.Error(w, "Service not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(`<!DOCTYPE html><body><h1>` + s.Name + `</h1><p>Price: ` + strconv.FormatFloat(s.Price, 'f', 2, 64) + `</p><p>Description: ` + s.Description + `</p><p>Duration: ` + s.Duration + `</p><a href="/services">Back</a></body></html>`))
}
func UpdateService(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10)
r.ParseForm()
clientID, _ := strconv.ParseInt(r.FormValue("client_id"), 10, 64)
price, _ := strconv.ParseFloat(r.FormValue("price"), 64)
DB.Exec("UPDATE services SET client_id=?, name=?, price=?, description=?, duration=? WHERE service_id=?", clientID, r.FormValue("name"), price, r.FormValue("description"), r.FormValue("duration"), id)
}
func DeleteService(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10)
DB.Exec("DELETE FROM services WHERE service_id = ?", id)
}

View File

@@ -0,0 +1,14 @@
package handlers
import (
"database/sql"
"time"
)
func SetupHandlers(db *sql.DB) {
DB = db
}
func getCurrentTimestamp() int64 {
return time.Now().Unix()
}