diff --git a/docker-compose.yml b/docker-compose.yml
index e32f4f0d..696b7a40 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -32,7 +32,15 @@ services:
- action: rebuild
path: ./app
ignore:
- - ./app/webroot
+ - ./app/webroot/pdf
+ - ./app/webroot/attachments_files
+ - ./app/tmp
+ - action: sync
+ path: ./app/webroot/css
+ target: /var/www/cmc-sales/app/webroot/css
+ - action: sync
+ path: ./app/webroot/js
+ target: /var/www/cmc-sales/app/webroot/js
db:
image: mariadb:latest
@@ -66,6 +74,8 @@ services:
condition: service_started
ports:
- "8080:8080"
+ volumes:
+ - ./app/webroot/pdf:/root/webroot/pdf
networks:
- cmc-network
restart: unless-stopped
@@ -76,6 +86,14 @@ services:
ignore:
- ./go-app/bin
- ./go-app/.env
+ - ./go-app/tmp
+ - "**/.*" # Ignore hidden files
+ - action: sync
+ path: ./go-app/templates
+ target: /app/templates
+ - action: sync
+ path: ./go-app/static
+ target: /app/static
volumes:
db_data:
diff --git a/go-app/.env.example b/go-app/.env.example
index c827b7df..30589799 100644
--- a/go-app/.env.example
+++ b/go-app/.env.example
@@ -5,5 +5,8 @@ DB_USER=cmc
DB_PASSWORD=xVRQI&cA?7AU=hqJ!%au
DB_NAME=cmc
+# Root database password (for dbshell-root)
+DB_ROOT_PASSWORD=secureRootPassword
+
# Server configuration
PORT=8080
\ No newline at end of file
diff --git a/go-app/Makefile b/go-app/Makefile
index 5af701b2..24df33e5 100644
--- a/go-app/Makefile
+++ b/go-app/Makefile
@@ -43,4 +43,24 @@ docker-build: ## Build Docker image
.PHONY: docker-run
docker-run: ## Run application in Docker
- docker run --rm -p 8080:8080 --network=host cmc-go:latest
\ No newline at end of file
+ docker run --rm -p 8080:8080 --network=host cmc-go:latest
+
+.PHONY: dbshell
+dbshell: ## Connect to MariaDB database interactively
+ @echo "Connecting to MariaDB..."
+ @if [ -z "$$DB_PASSWORD" ]; then \
+ echo "Reading password from docker-compose environment..."; \
+ docker compose exec db mariadb -u cmc -p cmc; \
+ else \
+ docker compose exec -e MYSQL_PWD="$$DB_PASSWORD" db mariadb -u cmc cmc; \
+ fi
+
+.PHONY: dbshell-root
+dbshell-root: ## Connect to MariaDB as root user
+ @echo "Connecting to MariaDB as root..."
+ @if [ -z "$$DB_ROOT_PASSWORD" ]; then \
+ echo "Please set DB_ROOT_PASSWORD environment variable"; \
+ exit 1; \
+ else \
+ docker compose exec -e MYSQL_PWD="$$DB_ROOT_PASSWORD" db mariadb -u root; \
+ fi
\ No newline at end of file
diff --git a/go-app/cmd/server/main.go b/go-app/cmd/server/main.go
index aae03bf7..2269eac9 100644
--- a/go-app/cmd/server/main.go
+++ b/go-app/cmd/server/main.go
@@ -57,14 +57,23 @@ func main() {
productHandler := handlers.NewProductHandler(queries)
purchaseOrderHandler := handlers.NewPurchaseOrderHandler(queries)
enquiryHandler := handlers.NewEnquiryHandler(queries)
+ documentHandler := handlers.NewDocumentHandler(queries)
pageHandler := handlers.NewPageHandler(queries, tmpl)
+ addressHandler := handlers.NewAddressHandler(queries)
+ attachmentHandler := handlers.NewAttachmentHandler(queries)
+ countryHandler := handlers.NewCountryHandler(queries)
+ statusHandler := handlers.NewStatusHandler(queries)
+ lineItemHandler := handlers.NewLineItemHandler(queries)
// Setup routes
r := mux.NewRouter()
-
+
// Static files
r.PathPrefix("/static/").Handler(http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
-
+
+ // PDF files (matching CakePHP structure)
+ r.PathPrefix("/pdf/").Handler(http.StripPrefix("/pdf/", http.FileServer(http.Dir("webroot/pdf"))))
+
// API routes
api := r.PathPrefix("/api/v1").Subrouter()
@@ -103,6 +112,55 @@ func main() {
api.HandleFunc("/enquiries/{id}/mark-submitted", enquiryHandler.MarkSubmitted).Methods("PUT")
api.HandleFunc("/enquiries/search", enquiryHandler.Search).Methods("GET")
+ // Document routes
+ api.HandleFunc("/documents", documentHandler.List).Methods("GET")
+ api.HandleFunc("/documents", documentHandler.Create).Methods("POST")
+ api.HandleFunc("/documents/{id}", documentHandler.Get).Methods("GET")
+ api.HandleFunc("/documents/{id}", documentHandler.Update).Methods("PUT")
+ api.HandleFunc("/documents/{id}/archive", documentHandler.Archive).Methods("PUT")
+ api.HandleFunc("/documents/{id}/unarchive", documentHandler.Unarchive).Methods("PUT")
+ api.HandleFunc("/documents/search", documentHandler.Search).Methods("GET")
+
+ // Address routes
+ api.HandleFunc("/addresses", addressHandler.List).Methods("GET")
+ api.HandleFunc("/addresses", addressHandler.Create).Methods("POST")
+ api.HandleFunc("/addresses/{id}", addressHandler.Get).Methods("GET")
+ api.HandleFunc("/addresses/{id}", addressHandler.Update).Methods("PUT")
+ api.HandleFunc("/addresses/{id}", addressHandler.Delete).Methods("DELETE")
+ api.HandleFunc("/addresses/customer/{customerID}", addressHandler.CustomerAddresses).Methods("GET")
+
+ // Attachment routes
+ api.HandleFunc("/attachments", attachmentHandler.List).Methods("GET")
+ api.HandleFunc("/attachments/archived", attachmentHandler.Archived).Methods("GET")
+ api.HandleFunc("/attachments", attachmentHandler.Create).Methods("POST")
+ api.HandleFunc("/attachments/{id}", attachmentHandler.Get).Methods("GET")
+ api.HandleFunc("/attachments/{id}", attachmentHandler.Update).Methods("PUT")
+ api.HandleFunc("/attachments/{id}", attachmentHandler.Delete).Methods("DELETE")
+
+ // Country routes
+ api.HandleFunc("/countries", countryHandler.List).Methods("GET")
+ api.HandleFunc("/countries", countryHandler.Create).Methods("POST")
+ api.HandleFunc("/countries/{id}", countryHandler.Get).Methods("GET")
+ api.HandleFunc("/countries/{id}", countryHandler.Update).Methods("PUT")
+ api.HandleFunc("/countries/{id}", countryHandler.Delete).Methods("DELETE")
+ api.HandleFunc("/countries/complete", countryHandler.CompleteCountry).Methods("GET")
+
+ // Status routes
+ api.HandleFunc("/statuses", statusHandler.List).Methods("GET")
+ api.HandleFunc("/statuses", statusHandler.Create).Methods("POST")
+ api.HandleFunc("/statuses/{id}", statusHandler.Get).Methods("GET")
+ api.HandleFunc("/statuses/{id}", statusHandler.Update).Methods("PUT")
+ api.HandleFunc("/statuses/{id}", statusHandler.Delete).Methods("DELETE")
+ api.HandleFunc("/statuses/json/{selectedId}", statusHandler.JsonList).Methods("GET")
+
+ // Line Item routes
+ api.HandleFunc("/line-items", lineItemHandler.List).Methods("GET")
+ api.HandleFunc("/line-items", lineItemHandler.Create).Methods("POST")
+ api.HandleFunc("/line-items/{id}", lineItemHandler.Get).Methods("GET")
+ api.HandleFunc("/line-items/{id}", lineItemHandler.Update).Methods("PUT")
+ api.HandleFunc("/line-items/{id}", lineItemHandler.Delete).Methods("DELETE")
+ api.HandleFunc("/line-items/document/{documentID}/table", lineItemHandler.GetTable).Methods("GET")
+
// Health check
api.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
@@ -111,28 +169,28 @@ func main() {
// Page routes
r.HandleFunc("/", pageHandler.Home).Methods("GET")
-
+
// Customer pages
r.HandleFunc("/customers", pageHandler.CustomersIndex).Methods("GET")
r.HandleFunc("/customers/new", pageHandler.CustomersNew).Methods("GET")
r.HandleFunc("/customers/search", pageHandler.CustomersSearch).Methods("GET")
r.HandleFunc("/customers/{id}", pageHandler.CustomersShow).Methods("GET")
r.HandleFunc("/customers/{id}/edit", pageHandler.CustomersEdit).Methods("GET")
-
+
// Product pages
r.HandleFunc("/products", pageHandler.ProductsIndex).Methods("GET")
r.HandleFunc("/products/new", pageHandler.ProductsNew).Methods("GET")
r.HandleFunc("/products/search", pageHandler.ProductsSearch).Methods("GET")
r.HandleFunc("/products/{id}", pageHandler.ProductsShow).Methods("GET")
r.HandleFunc("/products/{id}/edit", pageHandler.ProductsEdit).Methods("GET")
-
+
// Purchase Order pages
r.HandleFunc("/purchase-orders", pageHandler.PurchaseOrdersIndex).Methods("GET")
r.HandleFunc("/purchase-orders/new", pageHandler.PurchaseOrdersNew).Methods("GET")
r.HandleFunc("/purchase-orders/search", pageHandler.PurchaseOrdersSearch).Methods("GET")
r.HandleFunc("/purchase-orders/{id}", pageHandler.PurchaseOrdersShow).Methods("GET")
r.HandleFunc("/purchase-orders/{id}/edit", pageHandler.PurchaseOrdersEdit).Methods("GET")
-
+
// Enquiry pages
r.HandleFunc("/enquiries", pageHandler.EnquiriesIndex).Methods("GET")
r.HandleFunc("/enquiries/new", pageHandler.EnquiriesNew).Methods("GET")
@@ -140,19 +198,67 @@ func main() {
r.HandleFunc("/enquiries/{id}", pageHandler.EnquiriesShow).Methods("GET")
r.HandleFunc("/enquiries/{id}/edit", pageHandler.EnquiriesEdit).Methods("GET")
+ // Document pages
+ r.HandleFunc("/documents", pageHandler.DocumentsIndex).Methods("GET")
+ r.HandleFunc("/documents/search", pageHandler.DocumentsSearch).Methods("GET")
+ r.HandleFunc("/documents/view/{id}", pageHandler.DocumentsView).Methods("GET")
+ r.HandleFunc("/documents/{id}", pageHandler.DocumentsShow).Methods("GET")
+ r.HandleFunc("/documents/pdf/{id}", documentHandler.GeneratePDF).Methods("GET")
+
+ // Address routes (matching CakePHP)
+ r.HandleFunc("/addresses", addressHandler.List).Methods("GET")
+ r.HandleFunc("/addresses/view/{id}", addressHandler.Get).Methods("GET")
+ r.HandleFunc("/addresses/add/{customerid}", addressHandler.Create).Methods("GET", "POST")
+ r.HandleFunc("/addresses/add_another/{increment}", addressHandler.AddAnother).Methods("GET")
+ r.HandleFunc("/addresses/remove_another/{increment}", addressHandler.RemoveAnother).Methods("GET")
+ r.HandleFunc("/addresses/edit/{id}", addressHandler.Update).Methods("GET", "POST")
+ r.HandleFunc("/addresses/customer_addresses/{customerID}", addressHandler.CustomerAddresses).Methods("GET")
+
+ // Attachment routes (matching CakePHP)
+ r.HandleFunc("/attachments", attachmentHandler.List).Methods("GET")
+ r.HandleFunc("/attachments/view/{id}", attachmentHandler.Get).Methods("GET")
+ r.HandleFunc("/attachments/archived", attachmentHandler.Archived).Methods("GET")
+ r.HandleFunc("/attachments/add", attachmentHandler.Create).Methods("GET", "POST")
+ r.HandleFunc("/attachments/edit/{id}", attachmentHandler.Update).Methods("GET", "POST")
+ r.HandleFunc("/attachments/delete/{id}", attachmentHandler.Delete).Methods("POST")
+
+ // Country routes (matching CakePHP)
+ r.HandleFunc("/countries", countryHandler.List).Methods("GET")
+ r.HandleFunc("/countries/view/{id}", countryHandler.Get).Methods("GET")
+ r.HandleFunc("/countries/add", countryHandler.Create).Methods("GET", "POST")
+ r.HandleFunc("/countries/edit/{id}", countryHandler.Update).Methods("GET", "POST")
+ r.HandleFunc("/countries/delete/{id}", countryHandler.Delete).Methods("POST")
+ r.HandleFunc("/countries/complete_country", countryHandler.CompleteCountry).Methods("GET")
+
+ // Status routes (matching CakePHP)
+ r.HandleFunc("/statuses", statusHandler.List).Methods("GET")
+ r.HandleFunc("/statuses/view/{id}", statusHandler.Get).Methods("GET")
+ r.HandleFunc("/statuses/add", statusHandler.Create).Methods("GET", "POST")
+ r.HandleFunc("/statuses/edit/{id}", statusHandler.Update).Methods("GET", "POST")
+ r.HandleFunc("/statuses/delete/{id}", statusHandler.Delete).Methods("POST")
+ r.HandleFunc("/statuses/json_list/{selectedId}", statusHandler.JsonList).Methods("GET")
+
+ // Line Item routes (matching CakePHP)
+ r.HandleFunc("/line_items/ajax_add", lineItemHandler.AjaxAdd).Methods("POST")
+ r.HandleFunc("/line_items/ajax_edit", lineItemHandler.AjaxEdit).Methods("POST")
+ r.HandleFunc("/line_items/ajax_delete/{id}", lineItemHandler.AjaxDelete).Methods("POST")
+ r.HandleFunc("/line_items/get_table/{documentID}", lineItemHandler.GetTable).Methods("GET")
+ r.HandleFunc("/line_items/edit/{id}", lineItemHandler.Update).Methods("GET", "POST")
+ r.HandleFunc("/line_items/add/{documentID}", lineItemHandler.Create).Methods("GET", "POST")
+
// HTMX endpoints
r.HandleFunc("/customers", customerHandler.Create).Methods("POST")
r.HandleFunc("/customers/{id}", customerHandler.Update).Methods("PUT")
r.HandleFunc("/customers/{id}", customerHandler.Delete).Methods("DELETE")
-
+
r.HandleFunc("/products", productHandler.Create).Methods("POST")
r.HandleFunc("/products/{id}", productHandler.Update).Methods("PUT")
r.HandleFunc("/products/{id}", productHandler.Delete).Methods("DELETE")
-
+
r.HandleFunc("/purchase-orders", purchaseOrderHandler.Create).Methods("POST")
r.HandleFunc("/purchase-orders/{id}", purchaseOrderHandler.Update).Methods("PUT")
r.HandleFunc("/purchase-orders/{id}", purchaseOrderHandler.Delete).Methods("DELETE")
-
+
r.HandleFunc("/enquiries", enquiryHandler.Create).Methods("POST")
r.HandleFunc("/enquiries/{id}", enquiryHandler.Update).Methods("PUT")
r.HandleFunc("/enquiries/{id}", enquiryHandler.Delete).Methods("DELETE")
@@ -160,6 +266,11 @@ func main() {
r.HandleFunc("/enquiries/{id}/status", enquiryHandler.UpdateStatus).Methods("PUT")
r.HandleFunc("/enquiries/{id}/mark-submitted", enquiryHandler.MarkSubmitted).Methods("PUT")
+ r.HandleFunc("/documents", documentHandler.Create).Methods("POST")
+ r.HandleFunc("/documents/{id}", documentHandler.Update).Methods("PUT")
+ r.HandleFunc("/documents/{id}/archive", documentHandler.Archive).Methods("PUT")
+ r.HandleFunc("/documents/{id}/unarchive", documentHandler.Unarchive).Methods("PUT")
+
// Start server
port := getEnv("PORT", "8080")
log.Printf("Starting server on port %s", port)
@@ -173,4 +284,4 @@ func getEnv(key, defaultValue string) string {
return value
}
return defaultValue
-}
\ No newline at end of file
+}
diff --git a/go-app/go.mod b/go-app/go.mod
index 61cb205d..3dbae654 100644
--- a/go-app/go.mod
+++ b/go-app/go.mod
@@ -6,4 +6,5 @@ require (
github.com/go-sql-driver/mysql v1.7.1
github.com/gorilla/mux v1.8.1
github.com/joho/godotenv v1.5.1
+ github.com/jung-kurt/gofpdf v1.16.2
)
diff --git a/go-app/go.sum b/go-app/go.sum
index 67ed1c14..401bc738 100644
--- a/go-app/go.sum
+++ b/go-app/go.sum
@@ -1,6 +1,18 @@
+github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
+github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes=
+github.com/jung-kurt/gofpdf v1.16.2 h1:jgbatWHfRlPYiK85qgevsZTHviWXKwB1TTiKdz5PtRc=
+github.com/jung-kurt/gofpdf v1.16.2/go.mod h1:1hl7y57EsiPAkLbOwzpzqgx1A30nQCk/YmFV8S2vmK0=
+github.com/phpdave11/gofpdi v1.0.7/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
+github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w=
+github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
+golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
diff --git a/go-app/internal/cmc/db/addresses.sql.go b/go-app/internal/cmc/db/addresses.sql.go
new file mode 100644
index 00000000..caf5f701
--- /dev/null
+++ b/go-app/internal/cmc/db/addresses.sql.go
@@ -0,0 +1,259 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.29.0
+// source: addresses.sql
+
+package db
+
+import (
+ "context"
+ "database/sql"
+)
+
+const createAddress = `-- name: CreateAddress :execresult
+INSERT INTO addresses (
+ name, address, city, state_id, country_id,
+ customer_id, type, postcode
+) VALUES (
+ ?, ?, ?, ?, ?, ?, ?, ?
+)
+`
+
+type CreateAddressParams struct {
+ Name string `json:"name"`
+ Address string `json:"address"`
+ City string `json:"city"`
+ StateID int32 `json:"state_id"`
+ CountryID int32 `json:"country_id"`
+ CustomerID int32 `json:"customer_id"`
+ Type string `json:"type"`
+ Postcode string `json:"postcode"`
+}
+
+func (q *Queries) CreateAddress(ctx context.Context, arg CreateAddressParams) (sql.Result, error) {
+ return q.db.ExecContext(ctx, createAddress,
+ arg.Name,
+ arg.Address,
+ arg.City,
+ arg.StateID,
+ arg.CountryID,
+ arg.CustomerID,
+ arg.Type,
+ arg.Postcode,
+ )
+}
+
+const deleteAddress = `-- name: DeleteAddress :exec
+DELETE FROM addresses
+WHERE id = ?
+`
+
+func (q *Queries) DeleteAddress(ctx context.Context, id int32) error {
+ _, err := q.db.ExecContext(ctx, deleteAddress, id)
+ return err
+}
+
+const getAddress = `-- name: GetAddress :one
+SELECT id, name, address, city, state_id, country_id, customer_id, type, postcode FROM addresses
+WHERE id = ? LIMIT 1
+`
+
+func (q *Queries) GetAddress(ctx context.Context, id int32) (Address, error) {
+ row := q.db.QueryRowContext(ctx, getAddress, id)
+ var i Address
+ err := row.Scan(
+ &i.ID,
+ &i.Name,
+ &i.Address,
+ &i.City,
+ &i.StateID,
+ &i.CountryID,
+ &i.CustomerID,
+ &i.Type,
+ &i.Postcode,
+ )
+ return i, err
+}
+
+const getCustomerAddresses = `-- name: GetCustomerAddresses :many
+SELECT a.id, a.name, a.address, a.city, a.state_id, a.country_id, a.customer_id, a.type, a.postcode, s.name as state_name, s.shortform as state_shortform, c.name as country_name
+FROM addresses a
+LEFT JOIN states s ON a.state_id = s.id
+LEFT JOIN countries c ON a.country_id = c.id
+WHERE a.customer_id = ?
+ORDER BY a.name
+`
+
+type GetCustomerAddressesRow struct {
+ ID int32 `json:"id"`
+ Name string `json:"name"`
+ Address string `json:"address"`
+ City string `json:"city"`
+ StateID int32 `json:"state_id"`
+ CountryID int32 `json:"country_id"`
+ CustomerID int32 `json:"customer_id"`
+ Type string `json:"type"`
+ Postcode string `json:"postcode"`
+ StateName sql.NullString `json:"state_name"`
+ StateShortform sql.NullString `json:"state_shortform"`
+ CountryName sql.NullString `json:"country_name"`
+}
+
+func (q *Queries) GetCustomerAddresses(ctx context.Context, customerID int32) ([]GetCustomerAddressesRow, error) {
+ rows, err := q.db.QueryContext(ctx, getCustomerAddresses, customerID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []GetCustomerAddressesRow{}
+ for rows.Next() {
+ var i GetCustomerAddressesRow
+ if err := rows.Scan(
+ &i.ID,
+ &i.Name,
+ &i.Address,
+ &i.City,
+ &i.StateID,
+ &i.CountryID,
+ &i.CustomerID,
+ &i.Type,
+ &i.Postcode,
+ &i.StateName,
+ &i.StateShortform,
+ &i.CountryName,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const listAddresses = `-- name: ListAddresses :many
+SELECT id, name, address, city, state_id, country_id, customer_id, type, postcode FROM addresses
+ORDER BY name
+LIMIT ? OFFSET ?
+`
+
+type ListAddressesParams struct {
+ Limit int32 `json:"limit"`
+ Offset int32 `json:"offset"`
+}
+
+func (q *Queries) ListAddresses(ctx context.Context, arg ListAddressesParams) ([]Address, error) {
+ rows, err := q.db.QueryContext(ctx, listAddresses, arg.Limit, arg.Offset)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []Address{}
+ for rows.Next() {
+ var i Address
+ if err := rows.Scan(
+ &i.ID,
+ &i.Name,
+ &i.Address,
+ &i.City,
+ &i.StateID,
+ &i.CountryID,
+ &i.CustomerID,
+ &i.Type,
+ &i.Postcode,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const listAddressesByCustomer = `-- name: ListAddressesByCustomer :many
+SELECT id, name, address, city, state_id, country_id, customer_id, type, postcode FROM addresses
+WHERE customer_id = ?
+ORDER BY name
+`
+
+func (q *Queries) ListAddressesByCustomer(ctx context.Context, customerID int32) ([]Address, error) {
+ rows, err := q.db.QueryContext(ctx, listAddressesByCustomer, customerID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []Address{}
+ for rows.Next() {
+ var i Address
+ if err := rows.Scan(
+ &i.ID,
+ &i.Name,
+ &i.Address,
+ &i.City,
+ &i.StateID,
+ &i.CountryID,
+ &i.CustomerID,
+ &i.Type,
+ &i.Postcode,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const updateAddress = `-- name: UpdateAddress :exec
+UPDATE addresses
+SET name = ?,
+ address = ?,
+ city = ?,
+ state_id = ?,
+ country_id = ?,
+ customer_id = ?,
+ type = ?,
+ postcode = ?
+WHERE id = ?
+`
+
+type UpdateAddressParams struct {
+ Name string `json:"name"`
+ Address string `json:"address"`
+ City string `json:"city"`
+ StateID int32 `json:"state_id"`
+ CountryID int32 `json:"country_id"`
+ CustomerID int32 `json:"customer_id"`
+ Type string `json:"type"`
+ Postcode string `json:"postcode"`
+ ID int32 `json:"id"`
+}
+
+func (q *Queries) UpdateAddress(ctx context.Context, arg UpdateAddressParams) error {
+ _, err := q.db.ExecContext(ctx, updateAddress,
+ arg.Name,
+ arg.Address,
+ arg.City,
+ arg.StateID,
+ arg.CountryID,
+ arg.CustomerID,
+ arg.Type,
+ arg.Postcode,
+ arg.ID,
+ )
+ return err
+}
diff --git a/go-app/internal/cmc/db/attachments.sql.go b/go-app/internal/cmc/db/attachments.sql.go
new file mode 100644
index 00000000..97b3fac1
--- /dev/null
+++ b/go-app/internal/cmc/db/attachments.sql.go
@@ -0,0 +1,232 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.29.0
+// source: attachments.sql
+
+package db
+
+import (
+ "context"
+ "database/sql"
+)
+
+const createAttachment = `-- name: CreateAttachment :execresult
+INSERT INTO attachments (
+ principle_id, created, modified, name, filename,
+ file, type, size, description, archived
+) VALUES (
+ ?, NOW(), NOW(), ?, ?, ?, ?, ?, ?, 0
+)
+`
+
+type CreateAttachmentParams struct {
+ PrincipleID int32 `json:"principle_id"`
+ Name string `json:"name"`
+ Filename string `json:"filename"`
+ File string `json:"file"`
+ Type string `json:"type"`
+ Size int32 `json:"size"`
+ Description string `json:"description"`
+}
+
+func (q *Queries) CreateAttachment(ctx context.Context, arg CreateAttachmentParams) (sql.Result, error) {
+ return q.db.ExecContext(ctx, createAttachment,
+ arg.PrincipleID,
+ arg.Name,
+ arg.Filename,
+ arg.File,
+ arg.Type,
+ arg.Size,
+ arg.Description,
+ )
+}
+
+const deleteAttachment = `-- name: DeleteAttachment :exec
+UPDATE attachments
+SET archived = 1,
+ modified = NOW()
+WHERE id = ?
+`
+
+func (q *Queries) DeleteAttachment(ctx context.Context, id int32) error {
+ _, err := q.db.ExecContext(ctx, deleteAttachment, id)
+ return err
+}
+
+const getAttachment = `-- name: GetAttachment :one
+SELECT id, principle_id, created, modified, name, filename, file, type, size, description, archived FROM attachments
+WHERE id = ? LIMIT 1
+`
+
+func (q *Queries) GetAttachment(ctx context.Context, id int32) (Attachment, error) {
+ row := q.db.QueryRowContext(ctx, getAttachment, id)
+ var i Attachment
+ err := row.Scan(
+ &i.ID,
+ &i.PrincipleID,
+ &i.Created,
+ &i.Modified,
+ &i.Name,
+ &i.Filename,
+ &i.File,
+ &i.Type,
+ &i.Size,
+ &i.Description,
+ &i.Archived,
+ )
+ return i, err
+}
+
+const listArchivedAttachments = `-- name: ListArchivedAttachments :many
+SELECT id, principle_id, created, modified, name, filename, file, type, size, description, archived FROM attachments
+WHERE archived = 1
+ORDER BY created DESC
+LIMIT ? OFFSET ?
+`
+
+type ListArchivedAttachmentsParams struct {
+ Limit int32 `json:"limit"`
+ Offset int32 `json:"offset"`
+}
+
+func (q *Queries) ListArchivedAttachments(ctx context.Context, arg ListArchivedAttachmentsParams) ([]Attachment, error) {
+ rows, err := q.db.QueryContext(ctx, listArchivedAttachments, arg.Limit, arg.Offset)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []Attachment{}
+ for rows.Next() {
+ var i Attachment
+ if err := rows.Scan(
+ &i.ID,
+ &i.PrincipleID,
+ &i.Created,
+ &i.Modified,
+ &i.Name,
+ &i.Filename,
+ &i.File,
+ &i.Type,
+ &i.Size,
+ &i.Description,
+ &i.Archived,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const listAttachments = `-- name: ListAttachments :many
+SELECT id, principle_id, created, modified, name, filename, file, type, size, description, archived FROM attachments
+WHERE archived = 0
+ORDER BY created DESC
+LIMIT ? OFFSET ?
+`
+
+type ListAttachmentsParams struct {
+ Limit int32 `json:"limit"`
+ Offset int32 `json:"offset"`
+}
+
+func (q *Queries) ListAttachments(ctx context.Context, arg ListAttachmentsParams) ([]Attachment, error) {
+ rows, err := q.db.QueryContext(ctx, listAttachments, arg.Limit, arg.Offset)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []Attachment{}
+ for rows.Next() {
+ var i Attachment
+ if err := rows.Scan(
+ &i.ID,
+ &i.PrincipleID,
+ &i.Created,
+ &i.Modified,
+ &i.Name,
+ &i.Filename,
+ &i.File,
+ &i.Type,
+ &i.Size,
+ &i.Description,
+ &i.Archived,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const listAttachmentsByPrinciple = `-- name: ListAttachmentsByPrinciple :many
+SELECT id, principle_id, created, modified, name, filename, file, type, size, description, archived FROM attachments
+WHERE principle_id = ? AND archived = 0
+ORDER BY created DESC
+`
+
+func (q *Queries) ListAttachmentsByPrinciple(ctx context.Context, principleID int32) ([]Attachment, error) {
+ rows, err := q.db.QueryContext(ctx, listAttachmentsByPrinciple, principleID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []Attachment{}
+ for rows.Next() {
+ var i Attachment
+ if err := rows.Scan(
+ &i.ID,
+ &i.PrincipleID,
+ &i.Created,
+ &i.Modified,
+ &i.Name,
+ &i.Filename,
+ &i.File,
+ &i.Type,
+ &i.Size,
+ &i.Description,
+ &i.Archived,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const updateAttachment = `-- name: UpdateAttachment :exec
+UPDATE attachments
+SET modified = NOW(),
+ name = ?,
+ description = ?
+WHERE id = ?
+`
+
+type UpdateAttachmentParams struct {
+ Name string `json:"name"`
+ Description string `json:"description"`
+ ID int32 `json:"id"`
+}
+
+func (q *Queries) UpdateAttachment(ctx context.Context, arg UpdateAttachmentParams) error {
+ _, err := q.db.ExecContext(ctx, updateAttachment, arg.Name, arg.Description, arg.ID)
+ return err
+}
diff --git a/go-app/internal/cmc/db/boxes.sql.go b/go-app/internal/cmc/db/boxes.sql.go
new file mode 100644
index 00000000..12cd6246
--- /dev/null
+++ b/go-app/internal/cmc/db/boxes.sql.go
@@ -0,0 +1,171 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.29.0
+// source: boxes.sql
+
+package db
+
+import (
+ "context"
+ "database/sql"
+)
+
+const createBox = `-- name: CreateBox :execresult
+INSERT INTO boxes (
+ shipment_id, length, width, height, weight
+) VALUES (
+ ?, ?, ?, ?, ?
+)
+`
+
+type CreateBoxParams struct {
+ ShipmentID int32 `json:"shipment_id"`
+ Length string `json:"length"`
+ Width string `json:"width"`
+ Height string `json:"height"`
+ Weight string `json:"weight"`
+}
+
+func (q *Queries) CreateBox(ctx context.Context, arg CreateBoxParams) (sql.Result, error) {
+ return q.db.ExecContext(ctx, createBox,
+ arg.ShipmentID,
+ arg.Length,
+ arg.Width,
+ arg.Height,
+ arg.Weight,
+ )
+}
+
+const deleteBox = `-- name: DeleteBox :exec
+DELETE FROM boxes
+WHERE id = ?
+`
+
+func (q *Queries) DeleteBox(ctx context.Context, id int32) error {
+ _, err := q.db.ExecContext(ctx, deleteBox, id)
+ return err
+}
+
+const getBox = `-- name: GetBox :one
+SELECT id, shipment_id, length, width, height, weight FROM boxes
+WHERE id = ? LIMIT 1
+`
+
+func (q *Queries) GetBox(ctx context.Context, id int32) (Box, error) {
+ row := q.db.QueryRowContext(ctx, getBox, id)
+ var i Box
+ err := row.Scan(
+ &i.ID,
+ &i.ShipmentID,
+ &i.Length,
+ &i.Width,
+ &i.Height,
+ &i.Weight,
+ )
+ return i, err
+}
+
+const listBoxes = `-- name: ListBoxes :many
+SELECT id, shipment_id, length, width, height, weight FROM boxes
+ORDER BY id DESC
+LIMIT ? OFFSET ?
+`
+
+type ListBoxesParams struct {
+ Limit int32 `json:"limit"`
+ Offset int32 `json:"offset"`
+}
+
+func (q *Queries) ListBoxes(ctx context.Context, arg ListBoxesParams) ([]Box, error) {
+ rows, err := q.db.QueryContext(ctx, listBoxes, arg.Limit, arg.Offset)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []Box{}
+ for rows.Next() {
+ var i Box
+ if err := rows.Scan(
+ &i.ID,
+ &i.ShipmentID,
+ &i.Length,
+ &i.Width,
+ &i.Height,
+ &i.Weight,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const listBoxesByShipment = `-- name: ListBoxesByShipment :many
+SELECT id, shipment_id, length, width, height, weight FROM boxes
+WHERE shipment_id = ?
+ORDER BY id
+`
+
+func (q *Queries) ListBoxesByShipment(ctx context.Context, shipmentID int32) ([]Box, error) {
+ rows, err := q.db.QueryContext(ctx, listBoxesByShipment, shipmentID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []Box{}
+ for rows.Next() {
+ var i Box
+ if err := rows.Scan(
+ &i.ID,
+ &i.ShipmentID,
+ &i.Length,
+ &i.Width,
+ &i.Height,
+ &i.Weight,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const updateBox = `-- name: UpdateBox :exec
+UPDATE boxes
+SET length = ?,
+ width = ?,
+ height = ?,
+ weight = ?
+WHERE id = ?
+`
+
+type UpdateBoxParams struct {
+ Length string `json:"length"`
+ Width string `json:"width"`
+ Height string `json:"height"`
+ Weight string `json:"weight"`
+ ID int32 `json:"id"`
+}
+
+func (q *Queries) UpdateBox(ctx context.Context, arg UpdateBoxParams) error {
+ _, err := q.db.ExecContext(ctx, updateBox,
+ arg.Length,
+ arg.Width,
+ arg.Height,
+ arg.Weight,
+ arg.ID,
+ )
+ return err
+}
diff --git a/go-app/internal/cmc/db/countries.sql.go b/go-app/internal/cmc/db/countries.sql.go
new file mode 100644
index 00000000..894b044e
--- /dev/null
+++ b/go-app/internal/cmc/db/countries.sql.go
@@ -0,0 +1,118 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.29.0
+// source: countries.sql
+
+package db
+
+import (
+ "context"
+ "database/sql"
+)
+
+const createCountry = `-- name: CreateCountry :execresult
+INSERT INTO countries (name) VALUES (?)
+`
+
+func (q *Queries) CreateCountry(ctx context.Context, name string) (sql.Result, error) {
+ return q.db.ExecContext(ctx, createCountry, name)
+}
+
+const deleteCountry = `-- name: DeleteCountry :exec
+DELETE FROM countries WHERE id = ?
+`
+
+func (q *Queries) DeleteCountry(ctx context.Context, id int32) error {
+ _, err := q.db.ExecContext(ctx, deleteCountry, id)
+ return err
+}
+
+const getCountry = `-- name: GetCountry :one
+SELECT id, name FROM countries
+WHERE id = ? LIMIT 1
+`
+
+func (q *Queries) GetCountry(ctx context.Context, id int32) (Country, error) {
+ row := q.db.QueryRowContext(ctx, getCountry, id)
+ var i Country
+ err := row.Scan(&i.ID, &i.Name)
+ return i, err
+}
+
+const listCountries = `-- name: ListCountries :many
+SELECT id, name FROM countries
+ORDER BY name
+LIMIT ? OFFSET ?
+`
+
+type ListCountriesParams struct {
+ Limit int32 `json:"limit"`
+ Offset int32 `json:"offset"`
+}
+
+func (q *Queries) ListCountries(ctx context.Context, arg ListCountriesParams) ([]Country, error) {
+ rows, err := q.db.QueryContext(ctx, listCountries, arg.Limit, arg.Offset)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []Country{}
+ for rows.Next() {
+ var i Country
+ if err := rows.Scan(&i.ID, &i.Name); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const searchCountriesByName = `-- name: SearchCountriesByName :many
+SELECT id, name FROM countries
+WHERE name LIKE CONCAT('%', ?, '%')
+ORDER BY name
+LIMIT 10
+`
+
+func (q *Queries) SearchCountriesByName(ctx context.Context, concat interface{}) ([]Country, error) {
+ rows, err := q.db.QueryContext(ctx, searchCountriesByName, concat)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []Country{}
+ for rows.Next() {
+ var i Country
+ if err := rows.Scan(&i.ID, &i.Name); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const updateCountry = `-- name: UpdateCountry :exec
+UPDATE countries SET name = ? WHERE id = ?
+`
+
+type UpdateCountryParams struct {
+ Name string `json:"name"`
+ ID int32 `json:"id"`
+}
+
+func (q *Queries) UpdateCountry(ctx context.Context, arg UpdateCountryParams) error {
+ _, err := q.db.ExecContext(ctx, updateCountry, arg.Name, arg.ID)
+ return err
+}
diff --git a/go-app/internal/cmc/db/documents.sql.go b/go-app/internal/cmc/db/documents.sql.go
new file mode 100644
index 00000000..9feb8b8c
--- /dev/null
+++ b/go-app/internal/cmc/db/documents.sql.go
@@ -0,0 +1,624 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.29.0
+// source: documents.sql
+
+package db
+
+import (
+ "context"
+ "database/sql"
+ "time"
+)
+
+const archiveDocument = `-- name: ArchiveDocument :exec
+DELETE FROM documents
+WHERE id = ?
+`
+
+func (q *Queries) ArchiveDocument(ctx context.Context, id int32) error {
+ _, err := q.db.ExecContext(ctx, archiveDocument, id)
+ return err
+}
+
+const createDocument = `-- name: CreateDocument :execresult
+INSERT INTO documents (
+ type,
+ created,
+ user_id,
+ doc_page_count,
+ cmc_reference,
+ pdf_filename,
+ pdf_created_at,
+ pdf_created_by_user_id,
+ shipping_details,
+ revision,
+ bill_to,
+ ship_to,
+ email_sent_at,
+ email_sent_by_user_id
+) VALUES (
+ ?, NOW(), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
+)
+`
+
+type CreateDocumentParams struct {
+ Type DocumentsType `json:"type"`
+ UserID int32 `json:"user_id"`
+ DocPageCount int32 `json:"doc_page_count"`
+ CmcReference string `json:"cmc_reference"`
+ PdfFilename string `json:"pdf_filename"`
+ PdfCreatedAt time.Time `json:"pdf_created_at"`
+ PdfCreatedByUserID int32 `json:"pdf_created_by_user_id"`
+ ShippingDetails sql.NullString `json:"shipping_details"`
+ Revision int32 `json:"revision"`
+ BillTo sql.NullString `json:"bill_to"`
+ ShipTo sql.NullString `json:"ship_to"`
+ EmailSentAt time.Time `json:"email_sent_at"`
+ EmailSentByUserID int32 `json:"email_sent_by_user_id"`
+}
+
+func (q *Queries) CreateDocument(ctx context.Context, arg CreateDocumentParams) (sql.Result, error) {
+ return q.db.ExecContext(ctx, createDocument,
+ arg.Type,
+ arg.UserID,
+ arg.DocPageCount,
+ arg.CmcReference,
+ arg.PdfFilename,
+ arg.PdfCreatedAt,
+ arg.PdfCreatedByUserID,
+ arg.ShippingDetails,
+ arg.Revision,
+ arg.BillTo,
+ arg.ShipTo,
+ arg.EmailSentAt,
+ arg.EmailSentByUserID,
+ )
+}
+
+const getDocumentByID = `-- name: GetDocumentByID :one
+SELECT
+ d.id,
+ d.type,
+ d.created,
+ d.user_id,
+ d.doc_page_count,
+ d.pdf_filename,
+ d.pdf_created_at,
+ d.pdf_created_by_user_id,
+ d.cmc_reference,
+ d.revision,
+ d.shipping_details,
+ d.bill_to,
+ d.ship_to,
+ d.email_sent_at,
+ d.email_sent_by_user_id,
+ u.first_name as user_first_name,
+ u.last_name as user_last_name,
+ u.username as user_username,
+ pdf_creator.first_name as pdf_creator_first_name,
+ pdf_creator.last_name as pdf_creator_last_name,
+ pdf_creator.username as pdf_creator_username,
+ COALESCE(ec.name, ic.name, '') as customer_name,
+ e.title as enquiry_title
+FROM documents d
+LEFT JOIN users u ON d.user_id = u.id
+LEFT JOIN users pdf_creator ON d.pdf_created_by_user_id = pdf_creator.id
+LEFT JOIN enquiries e ON d.type IN ('quote', 'orderAck') AND d.cmc_reference = e.title
+LEFT JOIN customers ec ON e.customer_id = ec.id
+LEFT JOIN invoices i ON d.type = 'invoice' AND d.cmc_reference = i.title
+LEFT JOIN customers ic ON i.customer_id = ic.id
+WHERE d.id = ?
+`
+
+type GetDocumentByIDRow struct {
+ ID int32 `json:"id"`
+ Type DocumentsType `json:"type"`
+ Created time.Time `json:"created"`
+ UserID int32 `json:"user_id"`
+ DocPageCount int32 `json:"doc_page_count"`
+ PdfFilename string `json:"pdf_filename"`
+ PdfCreatedAt time.Time `json:"pdf_created_at"`
+ PdfCreatedByUserID int32 `json:"pdf_created_by_user_id"`
+ CmcReference string `json:"cmc_reference"`
+ Revision int32 `json:"revision"`
+ ShippingDetails sql.NullString `json:"shipping_details"`
+ BillTo sql.NullString `json:"bill_to"`
+ ShipTo sql.NullString `json:"ship_to"`
+ EmailSentAt time.Time `json:"email_sent_at"`
+ EmailSentByUserID int32 `json:"email_sent_by_user_id"`
+ UserFirstName sql.NullString `json:"user_first_name"`
+ UserLastName sql.NullString `json:"user_last_name"`
+ UserUsername sql.NullString `json:"user_username"`
+ PdfCreatorFirstName sql.NullString `json:"pdf_creator_first_name"`
+ PdfCreatorLastName sql.NullString `json:"pdf_creator_last_name"`
+ PdfCreatorUsername sql.NullString `json:"pdf_creator_username"`
+ CustomerName string `json:"customer_name"`
+ EnquiryTitle sql.NullString `json:"enquiry_title"`
+}
+
+func (q *Queries) GetDocumentByID(ctx context.Context, id int32) (GetDocumentByIDRow, error) {
+ row := q.db.QueryRowContext(ctx, getDocumentByID, id)
+ var i GetDocumentByIDRow
+ err := row.Scan(
+ &i.ID,
+ &i.Type,
+ &i.Created,
+ &i.UserID,
+ &i.DocPageCount,
+ &i.PdfFilename,
+ &i.PdfCreatedAt,
+ &i.PdfCreatedByUserID,
+ &i.CmcReference,
+ &i.Revision,
+ &i.ShippingDetails,
+ &i.BillTo,
+ &i.ShipTo,
+ &i.EmailSentAt,
+ &i.EmailSentByUserID,
+ &i.UserFirstName,
+ &i.UserLastName,
+ &i.UserUsername,
+ &i.PdfCreatorFirstName,
+ &i.PdfCreatorLastName,
+ &i.PdfCreatorUsername,
+ &i.CustomerName,
+ &i.EnquiryTitle,
+ )
+ return i, err
+}
+
+const getPurchaseOrderByDocumentID = `-- name: GetPurchaseOrderByDocumentID :one
+SELECT
+ po.id,
+ po.title,
+ po.principle_id,
+ po.principle_reference,
+ po.issue_date,
+ po.ordered_from,
+ po.dispatch_by,
+ po.deliver_to,
+ po.shipping_instructions
+FROM purchase_orders po
+JOIN documents d ON d.cmc_reference = po.title
+WHERE d.id = ? AND d.type = 'purchaseOrder'
+`
+
+type GetPurchaseOrderByDocumentIDRow struct {
+ ID int32 `json:"id"`
+ Title string `json:"title"`
+ PrincipleID int32 `json:"principle_id"`
+ PrincipleReference string `json:"principle_reference"`
+ IssueDate time.Time `json:"issue_date"`
+ OrderedFrom string `json:"ordered_from"`
+ DispatchBy string `json:"dispatch_by"`
+ DeliverTo string `json:"deliver_to"`
+ ShippingInstructions string `json:"shipping_instructions"`
+}
+
+func (q *Queries) GetPurchaseOrderByDocumentID(ctx context.Context, id int32) (GetPurchaseOrderByDocumentIDRow, error) {
+ row := q.db.QueryRowContext(ctx, getPurchaseOrderByDocumentID, id)
+ var i GetPurchaseOrderByDocumentIDRow
+ err := row.Scan(
+ &i.ID,
+ &i.Title,
+ &i.PrincipleID,
+ &i.PrincipleReference,
+ &i.IssueDate,
+ &i.OrderedFrom,
+ &i.DispatchBy,
+ &i.DeliverTo,
+ &i.ShippingInstructions,
+ )
+ return i, err
+}
+
+const listDocuments = `-- name: ListDocuments :many
+SELECT
+ d.id,
+ d.type,
+ d.created,
+ d.user_id,
+ d.doc_page_count,
+ d.pdf_filename,
+ d.pdf_created_at,
+ d.pdf_created_by_user_id,
+ d.cmc_reference,
+ d.revision,
+ d.email_sent_at,
+ d.email_sent_by_user_id,
+ u.first_name as user_first_name,
+ u.last_name as user_last_name,
+ u.username as user_username,
+ pdf_creator.first_name as pdf_creator_first_name,
+ pdf_creator.last_name as pdf_creator_last_name,
+ pdf_creator.username as pdf_creator_username
+FROM documents d
+LEFT JOIN users u ON d.user_id = u.id
+LEFT JOIN users pdf_creator ON d.pdf_created_by_user_id = pdf_creator.id
+ORDER BY d.id DESC
+LIMIT 1000
+`
+
+type ListDocumentsRow struct {
+ ID int32 `json:"id"`
+ Type DocumentsType `json:"type"`
+ Created time.Time `json:"created"`
+ UserID int32 `json:"user_id"`
+ DocPageCount int32 `json:"doc_page_count"`
+ PdfFilename string `json:"pdf_filename"`
+ PdfCreatedAt time.Time `json:"pdf_created_at"`
+ PdfCreatedByUserID int32 `json:"pdf_created_by_user_id"`
+ CmcReference string `json:"cmc_reference"`
+ Revision int32 `json:"revision"`
+ EmailSentAt time.Time `json:"email_sent_at"`
+ EmailSentByUserID int32 `json:"email_sent_by_user_id"`
+ UserFirstName sql.NullString `json:"user_first_name"`
+ UserLastName sql.NullString `json:"user_last_name"`
+ UserUsername sql.NullString `json:"user_username"`
+ PdfCreatorFirstName sql.NullString `json:"pdf_creator_first_name"`
+ PdfCreatorLastName sql.NullString `json:"pdf_creator_last_name"`
+ PdfCreatorUsername sql.NullString `json:"pdf_creator_username"`
+}
+
+func (q *Queries) ListDocuments(ctx context.Context) ([]ListDocumentsRow, error) {
+ rows, err := q.db.QueryContext(ctx, listDocuments)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []ListDocumentsRow{}
+ for rows.Next() {
+ var i ListDocumentsRow
+ if err := rows.Scan(
+ &i.ID,
+ &i.Type,
+ &i.Created,
+ &i.UserID,
+ &i.DocPageCount,
+ &i.PdfFilename,
+ &i.PdfCreatedAt,
+ &i.PdfCreatedByUserID,
+ &i.CmcReference,
+ &i.Revision,
+ &i.EmailSentAt,
+ &i.EmailSentByUserID,
+ &i.UserFirstName,
+ &i.UserLastName,
+ &i.UserUsername,
+ &i.PdfCreatorFirstName,
+ &i.PdfCreatorLastName,
+ &i.PdfCreatorUsername,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const listDocumentsByType = `-- name: ListDocumentsByType :many
+SELECT
+ d.id,
+ d.type,
+ d.created,
+ d.user_id,
+ d.doc_page_count,
+ d.pdf_filename,
+ d.pdf_created_at,
+ d.pdf_created_by_user_id,
+ d.cmc_reference,
+ d.revision,
+ d.email_sent_at,
+ d.email_sent_by_user_id,
+ u.first_name as user_first_name,
+ u.last_name as user_last_name,
+ u.username as user_username,
+ pdf_creator.first_name as pdf_creator_first_name,
+ pdf_creator.last_name as pdf_creator_last_name,
+ pdf_creator.username as pdf_creator_username,
+ COALESCE(ec.name, ic.name, '') as customer_name,
+ e.title as enquiry_title
+FROM documents d
+LEFT JOIN users u ON d.user_id = u.id
+LEFT JOIN users pdf_creator ON d.pdf_created_by_user_id = pdf_creator.id
+LEFT JOIN enquiries e ON d.type IN ('quote', 'orderAck') AND d.cmc_reference = e.title
+LEFT JOIN customers ec ON e.customer_id = ec.id
+LEFT JOIN invoices i ON d.type = 'invoice' AND d.cmc_reference = i.title
+LEFT JOIN customers ic ON i.customer_id = ic.id
+WHERE d.type = ?
+ORDER BY d.id DESC
+LIMIT 1000
+`
+
+type ListDocumentsByTypeRow struct {
+ ID int32 `json:"id"`
+ Type DocumentsType `json:"type"`
+ Created time.Time `json:"created"`
+ UserID int32 `json:"user_id"`
+ DocPageCount int32 `json:"doc_page_count"`
+ PdfFilename string `json:"pdf_filename"`
+ PdfCreatedAt time.Time `json:"pdf_created_at"`
+ PdfCreatedByUserID int32 `json:"pdf_created_by_user_id"`
+ CmcReference string `json:"cmc_reference"`
+ Revision int32 `json:"revision"`
+ EmailSentAt time.Time `json:"email_sent_at"`
+ EmailSentByUserID int32 `json:"email_sent_by_user_id"`
+ UserFirstName sql.NullString `json:"user_first_name"`
+ UserLastName sql.NullString `json:"user_last_name"`
+ UserUsername sql.NullString `json:"user_username"`
+ PdfCreatorFirstName sql.NullString `json:"pdf_creator_first_name"`
+ PdfCreatorLastName sql.NullString `json:"pdf_creator_last_name"`
+ PdfCreatorUsername sql.NullString `json:"pdf_creator_username"`
+ CustomerName string `json:"customer_name"`
+ EnquiryTitle sql.NullString `json:"enquiry_title"`
+}
+
+func (q *Queries) ListDocumentsByType(ctx context.Context, type_ DocumentsType) ([]ListDocumentsByTypeRow, error) {
+ rows, err := q.db.QueryContext(ctx, listDocumentsByType, type_)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []ListDocumentsByTypeRow{}
+ for rows.Next() {
+ var i ListDocumentsByTypeRow
+ if err := rows.Scan(
+ &i.ID,
+ &i.Type,
+ &i.Created,
+ &i.UserID,
+ &i.DocPageCount,
+ &i.PdfFilename,
+ &i.PdfCreatedAt,
+ &i.PdfCreatedByUserID,
+ &i.CmcReference,
+ &i.Revision,
+ &i.EmailSentAt,
+ &i.EmailSentByUserID,
+ &i.UserFirstName,
+ &i.UserLastName,
+ &i.UserUsername,
+ &i.PdfCreatorFirstName,
+ &i.PdfCreatorLastName,
+ &i.PdfCreatorUsername,
+ &i.CustomerName,
+ &i.EnquiryTitle,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const searchDocuments = `-- name: SearchDocuments :many
+SELECT
+ d.id,
+ d.type,
+ d.created,
+ d.user_id,
+ d.doc_page_count,
+ d.pdf_filename,
+ d.pdf_created_at,
+ d.pdf_created_by_user_id,
+ d.cmc_reference,
+ d.revision,
+ d.email_sent_at,
+ d.email_sent_by_user_id,
+ u.first_name as user_first_name,
+ u.last_name as user_last_name,
+ u.username as user_username,
+ pdf_creator.first_name as pdf_creator_first_name,
+ pdf_creator.last_name as pdf_creator_last_name,
+ pdf_creator.username as pdf_creator_username,
+ COALESCE(ec.name, ic.name, '') as customer_name,
+ e.title as enquiry_title
+FROM documents d
+LEFT JOIN users u ON d.user_id = u.id
+LEFT JOIN users pdf_creator ON d.pdf_created_by_user_id = pdf_creator.id
+LEFT JOIN enquiries e ON d.type IN ('quote', 'orderAck') AND d.cmc_reference = e.title
+LEFT JOIN customers ec ON e.customer_id = ec.id
+LEFT JOIN invoices i ON d.type = 'invoice' AND d.cmc_reference = i.title
+LEFT JOIN customers ic ON i.customer_id = ic.id
+WHERE (
+ d.pdf_filename LIKE ? OR
+ d.cmc_reference LIKE ? OR
+ COALESCE(ec.name, ic.name) LIKE ? OR
+ e.title LIKE ? OR
+ u.username LIKE ?
+)
+ORDER BY d.id DESC
+LIMIT 1000
+`
+
+type SearchDocumentsParams struct {
+ PdfFilename string `json:"pdf_filename"`
+ CmcReference string `json:"cmc_reference"`
+ Name string `json:"name"`
+ Title string `json:"title"`
+ Username string `json:"username"`
+}
+
+type SearchDocumentsRow struct {
+ ID int32 `json:"id"`
+ Type DocumentsType `json:"type"`
+ Created time.Time `json:"created"`
+ UserID int32 `json:"user_id"`
+ DocPageCount int32 `json:"doc_page_count"`
+ PdfFilename string `json:"pdf_filename"`
+ PdfCreatedAt time.Time `json:"pdf_created_at"`
+ PdfCreatedByUserID int32 `json:"pdf_created_by_user_id"`
+ CmcReference string `json:"cmc_reference"`
+ Revision int32 `json:"revision"`
+ EmailSentAt time.Time `json:"email_sent_at"`
+ EmailSentByUserID int32 `json:"email_sent_by_user_id"`
+ UserFirstName sql.NullString `json:"user_first_name"`
+ UserLastName sql.NullString `json:"user_last_name"`
+ UserUsername sql.NullString `json:"user_username"`
+ PdfCreatorFirstName sql.NullString `json:"pdf_creator_first_name"`
+ PdfCreatorLastName sql.NullString `json:"pdf_creator_last_name"`
+ PdfCreatorUsername sql.NullString `json:"pdf_creator_username"`
+ CustomerName string `json:"customer_name"`
+ EnquiryTitle sql.NullString `json:"enquiry_title"`
+}
+
+func (q *Queries) SearchDocuments(ctx context.Context, arg SearchDocumentsParams) ([]SearchDocumentsRow, error) {
+ rows, err := q.db.QueryContext(ctx, searchDocuments,
+ arg.PdfFilename,
+ arg.CmcReference,
+ arg.Name,
+ arg.Title,
+ arg.Username,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []SearchDocumentsRow{}
+ for rows.Next() {
+ var i SearchDocumentsRow
+ if err := rows.Scan(
+ &i.ID,
+ &i.Type,
+ &i.Created,
+ &i.UserID,
+ &i.DocPageCount,
+ &i.PdfFilename,
+ &i.PdfCreatedAt,
+ &i.PdfCreatedByUserID,
+ &i.CmcReference,
+ &i.Revision,
+ &i.EmailSentAt,
+ &i.EmailSentByUserID,
+ &i.UserFirstName,
+ &i.UserLastName,
+ &i.UserUsername,
+ &i.PdfCreatorFirstName,
+ &i.PdfCreatorLastName,
+ &i.PdfCreatorUsername,
+ &i.CustomerName,
+ &i.EnquiryTitle,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const unarchiveDocument = `-- name: UnarchiveDocument :exec
+SELECT 1 WHERE ? = ?
+`
+
+type UnarchiveDocumentParams struct {
+ Column1 interface{} `json:"column_1"`
+ Column2 interface{} `json:"column_2"`
+}
+
+// Note: Unarchiving not supported as documents table doesn't have an archived column
+// This is a no-op for compatibility
+func (q *Queries) UnarchiveDocument(ctx context.Context, arg UnarchiveDocumentParams) error {
+ _, err := q.db.ExecContext(ctx, unarchiveDocument, arg.Column1, arg.Column2)
+ return err
+}
+
+const updateDocument = `-- name: UpdateDocument :exec
+UPDATE documents
+SET
+ type = ?,
+ user_id = ?,
+ doc_page_count = ?,
+ cmc_reference = ?,
+ pdf_filename = ?,
+ pdf_created_at = ?,
+ pdf_created_by_user_id = ?,
+ shipping_details = ?,
+ revision = ?,
+ bill_to = ?,
+ ship_to = ?,
+ email_sent_at = ?,
+ email_sent_by_user_id = ?
+WHERE id = ?
+`
+
+type UpdateDocumentParams struct {
+ Type DocumentsType `json:"type"`
+ UserID int32 `json:"user_id"`
+ DocPageCount int32 `json:"doc_page_count"`
+ CmcReference string `json:"cmc_reference"`
+ PdfFilename string `json:"pdf_filename"`
+ PdfCreatedAt time.Time `json:"pdf_created_at"`
+ PdfCreatedByUserID int32 `json:"pdf_created_by_user_id"`
+ ShippingDetails sql.NullString `json:"shipping_details"`
+ Revision int32 `json:"revision"`
+ BillTo sql.NullString `json:"bill_to"`
+ ShipTo sql.NullString `json:"ship_to"`
+ EmailSentAt time.Time `json:"email_sent_at"`
+ EmailSentByUserID int32 `json:"email_sent_by_user_id"`
+ ID int32 `json:"id"`
+}
+
+func (q *Queries) UpdateDocument(ctx context.Context, arg UpdateDocumentParams) error {
+ _, err := q.db.ExecContext(ctx, updateDocument,
+ arg.Type,
+ arg.UserID,
+ arg.DocPageCount,
+ arg.CmcReference,
+ arg.PdfFilename,
+ arg.PdfCreatedAt,
+ arg.PdfCreatedByUserID,
+ arg.ShippingDetails,
+ arg.Revision,
+ arg.BillTo,
+ arg.ShipTo,
+ arg.EmailSentAt,
+ arg.EmailSentByUserID,
+ arg.ID,
+ )
+ return err
+}
+
+const updateDocumentPDFInfo = `-- name: UpdateDocumentPDFInfo :exec
+UPDATE documents
+SET
+ pdf_filename = ?,
+ pdf_created_at = ?,
+ pdf_created_by_user_id = ?
+WHERE id = ?
+`
+
+type UpdateDocumentPDFInfoParams struct {
+ PdfFilename string `json:"pdf_filename"`
+ PdfCreatedAt time.Time `json:"pdf_created_at"`
+ PdfCreatedByUserID int32 `json:"pdf_created_by_user_id"`
+ ID int32 `json:"id"`
+}
+
+func (q *Queries) UpdateDocumentPDFInfo(ctx context.Context, arg UpdateDocumentPDFInfoParams) error {
+ _, err := q.db.ExecContext(ctx, updateDocumentPDFInfo,
+ arg.PdfFilename,
+ arg.PdfCreatedAt,
+ arg.PdfCreatedByUserID,
+ arg.ID,
+ )
+ return err
+}
diff --git a/go-app/internal/cmc/db/line_items.sql.go b/go-app/internal/cmc/db/line_items.sql.go
new file mode 100644
index 00000000..523be51d
--- /dev/null
+++ b/go-app/internal/cmc/db/line_items.sql.go
@@ -0,0 +1,461 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.29.0
+// source: line_items.sql
+
+package db
+
+import (
+ "context"
+ "database/sql"
+)
+
+const createLineItem = `-- name: CreateLineItem :execresult
+INSERT INTO line_items (
+ item_number, ` + "`" + `option` + "`" + `, quantity, title, description,
+ document_id, product_id, has_text_prices, has_price,
+ unit_price_string, gross_price_string, costing_id,
+ gross_unit_price, net_unit_price, discount_percent,
+ discount_amount_unit, discount_amount_total,
+ gross_price, net_price
+) VALUES (
+ ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
+)
+`
+
+type CreateLineItemParams struct {
+ ItemNumber string `json:"item_number"`
+ Option bool `json:"option"`
+ Quantity string `json:"quantity"`
+ Title string `json:"title"`
+ Description string `json:"description"`
+ DocumentID int32 `json:"document_id"`
+ ProductID sql.NullInt32 `json:"product_id"`
+ HasTextPrices bool `json:"has_text_prices"`
+ HasPrice int8 `json:"has_price"`
+ UnitPriceString sql.NullString `json:"unit_price_string"`
+ GrossPriceString sql.NullString `json:"gross_price_string"`
+ CostingID sql.NullInt32 `json:"costing_id"`
+ GrossUnitPrice sql.NullString `json:"gross_unit_price"`
+ NetUnitPrice sql.NullString `json:"net_unit_price"`
+ DiscountPercent sql.NullString `json:"discount_percent"`
+ DiscountAmountUnit sql.NullString `json:"discount_amount_unit"`
+ DiscountAmountTotal sql.NullString `json:"discount_amount_total"`
+ GrossPrice sql.NullString `json:"gross_price"`
+ NetPrice sql.NullString `json:"net_price"`
+}
+
+func (q *Queries) CreateLineItem(ctx context.Context, arg CreateLineItemParams) (sql.Result, error) {
+ return q.db.ExecContext(ctx, createLineItem,
+ arg.ItemNumber,
+ arg.Option,
+ arg.Quantity,
+ arg.Title,
+ arg.Description,
+ arg.DocumentID,
+ arg.ProductID,
+ arg.HasTextPrices,
+ arg.HasPrice,
+ arg.UnitPriceString,
+ arg.GrossPriceString,
+ arg.CostingID,
+ arg.GrossUnitPrice,
+ arg.NetUnitPrice,
+ arg.DiscountPercent,
+ arg.DiscountAmountUnit,
+ arg.DiscountAmountTotal,
+ arg.GrossPrice,
+ arg.NetPrice,
+ )
+}
+
+const deleteLineItem = `-- name: DeleteLineItem :exec
+DELETE FROM line_items
+WHERE id = ?
+`
+
+func (q *Queries) DeleteLineItem(ctx context.Context, id int32) error {
+ _, err := q.db.ExecContext(ctx, deleteLineItem, id)
+ return err
+}
+
+const getLineItem = `-- name: GetLineItem :one
+SELECT id, item_number, ` + "`" + `option` + "`" + `, quantity, title, description, document_id, product_id, has_text_prices, has_price, unit_price_string, gross_price_string, costing_id, gross_unit_price, net_unit_price, discount_percent, discount_amount_unit, discount_amount_total, gross_price, net_price FROM line_items
+WHERE id = ? LIMIT 1
+`
+
+func (q *Queries) GetLineItem(ctx context.Context, id int32) (LineItem, error) {
+ row := q.db.QueryRowContext(ctx, getLineItem, id)
+ var i LineItem
+ err := row.Scan(
+ &i.ID,
+ &i.ItemNumber,
+ &i.Option,
+ &i.Quantity,
+ &i.Title,
+ &i.Description,
+ &i.DocumentID,
+ &i.ProductID,
+ &i.HasTextPrices,
+ &i.HasPrice,
+ &i.UnitPriceString,
+ &i.GrossPriceString,
+ &i.CostingID,
+ &i.GrossUnitPrice,
+ &i.NetUnitPrice,
+ &i.DiscountPercent,
+ &i.DiscountAmountUnit,
+ &i.DiscountAmountTotal,
+ &i.GrossPrice,
+ &i.NetPrice,
+ )
+ return i, err
+}
+
+const getLineItemsByProduct = `-- name: GetLineItemsByProduct :many
+SELECT id, item_number, ` + "`" + `option` + "`" + `, quantity, title, description, document_id, product_id, has_text_prices, has_price, unit_price_string, gross_price_string, costing_id, gross_unit_price, net_unit_price, discount_percent, discount_amount_unit, discount_amount_total, gross_price, net_price FROM line_items
+WHERE product_id = ?
+ORDER BY item_number ASC
+`
+
+func (q *Queries) GetLineItemsByProduct(ctx context.Context, productID sql.NullInt32) ([]LineItem, error) {
+ rows, err := q.db.QueryContext(ctx, getLineItemsByProduct, productID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []LineItem{}
+ for rows.Next() {
+ var i LineItem
+ if err := rows.Scan(
+ &i.ID,
+ &i.ItemNumber,
+ &i.Option,
+ &i.Quantity,
+ &i.Title,
+ &i.Description,
+ &i.DocumentID,
+ &i.ProductID,
+ &i.HasTextPrices,
+ &i.HasPrice,
+ &i.UnitPriceString,
+ &i.GrossPriceString,
+ &i.CostingID,
+ &i.GrossUnitPrice,
+ &i.NetUnitPrice,
+ &i.DiscountPercent,
+ &i.DiscountAmountUnit,
+ &i.DiscountAmountTotal,
+ &i.GrossPrice,
+ &i.NetPrice,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const getLineItemsTable = `-- name: GetLineItemsTable :many
+SELECT li.id, li.item_number, li.` + "`" + `option` + "`" + `, li.quantity, li.title, li.description, li.document_id, li.product_id, li.has_text_prices, li.has_price, li.unit_price_string, li.gross_price_string, li.costing_id, li.gross_unit_price, li.net_unit_price, li.discount_percent, li.discount_amount_unit, li.discount_amount_total, li.gross_price, li.net_price, p.title as product_title, p.model_number
+FROM line_items li
+LEFT JOIN products p ON li.product_id = p.id
+WHERE li.document_id = ?
+ORDER BY li.item_number ASC
+`
+
+type GetLineItemsTableRow struct {
+ ID int32 `json:"id"`
+ ItemNumber string `json:"item_number"`
+ Option bool `json:"option"`
+ Quantity string `json:"quantity"`
+ Title string `json:"title"`
+ Description string `json:"description"`
+ DocumentID int32 `json:"document_id"`
+ ProductID sql.NullInt32 `json:"product_id"`
+ HasTextPrices bool `json:"has_text_prices"`
+ HasPrice int8 `json:"has_price"`
+ UnitPriceString sql.NullString `json:"unit_price_string"`
+ GrossPriceString sql.NullString `json:"gross_price_string"`
+ CostingID sql.NullInt32 `json:"costing_id"`
+ GrossUnitPrice sql.NullString `json:"gross_unit_price"`
+ NetUnitPrice sql.NullString `json:"net_unit_price"`
+ DiscountPercent sql.NullString `json:"discount_percent"`
+ DiscountAmountUnit sql.NullString `json:"discount_amount_unit"`
+ DiscountAmountTotal sql.NullString `json:"discount_amount_total"`
+ GrossPrice sql.NullString `json:"gross_price"`
+ NetPrice sql.NullString `json:"net_price"`
+ ProductTitle sql.NullString `json:"product_title"`
+ ModelNumber sql.NullString `json:"model_number"`
+}
+
+func (q *Queries) GetLineItemsTable(ctx context.Context, documentID int32) ([]GetLineItemsTableRow, error) {
+ rows, err := q.db.QueryContext(ctx, getLineItemsTable, documentID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []GetLineItemsTableRow{}
+ for rows.Next() {
+ var i GetLineItemsTableRow
+ if err := rows.Scan(
+ &i.ID,
+ &i.ItemNumber,
+ &i.Option,
+ &i.Quantity,
+ &i.Title,
+ &i.Description,
+ &i.DocumentID,
+ &i.ProductID,
+ &i.HasTextPrices,
+ &i.HasPrice,
+ &i.UnitPriceString,
+ &i.GrossPriceString,
+ &i.CostingID,
+ &i.GrossUnitPrice,
+ &i.NetUnitPrice,
+ &i.DiscountPercent,
+ &i.DiscountAmountUnit,
+ &i.DiscountAmountTotal,
+ &i.GrossPrice,
+ &i.NetPrice,
+ &i.ProductTitle,
+ &i.ModelNumber,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const getMaxItemNumber = `-- name: GetMaxItemNumber :one
+SELECT COALESCE(MAX(item_number), 0) as max_item_number
+FROM line_items
+WHERE document_id = ?
+`
+
+func (q *Queries) GetMaxItemNumber(ctx context.Context, documentID int32) (interface{}, error) {
+ row := q.db.QueryRowContext(ctx, getMaxItemNumber, documentID)
+ var max_item_number interface{}
+ err := row.Scan(&max_item_number)
+ return max_item_number, err
+}
+
+const listLineItems = `-- name: ListLineItems :many
+SELECT id, item_number, ` + "`" + `option` + "`" + `, quantity, title, description, document_id, product_id, has_text_prices, has_price, unit_price_string, gross_price_string, costing_id, gross_unit_price, net_unit_price, discount_percent, discount_amount_unit, discount_amount_total, gross_price, net_price FROM line_items
+ORDER BY item_number ASC
+LIMIT ? OFFSET ?
+`
+
+type ListLineItemsParams struct {
+ Limit int32 `json:"limit"`
+ Offset int32 `json:"offset"`
+}
+
+func (q *Queries) ListLineItems(ctx context.Context, arg ListLineItemsParams) ([]LineItem, error) {
+ rows, err := q.db.QueryContext(ctx, listLineItems, arg.Limit, arg.Offset)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []LineItem{}
+ for rows.Next() {
+ var i LineItem
+ if err := rows.Scan(
+ &i.ID,
+ &i.ItemNumber,
+ &i.Option,
+ &i.Quantity,
+ &i.Title,
+ &i.Description,
+ &i.DocumentID,
+ &i.ProductID,
+ &i.HasTextPrices,
+ &i.HasPrice,
+ &i.UnitPriceString,
+ &i.GrossPriceString,
+ &i.CostingID,
+ &i.GrossUnitPrice,
+ &i.NetUnitPrice,
+ &i.DiscountPercent,
+ &i.DiscountAmountUnit,
+ &i.DiscountAmountTotal,
+ &i.GrossPrice,
+ &i.NetPrice,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const listLineItemsByDocument = `-- name: ListLineItemsByDocument :many
+SELECT id, item_number, ` + "`" + `option` + "`" + `, quantity, title, description, document_id, product_id, has_text_prices, has_price, unit_price_string, gross_price_string, costing_id, gross_unit_price, net_unit_price, discount_percent, discount_amount_unit, discount_amount_total, gross_price, net_price FROM line_items
+WHERE document_id = ?
+ORDER BY item_number ASC
+`
+
+func (q *Queries) ListLineItemsByDocument(ctx context.Context, documentID int32) ([]LineItem, error) {
+ rows, err := q.db.QueryContext(ctx, listLineItemsByDocument, documentID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []LineItem{}
+ for rows.Next() {
+ var i LineItem
+ if err := rows.Scan(
+ &i.ID,
+ &i.ItemNumber,
+ &i.Option,
+ &i.Quantity,
+ &i.Title,
+ &i.Description,
+ &i.DocumentID,
+ &i.ProductID,
+ &i.HasTextPrices,
+ &i.HasPrice,
+ &i.UnitPriceString,
+ &i.GrossPriceString,
+ &i.CostingID,
+ &i.GrossUnitPrice,
+ &i.NetUnitPrice,
+ &i.DiscountPercent,
+ &i.DiscountAmountUnit,
+ &i.DiscountAmountTotal,
+ &i.GrossPrice,
+ &i.NetPrice,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const updateLineItem = `-- name: UpdateLineItem :exec
+UPDATE line_items
+SET item_number = ?,
+ ` + "`" + `option` + "`" + ` = ?,
+ quantity = ?,
+ title = ?,
+ description = ?,
+ document_id = ?,
+ product_id = ?,
+ has_text_prices = ?,
+ has_price = ?,
+ unit_price_string = ?,
+ gross_price_string = ?,
+ costing_id = ?,
+ gross_unit_price = ?,
+ net_unit_price = ?,
+ discount_percent = ?,
+ discount_amount_unit = ?,
+ discount_amount_total = ?,
+ gross_price = ?,
+ net_price = ?
+WHERE id = ?
+`
+
+type UpdateLineItemParams struct {
+ ItemNumber string `json:"item_number"`
+ Option bool `json:"option"`
+ Quantity string `json:"quantity"`
+ Title string `json:"title"`
+ Description string `json:"description"`
+ DocumentID int32 `json:"document_id"`
+ ProductID sql.NullInt32 `json:"product_id"`
+ HasTextPrices bool `json:"has_text_prices"`
+ HasPrice int8 `json:"has_price"`
+ UnitPriceString sql.NullString `json:"unit_price_string"`
+ GrossPriceString sql.NullString `json:"gross_price_string"`
+ CostingID sql.NullInt32 `json:"costing_id"`
+ GrossUnitPrice sql.NullString `json:"gross_unit_price"`
+ NetUnitPrice sql.NullString `json:"net_unit_price"`
+ DiscountPercent sql.NullString `json:"discount_percent"`
+ DiscountAmountUnit sql.NullString `json:"discount_amount_unit"`
+ DiscountAmountTotal sql.NullString `json:"discount_amount_total"`
+ GrossPrice sql.NullString `json:"gross_price"`
+ NetPrice sql.NullString `json:"net_price"`
+ ID int32 `json:"id"`
+}
+
+func (q *Queries) UpdateLineItem(ctx context.Context, arg UpdateLineItemParams) error {
+ _, err := q.db.ExecContext(ctx, updateLineItem,
+ arg.ItemNumber,
+ arg.Option,
+ arg.Quantity,
+ arg.Title,
+ arg.Description,
+ arg.DocumentID,
+ arg.ProductID,
+ arg.HasTextPrices,
+ arg.HasPrice,
+ arg.UnitPriceString,
+ arg.GrossPriceString,
+ arg.CostingID,
+ arg.GrossUnitPrice,
+ arg.NetUnitPrice,
+ arg.DiscountPercent,
+ arg.DiscountAmountUnit,
+ arg.DiscountAmountTotal,
+ arg.GrossPrice,
+ arg.NetPrice,
+ arg.ID,
+ )
+ return err
+}
+
+const updateLineItemPrices = `-- name: UpdateLineItemPrices :exec
+UPDATE line_items
+SET gross_unit_price = ?,
+ net_unit_price = ?,
+ gross_price = ?,
+ net_price = ?
+WHERE id = ?
+`
+
+type UpdateLineItemPricesParams struct {
+ GrossUnitPrice sql.NullString `json:"gross_unit_price"`
+ NetUnitPrice sql.NullString `json:"net_unit_price"`
+ GrossPrice sql.NullString `json:"gross_price"`
+ NetPrice sql.NullString `json:"net_price"`
+ ID int32 `json:"id"`
+}
+
+func (q *Queries) UpdateLineItemPrices(ctx context.Context, arg UpdateLineItemPricesParams) error {
+ _, err := q.db.ExecContext(ctx, updateLineItemPrices,
+ arg.GrossUnitPrice,
+ arg.NetUnitPrice,
+ arg.GrossPrice,
+ arg.NetPrice,
+ arg.ID,
+ )
+ return err
+}
diff --git a/go-app/internal/cmc/db/models.go b/go-app/internal/cmc/db/models.go
index e16a4b6f..7da8b1b9 100644
--- a/go-app/internal/cmc/db/models.go
+++ b/go-app/internal/cmc/db/models.go
@@ -11,6 +11,51 @@ import (
"time"
)
+type DocumentsType string
+
+const (
+ DocumentsTypeQuote DocumentsType = "quote"
+ DocumentsTypeInvoice DocumentsType = "invoice"
+ DocumentsTypePurchaseOrder DocumentsType = "purchaseOrder"
+ DocumentsTypeOrderAck DocumentsType = "orderAck"
+ DocumentsTypePackingList DocumentsType = "packingList"
+)
+
+func (e *DocumentsType) Scan(src interface{}) error {
+ switch s := src.(type) {
+ case []byte:
+ *e = DocumentsType(s)
+ case string:
+ *e = DocumentsType(s)
+ default:
+ return fmt.Errorf("unsupported scan type for DocumentsType: %T", src)
+ }
+ return nil
+}
+
+type NullDocumentsType struct {
+ DocumentsType DocumentsType `json:"documents_type"`
+ Valid bool `json:"valid"` // Valid is true if DocumentsType is not NULL
+}
+
+// Scan implements the Scanner interface.
+func (ns *NullDocumentsType) Scan(value interface{}) error {
+ if value == nil {
+ ns.DocumentsType, ns.Valid = "", false
+ return nil
+ }
+ ns.Valid = true
+ return ns.DocumentsType.Scan(value)
+}
+
+// Value implements the driver Valuer interface.
+func (ns NullDocumentsType) Value() (driver.Value, error) {
+ if !ns.Valid {
+ return nil, nil
+ }
+ return string(ns.DocumentsType), nil
+}
+
type UsersAccessLevel string
const (
@@ -98,6 +143,48 @@ func (ns NullUsersType) Value() (driver.Value, error) {
return string(ns.UsersType), nil
}
+type Address struct {
+ ID int32 `json:"id"`
+ // Descriptive Name for this address
+ Name string `json:"name"`
+ // street or unit number and street name
+ Address string `json:"address"`
+ // Suburb / City
+ City string `json:"city"`
+ // State foreign Key
+ StateID int32 `json:"state_id"`
+ // Country foreign Key
+ CountryID int32 `json:"country_id"`
+ // Customer foreign key
+ CustomerID int32 `json:"customer_id"`
+ // either bill / ship / both
+ Type string `json:"type"`
+ Postcode string `json:"postcode"`
+}
+
+type Attachment struct {
+ ID int32 `json:"id"`
+ PrincipleID int32 `json:"principle_id"`
+ Created time.Time `json:"created"`
+ Modified time.Time `json:"modified"`
+ Name string `json:"name"`
+ Filename string `json:"filename"`
+ File string `json:"file"`
+ Type string `json:"type"`
+ Size int32 `json:"size"`
+ Description string `json:"description"`
+ Archived bool `json:"archived"`
+}
+
+type Box struct {
+ ID int32 `json:"id"`
+ ShipmentID int32 `json:"shipment_id"`
+ Length string `json:"length"`
+ Width string `json:"width"`
+ Height string `json:"height"`
+ Weight string `json:"weight"`
+}
+
type Country struct {
ID int32 `json:"id"`
Name string `json:"name"`
@@ -118,6 +205,25 @@ type Customer struct {
CountryID int32 `json:"country_id"`
}
+type Document struct {
+ ID int32 `json:"id"`
+ Type DocumentsType `json:"type"`
+ Created time.Time `json:"created"`
+ UserID int32 `json:"user_id"`
+ DocPageCount int32 `json:"doc_page_count"`
+ // Either the Enquiry number, Invoice no, order ack. Convenient place to store this to save on queries
+ CmcReference string `json:"cmc_reference"`
+ PdfFilename string `json:"pdf_filename"`
+ PdfCreatedAt time.Time `json:"pdf_created_at"`
+ PdfCreatedByUserID int32 `json:"pdf_created_by_user_id"`
+ ShippingDetails sql.NullString `json:"shipping_details"`
+ Revision int32 `json:"revision"`
+ BillTo sql.NullString `json:"bill_to"`
+ ShipTo sql.NullString `json:"ship_to"`
+ EmailSentAt time.Time `json:"email_sent_at"`
+ EmailSentByUserID int32 `json:"email_sent_by_user_id"`
+}
+
type Enquiry struct {
ID int32 `json:"id"`
Created time.Time `json:"created"`
@@ -148,6 +254,37 @@ type Enquiry struct {
Archived int8 `json:"archived"`
}
+type Invoice struct {
+ ID int32 `json:"id"`
+ // CMC Invoice Number String
+ Title string `json:"title"`
+ CustomerID int32 `json:"customer_id"`
+}
+
+type LineItem struct {
+ ID int32 `json:"id"`
+ ItemNumber string `json:"item_number"`
+ Option bool `json:"option"`
+ Quantity string `json:"quantity"`
+ Title string `json:"title"`
+ Description string `json:"description"`
+ DocumentID int32 `json:"document_id"`
+ ProductID sql.NullInt32 `json:"product_id"`
+ HasTextPrices bool `json:"has_text_prices"`
+ HasPrice int8 `json:"has_price"`
+ UnitPriceString sql.NullString `json:"unit_price_string"`
+ GrossPriceString sql.NullString `json:"gross_price_string"`
+ CostingID sql.NullInt32 `json:"costing_id"`
+ // Either fill this in or have a costing_id associated with this record
+ GrossUnitPrice sql.NullString `json:"gross_unit_price"`
+ NetUnitPrice sql.NullString `json:"net_unit_price"`
+ DiscountPercent sql.NullString `json:"discount_percent"`
+ DiscountAmountUnit sql.NullString `json:"discount_amount_unit"`
+ DiscountAmountTotal sql.NullString `json:"discount_amount_total"`
+ GrossPrice sql.NullString `json:"gross_price"`
+ NetPrice sql.NullString `json:"net_price"`
+}
+
type Principle struct {
ID int32 `json:"id"`
Name string `json:"name"`
diff --git a/go-app/internal/cmc/db/principles.sql.go b/go-app/internal/cmc/db/principles.sql.go
new file mode 100644
index 00000000..4aa3fb7a
--- /dev/null
+++ b/go-app/internal/cmc/db/principles.sql.go
@@ -0,0 +1,143 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.29.0
+// source: principles.sql
+
+package db
+
+import (
+ "context"
+ "database/sql"
+)
+
+const createPrinciple = `-- name: CreatePrinciple :execresult
+INSERT INTO principles (name, short_name, code) VALUES (?, ?, ?)
+`
+
+type CreatePrincipleParams struct {
+ Name string `json:"name"`
+ ShortName sql.NullString `json:"short_name"`
+ Code int32 `json:"code"`
+}
+
+func (q *Queries) CreatePrinciple(ctx context.Context, arg CreatePrincipleParams) (sql.Result, error) {
+ return q.db.ExecContext(ctx, createPrinciple, arg.Name, arg.ShortName, arg.Code)
+}
+
+const getPrinciple = `-- name: GetPrinciple :one
+SELECT id, name, short_name, code FROM principles
+WHERE id = ? LIMIT 1
+`
+
+func (q *Queries) GetPrinciple(ctx context.Context, id int32) (Principle, error) {
+ row := q.db.QueryRowContext(ctx, getPrinciple, id)
+ var i Principle
+ err := row.Scan(
+ &i.ID,
+ &i.Name,
+ &i.ShortName,
+ &i.Code,
+ )
+ return i, err
+}
+
+const getPrincipleProducts = `-- name: GetPrincipleProducts :many
+SELECT id, principle_id, product_category_id, title, description, model_number, model_number_format, notes, stock, item_code, item_description FROM products
+WHERE principle_id = ?
+ORDER BY title
+`
+
+func (q *Queries) GetPrincipleProducts(ctx context.Context, principleID int32) ([]Product, error) {
+ rows, err := q.db.QueryContext(ctx, getPrincipleProducts, principleID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []Product{}
+ for rows.Next() {
+ var i Product
+ if err := rows.Scan(
+ &i.ID,
+ &i.PrincipleID,
+ &i.ProductCategoryID,
+ &i.Title,
+ &i.Description,
+ &i.ModelNumber,
+ &i.ModelNumberFormat,
+ &i.Notes,
+ &i.Stock,
+ &i.ItemCode,
+ &i.ItemDescription,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const listPrinciples = `-- name: ListPrinciples :many
+SELECT id, name, short_name, code FROM principles
+ORDER BY name
+LIMIT ? OFFSET ?
+`
+
+type ListPrinciplesParams struct {
+ Limit int32 `json:"limit"`
+ Offset int32 `json:"offset"`
+}
+
+func (q *Queries) ListPrinciples(ctx context.Context, arg ListPrinciplesParams) ([]Principle, error) {
+ rows, err := q.db.QueryContext(ctx, listPrinciples, arg.Limit, arg.Offset)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []Principle{}
+ for rows.Next() {
+ var i Principle
+ if err := rows.Scan(
+ &i.ID,
+ &i.Name,
+ &i.ShortName,
+ &i.Code,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const updatePrinciple = `-- name: UpdatePrinciple :exec
+UPDATE principles SET name = ?, short_name = ?, code = ? WHERE id = ?
+`
+
+type UpdatePrincipleParams struct {
+ Name string `json:"name"`
+ ShortName sql.NullString `json:"short_name"`
+ Code int32 `json:"code"`
+ ID int32 `json:"id"`
+}
+
+func (q *Queries) UpdatePrinciple(ctx context.Context, arg UpdatePrincipleParams) error {
+ _, err := q.db.ExecContext(ctx, updatePrinciple,
+ arg.Name,
+ arg.ShortName,
+ arg.Code,
+ arg.ID,
+ )
+ return err
+}
diff --git a/go-app/internal/cmc/db/querier.go b/go-app/internal/cmc/db/querier.go
index 7b450c35..81601a12 100644
--- a/go-app/internal/cmc/db/querier.go
+++ b/go-app/internal/cmc/db/querier.go
@@ -10,49 +10,119 @@ import (
)
type Querier interface {
+ ArchiveDocument(ctx context.Context, id int32) error
ArchiveEnquiry(ctx context.Context, id int32) error
+ ArchiveUser(ctx context.Context, id int32) error
CountEnquiries(ctx context.Context) (int64, error)
CountEnquiriesByPrinciple(ctx context.Context, principleCode int32) (int64, error)
CountEnquiriesByPrincipleAndState(ctx context.Context, arg CountEnquiriesByPrincipleAndStateParams) (int64, error)
CountEnquiriesByStatus(ctx context.Context, statusID int32) (int64, error)
+ CreateAddress(ctx context.Context, arg CreateAddressParams) (sql.Result, error)
+ CreateAttachment(ctx context.Context, arg CreateAttachmentParams) (sql.Result, error)
+ CreateBox(ctx context.Context, arg CreateBoxParams) (sql.Result, error)
+ CreateCountry(ctx context.Context, name string) (sql.Result, error)
CreateCustomer(ctx context.Context, arg CreateCustomerParams) (sql.Result, error)
+ CreateDocument(ctx context.Context, arg CreateDocumentParams) (sql.Result, error)
CreateEnquiry(ctx context.Context, arg CreateEnquiryParams) (sql.Result, error)
+ CreateLineItem(ctx context.Context, arg CreateLineItemParams) (sql.Result, error)
+ CreatePrinciple(ctx context.Context, arg CreatePrincipleParams) (sql.Result, error)
CreateProduct(ctx context.Context, arg CreateProductParams) (sql.Result, error)
CreatePurchaseOrder(ctx context.Context, arg CreatePurchaseOrderParams) (sql.Result, error)
+ CreateState(ctx context.Context, arg CreateStateParams) (sql.Result, error)
+ CreateStatus(ctx context.Context, arg CreateStatusParams) (sql.Result, error)
+ CreateUser(ctx context.Context, arg CreateUserParams) (sql.Result, error)
+ DeleteAddress(ctx context.Context, id int32) error
+ DeleteAttachment(ctx context.Context, id int32) error
+ DeleteBox(ctx context.Context, id int32) error
+ DeleteCountry(ctx context.Context, id int32) error
DeleteCustomer(ctx context.Context, id int32) error
+ DeleteLineItem(ctx context.Context, id int32) error
DeleteProduct(ctx context.Context, id int32) error
DeletePurchaseOrder(ctx context.Context, id int32) error
+ DeleteState(ctx context.Context, id int32) error
+ DeleteStatus(ctx context.Context, id int32) error
+ GetAddress(ctx context.Context, id int32) (Address, error)
GetAllCountries(ctx context.Context) ([]Country, error)
GetAllPrinciples(ctx context.Context) ([]Principle, error)
GetAllStates(ctx context.Context) ([]State, error)
GetAllStatuses(ctx context.Context) ([]GetAllStatusesRow, error)
+ GetAllUsers(ctx context.Context) ([]GetAllUsersRow, error)
+ GetAttachment(ctx context.Context, id int32) (Attachment, error)
+ GetBox(ctx context.Context, id int32) (Box, error)
+ GetCountry(ctx context.Context, id int32) (Country, error)
GetCustomer(ctx context.Context, id int32) (Customer, error)
+ GetCustomerAddresses(ctx context.Context, customerID int32) ([]GetCustomerAddressesRow, error)
GetCustomerByABN(ctx context.Context, abn sql.NullString) (Customer, error)
+ GetDocumentByID(ctx context.Context, id int32) (GetDocumentByIDRow, error)
GetEnquiriesByCustomer(ctx context.Context, arg GetEnquiriesByCustomerParams) ([]GetEnquiriesByCustomerRow, error)
GetEnquiriesByUser(ctx context.Context, arg GetEnquiriesByUserParams) ([]GetEnquiriesByUserRow, error)
GetEnquiry(ctx context.Context, id int32) (GetEnquiryRow, error)
+ GetLineItem(ctx context.Context, id int32) (LineItem, error)
+ GetLineItemsByProduct(ctx context.Context, productID sql.NullInt32) ([]LineItem, error)
+ GetLineItemsTable(ctx context.Context, documentID int32) ([]GetLineItemsTableRow, error)
+ GetMaxItemNumber(ctx context.Context, documentID int32) (interface{}, error)
+ GetPrinciple(ctx context.Context, id int32) (Principle, error)
+ GetPrincipleProducts(ctx context.Context, principleID int32) ([]Product, error)
GetProduct(ctx context.Context, id int32) (Product, error)
GetProductByItemCode(ctx context.Context, itemCode string) (Product, error)
GetProductsByCategory(ctx context.Context, arg GetProductsByCategoryParams) ([]Product, error)
GetPurchaseOrder(ctx context.Context, id int32) (PurchaseOrder, error)
+ GetPurchaseOrderByDocumentID(ctx context.Context, id int32) (GetPurchaseOrderByDocumentIDRow, error)
GetPurchaseOrderRevisions(ctx context.Context, parentPurchaseOrderID int32) ([]PurchaseOrder, error)
GetPurchaseOrdersByPrinciple(ctx context.Context, arg GetPurchaseOrdersByPrincipleParams) ([]PurchaseOrder, error)
+ GetState(ctx context.Context, id int32) (State, error)
+ GetStatus(ctx context.Context, id int32) (Status, error)
+ GetUser(ctx context.Context, id int32) (GetUserRow, error)
+ GetUserByUsername(ctx context.Context, username string) (GetUserByUsernameRow, error)
+ ListAddresses(ctx context.Context, arg ListAddressesParams) ([]Address, error)
+ ListAddressesByCustomer(ctx context.Context, customerID int32) ([]Address, error)
+ ListArchivedAttachments(ctx context.Context, arg ListArchivedAttachmentsParams) ([]Attachment, error)
ListArchivedEnquiries(ctx context.Context, arg ListArchivedEnquiriesParams) ([]ListArchivedEnquiriesRow, error)
+ ListAttachments(ctx context.Context, arg ListAttachmentsParams) ([]Attachment, error)
+ ListAttachmentsByPrinciple(ctx context.Context, principleID int32) ([]Attachment, error)
+ ListBoxes(ctx context.Context, arg ListBoxesParams) ([]Box, error)
+ ListBoxesByShipment(ctx context.Context, shipmentID int32) ([]Box, error)
+ ListCountries(ctx context.Context, arg ListCountriesParams) ([]Country, error)
ListCustomers(ctx context.Context, arg ListCustomersParams) ([]Customer, error)
+ ListDocuments(ctx context.Context) ([]ListDocumentsRow, error)
+ ListDocumentsByType(ctx context.Context, type_ DocumentsType) ([]ListDocumentsByTypeRow, error)
ListEnquiries(ctx context.Context, arg ListEnquiriesParams) ([]ListEnquiriesRow, error)
+ ListLineItems(ctx context.Context, arg ListLineItemsParams) ([]LineItem, error)
+ ListLineItemsByDocument(ctx context.Context, documentID int32) ([]LineItem, error)
+ ListPrinciples(ctx context.Context, arg ListPrinciplesParams) ([]Principle, error)
ListProducts(ctx context.Context, arg ListProductsParams) ([]Product, error)
ListPurchaseOrders(ctx context.Context, arg ListPurchaseOrdersParams) ([]PurchaseOrder, error)
+ ListStates(ctx context.Context, arg ListStatesParams) ([]State, error)
+ ListStatuses(ctx context.Context, arg ListStatusesParams) ([]Status, error)
MarkEnquirySubmitted(ctx context.Context, arg MarkEnquirySubmittedParams) error
+ SearchCountriesByName(ctx context.Context, concat interface{}) ([]Country, error)
SearchCustomersByName(ctx context.Context, arg SearchCustomersByNameParams) ([]Customer, error)
+ SearchDocuments(ctx context.Context, arg SearchDocumentsParams) ([]SearchDocumentsRow, error)
SearchEnquiries(ctx context.Context, arg SearchEnquiriesParams) ([]SearchEnquiriesRow, error)
SearchProductsByTitle(ctx context.Context, arg SearchProductsByTitleParams) ([]Product, error)
SearchPurchaseOrdersByTitle(ctx context.Context, arg SearchPurchaseOrdersByTitleParams) ([]PurchaseOrder, error)
+ // Note: Unarchiving not supported as documents table doesn't have an archived column
+ // This is a no-op for compatibility
+ UnarchiveDocument(ctx context.Context, arg UnarchiveDocumentParams) error
UnarchiveEnquiry(ctx context.Context, id int32) error
+ UnarchiveUser(ctx context.Context, id int32) error
+ UpdateAddress(ctx context.Context, arg UpdateAddressParams) error
+ UpdateAttachment(ctx context.Context, arg UpdateAttachmentParams) error
+ UpdateBox(ctx context.Context, arg UpdateBoxParams) error
+ UpdateCountry(ctx context.Context, arg UpdateCountryParams) error
UpdateCustomer(ctx context.Context, arg UpdateCustomerParams) error
+ UpdateDocument(ctx context.Context, arg UpdateDocumentParams) error
+ UpdateDocumentPDFInfo(ctx context.Context, arg UpdateDocumentPDFInfoParams) error
UpdateEnquiry(ctx context.Context, arg UpdateEnquiryParams) error
UpdateEnquiryStatus(ctx context.Context, arg UpdateEnquiryStatusParams) error
+ UpdateLineItem(ctx context.Context, arg UpdateLineItemParams) error
+ UpdateLineItemPrices(ctx context.Context, arg UpdateLineItemPricesParams) error
+ UpdatePrinciple(ctx context.Context, arg UpdatePrincipleParams) error
UpdateProduct(ctx context.Context, arg UpdateProductParams) error
UpdatePurchaseOrder(ctx context.Context, arg UpdatePurchaseOrderParams) error
+ UpdateState(ctx context.Context, arg UpdateStateParams) error
+ UpdateStatus(ctx context.Context, arg UpdateStatusParams) error
+ UpdateUser(ctx context.Context, arg UpdateUserParams) error
}
var _ Querier = (*Queries)(nil)
diff --git a/go-app/internal/cmc/db/states.sql.go b/go-app/internal/cmc/db/states.sql.go
new file mode 100644
index 00000000..b9a82efe
--- /dev/null
+++ b/go-app/internal/cmc/db/states.sql.go
@@ -0,0 +1,120 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.29.0
+// source: states.sql
+
+package db
+
+import (
+ "context"
+ "database/sql"
+)
+
+const createState = `-- name: CreateState :execresult
+INSERT INTO states (
+ name, shortform, enqform
+) VALUES (
+ ?, ?, ?
+)
+`
+
+type CreateStateParams struct {
+ Name string `json:"name"`
+ Shortform sql.NullString `json:"shortform"`
+ Enqform sql.NullString `json:"enqform"`
+}
+
+func (q *Queries) CreateState(ctx context.Context, arg CreateStateParams) (sql.Result, error) {
+ return q.db.ExecContext(ctx, createState, arg.Name, arg.Shortform, arg.Enqform)
+}
+
+const deleteState = `-- name: DeleteState :exec
+DELETE FROM states
+WHERE id = ?
+`
+
+func (q *Queries) DeleteState(ctx context.Context, id int32) error {
+ _, err := q.db.ExecContext(ctx, deleteState, id)
+ return err
+}
+
+const getState = `-- name: GetState :one
+SELECT id, name, shortform, enqform FROM states
+WHERE id = ? LIMIT 1
+`
+
+func (q *Queries) GetState(ctx context.Context, id int32) (State, error) {
+ row := q.db.QueryRowContext(ctx, getState, id)
+ var i State
+ err := row.Scan(
+ &i.ID,
+ &i.Name,
+ &i.Shortform,
+ &i.Enqform,
+ )
+ return i, err
+}
+
+const listStates = `-- name: ListStates :many
+SELECT id, name, shortform, enqform FROM states
+ORDER BY name
+LIMIT ? OFFSET ?
+`
+
+type ListStatesParams struct {
+ Limit int32 `json:"limit"`
+ Offset int32 `json:"offset"`
+}
+
+func (q *Queries) ListStates(ctx context.Context, arg ListStatesParams) ([]State, error) {
+ rows, err := q.db.QueryContext(ctx, listStates, arg.Limit, arg.Offset)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []State{}
+ for rows.Next() {
+ var i State
+ if err := rows.Scan(
+ &i.ID,
+ &i.Name,
+ &i.Shortform,
+ &i.Enqform,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const updateState = `-- name: UpdateState :exec
+UPDATE states
+SET name = ?,
+ shortform = ?,
+ enqform = ?
+WHERE id = ?
+`
+
+type UpdateStateParams struct {
+ Name string `json:"name"`
+ Shortform sql.NullString `json:"shortform"`
+ Enqform sql.NullString `json:"enqform"`
+ ID int32 `json:"id"`
+}
+
+func (q *Queries) UpdateState(ctx context.Context, arg UpdateStateParams) error {
+ _, err := q.db.ExecContext(ctx, updateState,
+ arg.Name,
+ arg.Shortform,
+ arg.Enqform,
+ arg.ID,
+ )
+ return err
+}
diff --git a/go-app/internal/cmc/db/statuses.sql.go b/go-app/internal/cmc/db/statuses.sql.go
new file mode 100644
index 00000000..bbb720e9
--- /dev/null
+++ b/go-app/internal/cmc/db/statuses.sql.go
@@ -0,0 +1,94 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.29.0
+// source: statuses.sql
+
+package db
+
+import (
+ "context"
+ "database/sql"
+)
+
+const createStatus = `-- name: CreateStatus :execresult
+INSERT INTO statuses (name, color) VALUES (?, ?)
+`
+
+type CreateStatusParams struct {
+ Name string `json:"name"`
+ Color sql.NullString `json:"color"`
+}
+
+func (q *Queries) CreateStatus(ctx context.Context, arg CreateStatusParams) (sql.Result, error) {
+ return q.db.ExecContext(ctx, createStatus, arg.Name, arg.Color)
+}
+
+const deleteStatus = `-- name: DeleteStatus :exec
+DELETE FROM statuses WHERE id = ?
+`
+
+func (q *Queries) DeleteStatus(ctx context.Context, id int32) error {
+ _, err := q.db.ExecContext(ctx, deleteStatus, id)
+ return err
+}
+
+const getStatus = `-- name: GetStatus :one
+SELECT id, name, color FROM statuses
+WHERE id = ? LIMIT 1
+`
+
+func (q *Queries) GetStatus(ctx context.Context, id int32) (Status, error) {
+ row := q.db.QueryRowContext(ctx, getStatus, id)
+ var i Status
+ err := row.Scan(&i.ID, &i.Name, &i.Color)
+ return i, err
+}
+
+const listStatuses = `-- name: ListStatuses :many
+SELECT id, name, color FROM statuses
+ORDER BY name
+LIMIT ? OFFSET ?
+`
+
+type ListStatusesParams struct {
+ Limit int32 `json:"limit"`
+ Offset int32 `json:"offset"`
+}
+
+func (q *Queries) ListStatuses(ctx context.Context, arg ListStatusesParams) ([]Status, error) {
+ rows, err := q.db.QueryContext(ctx, listStatuses, arg.Limit, arg.Offset)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []Status{}
+ for rows.Next() {
+ var i Status
+ if err := rows.Scan(&i.ID, &i.Name, &i.Color); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const updateStatus = `-- name: UpdateStatus :exec
+UPDATE statuses SET name = ?, color = ? WHERE id = ?
+`
+
+type UpdateStatusParams struct {
+ Name string `json:"name"`
+ Color sql.NullString `json:"color"`
+ ID int32 `json:"id"`
+}
+
+func (q *Queries) UpdateStatus(ctx context.Context, arg UpdateStatusParams) error {
+ _, err := q.db.ExecContext(ctx, updateStatus, arg.Name, arg.Color, arg.ID)
+ return err
+}
diff --git a/go-app/internal/cmc/db/users.sql.go b/go-app/internal/cmc/db/users.sql.go
new file mode 100644
index 00000000..88e37ec5
--- /dev/null
+++ b/go-app/internal/cmc/db/users.sql.go
@@ -0,0 +1,211 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.29.0
+// source: users.sql
+
+package db
+
+import (
+ "context"
+ "database/sql"
+)
+
+const archiveUser = `-- name: ArchiveUser :exec
+UPDATE users
+SET archived = 1
+WHERE id = ?
+`
+
+func (q *Queries) ArchiveUser(ctx context.Context, id int32) error {
+ _, err := q.db.ExecContext(ctx, archiveUser, id)
+ return err
+}
+
+const createUser = `-- name: CreateUser :execresult
+INSERT INTO users (
+ username, first_name, last_name, email, type, enabled, archived
+) VALUES (
+ ?, ?, ?, ?, ?, ?, 0)
+`
+
+type CreateUserParams struct {
+ Username string `json:"username"`
+ FirstName string `json:"first_name"`
+ LastName string `json:"last_name"`
+ Email string `json:"email"`
+ Type UsersType `json:"type"`
+ Enabled bool `json:"enabled"`
+}
+
+func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (sql.Result, error) {
+ return q.db.ExecContext(ctx, createUser,
+ arg.Username,
+ arg.FirstName,
+ arg.LastName,
+ arg.Email,
+ arg.Type,
+ arg.Enabled,
+ )
+}
+
+const getAllUsers = `-- name: GetAllUsers :many
+SELECT id, username, first_name, last_name, email, type, enabled, archived
+FROM users
+WHERE archived = 0
+ORDER BY first_name, last_name
+`
+
+type GetAllUsersRow struct {
+ ID int32 `json:"id"`
+ Username string `json:"username"`
+ FirstName string `json:"first_name"`
+ LastName string `json:"last_name"`
+ Email string `json:"email"`
+ Type UsersType `json:"type"`
+ Enabled bool `json:"enabled"`
+ Archived sql.NullBool `json:"archived"`
+}
+
+func (q *Queries) GetAllUsers(ctx context.Context) ([]GetAllUsersRow, error) {
+ rows, err := q.db.QueryContext(ctx, getAllUsers)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []GetAllUsersRow{}
+ for rows.Next() {
+ var i GetAllUsersRow
+ if err := rows.Scan(
+ &i.ID,
+ &i.Username,
+ &i.FirstName,
+ &i.LastName,
+ &i.Email,
+ &i.Type,
+ &i.Enabled,
+ &i.Archived,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const getUser = `-- name: GetUser :one
+SELECT id, username, first_name, last_name, email, type, enabled, archived
+FROM users
+WHERE id = ?
+`
+
+type GetUserRow struct {
+ ID int32 `json:"id"`
+ Username string `json:"username"`
+ FirstName string `json:"first_name"`
+ LastName string `json:"last_name"`
+ Email string `json:"email"`
+ Type UsersType `json:"type"`
+ Enabled bool `json:"enabled"`
+ Archived sql.NullBool `json:"archived"`
+}
+
+func (q *Queries) GetUser(ctx context.Context, id int32) (GetUserRow, error) {
+ row := q.db.QueryRowContext(ctx, getUser, id)
+ var i GetUserRow
+ err := row.Scan(
+ &i.ID,
+ &i.Username,
+ &i.FirstName,
+ &i.LastName,
+ &i.Email,
+ &i.Type,
+ &i.Enabled,
+ &i.Archived,
+ )
+ return i, err
+}
+
+const getUserByUsername = `-- name: GetUserByUsername :one
+SELECT id, username, first_name, last_name, email, type, enabled, archived
+FROM users
+WHERE username = ? AND archived = 0
+`
+
+type GetUserByUsernameRow struct {
+ ID int32 `json:"id"`
+ Username string `json:"username"`
+ FirstName string `json:"first_name"`
+ LastName string `json:"last_name"`
+ Email string `json:"email"`
+ Type UsersType `json:"type"`
+ Enabled bool `json:"enabled"`
+ Archived sql.NullBool `json:"archived"`
+}
+
+func (q *Queries) GetUserByUsername(ctx context.Context, username string) (GetUserByUsernameRow, error) {
+ row := q.db.QueryRowContext(ctx, getUserByUsername, username)
+ var i GetUserByUsernameRow
+ err := row.Scan(
+ &i.ID,
+ &i.Username,
+ &i.FirstName,
+ &i.LastName,
+ &i.Email,
+ &i.Type,
+ &i.Enabled,
+ &i.Archived,
+ )
+ return i, err
+}
+
+const unarchiveUser = `-- name: UnarchiveUser :exec
+UPDATE users
+SET archived = 0
+WHERE id = ?
+`
+
+func (q *Queries) UnarchiveUser(ctx context.Context, id int32) error {
+ _, err := q.db.ExecContext(ctx, unarchiveUser, id)
+ return err
+}
+
+const updateUser = `-- name: UpdateUser :exec
+UPDATE users
+SET
+ username = ?,
+ first_name = ?,
+ last_name = ?,
+ email = ?,
+ type = ?,
+ enabled = ?
+WHERE id = ?
+`
+
+type UpdateUserParams struct {
+ Username string `json:"username"`
+ FirstName string `json:"first_name"`
+ LastName string `json:"last_name"`
+ Email string `json:"email"`
+ Type UsersType `json:"type"`
+ Enabled bool `json:"enabled"`
+ ID int32 `json:"id"`
+}
+
+func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) error {
+ _, err := q.db.ExecContext(ctx, updateUser,
+ arg.Username,
+ arg.FirstName,
+ arg.LastName,
+ arg.Email,
+ arg.Type,
+ arg.Enabled,
+ arg.ID,
+ )
+ return err
+}
diff --git a/go-app/internal/cmc/handlers/addresses.go b/go-app/internal/cmc/handlers/addresses.go
new file mode 100644
index 00000000..67a5031f
--- /dev/null
+++ b/go-app/internal/cmc/handlers/addresses.go
@@ -0,0 +1,305 @@
+package handlers
+
+import (
+ "database/sql"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "strconv"
+
+ "code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db"
+ "github.com/gorilla/mux"
+)
+
+type AddressHandler struct {
+ queries *db.Queries
+}
+
+func NewAddressHandler(queries *db.Queries) *AddressHandler {
+ return &AddressHandler{queries: queries}
+}
+
+func (h *AddressHandler) List(w http.ResponseWriter, r *http.Request) {
+ limit := 50
+ offset := 0
+
+ if l := r.URL.Query().Get("limit"); l != "" {
+ if val, err := strconv.Atoi(l); err == nil {
+ limit = val
+ }
+ }
+
+ if o := r.URL.Query().Get("offset"); o != "" {
+ if val, err := strconv.Atoi(o); err == nil {
+ offset = val
+ }
+ }
+
+ addresses, err := h.queries.ListAddresses(r.Context(), db.ListAddressesParams{
+ Limit: int32(limit),
+ Offset: int32(offset),
+ })
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(addresses)
+}
+
+func (h *AddressHandler) Get(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+ id, err := strconv.Atoi(vars["id"])
+ if err != nil {
+ http.Error(w, "Invalid address ID", http.StatusBadRequest)
+ return
+ }
+
+ address, err := h.queries.GetAddress(r.Context(), int32(id))
+ if err != nil {
+ if err == sql.ErrNoRows {
+ http.Error(w, "Address not found", http.StatusNotFound)
+ return
+ }
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(address)
+}
+
+func (h *AddressHandler) Create(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+
+ // Parse form data for HTMX requests
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ // Get customer ID from URL or form
+ var customerID int32
+ if cid := vars["customerid"]; cid != "" {
+ if id, err := strconv.Atoi(cid); err == nil {
+ customerID = int32(id)
+ }
+ } else if cid := r.FormValue("customer_id"); cid != "" {
+ if id, err := strconv.Atoi(cid); err == nil {
+ customerID = int32(id)
+ }
+ }
+
+ params := db.CreateAddressParams{
+ Name: r.FormValue("name"),
+ Address: r.FormValue("address"),
+ City: r.FormValue("city"),
+ StateID: 1, // Default state
+ CountryID: 1, // Default country
+ CustomerID: customerID,
+ Type: r.FormValue("type"),
+ Postcode: r.FormValue("postcode"),
+ }
+
+ // Parse state_id and country_id if provided
+ if sid := r.FormValue("state_id"); sid != "" {
+ if id, err := strconv.Atoi(sid); err == nil {
+ params.StateID = int32(id)
+ }
+ }
+ if cid := r.FormValue("country_id"); cid != "" {
+ if id, err := strconv.Atoi(cid); err == nil {
+ params.CountryID = int32(id)
+ }
+ }
+
+ // Check if this is a JSON request
+ if r.Header.Get("Content-Type") == "application/json" {
+ if err := json.NewDecoder(r.Body).Decode(¶ms); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ }
+
+ result, err := h.queries.CreateAddress(r.Context(), params)
+ if err != nil {
+ if r.Header.Get("HX-Request") == "true" {
+ w.Header().Set("Content-Type", "text/html")
+ w.Write([]byte(`
Error creating address
`))
+ return
+ }
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ id, err := result.LastInsertId()
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ // If HTMX request, return the new address row
+ if r.Header.Get("HX-Request") == "true" {
+ w.Header().Set("Content-Type", "text/html")
+ // Return a simple HTML response for now
+ w.Write([]byte(`Address created successfully
`))
+ return
+ }
+
+ // JSON response for API
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "id": id,
+ })
+}
+
+func (h *AddressHandler) Update(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+ id, err := strconv.Atoi(vars["id"])
+ if err != nil {
+ http.Error(w, "Invalid address ID", http.StatusBadRequest)
+ return
+ }
+
+ var params db.UpdateAddressParams
+ if r.Header.Get("Content-Type") == "application/json" {
+ if err := json.NewDecoder(r.Body).Decode(¶ms); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ } else {
+ // Handle form data
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ params = db.UpdateAddressParams{
+ Name: r.FormValue("name"),
+ Address: r.FormValue("address"),
+ City: r.FormValue("city"),
+ Type: r.FormValue("type"),
+ Postcode: r.FormValue("postcode"),
+ }
+
+ // Parse IDs
+ if sid := r.FormValue("state_id"); sid != "" {
+ if stateID, err := strconv.Atoi(sid); err == nil {
+ params.StateID = int32(stateID)
+ }
+ }
+ if cid := r.FormValue("country_id"); cid != "" {
+ if countryID, err := strconv.Atoi(cid); err == nil {
+ params.CountryID = int32(countryID)
+ }
+ }
+ if cid := r.FormValue("customer_id"); cid != "" {
+ if customerID, err := strconv.Atoi(cid); err == nil {
+ params.CustomerID = int32(customerID)
+ }
+ }
+ }
+
+ params.ID = int32(id)
+
+ if err := h.queries.UpdateAddress(r.Context(), params); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ w.WriteHeader(http.StatusNoContent)
+}
+
+func (h *AddressHandler) Delete(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+ id, err := strconv.Atoi(vars["id"])
+ if err != nil {
+ http.Error(w, "Invalid address ID", http.StatusBadRequest)
+ return
+ }
+
+ if err := h.queries.DeleteAddress(r.Context(), int32(id)); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ w.WriteHeader(http.StatusNoContent)
+}
+
+func (h *AddressHandler) CustomerAddresses(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+ customerID, err := strconv.Atoi(vars["customerID"])
+ if err != nil {
+ http.Error(w, "Invalid customer ID", http.StatusBadRequest)
+ return
+ }
+
+ addresses, err := h.queries.GetCustomerAddresses(r.Context(), int32(customerID))
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ // If AJAX request, return HTML
+ if r.Header.Get("X-Requested-With") == "XMLHttpRequest" {
+ w.Header().Set("Content-Type", "text/html")
+ // For now, return a simple HTML list
+ w.Write([]byte(""))
+ for _, addr := range addresses {
+ w.Write([]byte(fmt.Sprintf("%s - %s ", addr.ID, addr.Name, addr.Address)))
+ }
+ w.Write([]byte(" "))
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(addresses)
+}
+
+func (h *AddressHandler) AddAnother(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+ increment := vars["increment"]
+
+ // Return an HTML form for adding another address
+ w.Header().Set("Content-Type", "text/html")
+ html := fmt.Sprintf(`
+
+ `, increment, increment, increment, increment, increment, increment, increment, increment)
+
+ w.Write([]byte(html))
+}
+
+func (h *AddressHandler) RemoveAnother(w http.ResponseWriter, r *http.Request) {
+ // This typically returns empty content or a success message
+ // The actual removal is handled by JavaScript on the frontend
+ w.WriteHeader(http.StatusOK)
+}
\ No newline at end of file
diff --git a/go-app/internal/cmc/handlers/attachments.go b/go-app/internal/cmc/handlers/attachments.go
new file mode 100644
index 00000000..94599939
--- /dev/null
+++ b/go-app/internal/cmc/handlers/attachments.go
@@ -0,0 +1,251 @@
+package handlers
+
+import (
+ "database/sql"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strconv"
+ "time"
+
+ "code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db"
+ "github.com/gorilla/mux"
+)
+
+type AttachmentHandler struct {
+ queries *db.Queries
+}
+
+func NewAttachmentHandler(queries *db.Queries) *AttachmentHandler {
+ return &AttachmentHandler{queries: queries}
+}
+
+func (h *AttachmentHandler) List(w http.ResponseWriter, r *http.Request) {
+ limit := 50
+ offset := 0
+
+ if l := r.URL.Query().Get("limit"); l != "" {
+ if val, err := strconv.Atoi(l); err == nil {
+ limit = val
+ }
+ }
+
+ if o := r.URL.Query().Get("offset"); o != "" {
+ if val, err := strconv.Atoi(o); err == nil {
+ offset = val
+ }
+ }
+
+ attachments, err := h.queries.ListAttachments(r.Context(), db.ListAttachmentsParams{
+ Limit: int32(limit),
+ Offset: int32(offset),
+ })
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(attachments)
+}
+
+func (h *AttachmentHandler) Archived(w http.ResponseWriter, r *http.Request) {
+ limit := 50
+ offset := 0
+
+ if l := r.URL.Query().Get("limit"); l != "" {
+ if val, err := strconv.Atoi(l); err == nil {
+ limit = val
+ }
+ }
+
+ if o := r.URL.Query().Get("offset"); o != "" {
+ if val, err := strconv.Atoi(o); err == nil {
+ offset = val
+ }
+ }
+
+ attachments, err := h.queries.ListArchivedAttachments(r.Context(), db.ListArchivedAttachmentsParams{
+ Limit: int32(limit),
+ Offset: int32(offset),
+ })
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(attachments)
+}
+
+func (h *AttachmentHandler) Get(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+ id, err := strconv.Atoi(vars["id"])
+ if err != nil {
+ http.Error(w, "Invalid attachment ID", http.StatusBadRequest)
+ return
+ }
+
+ attachment, err := h.queries.GetAttachment(r.Context(), int32(id))
+ if err != nil {
+ if err == sql.ErrNoRows {
+ http.Error(w, "Attachment not found", http.StatusNotFound)
+ return
+ }
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(attachment)
+}
+
+func (h *AttachmentHandler) Create(w http.ResponseWriter, r *http.Request) {
+ // Parse multipart form
+ err := r.ParseMultipartForm(32 << 20) // 32MB max
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ // Get file from form
+ file, handler, err := r.FormFile("file")
+ if err != nil {
+ http.Error(w, "No file uploaded", http.StatusBadRequest)
+ return
+ }
+ defer file.Close()
+
+ // Generate unique filename
+ ext := filepath.Ext(handler.Filename)
+ filename := fmt.Sprintf("%d_%s%s", time.Now().Unix(), handler.Filename[:len(handler.Filename)-len(ext)], ext)
+
+ // Create attachments directory if it doesn't exist
+ attachDir := "webroot/attachments_files"
+ if err := os.MkdirAll(attachDir, 0755); err != nil {
+ http.Error(w, "Failed to create attachments directory", http.StatusInternalServerError)
+ return
+ }
+
+ // Save file to disk
+ filepath := filepath.Join(attachDir, filename)
+ dst, err := os.Create(filepath)
+ if err != nil {
+ http.Error(w, "Failed to save file", http.StatusInternalServerError)
+ return
+ }
+ defer dst.Close()
+
+ if _, err := io.Copy(dst, file); err != nil {
+ http.Error(w, "Failed to save file", http.StatusInternalServerError)
+ return
+ }
+
+ // Parse principle_id
+ principleID := 1 // Default
+ if pid := r.FormValue("principle_id"); pid != "" {
+ if id, err := strconv.Atoi(pid); err == nil {
+ principleID = id
+ }
+ }
+
+ // Create database record
+ params := db.CreateAttachmentParams{
+ PrincipleID: int32(principleID),
+ Name: r.FormValue("name"),
+ Filename: handler.Filename,
+ File: filename,
+ Type: handler.Header.Get("Content-Type"),
+ Size: int32(handler.Size),
+ Description: r.FormValue("description"),
+ }
+
+ if params.Name == "" {
+ params.Name = handler.Filename
+ }
+
+ result, err := h.queries.CreateAttachment(r.Context(), params)
+ if err != nil {
+ // Clean up file on error
+ os.Remove(filepath)
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ id, err := result.LastInsertId()
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ // If HTMX request, return success message
+ if r.Header.Get("HX-Request") == "true" {
+ w.Header().Set("Content-Type", "text/html")
+ w.Write([]byte(`Attachment uploaded successfully
`))
+ return
+ }
+
+ // JSON response for API
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "id": id,
+ })
+}
+
+func (h *AttachmentHandler) Update(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+ id, err := strconv.Atoi(vars["id"])
+ if err != nil {
+ http.Error(w, "Invalid attachment ID", http.StatusBadRequest)
+ return
+ }
+
+ var params db.UpdateAttachmentParams
+ if r.Header.Get("Content-Type") == "application/json" {
+ if err := json.NewDecoder(r.Body).Decode(¶ms); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ } else {
+ // Handle form data
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ params = db.UpdateAttachmentParams{
+ Name: r.FormValue("name"),
+ Description: r.FormValue("description"),
+ }
+ }
+
+ params.ID = int32(id)
+
+ if err := h.queries.UpdateAttachment(r.Context(), params); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ w.WriteHeader(http.StatusNoContent)
+}
+
+func (h *AttachmentHandler) Delete(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+ id, err := strconv.Atoi(vars["id"])
+ if err != nil {
+ http.Error(w, "Invalid attachment ID", http.StatusBadRequest)
+ return
+ }
+
+ // Soft delete (archive)
+ if err := h.queries.DeleteAttachment(r.Context(), int32(id)); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ w.WriteHeader(http.StatusNoContent)
+}
\ No newline at end of file
diff --git a/go-app/internal/cmc/handlers/countries.go b/go-app/internal/cmc/handlers/countries.go
new file mode 100644
index 00000000..e3e07fd4
--- /dev/null
+++ b/go-app/internal/cmc/handlers/countries.go
@@ -0,0 +1,209 @@
+package handlers
+
+import (
+ "database/sql"
+ "encoding/json"
+ "net/http"
+ "strconv"
+
+ "code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db"
+ "github.com/gorilla/mux"
+)
+
+type CountryHandler struct {
+ queries *db.Queries
+}
+
+func NewCountryHandler(queries *db.Queries) *CountryHandler {
+ return &CountryHandler{queries: queries}
+}
+
+func (h *CountryHandler) List(w http.ResponseWriter, r *http.Request) {
+ limit := 50
+ offset := 0
+
+ if l := r.URL.Query().Get("limit"); l != "" {
+ if val, err := strconv.Atoi(l); err == nil {
+ limit = val
+ }
+ }
+
+ if o := r.URL.Query().Get("offset"); o != "" {
+ if val, err := strconv.Atoi(o); err == nil {
+ offset = val
+ }
+ }
+
+ countries, err := h.queries.ListCountries(r.Context(), db.ListCountriesParams{
+ Limit: int32(limit),
+ Offset: int32(offset),
+ })
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(countries)
+}
+
+func (h *CountryHandler) Get(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+ id, err := strconv.Atoi(vars["id"])
+ if err != nil {
+ http.Error(w, "Invalid country ID", http.StatusBadRequest)
+ return
+ }
+
+ country, err := h.queries.GetCountry(r.Context(), int32(id))
+ if err != nil {
+ if err == sql.ErrNoRows {
+ http.Error(w, "Country not found", http.StatusNotFound)
+ return
+ }
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(country)
+}
+
+func (h *CountryHandler) Create(w http.ResponseWriter, r *http.Request) {
+ // Parse form data for HTMX requests
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ name := r.FormValue("name")
+
+ // Check if this is a JSON request
+ if r.Header.Get("Content-Type") == "application/json" {
+ var params struct {
+ Name string `json:"name"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(¶ms); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ name = params.Name
+ }
+
+ result, err := h.queries.CreateCountry(r.Context(), name)
+ if err != nil {
+ if r.Header.Get("HX-Request") == "true" {
+ w.Header().Set("Content-Type", "text/html")
+ w.Write([]byte(`Error creating country
`))
+ return
+ }
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ id, err := result.LastInsertId()
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ // If HTMX request, return success message
+ if r.Header.Get("HX-Request") == "true" {
+ w.Header().Set("Content-Type", "text/html")
+ w.Write([]byte(`Country created successfully
`))
+ return
+ }
+
+ // JSON response for API
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "id": id,
+ })
+}
+
+func (h *CountryHandler) Update(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+ id, err := strconv.Atoi(vars["id"])
+ if err != nil {
+ http.Error(w, "Invalid country ID", http.StatusBadRequest)
+ return
+ }
+
+ var name string
+ if r.Header.Get("Content-Type") == "application/json" {
+ var params struct {
+ Name string `json:"name"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(¶ms); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ name = params.Name
+ } else {
+ // Handle form data
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ name = r.FormValue("name")
+ }
+
+ if err := h.queries.UpdateCountry(r.Context(), db.UpdateCountryParams{
+ Name: name,
+ ID: int32(id),
+ }); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ w.WriteHeader(http.StatusNoContent)
+}
+
+func (h *CountryHandler) Delete(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+ id, err := strconv.Atoi(vars["id"])
+ if err != nil {
+ http.Error(w, "Invalid country ID", http.StatusBadRequest)
+ return
+ }
+
+ if err := h.queries.DeleteCountry(r.Context(), int32(id)); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ w.WriteHeader(http.StatusNoContent)
+}
+
+func (h *CountryHandler) CompleteCountry(w http.ResponseWriter, r *http.Request) {
+ query := r.URL.Query().Get("term")
+ if query == "" {
+ query = r.URL.Query().Get("q")
+ }
+
+ if query == "" {
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode([]interface{}{})
+ return
+ }
+
+ countries, err := h.queries.SearchCountriesByName(r.Context(), query)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ // Format for autocomplete
+ var results []map[string]interface{}
+ for _, country := range countries {
+ results = append(results, map[string]interface{}{
+ "id": country.ID,
+ "label": country.Name,
+ "value": country.Name,
+ })
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(results)
+}
\ No newline at end of file
diff --git a/go-app/internal/cmc/handlers/document.go b/go-app/internal/cmc/handlers/document.go
new file mode 100644
index 00000000..f6108672
--- /dev/null
+++ b/go-app/internal/cmc/handlers/document.go
@@ -0,0 +1,518 @@
+package handlers
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "fmt"
+ "log"
+ "net/http"
+ "os"
+ "strconv"
+ "time"
+
+ "code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db"
+ "code.springupsoftware.com/cmc/cmc-sales/internal/cmc/pdf"
+ "github.com/gorilla/mux"
+)
+
+type DocumentHandler struct {
+ queries *db.Queries
+}
+
+func NewDocumentHandler(queries *db.Queries) *DocumentHandler {
+ return &DocumentHandler{queries: queries}
+}
+
+// List handles GET /api/documents
+func (h *DocumentHandler) List(w http.ResponseWriter, r *http.Request) {
+ ctx := context.Background()
+
+ // Check for type filter
+ docType := r.URL.Query().Get("type")
+
+ var documents interface{}
+ var err error
+
+ if docType != "" {
+ // Convert string to DocumentsType enum
+ documents, err = h.queries.ListDocumentsByType(ctx, db.DocumentsType(docType))
+ } else {
+ documents, err = h.queries.ListDocuments(ctx)
+ }
+
+ if err != nil {
+ log.Printf("Error fetching documents: %v", err)
+ http.Error(w, "Failed to fetch documents", http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(documents)
+}
+
+// Helper functions to convert between pointer and null types
+func nullInt32FromPtr(p *int32) sql.NullInt32 {
+ if p == nil {
+ return sql.NullInt32{Valid: false}
+ }
+ return sql.NullInt32{Int32: *p, Valid: true}
+}
+
+func nullStringFromPtr(p *string) sql.NullString {
+ if p == nil {
+ return sql.NullString{Valid: false}
+ }
+ return sql.NullString{String: *p, Valid: true}
+}
+
+func nullTimeFromPtr(p *time.Time) sql.NullTime {
+ if p == nil {
+ return sql.NullTime{Valid: false}
+ }
+ return sql.NullTime{Time: *p, Valid: true}
+}
+
+// Get handles GET /api/documents/{id}
+func (h *DocumentHandler) Get(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+ idStr := vars["id"]
+
+ id, err := strconv.ParseInt(idStr, 10, 64)
+ if err != nil {
+ http.Error(w, "Invalid document ID", http.StatusBadRequest)
+ return
+ }
+
+ ctx := context.Background()
+ document, err := h.queries.GetDocumentByID(ctx, int32(id))
+ if err != nil {
+ log.Printf("Error fetching document %d: %v", id, err)
+ http.Error(w, "Document not found", http.StatusNotFound)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(document)
+}
+
+// Create handles POST /api/documents
+func (h *DocumentHandler) Create(w http.ResponseWriter, r *http.Request) {
+ var req struct {
+ Type string `json:"type"`
+ UserID int32 `json:"user_id"`
+ DocPageCount int32 `json:"doc_page_count"`
+ CMCReference string `json:"cmc_reference"`
+ PDFFilename string `json:"pdf_filename"`
+ PDFCreatedAt string `json:"pdf_created_at"`
+ PDFCreatedByUserID int32 `json:"pdf_created_by_user_id"`
+ ShippingDetails *string `json:"shipping_details,omitempty"`
+ Revision *int32 `json:"revision,omitempty"`
+ BillTo *string `json:"bill_to,omitempty"`
+ ShipTo *string `json:"ship_to,omitempty"`
+ EmailSentAt string `json:"email_sent_at"`
+ EmailSentByUserID int32 `json:"email_sent_by_user_id"`
+ }
+
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "Invalid JSON", http.StatusBadRequest)
+ return
+ }
+
+ // Validate required fields
+ if req.Type == "" {
+ http.Error(w, "Type is required", http.StatusBadRequest)
+ return
+ }
+
+ if req.UserID == 0 {
+ http.Error(w, "User ID is required", http.StatusBadRequest)
+ return
+ }
+
+ if req.CMCReference == "" {
+ http.Error(w, "CMC Reference is required", http.StatusBadRequest)
+ return
+ }
+
+ ctx := context.Background()
+
+ // Parse timestamps
+ pdfCreatedAt, err := time.Parse("2006-01-02 15:04:05", req.PDFCreatedAt)
+ if err != nil {
+ http.Error(w, "Invalid pdf_created_at format", http.StatusBadRequest)
+ return
+ }
+
+ emailSentAt, err := time.Parse("2006-01-02 15:04:05", req.EmailSentAt)
+ if err != nil {
+ http.Error(w, "Invalid email_sent_at format", http.StatusBadRequest)
+ return
+ }
+
+ revision := int32(0)
+ if req.Revision != nil {
+ revision = *req.Revision
+ }
+
+ params := db.CreateDocumentParams{
+ Type: db.DocumentsType(req.Type),
+ UserID: req.UserID,
+ DocPageCount: req.DocPageCount,
+ CmcReference: req.CMCReference,
+ PdfFilename: req.PDFFilename,
+ PdfCreatedAt: pdfCreatedAt,
+ PdfCreatedByUserID: req.PDFCreatedByUserID,
+ ShippingDetails: nullStringFromPtr(req.ShippingDetails),
+ Revision: revision,
+ BillTo: nullStringFromPtr(req.BillTo),
+ ShipTo: nullStringFromPtr(req.ShipTo),
+ EmailSentAt: emailSentAt,
+ EmailSentByUserID: req.EmailSentByUserID,
+ }
+
+ result, err := h.queries.CreateDocument(ctx, params)
+ if err != nil {
+ log.Printf("Error creating document: %v", err)
+ http.Error(w, "Failed to create document", http.StatusInternalServerError)
+ return
+ }
+
+ id, _ := result.LastInsertId()
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "id": id,
+ "message": "Document created successfully",
+ })
+}
+
+// Update handles PUT /api/documents/{id}
+func (h *DocumentHandler) Update(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+ idStr := vars["id"]
+
+ id, err := strconv.ParseInt(idStr, 10, 64)
+ if err != nil {
+ http.Error(w, "Invalid document ID", http.StatusBadRequest)
+ return
+ }
+
+ var req struct {
+ Type string `json:"type"`
+ UserID int32 `json:"user_id"`
+ DocPageCount int32 `json:"doc_page_count"`
+ CMCReference string `json:"cmc_reference"`
+ PDFFilename string `json:"pdf_filename"`
+ PDFCreatedAt string `json:"pdf_created_at"`
+ PDFCreatedByUserID int32 `json:"pdf_created_by_user_id"`
+ ShippingDetails *string `json:"shipping_details,omitempty"`
+ Revision *int32 `json:"revision,omitempty"`
+ BillTo *string `json:"bill_to,omitempty"`
+ ShipTo *string `json:"ship_to,omitempty"`
+ EmailSentAt string `json:"email_sent_at"`
+ EmailSentByUserID int32 `json:"email_sent_by_user_id"`
+ }
+
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "Invalid JSON", http.StatusBadRequest)
+ return
+ }
+
+ ctx := context.Background()
+
+ // Parse timestamps
+ pdfCreatedAt, err := time.Parse("2006-01-02 15:04:05", req.PDFCreatedAt)
+ if err != nil {
+ http.Error(w, "Invalid pdf_created_at format", http.StatusBadRequest)
+ return
+ }
+
+ emailSentAt, err := time.Parse("2006-01-02 15:04:05", req.EmailSentAt)
+ if err != nil {
+ http.Error(w, "Invalid email_sent_at format", http.StatusBadRequest)
+ return
+ }
+
+ revision := int32(0)
+ if req.Revision != nil {
+ revision = *req.Revision
+ }
+
+ params := db.UpdateDocumentParams{
+ Type: db.DocumentsType(req.Type),
+ UserID: req.UserID,
+ DocPageCount: req.DocPageCount,
+ CmcReference: req.CMCReference,
+ PdfFilename: req.PDFFilename,
+ PdfCreatedAt: pdfCreatedAt,
+ PdfCreatedByUserID: req.PDFCreatedByUserID,
+ ShippingDetails: nullStringFromPtr(req.ShippingDetails),
+ Revision: revision,
+ BillTo: nullStringFromPtr(req.BillTo),
+ ShipTo: nullStringFromPtr(req.ShipTo),
+ EmailSentAt: emailSentAt,
+ EmailSentByUserID: req.EmailSentByUserID,
+ ID: int32(id),
+ }
+
+ err = h.queries.UpdateDocument(ctx, params)
+ if err != nil {
+ log.Printf("Error updating document %d: %v", id, err)
+ http.Error(w, "Failed to update document", http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]string{
+ "message": "Document updated successfully",
+ })
+}
+
+// Archive handles PUT /api/documents/{id}/archive
+func (h *DocumentHandler) Archive(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+ idStr := vars["id"]
+
+ id, err := strconv.ParseInt(idStr, 10, 64)
+ if err != nil {
+ http.Error(w, "Invalid document ID", http.StatusBadRequest)
+ return
+ }
+
+ ctx := context.Background()
+ err = h.queries.ArchiveDocument(ctx, int32(id))
+ if err != nil {
+ log.Printf("Error archiving document %d: %v", id, err)
+ http.Error(w, "Failed to archive document", http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]string{
+ "message": "Document archived successfully",
+ })
+}
+
+// Unarchive handles PUT /api/documents/{id}/unarchive
+func (h *DocumentHandler) Unarchive(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+ idStr := vars["id"]
+
+ id, err := strconv.ParseInt(idStr, 10, 64)
+ if err != nil {
+ http.Error(w, "Invalid document ID", http.StatusBadRequest)
+ return
+ }
+
+ ctx := context.Background()
+ err = h.queries.UnarchiveDocument(ctx, db.UnarchiveDocumentParams{
+ Column1: int32(id),
+ Column2: int32(id),
+ })
+ if err != nil {
+ log.Printf("Error unarchiving document %d: %v", id, err)
+ http.Error(w, "Failed to unarchive document", http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]string{
+ "message": "Document unarchived successfully",
+ })
+}
+
+// Search handles GET /api/documents/search?q=query
+func (h *DocumentHandler) Search(w http.ResponseWriter, r *http.Request) {
+ query := r.URL.Query().Get("q")
+ if query == "" {
+ http.Error(w, "Search query is required", http.StatusBadRequest)
+ return
+ }
+
+ ctx := context.Background()
+ searchPattern := "%" + query + "%"
+ documents, err := h.queries.SearchDocuments(ctx, db.SearchDocumentsParams{
+ PdfFilename: searchPattern,
+ CmcReference: searchPattern,
+ Name: searchPattern,
+ Title: searchPattern,
+ Username: searchPattern,
+ })
+ if err != nil {
+ log.Printf("Error searching documents: %v", err)
+ http.Error(w, "Failed to search documents", http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(documents)
+}
+
+// GeneratePDF handles GET /documents/pdf/{id}
+func (h *DocumentHandler) GeneratePDF(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+ idStr := vars["id"]
+
+ id, err := strconv.ParseInt(idStr, 10, 64)
+ if err != nil {
+ http.Error(w, "Invalid document ID", http.StatusBadRequest)
+ return
+ }
+
+ ctx := context.Background()
+
+ // Get document
+ document, err := h.queries.GetDocumentByID(ctx, int32(id))
+ if err != nil {
+ log.Printf("Error fetching document %d: %v", id, err)
+ http.Error(w, "Document not found", http.StatusNotFound)
+ return
+ }
+
+ // Get output directory from environment or use default
+ outputDir := os.Getenv("PDF_OUTPUT_DIR")
+ if outputDir == "" {
+ outputDir = "webroot/pdf"
+ }
+
+ // Ensure output directory exists
+ if err := os.MkdirAll(outputDir, 0755); err != nil {
+ log.Printf("Error creating PDF directory: %v", err)
+ http.Error(w, "Failed to create PDF directory", http.StatusInternalServerError)
+ return
+ }
+
+ var filename string
+
+ // Generate PDF based on document type
+ log.Printf("Generating PDF for document %d, type: %s, outputDir: %s", id, document.Type, outputDir)
+ switch document.Type {
+ case db.DocumentsTypeQuote:
+ // Get quote-specific data
+ // TODO: Get quote data from database
+
+ // Get enquiry data
+ // TODO: Get enquiry from quote
+
+ // Get customer data
+ // TODO: Get customer from enquiry
+
+ // Get user data
+ user, err := h.queries.GetUser(ctx, document.UserID)
+ if err != nil {
+ log.Printf("Error fetching user: %v", err)
+ http.Error(w, "Failed to fetch user data", http.StatusInternalServerError)
+ return
+ }
+
+ // Get line items
+ lineItems, err := h.queries.GetLineItemsTable(ctx, int32(id))
+ if err != nil {
+ log.Printf("Error fetching line items: %v", err)
+ http.Error(w, "Failed to fetch line items", http.StatusInternalServerError)
+ return
+ }
+
+ // For now, create a simplified quote PDF
+ data := &pdf.QuotePDFData{
+ Document: &document,
+ User: &user,
+ LineItems: lineItems,
+ CurrencySymbol: "$", // Default to AUD
+ ShowGST: true, // Default to showing GST
+ }
+
+ // Generate PDF
+ log.Printf("Calling GenerateQuotePDF with outputDir: %s", outputDir)
+ filename, err = pdf.GenerateQuotePDF(data, outputDir)
+ if err != nil {
+ log.Printf("Error generating quote PDF: %v", err)
+ http.Error(w, "Failed to generate PDF", http.StatusInternalServerError)
+ return
+ }
+ log.Printf("Successfully generated PDF: %s", filename)
+
+ case db.DocumentsTypeInvoice:
+ // Get invoice-specific data
+ // TODO: Implement invoice PDF generation
+ http.Error(w, "Invoice PDF generation not yet implemented", http.StatusNotImplemented)
+ return
+
+ case db.DocumentsTypePurchaseOrder:
+ // Get purchase order data
+ po, err := h.queries.GetPurchaseOrderByDocumentID(ctx, int32(id))
+ if err != nil {
+ log.Printf("Error fetching purchase order: %v", err)
+ http.Error(w, "Failed to fetch purchase order data", http.StatusInternalServerError)
+ return
+ }
+
+ // Get principle data
+ principle, err := h.queries.GetPrinciple(ctx, po.PrincipleID)
+ if err != nil {
+ log.Printf("Error fetching principle: %v", err)
+ http.Error(w, "Failed to fetch principle data", http.StatusInternalServerError)
+ return
+ }
+
+ // Get line items
+ lineItems, err := h.queries.GetLineItemsTable(ctx, int32(id))
+ if err != nil {
+ log.Printf("Error fetching line items: %v", err)
+ http.Error(w, "Failed to fetch line items", http.StatusInternalServerError)
+ return
+ }
+
+ // Create purchase order PDF data
+ data := &pdf.PurchaseOrderPDFData{
+ Document: &document,
+ PurchaseOrder: &po,
+ Principle: &principle,
+ LineItems: lineItems,
+ CurrencySymbol: "$", // Default to AUD
+ ShowGST: true, // Default to showing GST for Australian principles
+ }
+
+ // Generate PDF
+ filename, err = pdf.GeneratePurchaseOrderPDF(data, outputDir)
+ if err != nil {
+ log.Printf("Error generating purchase order PDF: %v", err)
+ http.Error(w, "Failed to generate PDF", http.StatusInternalServerError)
+ return
+ }
+
+ default:
+ http.Error(w, "Unsupported document type for PDF generation", http.StatusBadRequest)
+ return
+ }
+
+ // Update document with PDF filename and timestamp
+ now := time.Now()
+ err = h.queries.UpdateDocumentPDFInfo(ctx, db.UpdateDocumentPDFInfoParams{
+ PdfFilename: filename,
+ PdfCreatedAt: now,
+ PdfCreatedByUserID: 1, // TODO: Get current user ID
+ ID: int32(id),
+ })
+ if err != nil {
+ log.Printf("Error updating document PDF info: %v", err)
+ // Don't fail the request, PDF was generated successfully
+ }
+
+ // Return success response with redirect to document view
+ w.Header().Set("Content-Type", "text/html")
+ fmt.Fprintf(w, `
+
+
+
+
+
+ PDF generated successfully. Click here if you are not redirected.
+
+
+ `, id, id)
+}
\ No newline at end of file
diff --git a/go-app/internal/cmc/handlers/line_items.go b/go-app/internal/cmc/handlers/line_items.go
new file mode 100644
index 00000000..9c88d856
--- /dev/null
+++ b/go-app/internal/cmc/handlers/line_items.go
@@ -0,0 +1,544 @@
+package handlers
+
+import (
+ "database/sql"
+ "encoding/json"
+ "fmt"
+ "log"
+ "net/http"
+ "strconv"
+
+ "code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db"
+ "github.com/gorilla/mux"
+)
+
+type LineItemHandler struct {
+ queries *db.Queries
+}
+
+func NewLineItemHandler(queries *db.Queries) *LineItemHandler {
+ return &LineItemHandler{queries: queries}
+}
+
+func (h *LineItemHandler) List(w http.ResponseWriter, r *http.Request) {
+ limit := 50
+ offset := 0
+
+ if l := r.URL.Query().Get("limit"); l != "" {
+ if val, err := strconv.Atoi(l); err == nil {
+ limit = val
+ }
+ }
+
+ if o := r.URL.Query().Get("offset"); o != "" {
+ if val, err := strconv.Atoi(o); err == nil {
+ offset = val
+ }
+ }
+
+ lineItems, err := h.queries.ListLineItems(r.Context(), db.ListLineItemsParams{
+ Limit: int32(limit),
+ Offset: int32(offset),
+ })
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(lineItems)
+}
+
+func (h *LineItemHandler) Get(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+ id, err := strconv.Atoi(vars["id"])
+ if err != nil {
+ http.Error(w, "Invalid line item ID", http.StatusBadRequest)
+ return
+ }
+
+ lineItem, err := h.queries.GetLineItem(r.Context(), int32(id))
+ if err != nil {
+ if err == sql.ErrNoRows {
+ http.Error(w, "Line item not found", http.StatusNotFound)
+ return
+ }
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ // Add CORS headers for API access
+ w.Header().Set("Access-Control-Allow-Origin", "*")
+ w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
+ w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(lineItem)
+}
+
+func (h *LineItemHandler) AjaxAdd(w http.ResponseWriter, r *http.Request) {
+ // Parse form data
+ if err := r.ParseForm(); err != nil {
+ w.WriteHeader(http.StatusBadRequest)
+ w.Write([]byte("NO-DATA"))
+ return
+ }
+
+ // Parse required fields
+ documentID, err := strconv.Atoi(r.FormValue("document_id"))
+ if err != nil {
+ w.WriteHeader(http.StatusBadRequest)
+ w.Write([]byte("FAILURE"))
+ return
+ }
+
+ // Get next item number
+ maxItemInterface, err := h.queries.GetMaxItemNumber(r.Context(), int32(documentID))
+ if err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ w.Write([]byte("FAILURE"))
+ return
+ }
+
+ maxItemNumber := float64(0)
+ if maxItemInterface != nil {
+ switch v := maxItemInterface.(type) {
+ case string:
+ if parsed, err := strconv.ParseFloat(v, 64); err == nil {
+ maxItemNumber = parsed
+ }
+ case float64:
+ maxItemNumber = v
+ case int64:
+ maxItemNumber = float64(v)
+ }
+ }
+ nextItemNumber := fmt.Sprintf("%.2f", maxItemNumber+1.0)
+
+ // Parse optional fields
+ var productID sql.NullInt32
+ if pid := r.FormValue("product_id"); pid != "" {
+ if id, err := strconv.Atoi(pid); err == nil {
+ productID = sql.NullInt32{Int32: int32(id), Valid: true}
+ }
+ }
+
+ var costingID sql.NullInt32
+ if cid := r.FormValue("costing_id"); cid != "" {
+ if id, err := strconv.Atoi(cid); err == nil {
+ costingID = sql.NullInt32{Int32: int32(id), Valid: true}
+ }
+ }
+
+ // Parse boolean fields
+ option := r.FormValue("option") == "1" || r.FormValue("option") == "true"
+ hasTextPrices := r.FormValue("has_text_prices") == "1" || r.FormValue("has_text_prices") == "true"
+
+ hasPrice := int8(1)
+ if hp := r.FormValue("has_price"); hp == "0" || hp == "false" {
+ hasPrice = 0
+ }
+
+ params := db.CreateLineItemParams{
+ ItemNumber: nextItemNumber,
+ Option: option,
+ Quantity: r.FormValue("quantity"),
+ Title: r.FormValue("title"),
+ Description: r.FormValue("description"),
+ DocumentID: int32(documentID),
+ ProductID: productID,
+ HasTextPrices: hasTextPrices,
+ HasPrice: hasPrice,
+ UnitPriceString: sql.NullString{String: r.FormValue("unit_price_string"), Valid: r.FormValue("unit_price_string") != ""},
+ GrossPriceString: sql.NullString{String: r.FormValue("gross_price_string"), Valid: r.FormValue("gross_price_string") != ""},
+ CostingID: costingID,
+ GrossUnitPrice: sql.NullString{String: r.FormValue("gross_unit_price"), Valid: r.FormValue("gross_unit_price") != ""},
+ NetUnitPrice: sql.NullString{String: r.FormValue("net_unit_price"), Valid: r.FormValue("net_unit_price") != ""},
+ DiscountPercent: sql.NullString{String: r.FormValue("discount_percent"), Valid: r.FormValue("discount_percent") != ""},
+ DiscountAmountUnit: sql.NullString{String: r.FormValue("discount_amount_unit"), Valid: r.FormValue("discount_amount_unit") != ""},
+ DiscountAmountTotal: sql.NullString{String: r.FormValue("discount_amount_total"), Valid: r.FormValue("discount_amount_total") != ""},
+ GrossPrice: sql.NullString{String: r.FormValue("gross_price"), Valid: r.FormValue("gross_price") != ""},
+ NetPrice: sql.NullString{String: r.FormValue("net_price"), Valid: r.FormValue("net_price") != ""},
+ }
+
+ _, err = h.queries.CreateLineItem(r.Context(), params)
+ if err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ w.Write([]byte("FAILURE"))
+ return
+ }
+
+ // TODO: Update invoice totals like the original CakePHP code
+ // h.updateInvoice(documentID)
+
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte("SUCCESS"))
+}
+
+func (h *LineItemHandler) AjaxEdit(w http.ResponseWriter, r *http.Request) {
+ // Parse form data
+ if err := r.ParseForm(); err != nil {
+ w.WriteHeader(http.StatusBadRequest)
+ w.Write([]byte("NO-DATA"))
+ return
+ }
+
+ // Parse ID
+ id, err := strconv.Atoi(r.FormValue("id"))
+ if err != nil {
+ log.Printf("Error updating line item ID: %d %s\n", id, err)
+ w.WriteHeader(http.StatusBadRequest)
+ w.Write([]byte("FAILURE"))
+ return
+ }
+
+ documentID, err := strconv.Atoi(r.FormValue("document_id"))
+ if err != nil {
+ w.WriteHeader(http.StatusBadRequest)
+ w.Write([]byte("FAILURE"))
+ return
+ }
+
+ // Parse optional fields
+ var productID sql.NullInt32
+ if pid := r.FormValue("product_id"); pid != "" {
+ if prodID, err := strconv.Atoi(pid); err == nil {
+ productID = sql.NullInt32{Int32: int32(prodID), Valid: true}
+ }
+ }
+
+ var costingID sql.NullInt32
+ if cid := r.FormValue("costing_id"); cid != "" {
+ if cID, err := strconv.Atoi(cid); err == nil {
+ costingID = sql.NullInt32{Int32: int32(cID), Valid: true}
+ }
+ }
+
+ // Parse boolean fields
+ option := r.FormValue("option") == "1" || r.FormValue("option") == "true"
+ hasTextPrices := r.FormValue("has_text_prices") == "1" || r.FormValue("has_text_prices") == "true"
+
+ hasPrice := int8(1)
+ if hp := r.FormValue("has_price"); hp == "0" || hp == "false" {
+ hasPrice = 0
+ }
+
+ params := db.UpdateLineItemParams{
+ ID: int32(id),
+ ItemNumber: r.FormValue("item_number"),
+ Option: option,
+ Quantity: r.FormValue("quantity"),
+ Title: r.FormValue("title"),
+ Description: r.FormValue("description"),
+ DocumentID: int32(documentID),
+ ProductID: productID,
+ HasTextPrices: hasTextPrices,
+ HasPrice: hasPrice,
+ UnitPriceString: sql.NullString{String: r.FormValue("unit_price_string"), Valid: r.FormValue("unit_price_string") != ""},
+ GrossPriceString: sql.NullString{String: r.FormValue("gross_price_string"), Valid: r.FormValue("gross_price_string") != ""},
+ CostingID: costingID,
+ GrossUnitPrice: sql.NullString{String: r.FormValue("gross_unit_price"), Valid: r.FormValue("gross_unit_price") != ""},
+ NetUnitPrice: sql.NullString{String: r.FormValue("net_unit_price"), Valid: r.FormValue("net_unit_price") != ""},
+ DiscountPercent: sql.NullString{String: r.FormValue("discount_percent"), Valid: r.FormValue("discount_percent") != ""},
+ DiscountAmountUnit: sql.NullString{String: r.FormValue("discount_amount_unit"), Valid: r.FormValue("discount_amount_unit") != ""},
+ DiscountAmountTotal: sql.NullString{String: r.FormValue("discount_amount_total"), Valid: r.FormValue("discount_amount_total") != ""},
+ GrossPrice: sql.NullString{String: r.FormValue("gross_price"), Valid: r.FormValue("gross_price") != ""},
+ NetPrice: sql.NullString{String: r.FormValue("net_price"), Valid: r.FormValue("net_price") != ""},
+ }
+
+ err = h.queries.UpdateLineItem(r.Context(), params)
+ if err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ w.Write([]byte("FAILURE"))
+ return
+ }
+
+ // TODO: Update invoice totals like the original CakePHP code
+ // h.updateInvoice(documentID)
+
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte("SUCCESS"))
+}
+
+func (h *LineItemHandler) AjaxDelete(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+ id, err := strconv.Atoi(vars["id"])
+ if err != nil {
+ w.WriteHeader(http.StatusBadRequest)
+ w.Write([]byte("FAILURE"))
+ return
+ }
+
+ // Get the line item to find document_id for invoice update
+ _, err = h.queries.GetLineItem(r.Context(), int32(id))
+ if err != nil {
+ w.WriteHeader(http.StatusNotFound)
+ w.Write([]byte("FAILURE"))
+ return
+ }
+
+ err = h.queries.DeleteLineItem(r.Context(), int32(id))
+ if err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ w.Write([]byte("FAILURE"))
+ return
+ }
+
+ // TODO: Update invoice totals like the original CakePHP code
+ // h.updateInvoice(lineItem.DocumentID)
+
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte("SUCCESS"))
+}
+
+func (h *LineItemHandler) GetTable(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+ documentID, err := strconv.Atoi(vars["documentID"])
+ if err != nil {
+ http.Error(w, "Invalid document ID", http.StatusBadRequest)
+ return
+ }
+
+ lineItems, err := h.queries.GetLineItemsTable(r.Context(), int32(documentID))
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ // If AJAX request, return HTML table rows only (for refreshing)
+ if r.Header.Get("X-Requested-With") == "XMLHttpRequest" {
+ w.Header().Set("Content-Type", "text/html")
+
+ // Generate HTML table rows
+ html := ""
+
+ if len(lineItems) == 0 {
+ html = `No line items yet `
+ } else {
+ for _, item := range lineItems {
+ unitPrice := "-"
+ if item.GrossUnitPrice.Valid {
+ unitPrice = "$" + item.GrossUnitPrice.String
+ } else if item.UnitPriceString.Valid {
+ unitPrice = item.UnitPriceString.String
+ }
+
+ totalPrice := "-"
+ if item.GrossPrice.Valid {
+ totalPrice = "$" + item.GrossPrice.String
+ } else if item.GrossPriceString.Valid {
+ totalPrice = item.GrossPriceString.String
+ }
+
+ html += fmt.Sprintf(`
+
+ %s
+ %s
+ %s
+ %s
+ %s
+ %s
+
+
+
+
+
+ Edit
+
+
+
+
+
+ Delete
+
+
+ `,
+ item.ID, item.ItemNumber, item.Title, item.Description, item.Quantity,
+ unitPrice, totalPrice, item.ID, item.ID)
+ }
+ }
+
+ w.Write([]byte(html))
+ return
+ }
+
+ // JSON response for API
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(lineItems)
+}
+
+func (h *LineItemHandler) Create(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+ documentID, err := strconv.Atoi(vars["documentID"])
+ if err != nil {
+ http.Error(w, "Invalid document ID", http.StatusBadRequest)
+ return
+ }
+
+ // Parse form data for HTMX requests
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ // Get next item number
+ maxItemInterface, err := h.queries.GetMaxItemNumber(r.Context(), int32(documentID))
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ maxItemNumber := float64(0)
+ if maxItemInterface != nil {
+ switch v := maxItemInterface.(type) {
+ case string:
+ if parsed, err := strconv.ParseFloat(v, 64); err == nil {
+ maxItemNumber = parsed
+ }
+ case float64:
+ maxItemNumber = v
+ case int64:
+ maxItemNumber = float64(v)
+ }
+ }
+ nextItemNumber := fmt.Sprintf("%.2f", maxItemNumber+1.0)
+
+ // Parse optional fields
+ var productID sql.NullInt32
+ if pid := r.FormValue("product_id"); pid != "" {
+ if id, err := strconv.Atoi(pid); err == nil {
+ productID = sql.NullInt32{Int32: int32(id), Valid: true}
+ }
+ }
+
+ params := db.CreateLineItemParams{
+ ItemNumber: nextItemNumber,
+ Option: r.FormValue("option") == "1",
+ Quantity: r.FormValue("quantity"),
+ Title: r.FormValue("title"),
+ Description: r.FormValue("description"),
+ DocumentID: int32(documentID),
+ ProductID: productID,
+ HasTextPrices: r.FormValue("has_text_prices") == "1",
+ HasPrice: 1, // Default to has price
+ UnitPriceString: sql.NullString{String: r.FormValue("unit_price_string"), Valid: r.FormValue("unit_price_string") != ""},
+ GrossPriceString: sql.NullString{String: r.FormValue("gross_price_string"), Valid: r.FormValue("gross_price_string") != ""},
+ GrossUnitPrice: sql.NullString{String: r.FormValue("gross_unit_price"), Valid: r.FormValue("gross_unit_price") != ""},
+ NetUnitPrice: sql.NullString{String: r.FormValue("net_unit_price"), Valid: r.FormValue("net_unit_price") != ""},
+ GrossPrice: sql.NullString{String: r.FormValue("gross_price"), Valid: r.FormValue("gross_price") != ""},
+ NetPrice: sql.NullString{String: r.FormValue("net_price"), Valid: r.FormValue("net_price") != ""},
+ }
+
+ // Check if this is a JSON request
+ if r.Header.Get("Content-Type") == "application/json" {
+ if err := json.NewDecoder(r.Body).Decode(¶ms); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ }
+
+ result, err := h.queries.CreateLineItem(r.Context(), params)
+ if err != nil {
+ if r.Header.Get("HX-Request") == "true" {
+ w.Header().Set("Content-Type", "text/html")
+ w.Write([]byte(`Error creating line item
`))
+ return
+ }
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ id, err := result.LastInsertId()
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ // If HTMX request, return success message
+ if r.Header.Get("HX-Request") == "true" {
+ w.Header().Set("Content-Type", "text/html")
+ w.Write([]byte(`Line item created successfully
`))
+ return
+ }
+
+ // JSON response for API
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "id": id,
+ })
+}
+
+func (h *LineItemHandler) Update(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+ id, err := strconv.Atoi(vars["id"])
+ if err != nil {
+ http.Error(w, "Invalid line item ID", http.StatusBadRequest)
+ return
+ }
+
+ var params db.UpdateLineItemParams
+ if r.Header.Get("Content-Type") == "application/json" {
+ if err := json.NewDecoder(r.Body).Decode(¶ms); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ } else {
+ // Handle form data
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ var productID sql.NullInt32
+ if pid := r.FormValue("product_id"); pid != "" {
+ if prodID, err := strconv.Atoi(pid); err == nil {
+ productID = sql.NullInt32{Int32: int32(prodID), Valid: true}
+ }
+ }
+
+ documentID, _ := strconv.Atoi(r.FormValue("document_id"))
+
+ params = db.UpdateLineItemParams{
+ ItemNumber: r.FormValue("item_number"),
+ Option: r.FormValue("option") == "1",
+ Quantity: r.FormValue("quantity"),
+ Title: r.FormValue("title"),
+ Description: r.FormValue("description"),
+ DocumentID: int32(documentID),
+ ProductID: productID,
+ HasTextPrices: r.FormValue("has_text_prices") == "1",
+ HasPrice: 1,
+ UnitPriceString: sql.NullString{String: r.FormValue("unit_price_string"), Valid: r.FormValue("unit_price_string") != ""},
+ GrossPriceString: sql.NullString{String: r.FormValue("gross_price_string"), Valid: r.FormValue("gross_price_string") != ""},
+ GrossUnitPrice: sql.NullString{String: r.FormValue("gross_unit_price"), Valid: r.FormValue("gross_unit_price") != ""},
+ NetUnitPrice: sql.NullString{String: r.FormValue("net_unit_price"), Valid: r.FormValue("net_unit_price") != ""},
+ GrossPrice: sql.NullString{String: r.FormValue("gross_price"), Valid: r.FormValue("gross_price") != ""},
+ NetPrice: sql.NullString{String: r.FormValue("net_price"), Valid: r.FormValue("net_price") != ""},
+ }
+ }
+
+ params.ID = int32(id)
+
+ if err := h.queries.UpdateLineItem(r.Context(), params); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ w.WriteHeader(http.StatusNoContent)
+}
+
+func (h *LineItemHandler) Delete(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+ id, err := strconv.Atoi(vars["id"])
+ if err != nil {
+ http.Error(w, "Invalid line item ID", http.StatusBadRequest)
+ return
+ }
+
+ if err := h.queries.DeleteLineItem(r.Context(), int32(id)); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ w.WriteHeader(http.StatusNoContent)
+}
diff --git a/go-app/internal/cmc/handlers/pages.go b/go-app/internal/cmc/handlers/pages.go
index c0273d02..f1f1dd5f 100644
--- a/go-app/internal/cmc/handlers/pages.go
+++ b/go-app/internal/cmc/handlers/pages.go
@@ -1,6 +1,7 @@
package handlers
import (
+ "log"
"net/http"
"strconv"
@@ -10,8 +11,8 @@ import (
)
type PageHandler struct {
- queries *db.Queries
- tmpl *templates.TemplateManager
+ queries *db.Queries
+ tmpl *templates.TemplateManager
}
func NewPageHandler(queries *db.Queries, tmpl *templates.TemplateManager) *PageHandler {
@@ -26,7 +27,7 @@ func (h *PageHandler) Home(w http.ResponseWriter, r *http.Request) {
data := map[string]interface{}{
"Title": "Dashboard",
}
-
+
if err := h.tmpl.Render(w, "index.html", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
@@ -40,10 +41,10 @@ func (h *PageHandler) CustomersIndex(w http.ResponseWriter, r *http.Request) {
page = val
}
}
-
+
limit := 20
offset := (page - 1) * limit
-
+
customers, err := h.queries.ListCustomers(r.Context(), db.ListCustomersParams{
Limit: int32(limit + 1), // Get one extra to check if there are more
Offset: int32(offset),
@@ -52,12 +53,12 @@ func (h *PageHandler) CustomersIndex(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
-
+
hasMore := len(customers) > limit
if hasMore {
customers = customers[:limit]
}
-
+
data := map[string]interface{}{
"Customers": customers,
"Page": page,
@@ -65,7 +66,7 @@ func (h *PageHandler) CustomersIndex(w http.ResponseWriter, r *http.Request) {
"NextPage": page + 1,
"HasMore": hasMore,
}
-
+
// Check if this is an HTMX request
if r.Header.Get("HX-Request") == "true" {
if err := h.tmpl.Render(w, "customers/table.html", data); err != nil {
@@ -73,7 +74,7 @@ func (h *PageHandler) CustomersIndex(w http.ResponseWriter, r *http.Request) {
}
return
}
-
+
if err := h.tmpl.Render(w, "customers/index.html", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
@@ -83,7 +84,7 @@ func (h *PageHandler) CustomersNew(w http.ResponseWriter, r *http.Request) {
data := map[string]interface{}{
"Customer": db.Customer{},
}
-
+
if err := h.tmpl.Render(w, "customers/form.html", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
@@ -96,17 +97,17 @@ func (h *PageHandler) CustomersEdit(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Invalid customer ID", http.StatusBadRequest)
return
}
-
+
customer, err := h.queries.GetCustomer(r.Context(), int32(id))
if err != nil {
http.Error(w, "Customer not found", http.StatusNotFound)
return
}
-
+
data := map[string]interface{}{
"Customer": customer,
}
-
+
if err := h.tmpl.Render(w, "customers/form.html", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
@@ -119,17 +120,17 @@ func (h *PageHandler) CustomersShow(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Invalid customer ID", http.StatusBadRequest)
return
}
-
+
customer, err := h.queries.GetCustomer(r.Context(), int32(id))
if err != nil {
http.Error(w, "Customer not found", http.StatusNotFound)
return
}
-
+
data := map[string]interface{}{
"Customer": customer,
}
-
+
if err := h.tmpl.Render(w, "customers/show.html", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
@@ -143,13 +144,13 @@ func (h *PageHandler) CustomersSearch(w http.ResponseWriter, r *http.Request) {
page = val
}
}
-
+
limit := 20
offset := (page - 1) * limit
-
+
var customers []db.Customer
var err error
-
+
if query == "" {
customers, err = h.queries.ListCustomers(r.Context(), db.ListCustomersParams{
Limit: int32(limit + 1),
@@ -163,17 +164,17 @@ func (h *PageHandler) CustomersSearch(w http.ResponseWriter, r *http.Request) {
Offset: int32(offset),
})
}
-
+
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
-
+
hasMore := len(customers) > limit
if hasMore {
customers = customers[:limit]
}
-
+
data := map[string]interface{}{
"Customers": customers,
"Page": page,
@@ -181,7 +182,7 @@ func (h *PageHandler) CustomersSearch(w http.ResponseWriter, r *http.Request) {
"NextPage": page + 1,
"HasMore": hasMore,
}
-
+
w.Header().Set("Content-Type", "text/html")
if err := h.tmpl.Render(w, "customers/table.html", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -194,7 +195,7 @@ func (h *PageHandler) ProductsIndex(w http.ResponseWriter, r *http.Request) {
data := map[string]interface{}{
"Products": []db.Product{}, // Placeholder
}
-
+
if err := h.tmpl.Render(w, "products/index.html", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
@@ -204,7 +205,7 @@ func (h *PageHandler) ProductsNew(w http.ResponseWriter, r *http.Request) {
data := map[string]interface{}{
"Product": db.Product{},
}
-
+
if err := h.tmpl.Render(w, "products/form.html", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
@@ -217,17 +218,17 @@ func (h *PageHandler) ProductsShow(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Invalid product ID", http.StatusBadRequest)
return
}
-
+
product, err := h.queries.GetProduct(r.Context(), int32(id))
if err != nil {
http.Error(w, "Product not found", http.StatusNotFound)
return
}
-
+
data := map[string]interface{}{
"Product": product,
}
-
+
if err := h.tmpl.Render(w, "products/show.html", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
@@ -240,17 +241,17 @@ func (h *PageHandler) ProductsEdit(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Invalid product ID", http.StatusBadRequest)
return
}
-
+
product, err := h.queries.GetProduct(r.Context(), int32(id))
if err != nil {
http.Error(w, "Product not found", http.StatusNotFound)
return
}
-
+
data := map[string]interface{}{
"Product": product,
}
-
+
if err := h.tmpl.Render(w, "products/form.html", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
@@ -261,7 +262,7 @@ func (h *PageHandler) ProductsSearch(w http.ResponseWriter, r *http.Request) {
data := map[string]interface{}{
"Products": []db.Product{},
}
-
+
w.Header().Set("Content-Type", "text/html")
if err := h.tmpl.Render(w, "products/table.html", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -273,7 +274,7 @@ func (h *PageHandler) PurchaseOrdersIndex(w http.ResponseWriter, r *http.Request
data := map[string]interface{}{
"PurchaseOrders": []db.PurchaseOrder{},
}
-
+
if err := h.tmpl.Render(w, "purchase-orders/index.html", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
@@ -283,7 +284,7 @@ func (h *PageHandler) PurchaseOrdersNew(w http.ResponseWriter, r *http.Request)
data := map[string]interface{}{
"PurchaseOrder": db.PurchaseOrder{},
}
-
+
if err := h.tmpl.Render(w, "purchase-orders/form.html", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
@@ -296,17 +297,17 @@ func (h *PageHandler) PurchaseOrdersShow(w http.ResponseWriter, r *http.Request)
http.Error(w, "Invalid purchase order ID", http.StatusBadRequest)
return
}
-
+
purchaseOrder, err := h.queries.GetPurchaseOrder(r.Context(), int32(id))
if err != nil {
http.Error(w, "Purchase order not found", http.StatusNotFound)
return
}
-
+
data := map[string]interface{}{
"PurchaseOrder": purchaseOrder,
}
-
+
if err := h.tmpl.Render(w, "purchase-orders/show.html", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
@@ -319,17 +320,17 @@ func (h *PageHandler) PurchaseOrdersEdit(w http.ResponseWriter, r *http.Request)
http.Error(w, "Invalid purchase order ID", http.StatusBadRequest)
return
}
-
+
purchaseOrder, err := h.queries.GetPurchaseOrder(r.Context(), int32(id))
if err != nil {
http.Error(w, "Purchase order not found", http.StatusNotFound)
return
}
-
+
data := map[string]interface{}{
"PurchaseOrder": purchaseOrder,
}
-
+
if err := h.tmpl.Render(w, "purchase-orders/form.html", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
@@ -339,7 +340,7 @@ func (h *PageHandler) PurchaseOrdersSearch(w http.ResponseWriter, r *http.Reques
data := map[string]interface{}{
"PurchaseOrders": []db.PurchaseOrder{},
}
-
+
w.Header().Set("Content-Type", "text/html")
if err := h.tmpl.Render(w, "purchase-orders/table.html", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -354,14 +355,14 @@ func (h *PageHandler) EnquiriesIndex(w http.ResponseWriter, r *http.Request) {
page = val
}
}
-
+
limit := 150
offset := (page - 1) * limit
-
+
var enquiries interface{}
var err error
var hasMore bool
-
+
// Check if we want archived enquiries
if r.URL.Query().Get("archived") == "true" {
archivedEnquiries, err := h.queries.ListArchivedEnquiries(r.Context(), db.ListArchivedEnquiriesParams{
@@ -388,19 +389,19 @@ func (h *PageHandler) EnquiriesIndex(w http.ResponseWriter, r *http.Request) {
enquiries = activeEnquiries
}
}
-
+
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
-
+
// Get status list for dropdown and CSS classes
statuses, err := h.queries.GetAllStatuses(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
-
+
data := map[string]interface{}{
"Enquiries": enquiries,
"Statuses": statuses,
@@ -409,7 +410,7 @@ func (h *PageHandler) EnquiriesIndex(w http.ResponseWriter, r *http.Request) {
"NextPage": page + 1,
"HasMore": hasMore,
}
-
+
// Check if this is an HTMX request
if r.Header.Get("HX-Request") == "true" {
if err := h.tmpl.RenderPartial(w, "enquiries/table.html", "enquiry-table", data); err != nil {
@@ -417,7 +418,7 @@ func (h *PageHandler) EnquiriesIndex(w http.ResponseWriter, r *http.Request) {
}
return
}
-
+
if err := h.tmpl.Render(w, "enquiries/index.html", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
@@ -430,33 +431,33 @@ func (h *PageHandler) EnquiriesNew(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
-
+
principles, err := h.queries.GetAllPrinciples(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
-
+
states, err := h.queries.GetAllStates(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
-
+
countries, err := h.queries.GetAllCountries(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
-
+
data := map[string]interface{}{
- "Enquiry": db.Enquiry{},
- "Statuses": statuses,
+ "Enquiry": db.Enquiry{},
+ "Statuses": statuses,
"Principles": principles,
- "States": states,
- "Countries": countries,
+ "States": states,
+ "Countries": countries,
}
-
+
if err := h.tmpl.Render(w, "enquiries/form.html", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
@@ -469,17 +470,17 @@ func (h *PageHandler) EnquiriesShow(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Invalid enquiry ID", http.StatusBadRequest)
return
}
-
+
enquiry, err := h.queries.GetEnquiry(r.Context(), int32(id))
if err != nil {
http.Error(w, "Enquiry not found", http.StatusNotFound)
return
}
-
+
data := map[string]interface{}{
"Enquiry": enquiry,
}
-
+
if err := h.tmpl.Render(w, "enquiries/show.html", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
@@ -492,46 +493,46 @@ func (h *PageHandler) EnquiriesEdit(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Invalid enquiry ID", http.StatusBadRequest)
return
}
-
+
enquiry, err := h.queries.GetEnquiry(r.Context(), int32(id))
if err != nil {
http.Error(w, "Enquiry not found", http.StatusNotFound)
return
}
-
+
// Get required form data
statuses, err := h.queries.GetAllStatuses(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
-
+
principles, err := h.queries.GetAllPrinciples(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
-
+
states, err := h.queries.GetAllStates(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
-
+
countries, err := h.queries.GetAllCountries(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
-
+
data := map[string]interface{}{
- "Enquiry": enquiry,
- "Statuses": statuses,
+ "Enquiry": enquiry,
+ "Statuses": statuses,
"Principles": principles,
- "States": states,
- "Countries": countries,
+ "States": states,
+ "Countries": countries,
}
-
+
if err := h.tmpl.Render(w, "enquiries/form.html", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
@@ -545,13 +546,13 @@ func (h *PageHandler) EnquiriesSearch(w http.ResponseWriter, r *http.Request) {
page = val
}
}
-
+
limit := 150
offset := (page - 1) * limit
-
+
var enquiries interface{}
var hasMore bool
-
+
if query == "" {
// If no search query, return regular list
regularEnquiries, err := h.queries.ListEnquiries(r.Context(), db.ListEnquiriesParams{
@@ -562,7 +563,7 @@ func (h *PageHandler) EnquiriesSearch(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
-
+
hasMore = len(regularEnquiries) > limit
if hasMore {
regularEnquiries = regularEnquiries[:limit]
@@ -580,14 +581,14 @@ func (h *PageHandler) EnquiriesSearch(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
-
+
hasMore = len(searchResults) > limit
if hasMore {
searchResults = searchResults[:limit]
}
enquiries = searchResults
}
-
+
data := map[string]interface{}{
"Enquiries": enquiries,
"Page": page,
@@ -595,9 +596,182 @@ func (h *PageHandler) EnquiriesSearch(w http.ResponseWriter, r *http.Request) {
"NextPage": page + 1,
"HasMore": hasMore,
}
-
+
w.Header().Set("Content-Type", "text/html")
if err := h.tmpl.RenderPartial(w, "enquiries/table.html", "enquiry-table", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
-}
\ No newline at end of file
+}
+
+// Document page handlers
+func (h *PageHandler) DocumentsIndex(w http.ResponseWriter, r *http.Request) {
+ page := 1
+ if p := r.URL.Query().Get("page"); p != "" {
+ if val, err := strconv.Atoi(p); err == nil && val > 0 {
+ page = val
+ }
+ }
+
+ // Get document type filter
+ docType := r.URL.Query().Get("type")
+
+ var documents interface{}
+ var err error
+
+ if docType != "" {
+ documents, err = h.queries.ListDocumentsByType(r.Context(), db.DocumentsType(docType))
+ } else {
+ documents, err = h.queries.ListDocuments(r.Context())
+ }
+
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ // Get users list for display names (if needed)
+ users, err := h.queries.GetAllUsers(r.Context())
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ data := map[string]interface{}{
+ "Documents": documents,
+ "Users": users,
+ "Page": page,
+ "DocType": docType,
+ }
+
+ // Check if this is an HTMX request
+ if r.Header.Get("HX-Request") == "true" {
+ if err := h.tmpl.RenderPartial(w, "documents/table.html", "document-table", data); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ }
+ return
+ }
+
+ if err := h.tmpl.Render(w, "documents/index.html", data); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ }
+}
+
+func (h *PageHandler) DocumentsShow(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+ id, err := strconv.Atoi(vars["id"])
+ if err != nil {
+ http.Error(w, "Invalid document ID", http.StatusBadRequest)
+ return
+ }
+
+ document, err := h.queries.GetDocumentByID(r.Context(), int32(id))
+ if err != nil {
+ log.Printf("Error fetching document %d: %v", id, err)
+ http.Error(w, "Document not found", http.StatusNotFound)
+ return
+ }
+
+ data := map[string]interface{}{
+ "Document": document,
+ }
+
+ if err := h.tmpl.Render(w, "documents/show.html", data); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ }
+}
+
+func (h *PageHandler) DocumentsSearch(w http.ResponseWriter, r *http.Request) {
+ query := r.URL.Query().Get("search")
+
+ var documents interface{}
+ var err error
+
+ if query == "" {
+ // If no search query, return regular list
+ documents, err = h.queries.ListDocuments(r.Context())
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ } else {
+ searchPattern := "%" + query + "%"
+ documents, err = h.queries.SearchDocuments(r.Context(), db.SearchDocumentsParams{
+ PdfFilename: searchPattern,
+ CmcReference: searchPattern,
+ Name: searchPattern,
+ Title: searchPattern,
+ Username: searchPattern,
+ })
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ }
+
+ data := map[string]interface{}{
+ "Documents": documents,
+ }
+
+ w.Header().Set("Content-Type", "text/html")
+ if err := h.tmpl.RenderPartial(w, "documents/table.html", "document-table", data); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ }
+}
+
+func (h *PageHandler) DocumentsView(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+ id, err := strconv.Atoi(vars["id"])
+ if err != nil {
+ http.Error(w, "Invalid document ID", http.StatusBadRequest)
+ return
+ }
+
+ document, err := h.queries.GetDocumentByID(r.Context(), int32(id))
+ if err != nil {
+ http.Error(w, "Document not found", http.StatusNotFound)
+ return
+ }
+
+ // Load line items for this document
+ lineItems, err := h.queries.ListLineItemsByDocument(r.Context(), int32(id))
+ if err != nil {
+ log.Printf("Error loading line items for document %d: %v", id, err)
+ // Don't fail the entire page if line items can't be loaded
+ lineItems = []db.LineItem{}
+ }
+
+ // Prepare data based on document type
+ data := map[string]interface{}{
+ "Document": document,
+ "DocType": string(document.Type),
+ "LineItems": lineItems,
+ }
+
+ // Add document type specific data
+ switch document.Type {
+ case db.DocumentsTypeQuote:
+ // For quotes, we might need to load enquiry data
+ if document.CmcReference != "" {
+ // The CmcReference for quotes is the enquiry title
+ data["EnquiryTitle"] = document.CmcReference
+ }
+
+ case db.DocumentsTypeInvoice:
+ // For invoices, load job and customer data if needed
+ data["ShowPaymentButton"] = true
+
+ case db.DocumentsTypePurchaseOrder:
+ // For purchase orders, load principle data if needed
+
+ case db.DocumentsTypeOrderAck:
+ // For order acknowledgements, load job data if needed
+
+ case db.DocumentsTypePackingList:
+ // For packing lists, load job data if needed
+ }
+
+ // Render the appropriate template
+ if err := h.tmpl.Render(w, "documents/view.html", data); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ }
+}
diff --git a/go-app/internal/cmc/handlers/statuses.go b/go-app/internal/cmc/handlers/statuses.go
new file mode 100644
index 00000000..67f6a956
--- /dev/null
+++ b/go-app/internal/cmc/handlers/statuses.go
@@ -0,0 +1,206 @@
+package handlers
+
+import (
+ "database/sql"
+ "encoding/json"
+ "net/http"
+ "strconv"
+
+ "code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db"
+ "github.com/gorilla/mux"
+)
+
+type StatusHandler struct {
+ queries *db.Queries
+}
+
+func NewStatusHandler(queries *db.Queries) *StatusHandler {
+ return &StatusHandler{queries: queries}
+}
+
+func (h *StatusHandler) List(w http.ResponseWriter, r *http.Request) {
+ limit := 50
+ offset := 0
+
+ if l := r.URL.Query().Get("limit"); l != "" {
+ if val, err := strconv.Atoi(l); err == nil {
+ limit = val
+ }
+ }
+
+ if o := r.URL.Query().Get("offset"); o != "" {
+ if val, err := strconv.Atoi(o); err == nil {
+ offset = val
+ }
+ }
+
+ statuses, err := h.queries.ListStatuses(r.Context(), db.ListStatusesParams{
+ Limit: int32(limit),
+ Offset: int32(offset),
+ })
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(statuses)
+}
+
+func (h *StatusHandler) Get(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+ id, err := strconv.Atoi(vars["id"])
+ if err != nil {
+ http.Error(w, "Invalid status ID", http.StatusBadRequest)
+ return
+ }
+
+ status, err := h.queries.GetStatus(r.Context(), int32(id))
+ if err != nil {
+ if err == sql.ErrNoRows {
+ http.Error(w, "Status not found", http.StatusNotFound)
+ return
+ }
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(status)
+}
+
+func (h *StatusHandler) Create(w http.ResponseWriter, r *http.Request) {
+ // Parse form data for HTMX requests
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ params := db.CreateStatusParams{
+ Name: r.FormValue("name"),
+ Color: sql.NullString{String: r.FormValue("color"), Valid: r.FormValue("color") != ""},
+ }
+
+ // Check if this is a JSON request
+ if r.Header.Get("Content-Type") == "application/json" {
+ if err := json.NewDecoder(r.Body).Decode(¶ms); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ }
+
+ result, err := h.queries.CreateStatus(r.Context(), params)
+ if err != nil {
+ if r.Header.Get("HX-Request") == "true" {
+ w.Header().Set("Content-Type", "text/html")
+ w.Write([]byte(`Error creating status
`))
+ return
+ }
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ id, err := result.LastInsertId()
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ // If HTMX request, return success message
+ if r.Header.Get("HX-Request") == "true" {
+ w.Header().Set("Content-Type", "text/html")
+ w.Write([]byte(`Status created successfully
`))
+ return
+ }
+
+ // JSON response for API
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "id": id,
+ })
+}
+
+func (h *StatusHandler) Update(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+ id, err := strconv.Atoi(vars["id"])
+ if err != nil {
+ http.Error(w, "Invalid status ID", http.StatusBadRequest)
+ return
+ }
+
+ var params db.UpdateStatusParams
+ if r.Header.Get("Content-Type") == "application/json" {
+ if err := json.NewDecoder(r.Body).Decode(¶ms); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ } else {
+ // Handle form data
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ params = db.UpdateStatusParams{
+ Name: r.FormValue("name"),
+ Color: sql.NullString{String: r.FormValue("color"), Valid: r.FormValue("color") != ""},
+ }
+ }
+
+ params.ID = int32(id)
+
+ if err := h.queries.UpdateStatus(r.Context(), params); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ w.WriteHeader(http.StatusNoContent)
+}
+
+func (h *StatusHandler) Delete(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+ id, err := strconv.Atoi(vars["id"])
+ if err != nil {
+ http.Error(w, "Invalid status ID", http.StatusBadRequest)
+ return
+ }
+
+ if err := h.queries.DeleteStatus(r.Context(), int32(id)); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ w.WriteHeader(http.StatusNoContent)
+}
+
+func (h *StatusHandler) JsonList(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+ selectedID := 0
+ if sid := vars["selectedId"]; sid != "" {
+ selectedID, _ = strconv.Atoi(sid)
+ }
+
+ statuses, err := h.queries.ListStatuses(r.Context(), db.ListStatusesParams{
+ Limit: 100,
+ Offset: 0,
+ })
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ // Format for JSON list with selected indicator
+ var results []map[string]interface{}
+ for _, status := range statuses {
+ results = append(results, map[string]interface{}{
+ "id": status.ID,
+ "name": status.Name,
+ "color": status.Color.String,
+ "selected": status.ID == int32(selectedID),
+ })
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(results)
+}
\ No newline at end of file
diff --git a/go-app/internal/cmc/pdf/generator.go b/go-app/internal/cmc/pdf/generator.go
new file mode 100644
index 00000000..2d025655
--- /dev/null
+++ b/go-app/internal/cmc/pdf/generator.go
@@ -0,0 +1,310 @@
+package pdf
+
+import (
+ "fmt"
+ "path/filepath"
+
+ "github.com/jung-kurt/gofpdf"
+)
+
+// Generator handles PDF generation for documents
+type Generator struct {
+ pdf *gofpdf.Fpdf
+ outputDir string
+ headerText string
+ footerText string
+ docRef string
+ currentPage int
+}
+
+// NewGenerator creates a new PDF generator
+func NewGenerator(outputDir string) *Generator {
+ pdf := gofpdf.New("P", "mm", "A4", "")
+
+ return &Generator{
+ pdf: pdf,
+ outputDir: outputDir,
+ headerText: "CMC TECHNOLOGIES",
+ footerText: "Copyright Š %d CMC Technologies. All rights reserved.",
+ }
+}
+
+// AddPage adds a new page to the PDF
+func (g *Generator) AddPage() {
+ g.pdf.AddPage()
+ g.currentPage++
+}
+
+// Page1Header adds the standard header for page 1
+func (g *Generator) Page1Header() {
+ g.pdf.SetY(10)
+
+ // Set text color to blue
+ g.pdf.SetTextColor(0, 0, 152)
+
+ // Add logo if available (assuming logo is in static directory)
+ // logoPath := filepath.Join("static", "images", "cmclogosmall.png")
+ // Try to add logo, but don't fail if it doesn't exist or isn't a proper image
+ // g.pdf.ImageOptions(logoPath, 10, 10, 0, 28, false, gofpdf.ImageOptions{ImageType: "PNG"}, 0, "http://www.cmctechnologies.com.au")
+
+ // Company name
+ g.pdf.SetFont("Helvetica", "B", 30)
+ g.pdf.SetX(40)
+ g.pdf.CellFormat(0, 0, g.headerText, "", 1, "C", false, 0, "")
+
+ // Company details
+ g.pdf.SetFont("Helvetica", "", 10)
+ g.pdf.SetY(22)
+ g.pdf.SetX(40)
+ g.pdf.CellFormat(0, 0, "PTY LIMITED ACN: 085 991 224 ABN: 47 085 991 224", "", 1, "C", false, 0, "")
+
+ // Draw horizontal line
+ g.pdf.SetDrawColor(0, 0, 0)
+ g.pdf.Line(43, 24, 200, 24)
+
+ // Contact details
+ g.pdf.SetTextColor(0, 0, 0)
+ g.pdf.SetY(32)
+
+ // Left column - labels
+ g.pdf.SetX(45)
+ g.pdf.MultiCell(30, 5, "Phone:\nFax:\nEmail:\nWeb Site:", "", "L", false)
+
+ // Middle column - values
+ g.pdf.SetY(32)
+ g.pdf.SetX(65)
+ g.pdf.SetFont("Helvetica", "", 10)
+ g.pdf.MultiCell(55, 5, "+61 2 9669 4000\n+61 2 9669 4111\nsales@cmctechnologies.com.au\nwww.cmctechnologies.net.au", "", "L", false)
+
+ // Right column - address
+ g.pdf.SetY(32)
+ g.pdf.SetX(150)
+ g.pdf.MultiCell(52, 5, "Unit 19, 77 Bourke Rd\nAlexandria NSW 2015\nAUSTRALIA", "", "L", false)
+
+ // Engineering text
+ g.pdf.SetTextColor(0, 0, 152)
+ g.pdf.SetFont("Helvetica", "B", 10)
+ g.pdf.SetY(37)
+ g.pdf.SetX(10)
+ g.pdf.MultiCell(30, 5, "Engineering &\nIndustrial\nInstrumentation", "", "L", false)
+}
+
+// Page1Footer adds the standard footer
+func (g *Generator) Page1Footer() {
+ g.pdf.SetY(-20)
+
+ // Footer line
+ g.pdf.SetDrawColor(0, 0, 0)
+ g.pdf.Line(10, g.pdf.GetY(), 200, g.pdf.GetY())
+
+ // Footer text
+ g.pdf.SetFont("Helvetica", "", 9)
+ g.pdf.SetY(-18)
+ g.pdf.CellFormat(0, 5, "CMC TECHNOLOGIES Provides Solutions in the Following Fields", "", 1, "C", false, 0, "")
+
+ // Color-coded services
+ g.pdf.SetY(-13)
+ g.pdf.SetX(10)
+
+ // First line of services
+ services := []struct {
+ text string
+ r, g, b int
+ }{
+ {"EXPLOSION PREVENTION AND PROTECTION", 153, 0, 10},
+ {"â", 0, 0, 0},
+ {"FIRE PROTECTION", 255, 153, 0},
+ {"â", 0, 0, 0},
+ {"PRESSURE RELIEF", 255, 0, 25},
+ {"â", 0, 0, 0},
+ {"VISION IN THE PROCESS", 0, 128, 30},
+ }
+
+ x := 15.0
+ for _, service := range services {
+ g.pdf.SetTextColor(service.r, service.g, service.b)
+ width := g.pdf.GetStringWidth(service.text)
+ g.pdf.SetX(x)
+ g.pdf.CellFormat(width, 5, service.text, "", 0, "L", false, 0, "")
+ x += width + 1
+ }
+
+ // Second line of services
+ g.pdf.SetY(-8)
+ g.pdf.SetX(60)
+ g.pdf.SetTextColor(47, 75, 224)
+ g.pdf.CellFormat(0, 5, "FLOW MEASUREMENT", "", 0, "L", false, 0, "")
+ g.pdf.SetX(110)
+ g.pdf.SetTextColor(171, 49, 248)
+ g.pdf.CellFormat(0, 5, "âPROCESS INSTRUMENTATION", "", 0, "L", false, 0, "")
+}
+
+// DetailsBox adds the document details box (quote/invoice details)
+func (g *Generator) DetailsBox(docType, companyName, emailTo, attention, fromName, fromEmail, refNumber, yourRef, issueDate string) {
+ g.pdf.SetY(60)
+ g.pdf.SetFont("Helvetica", "", 10)
+ g.pdf.SetTextColor(0, 0, 0)
+
+ // Create details table
+ lineHeight := 5.0
+ col1Width := 40.0
+ col2Width := 60.0
+ col3Width := 40.0
+ col4Width := 50.0
+
+ // Row 1
+ g.pdf.SetFont("Helvetica", "B", 10)
+ g.pdf.CellFormat(col1Width, lineHeight, "COMPANY NAME:", "1", 0, "L", false, 0, "")
+ g.pdf.SetFont("Helvetica", "", 10)
+ g.pdf.CellFormat(col2Width, lineHeight, companyName, "1", 0, "L", false, 0, "")
+ g.pdf.SetFont("Helvetica", "B", 10)
+ g.pdf.CellFormat(col3Width, lineHeight, docType+" NO.:", "1", 0, "L", false, 0, "")
+ g.pdf.SetFont("Helvetica", "", 10)
+ g.pdf.CellFormat(col4Width, lineHeight, refNumber, "1", 1, "L", false, 0, "")
+
+ // Row 2
+ g.pdf.SetFont("Helvetica", "B", 10)
+ g.pdf.CellFormat(col1Width, lineHeight, "EMAIL TO:", "1", 0, "L", false, 0, "")
+ g.pdf.SetFont("Helvetica", "", 10)
+ g.pdf.CellFormat(col2Width, lineHeight, emailTo, "1", 0, "L", false, 0, "")
+ g.pdf.SetFont("Helvetica", "B", 10)
+ g.pdf.CellFormat(col3Width, lineHeight, "YOUR REFERENCE:", "1", 0, "L", false, 0, "")
+ g.pdf.SetFont("Helvetica", "", 10)
+ g.pdf.CellFormat(col4Width, lineHeight, yourRef, "1", 1, "L", false, 0, "")
+
+ // Row 3
+ g.pdf.SetFont("Helvetica", "B", 10)
+ g.pdf.CellFormat(col1Width, lineHeight, "ATTENTION:", "1", 0, "L", false, 0, "")
+ g.pdf.SetFont("Helvetica", "", 10)
+ g.pdf.CellFormat(col2Width, lineHeight, attention, "1", 0, "L", false, 0, "")
+ g.pdf.SetFont("Helvetica", "B", 10)
+ g.pdf.CellFormat(col3Width, lineHeight, "ISSUE DATE:", "1", 0, "L", false, 0, "")
+ g.pdf.SetFont("Helvetica", "", 10)
+ g.pdf.CellFormat(col4Width, lineHeight, issueDate, "1", 1, "L", false, 0, "")
+
+ // Row 4
+ g.pdf.SetFont("Helvetica", "B", 10)
+ g.pdf.CellFormat(col1Width, lineHeight, "FROM:", "1", 0, "L", false, 0, "")
+ g.pdf.SetFont("Helvetica", "", 10)
+ g.pdf.CellFormat(col2Width, lineHeight, fromName, "1", 0, "L", false, 0, "")
+ g.pdf.SetFont("Helvetica", "B", 10)
+ g.pdf.CellFormat(col3Width, lineHeight, "PAGES:", "1", 0, "L", false, 0, "")
+ g.pdf.SetFont("Helvetica", "", 10)
+ pageText := fmt.Sprintf("%d of {nb}", g.pdf.PageNo())
+ g.pdf.CellFormat(col4Width, lineHeight, pageText, "1", 1, "L", false, 0, "")
+
+ // Row 5
+ g.pdf.SetFont("Helvetica", "B", 10)
+ g.pdf.CellFormat(col1Width, lineHeight, "EMAIL:", "1", 0, "L", false, 0, "")
+ g.pdf.SetFont("Helvetica", "", 10)
+ g.pdf.CellFormat(col2Width, lineHeight, fromEmail, "1", 0, "L", false, 0, "")
+ g.pdf.CellFormat(col3Width+col4Width, lineHeight, "", "1", 1, "L", false, 0, "")
+}
+
+// AddContent adds HTML content to the PDF
+func (g *Generator) AddContent(content string) {
+ g.pdf.SetFont("Helvetica", "", 10)
+ g.pdf.SetTextColor(0, 0, 0)
+ g.pdf.SetY(g.pdf.GetY() + 10)
+
+ // Basic HTML to PDF conversion
+ // Note: gofpdf has limited HTML support, so we'll need to parse and convert manually
+ // For now, we'll just add the content as plain text
+ g.pdf.MultiCell(0, 5, content, "", "L", false)
+}
+
+// AddLineItemsTable adds a table of line items
+func (g *Generator) AddLineItemsTable(items []LineItem, currencySymbol string, showGST bool) {
+ g.pdf.SetFont("Helvetica", "B", 10)
+ g.pdf.SetTextColor(0, 0, 0)
+
+ // Column widths
+ itemWidth := 15.0
+ qtyWidth := 15.0
+ descWidth := 100.0
+ unitWidth := 30.0
+ totalWidth := 30.0
+
+ // Table header
+ g.pdf.CellFormat(itemWidth, 7, "ITEM", "1", 0, "C", false, 0, "")
+ g.pdf.CellFormat(qtyWidth, 7, "QTY", "1", 0, "C", false, 0, "")
+ g.pdf.CellFormat(descWidth, 7, "DESCRIPTION", "1", 0, "C", false, 0, "")
+ g.pdf.CellFormat(unitWidth, 7, "UNIT PRICE", "1", 0, "C", false, 0, "")
+ g.pdf.CellFormat(totalWidth, 7, "TOTAL", "1", 1, "C", false, 0, "")
+
+ // Table rows
+ g.pdf.SetFont("Helvetica", "", 9)
+
+ subtotal := 0.0
+ for _, item := range items {
+ // Check if we need a new page
+ if g.pdf.GetY() > 250 {
+ g.AddPage()
+ g.pdf.SetFont("Helvetica", "B", 10)
+ g.pdf.CellFormat(itemWidth, 7, "ITEM", "1", 0, "C", false, 0, "")
+ g.pdf.CellFormat(qtyWidth, 7, "QTY", "1", 0, "C", false, 0, "")
+ g.pdf.CellFormat(descWidth, 7, "DESCRIPTION", "1", 0, "C", false, 0, "")
+ g.pdf.CellFormat(unitWidth, 7, "UNIT PRICE", "1", 0, "C", false, 0, "")
+ g.pdf.CellFormat(totalWidth, 7, "TOTAL", "1", 1, "C", false, 0, "")
+ g.pdf.SetFont("Helvetica", "", 9)
+ }
+
+ g.pdf.CellFormat(itemWidth, 6, item.ItemNumber, "1", 0, "C", false, 0, "")
+ g.pdf.CellFormat(qtyWidth, 6, item.Quantity, "1", 0, "C", false, 0, "")
+ g.pdf.CellFormat(descWidth, 6, item.Title, "1", 0, "L", false, 0, "")
+ g.pdf.CellFormat(unitWidth, 6, fmt.Sprintf("%s%.2f", currencySymbol, item.UnitPrice), "1", 0, "R", false, 0, "")
+ g.pdf.CellFormat(totalWidth, 6, fmt.Sprintf("%s%.2f", currencySymbol, item.TotalPrice), "1", 1, "R", false, 0, "")
+
+ subtotal += item.TotalPrice
+ }
+
+ // Totals
+ g.pdf.SetFont("Helvetica", "B", 10)
+
+ // Subtotal
+ g.pdf.SetX(g.pdf.GetX() + itemWidth + qtyWidth + descWidth)
+ g.pdf.CellFormat(unitWidth, 6, "SUBTOTAL:", "1", 0, "R", false, 0, "")
+ g.pdf.CellFormat(totalWidth, 6, fmt.Sprintf("%s%.2f", currencySymbol, subtotal), "1", 1, "R", false, 0, "")
+
+ // GST if applicable
+ if showGST {
+ gst := subtotal * 0.10
+ g.pdf.SetX(g.pdf.GetX() + itemWidth + qtyWidth + descWidth)
+ g.pdf.CellFormat(unitWidth, 6, "GST (10%):", "1", 0, "R", false, 0, "")
+ g.pdf.CellFormat(totalWidth, 6, fmt.Sprintf("%s%.2f", currencySymbol, gst), "1", 1, "R", false, 0, "")
+
+ // Total
+ total := subtotal + gst
+ g.pdf.SetX(g.pdf.GetX() + itemWidth + qtyWidth + descWidth)
+ g.pdf.CellFormat(unitWidth, 6, "TOTAL DUE:", "1", 0, "R", false, 0, "")
+ g.pdf.CellFormat(totalWidth, 6, fmt.Sprintf("%s%.2f", currencySymbol, total), "1", 1, "R", false, 0, "")
+ } else {
+ // Total without GST
+ g.pdf.SetX(g.pdf.GetX() + itemWidth + qtyWidth + descWidth)
+ g.pdf.CellFormat(unitWidth, 6, "TOTAL:", "1", 0, "R", false, 0, "")
+ g.pdf.CellFormat(totalWidth, 6, fmt.Sprintf("%s%.2f", currencySymbol, subtotal), "1", 1, "R", false, 0, "")
+ }
+}
+
+// Save saves the PDF to a file
+func (g *Generator) Save(filename string) error {
+ g.pdf.AliasNbPages("")
+ outputPath := filepath.Join(g.outputDir, filename)
+ fmt.Printf("Generator.Save: Saving PDF to path: %s\n", outputPath)
+ err := g.pdf.OutputFileAndClose(outputPath)
+ if err != nil {
+ fmt.Printf("Generator.Save: Error saving PDF: %v\n", err)
+ } else {
+ fmt.Printf("Generator.Save: PDF saved successfully to: %s\n", outputPath)
+ }
+ return err
+}
+
+// LineItem represents a line item for the PDF
+type LineItem struct {
+ ItemNumber string
+ Quantity string
+ Title string
+ UnitPrice float64
+ TotalPrice float64
+}
\ No newline at end of file
diff --git a/go-app/internal/cmc/pdf/templates.go b/go-app/internal/cmc/pdf/templates.go
new file mode 100644
index 00000000..d12b07a6
--- /dev/null
+++ b/go-app/internal/cmc/pdf/templates.go
@@ -0,0 +1,316 @@
+package pdf
+
+import (
+ "fmt"
+ "time"
+
+ "code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db"
+)
+
+// QuotePDFData contains all data needed to generate a quote PDF
+type QuotePDFData struct {
+ Document *db.GetDocumentByIDRow
+ Quote interface{} // Quote specific data
+ Enquiry *db.Enquiry
+ Customer *db.Customer
+ Contact interface{} // Contact data
+ User *db.GetUserRow
+ LineItems []db.GetLineItemsTableRow
+ Currency interface{} // Currency data
+ CurrencySymbol string
+ ShowGST bool
+ CommercialComments string
+}
+
+// GenerateQuotePDF generates a PDF for a quote
+func GenerateQuotePDF(data *QuotePDFData, outputDir string) (string, error) {
+ fmt.Printf("GenerateQuotePDF called with outputDir: %s\n", outputDir)
+ gen := NewGenerator(outputDir)
+
+ // First page with header
+ gen.AddPage()
+ gen.Page1Header()
+
+ // Extract data for details box
+ companyName := data.Document.CustomerName
+ emailTo := "" // TODO: Get from contact
+ attention := "" // TODO: Get from contact
+ fromName := fmt.Sprintf("%s %s", data.User.FirstName, data.User.LastName)
+ fromEmail := data.User.Email
+ enquiryNumber := ""
+ if data.Document.EnquiryTitle.Valid {
+ enquiryNumber = data.Document.EnquiryTitle.String
+ }
+
+ if data.Document.Revision > 0 {
+ enquiryNumber = fmt.Sprintf("%s.%d", enquiryNumber, data.Document.Revision)
+ }
+
+ yourReference := fmt.Sprintf("Enquiry on %s", data.Document.Created.Format("2 Jan 2006"))
+ issueDate := time.Now().Format("2 January 2006")
+
+ // Add details box
+ gen.DetailsBox("QUOTE", companyName, emailTo, attention, fromName, fromEmail, enquiryNumber, yourReference, issueDate)
+
+ // Add page content if any
+ // TODO: Add document pages content
+
+ gen.Page1Footer()
+
+ // Add pricing page
+ gen.AddPage()
+ gen.pdf.SetFont("Helvetica", "B", 14)
+ gen.pdf.CellFormat(0, 10, "PRICING & SPECIFICATIONS", "", 1, "C", false, 0, "")
+ gen.pdf.Ln(5)
+
+ // Convert line items
+ pdfItems := make([]LineItem, len(data.LineItems))
+ for i, item := range data.LineItems {
+ unitPrice := 0.0
+ totalPrice := 0.0
+
+ // Parse prices
+ if item.GrossUnitPrice.Valid {
+ fmt.Sscanf(item.GrossUnitPrice.String, "%f", &unitPrice)
+ }
+ if item.GrossPrice.Valid {
+ fmt.Sscanf(item.GrossPrice.String, "%f", &totalPrice)
+ }
+
+ pdfItems[i] = LineItem{
+ ItemNumber: item.ItemNumber,
+ Quantity: item.Quantity,
+ Title: item.Title,
+ UnitPrice: unitPrice,
+ TotalPrice: totalPrice,
+ }
+ }
+
+ // Add line items table
+ gen.AddLineItemsTable(pdfItems, data.CurrencySymbol, data.ShowGST)
+
+ // Add commercial comments if any
+ if data.CommercialComments != "" {
+ gen.pdf.Ln(10)
+ gen.pdf.SetFont("Helvetica", "B", 10)
+ gen.pdf.CellFormat(0, 5, "COMMERCIAL COMMENTS", "", 1, "L", false, 0, "")
+ gen.pdf.SetFont("Helvetica", "", 9)
+ gen.pdf.MultiCell(0, 5, data.CommercialComments, "", "L", false)
+ }
+
+ // TODO: Add terms and conditions page
+
+ // Generate filename
+ filename := enquiryNumber
+ if data.Document.Revision > 0 {
+ filename = fmt.Sprintf("%s_%d.pdf", enquiryNumber, data.Document.Revision)
+ } else {
+ filename = fmt.Sprintf("%s.pdf", enquiryNumber)
+ }
+
+ // Save PDF
+ fmt.Printf("Saving PDF with filename: %s to outputDir: %s\n", filename, outputDir)
+ err := gen.Save(filename)
+ if err != nil {
+ fmt.Printf("Error saving PDF: %v\n", err)
+ } else {
+ fmt.Printf("PDF saved successfully: %s\n", filename)
+ }
+ return filename, err
+}
+
+// InvoicePDFData contains all data needed to generate an invoice PDF
+type InvoicePDFData struct {
+ Document *db.Document
+ Invoice *db.Invoice
+ Enquiry *db.Enquiry
+ Customer *db.Customer
+ Job interface{} // Job data
+ LineItems []db.GetLineItemsTableRow
+ Currency interface{} // Currency data
+ CurrencySymbol string
+ ShowGST bool
+ ShipVia string
+ FOB string
+ IssueDate time.Time
+}
+
+// GenerateInvoicePDF generates a PDF for an invoice
+func GenerateInvoicePDF(data *InvoicePDFData, outputDir string) (string, error) {
+ gen := NewGenerator(outputDir)
+
+ // First page with header
+ gen.AddPage()
+ gen.Page1Header()
+
+ // Extract data for details box
+ companyName := data.Customer.Name
+ emailTo := "" // TODO: Get from contact
+ attention := "" // TODO: Get from contact
+ fromName := "" // TODO: Get from user
+ fromEmail := "" // TODO: Get from user
+ invoiceNumber := data.Invoice.Title
+
+ yourReference := "" // TODO: Get reference
+ issueDate := data.IssueDate.Format("2 January 2006")
+
+ // Add details box
+ gen.DetailsBox("INVOICE", companyName, emailTo, attention, fromName, fromEmail, invoiceNumber, yourReference, issueDate)
+
+ // Add shipping details
+ gen.pdf.Ln(5)
+ gen.pdf.SetFont("Helvetica", "B", 10)
+ gen.pdf.CellFormat(30, 5, "Ship Via:", "", 0, "L", false, 0, "")
+ gen.pdf.SetFont("Helvetica", "", 10)
+ gen.pdf.CellFormat(60, 5, data.ShipVia, "", 1, "L", false, 0, "")
+
+ gen.pdf.SetFont("Helvetica", "B", 10)
+ gen.pdf.CellFormat(30, 5, "FOB:", "", 0, "L", false, 0, "")
+ gen.pdf.SetFont("Helvetica", "", 10)
+ gen.pdf.CellFormat(60, 5, data.FOB, "", 1, "L", false, 0, "")
+
+ gen.Page1Footer()
+
+ // Add line items page
+ gen.AddPage()
+ gen.pdf.SetFont("Helvetica", "B", 14)
+ gen.pdf.CellFormat(0, 10, "INVOICE DETAILS", "", 1, "C", false, 0, "")
+ gen.pdf.Ln(5)
+
+ // Convert line items
+ pdfItems := make([]LineItem, len(data.LineItems))
+ for i, item := range data.LineItems {
+ unitPrice := 0.0
+ totalPrice := 0.0
+
+ // Parse prices
+ if item.GrossUnitPrice.Valid {
+ fmt.Sscanf(item.GrossUnitPrice.String, "%f", &unitPrice)
+ }
+ if item.GrossPrice.Valid {
+ fmt.Sscanf(item.GrossPrice.String, "%f", &totalPrice)
+ }
+
+ pdfItems[i] = LineItem{
+ ItemNumber: item.ItemNumber,
+ Quantity: item.Quantity,
+ Title: item.Title,
+ UnitPrice: unitPrice,
+ TotalPrice: totalPrice,
+ }
+ }
+
+ // Add line items table
+ gen.AddLineItemsTable(pdfItems, data.CurrencySymbol, data.ShowGST)
+
+ // Generate filename
+ filename := fmt.Sprintf("%s.pdf", invoiceNumber)
+
+ // Save PDF
+ err := gen.Save(filename)
+ return filename, err
+}
+
+// PurchaseOrderPDFData contains all data needed to generate a purchase order PDF
+type PurchaseOrderPDFData struct {
+ Document *db.GetDocumentByIDRow
+ PurchaseOrder *db.GetPurchaseOrderByDocumentIDRow
+ Principle *db.Principle
+ LineItems []db.GetLineItemsTableRow
+ Currency interface{} // Currency data
+ CurrencySymbol string
+ ShowGST bool
+}
+
+// GeneratePurchaseOrderPDF generates a PDF for a purchase order
+func GeneratePurchaseOrderPDF(data *PurchaseOrderPDFData, outputDir string) (string, error) {
+ gen := NewGenerator(outputDir)
+
+ // First page with header
+ gen.AddPage()
+ gen.Page1Header()
+
+ // Extract data for details box
+ companyName := data.Principle.Name
+ emailTo := "" // TODO: Get from principle contact
+ attention := "" // TODO: Get from principle contact
+ fromName := "" // TODO: Get from user
+ fromEmail := "" // TODO: Get from user
+ poNumber := data.PurchaseOrder.Title
+
+ yourReference := data.PurchaseOrder.PrincipleReference
+ issueDate := data.PurchaseOrder.IssueDate.Format("Monday, 2 January 2006")
+
+ // Add details box
+ gen.DetailsBox("PURCHASE ORDER", companyName, emailTo, attention, fromName, fromEmail, poNumber, yourReference, issueDate)
+
+ // Add PO specific details
+ gen.pdf.Ln(5)
+ gen.pdf.SetFont("Helvetica", "B", 10)
+ gen.pdf.CellFormat(40, 5, "Ordered From:", "", 0, "L", false, 0, "")
+ gen.pdf.SetFont("Helvetica", "", 10)
+ gen.pdf.MultiCell(0, 5, data.PurchaseOrder.OrderedFrom, "", "L", false)
+
+ gen.pdf.SetFont("Helvetica", "B", 10)
+ gen.pdf.CellFormat(40, 5, "Dispatch By:", "", 0, "L", false, 0, "")
+ gen.pdf.SetFont("Helvetica", "", 10)
+ gen.pdf.CellFormat(0, 5, data.PurchaseOrder.DispatchBy, "", 1, "L", false, 0, "")
+
+ gen.pdf.SetFont("Helvetica", "B", 10)
+ gen.pdf.CellFormat(40, 5, "Deliver To:", "", 0, "L", false, 0, "")
+ gen.pdf.SetFont("Helvetica", "", 10)
+ gen.pdf.MultiCell(0, 5, data.PurchaseOrder.DeliverTo, "", "L", false)
+
+ if data.PurchaseOrder.ShippingInstructions != "" {
+ gen.pdf.SetFont("Helvetica", "B", 10)
+ gen.pdf.CellFormat(0, 5, "Shipping Instructions:", "", 1, "L", false, 0, "")
+ gen.pdf.SetFont("Helvetica", "", 10)
+ gen.pdf.MultiCell(0, 5, data.PurchaseOrder.ShippingInstructions, "", "L", false)
+ }
+
+ gen.Page1Footer()
+
+ // Add line items page
+ gen.AddPage()
+ gen.pdf.SetFont("Helvetica", "B", 14)
+ gen.pdf.CellFormat(0, 10, "ORDER DETAILS", "", 1, "C", false, 0, "")
+ gen.pdf.Ln(5)
+
+ // Convert line items
+ pdfItems := make([]LineItem, len(data.LineItems))
+ for i, item := range data.LineItems {
+ unitPrice := 0.0
+ totalPrice := 0.0
+
+ // Parse prices
+ if item.GrossUnitPrice.Valid {
+ fmt.Sscanf(item.GrossUnitPrice.String, "%f", &unitPrice)
+ }
+ if item.GrossPrice.Valid {
+ fmt.Sscanf(item.GrossPrice.String, "%f", &totalPrice)
+ }
+
+ pdfItems[i] = LineItem{
+ ItemNumber: item.ItemNumber,
+ Quantity: item.Quantity,
+ Title: item.Title,
+ UnitPrice: unitPrice,
+ TotalPrice: totalPrice,
+ }
+ }
+
+ // Add line items table
+ gen.AddLineItemsTable(pdfItems, data.CurrencySymbol, data.ShowGST)
+
+ // Generate filename
+ filename := poNumber
+ if data.Document.Revision > 0 {
+ filename = fmt.Sprintf("%s-Rev%d.pdf", data.PurchaseOrder.Title, data.Document.Revision)
+ } else {
+ filename = fmt.Sprintf("%s.pdf", data.PurchaseOrder.Title)
+ }
+
+ // Save PDF
+ err := gen.Save(filename)
+ return filename, err
+}
\ No newline at end of file
diff --git a/go-app/internal/cmc/templates/templates.go b/go-app/internal/cmc/templates/templates.go
index 57fabbce..ba9071e5 100644
--- a/go-app/internal/cmc/templates/templates.go
+++ b/go-app/internal/cmc/templates/templates.go
@@ -54,6 +54,15 @@ func NewTemplateManager(templatesDir string) (*TemplateManager, error) {
"enquiries/show.html",
"enquiries/form.html",
"enquiries/table.html",
+ "documents/index.html",
+ "documents/show.html",
+ "documents/table.html",
+ "documents/view.html",
+ "documents/quote-view.html",
+ "documents/invoice-view.html",
+ "documents/purchase-order-view.html",
+ "documents/orderack-view.html",
+ "documents/packinglist-view.html",
"index.html",
}
@@ -72,6 +81,23 @@ func NewTemplateManager(templatesDir string) (*TemplateManager, error) {
}
}
+ // For documents view page, include all document type elements
+ if page == "documents/view.html" {
+ docElements := []string{
+ "documents/quote-view.html",
+ "documents/invoice-view.html",
+ "documents/purchase-order-view.html",
+ "documents/orderack-view.html",
+ "documents/packinglist-view.html",
+ }
+ for _, elem := range docElements {
+ elemPath := filepath.Join(templatesDir, elem)
+ if _, err := os.Stat(elemPath); err == nil {
+ files = append(files, elemPath)
+ }
+ }
+ }
+
tmpl, err := template.New(filepath.Base(page)).Funcs(funcMap).ParseFiles(files...)
if err != nil {
return nil, err
diff --git a/go-app/server b/go-app/server
index b4baa764..84359de3 100755
Binary files a/go-app/server and b/go-app/server differ
diff --git a/go-app/sql/queries/addresses.sql b/go-app/sql/queries/addresses.sql
new file mode 100644
index 00000000..ad96e2ce
--- /dev/null
+++ b/go-app/sql/queries/addresses.sql
@@ -0,0 +1,45 @@
+-- name: GetAddress :one
+SELECT * FROM addresses
+WHERE id = ? LIMIT 1;
+
+-- name: ListAddresses :many
+SELECT * FROM addresses
+ORDER BY name
+LIMIT ? OFFSET ?;
+
+-- name: ListAddressesByCustomer :many
+SELECT * FROM addresses
+WHERE customer_id = ?
+ORDER BY name;
+
+-- name: CreateAddress :execresult
+INSERT INTO addresses (
+ name, address, city, state_id, country_id,
+ customer_id, type, postcode
+) VALUES (
+ ?, ?, ?, ?, ?, ?, ?, ?
+);
+
+-- name: UpdateAddress :exec
+UPDATE addresses
+SET name = ?,
+ address = ?,
+ city = ?,
+ state_id = ?,
+ country_id = ?,
+ customer_id = ?,
+ type = ?,
+ postcode = ?
+WHERE id = ?;
+
+-- name: DeleteAddress :exec
+DELETE FROM addresses
+WHERE id = ?;
+
+-- name: GetCustomerAddresses :many
+SELECT a.*, s.name as state_name, s.shortform as state_shortform, c.name as country_name
+FROM addresses a
+LEFT JOIN states s ON a.state_id = s.id
+LEFT JOIN countries c ON a.country_id = c.id
+WHERE a.customer_id = ?
+ORDER BY a.name;
\ No newline at end of file
diff --git a/go-app/sql/queries/attachments.sql b/go-app/sql/queries/attachments.sql
new file mode 100644
index 00000000..d7f320f5
--- /dev/null
+++ b/go-app/sql/queries/attachments.sql
@@ -0,0 +1,41 @@
+-- name: GetAttachment :one
+SELECT * FROM attachments
+WHERE id = ? LIMIT 1;
+
+-- name: ListAttachments :many
+SELECT * FROM attachments
+WHERE archived = 0
+ORDER BY created DESC
+LIMIT ? OFFSET ?;
+
+-- name: ListArchivedAttachments :many
+SELECT * FROM attachments
+WHERE archived = 1
+ORDER BY created DESC
+LIMIT ? OFFSET ?;
+
+-- name: CreateAttachment :execresult
+INSERT INTO attachments (
+ principle_id, created, modified, name, filename,
+ file, type, size, description, archived
+) VALUES (
+ ?, NOW(), NOW(), ?, ?, ?, ?, ?, ?, 0
+);
+
+-- name: UpdateAttachment :exec
+UPDATE attachments
+SET modified = NOW(),
+ name = ?,
+ description = ?
+WHERE id = ?;
+
+-- name: DeleteAttachment :exec
+UPDATE attachments
+SET archived = 1,
+ modified = NOW()
+WHERE id = ?;
+
+-- name: ListAttachmentsByPrinciple :many
+SELECT * FROM attachments
+WHERE principle_id = ? AND archived = 0
+ORDER BY created DESC;
\ No newline at end of file
diff --git a/go-app/sql/queries/boxes.sql b/go-app/sql/queries/boxes.sql
new file mode 100644
index 00000000..7428a61f
--- /dev/null
+++ b/go-app/sql/queries/boxes.sql
@@ -0,0 +1,32 @@
+-- name: GetBox :one
+SELECT * FROM boxes
+WHERE id = ? LIMIT 1;
+
+-- name: ListBoxes :many
+SELECT * FROM boxes
+ORDER BY id DESC
+LIMIT ? OFFSET ?;
+
+-- name: ListBoxesByShipment :many
+SELECT * FROM boxes
+WHERE shipment_id = ?
+ORDER BY id;
+
+-- name: CreateBox :execresult
+INSERT INTO boxes (
+ shipment_id, length, width, height, weight
+) VALUES (
+ ?, ?, ?, ?, ?
+);
+
+-- name: UpdateBox :exec
+UPDATE boxes
+SET length = ?,
+ width = ?,
+ height = ?,
+ weight = ?
+WHERE id = ?;
+
+-- name: DeleteBox :exec
+DELETE FROM boxes
+WHERE id = ?;
\ No newline at end of file
diff --git a/go-app/sql/queries/countries.sql b/go-app/sql/queries/countries.sql
new file mode 100644
index 00000000..28edcb95
--- /dev/null
+++ b/go-app/sql/queries/countries.sql
@@ -0,0 +1,23 @@
+-- name: GetCountry :one
+SELECT * FROM countries
+WHERE id = ? LIMIT 1;
+
+-- name: ListCountries :many
+SELECT * FROM countries
+ORDER BY name
+LIMIT ? OFFSET ?;
+
+-- name: CreateCountry :execresult
+INSERT INTO countries (name) VALUES (?);
+
+-- name: UpdateCountry :exec
+UPDATE countries SET name = ? WHERE id = ?;
+
+-- name: DeleteCountry :exec
+DELETE FROM countries WHERE id = ?;
+
+-- name: SearchCountriesByName :many
+SELECT * FROM countries
+WHERE name LIKE CONCAT('%', ?, '%')
+ORDER BY name
+LIMIT 10;
\ No newline at end of file
diff --git a/go-app/sql/queries/documents.sql b/go-app/sql/queries/documents.sql
new file mode 100644
index 00000000..e1cbd914
--- /dev/null
+++ b/go-app/sql/queries/documents.sql
@@ -0,0 +1,201 @@
+-- name: ListDocuments :many
+SELECT
+ d.id,
+ d.type,
+ d.created,
+ d.user_id,
+ d.doc_page_count,
+ d.pdf_filename,
+ d.pdf_created_at,
+ d.pdf_created_by_user_id,
+ d.cmc_reference,
+ d.revision,
+ d.email_sent_at,
+ d.email_sent_by_user_id,
+ u.first_name as user_first_name,
+ u.last_name as user_last_name,
+ u.username as user_username,
+ pdf_creator.first_name as pdf_creator_first_name,
+ pdf_creator.last_name as pdf_creator_last_name,
+ pdf_creator.username as pdf_creator_username
+FROM documents d
+LEFT JOIN users u ON d.user_id = u.id
+LEFT JOIN users pdf_creator ON d.pdf_created_by_user_id = pdf_creator.id
+ORDER BY d.id DESC
+LIMIT 1000;
+
+-- name: ListDocumentsByType :many
+SELECT
+ d.id,
+ d.type,
+ d.created,
+ d.user_id,
+ d.doc_page_count,
+ d.pdf_filename,
+ d.pdf_created_at,
+ d.pdf_created_by_user_id,
+ d.cmc_reference,
+ d.revision,
+ d.email_sent_at,
+ d.email_sent_by_user_id,
+ u.first_name as user_first_name,
+ u.last_name as user_last_name,
+ u.username as user_username,
+ pdf_creator.first_name as pdf_creator_first_name,
+ pdf_creator.last_name as pdf_creator_last_name,
+ pdf_creator.username as pdf_creator_username,
+ COALESCE(ec.name, ic.name, '') as customer_name,
+ e.title as enquiry_title
+FROM documents d
+LEFT JOIN users u ON d.user_id = u.id
+LEFT JOIN users pdf_creator ON d.pdf_created_by_user_id = pdf_creator.id
+LEFT JOIN enquiries e ON d.type IN ('quote', 'orderAck') AND d.cmc_reference = e.title
+LEFT JOIN customers ec ON e.customer_id = ec.id
+LEFT JOIN invoices i ON d.type = 'invoice' AND d.cmc_reference = i.title
+LEFT JOIN customers ic ON i.customer_id = ic.id
+WHERE d.type = ?
+ORDER BY d.id DESC
+LIMIT 1000;
+
+-- name: GetDocumentByID :one
+SELECT
+ d.id,
+ d.type,
+ d.created,
+ d.user_id,
+ d.doc_page_count,
+ d.pdf_filename,
+ d.pdf_created_at,
+ d.pdf_created_by_user_id,
+ d.cmc_reference,
+ d.revision,
+ d.shipping_details,
+ d.bill_to,
+ d.ship_to,
+ d.email_sent_at,
+ d.email_sent_by_user_id,
+ u.first_name as user_first_name,
+ u.last_name as user_last_name,
+ u.username as user_username,
+ pdf_creator.first_name as pdf_creator_first_name,
+ pdf_creator.last_name as pdf_creator_last_name,
+ pdf_creator.username as pdf_creator_username,
+ COALESCE(ec.name, ic.name, '') as customer_name,
+ e.title as enquiry_title
+FROM documents d
+LEFT JOIN users u ON d.user_id = u.id
+LEFT JOIN users pdf_creator ON d.pdf_created_by_user_id = pdf_creator.id
+LEFT JOIN enquiries e ON d.type IN ('quote', 'orderAck') AND d.cmc_reference = e.title
+LEFT JOIN customers ec ON e.customer_id = ec.id
+LEFT JOIN invoices i ON d.type = 'invoice' AND d.cmc_reference = i.title
+LEFT JOIN customers ic ON i.customer_id = ic.id
+WHERE d.id = ?;
+
+-- name: CreateDocument :execresult
+INSERT INTO documents (
+ type,
+ created,
+ user_id,
+ doc_page_count,
+ cmc_reference,
+ pdf_filename,
+ pdf_created_at,
+ pdf_created_by_user_id,
+ shipping_details,
+ revision,
+ bill_to,
+ ship_to,
+ email_sent_at,
+ email_sent_by_user_id
+) VALUES (
+ ?, NOW(), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
+);
+
+-- name: UpdateDocument :exec
+UPDATE documents
+SET
+ type = ?,
+ user_id = ?,
+ doc_page_count = ?,
+ cmc_reference = ?,
+ pdf_filename = ?,
+ pdf_created_at = ?,
+ pdf_created_by_user_id = ?,
+ shipping_details = ?,
+ revision = ?,
+ bill_to = ?,
+ ship_to = ?,
+ email_sent_at = ?,
+ email_sent_by_user_id = ?
+WHERE id = ?;
+
+-- name: ArchiveDocument :exec
+DELETE FROM documents
+WHERE id = ?;
+
+-- name: UnarchiveDocument :exec
+-- Note: Unarchiving not supported as documents table doesn't have an archived column
+-- This is a no-op for compatibility
+SELECT 1 WHERE ? = ?;
+
+-- name: SearchDocuments :many
+SELECT
+ d.id,
+ d.type,
+ d.created,
+ d.user_id,
+ d.doc_page_count,
+ d.pdf_filename,
+ d.pdf_created_at,
+ d.pdf_created_by_user_id,
+ d.cmc_reference,
+ d.revision,
+ d.email_sent_at,
+ d.email_sent_by_user_id,
+ u.first_name as user_first_name,
+ u.last_name as user_last_name,
+ u.username as user_username,
+ pdf_creator.first_name as pdf_creator_first_name,
+ pdf_creator.last_name as pdf_creator_last_name,
+ pdf_creator.username as pdf_creator_username,
+ COALESCE(ec.name, ic.name, '') as customer_name,
+ e.title as enquiry_title
+FROM documents d
+LEFT JOIN users u ON d.user_id = u.id
+LEFT JOIN users pdf_creator ON d.pdf_created_by_user_id = pdf_creator.id
+LEFT JOIN enquiries e ON d.type IN ('quote', 'orderAck') AND d.cmc_reference = e.title
+LEFT JOIN customers ec ON e.customer_id = ec.id
+LEFT JOIN invoices i ON d.type = 'invoice' AND d.cmc_reference = i.title
+LEFT JOIN customers ic ON i.customer_id = ic.id
+WHERE (
+ d.pdf_filename LIKE ? OR
+ d.cmc_reference LIKE ? OR
+ COALESCE(ec.name, ic.name) LIKE ? OR
+ e.title LIKE ? OR
+ u.username LIKE ?
+)
+ORDER BY d.id DESC
+LIMIT 1000;
+
+-- name: UpdateDocumentPDFInfo :exec
+UPDATE documents
+SET
+ pdf_filename = ?,
+ pdf_created_at = ?,
+ pdf_created_by_user_id = ?
+WHERE id = ?;
+
+-- name: GetPurchaseOrderByDocumentID :one
+SELECT
+ po.id,
+ po.title,
+ po.principle_id,
+ po.principle_reference,
+ po.issue_date,
+ po.ordered_from,
+ po.dispatch_by,
+ po.deliver_to,
+ po.shipping_instructions
+FROM purchase_orders po
+JOIN documents d ON d.cmc_reference = po.title
+WHERE d.id = ? AND d.type = 'purchaseOrder';
\ No newline at end of file
diff --git a/go-app/sql/queries/line_items.sql b/go-app/sql/queries/line_items.sql
new file mode 100644
index 00000000..6775ad86
--- /dev/null
+++ b/go-app/sql/queries/line_items.sql
@@ -0,0 +1,77 @@
+-- name: GetLineItem :one
+SELECT * FROM line_items
+WHERE id = ? LIMIT 1;
+
+-- name: ListLineItems :many
+SELECT * FROM line_items
+ORDER BY item_number ASC
+LIMIT ? OFFSET ?;
+
+-- name: ListLineItemsByDocument :many
+SELECT * FROM line_items
+WHERE document_id = ?
+ORDER BY item_number ASC;
+
+-- name: CreateLineItem :execresult
+INSERT INTO line_items (
+ item_number, `option`, quantity, title, description,
+ document_id, product_id, has_text_prices, has_price,
+ unit_price_string, gross_price_string, costing_id,
+ gross_unit_price, net_unit_price, discount_percent,
+ discount_amount_unit, discount_amount_total,
+ gross_price, net_price
+) VALUES (
+ ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
+);
+
+-- name: UpdateLineItem :exec
+UPDATE line_items
+SET item_number = ?,
+ `option` = ?,
+ quantity = ?,
+ title = ?,
+ description = ?,
+ document_id = ?,
+ product_id = ?,
+ has_text_prices = ?,
+ has_price = ?,
+ unit_price_string = ?,
+ gross_price_string = ?,
+ costing_id = ?,
+ gross_unit_price = ?,
+ net_unit_price = ?,
+ discount_percent = ?,
+ discount_amount_unit = ?,
+ discount_amount_total = ?,
+ gross_price = ?,
+ net_price = ?
+WHERE id = ?;
+
+-- name: DeleteLineItem :exec
+DELETE FROM line_items
+WHERE id = ?;
+
+-- name: GetLineItemsTable :many
+SELECT li.*, p.title as product_title, p.model_number
+FROM line_items li
+LEFT JOIN products p ON li.product_id = p.id
+WHERE li.document_id = ?
+ORDER BY li.item_number ASC;
+
+-- name: GetLineItemsByProduct :many
+SELECT * FROM line_items
+WHERE product_id = ?
+ORDER BY item_number ASC;
+
+-- name: GetMaxItemNumber :one
+SELECT COALESCE(MAX(item_number), 0) as max_item_number
+FROM line_items
+WHERE document_id = ?;
+
+-- name: UpdateLineItemPrices :exec
+UPDATE line_items
+SET gross_unit_price = ?,
+ net_unit_price = ?,
+ gross_price = ?,
+ net_price = ?
+WHERE id = ?;
\ No newline at end of file
diff --git a/go-app/sql/queries/principles.sql b/go-app/sql/queries/principles.sql
new file mode 100644
index 00000000..e23bf9b5
--- /dev/null
+++ b/go-app/sql/queries/principles.sql
@@ -0,0 +1,19 @@
+-- name: GetPrinciple :one
+SELECT * FROM principles
+WHERE id = ? LIMIT 1;
+
+-- name: ListPrinciples :many
+SELECT * FROM principles
+ORDER BY name
+LIMIT ? OFFSET ?;
+
+-- name: CreatePrinciple :execresult
+INSERT INTO principles (name, short_name, code) VALUES (?, ?, ?);
+
+-- name: UpdatePrinciple :exec
+UPDATE principles SET name = ?, short_name = ?, code = ? WHERE id = ?;
+
+-- name: GetPrincipleProducts :many
+SELECT * FROM products
+WHERE principle_id = ?
+ORDER BY title;
\ No newline at end of file
diff --git a/go-app/sql/queries/states.sql b/go-app/sql/queries/states.sql
new file mode 100644
index 00000000..70a6961b
--- /dev/null
+++ b/go-app/sql/queries/states.sql
@@ -0,0 +1,26 @@
+-- name: GetState :one
+SELECT * FROM states
+WHERE id = ? LIMIT 1;
+
+-- name: ListStates :many
+SELECT * FROM states
+ORDER BY name
+LIMIT ? OFFSET ?;
+
+-- name: CreateState :execresult
+INSERT INTO states (
+ name, shortform, enqform
+) VALUES (
+ ?, ?, ?
+);
+
+-- name: UpdateState :exec
+UPDATE states
+SET name = ?,
+ shortform = ?,
+ enqform = ?
+WHERE id = ?;
+
+-- name: DeleteState :exec
+DELETE FROM states
+WHERE id = ?;
\ No newline at end of file
diff --git a/go-app/sql/queries/statuses.sql b/go-app/sql/queries/statuses.sql
new file mode 100644
index 00000000..06f2dfc9
--- /dev/null
+++ b/go-app/sql/queries/statuses.sql
@@ -0,0 +1,17 @@
+-- name: GetStatus :one
+SELECT * FROM statuses
+WHERE id = ? LIMIT 1;
+
+-- name: ListStatuses :many
+SELECT * FROM statuses
+ORDER BY name
+LIMIT ? OFFSET ?;
+
+-- name: CreateStatus :execresult
+INSERT INTO statuses (name, color) VALUES (?, ?);
+
+-- name: UpdateStatus :exec
+UPDATE statuses SET name = ?, color = ? WHERE id = ?;
+
+-- name: DeleteStatus :exec
+DELETE FROM statuses WHERE id = ?;
\ No newline at end of file
diff --git a/go-app/sql/queries/users.sql b/go-app/sql/queries/users.sql
new file mode 100644
index 00000000..5826f827
--- /dev/null
+++ b/go-app/sql/queries/users.sql
@@ -0,0 +1,42 @@
+-- name: GetAllUsers :many
+SELECT id, username, first_name, last_name, email, type, enabled, archived
+FROM users
+WHERE archived = 0
+ORDER BY first_name, last_name;
+
+-- name: GetUser :one
+SELECT id, username, first_name, last_name, email, type, enabled, archived
+FROM users
+WHERE id = ?;
+
+-- name: GetUserByUsername :one
+SELECT id, username, first_name, last_name, email, type, enabled, archived
+FROM users
+WHERE username = ? AND archived = 0;
+
+-- name: CreateUser :execresult
+INSERT INTO users (
+ username, first_name, last_name, email, type, enabled, archived
+) VALUES (
+ ?, ?, ?, ?, ?, ?, 0);
+
+-- name: UpdateUser :exec
+UPDATE users
+SET
+ username = ?,
+ first_name = ?,
+ last_name = ?,
+ email = ?,
+ type = ?,
+ enabled = ?
+WHERE id = ?;
+
+-- name: ArchiveUser :exec
+UPDATE users
+SET archived = 1
+WHERE id = ?;
+
+-- name: UnarchiveUser :exec
+UPDATE users
+SET archived = 0
+WHERE id = ?;
\ No newline at end of file
diff --git a/go-app/sql/schema/005_invoices.sql b/go-app/sql/schema/005_invoices.sql
new file mode 100644
index 00000000..29b47330
--- /dev/null
+++ b/go-app/sql/schema/005_invoices.sql
@@ -0,0 +1,7 @@
+-- Invoices table schema (subset for document relationships)
+CREATE TABLE `invoices` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `title` varchar(255) NOT NULL COMMENT 'CMC Invoice Number String',
+ `customer_id` int(11) NOT NULL,
+ PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
\ No newline at end of file
diff --git a/go-app/sql/schema/006_addresses.sql b/go-app/sql/schema/006_addresses.sql
new file mode 100644
index 00000000..0d2435de
--- /dev/null
+++ b/go-app/sql/schema/006_addresses.sql
@@ -0,0 +1,13 @@
+-- Schema for addresses table
+CREATE TABLE IF NOT EXISTS `addresses` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `name` varchar(255) NOT NULL COMMENT 'Descriptive Name for this address',
+ `address` text NOT NULL COMMENT 'street or unit number and street name',
+ `city` varchar(255) NOT NULL COMMENT 'Suburb / City',
+ `state_id` int(11) NOT NULL COMMENT 'State foreign Key',
+ `country_id` int(11) NOT NULL COMMENT 'Country foreign Key',
+ `customer_id` int(11) NOT NULL COMMENT 'Customer foreign key',
+ `type` varchar(255) NOT NULL COMMENT 'either bill / ship / both',
+ `postcode` varchar(50) NOT NULL,
+ PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
\ No newline at end of file
diff --git a/go-app/sql/schema/007_attachments.sql b/go-app/sql/schema/007_attachments.sql
new file mode 100644
index 00000000..331488b6
--- /dev/null
+++ b/go-app/sql/schema/007_attachments.sql
@@ -0,0 +1,15 @@
+-- Schema for attachments table
+CREATE TABLE IF NOT EXISTS `attachments` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `principle_id` int(11) NOT NULL,
+ `created` datetime NOT NULL,
+ `modified` datetime NOT NULL,
+ `name` varchar(255) NOT NULL,
+ `filename` varchar(255) NOT NULL,
+ `file` varchar(255) NOT NULL,
+ `type` varchar(255) NOT NULL,
+ `size` int(11) NOT NULL,
+ `description` text NOT NULL,
+ `archived` tinyint(1) NOT NULL DEFAULT 0,
+ PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
\ No newline at end of file
diff --git a/go-app/sql/schema/008_boxes.sql b/go-app/sql/schema/008_boxes.sql
new file mode 100644
index 00000000..a9546542
--- /dev/null
+++ b/go-app/sql/schema/008_boxes.sql
@@ -0,0 +1,10 @@
+-- Schema for boxes table
+CREATE TABLE IF NOT EXISTS `boxes` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `shipment_id` int(11) NOT NULL,
+ `length` decimal(5,1) NOT NULL,
+ `width` decimal(5,1) NOT NULL,
+ `height` decimal(5,1) NOT NULL,
+ `weight` decimal(5,1) NOT NULL,
+ PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
\ No newline at end of file
diff --git a/go-app/sql/schema/009_states.sql b/go-app/sql/schema/009_states.sql
new file mode 100644
index 00000000..7768fbc8
--- /dev/null
+++ b/go-app/sql/schema/009_states.sql
@@ -0,0 +1,8 @@
+-- Schema for states table
+CREATE TABLE IF NOT EXISTS `states` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `name` varchar(255) NOT NULL,
+ `shortform` varchar(10) DEFAULT NULL,
+ `enqform` varchar(10) DEFAULT NULL,
+ PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
\ No newline at end of file
diff --git a/go-app/sql/schema/010_statuses.sql b/go-app/sql/schema/010_statuses.sql
new file mode 100644
index 00000000..ffdc63ab
--- /dev/null
+++ b/go-app/sql/schema/010_statuses.sql
@@ -0,0 +1,7 @@
+-- Schema for statuses table
+CREATE TABLE IF NOT EXISTS `statuses` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `name` varchar(255) NOT NULL,
+ `color` varchar(7) DEFAULT NULL,
+ PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
\ No newline at end of file
diff --git a/go-app/sql/schema/011_principles.sql b/go-app/sql/schema/011_principles.sql
new file mode 100644
index 00000000..1b74c465
--- /dev/null
+++ b/go-app/sql/schema/011_principles.sql
@@ -0,0 +1,8 @@
+-- Schema for principles table
+CREATE TABLE IF NOT EXISTS `principles` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `name` varchar(255) NOT NULL,
+ `short_name` varchar(50) DEFAULT NULL,
+ `code` int(3) NOT NULL,
+ PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
\ No newline at end of file
diff --git a/go-app/sql/schema/012_countries.sql b/go-app/sql/schema/012_countries.sql
new file mode 100644
index 00000000..a655f892
--- /dev/null
+++ b/go-app/sql/schema/012_countries.sql
@@ -0,0 +1,6 @@
+-- Schema for countries table
+CREATE TABLE IF NOT EXISTS `countries` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `name` varchar(100) NOT NULL,
+ PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
\ No newline at end of file
diff --git a/go-app/sql/schema/013_line_items.sql b/go-app/sql/schema/013_line_items.sql
new file mode 100644
index 00000000..7b999c52
--- /dev/null
+++ b/go-app/sql/schema/013_line_items.sql
@@ -0,0 +1,24 @@
+-- Schema for line_items table
+CREATE TABLE IF NOT EXISTS `line_items` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `item_number` decimal(10,2) NOT NULL,
+ `option` tinyint(1) NOT NULL DEFAULT 0,
+ `quantity` decimal(10,2) NOT NULL,
+ `title` varchar(500) NOT NULL,
+ `description` text NOT NULL,
+ `document_id` int(11) NOT NULL,
+ `product_id` int(11) DEFAULT NULL,
+ `has_text_prices` tinyint(1) NOT NULL DEFAULT 0,
+ `has_price` tinyint(4) NOT NULL DEFAULT 1,
+ `unit_price_string` varchar(255) DEFAULT NULL,
+ `gross_price_string` varchar(255) DEFAULT NULL,
+ `costing_id` int(11) DEFAULT NULL,
+ `gross_unit_price` decimal(10,2) DEFAULT NULL COMMENT 'Either fill this in or have a costing_id associated with this record',
+ `net_unit_price` decimal(10,2) DEFAULT NULL,
+ `discount_percent` decimal(10,2) DEFAULT NULL,
+ `discount_amount_unit` decimal(10,2) DEFAULT NULL,
+ `discount_amount_total` decimal(10,2) DEFAULT NULL,
+ `gross_price` decimal(10,2) DEFAULT NULL,
+ `net_price` decimal(10,2) DEFAULT NULL,
+ PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
\ No newline at end of file
diff --git a/go-app/sql/schema/documents.sql b/go-app/sql/schema/documents.sql
new file mode 100644
index 00000000..0e2167af
--- /dev/null
+++ b/go-app/sql/schema/documents.sql
@@ -0,0 +1,23 @@
+-- Documents table schema (matches actual production database)
+CREATE TABLE `documents` (
+ `id` int(11) NOT NULL AUTO_INCREMENT,
+ `type` enum('quote','invoice','purchaseOrder','orderAck','packingList') NOT NULL,
+ `created` datetime NOT NULL,
+ `user_id` int(11) NOT NULL,
+ `doc_page_count` int(11) NOT NULL,
+ `cmc_reference` varchar(255) NOT NULL COMMENT 'Either the Enquiry number, Invoice no, order ack. Convenient place to store this to save on queries',
+ `pdf_filename` varchar(255) NOT NULL,
+ `pdf_created_at` datetime NOT NULL,
+ `pdf_created_by_user_id` int(11) NOT NULL,
+ `shipping_details` text DEFAULT NULL,
+ `revision` int(11) NOT NULL DEFAULT 0,
+ `bill_to` text DEFAULT NULL,
+ `ship_to` text DEFAULT NULL,
+ `email_sent_at` datetime NOT NULL,
+ `email_sent_by_user_id` int(11) NOT NULL,
+ PRIMARY KEY (`id`),
+ KEY `idx_documents_type` (`type`),
+ KEY `idx_documents_created` (`created`),
+ KEY `idx_documents_user_id` (`user_id`),
+ KEY `idx_documents_cmc_reference` (`cmc_reference`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
\ No newline at end of file
diff --git a/go-app/sql/schema/enquiries.sql b/go-app/sql/schema/enquiries.sql
index 2df83c31..b99b0317 100644
--- a/go-app/sql/schema/enquiries.sql
+++ b/go-app/sql/schema/enquiries.sql
@@ -1,5 +1,5 @@
-- Enquiries table schema
-CREATE TABLE `enquiries` (
+CREATE TABLE IF NOT EXISTS `enquiries` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`created` datetime NOT NULL,
`submitted` date DEFAULT NULL,
@@ -24,34 +24,4 @@ CREATE TABLE `enquiries` (
`quote_count` int(11) NOT NULL DEFAULT 0,
`archived` tinyint(4) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`)
-) ENGINE=MyISAM AUTO_INCREMENT=17101 DEFAULT CHARSET=latin1 COLLATE=latin1_swedish_ci;
-
--- Related tables for foreign key references
-CREATE TABLE `statuses` (
- `id` int(11) NOT NULL AUTO_INCREMENT,
- `name` varchar(255) NOT NULL,
- `color` varchar(7) DEFAULT NULL,
- PRIMARY KEY (`id`)
-) ENGINE=MyISAM DEFAULT CHARSET=latin1 COLLATE=latin1_swedish_ci;
-
-CREATE TABLE `principles` (
- `id` int(11) NOT NULL AUTO_INCREMENT,
- `name` varchar(255) NOT NULL,
- `short_name` varchar(50) DEFAULT NULL,
- `code` int(3) NOT NULL,
- PRIMARY KEY (`id`)
-) ENGINE=MyISAM DEFAULT CHARSET=latin1 COLLATE=latin1_swedish_ci;
-
-CREATE TABLE `states` (
- `id` int(11) NOT NULL AUTO_INCREMENT,
- `name` varchar(100) NOT NULL,
- `shortform` varchar(20) DEFAULT NULL,
- `enqform` varchar(10) DEFAULT NULL,
- PRIMARY KEY (`id`)
-) ENGINE=MyISAM DEFAULT CHARSET=latin1 COLLATE=latin1_swedish_ci;
-
-CREATE TABLE `countries` (
- `id` int(11) NOT NULL AUTO_INCREMENT,
- `name` varchar(100) NOT NULL,
- PRIMARY KEY (`id`)
-) ENGINE=MyISAM DEFAULT CHARSET=latin1 COLLATE=latin1_swedish_ci;
\ No newline at end of file
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
\ No newline at end of file
diff --git a/go-app/static/images/cmclogosmall.png b/go-app/static/images/cmclogosmall.png
new file mode 100644
index 00000000..3c1e206e
Binary files /dev/null and b/go-app/static/images/cmclogosmall.png differ
diff --git a/go-app/templates/documents/index.html b/go-app/templates/documents/index.html
new file mode 100644
index 00000000..57586388
--- /dev/null
+++ b/go-app/templates/documents/index.html
@@ -0,0 +1,65 @@
+{{define "content"}}
+
+
+
+
Documents
+
Manage your documents (Quotes, Invoices, Purchase Orders, etc.)
+
+
+
+
+
+
+ All Types
+ Quotes
+ Invoices
+ Purchase Orders
+ Order Acknowledgements
+ Packing Lists
+
+
+
+
+
+
+
+
+
+
+ {{template "document-table" .}}
+
+
+
+
+{{end}}
\ No newline at end of file
diff --git a/go-app/templates/documents/invoice-view.html b/go-app/templates/documents/invoice-view.html
new file mode 100644
index 00000000..4e4f2571
--- /dev/null
+++ b/go-app/templates/documents/invoice-view.html
@@ -0,0 +1,175 @@
+{{define "document-invoice-view"}}
+
+
+
+
+ Invoice: {{.Document.CmcReference}}
+
+
+ Created {{.Document.Created.Format "2 January 2006"}}
+
+
+
+
+ {{if .ShowPaymentButton}}
+
+
+
+
+ Enter Payment
+
+ {{end}}
+
+
+
+
+ Generate PDF
+
+
+
+
+
+ Email Invoice
+
+
+
+
+
+
+
+
+
+
+
+ Invoice Number:
+ {{.Document.CmcReference}}
+
+
+ Created By:
+
+ {{if and .Document.UserFirstName.Valid .Document.UserLastName.Valid}}
+ {{.Document.UserFirstName.String}} {{.Document.UserLastName.String}}
+ {{else if .Document.UserUsername.Valid}}
+ {{.Document.UserUsername.String}}
+ {{else}}
+ User #{{.Document.UserID}}
+ {{end}}
+
+
+
+ Status:
+ Invoice
+
+
+
+
+
+
+
Operations
+
+
+
+
+
+ Create Packing List
+
+
+
+
+
+
+
+
+
+
+
+
+{{end}}
\ No newline at end of file
diff --git a/go-app/templates/documents/orderack-view.html b/go-app/templates/documents/orderack-view.html
new file mode 100644
index 00000000..d45bca03
--- /dev/null
+++ b/go-app/templates/documents/orderack-view.html
@@ -0,0 +1,187 @@
+{{define "document-orderack-view"}}
+
+
+
+
+ Order Acknowledgement: {{.Document.CmcReference}}
+
+
+ Created {{.Document.Created.Format "2 January 2006"}}
+
+
+
+
+
+
+
+
+ Generate PDF
+
+
+
+
+
+ Email Order Ack
+
+
+
+
+
+
+
+
+
+
+
+ Order Ack Number:
+ {{.Document.CmcReference}}
+
+
+ Created By:
+
+ {{if and .Document.UserFirstName.Valid .Document.UserLastName.Valid}}
+ {{.Document.UserFirstName.String}} {{.Document.UserLastName.String}}
+ {{else if .Document.UserUsername.Valid}}
+ {{.Document.UserUsername.String}}
+ {{else}}
+ User #{{.Document.UserID}}
+ {{end}}
+
+
+
+ Status:
+ Order Acknowledgement
+
+
+
+
+
+
+
Operations
+
+
+
+
+
+ Create Invoice
+
+
+
+
+
+ Create Packing List
+
+
+
+
+
+
+
+
+
+
Order Acknowledgement Details
+
+
+
+
+
Currency
+
+
+
+ AUD
+ USD
+ EUR
+
+
+
+
+
+
+
+
+
Estimated Delivery
+
+
+
+
+
+
+
+
+
+
+
+
+
Shipping Details
+
+ {{.Document.ShippingDetails.String}}
+
+
+
+
+
+ Save Order Ack Details
+
+
+
+
+
+
+
+{{end}}
\ No newline at end of file
diff --git a/go-app/templates/documents/packinglist-view.html b/go-app/templates/documents/packinglist-view.html
new file mode 100644
index 00000000..9fd8f7fd
--- /dev/null
+++ b/go-app/templates/documents/packinglist-view.html
@@ -0,0 +1,132 @@
+{{define "document-packinglist-view"}}
+
+
+
+
+ Packing List: {{.Document.CmcReference}}
+
+
+ Created {{.Document.Created.Format "2 January 2006"}}
+
+
+
+
+
+
+
+
+ Generate PDF
+
+
+
+
+
+ Email Packing List
+
+
+
+
+
+
+
+
+
+
+
+ Packing List Number:
+ {{.Document.CmcReference}}
+
+
+ Created By:
+
+ {{if and .Document.UserFirstName.Valid .Document.UserLastName.Valid}}
+ {{.Document.UserFirstName.String}} {{.Document.UserLastName.String}}
+ {{else if .Document.UserUsername.Valid}}
+ {{.Document.UserUsername.String}}
+ {{else}}
+ User #{{.Document.UserID}}
+ {{end}}
+
+
+
+ Status:
+ Packing List
+
+
+
+
+
+
+
+
+
+
Packing List Details
+
+
+
+
+
Currency
+
+
+
+ AUD
+ USD
+ EUR
+
+
+
+
+
+
+
+
+
+
+
+
+
Shipping Details
+
+ {{.Document.ShippingDetails.String}}
+
+
+
+
+
+ Save Packing List Details
+
+
+
+
+
+{{end}}
\ No newline at end of file
diff --git a/go-app/templates/documents/purchase-order-view.html b/go-app/templates/documents/purchase-order-view.html
new file mode 100644
index 00000000..1695a64c
--- /dev/null
+++ b/go-app/templates/documents/purchase-order-view.html
@@ -0,0 +1,185 @@
+{{define "document-purchase-order-view"}}
+
+
+
+
+ Purchase Order: {{.Document.CmcReference}}
+
+
+ Created {{.Document.Created.Format "2 January 2006"}}
+
+
+
+
+
+
+
+
+ Generate PDF
+
+
+
+
+
+ Email PO
+
+
+
+
+
+
+
+
+
+
+
+ PO Number:
+ {{.Document.CmcReference}}
+
+
+ Created By:
+
+ {{if and .Document.UserFirstName.Valid .Document.UserLastName.Valid}}
+ {{.Document.UserFirstName.String}} {{.Document.UserLastName.String}}
+ {{else if .Document.UserUsername.Valid}}
+ {{.Document.UserUsername.String}}
+ {{else}}
+ User #{{.Document.UserID}}
+ {{end}}
+
+
+
+ Status:
+ Purchase Order
+
+
+
+
+
+
+
+
+
+
Purchase Order Details
+
+
+
+
+
Principle
+
+
+
+ Select Principle
+
+
+
+
+
+
+
+
Principle Reference
+
+
+
+
+
+
+
+
+
+
+
+
+
Currency
+
+
+
+ AUD
+ USD
+ EUR
+
+
+
+
+
+
+
+
+
+
+
+
+
Freight Forwarder
+
+
+
+ Select Freight Forwarder
+ DHL
+ FedEx
+ TNT
+ Other
+
+
+
+
+
+
+
Shipping Instructions
+
+ {{.Document.ShippingDetails.String}}
+
+
+
+
+
+
+
+{{end}}
\ No newline at end of file
diff --git a/go-app/templates/documents/quote-view.html b/go-app/templates/documents/quote-view.html
new file mode 100644
index 00000000..e40239bc
--- /dev/null
+++ b/go-app/templates/documents/quote-view.html
@@ -0,0 +1,119 @@
+{{define "document-quote-view"}}
+
+
+
+
+ Quote: {{.Document.CmcReference}}
+ {{if gt .Document.Revision 0}}
+ Rev {{.Document.Revision}}
+ {{end}}
+
+
+ Created {{.Document.Created.Format "2 January 2006"}}
+
+
+
+
+
+
+
+
+ Generate PDF
+
+
+
+
+
+ Email Quote
+
+
+
+
+
+
+
+
+
+
+
+ Enquiry:
+
+ {{if .EnquiryTitle}}
+ {{.EnquiryTitle}}
+ {{else}}
+ {{.Document.CmcReference}}
+ {{end}}
+
+
+
+ Created By:
+
+ {{if and .Document.UserFirstName.Valid .Document.UserLastName.Valid}}
+ {{.Document.UserFirstName.String}} {{.Document.UserLastName.String}}
+ {{else if .Document.UserUsername.Valid}}
+ {{.Document.UserUsername.String}}
+ {{else}}
+ User #{{.Document.UserID}}
+ {{end}}
+
+
+
+ Status:
+ Quote
+
+
+
+
+
+
+
Operations
+
+
+
+
+
+ Create Order Acknowledgement
+
+
+
+
+
+ Create Invoice
+
+
+
+
+
+
+
+
+
+
Quote Details
+
+
Quote page content and details would be displayed here.
+
+
+
+
+ Edit Quote Details
+
+
+
+
+
+
+{{end}}
\ No newline at end of file
diff --git a/go-app/templates/documents/show.html b/go-app/templates/documents/show.html
new file mode 100644
index 00000000..70261979
--- /dev/null
+++ b/go-app/templates/documents/show.html
@@ -0,0 +1,144 @@
+{{define "content"}}
+
+
+
+
+
+
+
+
Document #{{.Document.ID}}
+
+
+ {{if eq .Document.Type "quote"}}Quote
+ {{else if eq .Document.Type "invoice"}}Invoice
+ {{else if eq .Document.Type "purchaseOrder"}}Purchase Order
+ {{else if eq .Document.Type "orderAck"}}Order Acknowledgement
+ {{else if eq .Document.Type "packingList"}}Packing List
+ {{else}}{{.Document.Type}}{{end}}
+
+
+
+
+
+
+
+
+
+
Document Information
+
+
+
+
+
+ {{if .Document.PdfFilename}}
+
+ {{end}}
+
+ {{if or .Document.CustomerName .Document.EnquiryTitle.Valid}}
+
+
Related Information
+
+
+ {{if .Document.CustomerName}}
+
+ Customer
+ {{.Document.CustomerName}}
+
+ {{end}}
+ {{if .Document.EnquiryTitle.Valid}}
+
+ Enquiry
+ {{.Document.EnquiryTitle.String}}
+
+ {{end}}
+
+ CMC Reference
+ {{.Document.CmcReference}}
+
+
+ Revision
+ {{.Document.Revision}}
+
+
+
+
+ {{end}}
+
+
+
+{{end}}
\ No newline at end of file
diff --git a/go-app/templates/documents/table.html b/go-app/templates/documents/table.html
new file mode 100644
index 00000000..e8af2388
--- /dev/null
+++ b/go-app/templates/documents/table.html
@@ -0,0 +1,123 @@
+{{define "document-table"}}
+
+
+
+
+ Created
+ User
+ Type
+ #Pages
+ PDF Filename
+ PDF Created
+ PDF Created By
+ Reference
+ Actions
+
+
+
+ {{range .Documents}}
+
+
+
+ {{.Created.Format "2 Jan 2006 15:04"}}
+
+
+
+ {{if and .UserFirstName.Valid .UserLastName.Valid}}
+
+ {{.UserFirstName.String}} {{.UserLastName.String}}
+
+ {{else if .UserUsername.Valid}}
+
+ {{.UserUsername.String}}
+
+ {{else}}
+ Unknown User
+ {{end}}
+
+
+
+ {{if eq .Type "quote"}}Quote
+ {{else if eq .Type "invoice"}}Invoice
+ {{else if eq .Type "purchaseOrder"}}Purchase Order
+ {{else if eq .Type "orderAck"}}Order Acknowledgement
+ {{else if eq .Type "packingList"}}Packing List
+ {{else}}{{.Type}}{{end}}
+
+
+
+ {{if .DocPageCount}}
+ {{.DocPageCount}}
+ {{else}}
+ -
+ {{end}}
+
+
+ {{if .PdfFilename}}
+
+ {{.PdfFilename}}
+
+ {{else}}
+ No PDF
+ {{end}}
+
+
+
+ {{.PdfCreatedAt.Format "2 Jan 2006 15:04"}}
+
+
+
+ {{if and .PdfCreatorFirstName.Valid .PdfCreatorLastName.Valid}}
+
+ {{.PdfCreatorFirstName.String}} {{.PdfCreatorLastName.String}}
+
+ {{else if .PdfCreatorUsername.Valid}}
+
+ {{.PdfCreatorUsername.String}}
+
+ {{else}}
+ -
+ {{end}}
+
+
+ {{if .CmcReference}}
+ {{.CmcReference}}
+ {{else}}
+ -
+ {{end}}
+
+
+
+
+
+ {{else}}
+
+
+
+
+
+
+
No documents found
+
+
+
+ {{end}}
+
+
+
+
+{{if .Documents}}
+
+
+ Showing most recent 1000 documents
+
+
+{{end}}
+{{end}}
\ No newline at end of file
diff --git a/go-app/templates/documents/view.html b/go-app/templates/documents/view.html
new file mode 100644
index 00000000..276fa6ef
--- /dev/null
+++ b/go-app/templates/documents/view.html
@@ -0,0 +1,502 @@
+{{define "content"}}
+
+
+
+
+ {{if eq .DocType "quote"}}
+ {{template "document-quote-view" .}}
+ {{else if eq .DocType "invoice"}}
+ {{template "document-invoice-view" .}}
+ {{else if eq .DocType "purchaseOrder"}}
+ {{template "document-purchase-order-view" .}}
+ {{else if eq .DocType "orderAck"}}
+ {{template "document-orderack-view" .}}
+ {{else if eq .DocType "packingList"}}
+ {{template "document-packinglist-view" .}}
+ {{else}}
+
+ Unknown document type: {{.DocType}}
+
+ {{end}}
+
+
+
+
Line Items
+
+
+
+
+ Item #
+ Title
+ Description
+ Quantity
+ Unit Price
+ Total Price
+ Actions
+
+
+
+ {{if .LineItems}}
+ {{range .LineItems}}
+
+ {{.ItemNumber}}
+ {{.Title}}
+ {{.Description}}
+ {{.Quantity}}
+
+ {{if .GrossUnitPrice.Valid}}
+ ${{.GrossUnitPrice.String}}
+ {{else if .UnitPriceString.Valid}}
+ {{.UnitPriceString.String}}
+ {{else}}
+ -
+ {{end}}
+
+
+ {{if .GrossPrice.Valid}}
+ ${{.GrossPrice.String}}
+ {{else if .GrossPriceString.Valid}}
+ {{.GrossPriceString.String}}
+ {{else}}
+ -
+ {{end}}
+
+
+
+
+
+
+ Edit
+
+
+
+
+
+ Delete
+
+
+
+ {{end}}
+ {{else}}
+
+
+ No line items yet
+
+
+ {{end}}
+
+
+
+
+
+
+
+ Add Line Item
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Optional item
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Optional item
+
+
+
+
+
+
+
+
+
+
+
+
Attachments
+
+
+
+
+ Filename
+ Name
+ Description
+ Size
+ Actions
+
+
+
+
+
+ No attachments yet
+
+
+
+
+
+
+
+
+
+ Add Attachment
+
+
+
+
+
+{{end}}
\ No newline at end of file
diff --git a/go-app/templates/index.html b/go-app/templates/index.html
index 06408571..b89f3449 100644
--- a/go-app/templates/index.html
+++ b/go-app/templates/index.html
@@ -106,6 +106,51 @@
+
+
Recent Activity
diff --git a/start-development.sh b/start-development.sh
new file mode 100755
index 00000000..4d252285
--- /dev/null
+++ b/start-development.sh
@@ -0,0 +1,125 @@
+#!/bin/bash
+
+# CMC Sales Development Environment Setup Script
+set -e
+
+# Colors for output
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+NC='\033[0m' # No Color
+
+echo -e "${BLUE}đ Starting CMC Sales Development Environment${NC}"
+echo ""
+
+# Function to check if command exists
+command_exists() {
+ command -v "$1" >/dev/null 2>&1
+}
+
+# Check prerequisites
+echo -e "${YELLOW}đ Checking prerequisites...${NC}"
+
+if ! command_exists docker; then
+ echo -e "${RED}â Docker is not installed${NC}"
+ exit 1
+fi
+
+if ! command_exists docker-compose; then
+ echo -e "${RED}â Docker Compose is not installed${NC}"
+ exit 1
+fi
+
+echo -e "${GREEN}â
Prerequisites satisfied${NC}"
+echo ""
+
+# Check Docker Compose version for watch support
+DOCKER_COMPOSE_VERSION=$(docker compose version --short 2>/dev/null || echo "unknown")
+SUPPORTS_WATCH=false
+
+if [[ "$DOCKER_COMPOSE_VERSION" != "unknown" ]]; then
+ # Check if version is 2.22.0 or higher (when watch was introduced)
+ if docker compose version 2>/dev/null | grep -q "Docker Compose version v2\.\([2-9][2-9]\|[3-9][0-9]\)"; then
+ SUPPORTS_WATCH=true
+ fi
+fi
+
+echo ""
+echo -e "${YELLOW}đī¸ Building and starting services...${NC}"
+
+# Ask user about watch mode
+if [[ "$SUPPORTS_WATCH" == "true" ]]; then
+ echo -e "${BLUE}đĄ Docker Compose watch mode is available for automatic rebuilds on file changes.${NC}"
+ read -p "Would you like to enable watch mode? (Y/n): " enable_watch
+ if [[ ! $enable_watch =~ ^[Nn]$ ]]; then
+ USE_WATCH=true
+ else
+ USE_WATCH=false
+ fi
+else
+ echo -e "${YELLOW}â ī¸ Docker Compose watch mode not available (requires v2.22.0+)${NC}"
+ USE_WATCH=false
+fi
+
+if [[ "$USE_WATCH" == "true" ]]; then
+ echo -e "${GREEN}đ Starting services with watch mode enabled...${NC}"
+ echo -e "${BLUE} đ Watching for changes in: go-app/, app/webroot/css/, app/webroot/js/${NC}"
+ echo -e "${BLUE} đ Services will automatically rebuild on file changes${NC}"
+ echo -e "${BLUE} âšī¸ Press Ctrl+C to stop all services${NC}"
+ echo ""
+
+ # Start with watch mode (this will run in foreground)
+ docker compose up --build --watch
+else
+ # Traditional mode
+ docker compose up --build -d
+
+ echo ""
+ echo -e "${YELLOW}âŗ Waiting for services to be ready...${NC}"
+ sleep 10
+
+ # Check service health
+ echo -e "${YELLOW}đĨ Checking service health...${NC}"
+
+ # Check database
+ if docker compose exec -T db mysqladmin ping -h localhost -u cmc -p'xVRQI&cA?7AU=hqJ!%au' --silent; then
+ echo -e "${GREEN}â
Database is ready${NC}"
+ else
+ echo -e "${RED}â Database is not ready${NC}"
+ fi
+
+ # Check Go app
+ if curl -s http://localhost:8080/api/v1/health > /dev/null; then
+ echo -e "${GREEN}â
Go application is ready${NC}"
+ else
+ echo -e "${YELLOW}â ī¸ Go application might still be starting...${NC}"
+ fi
+
+ echo ""
+ echo -e "${GREEN}đ Development environment is running!${NC}"
+ echo ""
+ echo -e "${BLUE}đą Access your applications:${NC}"
+ echo -e " đī¸ CakePHP (Legacy): ${GREEN}http://cmclocal${NC} (add to /etc/hosts: 127.0.0.1 cmclocal)"
+ echo -e " đ Go (Modern): ${GREEN}http://localhost:8080${NC}"
+ echo -e " đī¸ Database: ${GREEN}localhost:3306${NC} (user: cmc, pass: xVRQI&cA?7AU=hqJ!%au)"
+ echo ""
+ echo -e "${BLUE}đ§ Useful commands:${NC}"
+ echo -e " View logs: ${YELLOW}docker compose logs -f${NC}"
+ echo -e " Stop services: ${YELLOW}docker compose down${NC}"
+ echo -e " Restart Go app: ${YELLOW}docker compose restart cmc-go${NC}"
+ echo -e " Restart PHP app: ${YELLOW}docker compose restart cmc-php${NC}"
+ echo -e " Enable watch mode: ${YELLOW}docker compose up --watch${NC}"
+ echo ""
+ echo -e "${BLUE}đ Documentation:${NC}"
+ echo -e " Go app README: ${YELLOW}go-app/README.md${NC}"
+ echo -e " Project guide: ${YELLOW}CLAUDE.md${NC}"
+ echo ""
+
+ # Optional: Follow logs
+ read -p "Would you like to follow the logs? (y/N): " follow_logs
+ if [[ $follow_logs =~ ^[Yy]$ ]]; then
+ echo -e "${YELLOW}đ Following logs (Ctrl+C to exit)...${NC}"
+ docker compose logs -f
+ fi
+fi
\ No newline at end of file