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,420 @@
package db
import (
"database/sql"
"fmt"
)
type Client struct {
ClientID int64 `json:"client_id"`
Name string `json:"name"`
Phone string `json:"phone,omitempty"`
Email string `json:"email,omitempty"`
Address string `json:"address,omitempty"`
Notes string `json:"notes,omitempty"`
CreatedAt int64 `json:"created_at"`
}
type Customer struct {
CustomerID int64 `json:"customer_id"`
ClientID int64 `json:"client_id"`
Name string `json:"name"`
Phone string `json:"phone,omitempty"`
BirthDate string `json:"birth_date,omitempty"`
Instagram string `json:"instagram,omitempty"`
CreatedAt int64 `json:"created_at"`
}
type Service struct {
ServiceID int64 `json:"service_id"`
ClientID int64 `json:"client_id"`
Name string `json:"name"`
Price float64 `json:"price,omitempty"`
Description string `json:"description,omitempty"`
Duration string `json:"duration,omitempty"`
CreatedAt int64 `json:"created_at"`
}
type Schedule struct {
ScheduleID int64 `json:"schedule_id"`
CustomerID int64 `json:"customer_id"`
ClientID int64 `json:"client_id"`
PlanDate string `json:"plan_date,omitempty"`
ConfirmedDate string `json:"confirmed_date,omitempty"`
Time string `json:"time,omitempty"`
Status string `json:"status"`
Notes string `json:"notes,omitempty"`
CreatedAt int64 `json:"created_at"`
}
type Payment struct {
PaymentID int64 `json:"payment_id"`
ClientID int64 `json:"client_id"`
CustomerID int64 `json:"customer_id"`
ScheduleID int64 `json:"schedule_id,omitempty"`
HasPaid bool `json:"has_paid"`
Amount float64 `json:"amount,omitempty"`
PaymentDate string `json:"payment_date,omitempty"`
PaymentMethod string `json:"payment_method,omitempty"`
CreatedAt int64 `json:"created_at"`
}
type Question struct {
QuestionID int64 `json:"question_id"`
ClientID int64 `json:"client_id"`
CustomerID int64 `json:"customer_id"`
Question string `json:"question"`
Timestamp int64 `json:"timestamp"`
Status string `json:"status"`
CreatedAt int64 `json:"created_at"`
}
type Answer struct {
AnswerID int64 `json:"answer_id"`
ClientID int64 `json:"client_id"`
QuestionID int64 `json:"question_id"`
Answer string `json:"answer"`
Timestamp int64 `json:"timestamp"`
Status string `json:"status"`
CreatedAt int64 `json:"created_at"`
}
func (c *Client) Create(db *sql.DB) error {
result, err := db.Exec(
"INSERT INTO clients (name, phone, email, address, notes, created_at) VALUES (?, ?, ?, ?, ?, ?)",
c.Name, c.Phone, c.Email, c.Address, c.Notes, c.CreatedAt,
)
if err != nil {
return err
}
id, err := result.LastInsertId()
if err != nil {
return err
}
c.ClientID = id
return nil
}
func (c *Client) Read(db *sql.DB, id int64) error {
return db.QueryRow(
"SELECT client_id, name, phone, email, address, notes, created_at FROM clients WHERE client_id = ?",
id,
).Scan(&c.ClientID, &c.Name, &c.Phone, &c.Email, &c.Address, &c.Notes, &c.CreatedAt)
}
func (c *Client) Update(db *sql.DB) error {
_, err := db.Exec(
"UPDATE clients SET name = ?, phone = ?, email = ?, address = ?, notes = ? WHERE client_id = ?",
c.Name, c.Phone, c.Email, c.Address, c.Notes, c.ClientID,
)
return err
}
func (c *Client) Delete(db *sql.DB, id int64) error {
_, err := db.Exec("DELETE FROM clients WHERE client_id = ?", id)
return err
}
func ListClients(db *sql.DB, limit, offset int) ([]Client, error) {
rows, err := db.Query(
"SELECT client_id, name, phone, email, address, notes, created_at FROM clients ORDER BY created_at DESC LIMIT ? OFFSET ?",
limit, offset,
)
if err != nil {
return nil, err
}
defer rows.Close()
var clients []Client
for rows.Next() {
var c Client
if err := rows.Scan(&c.ClientID, &c.Name, &c.Phone, &c.Email, &c.Address, &c.Notes, &c.CreatedAt); err != nil {
return nil, err
}
clients = append(clients, c)
}
return clients, rows.Err()
}
func GetClientByID(db *sql.DB, id int64) (*Client, error) {
var c Client
err := db.QueryRow(
"SELECT client_id, name, phone, email, address, notes, created_at FROM clients WHERE client_id = ?",
id,
).Scan(&c.ClientID, &c.Name, &c.Phone, &c.Email, &c.Address, &c.Notes, &c.CreatedAt)
if err != nil {
return nil, err
}
return &c, nil
}
func (cu *Customer) Create(db *sql.DB) error {
result, err := db.Exec(
"INSERT INTO customers (client_id, name, phone, birth_date, instagram, created_at) VALUES (?, ?, ?, ?, ?, ?)",
cu.ClientID, cu.Name, cu.Phone, cu.BirthDate, cu.Instagram, cu.CreatedAt,
)
if err != nil {
return err
}
id, err := result.LastInsertId()
if err != nil {
return err
}
cu.CustomerID = id
return nil
}
func ListCustomers(db *sql.DB, clientID int64, limit, offset int) ([]Customer, error) {
query := "SELECT customer_id, client_id, name, phone, birth_date, instagram, created_at FROM customers"
var args []interface{}
if clientID > 0 {
query += " WHERE client_id = ?"
args = append(args, clientID)
}
query += " ORDER BY created_at DESC LIMIT ? OFFSET ?"
args = append(args, limit, offset)
rows, err := db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var customers []Customer
for rows.Next() {
var cu Customer
if err := rows.Scan(&cu.CustomerID, &cu.ClientID, &cu.Name, &cu.Phone, &cu.BirthDate, &cu.Instagram, &cu.CreatedAt); err != nil {
return nil, err
}
customers = append(customers, cu)
}
return customers, rows.Err()
}
func (s *Service) Create(db *sql.DB) error {
result, err := db.Exec(
"INSERT INTO services (client_id, name, price, description, duration, created_at) VALUES (?, ?, ?, ?, ?, ?)",
s.ClientID, s.Name, s.Price, s.Description, s.Duration, s.CreatedAt,
)
if err != nil {
return err
}
id, err := result.LastInsertId()
if err != nil {
return err
}
s.ServiceID = id
return nil
}
func ListServices(db *sql.DB, clientID int64, limit, offset int) ([]Service, error) {
query := "SELECT service_id, client_id, name, price, description, duration, created_at FROM services"
var args []interface{}
if clientID > 0 {
query += " WHERE client_id = ?"
args = append(args, clientID)
}
query += " ORDER BY created_at DESC LIMIT ? OFFSET ?"
args = append(args, limit, offset)
rows, err := db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var services []Service
for rows.Next() {
var s Service
if err := rows.Scan(&s.ServiceID, &s.ClientID, &s.Name, &s.Price, &s.Description, &s.Duration, &s.CreatedAt); err != nil {
return nil, err
}
services = append(services, s)
}
return services, rows.Err()
}
func (sch *Schedule) Create(db *sql.DB) error {
result, err := db.Exec(
"INSERT INTO scheduling (customer_id, client_id, plan_date, confirmed_date, time, status, notes, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
sch.CustomerID, sch.ClientID, sch.PlanDate, sch.ConfirmedDate, sch.Time, sch.Status, sch.Notes, sch.CreatedAt,
)
if err != nil {
return err
}
id, err := result.LastInsertId()
if err != nil {
return err
}
sch.ScheduleID = id
return nil
}
func ListSchedules(db *sql.DB, clientID, customerID int64, limit, offset int) ([]Schedule, error) {
query := "SELECT schedule_id, customer_id, client_id, plan_date, confirmed_date, time, status, notes, created_at FROM scheduling WHERE 1=1"
var args []interface{}
if clientID > 0 {
query += " AND client_id = ?"
args = append(args, clientID)
}
if customerID > 0 {
query += " AND customer_id = ?"
args = append(args, customerID)
}
query += " ORDER BY plan_date DESC LIMIT ? OFFSET ?"
args = append(args, limit, offset)
rows, err := db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var schedules []Schedule
for rows.Next() {
var sch Schedule
if err := rows.Scan(&sch.ScheduleID, &sch.CustomerID, &sch.ClientID, &sch.PlanDate, &sch.ConfirmedDate, &sch.Time, &sch.Status, &sch.Notes, &sch.CreatedAt); err != nil {
return nil, err
}
schedules = append(schedules, sch)
}
return schedules, rows.Err()
}
func (p *Payment) Create(db *sql.DB) error {
result, err := db.Exec(
"INSERT INTO payments (client_id, customer_id, schedule_id, has_paid, amount, payment_date, payment_method, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
p.ClientID, p.CustomerID, p.ScheduleID, boolToInt(p.HasPaid), p.Amount, p.PaymentDate, p.PaymentMethod, p.CreatedAt,
)
if err != nil {
return err
}
id, err := result.LastInsertId()
if err != nil {
return err
}
p.PaymentID = id
return nil
}
func ListPayments(db *sql.DB, clientID int64, limit, offset int) ([]Payment, error) {
query := "SELECT payment_id, client_id, customer_id, schedule_id, has_paid, amount, payment_date, payment_method, created_at FROM payments"
var args []interface{}
if clientID > 0 {
query += " WHERE client_id = ?"
args = append(args, clientID)
}
query += " ORDER BY created_at DESC LIMIT ? OFFSET ?"
args = append(args, limit, offset)
rows, err := db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var payments []Payment
for rows.Next() {
var p Payment
var hasPaid int
if err := rows.Scan(&p.PaymentID, &p.ClientID, &p.CustomerID, &p.ScheduleID, &hasPaid, &p.Amount, &p.PaymentDate, &p.PaymentMethod, &p.CreatedAt); err != nil {
return nil, err
}
p.HasPaid = hasPaid == 1
payments = append(payments, p)
}
return payments, rows.Err()
}
func (q *Question) Create(db *sql.DB) error {
result, err := db.Exec(
"INSERT INTO questions (client_id, customer_id, question, timestamp, status, created_at) VALUES (?, ?, ?, ?, ?, ?)",
q.ClientID, q.CustomerID, q.Question, q.Timestamp, q.Status, q.CreatedAt,
)
if err != nil {
return err
}
id, err := result.LastInsertId()
if err != nil {
return err
}
q.QuestionID = id
return nil
}
func ListQuestions(db *sql.DB, clientID int64, limit, offset int) ([]Question, error) {
query := "SELECT question_id, client_id, customer_id, question, timestamp, status, created_at FROM questions"
var args []interface{}
if clientID > 0 {
query += " WHERE client_id = ?"
args = append(args, clientID)
}
query += " ORDER BY created_at DESC LIMIT ? OFFSET ?"
args = append(args, limit, offset)
rows, err := db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var questions []Question
for rows.Next() {
var q Question
if err := rows.Scan(&q.QuestionID, &q.ClientID, &q.CustomerID, &q.Question, &q.Timestamp, &q.Status, &q.CreatedAt); err != nil {
return nil, err
}
questions = append(questions, q)
}
return questions, rows.Err()
}
func (a *Answer) Create(db *sql.DB) error {
result, err := db.Exec(
"INSERT INTO answers (client_id, question_id, answer, timestamp, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
a.ClientID, a.QuestionID, a.Answer, a.Timestamp, a.Status, a.CreatedAt,
)
if err != nil {
return err
}
id, err := result.LastInsertId()
if err != nil {
return err
}
a.AnswerID = id
return nil
}
func ListAnswers(db *sql.DB, questionID int64, limit, offset int) ([]Answer, error) {
query := "SELECT answer_id, client_id, question_id, answer, timestamp, status, created_at FROM answers"
var args []interface{}
if questionID > 0 {
query += " WHERE question_id = ?"
args = append(args, questionID)
}
query += " ORDER BY created_at DESC LIMIT ? OFFSET ?"
args = append(args, limit, offset)
rows, err := db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var answers []Answer
for rows.Next() {
var a Answer
if err := rows.Scan(&a.AnswerID, &a.ClientID, &a.QuestionID, &a.Answer, &a.Timestamp, &a.Status, &a.CreatedAt); err != nil {
return nil, err
}
answers = append(answers, a)
}
return answers, rows.Err()
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}

