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:
420
apps/go-crm/internal/db/crud.go
Normal file
420
apps/go-crm/internal/db/crud.go
Normal 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
|
||||
}
|
||||
140
apps/go-crm/internal/db/db.go
Normal file
140
apps/go-crm/internal/db/db.go
Normal 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
|
||||
}
|
||||
166
apps/go-crm/internal/handlers/auth.go
Normal file
166
apps/go-crm/internal/handlers/auth.go
Normal 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")
|
||||
}
|
||||
142
apps/go-crm/internal/handlers/clients.go
Normal file
142
apps/go-crm/internal/handlers/clients.go
Normal 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"))
|
||||
}
|
||||
84
apps/go-crm/internal/handlers/customers.go
Normal file
84
apps/go-crm/internal/handlers/customers.go
Normal 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)
|
||||
}
|
||||
88
apps/go-crm/internal/handlers/payments.go
Normal file
88
apps/go-crm/internal/handlers/payments.go
Normal 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"
|
||||
}
|
||||
113
apps/go-crm/internal/handlers/questions.go
Normal file
113
apps/go-crm/internal/handlers/questions.go
Normal 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) {}
|
||||
84
apps/go-crm/internal/handlers/scheduling.go
Normal file
84
apps/go-crm/internal/handlers/scheduling.go
Normal 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)
|
||||
}
|
||||
81
apps/go-crm/internal/handlers/services.go
Normal file
81
apps/go-crm/internal/handlers/services.go
Normal 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)
|
||||
}
|
||||
14
apps/go-crm/internal/handlers/setup.go
Normal file
14
apps/go-crm/internal/handlers/setup.go
Normal 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()
|
||||
}
|
||||
4
apps/go-crm/internal/templates/home.html
Normal file
4
apps/go-crm/internal/templates/home.html
Normal file
@@ -0,0 +1,4 @@
|
||||
{{ define "content" }}
|
||||
<h1>Welcome to CRM</h1>
|
||||
<p>Manage your clients, customers, and scheduling.</p>
|
||||
{{ end }}
|
||||
76
apps/go-crm/internal/templates/layout.go
Normal file
76
apps/go-crm/internal/templates/layout.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user