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("")) + 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(` +
+

Address %s

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ `, 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 + + + + + `, + 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.)

+
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+ {{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}} + + {{end}} + + +
+
+
+ +
+
+
+ + + + + + + + + + + + + + + +
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

+
+ +
+
+
+
+
+ + +
+

Invoice Details

+
+
+
+
+ +
+
+ +
+
+
+
+
+
+ +
+ +
+
+
+
+
+ +
+ +
+
+
+
+ +
+
+
+ +
+ +
+
+
+
+
+ +
+ +
+
+
+
+
+ +
+ +
+
+
+
+ +
+ +
+ +
+
+ +
+
+ +
+
+
+
+
+ + +{{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"}} +

+
+
+
+ + +
+
+
+ +
+
+
+ + + + + + + + + + + + + + + +
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

+
+ + +
+
+
+
+
+ + +
+

Order Acknowledgement 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"}} +

+
+
+
+ + +
+
+
+ +
+
+
+ + + + + + + + + + + + + + + +
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

+
+
+
+
+ +
+
+ +
+
+
+
+
+
+ +
+ +
+
+
+
+
+ +
+ +
+
+
+
+ +
+
+
+ +
+ +
+
+
+
+
+ +
+ +
+
+
+
+ +
+ +
+ +
+
+ +
+
+ +
+
+
+
+
+{{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"}} +

+
+
+
+ + +
+
+
+ +
+
+
+ + + + + + + + + + + + + + + +
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

+
+
+
+
+ +
+
+ +
+
+
+
+
+
+ +
+ +
+
+
+
+ +
+ +
+ +
+
+ +
+
+
+ +
+
+ +
+
+
+
+
+
+ +
+ +
+
+
+
+
+ +
+ +
+
+
+
+ +
+
+
+ +
+ +
+
+
+
+
+ +
+ +
+
+
+
+
+ +
+ +
+
+
+
+ +
+ +
+
+ +
+
+
+ +
+ +
+ +
+
+ +
+
+ +
+
+
+
+
+{{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"}} +

+
+
+
+ + +
+
+
+ +
+
+
+ + + + + + + + + + + + + + + +
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

+
+ + +
+
+
+
+
+ + +
+

Quote Details

+
+

Quote page content and details would be displayed here.

+ +
+
+
+ + +{{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}} + +

+
+
+ {{if .Document.PdfFilename}} + + + + + View PDF + + {{end}} +
+
+ +
+
+
+

Document Information

+ + + + + + + + + + + + + + + + + + + + + + + +
ID{{.Document.ID}}
Type{{.Document.Type}}
Created{{.Document.Created.Format "2 January 2006 at 15:04"}}
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}} + Unknown User + {{end}} +
Page Count{{.Document.DocPageCount}}
+
+
+ +
+ {{if .Document.PdfFilename}} +
+

PDF Information

+ + + + + + + + + + + {{if and .Document.PdfCreatorFirstName.Valid .Document.PdfCreatorLastName.Valid}} + + + + + {{end}} + +
Filename + + {{.Document.PdfFilename}} + +
PDF Created{{.Document.PdfCreatedAt.Format "2 January 2006 at 15:04"}}
PDF Created By + + {{.Document.PdfCreatorFirstName.String}} {{.Document.PdfCreatorLastName.String}} + +
+
+ {{end}} + + {{if or .Document.CustomerName .Document.EnquiryTitle.Valid}} +
+

Related Information

+ + + {{if .Document.CustomerName}} + + + + + {{end}} + {{if .Document.EnquiryTitle.Valid}} + + + + + {{end}} + + + + + + + + + +
Customer{{.Document.CustomerName}}
Enquiry{{.Document.EnquiryTitle.String}}
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"}} +
+ + + + + + + + + + + + + + + + {{range .Documents}} + + + + + + + + + + + + {{else}} + + + + {{end}} + +
CreatedUserType#PagesPDF FilenamePDF CreatedPDF Created ByReferenceActions
+ + + {{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}} + + + + {{if and .PdfCreatorFirstName.Valid .PdfCreatorLastName.Valid}} + + {{.PdfCreatorFirstName.String}} {{.PdfCreatorLastName.String}} + + {{else if .PdfCreatorUsername.Valid}} + + {{.PdfCreatorUsername.String}} + + {{else}} + - + {{end}} + + {{if .CmcReference}} + {{.CmcReference}} + {{else}} + - + {{end}} + + +
+
+ + + +

No documents found

+
+
+
+ +{{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

+
+ + + + + + + + + + + + + + {{if .LineItems}} + {{range .LineItems}} + + + + + + + + + + {{end}} + {{else}} + + + + {{end}} + +
Item #TitleDescriptionQuantityUnit PriceTotal PriceActions
{{.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}} + + + +
+ No line items yet +
+
+ +
+ + + + + + + + +
+

Attachments

+
+ + + + + + + + + + + + + + + +
FilenameNameDescriptionSizeActions
+ No attachments yet +
+
+ +
+
+ + +{{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 @@ +
+ +
+
+
+
+
+ + + +
+
+

Documents

+

Quotes, invoices, and more

+
+
+ +
+
+
+ + +
+
+
+
+
+ + + +
+
+

More Coming

+

Additional features planned

+
+
+
+
+
+
+

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