Compare commits
2 Commits
31039ea4af
...
f8135d0e1b
| Author | SHA1 | Date | |
|---|---|---|---|
| f8135d0e1b | |||
| 5b8107c169 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -50,3 +50,6 @@ Thumbs.db
|
||||
|
||||
|
||||
.opencode-sandbox/.env
|
||||
|
||||
# Go CRM data
|
||||
data/*.db
|
||||
|
||||
@@ -10,10 +10,17 @@ RUN pacman -Syu --noconfirm && \
|
||||
openssh \
|
||||
opencode \
|
||||
curl \
|
||||
go \
|
||||
sqlite \
|
||||
&& pacman -Scc --noconfirm
|
||||
|
||||
RUN npx -y skills add JuliusBrussee/caveman -g -a opencode -s caveman -y
|
||||
|
||||
# 2. Install air using Go
|
||||
RUN go install github.com/air-verse/air@latest
|
||||
|
||||
# Add Go binaries to your PATH so you can actually run 'air'
|
||||
ENV PATH="/root/go/bin:${PATH}"
|
||||
|
||||
# Git safe directory system-wide config
|
||||
RUN git config --system --add safe.directory /workspace
|
||||
|
||||
BIN
.opencode-sandbox/data/go-crm.db
Normal file
BIN
.opencode-sandbox/data/go-crm.db
Normal file
Binary file not shown.
@@ -15,11 +15,22 @@ services:
|
||||
- /home/ga/.ssh:/root/.ssh
|
||||
# Persist opencode auth
|
||||
- opencode-auth:/root/.local/share/opencode
|
||||
# Go CRM database
|
||||
- ./data:/workspace/data
|
||||
environment:
|
||||
- OPENCODE_API_KEY=${OPENCODE_API_KEY}
|
||||
stdin_open: true
|
||||
tty: true
|
||||
ports:
|
||||
- "8080:8080"
|
||||
working_dir: /workspace
|
||||
networks:
|
||||
- crm-network
|
||||
|
||||
networks:
|
||||
crm-network:
|
||||
name: crm-network
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
opencode-auth:
|
||||
35
apps/go-crm/.air.toml
Normal file
35
apps/go-crm/.air.toml
Normal file
@@ -0,0 +1,35 @@
|
||||
root_dir = "."
|
||||
tmp_dir = "tmp"
|
||||
|
||||
[build]
|
||||
bin = "./tmp/main"
|
||||
cmd = "go build -buildvcs=false -o ./tmp/main ."
|
||||
delay = 1000
|
||||
exclude_dir = ["assets", "tmp", "vendor"]
|
||||
exclude_file = []
|
||||
exclude_regex = ["_test.go"]
|
||||
exclude_unchanged = false
|
||||
follow_symlink = false
|
||||
full_screen = false
|
||||
include_dir = []
|
||||
include_ext = ["go", "templ"]
|
||||
kill_delay = "0s"
|
||||
log = "build-errors.toml"
|
||||
send_exit = false
|
||||
send_user = false
|
||||
stop_on_error = false
|
||||
|
||||
[log]
|
||||
main_only = false
|
||||
time = false
|
||||
|
||||
[misc]
|
||||
clean_on_exit = false
|
||||
dao = "0x60"
|
||||
force_kill = true
|
||||
kill = true
|
||||
restart = true
|
||||
|
||||
[screen]
|
||||
clear_on_rebuild = false
|
||||
extra_resize = false
|
||||
26
apps/go-crm/Dockerfile
Normal file
26
apps/go-crm/Dockerfile
Normal file
@@ -0,0 +1,26 @@
|
||||
FROM archlinux:latest
|
||||
|
||||
RUN pacman -Syu --noconfirm && \
|
||||
pacman -S --noconfirm \
|
||||
go \
|
||||
git \
|
||||
sqlite \
|
||||
curl \
|
||||
&& pacman -Scc --noconfirm
|
||||
|
||||
WORKDIR /tmp
|
||||
|
||||
RUN curl -sSL https://github.com/air-verse/air/releases/download/v1.27.10/air_1.27.10_linux_amd64.tar.gz | tar -xz && \
|
||||
chmod +x air && \
|
||||
mv air /usr/local/bin/air
|
||||
|
||||
WORKDIR /workspace/apps/go-crm
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["air"]
|
||||
BIN
apps/go-crm/data/go-crm.db
Normal file
BIN
apps/go-crm/data/go-crm.db
Normal file
Binary file not shown.
18
apps/go-crm/docker-compose.yml
Normal file
18
apps/go-crm/docker-compose.yml
Normal file
@@ -0,0 +1,18 @@
|
||||
services:
|
||||
go-crm:
|
||||
build: .
|
||||
container_name: go-crm
|
||||
ports:
|
||||
- "8888:8080"
|
||||
volumes:
|
||||
- /home/ga/workspace:/workspace
|
||||
- ./data:/workspace/data
|
||||
networks:
|
||||
- crm-network
|
||||
working_dir: /workspace/apps/go-crm
|
||||
command: air
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
crm-network:
|
||||
external: false
|
||||
25
apps/go-crm/go.mod
Normal file
25
apps/go-crm/go.mod
Normal file
@@ -0,0 +1,25 @@
|
||||
module go-crm
|
||||
|
||||
go 1.22
|
||||
|
||||
require (
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/go-chi/chi/v5 v5.1.0
|
||||
golang.org/x/crypto v0.27.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/mattn/go-isatty v0.0.17 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/sys v0.25.0 // indirect
|
||||
gorm.io/gorm v1.25.7 // indirect
|
||||
modernc.org/libc v1.22.5 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/memory v1.5.0 // indirect
|
||||
modernc.org/sqlite v1.23.1 // indirect
|
||||
)
|
||||
36
apps/go-crm/go.sum
Normal file
36
apps/go-crm/go.sum
Normal file
@@ -0,0 +1,36 @@
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
||||
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
|
||||
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
|
||||
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
|
||||
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
|
||||
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
gorm.io/gorm v1.25.7 h1:VsD6acwRjz2zFxGO50gPO6AkNs7KKnvfzUjHQhZDz/A=
|
||||
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
||||
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
||||
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
||||
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
|
||||
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
|
||||
468
apps/go-crm/internal/db/crud.go
Normal file
468
apps/go-crm/internal/db/crud.go
Normal file
@@ -0,0 +1,468 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
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"`
|
||||
ClientID int64 `json:"client_id"`
|
||||
CustomerID int64 `json:"customer_id"`
|
||||
ServiceID int64 `json:"service_id"`
|
||||
PlanDate string `json:"plan_date,omitempty"`
|
||||
Time string `json:"time,omitempty"`
|
||||
Status string `json:"status"`
|
||||
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"`
|
||||
}
|
||||
|
||||
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 (client_id, customer_id, service_id, plan_date, time, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
sch.ClientID, sch.CustomerID, sch.ServiceID, sch.PlanDate, sch.Time, sch.Status, 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, client_id, customer_id, service_id, plan_date, time, status, 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.ClientID, &sch.CustomerID, &sch.ServiceID, &sch.PlanDate, &sch.Time, &sch.Status, &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 boolToInt(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
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 WHERE 1=1"
|
||||
var args []interface{}
|
||||
if clientID > 0 {
|
||||
query += " AND 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()
|
||||
}
|
||||
|
||||
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 (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 WHERE 1=1"
|
||||
var args []interface{}
|
||||
if questionID > 0 {
|
||||
query += " AND 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()
|
||||
}
|
||||
|
||||
type AnswerWithDetails 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"`
|
||||
ClientName string `json:"client_name"`
|
||||
CustomerName string `json:"customer_name"`
|
||||
QuestionText string `json:"question_text"`
|
||||
}
|
||||
|
||||
func ListAnswersWithDetails(db *sql.DB, questionID int64, limit, offset int) ([]AnswerWithDetails, error) {
|
||||
query := `
|
||||
SELECT
|
||||
a.answer_id, a.client_id, a.question_id, a.answer, a.timestamp, a.status, a.created_at,
|
||||
c.name as client_name,
|
||||
cu.name as customer_name,
|
||||
q.question as question_text
|
||||
FROM answers a
|
||||
JOIN questions q ON a.question_id = q.question_id
|
||||
JOIN customers cu ON q.customer_id = cu.customer_id
|
||||
JOIN clients c ON q.client_id = c.client_id
|
||||
WHERE 1=1`
|
||||
var args []interface{}
|
||||
if questionID > 0 {
|
||||
query += " AND a.question_id = ?"
|
||||
args = append(args, questionID)
|
||||
}
|
||||
query += " ORDER BY a.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 []AnswerWithDetails
|
||||
for rows.Next() {
|
||||
var a AnswerWithDetails
|
||||
if err := rows.Scan(&a.AnswerID, &a.ClientID, &a.QuestionID, &a.Answer, &a.Timestamp, &a.Status, &a.CreatedAt, &a.ClientName, &a.CustomerName, &a.QuestionText); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
answers = append(answers, a)
|
||||
}
|
||||
return answers, rows.Err()
|
||||
}
|
||||
142
apps/go-crm/internal/db/db.go
Normal file
142
apps/go-crm/internal/db/db.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
_ "github.com/glebarez/sqlite"
|
||||
)
|
||||
|
||||
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,
|
||||
client_id INTEGER NOT NULL,
|
||||
customer_id INTEGER NOT NULL,
|
||||
service_id INTEGER NOT NULL,
|
||||
plan_date TEXT,
|
||||
time TEXT,
|
||||
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),
|
||||
FOREIGN KEY (service_id) REFERENCES services(service_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 = "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("sqlite", 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
|
||||
}
|
||||
164
apps/go-crm/internal/handlers/auth.go
Normal file
164
apps/go-crm/internal/handlers/auth.go
Normal file
@@ -0,0 +1,164 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"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.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")
|
||||
}
|
||||
155
apps/go-crm/internal/handlers/clients.go
Normal file
155
apps/go-crm/internal/handlers/clients.go
Normal file
@@ -0,0 +1,155 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"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>
|
||||
<style>
|
||||
.edit-form { display:none; margin-top:0.5rem; padding:0.5rem; border:1px solid #ccc; }
|
||||
.edit-row { display:none; }
|
||||
</style>
|
||||
</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>
|
||||
<button type="button" onclick="document.getElementById('editForm` + strconv.FormatInt(c.ClientID, 10) + `').style.display='block'">Edit</button>
|
||||
<form method="DELETE" style="display:inline" hx-delete="/clients/` + strconv.FormatInt(c.ClientID, 10) + `" hx-target="closest tr">
|
||||
<button type="submit">Delete</button>
|
||||
</form>
|
||||
<tr id="editForm` + strconv.FormatInt(c.ClientID, 10) + `" class="edit-row"><td colspan="4">
|
||||
<form hx-put="/clients/` + strconv.FormatInt(c.ClientID, 10) + `" hx-target="#clientList" hx-swap="innerHTML">
|
||||
<input type="text" name="name" value="` + c.Name + `">
|
||||
<input type="tel" name="phone" value="` + c.Phone + `">
|
||||
<input type="email" name="email" value="` + c.Email + `">
|
||||
<input type="text" name="address" value="` + c.Address + `">
|
||||
<textarea name="notes">` + c.Notes + `</textarea>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
</td></tr>
|
||||
</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, 64)
|
||||
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>Client ID: ` + strconv.FormatInt(client.ClientID, 10) + `</p>
|
||||
<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, 64)
|
||||
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("HX-Refresh", "true")
|
||||
}
|
||||
|
||||
func DeleteClient(w http.ResponseWriter, r *http.Request) {
|
||||
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
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"))
|
||||
}
|
||||
94
apps/go-crm/internal/handlers/customers.go
Normal file
94
apps/go-crm/internal/handlers/customers.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"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
|
||||
}
|
||||
|
||||
clients, err := db.ListClients(DB, limit, offset)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var clientOptions string
|
||||
for _, c := range clients {
|
||||
clientOptions += `<option value="` + strconv.FormatInt(c.ClientID, 10) + `">` + c.Name + `</option>`
|
||||
}
|
||||
|
||||
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><style>.edit-form{display:none;margin-top:0.5rem;padding:0.5rem;border:1px solid #ccc}</style></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"><select name="client_id" required><option value="">Select Client</option>` + clientOptions + `</select><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><button type="button" onclick="document.getElementById('editCust` + strconv.FormatInt(c.CustomerID, 10) + `').style.display='block'">Edit</button><form method="DELETE" style="display:inline" hx-delete="/customers/` + strconv.FormatInt(c.CustomerID, 10) + `" hx-target="closest tr"><button type="submit">Delete</button></form></td></tr><tr id="editCust` + strconv.FormatInt(c.CustomerID, 10) + `" style="display:none"><td colspan="5"><form hx-put="/customers/` + strconv.FormatInt(c.CustomerID, 10) + `" hx-target="#customerList" hx-swap="innerHTML"><input type="text" name="name" value="` + c.Name + `"><input type="tel" name="phone" value="` + c.Phone + `"><input type="date" name="birth_date" value="` + c.BirthDate + `"><input type="text" name="instagram" value="` + c.Instagram + `"><select name="client_id"><option value="` + strconv.FormatInt(c.ClientID, 10) + `">` + c.Name + `</option>` + clientOptions + `</select><button type="submit">Save</button></form></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, 64)
|
||||
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, 64)
|
||||
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
|
||||
}
|
||||
w.Header().Set("HX-Refresh", "true")
|
||||
}
|
||||
|
||||
func DeleteCustomer(w http.ResponseWriter, r *http.Request) {
|
||||
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
DB.Exec("DELETE FROM customers WHERE customer_id = ?", id)
|
||||
}
|
||||
138
apps/go-crm/internal/handlers/payments.go
Normal file
138
apps/go-crm/internal/handlers/payments.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"go-crm/internal/db"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
clients, err := db.ListClients(DB, limit, offset)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
customers, err := db.ListCustomers(DB, clientID, limit, offset)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var clientOptions, customerOptions string
|
||||
clientNames := make(map[int64]string)
|
||||
customerNames := make(map[int64]string)
|
||||
for _, c := range clients {
|
||||
clientOptions += `<option value="` + strconv.FormatInt(c.ClientID, 10) + `">` + c.Name + `</option>`
|
||||
clientNames[c.ClientID] = c.Name
|
||||
}
|
||||
for _, cu := range customers {
|
||||
customerOptions += `<option value="` + strconv.FormatInt(cu.CustomerID, 10) + `">` + cu.Name + `</option>`
|
||||
customerNames[cu.CustomerID] = cu.Name
|
||||
}
|
||||
|
||||
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><style>.edit-row{display:none}</style></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"><select name="client_id" required><option value="">Select Client</option>` + clientOptions + `</select><select name="customer_id" required><option value="">Select Customer</option>` + customerOptions + `</select><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"><select name="payment_method"><option value="">Select Payment Method</option><option value="PIX">PIX</option><option value="Dinheiro">Dinheiro</option><option value="Débito">Débito</option><option value="Crédito à Vista">Crédito à Vista</option><option value="Parcelado">Parcelado</option><option value="Boleto">Boleto</option><option value="Transferência Bancária">Transferência Bancária</option></select><button type="submit">Add</button></form></div><table><thead><tr><th>Client</th><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"
|
||||
}
|
||||
clientName := clientNames[p.ClientID]
|
||||
if clientName == "" {
|
||||
clientName = strconv.FormatInt(p.ClientID, 10)
|
||||
}
|
||||
customerName := customerNames[p.CustomerID]
|
||||
if customerName == "" {
|
||||
customerName = strconv.FormatInt(p.CustomerID, 10)
|
||||
}
|
||||
w.Write([]byte(`<tr><td>` + clientName + `</td><td>` + customerName + `</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><button type="button" onclick="document.getElementById('editPay` + strconv.FormatInt(p.PaymentID, 10) + `').style.display='table-row'">Edit</button><form method="DELETE" style="display:inline" hx-delete="/payments/` + strconv.FormatInt(p.PaymentID, 10) + `" hx-target="closest tr"><button type="submit">Delete</button></form></td></tr><tr id="editPay` + strconv.FormatInt(p.PaymentID, 10) + `" style="display:none"><td colspan="7"><form hx-put="/payments/` + strconv.FormatInt(p.PaymentID, 10) + `" hx-target="#paymentList" hx-swap="innerHTML"><select name="client_id"><option value="` + strconv.FormatInt(p.ClientID, 10) + `">` + clientName + `</option>` + clientOptions + `</select><select name="customer_id"><option value="` + strconv.FormatInt(p.CustomerID, 10) + `">` + customerName + `</option>` + customerOptions + `</select><input type="checkbox" name="has_paid"><input type="number" name="amount" value="` + strconv.FormatFloat(p.Amount, 'f', 2, 64) + `"><input type="date" name="payment_date" value="` + p.PaymentDate + `"><select name="payment_method"><option value="` + p.PaymentMethod + `">` + p.PaymentMethod + `</option><option value="PIX">PIX</option><option value="Dinheiro">Dinheiro</option><option value="Débito">Débito</option><option value="Crédito à Vista">Crédito à Vista</option><option value="Parcelado">Parcelado</option><option value="Boleto">Boleto</option><option value="Transferência Bancária">Transferência Bancária</option></select><button type="submit">Save</button></form></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, 64)
|
||||
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) {
|
||||
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
r.ParseForm()
|
||||
clientID, _ := strconv.ParseInt(r.FormValue("client_id"), 10, 64)
|
||||
customerID, _ := strconv.ParseInt(r.FormValue("customer_id"), 10, 64)
|
||||
amount, _ := strconv.ParseFloat(r.FormValue("amount"), 64)
|
||||
hasPaid := r.FormValue("has_paid") == "on"
|
||||
_, err := DB.Exec("UPDATE payments SET client_id=?, customer_id=?, has_paid=?, amount=?, payment_date=?, payment_method=? WHERE payment_id=?", clientID, customerID, hasPaid, amount, r.FormValue("payment_date"), r.FormValue("payment_method"), id)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.Header().Set("HX-Refresh", "true")
|
||||
}
|
||||
|
||||
func DeletePayment(w http.ResponseWriter, r *http.Request) {
|
||||
id, _ := strconv.ParseInt(r.URL.Query().Get("id"), 10, 64)
|
||||
DB.Exec("DELETE FROM payments WHERE payment_id = ?", id)
|
||||
}
|
||||
|
||||
func boolToStr(b bool) string {
|
||||
if b {
|
||||
return "Yes"
|
||||
}
|
||||
return "No"
|
||||
}
|
||||
282
apps/go-crm/internal/handlers/questions.go
Normal file
282
apps/go-crm/internal/handlers/questions.go
Normal file
@@ -0,0 +1,282 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"go-crm/internal/db"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
clients, err := db.ListClients(DB, limit, offset)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
customers, err := db.ListCustomers(DB, clientID, limit, offset)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var clientOptions, customerOptions string
|
||||
clientNames := make(map[int64]string)
|
||||
customerNames := make(map[int64]string)
|
||||
for _, c := range clients {
|
||||
clientOptions += `<option value="` + strconv.FormatInt(c.ClientID, 10) + `">` + c.Name + `</option>`
|
||||
clientNames[c.ClientID] = c.Name
|
||||
}
|
||||
for _, cu := range customers {
|
||||
customerOptions += `<option value="` + strconv.FormatInt(cu.CustomerID, 10) + `">` + cu.Name + `</option>`
|
||||
customerNames[cu.CustomerID] = cu.Name
|
||||
}
|
||||
|
||||
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><style>.edit-row{display:none}</style></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"><select name="client_id" required><option value="">Select Client</option>` + clientOptions + `</select><select name="customer_id" required><option value="">Select Customer</option>` + customerOptions + `</select><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>Client</th><th>Customer</th><th>Question</th><th>Status</th><th>Actions</th></tr></thead><tbody id="questionList">`))
|
||||
for _, q := range questions {
|
||||
clientName := clientNames[q.ClientID]
|
||||
if clientName == "" {
|
||||
clientName = strconv.FormatInt(q.ClientID, 10)
|
||||
}
|
||||
customerName := customerNames[q.CustomerID]
|
||||
if customerName == "" {
|
||||
customerName = strconv.FormatInt(q.CustomerID, 10)
|
||||
}
|
||||
w.Write([]byte(`<tr><td>` + clientName + `</td><td>` + customerName + `</td><td>` + q.Question + `</td><td>` + q.Status + `</td><td><a href="/questions/` + strconv.FormatInt(q.QuestionID, 10) + `">View</a><button type="button" onclick="document.getElementById('editQ` + strconv.FormatInt(q.QuestionID, 10) + `').style.display='table-row'">Edit</button><form method="DELETE" style="display:inline" hx-delete="/questions/` + strconv.FormatInt(q.QuestionID, 10) + `" hx-target="closest tr"><button type="submit">Delete</button></form></td></tr><tr id="editQ` + strconv.FormatInt(q.QuestionID, 10) + `" style="display:none"><td colspan="5"><form hx-put="/questions/` + strconv.FormatInt(q.QuestionID, 10) + `" hx-target="#questionList" hx-swap="innerHTML"><select name="client_id"><option value="` + strconv.FormatInt(q.ClientID, 10) + `">` + clientName + `</option>` + clientOptions + `</select><select name="customer_id"><option value="` + strconv.FormatInt(q.CustomerID, 10) + `">` + customerName + `</option>` + customerOptions + `</select><textarea name="question">` + q.Question + `</textarea><select name="status"><option value="` + q.Status + `">` + q.Status + `</option><option value="pending">Pending</option><option value="answered">Answered</option></select><button type="submit">Save</button></form></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) {
|
||||
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
var q db.Question
|
||||
err := DB.QueryRow("SELECT question_id, client_id, customer_id, question, timestamp, status, created_at FROM questions WHERE question_id = ?", id).Scan(&q.QuestionID, &q.ClientID, &q.CustomerID, &q.Question, &q.Timestamp, &q.Status, &q.CreatedAt)
|
||||
if err != nil {
|
||||
http.Error(w, "Question not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.Write([]byte(`<!DOCTYPE html><body><h1>Question</h1><p>Client ID: ` + strconv.FormatInt(q.ClientID, 10) + `</p><p>Customer ID: ` + strconv.FormatInt(q.CustomerID, 10) + `</p><p>Question: ` + q.Question + `</p><p>Status: ` + q.Status + `</p><a href="/questions">Back</a></body></html>`))
|
||||
}
|
||||
|
||||
func UpdateQuestion(w http.ResponseWriter, r *http.Request) {
|
||||
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
r.ParseForm()
|
||||
clientID, _ := strconv.ParseInt(r.FormValue("client_id"), 10, 64)
|
||||
customerID, _ := strconv.ParseInt(r.FormValue("customer_id"), 10, 64)
|
||||
_, err := DB.Exec("UPDATE questions SET client_id=?, customer_id=?, question=?, status=? WHERE question_id=?", clientID, customerID, r.FormValue("question"), r.FormValue("status"), id)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.Header().Set("HX-Refresh", "true")
|
||||
}
|
||||
|
||||
func DeleteQuestion(w http.ResponseWriter, r *http.Request) {
|
||||
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
DB.Exec("DELETE FROM questions WHERE question_id = ?", id)
|
||||
}
|
||||
|
||||
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.ListAnswersWithDetails(DB, questionID, limit, offset)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
questions, err := db.ListQuestions(DB, 0, limit, offset)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var questionOptions string
|
||||
questionText := make(map[int64]string)
|
||||
for _, q := range questions {
|
||||
questionOptions += `<option value="` + strconv.FormatInt(q.QuestionID, 10) + `">` + q.Question + `</option>`
|
||||
questionText[q.QuestionID] = q.Question
|
||||
}
|
||||
|
||||
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>
|
||||
<style>
|
||||
.edit-form { display:none; margin-top:0.5rem; padding:0.5rem; border:1px solid #ccc; }
|
||||
.edit-row { display:none; }
|
||||
</style>
|
||||
</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" hx-swap="innerHTML">
|
||||
<select name="question_id" required>
|
||||
<option value="">Select Question</option>
|
||||
` + questionOptions + `
|
||||
</select>
|
||||
<textarea name="answer" placeholder="Answer" required></textarea>
|
||||
<button type="submit">Add Answer</button>
|
||||
</form>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Client</th><th>Customer</th><th>Question</th><th>Answer</th><th>Status</th><th>Actions</th></tr>
|
||||
</thead>
|
||||
<tbody id="answerList">
|
||||
`))
|
||||
for _, a := range answers {
|
||||
qText := a.QuestionText
|
||||
if qText == "" {
|
||||
qText = questionText[a.QuestionID]
|
||||
if qText == "" {
|
||||
qText = strconv.FormatInt(a.QuestionID, 10)
|
||||
}
|
||||
}
|
||||
clientName := a.ClientName
|
||||
if clientName == "" {
|
||||
clientName = strconv.FormatInt(a.ClientID, 10)
|
||||
}
|
||||
customerName := a.CustomerName
|
||||
if customerName == "" {
|
||||
customerName = "Unknown"
|
||||
}
|
||||
w.Write([]byte(`
|
||||
<tr>
|
||||
<td>` + clientName + `</td>
|
||||
<td>` + customerName + `</td>
|
||||
<td>` + qText + `</td>
|
||||
<td>` + a.Answer + `</td>
|
||||
<td>` + a.Status + `</td>
|
||||
<td>
|
||||
<a href="/answers/` + strconv.FormatInt(a.AnswerID, 10) + `">View</a>
|
||||
<button type="button" onclick="document.getElementById('editA`+strconv.FormatInt(a.AnswerID, 10)+`').style.display='table-row'">Edit</button>
|
||||
<form method="DELETE" style="display:inline" hx-delete="/answers/`+strconv.FormatInt(a.AnswerID, 10)+`" hx-target="closest tr">
|
||||
<button type="submit">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<tr id="editA`+strconv.FormatInt(a.AnswerID, 10)+`" class="edit-row" style="display:none">
|
||||
<td colspan="6">
|
||||
<form hx-put="/answers/`+strconv.FormatInt(a.AnswerID, 10)+`" hx-target="#answerList" hx-swap="innerHTML">
|
||||
<select name="question_id">
|
||||
<option value="`+strconv.FormatInt(a.QuestionID, 10)+`">`+qText+`</option>
|
||||
`+questionOptions+`
|
||||
</select>
|
||||
<textarea name="answer">`+a.Answer+`</textarea>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>`))
|
||||
}
|
||||
w.Write([]byte(`
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>`))
|
||||
}
|
||||
|
||||
func CreateAnswer(w http.ResponseWriter, r *http.Request) {
|
||||
r.ParseForm()
|
||||
questionID, _ := strconv.ParseInt(r.FormValue("question_id"), 10, 64)
|
||||
|
||||
var clientID int64
|
||||
err := DB.QueryRow("SELECT client_id FROM questions WHERE question_id = ?", questionID).Scan(&clientID)
|
||||
if err != nil {
|
||||
http.Error(w, "Question not found", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
answer := db.Answer{
|
||||
ClientID: clientID,
|
||||
QuestionID: questionID,
|
||||
Answer: r.FormValue("answer"),
|
||||
Timestamp: time.Now().Unix(),
|
||||
Status: "active",
|
||||
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) {
|
||||
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
var a db.Answer
|
||||
err := DB.QueryRow("SELECT answer_id, client_id, question_id, answer, timestamp, status, created_at FROM answers WHERE answer_id = ?", id).Scan(&a.AnswerID, &a.ClientID, &a.QuestionID, &a.Answer, &a.Timestamp, &a.Status, &a.CreatedAt)
|
||||
if err != nil {
|
||||
http.Error(w, "Answer not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.Write([]byte(`<!DOCTYPE html><body><h1>Answer</h1><p>Question ID: ` + strconv.FormatInt(a.QuestionID, 10) + `</p><p>Answer: ` + a.Answer + `</p><a href="/answers">Back</a></body></html>`))
|
||||
}
|
||||
|
||||
func UpdateAnswer(w http.ResponseWriter, r *http.Request) {
|
||||
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
r.ParseForm()
|
||||
questionID, _ := strconv.ParseInt(r.FormValue("question_id"), 10, 64)
|
||||
_, err := DB.Exec("UPDATE answers SET question_id=?, answer=? WHERE answer_id=?", questionID, r.FormValue("answer"), id)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.Header().Set("HX-Refresh", "true")
|
||||
}
|
||||
|
||||
func DeleteAnswer(w http.ResponseWriter, r *http.Request) {
|
||||
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
DB.Exec("DELETE FROM answers WHERE answer_id = ?", id)
|
||||
}
|
||||
136
apps/go-crm/internal/handlers/scheduling.go
Normal file
136
apps/go-crm/internal/handlers/scheduling.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"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
|
||||
}
|
||||
|
||||
clients, err := db.ListClients(DB, limit, offset)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
customers, err := db.ListCustomers(DB, clientID, limit, offset)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
services, err := db.ListServices(DB, clientID, limit, offset)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var clientOptions, customerOptions, serviceOptions string
|
||||
clientNames := make(map[int64]string)
|
||||
customerNames := make(map[int64]string)
|
||||
serviceNames := make(map[int64]string)
|
||||
for _, c := range clients {
|
||||
clientOptions += `<option value="` + strconv.FormatInt(c.ClientID, 10) + `">` + c.Name + `</option>`
|
||||
clientNames[c.ClientID] = c.Name
|
||||
}
|
||||
for _, cu := range customers {
|
||||
customerOptions += `<option value="` + strconv.FormatInt(cu.CustomerID, 10) + `">` + cu.Name + `</option>`
|
||||
customerNames[cu.CustomerID] = cu.Name
|
||||
}
|
||||
for _, s := range services {
|
||||
serviceOptions += `<option value="` + strconv.FormatInt(s.ServiceID, 10) + `">` + s.Name + `</option>`
|
||||
serviceNames[s.ServiceID] = s.Name
|
||||
}
|
||||
|
||||
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><style>.edit-row{display:none}</style></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"><select name="client_id" required><option value="">Select Client</option>` + clientOptions + `</select><select name="customer_id" required><option value="">Select Customer</option>` + customerOptions + `</select><select name="service_id" required><option value="">Select Service</option>` + serviceOptions + `</select><input type="date" name="plan_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><button type="submit">Add</button></form></div><table><thead><tr><th>Client</th><th>Customer</th><th>Service</th><th>Date</th><th>Hour</th><th>Status</th><th>Actions</th></tr></thead><tbody id="scheduleList">`))
|
||||
for _, s := range schedules {
|
||||
clientName := clientNames[s.ClientID]
|
||||
if clientName == "" {
|
||||
clientName = strconv.FormatInt(s.ClientID, 10)
|
||||
}
|
||||
customerName := customerNames[s.CustomerID]
|
||||
if customerName == "" {
|
||||
customerName = strconv.FormatInt(s.CustomerID, 10)
|
||||
}
|
||||
serviceName := serviceNames[s.ServiceID]
|
||||
if serviceName == "" {
|
||||
serviceName = strconv.FormatInt(s.ServiceID, 10)
|
||||
}
|
||||
w.Write([]byte(`<tr><td>` + clientName + `</td><td>` + customerName + `</td><td>` + serviceName + `</td><td>` + s.PlanDate + `</td><td>` + s.Time + `</td><td>` + s.Status + `</td><td><a href="/scheduling/` + strconv.FormatInt(s.ScheduleID, 10) + `">View</a><button type="button" onclick="document.getElementById('editSched` + strconv.FormatInt(s.ScheduleID, 10) + `').style.display='table-row'">Edit</button><form method="DELETE" style="display:inline" hx-delete="/scheduling/` + strconv.FormatInt(s.ScheduleID, 10) + `" hx-target="closest tr"><button type="submit">Delete</button></form></td></tr><tr id="editSched` + strconv.FormatInt(s.ScheduleID, 10) + `" class="edit-row" style="display:none"><td colspan="7"><form hx-put="/scheduling/` + strconv.FormatInt(s.ScheduleID, 10) + `" hx-target="#scheduleList" hx-swap="innerHTML"><select name="client_id"><option value="` + strconv.FormatInt(s.ClientID, 10) + `">` + clientName + `</option>` + clientOptions + `</select><select name="customer_id"><option value="` + strconv.FormatInt(s.CustomerID, 10) + `">` + customerName + `</option>` + customerOptions + `</select><select name="service_id"><option value="` + strconv.FormatInt(s.ServiceID, 10) + `">` + serviceName + `</option>` + serviceOptions + `</select><input type="date" name="plan_date" value="` + s.PlanDate + `"><input type="time" name="time" value="` + s.Time + `"><select name="status"><option value="` + s.Status + `">` + s.Status + `</option><option value="pending">Pending</option><option value="confirmed">Confirmed</option><option value="cancelled">Cancelled</option></select><button type="submit">Save</button></form></td></tr>`))
|
||||
}
|
||||
w.Write([]byte(`</tbody></table></body></html>`))
|
||||
}
|
||||
|
||||
func CreateSchedule(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)
|
||||
serviceID, _ := strconv.ParseInt(r.FormValue("service_id"), 10, 64)
|
||||
schedule := db.Schedule{
|
||||
ClientID: clientID,
|
||||
CustomerID: customerID,
|
||||
ServiceID: serviceID,
|
||||
PlanDate: r.FormValue("plan_date"),
|
||||
Time: r.FormValue("time"),
|
||||
Status: r.FormValue("status"),
|
||||
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, 64)
|
||||
var sch db.Schedule
|
||||
err := DB.QueryRow("SELECT schedule_id, client_id, customer_id, service_id, plan_date, time, status, created_at FROM scheduling WHERE schedule_id = ?", id).Scan(&sch.ScheduleID, &sch.ClientID, &sch.CustomerID, &sch.ServiceID, &sch.PlanDate, &sch.Time, &sch.Status, &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>Client ID: ` + strconv.FormatInt(sch.ClientID, 10) + `</p><p>Customer ID: ` + strconv.FormatInt(sch.CustomerID, 10) + `</p><p>Service ID: ` + strconv.FormatInt(sch.ServiceID, 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, 64)
|
||||
r.ParseForm()
|
||||
clientID, _ := strconv.ParseInt(r.FormValue("client_id"), 10, 64)
|
||||
customerID, _ := strconv.ParseInt(r.FormValue("customer_id"), 10, 64)
|
||||
serviceID, _ := strconv.ParseInt(r.FormValue("service_id"), 10, 64)
|
||||
_, err := DB.Exec("UPDATE scheduling SET client_id=?, customer_id=?, service_id=?, plan_date=?, time=?, status=? WHERE schedule_id=?", clientID, customerID, serviceID, r.FormValue("plan_date"), r.FormValue("time"), r.FormValue("status"), id)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.Header().Set("HX-Refresh", "true")
|
||||
}
|
||||
|
||||
func DeleteSchedule(w http.ResponseWriter, r *http.Request) {
|
||||
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
DB.Exec("DELETE FROM scheduling WHERE schedule_id = ?", id)
|
||||
}
|
||||
96
apps/go-crm/internal/handlers/services.go
Normal file
96
apps/go-crm/internal/handlers/services.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"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
|
||||
}
|
||||
|
||||
clients, err := db.ListClients(DB, limit, offset)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var clientOptions string
|
||||
for _, c := range clients {
|
||||
clientOptions += `<option value="` + strconv.FormatInt(c.ClientID, 10) + `">` + c.Name + `</option>`
|
||||
}
|
||||
|
||||
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"><select name="client_id" required><option value="">Select Client</option>` + clientOptions + `</select><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><button type="button" onclick="document.getElementById('editServ` + strconv.FormatInt(s.ServiceID, 10) + `').style.display='table-row'">Edit</button><form method="DELETE" style="display:inline" hx-delete="/services/` + strconv.FormatInt(s.ServiceID, 10) + `" hx-target="closest tr"><button type="submit">Delete</button></form></td></tr><tr id="editServ` + strconv.FormatInt(s.ServiceID, 10) + `" style="display:none"><td colspan="4"><form hx-put="/services/` + strconv.FormatInt(s.ServiceID, 10) + `" hx-target="#serviceList" hx-swap="innerHTML"><input type="text" name="name" value="` + s.Name + `"><input type="number" name="price" value="` + strconv.FormatFloat(s.Price, 'f', 2, 64) + `"><textarea name="description">` + s.Description + `</textarea><input type="text" name="duration" value="` + s.Duration + `"><select name="client_id"><option value="` + strconv.FormatInt(s.ClientID, 10) + `">` + s.Name + `</option>` + clientOptions + `</select><button type="submit">Save</button></form></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, 64)
|
||||
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, 64)
|
||||
r.ParseForm()
|
||||
clientID, _ := strconv.ParseInt(r.FormValue("client_id"), 10, 64)
|
||||
price, _ := strconv.ParseFloat(r.FormValue("price"), 64)
|
||||
_, err := 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)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.Header().Set("HX-Refresh", "true")
|
||||
}
|
||||
|
||||
func DeleteService(w http.ResponseWriter, r *http.Request) {
|
||||
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
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 }}
|
||||
77
apps/go-crm/internal/templates/layout.go
Normal file
77
apps/go-crm/internal/templates/layout.go
Normal file
@@ -0,0 +1,77 @@
|
||||
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>
|
||||
<li><a href="/answers">Answers</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
<div class="container">
|
||||
{{ .Content }}
|
||||
</div>
|
||||
</body>
|
||||
</html>`))
|
||||
|
||||
return tmpl
|
||||
}
|
||||
|
||||
func Get(name string) *template.Template {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
return templates.Lookup(name)
|
||||
}
|
||||
112
apps/go-crm/main.go
Normal file
112
apps/go-crm/main.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"go-crm/internal/db"
|
||||
"go-crm/internal/handlers"
|
||||
"go-crm/internal/templates"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
)
|
||||
|
||||
func main() {
|
||||
database, err := db.Init("/workspace/data/go-crm.db")
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to initialize database: %v", err)
|
||||
}
|
||||
defer database.Close()
|
||||
|
||||
handlers.SetupHandlers(database)
|
||||
|
||||
r := chi.NewRouter()
|
||||
|
||||
r.Use(middleware.Logger)
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(middleware.RequestID)
|
||||
|
||||
templates.Init()
|
||||
|
||||
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
homeTmpl := templates.Get("home.html")
|
||||
layoutTmpl := templates.Layout("Home", "")
|
||||
if homeTmpl != nil && layoutTmpl != nil {
|
||||
buf := &strings.Builder{}
|
||||
homeTmpl.Execute(buf, map[string]string{"Title": "Home"})
|
||||
layoutTmpl.Execute(w, map[string]string{"Title": "Home", "Content": buf.String()})
|
||||
} else {
|
||||
w.Write([]byte(`<!DOCTYPE html><html><head><title>Home</title></head><body><h1>Welcome to CRM</h1><nav><a href="/clients">Clients</a> | <a href="/customers">Customers</a> | <a href="/services">Services</a> | <a href="/questions">Questions</a> | <a href="/answers">Answers</a></nav></body></html>`))
|
||||
}
|
||||
})
|
||||
|
||||
r.Route("/auth", func(r chi.Router) {
|
||||
r.Get("/signup", handlers.SignupPage)
|
||||
r.Post("/signup", handlers.Signup)
|
||||
r.Get("/login", handlers.LoginPage)
|
||||
r.Post("/login", handlers.Login)
|
||||
r.Post("/logout", handlers.Logout)
|
||||
})
|
||||
|
||||
r.Route("/clients", func(r chi.Router) {
|
||||
r.Get("/", handlers.ListClients)
|
||||
r.Post("/", handlers.CreateClient)
|
||||
r.Get("/{id}", handlers.ViewClient)
|
||||
r.Put("/{id}", handlers.UpdateClient)
|
||||
r.Delete("/{id}", handlers.DeleteClient)
|
||||
})
|
||||
|
||||
r.Route("/customers", func(r chi.Router) {
|
||||
r.Get("/", handlers.ListCustomers)
|
||||
r.Post("/", handlers.CreateCustomer)
|
||||
r.Get("/{id}", handlers.ViewCustomer)
|
||||
r.Put("/{id}", handlers.UpdateCustomer)
|
||||
r.Delete("/{id}", handlers.DeleteCustomer)
|
||||
})
|
||||
|
||||
r.Route("/services", func(r chi.Router) {
|
||||
r.Get("/", handlers.ListServices)
|
||||
r.Post("/", handlers.CreateService)
|
||||
r.Get("/{id}", handlers.ViewService)
|
||||
r.Put("/{id}", handlers.UpdateService)
|
||||
r.Delete("/{id}", handlers.DeleteService)
|
||||
})
|
||||
|
||||
r.Route("/scheduling", func(r chi.Router) {
|
||||
r.Get("/", handlers.ListSchedules)
|
||||
r.Post("/", handlers.CreateSchedule)
|
||||
r.Get("/{id}", handlers.ViewSchedule)
|
||||
r.Put("/{id}", handlers.UpdateSchedule)
|
||||
r.Delete("/{id}", handlers.DeleteSchedule)
|
||||
})
|
||||
|
||||
r.Route("/payments", func(r chi.Router) {
|
||||
r.Get("/", handlers.ListPayments)
|
||||
r.Post("/", handlers.CreatePayment)
|
||||
r.Get("/{id}", handlers.ViewPayment)
|
||||
r.Put("/{id}", handlers.UpdatePayment)
|
||||
r.Delete("/{id}", handlers.DeletePayment)
|
||||
})
|
||||
|
||||
r.Route("/questions", func(r chi.Router) {
|
||||
r.Get("/", handlers.ListQuestions)
|
||||
r.Post("/", handlers.CreateQuestion)
|
||||
r.Get("/{id}", handlers.ViewQuestion)
|
||||
r.Put("/{id}", handlers.UpdateQuestion)
|
||||
r.Delete("/{id}", handlers.DeleteQuestion)
|
||||
})
|
||||
|
||||
r.Route("/answers", func(r chi.Router) {
|
||||
r.Get("/", handlers.ListAnswers)
|
||||
r.Post("/", handlers.CreateAnswer)
|
||||
r.Get("/{id}", handlers.ViewAnswer)
|
||||
r.Put("/{id}", handlers.UpdateAnswer)
|
||||
r.Delete("/{id}", handlers.DeleteAnswer)
|
||||
})
|
||||
|
||||
fmt.Println("CRM server running on http://localhost:8080")
|
||||
log.Fatal(http.ListenAndServe(":8080", r))
|
||||
}
|
||||
1
apps/go-crm/tmp/build-errors.log
Normal file
1
apps/go-crm/tmp/build-errors.log
Normal file
@@ -0,0 +1 @@
|
||||
exit status 1exit status 1
|
||||
1
apps/go-crm/tmp/build-errors.toml
Normal file
1
apps/go-crm/tmp/build-errors.toml
Normal file
@@ -0,0 +1 @@
|
||||
exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1
|
||||
BIN
apps/go-crm/tmp/main
Executable file
BIN
apps/go-crm/tmp/main
Executable file
Binary file not shown.
Reference in New Issue
Block a user