View File

@@ -0,0 +1,140 @@
package db
import (
"database/sql"
"fmt"
"os"
"path/filepath"
_ "github.com/mattn/go-sqlite3"
)
const schema = `
CREATE TABLE IF NOT EXISTS accounts (
account_id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
password TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
session_id TEXT PRIMARY KEY,
account_id INTEGER NOT NULL,
expires INTEGER NOT NULL,
FOREIGN KEY (account_id) REFERENCES accounts(account_id)
);
CREATE TABLE IF NOT EXISTS clients (
client_id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
phone TEXT,
email TEXT,
address TEXT,
notes TEXT,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS customers (
customer_id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id INTEGER NOT NULL,
name TEXT NOT NULL,
phone TEXT,
birth_date TEXT,
instagram TEXT,
created_at INTEGER NOT NULL,
FOREIGN KEY (client_id) REFERENCES clients(client_id)
);
CREATE TABLE IF NOT EXISTS services (
service_id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id INTEGER NOT NULL,
name TEXT NOT NULL,
price REAL,
description TEXT,
duration TEXT,
created_at INTEGER NOT NULL,
FOREIGN KEY (client_id) REFERENCES clients(client_id)
);
CREATE TABLE IF NOT EXISTS scheduling (
schedule_id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_id INTEGER NOT NULL,
client_id INTEGER NOT NULL,
plan_date TEXT,
confirmed_date TEXT,
time TEXT,
status TEXT DEFAULT 'pending',
notes TEXT,
created_at INTEGER NOT NULL,
FOREIGN KEY (customer_id) REFERENCES customers(customer_id),
FOREIGN KEY (client_id) REFERENCES clients(client_id)
);
CREATE TABLE IF NOT EXISTS payments (
payment_id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id INTEGER NOT NULL,
customer_id INTEGER NOT NULL,
schedule_id INTEGER,
has_paid INTEGER DEFAULT 0,
amount REAL,
payment_date TEXT,
payment_method TEXT,
created_at INTEGER NOT NULL,
FOREIGN KEY (client_id) REFERENCES clients(client_id),
FOREIGN KEY (customer_id) REFERENCES customers(customer_id),
FOREIGN KEY (schedule_id) REFERENCES scheduling(schedule_id)
);
CREATE TABLE IF NOT EXISTS questions (
question_id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id INTEGER NOT NULL,
customer_id INTEGER NOT NULL,
question TEXT NOT NULL,
timestamp INTEGER NOT NULL,
status TEXT DEFAULT 'pending',
created_at INTEGER NOT NULL,
FOREIGN KEY (client_id) REFERENCES clients(client_id),
FOREIGN KEY (customer_id) REFERENCES customers(customer_id)
);
CREATE TABLE IF NOT EXISTS answers (
answer_id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id INTEGER NOT NULL,
question_id INTEGER NOT NULL,
answer TEXT NOT NULL,
timestamp INTEGER NOT NULL,
status TEXT DEFAULT 'active',
created_at INTEGER NOT NULL,
FOREIGN KEY (client_id) REFERENCES clients(client_id),
FOREIGN KEY (question_id) REFERENCES questions(question_id)
);
`
var dbPath = filepath.Join(os.Getenv("WORKDIR"), "data", "go-crm.db")
func Init(path string) (*sql.DB, error) {
if path != "" {
dbPath = path
}
dir := filepath.Dir(dbPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, fmt.Errorf("failed to create data directory: %w", err)
}
database, err := sql.Open("sqlite3", dbPath)
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
if err := database.Ping(); err != nil {
return nil, fmt.Errorf("failed to ping database: %w", err)
}
if _, err := database.Exec(schema); err != nil {
return nil, fmt.Errorf("failed to create schema: %w", err)
}
return database, nil
}

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()
}

View File

@@ -0,0 +1,4 @@
{{ define "content" }}
<h1>Welcome to CRM</h1>
<p>Manage your clients, customers, and scheduling.</p>
{{ end }}

View File

@@ -0,0 +1,76 @@
package templates
import (
"html/template"
"sync"
)
var (
templates *template.Template
mu sync.RWMutex
)
func Init() {
templates = template.Must(template.New("").ParseGlob("internal/templates/*.html"))
}
func Layout(title, content string) *template.Template {
mu.RLock()
defer mu.RUnlock()
tmpl := template.Must(template.New("layout.html").Parse(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ .Title }}</title>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; }
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
nav { background: white; border-bottom: 1px solid #e0e0e0; padding: 1rem 0; margin-bottom: 2rem; }
nav ul { list-style: none; display: flex; gap: 1.5rem; max-width: 1200px; margin: 0 auto; padding: 0 20px; }
nav a { color: #333; text-decoration: none; }
nav a:hover { color: #007bff; }
.card { background: white; border-radius: 8px; padding: 1.5rem; margin-bottom: 1rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
.btn { display: inline-block; padding: 0.5rem 1rem; background: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer; text-decoration: none; }
.btn:hover { background: #0056b3; }
.btn-secondary { background: #6c757d; }
.btn-danger { background: #dc3545; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 0.75rem; text-align: left; border-bottom: 1px solid #e0e0e0; }
th { background: #f8f9fa; font-weight: 600; }
form { display: flex; flex-direction: column; gap: 1rem; max-width: 500px; }
input, textarea, select { padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px; width: 100%; }
textarea { min-height: 100px; }
.error { color: #dc3545; }
.success { color: #28a745; }
</style>
</head>
<body>
<nav>
<ul>
<li><a href="/">Home</a></li>
<li><a href="/clients">Clients</a></li>
<li><a href="/customers">Customers</a></li>
<li><a href="/services">Services</a></li>
<li><a href="/scheduling">Scheduling</a></li>
<li><a href="/payments">Payments</a></li>
<li><a href="/questions">Questions</a></li>
</ul>
</nav>
<div class="container">
{{ template "content" . }}
</div>
</body>
</html>`))
return tmpl
}
func Get(name string) *template.Template {
mu.RLock()
defer mu.RUnlock()
return templates.Lookup(name)
}