Add the new go app

This commit is contained in:
Karl Cordes 2025-06-24 20:32:28 +10:00
parent d8b43cc9c7
commit e3442c29cc
57 changed files with 7697 additions and 40 deletions

122
CLAUDE.md Normal file
View file

@ -0,0 +1,122 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Application Overview
CMC Sales is a B2B sales management system for CMC Technologies. The codebase consists of:
- **Legacy CakePHP 1.2.5 application** (2008-era) - Primary business logic
- **Modern Go API** (in `/go-app/`) - New development using sqlc and Gorilla Mux
**Note**: Documentation also references a Django application that is not present in the current codebase.
## Development Commands
### Docker Development
```bash
# Start all services (both CakePHP and Go applications)
docker compose up
# Start specific services
docker compose up cmc-php # CakePHP app only
docker compose up cmc-go # Go app only
docker compose up db # Database only
# Restore database from backup (get backups via rsync first)
rsync -avz --progress cmc@sales.cmctechnologies.com.au:~/backups .
gunzip < backups/backup_*.sql.gz | mariadb -h 127.0.0.1 -u cmc -p cmc
# Access development sites (add to /etc/hosts: 127.0.0.1 cmclocal)
# http://cmclocal (CakePHP legacy app via nginx)
# http://localhost:8080 (Go modern app direct access)
```
### Testing
```bash
# CakePHP has minimal test coverage
# Test files located in /app/tests/
# Uses SimpleTest framework
```
### Go Application Development
```bash
# Navigate to Go app directory
cd go-app
# Configure private module access (first time only)
go env -w GOPRIVATE=code.springupsoftware.com
# Install dependencies and sqlc
make install
# Generate sqlc code from SQL queries
make sqlc
# Run the Go server locally
make run
# Run tests
make test
# Build binary
make build
```
## Architecture
### CakePHP Application (Legacy)
- **Framework**: CakePHP 1.2.5 (MVC pattern)
- **PHP Version**: PHP 5 (Ubuntu Lucid 10.04 container)
- **Location**: `/app/`
- **Key Directories**:
- `app/models/` - ActiveRecord models
- `app/controllers/` - Request handlers
- `app/views/` - Templates (.ctp files)
- `app/config/` - Configuration including database.php
- `app/vendors/` - Third-party libraries (TCPDF for PDFs, PHPExcel)
- `app/webroot/` - Public assets and file uploads
### Go Application (Modern)
- **Framework**: Gorilla Mux (HTTP router)
- **Database**: sqlc for type-safe SQL queries
- **Location**: `/go-app/`
- **Structure**:
- `cmd/server/` - Main application entry point
- `internal/cmc/handlers/` - HTTP request handlers
- `internal/cmc/db/` - Generated sqlc code
- `sql/queries/` - SQL query definitions
- `sql/schema/` - Database schema
- **API Base Path**: `/api/v1`
### Database
- **Engine**: MariaDB
- **Host**: `db` (Docker service) or `127.0.0.1` locally
- **Name**: `cmc`
- **User**: `cmc`
- **Password**: `xVRQI&cA?7AU=hqJ!%au` (hardcoded in `app/config/database.php`)
## Key Business Entities
- **Customers** - Client companies with ABN validation
- **Purchase Orders** - Orders with suppliers (revision tracking enabled)
- **Quotes** - Customer price quotations
- **Invoices** - Billing documents
- **Products** - Catalog with categories and options
- **Shipments** - Logistics and freight management
- **Jobs** - Project tracking
- **Documents** - PDF generation via TCPDF
- **Emails** - Correspondence tracking
## File Storage
- **PDF files**: `/app/webroot/pdf/` (mounted volume)
- **Attachments**: `/app/webroot/attachments_files/` (mounted volume)
## Critical Considerations
- **Security**: Running on extremely outdated Ubuntu Lucid (10.04) with PHP 5
- **Database Changes**: Exercise caution - the database may be shared with other applications
- **CakePHP Conventions**: Uses 2008-era patterns and practices
- **Authentication**: Basic auth configured via `userpasswd` file
- **PDF Generation**: Uses TCPDF library in `/app/vendors/tcpdf/`

50
Dockerfile.go Normal file
View file

@ -0,0 +1,50 @@
# Build stage
FROM golang:1.23-alpine AS builder
# Install build dependencies
RUN apk add --no-cache git
# Set working directory
WORKDIR /app
# Copy go mod files
COPY go-app/go.mod go-app/go.sum ./
# Download dependencies
RUN go mod download
# Copy source code
COPY go-app/ .
# Install sqlc (compatible with Go 1.23+)
RUN go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest
# Generate sqlc code
RUN sqlc generate
# Build the application
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o server cmd/server/main.go
# Runtime stage
FROM alpine:latest
# Install runtime dependencies
RUN apk --no-cache add ca-certificates
WORKDIR /root/
# Copy the binary from builder
COPY --from=builder /app/server .
# Copy templates and static files
COPY go-app/templates ./templates
COPY go-app/static ./static
# Copy .env file if needed
COPY go-app/.env.example .env
# Expose port
EXPOSE 8080
# Run the application
CMD ["./server"]

View file

@ -1,22 +1,50 @@
# cmc-sales
## Development Docker compose instructions
## Development Setup
CMC Sales now runs both legacy CakePHP and modern Go applications side by side.
### Quick Start
``` shell
git clone git@code.springupsoftware.com:cmc/cmc-sales.git
cd cmc-sales
# Easy way - use the setup script
./start-development.sh
# Manual way
rsync -avz --progress cmc@sales.cmctechnologies.com.au:~/backups .
docker compose up
# DB password is in config/database.php because..reasons.
# TODO move it to an environment var and rotate it..
docker compose up --build
gunzip < backups/backup_*.sql.gz | mariadb -h 127.0.0.1 -u cmc -p cmc
# edit your dev machine /etc/hosts and add
127.0.0.1 cmclocal
# open up cmclocal in your browser.. hopefully it'll work.
```
### Access Applications
**Add to /etc/hosts:**
```
127.0.0.1 cmclocal
```
**Application URLs:**
- **CakePHP (Legacy)**: http://cmclocal - Original CakePHP 1.2.5 application
- **Go (Modern)**: http://localhost:8080 - New Go application with HTMX frontend
- **Database**: localhost:3306 (user: `cmc`, password: see `app/config/database.php`)
### Architecture
- **cmc-php**: Legacy CakePHP application (nginx proxied)
- **cmc-go**: Modern Go application (direct access on port 8080)
- **db**: Shared MariaDB database
- **nginx**: Reverse proxy for CakePHP app
Both applications share the same database, allowing for gradual migration.
### Requirements
- **Go Application**: Requires Go 1.23+ (for latest sqlc)
- Alternative: Use `Dockerfile.go.legacy` with Go 1.21 and sqlc v1.26.0
## Install a new server

View file

@ -3,8 +3,12 @@ server {
auth_basic_user_file /etc/nginx/userpasswd;
auth_basic "Restricted";
location / {
proxy_pass http://cmc:80;
proxy_pass http://cmc-php:80;
proxy_read_timeout 300s;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
listen 0.0.0.0:80;

View file

@ -3,21 +3,22 @@ services:
image: nginx:latest
hostname: nginx
ports:
- "80:80" # Expose HTTP traffic
- "80:80" # CakePHP app (cmclocal)
volumes:
- ./conf/nginx-site.conf:/etc/nginx/conf.d/cmc.conf
# todo setup site config.
- ./userpasswd:/etc/nginx/userpasswd:ro
depends_on:
- cmc-php
restart: unless-stopped
networks:
- cmc-network
cmc:
cmc-php:
build:
context: .
dockerfile: Dockerfile
platform: linux/amd64
container_name: cmc-php
depends_on:
- db
volumes:
@ -25,6 +26,7 @@ services:
- ./app/webroot/attachments_files:/var/www/cmc-sales/app/webroot/attachments_files
networks:
- cmc-network
restart: unless-stopped
develop:
watch:
- action: rebuild
@ -32,32 +34,6 @@ services:
ignore:
- ./app/webroot
cmc-django: # Renamed service for clarity (optional, but good practice)
build: # Added build section
context: ./cmc-django # Path to the directory containing the Dockerfile
dockerfile: Dockerfile # Name of the Dockerfile
platform: linux/amd64 # Keep platform if needed for compatibility
container_name: cmc-django-web # Optional: Keep or change container name
command: uv run python cmcsales/manage.py runserver 0.0.0.0:8888 # Add command if needed
volumes:
- ./cmc-django:/app # Mount the Django project directory
# Keep other necessary volumes if they exist outside ./cmc-django
# Example: - ./app/webroot/pdf:/app/webroot/pdf # Adjust path if needed
# Example: - ./app/webroot/attachments_files:/app/webroot/attachments_files # Adjust path if needed
ports: # Add ports if the Django app needs to be accessed directly
- "8888:8888"
environment: # Add environment variables needed by Django
DJANGO_SETTINGS_MODULE: cmcsales.settings
DATABASE_HOST: db
DATABASE_PORT: 3306
DATABASE_NAME: cmc
DATABASE_USER: cmc
DATABASE_PASSWORD: xVRQI&cA?7AU=hqJ!%au
depends_on:
- db
networks:
- cmc-network
db:
image: mariadb:latest
container_name: cmc-db
@ -73,6 +49,34 @@ services:
networks:
- cmc-network
cmc-go:
build:
context: .
dockerfile: Dockerfile.go
container_name: cmc-go
environment:
DB_HOST: db
DB_PORT: 3306
DB_USER: cmc
DB_PASSWORD: xVRQI&cA?7AU=hqJ!%au
DB_NAME: cmc
PORT: 8080
depends_on:
db:
condition: service_started
ports:
- "8080:8080"
networks:
- cmc-network
restart: unless-stopped
develop:
watch:
- action: rebuild
path: ./go-app
ignore:
- ./go-app/bin
- ./go-app/.env
volumes:
db_data:

9
go-app/.env.example Normal file
View file

@ -0,0 +1,9 @@
# Database configuration
DB_HOST=localhost
DB_PORT=3306
DB_USER=cmc
DB_PASSWORD=xVRQI&cA?7AU=hqJ!%au
DB_NAME=cmc
# Server configuration
PORT=8080

33
go-app/.gitignore vendored Normal file
View file

@ -0,0 +1,33 @@
# Binaries
bin/
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool
*.out
# Go vendor directory
vendor/
# Environment files
.env
# Generated sqlc files (optional - you may want to commit these)
# internal/cmc/db/*.go
# IDE specific files
.idea/
.vscode/
*.swp
*.swo
*~
# OS specific files
.DS_Store
Thumbs.db

46
go-app/Makefile Normal file
View file

@ -0,0 +1,46 @@
.PHONY: help
help: ## Show this help message
@echo 'Usage: make [target]'
@echo ''
@echo 'Targets:'
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
.PHONY: install
install: ## Install dependencies
@echo "Setting up private module configuration..."
go env -w GOPRIVATE=code.springupsoftware.com
go mod download
go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest
.PHONY: sqlc
sqlc: ## Generate Go code from SQL queries
sqlc generate
.PHONY: build
build: sqlc ## Build the application
go build -o bin/server cmd/server/main.go
.PHONY: run
run: ## Run the application
go run cmd/server/main.go
.PHONY: dev
dev: sqlc ## Run the application with hot reload (requires air)
air
.PHONY: test
test: ## Run tests
go test -v ./...
.PHONY: clean
clean: ## Clean build artifacts
rm -rf bin/
rm -rf internal/cmc/db/*.go
.PHONY: docker-build
docker-build: ## Build Docker image
docker build -t cmc-go:latest -f Dockerfile.go .
.PHONY: docker-run
docker-run: ## Run application in Docker
docker run --rm -p 8080:8080 --network=host cmc-go:latest

123
go-app/README.md Normal file
View file

@ -0,0 +1,123 @@
# CMC Sales Go Application
Modern Go web application for CMC Sales system, designed to work alongside the legacy CakePHP application.
## Architecture
- **Backend**: Go with Gorilla Mux for HTTP routing
- **Database**: sqlc for type-safe SQL queries
- **Frontend**: Go templates with HTMX for dynamic interactions
- **Styling**: Bulma CSS framework
- **Database Driver**: MySQL/MariaDB
## Quick Start
### Local Development
1. Configure private module access (required for code.springupsoftware.com):
```bash
go env -w GOPRIVATE=code.springupsoftware.com
# If using SSH keys with git:
git config --global url."git@code.springupsoftware.com:".insteadOf "https://code.springupsoftware.com/"
```
2. Install dependencies:
```bash
make install
```
3. Copy environment variables:
```bash
cp .env.example .env
```
4. Generate sqlc code:
```bash
make sqlc
```
5. Run the server:
```bash
make run
```
### Docker Development
From the project root:
```bash
# Standard build (requires Go 1.23+)
docker compose up cmc-go
# If you encounter Go version issues, use the legacy build:
# Edit docker-compose.yml to use: dockerfile: Dockerfile.go.legacy
```
## Web Interface
The application provides a modern web interface accessible at:
- **Home**: `/` - Dashboard with quick links
- **Customers**: `/customers` - Customer management interface
- **Products**: `/products` - Product catalog management
- **Purchase Orders**: `/purchase-orders` - Order tracking
### Features
- **Real-time search** with HTMX
- **Responsive design** with Bulma CSS
- **Form validation** and error handling
- **Pagination** for large datasets
- **CRUD operations** with dynamic updates
## API Endpoints
REST API endpoints are available under `/api/v1`:
### Customers
- `GET /api/v1/customers` - List customers (JSON)
- `GET /api/v1/customers/{id}` - Get customer by ID
- `POST /api/v1/customers` - Create new customer
- `PUT /api/v1/customers/{id}` - Update customer
- `DELETE /api/v1/customers/{id}` - Delete customer
- `GET /api/v1/customers/search?q={query}` - Search customers
### Products
- `GET /api/v1/products` - List products (JSON)
- `GET /api/v1/products/{id}` - Get product by ID
- `POST /api/v1/products` - Create new product
- `PUT /api/v1/products/{id}` - Update product
- `DELETE /api/v1/products/{id}` - Delete product
- `GET /api/v1/products/search?q={query}` - Search products
### Purchase Orders
- `GET /api/v1/purchase-orders` - List purchase orders (JSON)
- `GET /api/v1/purchase-orders/{id}` - Get purchase order by ID
- `POST /api/v1/purchase-orders` - Create new purchase order
- `PUT /api/v1/purchase-orders/{id}` - Update purchase order
- `DELETE /api/v1/purchase-orders/{id}` - Delete purchase order
- `GET /api/v1/purchase-orders/search?q={query}` - Search purchase orders
### Health Check
- `GET /api/v1/health` - Service health check
## Development
### Adding New Queries
1. Add SQL queries to `sql/queries/`
2. Run `make sqlc` to generate Go code
3. Create handler in `internal/cmc/handlers/`
4. Register routes in `cmd/server/main.go`
### Testing
```bash
make test
```
### Building
```bash
make build
```
The binary will be output to `bin/server`

176
go-app/cmd/server/main.go Normal file
View file

@ -0,0 +1,176 @@
package main
import (
"database/sql"
"fmt"
"log"
"net/http"
"os"
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db"
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/handlers"
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/templates"
_ "github.com/go-sql-driver/mysql"
"github.com/gorilla/mux"
"github.com/joho/godotenv"
)
func main() {
// Load environment variables
if err := godotenv.Load(); err != nil {
log.Println("No .env file found")
}
// Database configuration
dbHost := getEnv("DB_HOST", "localhost")
dbPort := getEnv("DB_PORT", "3306")
dbUser := getEnv("DB_USER", "cmc")
dbPass := getEnv("DB_PASSWORD", "xVRQI&cA?7AU=hqJ!%au")
dbName := getEnv("DB_NAME", "cmc")
// Connect to database
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?parseTime=true", dbUser, dbPass, dbHost, dbPort, dbName)
database, err := sql.Open("mysql", dsn)
if err != nil {
log.Fatal("Failed to connect to database:", err)
}
defer database.Close()
// Test database connection
if err := database.Ping(); err != nil {
log.Fatal("Failed to ping database:", err)
}
log.Println("Connected to database successfully")
// Create queries instance
queries := db.New(database)
// Initialize template manager
tmpl, err := templates.NewTemplateManager("templates")
if err != nil {
log.Fatal("Failed to initialize templates:", err)
}
// Create handlers
customerHandler := handlers.NewCustomerHandler(queries)
productHandler := handlers.NewProductHandler(queries)
purchaseOrderHandler := handlers.NewPurchaseOrderHandler(queries)
enquiryHandler := handlers.NewEnquiryHandler(queries)
pageHandler := handlers.NewPageHandler(queries, tmpl)
// Setup routes
r := mux.NewRouter()
// Static files
r.PathPrefix("/static/").Handler(http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
// API routes
api := r.PathPrefix("/api/v1").Subrouter()
// Customer routes
api.HandleFunc("/customers", customerHandler.List).Methods("GET")
api.HandleFunc("/customers", customerHandler.Create).Methods("POST")
api.HandleFunc("/customers/{id}", customerHandler.Get).Methods("GET")
api.HandleFunc("/customers/{id}", customerHandler.Update).Methods("PUT")
api.HandleFunc("/customers/{id}", customerHandler.Delete).Methods("DELETE")
api.HandleFunc("/customers/search", customerHandler.Search).Methods("GET")
// Product routes
api.HandleFunc("/products", productHandler.List).Methods("GET")
api.HandleFunc("/products", productHandler.Create).Methods("POST")
api.HandleFunc("/products/{id}", productHandler.Get).Methods("GET")
api.HandleFunc("/products/{id}", productHandler.Update).Methods("PUT")
api.HandleFunc("/products/{id}", productHandler.Delete).Methods("DELETE")
api.HandleFunc("/products/search", productHandler.Search).Methods("GET")
// Purchase Order routes
api.HandleFunc("/purchase-orders", purchaseOrderHandler.List).Methods("GET")
api.HandleFunc("/purchase-orders", purchaseOrderHandler.Create).Methods("POST")
api.HandleFunc("/purchase-orders/{id}", purchaseOrderHandler.Get).Methods("GET")
api.HandleFunc("/purchase-orders/{id}", purchaseOrderHandler.Update).Methods("PUT")
api.HandleFunc("/purchase-orders/{id}", purchaseOrderHandler.Delete).Methods("DELETE")
api.HandleFunc("/purchase-orders/search", purchaseOrderHandler.Search).Methods("GET")
// Enquiry routes
api.HandleFunc("/enquiries", enquiryHandler.List).Methods("GET")
api.HandleFunc("/enquiries", enquiryHandler.Create).Methods("POST")
api.HandleFunc("/enquiries/{id}", enquiryHandler.Get).Methods("GET")
api.HandleFunc("/enquiries/{id}", enquiryHandler.Update).Methods("PUT")
api.HandleFunc("/enquiries/{id}", enquiryHandler.Delete).Methods("DELETE")
api.HandleFunc("/enquiries/{id}/undelete", enquiryHandler.Undelete).Methods("PUT")
api.HandleFunc("/enquiries/{id}/status", enquiryHandler.UpdateStatus).Methods("PUT")
api.HandleFunc("/enquiries/{id}/mark-submitted", enquiryHandler.MarkSubmitted).Methods("PUT")
api.HandleFunc("/enquiries/search", enquiryHandler.Search).Methods("GET")
// Health check
api.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"ok"}`))
}).Methods("GET")
// 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")
r.HandleFunc("/enquiries/search", pageHandler.EnquiriesSearch).Methods("GET")
r.HandleFunc("/enquiries/{id}", pageHandler.EnquiriesShow).Methods("GET")
r.HandleFunc("/enquiries/{id}/edit", pageHandler.EnquiriesEdit).Methods("GET")
// 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")
r.HandleFunc("/enquiries/{id}/undelete", enquiryHandler.Undelete).Methods("PUT")
r.HandleFunc("/enquiries/{id}/status", enquiryHandler.UpdateStatus).Methods("PUT")
r.HandleFunc("/enquiries/{id}/mark-submitted", enquiryHandler.MarkSubmitted).Methods("PUT")
// Start server
port := getEnv("PORT", "8080")
log.Printf("Starting server on port %s", port)
if err := http.ListenAndServe(":"+port, r); err != nil {
log.Fatal("Failed to start server:", err)
}
}
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}

9
go-app/go.mod Normal file
View file

@ -0,0 +1,9 @@
module code.springupsoftware.com/cmc/cmc-sales
go 1.23
require (
github.com/go-sql-driver/mysql v1.7.1
github.com/gorilla/mux v1.8.1
github.com/joho/godotenv v1.5.1
)

6
go-app/go.sum Normal file
View file

@ -0,0 +1,6 @@
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=

View file

@ -0,0 +1,250 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// source: customers.sql
package db
import (
"context"
"database/sql"
)
const createCustomer = `-- name: CreateCustomer :execresult
INSERT INTO customers (
name, trading_name, abn, created, notes,
discount_pricing_policies, payment_terms,
customer_category_id, url, country_id
) VALUES (
?, ?, ?, NOW(), ?, ?, ?, ?, ?, ?
)
`
type CreateCustomerParams struct {
Name string `json:"name"`
TradingName string `json:"trading_name"`
Abn sql.NullString `json:"abn"`
Notes string `json:"notes"`
DiscountPricingPolicies string `json:"discount_pricing_policies"`
PaymentTerms string `json:"payment_terms"`
CustomerCategoryID int32 `json:"customer_category_id"`
Url string `json:"url"`
CountryID int32 `json:"country_id"`
}
func (q *Queries) CreateCustomer(ctx context.Context, arg CreateCustomerParams) (sql.Result, error) {
return q.db.ExecContext(ctx, createCustomer,
arg.Name,
arg.TradingName,
arg.Abn,
arg.Notes,
arg.DiscountPricingPolicies,
arg.PaymentTerms,
arg.CustomerCategoryID,
arg.Url,
arg.CountryID,
)
}
const deleteCustomer = `-- name: DeleteCustomer :exec
DELETE FROM customers
WHERE id = ?
`
func (q *Queries) DeleteCustomer(ctx context.Context, id int32) error {
_, err := q.db.ExecContext(ctx, deleteCustomer, id)
return err
}
const getCustomer = `-- name: GetCustomer :one
SELECT id, name, trading_name, abn, created, notes, discount_pricing_policies, payment_terms, customer_category_id, url, country_id FROM customers
WHERE id = ? LIMIT 1
`
func (q *Queries) GetCustomer(ctx context.Context, id int32) (Customer, error) {
row := q.db.QueryRowContext(ctx, getCustomer, id)
var i Customer
err := row.Scan(
&i.ID,
&i.Name,
&i.TradingName,
&i.Abn,
&i.Created,
&i.Notes,
&i.DiscountPricingPolicies,
&i.PaymentTerms,
&i.CustomerCategoryID,
&i.Url,
&i.CountryID,
)
return i, err
}
const getCustomerByABN = `-- name: GetCustomerByABN :one
SELECT id, name, trading_name, abn, created, notes, discount_pricing_policies, payment_terms, customer_category_id, url, country_id FROM customers
WHERE abn = ?
LIMIT 1
`
func (q *Queries) GetCustomerByABN(ctx context.Context, abn sql.NullString) (Customer, error) {
row := q.db.QueryRowContext(ctx, getCustomerByABN, abn)
var i Customer
err := row.Scan(
&i.ID,
&i.Name,
&i.TradingName,
&i.Abn,
&i.Created,
&i.Notes,
&i.DiscountPricingPolicies,
&i.PaymentTerms,
&i.CustomerCategoryID,
&i.Url,
&i.CountryID,
)
return i, err
}
const listCustomers = `-- name: ListCustomers :many
SELECT id, name, trading_name, abn, created, notes, discount_pricing_policies, payment_terms, customer_category_id, url, country_id FROM customers
ORDER BY name
LIMIT ? OFFSET ?
`
type ListCustomersParams struct {
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
func (q *Queries) ListCustomers(ctx context.Context, arg ListCustomersParams) ([]Customer, error) {
rows, err := q.db.QueryContext(ctx, listCustomers, arg.Limit, arg.Offset)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Customer{}
for rows.Next() {
var i Customer
if err := rows.Scan(
&i.ID,
&i.Name,
&i.TradingName,
&i.Abn,
&i.Created,
&i.Notes,
&i.DiscountPricingPolicies,
&i.PaymentTerms,
&i.CustomerCategoryID,
&i.Url,
&i.CountryID,
); 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 searchCustomersByName = `-- name: SearchCustomersByName :many
SELECT id, name, trading_name, abn, created, notes, discount_pricing_policies, payment_terms, customer_category_id, url, country_id FROM customers
WHERE name LIKE CONCAT('%', ?, '%')
OR trading_name LIKE CONCAT('%', ?, '%')
ORDER BY name
LIMIT ? OFFSET ?
`
type SearchCustomersByNameParams struct {
CONCAT interface{} `json:"CONCAT"`
CONCAT_2 interface{} `json:"CONCAT_2"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
func (q *Queries) SearchCustomersByName(ctx context.Context, arg SearchCustomersByNameParams) ([]Customer, error) {
rows, err := q.db.QueryContext(ctx, searchCustomersByName,
arg.CONCAT,
arg.CONCAT_2,
arg.Limit,
arg.Offset,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Customer{}
for rows.Next() {
var i Customer
if err := rows.Scan(
&i.ID,
&i.Name,
&i.TradingName,
&i.Abn,
&i.Created,
&i.Notes,
&i.DiscountPricingPolicies,
&i.PaymentTerms,
&i.CustomerCategoryID,
&i.Url,
&i.CountryID,
); 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 updateCustomer = `-- name: UpdateCustomer :exec
UPDATE customers
SET name = ?,
trading_name = ?,
abn = ?,
notes = ?,
discount_pricing_policies = ?,
payment_terms = ?,
customer_category_id = ?,
url = ?,
country_id = ?
WHERE id = ?
`
type UpdateCustomerParams struct {
Name string `json:"name"`
TradingName string `json:"trading_name"`
Abn sql.NullString `json:"abn"`
Notes string `json:"notes"`
DiscountPricingPolicies string `json:"discount_pricing_policies"`
PaymentTerms string `json:"payment_terms"`
CustomerCategoryID int32 `json:"customer_category_id"`
Url string `json:"url"`
CountryID int32 `json:"country_id"`
ID int32 `json:"id"`
}
func (q *Queries) UpdateCustomer(ctx context.Context, arg UpdateCustomerParams) error {
_, err := q.db.ExecContext(ctx, updateCustomer,
arg.Name,
arg.TradingName,
arg.Abn,
arg.Notes,
arg.DiscountPricingPolicies,
arg.PaymentTerms,
arg.CustomerCategoryID,
arg.Url,
arg.CountryID,
arg.ID,
)
return err
}

View file

@ -0,0 +1,31 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
package db
import (
"context"
"database/sql"
)
type DBTX interface {
ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
PrepareContext(context.Context, string) (*sql.Stmt, error)
QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
QueryRowContext(context.Context, string, ...interface{}) *sql.Row
}
func New(db DBTX) *Queries {
return &Queries{db: db}
}
type Queries struct {
db DBTX
}
func (q *Queries) WithTx(tx *sql.Tx) *Queries {
return &Queries{
db: tx,
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,237 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
package db
import (
"database/sql"
"database/sql/driver"
"fmt"
"time"
)
type UsersAccessLevel string
const (
UsersAccessLevelAdmin UsersAccessLevel = "admin"
UsersAccessLevelManager UsersAccessLevel = "manager"
UsersAccessLevelUser UsersAccessLevel = "user"
UsersAccessLevelNone UsersAccessLevel = "none"
)
func (e *UsersAccessLevel) Scan(src interface{}) error {
switch s := src.(type) {
case []byte:
*e = UsersAccessLevel(s)
case string:
*e = UsersAccessLevel(s)
default:
return fmt.Errorf("unsupported scan type for UsersAccessLevel: %T", src)
}
return nil
}
type NullUsersAccessLevel struct {
UsersAccessLevel UsersAccessLevel `json:"users_access_level"`
Valid bool `json:"valid"` // Valid is true if UsersAccessLevel is not NULL
}
// Scan implements the Scanner interface.
func (ns *NullUsersAccessLevel) Scan(value interface{}) error {
if value == nil {
ns.UsersAccessLevel, ns.Valid = "", false
return nil
}
ns.Valid = true
return ns.UsersAccessLevel.Scan(value)
}
// Value implements the driver Valuer interface.
func (ns NullUsersAccessLevel) Value() (driver.Value, error) {
if !ns.Valid {
return nil, nil
}
return string(ns.UsersAccessLevel), nil
}
type UsersType string
const (
UsersTypePrinciple UsersType = "principle"
UsersTypeContact UsersType = "contact"
UsersTypeUser UsersType = "user"
)
func (e *UsersType) Scan(src interface{}) error {
switch s := src.(type) {
case []byte:
*e = UsersType(s)
case string:
*e = UsersType(s)
default:
return fmt.Errorf("unsupported scan type for UsersType: %T", src)
}
return nil
}
type NullUsersType struct {
UsersType UsersType `json:"users_type"`
Valid bool `json:"valid"` // Valid is true if UsersType is not NULL
}
// Scan implements the Scanner interface.
func (ns *NullUsersType) Scan(value interface{}) error {
if value == nil {
ns.UsersType, ns.Valid = "", false
return nil
}
ns.Valid = true
return ns.UsersType.Scan(value)
}
// Value implements the driver Valuer interface.
func (ns NullUsersType) Value() (driver.Value, error) {
if !ns.Valid {
return nil, nil
}
return string(ns.UsersType), nil
}
type Country struct {
ID int32 `json:"id"`
Name string `json:"name"`
}
type Customer struct {
ID int32 `json:"id"`
// Company Name
Name string `json:"name"`
TradingName string `json:"trading_name"`
Abn sql.NullString `json:"abn"`
Created time.Time `json:"created"`
Notes string `json:"notes"`
DiscountPricingPolicies string `json:"discount_pricing_policies"`
PaymentTerms string `json:"payment_terms"`
CustomerCategoryID int32 `json:"customer_category_id"`
Url string `json:"url"`
CountryID int32 `json:"country_id"`
}
type Enquiry struct {
ID int32 `json:"id"`
Created time.Time `json:"created"`
Submitted sql.NullTime `json:"submitted"`
// enquirynumber
Title string `json:"title"`
UserID int32 `json:"user_id"`
CustomerID int32 `json:"customer_id"`
ContactID int32 `json:"contact_id"`
ContactUserID int32 `json:"contact_user_id"`
StateID int32 `json:"state_id"`
CountryID int32 `json:"country_id"`
PrincipleID int32 `json:"principle_id"`
StatusID int32 `json:"status_id"`
Comments string `json:"comments"`
// Numeric Principle Code
PrincipleCode int32 `json:"principle_code"`
// GST applicable on this enquiry
Gst bool `json:"gst"`
BillingAddressID sql.NullInt32 `json:"billing_address_id"`
ShippingAddressID sql.NullInt32 `json:"shipping_address_id"`
// has the enquired been posted
Posted bool `json:"posted"`
EmailCount int32 `json:"email_count"`
InvoiceCount int32 `json:"invoice_count"`
JobCount int32 `json:"job_count"`
QuoteCount int32 `json:"quote_count"`
Archived int8 `json:"archived"`
}
type Principle struct {
ID int32 `json:"id"`
Name string `json:"name"`
ShortName sql.NullString `json:"short_name"`
Code int32 `json:"code"`
}
type Product struct {
ID int32 `json:"id"`
// Principle FK
PrincipleID int32 `json:"principle_id"`
ProductCategoryID int32 `json:"product_category_id"`
// This must match the Title in the Excel Costing File
Title string `json:"title"`
Description string `json:"description"`
// Part or model number principle uses to identify this product
ModelNumber sql.NullString `json:"model_number"`
// %1% - first item, %2% , second item etc
ModelNumberFormat sql.NullString `json:"model_number_format"`
// Any notes about this product. Note displayed on quotes
Notes sql.NullString `json:"notes"`
// Stock or Ident
Stock bool `json:"stock"`
ItemCode string `json:"item_code"`
ItemDescription string `json:"item_description"`
}
type PurchaseOrder struct {
ID int32 `json:"id"`
IssueDate time.Time `json:"issue_date"`
DispatchDate time.Time `json:"dispatch_date"`
DateArrived time.Time `json:"date_arrived"`
// CMC PONumber
Title string `json:"title"`
PrincipleID int32 `json:"principle_id"`
PrincipleReference string `json:"principle_reference"`
DocumentID int32 `json:"document_id"`
CurrencyID sql.NullInt32 `json:"currency_id"`
OrderedFrom string `json:"ordered_from"`
Description string `json:"description"`
DispatchBy string `json:"dispatch_by"`
DeliverTo string `json:"deliver_to"`
ShippingInstructions string `json:"shipping_instructions"`
JobsText string `json:"jobs_text"`
FreightForwarderText string `json:"freight_forwarder_text"`
ParentPurchaseOrderID int32 `json:"parent_purchase_order_id"`
}
type State struct {
ID int32 `json:"id"`
Name string `json:"name"`
Shortform sql.NullString `json:"shortform"`
Enqform sql.NullString `json:"enqform"`
}
type Status struct {
ID int32 `json:"id"`
Name string `json:"name"`
Color sql.NullString `json:"color"`
}
type User struct {
ID int32 `json:"id"`
PrincipleID int32 `json:"principle_id"`
CustomerID int32 `json:"customer_id"`
Type UsersType `json:"type"`
AccessLevel UsersAccessLevel `json:"access_level"`
Username string `json:"username"`
Password string `json:"password"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Email string `json:"email"`
JobTitle string `json:"job_title"`
Phone string `json:"phone"`
Mobile string `json:"mobile"`
Fax string `json:"fax"`
PhoneExtension string `json:"phone_extension"`
DirectPhone string `json:"direct_phone"`
Notes string `json:"notes"`
// Added by Vault. May or may not be a real person.
ByVault bool `json:"by_vault"`
// Disregard emails from this address in future.
Blacklisted bool `json:"blacklisted"`
Enabled bool `json:"enabled"`
Archived sql.NullBool `json:"archived"`
PrimaryContact bool `json:"primary_contact"`
}

View file

@ -0,0 +1,296 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// source: products.sql
package db
import (
"context"
"database/sql"
)
const createProduct = `-- name: CreateProduct :execresult
INSERT INTO products (
principle_id, product_category_id, title, description,
model_number, model_number_format, notes, stock,
item_code, item_description
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?
)
`
type CreateProductParams struct {
PrincipleID int32 `json:"principle_id"`
ProductCategoryID int32 `json:"product_category_id"`
Title string `json:"title"`
Description string `json:"description"`
ModelNumber sql.NullString `json:"model_number"`
ModelNumberFormat sql.NullString `json:"model_number_format"`
Notes sql.NullString `json:"notes"`
Stock bool `json:"stock"`
ItemCode string `json:"item_code"`
ItemDescription string `json:"item_description"`
}
func (q *Queries) CreateProduct(ctx context.Context, arg CreateProductParams) (sql.Result, error) {
return q.db.ExecContext(ctx, createProduct,
arg.PrincipleID,
arg.ProductCategoryID,
arg.Title,
arg.Description,
arg.ModelNumber,
arg.ModelNumberFormat,
arg.Notes,
arg.Stock,
arg.ItemCode,
arg.ItemDescription,
)
}
const deleteProduct = `-- name: DeleteProduct :exec
DELETE FROM products
WHERE id = ?
`
func (q *Queries) DeleteProduct(ctx context.Context, id int32) error {
_, err := q.db.ExecContext(ctx, deleteProduct, id)
return err
}
const getProduct = `-- name: GetProduct :one
SELECT id, principle_id, product_category_id, title, description, model_number, model_number_format, notes, stock, item_code, item_description FROM products
WHERE id = ? LIMIT 1
`
func (q *Queries) GetProduct(ctx context.Context, id int32) (Product, error) {
row := q.db.QueryRowContext(ctx, getProduct, id)
var i Product
err := row.Scan(
&i.ID,
&i.PrincipleID,
&i.ProductCategoryID,
&i.Title,
&i.Description,
&i.ModelNumber,
&i.ModelNumberFormat,
&i.Notes,
&i.Stock,
&i.ItemCode,
&i.ItemDescription,
)
return i, err
}
const getProductByItemCode = `-- name: GetProductByItemCode :one
SELECT id, principle_id, product_category_id, title, description, model_number, model_number_format, notes, stock, item_code, item_description FROM products
WHERE item_code = ?
LIMIT 1
`
func (q *Queries) GetProductByItemCode(ctx context.Context, itemCode string) (Product, error) {
row := q.db.QueryRowContext(ctx, getProductByItemCode, itemCode)
var i Product
err := row.Scan(
&i.ID,
&i.PrincipleID,
&i.ProductCategoryID,
&i.Title,
&i.Description,
&i.ModelNumber,
&i.ModelNumberFormat,
&i.Notes,
&i.Stock,
&i.ItemCode,
&i.ItemDescription,
)
return i, err
}
const getProductsByCategory = `-- name: GetProductsByCategory :many
SELECT id, principle_id, product_category_id, title, description, model_number, model_number_format, notes, stock, item_code, item_description FROM products
WHERE product_category_id = ?
ORDER BY title
LIMIT ? OFFSET ?
`
type GetProductsByCategoryParams struct {
ProductCategoryID int32 `json:"product_category_id"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
func (q *Queries) GetProductsByCategory(ctx context.Context, arg GetProductsByCategoryParams) ([]Product, error) {
rows, err := q.db.QueryContext(ctx, getProductsByCategory, arg.ProductCategoryID, arg.Limit, arg.Offset)
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 listProducts = `-- name: ListProducts :many
SELECT id, principle_id, product_category_id, title, description, model_number, model_number_format, notes, stock, item_code, item_description FROM products
ORDER BY title
LIMIT ? OFFSET ?
`
type ListProductsParams struct {
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
func (q *Queries) ListProducts(ctx context.Context, arg ListProductsParams) ([]Product, error) {
rows, err := q.db.QueryContext(ctx, listProducts, arg.Limit, arg.Offset)
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 searchProductsByTitle = `-- name: SearchProductsByTitle :many
SELECT id, principle_id, product_category_id, title, description, model_number, model_number_format, notes, stock, item_code, item_description FROM products
WHERE title LIKE CONCAT('%', ?, '%')
ORDER BY title
LIMIT ? OFFSET ?
`
type SearchProductsByTitleParams struct {
CONCAT interface{} `json:"CONCAT"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
func (q *Queries) SearchProductsByTitle(ctx context.Context, arg SearchProductsByTitleParams) ([]Product, error) {
rows, err := q.db.QueryContext(ctx, searchProductsByTitle, arg.CONCAT, arg.Limit, arg.Offset)
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 updateProduct = `-- name: UpdateProduct :exec
UPDATE products
SET principle_id = ?,
product_category_id = ?,
title = ?,
description = ?,
model_number = ?,
model_number_format = ?,
notes = ?,
stock = ?,
item_code = ?,
item_description = ?
WHERE id = ?
`
type UpdateProductParams struct {
PrincipleID int32 `json:"principle_id"`
ProductCategoryID int32 `json:"product_category_id"`
Title string `json:"title"`
Description string `json:"description"`
ModelNumber sql.NullString `json:"model_number"`
ModelNumberFormat sql.NullString `json:"model_number_format"`
Notes sql.NullString `json:"notes"`
Stock bool `json:"stock"`
ItemCode string `json:"item_code"`
ItemDescription string `json:"item_description"`
ID int32 `json:"id"`
}
func (q *Queries) UpdateProduct(ctx context.Context, arg UpdateProductParams) error {
_, err := q.db.ExecContext(ctx, updateProduct,
arg.PrincipleID,
arg.ProductCategoryID,
arg.Title,
arg.Description,
arg.ModelNumber,
arg.ModelNumberFormat,
arg.Notes,
arg.Stock,
arg.ItemCode,
arg.ItemDescription,
arg.ID,
)
return err
}

View file

@ -0,0 +1,375 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// source: purchase_orders.sql
package db
import (
"context"
"database/sql"
"time"
)
const createPurchaseOrder = `-- name: CreatePurchaseOrder :execresult
INSERT INTO purchase_orders (
issue_date, dispatch_date, date_arrived, title,
principle_id, principle_reference, document_id,
currency_id, ordered_from, description, dispatch_by,
deliver_to, shipping_instructions, jobs_text,
freight_forwarder_text, parent_purchase_order_id
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
)
`
type CreatePurchaseOrderParams struct {
IssueDate time.Time `json:"issue_date"`
DispatchDate time.Time `json:"dispatch_date"`
DateArrived time.Time `json:"date_arrived"`
Title string `json:"title"`
PrincipleID int32 `json:"principle_id"`
PrincipleReference string `json:"principle_reference"`
DocumentID int32 `json:"document_id"`
CurrencyID sql.NullInt32 `json:"currency_id"`
OrderedFrom string `json:"ordered_from"`
Description string `json:"description"`
DispatchBy string `json:"dispatch_by"`
DeliverTo string `json:"deliver_to"`
ShippingInstructions string `json:"shipping_instructions"`
JobsText string `json:"jobs_text"`
FreightForwarderText string `json:"freight_forwarder_text"`
ParentPurchaseOrderID int32 `json:"parent_purchase_order_id"`
}
func (q *Queries) CreatePurchaseOrder(ctx context.Context, arg CreatePurchaseOrderParams) (sql.Result, error) {
return q.db.ExecContext(ctx, createPurchaseOrder,
arg.IssueDate,
arg.DispatchDate,
arg.DateArrived,
arg.Title,
arg.PrincipleID,
arg.PrincipleReference,
arg.DocumentID,
arg.CurrencyID,
arg.OrderedFrom,
arg.Description,
arg.DispatchBy,
arg.DeliverTo,
arg.ShippingInstructions,
arg.JobsText,
arg.FreightForwarderText,
arg.ParentPurchaseOrderID,
)
}
const deletePurchaseOrder = `-- name: DeletePurchaseOrder :exec
DELETE FROM purchase_orders
WHERE id = ?
`
func (q *Queries) DeletePurchaseOrder(ctx context.Context, id int32) error {
_, err := q.db.ExecContext(ctx, deletePurchaseOrder, id)
return err
}
const getPurchaseOrder = `-- name: GetPurchaseOrder :one
SELECT id, issue_date, dispatch_date, date_arrived, title, principle_id, principle_reference, document_id, currency_id, ordered_from, description, dispatch_by, deliver_to, shipping_instructions, jobs_text, freight_forwarder_text, parent_purchase_order_id FROM purchase_orders
WHERE id = ? LIMIT 1
`
func (q *Queries) GetPurchaseOrder(ctx context.Context, id int32) (PurchaseOrder, error) {
row := q.db.QueryRowContext(ctx, getPurchaseOrder, id)
var i PurchaseOrder
err := row.Scan(
&i.ID,
&i.IssueDate,
&i.DispatchDate,
&i.DateArrived,
&i.Title,
&i.PrincipleID,
&i.PrincipleReference,
&i.DocumentID,
&i.CurrencyID,
&i.OrderedFrom,
&i.Description,
&i.DispatchBy,
&i.DeliverTo,
&i.ShippingInstructions,
&i.JobsText,
&i.FreightForwarderText,
&i.ParentPurchaseOrderID,
)
return i, err
}
const getPurchaseOrderRevisions = `-- name: GetPurchaseOrderRevisions :many
SELECT id, issue_date, dispatch_date, date_arrived, title, principle_id, principle_reference, document_id, currency_id, ordered_from, description, dispatch_by, deliver_to, shipping_instructions, jobs_text, freight_forwarder_text, parent_purchase_order_id FROM purchase_orders
WHERE parent_purchase_order_id = ?
ORDER BY id DESC
`
func (q *Queries) GetPurchaseOrderRevisions(ctx context.Context, parentPurchaseOrderID int32) ([]PurchaseOrder, error) {
rows, err := q.db.QueryContext(ctx, getPurchaseOrderRevisions, parentPurchaseOrderID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []PurchaseOrder{}
for rows.Next() {
var i PurchaseOrder
if err := rows.Scan(
&i.ID,
&i.IssueDate,
&i.DispatchDate,
&i.DateArrived,
&i.Title,
&i.PrincipleID,
&i.PrincipleReference,
&i.DocumentID,
&i.CurrencyID,
&i.OrderedFrom,
&i.Description,
&i.DispatchBy,
&i.DeliverTo,
&i.ShippingInstructions,
&i.JobsText,
&i.FreightForwarderText,
&i.ParentPurchaseOrderID,
); 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 getPurchaseOrdersByPrinciple = `-- name: GetPurchaseOrdersByPrinciple :many
SELECT id, issue_date, dispatch_date, date_arrived, title, principle_id, principle_reference, document_id, currency_id, ordered_from, description, dispatch_by, deliver_to, shipping_instructions, jobs_text, freight_forwarder_text, parent_purchase_order_id FROM purchase_orders
WHERE principle_id = ?
ORDER BY issue_date DESC
LIMIT ? OFFSET ?
`
type GetPurchaseOrdersByPrincipleParams struct {
PrincipleID int32 `json:"principle_id"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
func (q *Queries) GetPurchaseOrdersByPrinciple(ctx context.Context, arg GetPurchaseOrdersByPrincipleParams) ([]PurchaseOrder, error) {
rows, err := q.db.QueryContext(ctx, getPurchaseOrdersByPrinciple, arg.PrincipleID, arg.Limit, arg.Offset)
if err != nil {
return nil, err
}
defer rows.Close()
items := []PurchaseOrder{}
for rows.Next() {
var i PurchaseOrder
if err := rows.Scan(
&i.ID,
&i.IssueDate,
&i.DispatchDate,
&i.DateArrived,
&i.Title,
&i.PrincipleID,
&i.PrincipleReference,
&i.DocumentID,
&i.CurrencyID,
&i.OrderedFrom,
&i.Description,
&i.DispatchBy,
&i.DeliverTo,
&i.ShippingInstructions,
&i.JobsText,
&i.FreightForwarderText,
&i.ParentPurchaseOrderID,
); 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 listPurchaseOrders = `-- name: ListPurchaseOrders :many
SELECT id, issue_date, dispatch_date, date_arrived, title, principle_id, principle_reference, document_id, currency_id, ordered_from, description, dispatch_by, deliver_to, shipping_instructions, jobs_text, freight_forwarder_text, parent_purchase_order_id FROM purchase_orders
ORDER BY issue_date DESC
LIMIT ? OFFSET ?
`
type ListPurchaseOrdersParams struct {
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
func (q *Queries) ListPurchaseOrders(ctx context.Context, arg ListPurchaseOrdersParams) ([]PurchaseOrder, error) {
rows, err := q.db.QueryContext(ctx, listPurchaseOrders, arg.Limit, arg.Offset)
if err != nil {
return nil, err
}
defer rows.Close()
items := []PurchaseOrder{}
for rows.Next() {
var i PurchaseOrder
if err := rows.Scan(
&i.ID,
&i.IssueDate,
&i.DispatchDate,
&i.DateArrived,
&i.Title,
&i.PrincipleID,
&i.PrincipleReference,
&i.DocumentID,
&i.CurrencyID,
&i.OrderedFrom,
&i.Description,
&i.DispatchBy,
&i.DeliverTo,
&i.ShippingInstructions,
&i.JobsText,
&i.FreightForwarderText,
&i.ParentPurchaseOrderID,
); 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 searchPurchaseOrdersByTitle = `-- name: SearchPurchaseOrdersByTitle :many
SELECT id, issue_date, dispatch_date, date_arrived, title, principle_id, principle_reference, document_id, currency_id, ordered_from, description, dispatch_by, deliver_to, shipping_instructions, jobs_text, freight_forwarder_text, parent_purchase_order_id FROM purchase_orders
WHERE title LIKE CONCAT('%', ?, '%')
ORDER BY issue_date DESC
LIMIT ? OFFSET ?
`
type SearchPurchaseOrdersByTitleParams struct {
CONCAT interface{} `json:"CONCAT"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
func (q *Queries) SearchPurchaseOrdersByTitle(ctx context.Context, arg SearchPurchaseOrdersByTitleParams) ([]PurchaseOrder, error) {
rows, err := q.db.QueryContext(ctx, searchPurchaseOrdersByTitle, arg.CONCAT, arg.Limit, arg.Offset)
if err != nil {
return nil, err
}
defer rows.Close()
items := []PurchaseOrder{}
for rows.Next() {
var i PurchaseOrder
if err := rows.Scan(
&i.ID,
&i.IssueDate,
&i.DispatchDate,
&i.DateArrived,
&i.Title,
&i.PrincipleID,
&i.PrincipleReference,
&i.DocumentID,
&i.CurrencyID,
&i.OrderedFrom,
&i.Description,
&i.DispatchBy,
&i.DeliverTo,
&i.ShippingInstructions,
&i.JobsText,
&i.FreightForwarderText,
&i.ParentPurchaseOrderID,
); 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 updatePurchaseOrder = `-- name: UpdatePurchaseOrder :exec
UPDATE purchase_orders
SET issue_date = ?,
dispatch_date = ?,
date_arrived = ?,
title = ?,
principle_id = ?,
principle_reference = ?,
document_id = ?,
currency_id = ?,
ordered_from = ?,
description = ?,
dispatch_by = ?,
deliver_to = ?,
shipping_instructions = ?,
jobs_text = ?,
freight_forwarder_text = ?,
parent_purchase_order_id = ?
WHERE id = ?
`
type UpdatePurchaseOrderParams struct {
IssueDate time.Time `json:"issue_date"`
DispatchDate time.Time `json:"dispatch_date"`
DateArrived time.Time `json:"date_arrived"`
Title string `json:"title"`
PrincipleID int32 `json:"principle_id"`
PrincipleReference string `json:"principle_reference"`
DocumentID int32 `json:"document_id"`
CurrencyID sql.NullInt32 `json:"currency_id"`
OrderedFrom string `json:"ordered_from"`
Description string `json:"description"`
DispatchBy string `json:"dispatch_by"`
DeliverTo string `json:"deliver_to"`
ShippingInstructions string `json:"shipping_instructions"`
JobsText string `json:"jobs_text"`
FreightForwarderText string `json:"freight_forwarder_text"`
ParentPurchaseOrderID int32 `json:"parent_purchase_order_id"`
ID int32 `json:"id"`
}
func (q *Queries) UpdatePurchaseOrder(ctx context.Context, arg UpdatePurchaseOrderParams) error {
_, err := q.db.ExecContext(ctx, updatePurchaseOrder,
arg.IssueDate,
arg.DispatchDate,
arg.DateArrived,
arg.Title,
arg.PrincipleID,
arg.PrincipleReference,
arg.DocumentID,
arg.CurrencyID,
arg.OrderedFrom,
arg.Description,
arg.DispatchBy,
arg.DeliverTo,
arg.ShippingInstructions,
arg.JobsText,
arg.FreightForwarderText,
arg.ParentPurchaseOrderID,
arg.ID,
)
return err
}

View file

@ -0,0 +1,58 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
package db
import (
"context"
"database/sql"
)
type Querier interface {
ArchiveEnquiry(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)
CreateCustomer(ctx context.Context, arg CreateCustomerParams) (sql.Result, error)
CreateEnquiry(ctx context.Context, arg CreateEnquiryParams) (sql.Result, error)
CreateProduct(ctx context.Context, arg CreateProductParams) (sql.Result, error)
CreatePurchaseOrder(ctx context.Context, arg CreatePurchaseOrderParams) (sql.Result, error)
DeleteCustomer(ctx context.Context, id int32) error
DeleteProduct(ctx context.Context, id int32) error
DeletePurchaseOrder(ctx context.Context, id int32) 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)
GetCustomer(ctx context.Context, id int32) (Customer, error)
GetCustomerByABN(ctx context.Context, abn sql.NullString) (Customer, 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)
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)
GetPurchaseOrderRevisions(ctx context.Context, parentPurchaseOrderID int32) ([]PurchaseOrder, error)
GetPurchaseOrdersByPrinciple(ctx context.Context, arg GetPurchaseOrdersByPrincipleParams) ([]PurchaseOrder, error)
ListArchivedEnquiries(ctx context.Context, arg ListArchivedEnquiriesParams) ([]ListArchivedEnquiriesRow, error)
ListCustomers(ctx context.Context, arg ListCustomersParams) ([]Customer, error)
ListEnquiries(ctx context.Context, arg ListEnquiriesParams) ([]ListEnquiriesRow, error)
ListProducts(ctx context.Context, arg ListProductsParams) ([]Product, error)
ListPurchaseOrders(ctx context.Context, arg ListPurchaseOrdersParams) ([]PurchaseOrder, error)
MarkEnquirySubmitted(ctx context.Context, arg MarkEnquirySubmittedParams) error
SearchCustomersByName(ctx context.Context, arg SearchCustomersByNameParams) ([]Customer, 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)
UnarchiveEnquiry(ctx context.Context, id int32) error
UpdateCustomer(ctx context.Context, arg UpdateCustomerParams) error
UpdateEnquiry(ctx context.Context, arg UpdateEnquiryParams) error
UpdateEnquiryStatus(ctx context.Context, arg UpdateEnquiryStatusParams) error
UpdateProduct(ctx context.Context, arg UpdateProductParams) error
UpdatePurchaseOrder(ctx context.Context, arg UpdatePurchaseOrderParams) error
}
var _ Querier = (*Queries)(nil)

View file

@ -0,0 +1,205 @@
package handlers
import (
"database/sql"
"encoding/json"
"net/http"
"strconv"
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db"
"github.com/gorilla/mux"
)
type CustomerHandler struct {
queries *db.Queries
}
func NewCustomerHandler(queries *db.Queries) *CustomerHandler {
return &CustomerHandler{queries: queries}
}
func (h *CustomerHandler) 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
}
}
customers, err := h.queries.ListCustomers(r.Context(), db.ListCustomersParams{
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(customers)
}
func (h *CustomerHandler) Get(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
http.Error(w, "Invalid customer ID", http.StatusBadRequest)
return
}
customer, err := h.queries.GetCustomer(r.Context(), int32(id))
if err != nil {
if err == sql.ErrNoRows {
http.Error(w, "Customer not found", http.StatusNotFound)
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(customer)
}
func (h *CustomerHandler) 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.CreateCustomerParams{
Name: r.FormValue("name"),
TradingName: r.FormValue("trading_name"),
Abn: sql.NullString{String: r.FormValue("abn"), Valid: r.FormValue("abn") != ""},
Notes: r.FormValue("notes"),
DiscountPricingPolicies: r.FormValue("discount_pricing_policies"),
PaymentTerms: r.FormValue("payment_terms"),
CustomerCategoryID: 1, // Default category
Url: r.FormValue("url"),
CountryID: 1, // Default country
}
// Check if this is a JSON request
if r.Header.Get("Content-Type") == "application/json" {
if err := json.NewDecoder(r.Body).Decode(&params); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
}
result, err := h.queries.CreateCustomer(r.Context(), params)
if err != nil {
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(`<div class="notification is-danger">Error creating customer</div>`))
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, redirect to customer page
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("HX-Redirect", "/customers/"+strconv.FormatInt(id, 10))
w.WriteHeader(http.StatusCreated)
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 *CustomerHandler) Update(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
http.Error(w, "Invalid customer ID", http.StatusBadRequest)
return
}
var params db.UpdateCustomerParams
if err := json.NewDecoder(r.Body).Decode(&params); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
params.ID = int32(id)
if err := h.queries.UpdateCustomer(r.Context(), params); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *CustomerHandler) Delete(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
http.Error(w, "Invalid customer ID", http.StatusBadRequest)
return
}
if err := h.queries.DeleteCustomer(r.Context(), int32(id)); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *CustomerHandler) Search(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("q")
if query == "" {
http.Error(w, "Search query required", http.StatusBadRequest)
return
}
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
}
}
customers, err := h.queries.SearchCustomersByName(r.Context(), db.SearchCustomersByNameParams{
CONCAT: query,
CONCAT_2: query,
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(customers)
}

View file

@ -0,0 +1,344 @@
package handlers
import (
"database/sql"
"encoding/json"
"net/http"
"strconv"
"time"
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db"
"github.com/gorilla/mux"
)
type EnquiryHandler struct {
queries *db.Queries
}
func NewEnquiryHandler(queries *db.Queries) *EnquiryHandler {
return &EnquiryHandler{queries: queries}
}
type CreateEnquiryRequest struct {
Title string `json:"title"`
UserID int32 `json:"user_id"`
CustomerID int32 `json:"customer_id"`
ContactID int32 `json:"contact_id"`
ContactUserID int32 `json:"contact_user_id"`
StateID int32 `json:"state_id"`
CountryID int32 `json:"country_id"`
PrincipleID int32 `json:"principle_id"`
StatusID int32 `json:"status_id"`
Comments string `json:"comments"`
PrincipleCode int32 `json:"principle_code"`
Gst bool `json:"gst"`
BillingAddressID sql.NullInt32 `json:"billing_address_id"`
ShippingAddressID sql.NullInt32 `json:"shipping_address_id"`
Posted bool `json:"posted"`
}
type UpdateEnquiryRequest struct {
Title string `json:"title"`
UserID int32 `json:"user_id"`
CustomerID int32 `json:"customer_id"`
ContactID int32 `json:"contact_id"`
ContactUserID int32 `json:"contact_user_id"`
StateID int32 `json:"state_id"`
CountryID int32 `json:"country_id"`
PrincipleID int32 `json:"principle_id"`
StatusID int32 `json:"status_id"`
Comments string `json:"comments"`
PrincipleCode int32 `json:"principle_code"`
Gst bool `json:"gst"`
BillingAddressID sql.NullInt32 `json:"billing_address_id"`
ShippingAddressID sql.NullInt32 `json:"shipping_address_id"`
Posted bool `json:"posted"`
Submitted sql.NullTime `json:"submitted"`
}
func (h *EnquiryHandler) List(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
}
}
limit := 150 // Same as CakePHP controller
offset := (page - 1) * limit
var enquiries interface{}
var err error
// Check if we want archived enquiries
if r.URL.Query().Get("archived") == "true" {
enquiries, err = h.queries.ListArchivedEnquiries(r.Context(), db.ListArchivedEnquiriesParams{
Limit: int32(limit),
Offset: int32(offset),
})
} else {
enquiries, err = h.queries.ListEnquiries(r.Context(), db.ListEnquiriesParams{
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(enquiries)
}
func (h *EnquiryHandler) Get(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
http.Error(w, "Invalid enquiry ID", http.StatusBadRequest)
return
}
enquiry, err := h.queries.GetEnquiry(r.Context(), int32(id))
if err != nil {
if err == sql.ErrNoRows {
http.Error(w, "Enquiry not found", http.StatusNotFound)
} else {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(enquiry)
}
func (h *EnquiryHandler) Create(w http.ResponseWriter, r *http.Request) {
var req CreateEnquiryRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
now := time.Now()
result, err := h.queries.CreateEnquiry(r.Context(), db.CreateEnquiryParams{
Created: now,
Title: req.Title,
UserID: req.UserID,
CustomerID: req.CustomerID,
ContactID: req.ContactID,
ContactUserID: req.ContactUserID,
StateID: req.StateID,
CountryID: req.CountryID,
PrincipleID: req.PrincipleID,
StatusID: req.StatusID,
Comments: req.Comments,
PrincipleCode: req.PrincipleCode,
Gst: req.Gst,
BillingAddressID: req.BillingAddressID,
ShippingAddressID: req.ShippingAddressID,
Posted: req.Posted,
Archived: int8(0),
})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
id, err := result.LastInsertId()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Return the created enquiry
enquiry, err := h.queries.GetEnquiry(r.Context(), int32(id))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(enquiry)
}
func (h *EnquiryHandler) Update(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
http.Error(w, "Invalid enquiry ID", http.StatusBadRequest)
return
}
var req UpdateEnquiryRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
err = h.queries.UpdateEnquiry(r.Context(), db.UpdateEnquiryParams{
Title: req.Title,
UserID: req.UserID,
CustomerID: req.CustomerID,
ContactID: req.ContactID,
ContactUserID: req.ContactUserID,
StateID: req.StateID,
CountryID: req.CountryID,
PrincipleID: req.PrincipleID,
StatusID: req.StatusID,
Comments: req.Comments,
PrincipleCode: req.PrincipleCode,
Gst: req.Gst,
BillingAddressID: req.BillingAddressID,
ShippingAddressID: req.ShippingAddressID,
Posted: req.Posted,
Submitted: req.Submitted,
ID: int32(id),
})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Return the updated enquiry
enquiry, err := h.queries.GetEnquiry(r.Context(), int32(id))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(enquiry)
}
func (h *EnquiryHandler) Delete(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
http.Error(w, "Invalid enquiry ID", http.StatusBadRequest)
return
}
err = h.queries.ArchiveEnquiry(r.Context(), int32(id))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *EnquiryHandler) Undelete(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
http.Error(w, "Invalid enquiry ID", http.StatusBadRequest)
return
}
err = h.queries.UnarchiveEnquiry(r.Context(), int32(id))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *EnquiryHandler) UpdateStatus(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
http.Error(w, "Invalid enquiry ID", http.StatusBadRequest)
return
}
var req struct {
StatusID int32 `json:"status_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
err = h.queries.UpdateEnquiryStatus(r.Context(), db.UpdateEnquiryStatusParams{
StatusID: req.StatusID,
ID: int32(id),
})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Return the updated enquiry
enquiry, err := h.queries.GetEnquiry(r.Context(), int32(id))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(enquiry)
}
func (h *EnquiryHandler) MarkSubmitted(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
http.Error(w, "Invalid enquiry ID", http.StatusBadRequest)
return
}
today := time.Now()
err = h.queries.MarkEnquirySubmitted(r.Context(), db.MarkEnquirySubmittedParams{
Submitted: sql.NullTime{Time: today, Valid: true},
ID: int32(id),
})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *EnquiryHandler) Search(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("q")
if query == "" {
h.List(w, r)
return
}
page := 1
if p := r.URL.Query().Get("page"); p != "" {
if val, err := strconv.Atoi(p); err == nil && val > 0 {
page = val
}
}
limit := 150
offset := (page - 1) * limit
enquiries, err := h.queries.SearchEnquiries(r.Context(), db.SearchEnquiriesParams{
CONCAT: query,
CONCAT_2: query,
CONCAT_3: query,
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(enquiries)
}

View file

@ -0,0 +1,603 @@
package handlers
import (
"net/http"
"strconv"
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db"
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/templates"
"github.com/gorilla/mux"
)
type PageHandler struct {
queries *db.Queries
tmpl *templates.TemplateManager
}
func NewPageHandler(queries *db.Queries, tmpl *templates.TemplateManager) *PageHandler {
return &PageHandler{
queries: queries,
tmpl: tmpl,
}
}
// Home page
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)
}
}
// Customer pages
func (h *PageHandler) CustomersIndex(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
}
}
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),
})
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,
"PrevPage": page - 1,
"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 {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
return
}
if err := h.tmpl.Render(w, "customers/index.html", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
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)
}
}
func (h *PageHandler) CustomersEdit(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
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)
}
}
func (h *PageHandler) CustomersShow(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
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)
}
}
func (h *PageHandler) CustomersSearch(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("search")
page := 1
if p := r.URL.Query().Get("page"); p != "" {
if val, err := strconv.Atoi(p); err == nil && val > 0 {
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),
Offset: int32(offset),
})
} else {
customers, err = h.queries.SearchCustomersByName(r.Context(), db.SearchCustomersByNameParams{
CONCAT: query,
CONCAT_2: query,
Limit: int32(limit + 1),
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,
"PrevPage": page - 1,
"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)
}
}
// Product page handlers
func (h *PageHandler) ProductsIndex(w http.ResponseWriter, r *http.Request) {
// Similar implementation to CustomersIndex but for products
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)
}
}
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)
}
}
func (h *PageHandler) ProductsShow(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
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)
}
}
func (h *PageHandler) ProductsEdit(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
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)
}
}
func (h *PageHandler) ProductsSearch(w http.ResponseWriter, r *http.Request) {
// Similar to CustomersSearch but for products
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)
}
}
// Purchase Order page handlers
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)
}
}
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)
}
}
func (h *PageHandler) PurchaseOrdersShow(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
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)
}
}
func (h *PageHandler) PurchaseOrdersEdit(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
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)
}
}
func (h *PageHandler) PurchaseOrdersSearch(w http.ResponseWriter, r *http.Request) {
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)
}
}
// Enquiry page handlers
func (h *PageHandler) EnquiriesIndex(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
}
}
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{
Limit: int32(limit + 1),
Offset: int32(offset),
})
if err == nil {
hasMore = len(archivedEnquiries) > limit
if hasMore {
archivedEnquiries = archivedEnquiries[:limit]
}
enquiries = archivedEnquiries
}
} else {
activeEnquiries, err := h.queries.ListEnquiries(r.Context(), db.ListEnquiriesParams{
Limit: int32(limit + 1),
Offset: int32(offset),
})
if err == nil {
hasMore = len(activeEnquiries) > limit
if hasMore {
activeEnquiries = activeEnquiries[:limit]
}
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,
"Page": page,
"PrevPage": page - 1,
"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 {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
return
}
if err := h.tmpl.Render(w, "enquiries/index.html", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func (h *PageHandler) EnquiriesNew(w http.ResponseWriter, r *http.Request) {
// 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": db.Enquiry{},
"Statuses": statuses,
"Principles": principles,
"States": states,
"Countries": countries,
}
if err := h.tmpl.Render(w, "enquiries/form.html", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func (h *PageHandler) EnquiriesShow(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
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)
}
}
func (h *PageHandler) EnquiriesEdit(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
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,
"Principles": principles,
"States": states,
"Countries": countries,
}
if err := h.tmpl.Render(w, "enquiries/form.html", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func (h *PageHandler) EnquiriesSearch(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("search")
page := 1
if p := r.URL.Query().Get("page"); p != "" {
if val, err := strconv.Atoi(p); err == nil && val > 0 {
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{
Limit: int32(limit + 1),
Offset: int32(offset),
})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
hasMore = len(regularEnquiries) > limit
if hasMore {
regularEnquiries = regularEnquiries[:limit]
}
enquiries = regularEnquiries
} else {
searchResults, err := h.queries.SearchEnquiries(r.Context(), db.SearchEnquiriesParams{
CONCAT: query,
CONCAT_2: query,
CONCAT_3: query,
Limit: int32(limit + 1),
Offset: int32(offset),
})
if err != nil {
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,
"PrevPage": page - 1,
"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)
}
}

View file

@ -0,0 +1,171 @@
package handlers
import (
"database/sql"
"encoding/json"
"net/http"
"strconv"
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db"
"github.com/gorilla/mux"
)
type ProductHandler struct {
queries *db.Queries
}
func NewProductHandler(queries *db.Queries) *ProductHandler {
return &ProductHandler{queries: queries}
}
func (h *ProductHandler) 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
}
}
products, err := h.queries.ListProducts(r.Context(), db.ListProductsParams{
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(products)
}
func (h *ProductHandler) Get(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
http.Error(w, "Invalid product ID", http.StatusBadRequest)
return
}
product, err := h.queries.GetProduct(r.Context(), int32(id))
if err != nil {
if err == sql.ErrNoRows {
http.Error(w, "Product not found", http.StatusNotFound)
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(product)
}
func (h *ProductHandler) Create(w http.ResponseWriter, r *http.Request) {
var params db.CreateProductParams
if err := json.NewDecoder(r.Body).Decode(&params); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
result, err := h.queries.CreateProduct(r.Context(), params)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
id, err := result.LastInsertId()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]interface{}{
"id": id,
})
}
func (h *ProductHandler) Update(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
http.Error(w, "Invalid product ID", http.StatusBadRequest)
return
}
var params db.UpdateProductParams
if err := json.NewDecoder(r.Body).Decode(&params); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
params.ID = int32(id)
if err := h.queries.UpdateProduct(r.Context(), params); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *ProductHandler) Delete(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
http.Error(w, "Invalid product ID", http.StatusBadRequest)
return
}
if err := h.queries.DeleteProduct(r.Context(), int32(id)); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *ProductHandler) Search(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("q")
if query == "" {
http.Error(w, "Search query required", http.StatusBadRequest)
return
}
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
}
}
products, err := h.queries.SearchProductsByTitle(r.Context(), db.SearchProductsByTitleParams{
CONCAT: query,
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(products)
}

View file

@ -0,0 +1,171 @@
package handlers
import (
"database/sql"
"encoding/json"
"net/http"
"strconv"
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db"
"github.com/gorilla/mux"
)
type PurchaseOrderHandler struct {
queries *db.Queries
}
func NewPurchaseOrderHandler(queries *db.Queries) *PurchaseOrderHandler {
return &PurchaseOrderHandler{queries: queries}
}
func (h *PurchaseOrderHandler) 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
}
}
purchaseOrders, err := h.queries.ListPurchaseOrders(r.Context(), db.ListPurchaseOrdersParams{
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(purchaseOrders)
}
func (h *PurchaseOrderHandler) Get(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
http.Error(w, "Invalid purchase order ID", http.StatusBadRequest)
return
}
purchaseOrder, err := h.queries.GetPurchaseOrder(r.Context(), int32(id))
if err != nil {
if err == sql.ErrNoRows {
http.Error(w, "Purchase order not found", http.StatusNotFound)
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(purchaseOrder)
}
func (h *PurchaseOrderHandler) Create(w http.ResponseWriter, r *http.Request) {
var params db.CreatePurchaseOrderParams
if err := json.NewDecoder(r.Body).Decode(&params); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
result, err := h.queries.CreatePurchaseOrder(r.Context(), params)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
id, err := result.LastInsertId()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]interface{}{
"id": id,
})
}
func (h *PurchaseOrderHandler) Update(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
http.Error(w, "Invalid purchase order ID", http.StatusBadRequest)
return
}
var params db.UpdatePurchaseOrderParams
if err := json.NewDecoder(r.Body).Decode(&params); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
params.ID = int32(id)
if err := h.queries.UpdatePurchaseOrder(r.Context(), params); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *PurchaseOrderHandler) Delete(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
http.Error(w, "Invalid purchase order ID", http.StatusBadRequest)
return
}
if err := h.queries.DeletePurchaseOrder(r.Context(), int32(id)); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *PurchaseOrderHandler) Search(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("q")
if query == "" {
http.Error(w, "Search query required", http.StatusBadRequest)
return
}
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
}
}
purchaseOrders, err := h.queries.SearchPurchaseOrdersByTitle(r.Context(), db.SearchPurchaseOrdersByTitleParams{
CONCAT: query,
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(purchaseOrders)
}

View file

@ -0,0 +1,128 @@
package templates
import (
"fmt"
"html/template"
"io"
"os"
"path/filepath"
"time"
)
type TemplateManager struct {
templates map[string]*template.Template
}
func NewTemplateManager(templatesDir string) (*TemplateManager, error) {
tm := &TemplateManager{
templates: make(map[string]*template.Template),
}
// Define template functions
funcMap := template.FuncMap{
"formatDate": formatDate,
"truncate": truncate,
"currency": formatCurrency,
}
// Load all templates
layouts, err := filepath.Glob(filepath.Join(templatesDir, "layouts/*.html"))
if err != nil {
return nil, err
}
partials, err := filepath.Glob(filepath.Join(templatesDir, "partials/*.html"))
if err != nil {
return nil, err
}
// Load page templates
pages := []string{
"customers/index.html",
"customers/show.html",
"customers/form.html",
"customers/table.html",
"products/index.html",
"products/show.html",
"products/form.html",
"products/table.html",
"purchase-orders/index.html",
"purchase-orders/show.html",
"purchase-orders/form.html",
"purchase-orders/table.html",
"enquiries/index.html",
"enquiries/show.html",
"enquiries/form.html",
"enquiries/table.html",
"index.html",
}
for _, page := range pages {
pagePath := filepath.Join(templatesDir, page)
files := append(layouts, partials...)
files = append(files, pagePath)
// For index pages, also include the corresponding table template
if filepath.Base(page) == "index.html" {
dir := filepath.Dir(page)
tablePath := filepath.Join(templatesDir, dir, "table.html")
// Check if table file exists before adding it
if _, err := os.Stat(tablePath); err == nil {
files = append(files, tablePath)
}
}
tmpl, err := template.New(filepath.Base(page)).Funcs(funcMap).ParseFiles(files...)
if err != nil {
return nil, err
}
tm.templates[page] = tmpl
}
return tm, nil
}
func (tm *TemplateManager) Render(w io.Writer, name string, data interface{}) error {
tmpl, ok := tm.templates[name]
if !ok {
return template.New("error").Execute(w, "Template not found")
}
return tmpl.ExecuteTemplate(w, "base", data)
}
func (tm *TemplateManager) RenderPartial(w io.Writer, templateFile, templateName string, data interface{}) error {
tmpl, ok := tm.templates[templateFile]
if !ok {
return template.New("error").Execute(w, "Template not found")
}
return tmpl.ExecuteTemplate(w, templateName, data)
}
// Template helper functions
func formatDate(t interface{}) string {
switch v := t.(type) {
case time.Time:
return v.Format("2006-01-02")
case string:
if tm, err := time.Parse("2006-01-02 15:04:05", v); err == nil {
return tm.Format("2006-01-02")
}
return v
default:
return fmt.Sprintf("%v", v)
}
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}
func formatCurrency(amount float64) string {
return fmt.Sprintf("$%.2f", amount)
}

BIN
go-app/server Executable file

Binary file not shown.

View file

@ -0,0 +1,46 @@
-- name: GetCustomer :one
SELECT * FROM customers
WHERE id = ? LIMIT 1;
-- name: ListCustomers :many
SELECT * FROM customers
ORDER BY name
LIMIT ? OFFSET ?;
-- name: CreateCustomer :execresult
INSERT INTO customers (
name, trading_name, abn, created, notes,
discount_pricing_policies, payment_terms,
customer_category_id, url, country_id
) VALUES (
?, ?, ?, NOW(), ?, ?, ?, ?, ?, ?
);
-- name: UpdateCustomer :exec
UPDATE customers
SET name = ?,
trading_name = ?,
abn = ?,
notes = ?,
discount_pricing_policies = ?,
payment_terms = ?,
customer_category_id = ?,
url = ?,
country_id = ?
WHERE id = ?;
-- name: DeleteCustomer :exec
DELETE FROM customers
WHERE id = ?;
-- name: SearchCustomersByName :many
SELECT * FROM customers
WHERE name LIKE CONCAT('%', ?, '%')
OR trading_name LIKE CONCAT('%', ?, '%')
ORDER BY name
LIMIT ? OFFSET ?;
-- name: GetCustomerByABN :one
SELECT * FROM customers
WHERE abn = ?
LIMIT 1;

View file

@ -0,0 +1,191 @@
-- name: GetEnquiry :one
SELECT e.*,
u.first_name as user_first_name,
u.last_name as user_last_name,
c.name as customer_name,
contact.first_name as contact_first_name,
contact.last_name as contact_last_name,
contact.email as contact_email,
contact.mobile as contact_mobile,
contact.direct_phone as contact_direct_phone,
contact.phone as contact_phone,
contact.phone_extension as contact_phone_extension,
s.name as status_name,
p.name as principle_name,
p.short_name as principle_short_name,
st.name as state_name,
st.shortform as state_shortform,
co.name as country_name
FROM enquiries e
LEFT JOIN users u ON e.user_id = u.id
LEFT JOIN customers c ON e.customer_id = c.id
LEFT JOIN users contact ON e.contact_user_id = contact.id
LEFT JOIN statuses s ON e.status_id = s.id
LEFT JOIN principles p ON e.principle_id = p.id
LEFT JOIN states st ON e.state_id = st.id
LEFT JOIN countries co ON e.country_id = co.id
WHERE e.id = ?;
-- name: ListEnquiries :many
SELECT e.*,
u.first_name as user_first_name,
u.last_name as user_last_name,
c.name as customer_name,
contact.first_name as contact_first_name,
contact.last_name as contact_last_name,
contact.email as contact_email,
contact.mobile as contact_mobile,
contact.direct_phone as contact_direct_phone,
contact.phone as contact_phone,
contact.phone_extension as contact_phone_extension,
s.name as status_name,
p.name as principle_name,
p.short_name as principle_short_name
FROM enquiries e
LEFT JOIN users u ON e.user_id = u.id
LEFT JOIN customers c ON e.customer_id = c.id
LEFT JOIN users contact ON e.contact_user_id = contact.id
LEFT JOIN statuses s ON e.status_id = s.id
LEFT JOIN principles p ON e.principle_id = p.id
WHERE e.archived = 0
ORDER BY e.id DESC
LIMIT ? OFFSET ?;
-- name: ListArchivedEnquiries :many
SELECT e.*,
u.first_name as user_first_name,
u.last_name as user_last_name,
c.name as customer_name,
contact.first_name as contact_first_name,
contact.last_name as contact_last_name,
s.name as status_name,
p.name as principle_name,
p.short_name as principle_short_name
FROM enquiries e
LEFT JOIN users u ON e.user_id = u.id
LEFT JOIN customers c ON e.customer_id = c.id
LEFT JOIN users contact ON e.contact_user_id = contact.id
LEFT JOIN statuses s ON e.status_id = s.id
LEFT JOIN principles p ON e.principle_id = p.id
WHERE e.archived = 1
ORDER BY e.id DESC
LIMIT ? OFFSET ?;
-- name: CreateEnquiry :execresult
INSERT INTO enquiries (
created, title, user_id, customer_id, contact_id, contact_user_id,
state_id, country_id, principle_id, status_id, comments,
principle_code, gst, billing_address_id, shipping_address_id,
posted, archived
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
);
-- name: UpdateEnquiry :exec
UPDATE enquiries
SET title = ?, user_id = ?, customer_id = ?, contact_id = ?, contact_user_id = ?,
state_id = ?, country_id = ?, principle_id = ?, status_id = ?, comments = ?,
principle_code = ?, gst = ?, billing_address_id = ?, shipping_address_id = ?,
posted = ?, submitted = ?
WHERE id = ?;
-- name: UpdateEnquiryStatus :exec
UPDATE enquiries SET status_id = ? WHERE id = ?;
-- name: ArchiveEnquiry :exec
UPDATE enquiries SET archived = 1 WHERE id = ?;
-- name: UnarchiveEnquiry :exec
UPDATE enquiries SET archived = 0 WHERE id = ?;
-- name: MarkEnquirySubmitted :exec
UPDATE enquiries SET submitted = ? WHERE id = ?;
-- name: SearchEnquiries :many
SELECT e.*,
u.first_name as user_first_name,
u.last_name as user_last_name,
c.name as customer_name,
contact.first_name as contact_first_name,
contact.last_name as contact_last_name,
s.name as status_name,
p.name as principle_name,
p.short_name as principle_short_name
FROM enquiries e
LEFT JOIN users u ON e.user_id = u.id
LEFT JOIN customers c ON e.customer_id = c.id
LEFT JOIN users contact ON e.contact_user_id = contact.id
LEFT JOIN statuses s ON e.status_id = s.id
LEFT JOIN principles p ON e.principle_id = p.id
WHERE e.archived = 0
AND (
e.title LIKE CONCAT('%', ?, '%') OR
c.name LIKE CONCAT('%', ?, '%') OR
CONCAT(contact.first_name, ' ', contact.last_name) LIKE CONCAT('%', ?, '%')
)
ORDER BY e.id DESC
LIMIT ? OFFSET ?;
-- name: GetEnquiriesByUser :many
SELECT e.*,
u.first_name as user_first_name,
u.last_name as user_last_name,
c.name as customer_name,
contact.first_name as contact_first_name,
contact.last_name as contact_last_name,
s.name as status_name,
p.name as principle_name,
p.short_name as principle_short_name
FROM enquiries e
LEFT JOIN users u ON e.user_id = u.id
LEFT JOIN customers c ON e.customer_id = c.id
LEFT JOIN users contact ON e.contact_user_id = contact.id
LEFT JOIN statuses s ON e.status_id = s.id
LEFT JOIN principles p ON e.principle_id = p.id
WHERE e.user_id = ? AND e.archived = 0
ORDER BY e.id DESC
LIMIT ? OFFSET ?;
-- name: GetEnquiriesByCustomer :many
SELECT e.*,
u.first_name as user_first_name,
u.last_name as user_last_name,
c.name as customer_name,
contact.first_name as contact_first_name,
contact.last_name as contact_last_name,
s.name as status_name,
p.name as principle_name,
p.short_name as principle_short_name
FROM enquiries e
LEFT JOIN users u ON e.user_id = u.id
LEFT JOIN customers c ON e.customer_id = c.id
LEFT JOIN users contact ON e.contact_user_id = contact.id
LEFT JOIN statuses s ON e.status_id = s.id
LEFT JOIN principles p ON e.principle_id = p.id
WHERE e.customer_id = ? AND e.archived = 0
ORDER BY e.id DESC
LIMIT ? OFFSET ?;
-- name: CountEnquiries :one
SELECT COUNT(*) FROM enquiries WHERE archived = 0;
-- name: CountEnquiriesByStatus :one
SELECT COUNT(*) FROM enquiries WHERE status_id = ? AND archived = 0;
-- name: CountEnquiriesByPrinciple :one
SELECT COUNT(*) FROM enquiries WHERE principle_code = ?;
-- name: CountEnquiriesByPrincipleAndState :one
SELECT COUNT(*) FROM enquiries WHERE principle_code = ? AND state_id = ?;
-- name: GetAllStatuses :many
SELECT id, name FROM statuses ORDER BY name;
-- name: GetAllPrinciples :many
SELECT id, name, short_name, code FROM principles ORDER BY name;
-- name: GetAllStates :many
SELECT id, name, shortform, enqform FROM states ORDER BY name;
-- name: GetAllCountries :many
SELECT id, name FROM countries ORDER BY name;

View file

@ -0,0 +1,52 @@
-- name: GetProduct :one
SELECT * FROM products
WHERE id = ? LIMIT 1;
-- name: ListProducts :many
SELECT * FROM products
ORDER BY title
LIMIT ? OFFSET ?;
-- name: CreateProduct :execresult
INSERT INTO products (
principle_id, product_category_id, title, description,
model_number, model_number_format, notes, stock,
item_code, item_description
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?
);
-- name: UpdateProduct :exec
UPDATE products
SET principle_id = ?,
product_category_id = ?,
title = ?,
description = ?,
model_number = ?,
model_number_format = ?,
notes = ?,
stock = ?,
item_code = ?,
item_description = ?
WHERE id = ?;
-- name: DeleteProduct :exec
DELETE FROM products
WHERE id = ?;
-- name: SearchProductsByTitle :many
SELECT * FROM products
WHERE title LIKE CONCAT('%', ?, '%')
ORDER BY title
LIMIT ? OFFSET ?;
-- name: GetProductsByCategory :many
SELECT * FROM products
WHERE product_category_id = ?
ORDER BY title
LIMIT ? OFFSET ?;
-- name: GetProductByItemCode :one
SELECT * FROM products
WHERE item_code = ?
LIMIT 1;

View file

@ -0,0 +1,60 @@
-- name: GetPurchaseOrder :one
SELECT * FROM purchase_orders
WHERE id = ? LIMIT 1;
-- name: ListPurchaseOrders :many
SELECT * FROM purchase_orders
ORDER BY issue_date DESC
LIMIT ? OFFSET ?;
-- name: CreatePurchaseOrder :execresult
INSERT INTO purchase_orders (
issue_date, dispatch_date, date_arrived, title,
principle_id, principle_reference, document_id,
currency_id, ordered_from, description, dispatch_by,
deliver_to, shipping_instructions, jobs_text,
freight_forwarder_text, parent_purchase_order_id
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
);
-- name: UpdatePurchaseOrder :exec
UPDATE purchase_orders
SET issue_date = ?,
dispatch_date = ?,
date_arrived = ?,
title = ?,
principle_id = ?,
principle_reference = ?,
document_id = ?,
currency_id = ?,
ordered_from = ?,
description = ?,
dispatch_by = ?,
deliver_to = ?,
shipping_instructions = ?,
jobs_text = ?,
freight_forwarder_text = ?,
parent_purchase_order_id = ?
WHERE id = ?;
-- name: DeletePurchaseOrder :exec
DELETE FROM purchase_orders
WHERE id = ?;
-- name: GetPurchaseOrdersByPrinciple :many
SELECT * FROM purchase_orders
WHERE principle_id = ?
ORDER BY issue_date DESC
LIMIT ? OFFSET ?;
-- name: GetPurchaseOrderRevisions :many
SELECT * FROM purchase_orders
WHERE parent_purchase_order_id = ?
ORDER BY id DESC;
-- name: SearchPurchaseOrdersByTitle :many
SELECT * FROM purchase_orders
WHERE title LIKE CONCAT('%', ?, '%')
ORDER BY issue_date DESC
LIMIT ? OFFSET ?;

View file

@ -0,0 +1,15 @@
-- Customers table schema (extracted from existing database)
CREATE TABLE IF NOT EXISTS `customers` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(400) NOT NULL COMMENT 'Company Name',
`trading_name` varchar(400) NOT NULL DEFAULT '',
`abn` varchar(255) DEFAULT NULL,
`created` datetime NOT NULL,
`notes` text NOT NULL,
`discount_pricing_policies` text NOT NULL,
`payment_terms` varchar(255) NOT NULL,
`customer_category_id` int(11) NOT NULL,
`url` varchar(300) NOT NULL,
`country_id` int(11) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View file

@ -0,0 +1,15 @@
-- Products table schema (extracted from existing database)
CREATE TABLE IF NOT EXISTS `products` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`principle_id` int(11) NOT NULL COMMENT 'Principle FK',
`product_category_id` int(11) NOT NULL,
`title` varchar(255) NOT NULL COMMENT 'This must match the Title in the Excel Costing File',
`description` text NOT NULL,
`model_number` varchar(255) DEFAULT NULL COMMENT 'Part or model number principle uses to identify this product',
`model_number_format` varchar(255) DEFAULT NULL COMMENT '%1% - first item, %2% , second item etc ',
`notes` text DEFAULT NULL COMMENT 'Any notes about this product. Note displayed on quotes',
`stock` tinyint(1) NOT NULL COMMENT 'Stock or Ident',
`item_code` varchar(255) NOT NULL,
`item_description` varchar(255) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View file

@ -0,0 +1,21 @@
-- Purchase Orders table schema (extracted from existing database)
CREATE TABLE IF NOT EXISTS `purchase_orders` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`issue_date` date NOT NULL,
`dispatch_date` date NOT NULL,
`date_arrived` date NOT NULL,
`title` varchar(255) NOT NULL COMMENT 'CMC PONumber',
`principle_id` int(11) NOT NULL,
`principle_reference` varchar(255) NOT NULL,
`document_id` int(11) NOT NULL,
`currency_id` int(11) DEFAULT NULL,
`ordered_from` text NOT NULL,
`description` text NOT NULL,
`dispatch_by` varchar(255) NOT NULL,
`deliver_to` text NOT NULL,
`shipping_instructions` text NOT NULL,
`jobs_text` varchar(512) NOT NULL,
`freight_forwarder_text` text NOT NULL DEFAULT '',
`parent_purchase_order_id` int(11) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View file

@ -0,0 +1,26 @@
-- Users table schema
CREATE TABLE `users` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`principle_id` int(11) NOT NULL,
`customer_id` int(11) NOT NULL,
`type` enum('principle','contact','user') NOT NULL,
`access_level` enum('admin','manager','user','none') NOT NULL DEFAULT 'none',
`username` char(50) NOT NULL,
`password` char(60) NOT NULL,
`first_name` varchar(255) NOT NULL,
`last_name` varchar(255) NOT NULL,
`email` varchar(255) NOT NULL,
`job_title` varchar(255) NOT NULL,
`phone` varchar(255) NOT NULL,
`mobile` varchar(255) NOT NULL,
`fax` varchar(255) NOT NULL,
`phone_extension` varchar(255) NOT NULL,
`direct_phone` varchar(255) NOT NULL,
`notes` text NOT NULL,
`by_vault` tinyint(1) NOT NULL COMMENT 'Added by Vault. May or may not be a real person.',
`blacklisted` tinyint(1) NOT NULL COMMENT 'Disregard emails from this address in future.',
`enabled` tinyint(1) NOT NULL DEFAULT 0,
`archived` tinyint(1) DEFAULT 0,
`primary_contact` tinyint(1) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`)
) ENGINE=MyISAM AUTO_INCREMENT=48276 DEFAULT CHARSET=latin1 COLLATE=latin1_swedish_ci;

View file

@ -0,0 +1,57 @@
-- Enquiries table schema
CREATE TABLE `enquiries` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`created` datetime NOT NULL,
`submitted` date DEFAULT NULL,
`title` varchar(255) NOT NULL COMMENT 'enquirynumber',
`user_id` int(11) NOT NULL,
`customer_id` int(11) NOT NULL,
`contact_id` int(11) NOT NULL,
`contact_user_id` int(11) NOT NULL,
`state_id` int(11) NOT NULL,
`country_id` int(11) NOT NULL,
`principle_id` int(11) NOT NULL,
`status_id` int(11) NOT NULL,
`comments` text NOT NULL,
`principle_code` int(3) NOT NULL COMMENT 'Numeric Principle Code',
`gst` tinyint(1) NOT NULL COMMENT 'GST applicable on this enquiry',
`billing_address_id` int(11) DEFAULT NULL,
`shipping_address_id` int(11) DEFAULT NULL,
`posted` tinyint(1) NOT NULL COMMENT 'has the enquired been posted',
`email_count` int(11) NOT NULL DEFAULT 0,
`invoice_count` int(11) NOT NULL DEFAULT 0,
`job_count` int(11) NOT NULL DEFAULT 0,
`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;

14
go-app/sqlc.yaml Normal file
View file

@ -0,0 +1,14 @@
version: "2"
sql:
- engine: "mysql"
queries: "sql/queries/"
schema: "sql/schema/"
gen:
go:
package: "db"
out: "internal/cmc/db"
emit_json_tags: true
emit_prepared_queries: false
emit_interface: true
emit_exact_table_names: false
emit_empty_slices: true

109
go-app/static/css/style.css Normal file
View file

@ -0,0 +1,109 @@
/* Custom styles for CMC Sales */
/* Loading spinner */
.htmx-indicator {
display: none;
}
.htmx-request .htmx-indicator {
display: inline;
}
.htmx-request.htmx-indicator {
display: inline;
}
/* Smooth transitions */
.htmx-swapping {
opacity: 0;
transition: opacity 200ms ease-out;
}
.htmx-settling {
opacity: 1;
transition: opacity 200ms ease-in;
}
/* Table hover effects */
.table.is-hoverable tbody tr:hover {
background-color: #f5f5f5;
cursor: pointer;
}
/* Form improvements */
.field:not(:last-child) {
margin-bottom: 1.5rem;
}
/* Notification animations */
.notification {
animation: slideIn 300ms ease-out;
}
@keyframes slideIn {
from {
transform: translateY(-20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
/* Breadcrumb improvements */
.breadcrumb {
margin-bottom: 2rem;
}
/* Card hover effects */
.card:hover {
box-shadow: 0 8px 16px rgba(10,10,10,.1);
transition: box-shadow 200ms ease;
}
/* Button loading state */
.button.is-loading::after {
border-color: transparent transparent #fff #fff !important;
}
/* Responsive improvements */
@media screen and (max-width: 768px) {
.level-left,
.level-right {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.level-right {
margin-top: 1rem;
}
}
/* Footer stick to bottom */
body {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.section {
flex: 1;
}
/* Search box improvements */
.control.has-icons-left .input:focus ~ .icon {
color: #3273dc;
}
/* Table action buttons */
.table td .buttons {
margin-bottom: 0;
}
/* Error states */
.input.is-danger:focus {
border-color: #ff3860;
box-shadow: 0 0 0 0.125em rgba(255,56,96,.25);
}

112
go-app/static/js/app.js Normal file
View file

@ -0,0 +1,112 @@
// CMC Sales Application JavaScript
// Initialize HTMX events
document.body.addEventListener('htmx:configRequest', (event) => {
// Add CSRF token if needed in the future
});
// Handle HTMX errors
document.body.addEventListener('htmx:responseError', (event) => {
console.error('HTMX request failed:', event.detail);
showNotification('An error occurred. Please try again.', 'danger');
});
// Handle form validation
document.body.addEventListener('htmx:validation:validate', (event) => {
const element = event.detail.elt;
if (element.tagName === 'FORM') {
// Clear previous errors
element.querySelectorAll('.is-danger').forEach(el => {
el.classList.remove('is-danger');
});
element.querySelectorAll('.help.is-danger').forEach(el => {
el.remove();
});
// Validate required fields
let isValid = true;
element.querySelectorAll('[required]').forEach(input => {
if (!input.value.trim()) {
input.classList.add('is-danger');
const help = document.createElement('p');
help.className = 'help is-danger';
help.textContent = 'This field is required';
input.parentElement.appendChild(help);
isValid = false;
}
});
if (!isValid) {
event.preventDefault();
}
}
});
// Show notification
function showNotification(message, type = 'info') {
const notification = document.createElement('div');
notification.className = `notification is-${type}`;
notification.innerHTML = `
<button class="delete"></button>
${message}
`;
// Insert at the top of the main content
const container = document.querySelector('.section .container');
container.insertBefore(notification, container.firstChild);
// Auto-dismiss after 5 seconds
setTimeout(() => {
notification.remove();
}, 5000);
// Handle delete button
notification.querySelector('.delete').addEventListener('click', () => {
notification.remove();
});
}
// Handle delete confirmations
document.body.addEventListener('htmx:confirm', (event) => {
if (event.detail.question === 'Are you sure you want to delete this customer?') {
event.preventDefault();
// Use Bulma modal or native confirm
if (confirm(event.detail.question)) {
event.detail.issueRequest();
}
}
});
// Handle successful deletes
document.body.addEventListener('htmx:afterRequest', (event) => {
if (event.detail.xhr.status === 204 && event.detail.verb === 'delete') {
showNotification('Item deleted successfully', 'success');
}
});
// Format dates
function formatDate(dateString) {
const date = new Date(dateString);
return date.toLocaleDateString('en-AU', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
}
// Initialize tooltips if using Bulma extensions
document.addEventListener('DOMContentLoaded', () => {
// Add any initialization code here
});
// Handle search clear button
document.addEventListener('click', (event) => {
if (event.target.matches('[data-clear-search]')) {
const searchInput = document.querySelector('#customer-search');
if (searchInput) {
searchInput.value = '';
searchInput.dispatchEvent(new Event('keyup'));
}
}
});

View file

@ -0,0 +1,100 @@
{{define "title"}}{{if .Customer.ID}}Edit{{else}}New{{end}} Customer - CMC Sales{{end}}
{{define "content"}}
<div class="columns is-centered">
<div class="column is-8">
<h1 class="title">{{if .Customer.ID}}Edit{{else}}New{{end}} Customer</h1>
<form {{if .Customer.ID}}
hx-put="/customers/{{.Customer.ID}}"
{{else}}
hx-post="/customers"
{{end}}
hx-target="#form-response">
<div class="field">
<label class="label">Company Name</label>
<div class="control">
<input class="input" type="text" name="name"
placeholder="Company Name"
value="{{.Customer.Name}}" required>
</div>
</div>
<div class="field">
<label class="label">Trading Name</label>
<div class="control">
<input class="input" type="text" name="trading_name"
placeholder="Trading Name"
value="{{.Customer.TradingName}}">
</div>
</div>
<div class="field">
<label class="label">ABN</label>
<div class="control">
<input class="input" type="text" name="abn"
placeholder="ABN"
value="{{.Customer.Abn}}">
</div>
</div>
<div class="field">
<label class="label">Payment Terms</label>
<div class="control">
<div class="select is-fullwidth">
<select name="payment_terms">
<option value="Net 30" {{if eq .Customer.PaymentTerms "Net 30"}}selected{{end}}>Net 30</option>
<option value="Net 60" {{if eq .Customer.PaymentTerms "Net 60"}}selected{{end}}>Net 60</option>
<option value="Net 90" {{if eq .Customer.PaymentTerms "Net 90"}}selected{{end}}>Net 90</option>
<option value="COD" {{if eq .Customer.PaymentTerms "COD"}}selected{{end}}>COD</option>
<option value="Prepaid" {{if eq .Customer.PaymentTerms "Prepaid"}}selected{{end}}>Prepaid</option>
</select>
</div>
</div>
</div>
<div class="field">
<label class="label">Website URL</label>
<div class="control">
<input class="input" type="url" name="url"
placeholder="https://example.com"
value="{{.Customer.Url}}">
</div>
</div>
<div class="field">
<label class="label">Notes</label>
<div class="control">
<textarea class="textarea" name="notes"
placeholder="Customer notes...">{{.Customer.Notes}}</textarea>
</div>
</div>
<div class="field">
<label class="label">Discount Pricing Policies</label>
<div class="control">
<textarea class="textarea" name="discount_pricing_policies"
placeholder="Discount policies...">{{.Customer.DiscountPricingPolicies}}</textarea>
</div>
</div>
<div id="form-response"></div>
<div class="field is-grouped">
<div class="control">
<button class="button is-primary" type="submit">
<span class="icon">
<i class="fas fa-save"></i>
</span>
<span>Save</span>
</button>
</div>
<div class="control">
<a href="/customers" class="button is-light">Cancel</a>
</div>
</div>
</form>
</div>
</div>
{{end}}

View file

@ -0,0 +1,47 @@
{{define "title"}}Customers - CMC Sales{{end}}
{{define "content"}}
<div class="level">
<div class="level-left">
<div class="level-item">
<h1 class="title">Customers</h1>
</div>
</div>
<div class="level-right">
<div class="level-item">
<a href="/customers/new" class="button is-primary">
<span class="icon">
<i class="fas fa-plus"></i>
</span>
<span>New Customer</span>
</a>
</div>
</div>
</div>
<!-- Search Box -->
<div class="box">
<div class="field has-addons">
<div class="control has-icons-left is-expanded">
<input class="input" type="text" placeholder="Search customers..."
name="search" id="customer-search"
hx-get="/customers/search"
hx-trigger="keyup changed delay:500ms"
hx-target="#customer-table-container">
<span class="icon is-left">
<i class="fas fa-search"></i>
</span>
</div>
<div class="control">
<button class="button is-info" onclick="document.getElementById('customer-search').value=''">
Clear
</button>
</div>
</div>
</div>
<!-- Customer Table Container -->
<div id="customer-table-container">
{{template "customer-table" .}}
</div>
{{end}}

View file

@ -0,0 +1,121 @@
{{define "title"}}{{.Customer.Name}} - CMC Sales{{end}}
{{define "content"}}
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li><a href="/">Home</a></li>
<li><a href="/customers">Customers</a></li>
<li class="is-active"><a href="#" aria-current="page">{{.Customer.Name}}</a></li>
</ul>
</nav>
<div class="level">
<div class="level-left">
<div class="level-item">
<h1 class="title">{{.Customer.Name}}</h1>
</div>
</div>
<div class="level-right">
<div class="level-item">
<a href="/customers/{{.Customer.ID}}/edit" class="button is-info">
<span class="icon">
<i class="fas fa-edit"></i>
</span>
<span>Edit</span>
</a>
</div>
</div>
</div>
<div class="columns">
<div class="column is-8">
<div class="box">
<h2 class="subtitle">Customer Information</h2>
<table class="table is-fullwidth">
<tbody>
<tr>
<th>Company Name:</th>
<td>{{.Customer.Name}}</td>
</tr>
<tr>
<th>Trading Name:</th>
<td>{{if .Customer.TradingName}}{{.Customer.TradingName}}{{else}}<span class="has-text-grey">Not specified</span>{{end}}</td>
</tr>
<tr>
<th>ABN:</th>
<td>{{if .Customer.Abn.Valid}}{{.Customer.Abn.String}}{{else}}<span class="has-text-grey">Not specified</span>{{end}}</td>
</tr>
<tr>
<th>Payment Terms:</th>
<td>{{.Customer.PaymentTerms}}</td>
</tr>
<tr>
<th>Website:</th>
<td>
{{if .Customer.Url}}
<a href="{{.Customer.Url}}" target="_blank">{{.Customer.Url}}</a>
{{else}}
<span class="has-text-grey">Not specified</span>
{{end}}
</td>
</tr>
<tr>
<th>Created:</th>
<td>{{.Customer.Created}}</td>
</tr>
</tbody>
</table>
</div>
{{if .Customer.Notes}}
<div class="box">
<h2 class="subtitle">Notes</h2>
<div class="content">
<p>{{.Customer.Notes}}</p>
</div>
</div>
{{end}}
{{if .Customer.DiscountPricingPolicies}}
<div class="box">
<h2 class="subtitle">Discount Pricing Policies</h2>
<div class="content">
<p>{{.Customer.DiscountPricingPolicies}}</p>
</div>
</div>
{{end}}
</div>
<div class="column is-4">
<div class="box">
<h2 class="subtitle">Quick Actions</h2>
<div class="buttons is-fullwidth">
<a href="/quotes/new?customer_id={{.Customer.ID}}" class="button is-primary is-fullwidth">
<span class="icon">
<i class="fas fa-file-invoice-dollar"></i>
</span>
<span>Create Quote</span>
</a>
<a href="/invoices/new?customer_id={{.Customer.ID}}" class="button is-info is-fullwidth">
<span class="icon">
<i class="fas fa-file-invoice"></i>
</span>
<span>Create Invoice</span>
</a>
</div>
</div>
<div class="box">
<h2 class="subtitle">Recent Activity</h2>
<div id="customer-activity" hx-get="/api/customers/{{.Customer.ID}}/activity" hx-trigger="load">
<div class="has-text-centered">
<span class="icon">
<i class="fas fa-spinner fa-pulse"></i>
</span>
</div>
</div>
</div>
</div>
</div>
{{end}}

View file

@ -0,0 +1,68 @@
{{define "customer-table"}}
<div class="table-container">
<table class="table is-fullwidth is-striped is-hoverable">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Trading Name</th>
<th>ABN</th>
<th>Payment Terms</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{range .Customers}}
<tr>
<td>{{.ID}}</td>
<td>
<a href="/customers/{{.ID}}">{{.Name}}</a>
</td>
<td>{{.TradingName}}</td>
<td>{{.Abn}}</td>
<td>{{.PaymentTerms}}</td>
<td>
<div class="buttons are-small">
<a href="/customers/{{.ID}}/edit" class="button is-info is-outlined">
<span class="icon">
<i class="fas fa-edit"></i>
</span>
</a>
<button class="button is-danger is-outlined"
hx-delete="/customers/{{.ID}}"
hx-confirm="Are you sure you want to delete this customer?"
hx-target="closest tr"
hx-swap="outerHTML">
<span class="icon">
<i class="fas fa-trash"></i>
</span>
</button>
</div>
</td>
</tr>
{{else}}
<tr>
<td colspan="6" class="has-text-centered">
<p class="has-text-grey">No customers found</p>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<!-- Pagination -->
{{if .Customers}}
<nav class="pagination is-centered" role="navigation" aria-label="pagination">
<a class="pagination-previous" {{if eq .Page 1}}disabled{{end}}
hx-get="/customers?page={{.PrevPage}}"
hx-target="#customer-table-container">Previous</a>
<a class="pagination-next" {{if not .HasMore}}disabled{{end}}
hx-get="/customers?page={{.NextPage}}"
hx-target="#customer-table-container">Next</a>
<ul class="pagination-list">
<li><span class="pagination-ellipsis">Page {{.Page}}</span></li>
</ul>
</nav>
{{end}}
{{end}}

View file

@ -0,0 +1,270 @@
{{define "title"}}{{if .Enquiry.ID}}Edit{{else}}New{{end}} Enquiry - CMC Sales{{end}}
{{define "content"}}
<div class="columns">
<div class="column is-8 is-offset-2">
<div class="card">
<header class="card-header">
<p class="card-header-title">
{{if .Enquiry.ID}}
Edit Enquiry: {{.Enquiry.Title}}
{{else}}
New Enquiry
{{end}}
</p>
</header>
<div class="card-content">
<form
{{if .Enquiry.ID}}
hx-put="/api/v1/enquiries/{{.Enquiry.ID}}"
{{else}}
hx-post="/api/v1/enquiries"
{{end}}
hx-headers='{"Content-Type": "application/json"}'
hx-target="#form-response">
<!-- Basic Information -->
<div class="field">
<label class="label">Title/Enquiry Number</label>
<div class="control">
<input
class="input"
type="text"
name="title"
value="{{.Enquiry.Title}}"
placeholder="Auto-generated if left blank">
</div>
</div>
<div class="columns">
<div class="column">
<div class="field">
<label class="label">User (Sales Rep)</label>
<div class="control">
<div class="select is-fullwidth">
<select name="user_id" required>
<option value="">Select User</option>
{{range .Users}}
<option value="{{.ID}}" {{if eq .ID $.Enquiry.UserID}}selected{{end}}>
{{.FirstName}} {{.LastName}}
</option>
{{end}}
</select>
</div>
</div>
</div>
</div>
<div class="column">
<div class="field">
<label class="label">Status</label>
<div class="control">
<div class="select is-fullwidth">
<select name="status_id" required>
{{range .Statuses}}
<option value="{{.ID}}" {{if eq .ID $.Enquiry.StatusID}}selected{{end}}>
{{.Name}}
</option>
{{end}}
</select>
</div>
</div>
</div>
</div>
</div>
<!-- Customer and Contact -->
<div class="columns">
<div class="column">
<div class="field">
<label class="label">Customer</label>
<div class="control">
<div class="select is-fullwidth">
<select name="customer_id" required>
<option value="">Select Customer</option>
{{range .Customers}}
<option value="{{.ID}}" {{if eq .ID $.Enquiry.CustomerID}}selected{{end}}>
{{.Name}}
</option>
{{end}}
</select>
</div>
</div>
</div>
</div>
<div class="column">
<div class="field">
<label class="label">Contact</label>
<div class="control">
<div class="select is-fullwidth">
<select name="contact_user_id" required>
<option value="">Select Contact</option>
{{range .Contacts}}
<option value="{{.ID}}" {{if eq .ID $.Enquiry.ContactUserID}}selected{{end}}>
{{.FirstName}} {{.LastName}}
</option>
{{end}}
</select>
</div>
</div>
</div>
</div>
</div>
<!-- Location -->
<div class="columns">
<div class="column">
<div class="field">
<label class="label">State</label>
<div class="control">
<div class="select is-fullwidth">
<select name="state_id" required>
{{range .States}}
<option value="{{.ID}}" {{if eq .ID $.Enquiry.StateID}}selected{{end}}>
{{.Name}}
</option>
{{end}}
</select>
</div>
</div>
</div>
</div>
<div class="column">
<div class="field">
<label class="label">Country</label>
<div class="control">
<div class="select is-fullwidth">
<select name="country_id" required>
{{range .Countries}}
<option value="{{.ID}}" {{if eq .ID $.Enquiry.CountryID}}selected{{end}}>
{{.Name}}
</option>
{{end}}
</select>
</div>
</div>
</div>
</div>
</div>
<!-- Principle -->
<div class="field">
<label class="label">Principle (Supplier)</label>
<div class="control">
<div class="select is-fullwidth">
<select name="principle_id" required>
<option value="">Select Principle</option>
{{range .Principles}}
<option value="{{.ID}}" {{if eq .ID $.Enquiry.PrincipleID}}selected{{end}}>
{{if .ShortName}}
{{.ShortName}} - {{.Name}}
{{else}}
{{.Name}}
{{end}}
</option>
{{end}}
</select>
</div>
</div>
</div>
<!-- Comments -->
<div class="field">
<label class="label">Comments</label>
<div class="control">
<textarea
class="textarea"
name="comments"
rows="4"
placeholder="Enter enquiry details and comments...">{{.Enquiry.Comments}}</textarea>
</div>
</div>
<!-- Options -->
<div class="field">
<div class="control">
<label class="checkbox">
<input type="checkbox" name="gst" {{if .Enquiry.Gst}}checked{{end}}>
GST Applicable
</label>
</div>
</div>
<div class="field">
<div class="control">
<label class="checkbox">
<input type="checkbox" name="posted" {{if .Enquiry.Posted}}checked{{end}}>
Posted
</label>
</div>
</div>
{{if not .Enquiry.ID}}
<div class="field">
<div class="control">
<label class="checkbox">
<input type="checkbox" name="send_enquiry_email" checked>
Send enquiry email to contact
</label>
</div>
</div>
{{end}}
<!-- Submit Buttons -->
<div class="field is-grouped">
<div class="control">
<button type="submit" class="button is-primary">
{{if .Enquiry.ID}}Update{{else}}Create{{end}} Enquiry
</button>
</div>
<div class="control">
<a href="/enquiries" class="button is-light">Cancel</a>
</div>
</div>
<!-- Response area -->
<div id="form-response"></div>
</form>
</div>
</div>
</div>
</div>
{{end}}
{{define "scripts"}}
<script>
// Handle form submission success
document.addEventListener('htmx:afterSwap', function(evt) {
if (evt.detail.target.id === 'form-response') {
// Redirect to enquiry view on successful creation/update
const response = evt.detail.xhr.response;
if (evt.detail.xhr.status === 200 || evt.detail.xhr.status === 201) {
try {
const enquiry = JSON.parse(response);
window.location.href = '/enquiries/' + enquiry.id;
} catch (e) {
// If parsing fails, redirect to index
window.location.href = '/enquiries';
}
}
}
});
// Customer selection triggers contact loading
document.querySelector('select[name="customer_id"]').addEventListener('change', function(e) {
const customerId = e.target.value;
if (customerId) {
// Load contacts for selected customer
fetch(`/api/v1/customers/${customerId}/contacts`)
.then(response => response.json())
.then(contacts => {
const contactSelect = document.querySelector('select[name="contact_user_id"]');
contactSelect.innerHTML = '<option value="">Select Contact</option>';
contacts.forEach(contact => {
contactSelect.innerHTML += `<option value="${contact.id}">${contact.first_name} ${contact.last_name}</option>`;
});
})
.catch(err => console.error('Failed to load contacts:', err));
}
});
</script>
{{end}}

View file

@ -0,0 +1,159 @@
{{define "title"}}Enquiries - CMC Sales{{end}}
{{define "head"}}
<style>
/* Status-based row coloring matching CakePHP template */
.jobwon { background-color: #ffff99 !important; }
.joblost { background-color: #ff9999 !important; }
.information { background-color: #99ccff !important; }
.informationsent { background-color: #99ffcc !important; }
.quoted { background-color: #ccccff !important; }
.requestforquote { background-color: #ffcccc !important; }
.posted { font-weight: bold; }
.notposted { font-style: italic; color: #666; }
.enquiry-table {
font-size: 0.875rem;
}
.enquiry-table td {
padding: 0.5rem 0.25rem;
vertical-align: middle;
}
.archived-row {
opacity: 0.6;
}
.status-dropdown {
min-width: 120px;
}
</style>
{{end}}
{{define "content"}}
<div class="level">
<div class="level-left">
<div class="level-item">
<h1 class="title">Enquiries</h1>
</div>
</div>
<div class="level-right">
<div class="level-item">
<a href="/enquiries/new" class="button is-primary">
<span class="icon">
<i class="fas fa-plus"></i>
</span>
<span>New Enquiry</span>
</a>
</div>
</div>
</div>
<!-- Search Box -->
<div class="box">
<div class="field has-addons">
<div class="control is-expanded">
<input
class="input"
type="text"
placeholder="Search enquiries, customers, or contacts..."
hx-get="/enquiries/search"
hx-trigger="keyup changed delay:300ms"
hx-target="#enquiry-table"
hx-include="[name='page']"
name="search">
</div>
<div class="control">
<button class="button is-info" type="button">Search</button>
</div>
</div>
</div>
<!-- Deleted Enquiries Toggle -->
<div class="box">
<div class="field is-grouped">
<div class="control">
<label class="label">Archived Enquiries:</label>
</div>
<div class="control">
<button
id="show-archived"
class="button is-small"
hx-get="/enquiries?archived=true"
hx-target="#enquiry-table">
Show
</button>
</div>
<div class="control">
<button
id="hide-archived"
class="button is-small"
hx-get="/enquiries"
hx-target="#enquiry-table">
Hide
</button>
</div>
</div>
</div>
<!-- Pagination Info -->
<div class="content">
<p>
Page {{.Page}} of pages, showing {{len .Enquiries}} Enquiries
</p>
</div>
<!-- Pagination Top -->
<nav class="pagination is-centered" role="navigation">
{{if gt .Page 1}}
<a class="pagination-previous"
hx-get="/enquiries?page={{.PrevPage}}"
hx-target="#enquiry-table">Previous</a>
{{else}}
<a class="pagination-previous" disabled>Previous</a>
{{end}}
{{if .HasMore}}
<a class="pagination-next"
hx-get="/enquiries?page={{.NextPage}}"
hx-target="#enquiry-table">Next page</a>
{{else}}
<a class="pagination-next" disabled>Next page</a>
{{end}}
</nav>
<!-- Enquiry Table -->
<div id="enquiry-table">
{{template "enquiry-table" .}}
</div>
<!-- Pagination Bottom -->
<nav class="pagination is-centered" role="navigation">
{{if gt .Page 1}}
<a class="pagination-previous"
hx-get="/enquiries?page={{.PrevPage}}"
hx-target="#enquiry-table">Previous</a>
{{else}}
<a class="pagination-previous" disabled>Previous</a>
{{end}}
{{if .HasMore}}
<a class="pagination-next"
hx-get="/enquiries?page={{.NextPage}}"
hx-target="#enquiry-table">Next page</a>
{{else}}
<a class="pagination-next" disabled>Next page</a>
{{end}}
</nav>
{{end}}
{{define "scripts"}}
<script>
// Handle status updates via HTMX
document.addEventListener('htmx:afterSwap', function(evt) {
// Re-initialize any JavaScript needed after HTMX updates
console.log('HTMX content updated');
});
</script>
{{end}}

View file

@ -0,0 +1,326 @@
{{define "title"}}Enquiry: {{.Enquiry.Title}} - CMC Sales{{end}}
{{define "content"}}
<!-- Header -->
<div class="level">
<div class="level-left">
<div class="level-item">
<h1 class="title">Enquiry: {{.Enquiry.Title}}</h1>
</div>
</div>
<div class="level-right">
<div class="level-item">
<div class="buttons">
<a href="/enquiries/{{.Enquiry.ID}}/edit" class="button is-warning">
<span class="icon">
<i class="fas fa-edit"></i>
</span>
<span>Edit</span>
</a>
<a href="/enquiries" class="button is-light">
<span class="icon">
<i class="fas fa-arrow-left"></i>
</span>
<span>Back to List</span>
</a>
</div>
</div>
</div>
</div>
<div class="columns">
<!-- Main Enquiry Details -->
<div class="column is-8">
<div class="card">
<header class="card-header">
<p class="card-header-title">Enquiry Details</p>
</header>
<div class="card-content">
<div class="content">
<div class="columns">
<div class="column">
<table class="table is-fullwidth">
<tbody>
<tr>
<td><strong>Enquiry Number:</strong></td>
<td>{{.Enquiry.Title}}</td>
</tr>
<tr>
<td><strong>Created:</strong></td>
<td>{{.Enquiry.Created.Format "2 Jan 2006 15:04"}}</td>
</tr>
{{if .Enquiry.Submitted.Valid}}
<tr>
<td><strong>Submitted:</strong></td>
<td>{{.Enquiry.Submitted.Time.Format "2 Jan 2006"}}</td>
</tr>
{{end}}
<tr>
<td><strong>Sales Rep:</strong></td>
<td>
{{if and .Enquiry.UserFirstName.Valid .Enquiry.UserLastName.Valid}}
<a href="/users/{{.Enquiry.UserID}}">
{{.Enquiry.UserFirstName.String}} {{.Enquiry.UserLastName.String}}
</a>
{{else}}
-
{{end}}
</td>
</tr>
<tr>
<td><strong>Status:</strong></td>
<td>
<span class="tag is-info">
{{if .Enquiry.StatusName.Valid}}
{{.Enquiry.StatusName.String}}
{{else}}
Unknown
{{end}}
</span>
</td>
</tr>
<tr>
<td><strong>Posted:</strong></td>
<td>
{{if .Enquiry.Posted}}
<span class="tag is-success">Yes</span>
{{else}}
<span class="tag is-warning">No</span>
{{end}}
</td>
</tr>
<tr>
<td><strong>GST:</strong></td>
<td>
{{if .Enquiry.Gst}}
<span class="tag is-success">Applicable</span>
{{else}}
<span class="tag is-light">Not Applicable</span>
{{end}}
</td>
</tr>
</tbody>
</table>
</div>
</div>
{{if .Enquiry.Comments}}
<div class="field">
<label class="label">Comments</label>
<div class="content">
<p style="white-space: pre-wrap;">{{.Enquiry.Comments}}</p>
</div>
</div>
{{end}}
</div>
</div>
</div>
<!-- Related Documents -->
<div class="card mt-5">
<header class="card-header">
<p class="card-header-title">Related Documents</p>
</header>
<div class="card-content">
<div class="content">
<div class="columns">
<div class="column">
<h6 class="subtitle is-6">Quick Stats</h6>
<div class="tags">
<span class="tag">{{.Enquiry.EmailCount}} Emails</span>
<span class="tag">{{.Enquiry.QuoteCount}} Quotes</span>
<span class="tag">{{.Enquiry.InvoiceCount}} Invoices</span>
<span class="tag">{{.Enquiry.JobCount}} Jobs</span>
</div>
</div>
</div>
<!-- Placeholder for related documents - would need additional queries -->
<div class="notification is-light">
<p><strong>Note:</strong> Related quotes, invoices, jobs, and email correspondence would be displayed here. These require additional database queries and relationships to be implemented.</p>
</div>
</div>
</div>
</div>
</div>
<!-- Sidebar -->
<div class="column is-4">
<!-- Customer Information -->
<div class="card">
<header class="card-header">
<p class="card-header-title">Customer</p>
</header>
<div class="card-content">
<div class="content">
{{if .Enquiry.CustomerName.Valid}}
<h6 class="subtitle is-6">
<a href="/customers/{{.Enquiry.CustomerID}}">{{.Enquiry.CustomerName.String}}</a>
</h6>
{{end}}
<table class="table is-fullwidth">
<tbody>
<tr>
<td><strong>Contact:</strong></td>
<td>
{{if and .Enquiry.ContactFirstName.Valid .Enquiry.ContactLastName.Valid}}
<a href="/users/{{.Enquiry.ContactUserID}}">
{{.Enquiry.ContactFirstName.String}} {{.Enquiry.ContactLastName.String}}
</a>
{{else}}
-
{{end}}
</td>
</tr>
{{if .Enquiry.ContactEmail.Valid}}
<tr>
<td><strong>Email:</strong></td>
<td>
<a href="mailto:{{.Enquiry.ContactEmail.String}}?subject={{.Enquiry.Title}}&bcc=carpis@cmctechnologies.com.au">
{{.Enquiry.ContactEmail.String}}
</a>
</td>
</tr>
{{end}}
<tr>
<td><strong>Phone:</strong></td>
<td>
{{if .Enquiry.ContactMobile.Valid}}
{{.Enquiry.ContactMobile.String}}
{{else if .Enquiry.ContactDirectPhone.Valid}}
{{.Enquiry.ContactDirectPhone.String}}
{{else if .Enquiry.ContactPhone.Valid}}
{{.Enquiry.ContactPhone.String}}
{{if .Enquiry.ContactPhoneExtension.Valid}}
ext:{{.Enquiry.ContactPhoneExtension.String}}
{{end}}
{{else}}
-
{{end}}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Principle Information -->
<div class="card mt-4">
<header class="card-header">
<p class="card-header-title">Principle (Supplier)</p>
</header>
<div class="card-content">
<div class="content">
{{if .Enquiry.PrincipleName.Valid}}
<h6 class="subtitle is-6">
<a href="/principles/{{.Enquiry.PrincipleID}}">
{{if .Enquiry.PrincipleShortName.Valid}}
{{.Enquiry.PrincipleShortName.String}}
{{else}}
{{.Enquiry.PrincipleName.String}}
{{end}}
</a>
</h6>
{{if and .Enquiry.PrincipleShortName.Valid .Enquiry.PrincipleName.Valid}}
<p class="is-size-7">{{.Enquiry.PrincipleName.String}}</p>
{{end}}
<p class="is-size-7">Code: {{.Enquiry.PrincipleCode}}</p>
{{end}}
</div>
</div>
</div>
<!-- Location Information -->
<div class="card mt-4">
<header class="card-header">
<p class="card-header-title">Location</p>
</header>
<div class="card-content">
<div class="content">
<table class="table is-fullwidth">
<tbody>
<tr>
<td><strong>State:</strong></td>
<td>
{{if .Enquiry.StateName.Valid}}
{{.Enquiry.StateName.String}}
{{else}}
-
{{end}}
</td>
</tr>
<tr>
<td><strong>Country:</strong></td>
<td>
{{if .Enquiry.CountryName.Valid}}
{{.Enquiry.CountryName.String}}
{{else}}
-
{{end}}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Actions -->
<div class="card mt-4">
<header class="card-header">
<p class="card-header-title">Actions</p>
</header>
<div class="card-content">
<div class="content">
<div class="buttons">
{{if not .Enquiry.Submitted.Valid}}
<button
class="button is-success is-fullwidth"
hx-put="/api/v1/enquiries/{{.Enquiry.ID}}/mark-submitted"
hx-confirm="Mark this enquiry as submitted today?"
hx-target="#main-content"
hx-swap="outerHTML">
Mark as Submitted
</button>
{{end}}
{{if .Enquiry.Archived}}
<button
class="button is-info is-fullwidth"
hx-put="/api/v1/enquiries/{{.Enquiry.ID}}/undelete"
hx-confirm="Undelete this enquiry?"
hx-target="#main-content"
hx-swap="outerHTML">
Undelete Enquiry
</button>
{{else}}
<button
class="button is-danger is-fullwidth"
hx-delete="/api/v1/enquiries/{{.Enquiry.ID}}"
hx-confirm="Are you sure you want to delete this enquiry?"
hx-target="#main-content"
hx-swap="outerHTML">
Delete Enquiry
</button>
{{end}}
</div>
</div>
</div>
</div>
</div>
</div>
{{end}}
{{define "scripts"}}
<script>
// Handle action responses
document.addEventListener('htmx:afterSwap', function(evt) {
if (evt.detail.xhr.status === 204) {
// For delete/undelete actions, redirect to enquiries list
window.location.href = '/enquiries';
}
});
</script>
{{end}}

View file

@ -0,0 +1,192 @@
{{define "enquiry-table"}}
<div class="table-container">
<table class="table is-fullwidth is-striped is-hoverable enquiry-table">
<thead>
<tr>
<th>User</th>
<th>Date</th>
<th>Principle</th>
<th>Enquiry Number</th>
<th>Customer</th>
<th>Contact</th>
<th>Email</th>
<th>Phone No</th>
<th>Status</th>
<th>Comments</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{range .Enquiries}}
{{$rowClass := ""}}
{{$nameClass := ""}}
{{/* Set row color based on status - matching CakePHP logic */}}
{{if eq .StatusID 3}}
{{$rowClass = "jobwon"}}
{{else if eq .StatusID 4}}
{{$rowClass = "joblost"}}
{{else if eq .StatusID 8}}
{{$rowClass = "joblost"}}
{{else if eq .StatusID 9}}
{{$rowClass = "joblost"}}
{{else if eq .StatusID 10}}
{{$rowClass = "joblost"}}
{{else if eq .StatusID 6}}
{{$rowClass = "information"}}
{{else if eq .StatusID 11}}
{{$rowClass = "informationsent"}}
{{else if eq .StatusID 5}}
{{$rowClass = "quoted"}}
{{else if eq .StatusID 1}}
{{$rowClass = "requestforquote"}}
{{end}}
{{/* Set name class based on posted status */}}
{{if .Posted}}
{{$nameClass = "posted"}}
{{else}}
{{$nameClass = "notposted"}}
{{end}}
<tr class="{{$rowClass}} {{if .Archived}}archived-row{{end}}"
id="row{{.ID}}"
data-archived="{{.Archived}}">
<!-- User (initials) -->
<td class="{{$nameClass}}">
{{if and .UserFirstName.Valid .UserLastName.Valid}}
<a href="/users/{{.UserID}}" title="{{.UserFirstName.String}} {{.UserLastName.String}}">
{{slice .UserFirstName.String 0 1}}{{slice .UserLastName.String 0 1}}
</a>
{{else}}
-
{{end}}
</td>
<!-- Date -->
<td class="enqdate">
{{.Created.Format "2 Jan 2006"}}
</td>
<!-- Principle -->
<td class="principlename">
{{if .PrincipleShortName.Valid}}
<a href="/principles/{{.PrincipleID}}">{{.PrincipleShortName.String}}</a>
{{else if .PrincipleName.Valid}}
<a href="/principles/{{.PrincipleID}}">{{.PrincipleName.String}}</a>
{{else}}
-
{{end}}
</td>
<!-- Enquiry Number -->
<td>
<a href="/enquiries/{{.ID}}">{{.Title}}</a>
</td>
<!-- Customer -->
<td class="customername">
{{if .CustomerName.Valid}}
<a href="/customers/{{.CustomerID}}">{{.CustomerName.String}}</a>
{{else}}
-
{{end}}
</td>
<!-- Contact -->
<td class="contactname">
{{if and .ContactFirstName.Valid .ContactLastName.Valid}}
<a href="/users/{{.ContactUserID}}">{{.ContactFirstName.String}} {{.ContactLastName.String}}</a>
{{else}}
-
{{end}}
</td>
<!-- Email -->
<td class="contactemail">
{{if .ContactEmail.Valid}}
<a href="mailto:{{.ContactEmail.String}}?subject={{.Title}}&bcc=carpis@cmctechnologies.com.au">
{{.ContactEmail.String}}
</a>
{{else}}
-
{{end}}
</td>
<!-- Phone -->
<td class="contactphone">
{{if .ContactMobile.Valid}}
{{.ContactMobile.String}}
{{else if .ContactDirectPhone.Valid}}
{{.ContactDirectPhone.String}}
{{else if .ContactPhone.Valid}}
{{.ContactPhone.String}}
{{if .ContactPhoneExtension.Valid}}
ext:{{.ContactPhoneExtension.String}}
{{end}}
{{else}}
-
{{end}}
</td>
<!-- Status (editable) -->
<td class="statusTD">
<div class="status" id="{{.ID}}">
{{if .StatusName.Valid}}
{{.StatusName.String}}
{{else}}
-
{{end}}
</div>
</td>
<!-- Comments -->
<td class="comments">
{{if gt (len .Comments) 150}}
{{slice .Comments 0 150}}
<a href="/enquiries/{{.ID}}">.....more</a>
{{else}}
{{.Comments}}
{{end}}
</td>
<!-- Actions -->
<td class="viewedit">
<div class="buttons are-small">
<a href="/enquiries/{{.ID}}" class="button is-info is-small">View</a>
<a href="/enquiries/{{.ID}}/edit" class="button is-warning is-small">Edit</a>
{{if .Archived}}
<button
class="button is-success is-small"
hx-put="/api/v1/enquiries/{{.ID}}/undelete"
hx-confirm="Are you sure you want to undelete this enquiry?"
hx-target="#row{{.ID}}"
hx-swap="outerHTML">
Undelete
</button>
{{else}}
<button
class="button is-danger is-small"
hx-delete="/api/v1/enquiries/{{.ID}}"
hx-confirm="Are you sure you want to delete this enquiry?"
hx-target="#row{{.ID}}"
hx-swap="outerHTML">
Delete
</button>
{{end}}
</div>
</td>
</tr>
{{else}}
<tr>
<td colspan="11" class="has-text-centered">
<em>No enquiries found.</em>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{end}}

120
go-app/templates/index.html Normal file
View file

@ -0,0 +1,120 @@
{{define "title"}}Dashboard - CMC Sales{{end}}
{{define "content"}}
<div class="columns">
<div class="column">
<h1 class="title">CMC Sales Dashboard</h1>
<p class="subtitle">Welcome to the modern CMC Sales management system</p>
</div>
</div>
<div class="columns">
<!-- Customers Card -->
<div class="column is-3">
<div class="card">
<div class="card-content">
<div class="media">
<div class="media-left">
<span class="icon is-large has-text-primary">
<i class="fas fa-users fa-2x"></i>
</span>
</div>
<div class="media-content">
<p class="title is-4">Customers</p>
<p class="subtitle is-6">Manage customer accounts</p>
</div>
</div>
<div class="content">
<a href="/customers" class="button is-primary is-fullwidth">
View Customers
</a>
</div>
</div>
</div>
</div>
<!-- Products Card -->
<div class="column is-3">
<div class="card">
<div class="card-content">
<div class="media">
<div class="media-left">
<span class="icon is-large has-text-info">
<i class="fas fa-box fa-2x"></i>
</span>
</div>
<div class="media-content">
<p class="title is-4">Products</p>
<p class="subtitle is-6">Product catalog management</p>
</div>
</div>
<div class="content">
<a href="/products" class="button is-info is-fullwidth">
View Products
</a>
</div>
</div>
</div>
</div>
<!-- Purchase Orders Card -->
<div class="column is-3">
<div class="card">
<div class="card-content">
<div class="media">
<div class="media-left">
<span class="icon is-large has-text-success">
<i class="fas fa-file-invoice fa-2x"></i>
</span>
</div>
<div class="media-content">
<p class="title is-4">Purchase Orders</p>
<p class="subtitle is-6">Track and manage orders</p>
</div>
</div>
<div class="content">
<a href="/purchase-orders" class="button is-success is-fullwidth">
View Orders
</a>
</div>
</div>
</div>
</div>
<!-- Enquiries Card -->
<div class="column is-3">
<div class="card">
<div class="card-content">
<div class="media">
<div class="media-left">
<span class="icon is-large has-text-warning">
<i class="fas fa-envelope fa-2x"></i>
</span>
</div>
<div class="media-content">
<p class="title is-4">Enquiries</p>
<p class="subtitle is-6">Sales enquiry management</p>
</div>
</div>
<div class="content">
<a href="/enquiries" class="button is-warning is-fullwidth">
View Enquiries
</a>
</div>
</div>
</div>
</div>
</div>
<!-- Recent Activity Section -->
<div class="box mt-5">
<h2 class="title is-4">Recent Activity</h2>
<div id="recent-activity" hx-get="/api/recent-activity" hx-trigger="load">
<div class="has-text-centered">
<span class="icon is-large">
<i class="fas fa-spinner fa-pulse"></i>
</span>
</div>
</div>
</div>
{{end}}

View file

@ -0,0 +1,106 @@
{{define "base"}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{block "title" .}}CMC Sales{{end}}</title>
<!-- Bulma CSS -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css">
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- HTMX -->
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<!-- Custom CSS -->
<link rel="stylesheet" href="/static/css/style.css">
{{block "head" .}}{{end}}
</head>
<body>
<!-- Navigation -->
<nav class="navbar is-primary" role="navigation" aria-label="main navigation">
<div class="navbar-brand">
<a class="navbar-item" href="/">
<strong>CMC Sales</strong>
</a>
<a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" data-target="navbarMain">
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
</div>
<div id="navbarMain" class="navbar-menu">
<div class="navbar-start">
<a class="navbar-item" href="/">
<span class="icon"><i class="fas fa-home"></i></span>
<span>Dashboard</span>
</a>
<a class="navbar-item" href="/customers">
<span class="icon"><i class="fas fa-users"></i></span>
<span>Customers</span>
</a>
<a class="navbar-item" href="/products">
<span class="icon"><i class="fas fa-box"></i></span>
<span>Products</span>
</a>
<a class="navbar-item" href="/purchase-orders">
<span class="icon"><i class="fas fa-file-invoice"></i></span>
<span>Purchase Orders</span>
</a>
<a class="navbar-item" href="/enquiries">
<span class="icon"><i class="fas fa-envelope"></i></span>
<span>Enquiries</span>
</a>
</div>
</div>
</nav>
<!-- Main Content -->
<section class="section">
<div class="container">
{{block "content" .}}{{end}}
</div>
</section>
<!-- Footer -->
<footer class="footer">
<div class="content has-text-centered">
<p>
<strong>CMC Sales</strong> &copy; 2024 CMC Technologies
</p>
</div>
</footer>
<!-- Custom JS -->
<script src="/static/js/app.js"></script>
<!-- Navbar toggle script -->
<script>
document.addEventListener('DOMContentLoaded', () => {
const $navbarBurgers = Array.prototype.slice.call(document.querySelectorAll('.navbar-burger'), 0);
$navbarBurgers.forEach( el => {
el.addEventListener('click', () => {
const target = el.dataset.target;
const $target = document.getElementById(target);
el.classList.toggle('is-active');
$target.classList.toggle('is-active');
});
});
});
</script>
{{block "scripts" .}}{{end}}
</body>
</html>
{{end}}

View file

@ -0,0 +1,6 @@
{{define "notification"}}
<div class="notification {{.Type}}">
<button class="delete"></button>
{{.Message}}
</div>
{{end}}

View file

@ -0,0 +1,134 @@
{{define "title"}}{{if .Product.ID}}Edit{{else}}New{{end}} Product - CMC Sales{{end}}
{{define "content"}}
<div class="columns is-centered">
<div class="column is-8">
<h1 class="title">{{if .Product.ID}}Edit{{else}}New{{end}} Product</h1>
<form {{if .Product.ID}}
hx-put="/products/{{.Product.ID}}"
{{else}}
hx-post="/products"
{{end}}
hx-target="#form-response">
<div class="field">
<label class="label">Title</label>
<div class="control">
<input class="input" type="text" name="title"
placeholder="Product Title"
value="{{.Product.Title}}" required>
</div>
<p class="help">This must match the Title in the Excel Costing File</p>
</div>
<div class="field">
<label class="label">Item Code</label>
<div class="control">
<input class="input" type="text" name="item_code"
placeholder="Item Code"
value="{{.Product.ItemCode}}" required>
</div>
</div>
<div class="field">
<label class="label">Item Description</label>
<div class="control">
<input class="input" type="text" name="item_description"
placeholder="Item Description"
value="{{.Product.ItemDescription}}" required>
</div>
</div>
<div class="field">
<label class="label">Model Number</label>
<div class="control">
<input class="input" type="text" name="model_number"
placeholder="Model Number"
value="{{if .Product.ModelNumber.Valid}}{{.Product.ModelNumber.String}}{{end}}">
</div>
</div>
<div class="field">
<label class="label">Model Number Format</label>
<div class="control">
<input class="input" type="text" name="model_number_format"
placeholder="%1% - first item, %2% - second item etc"
value="{{if .Product.ModelNumberFormat.Valid}}{{.Product.ModelNumberFormat.String}}{{end}}">
</div>
</div>
<div class="field">
<label class="label">Stock Type</label>
<div class="control">
<div class="select is-fullwidth">
<select name="stock">
<option value="1" {{if .Product.Stock}}selected{{end}}>Stock</option>
<option value="0" {{if not .Product.Stock}}selected{{end}}>Indent</option>
</select>
</div>
</div>
</div>
<div class="field">
<label class="label">Category</label>
<div class="control">
<div class="select is-fullwidth">
<select name="product_category_id">
<option value="1" {{if eq .Product.ProductCategoryID 1}}selected{{end}}>Category 1</option>
<option value="2" {{if eq .Product.ProductCategoryID 2}}selected{{end}}>Category 2</option>
<option value="3" {{if eq .Product.ProductCategoryID 3}}selected{{end}}>Category 3</option>
</select>
</div>
</div>
</div>
<div class="field">
<label class="label">Principle</label>
<div class="control">
<div class="select is-fullwidth">
<select name="principle_id">
<option value="1" {{if eq .Product.PrincipleID 1}}selected{{end}}>Principle 1</option>
<option value="2" {{if eq .Product.PrincipleID 2}}selected{{end}}>Principle 2</option>
<option value="3" {{if eq .Product.PrincipleID 3}}selected{{end}}>Principle 3</option>
</select>
</div>
</div>
</div>
<div class="field">
<label class="label">Description</label>
<div class="control">
<textarea class="textarea" name="description"
placeholder="Product description...">{{.Product.Description}}</textarea>
</div>
</div>
<div class="field">
<label class="label">Notes</label>
<div class="control">
<textarea class="textarea" name="notes"
placeholder="Product notes...">{{if .Product.Notes.Valid}}{{.Product.Notes.String}}{{end}}</textarea>
</div>
<p class="help">Notes displayed on quotes</p>
</div>
<div id="form-response"></div>
<div class="field is-grouped">
<div class="control">
<button class="button is-primary" type="submit">
<span class="icon">
<i class="fas fa-save"></i>
</span>
<span>Save</span>
</button>
</div>
<div class="control">
<a href="/products" class="button is-light">Cancel</a>
</div>
</div>
</form>
</div>
</div>
{{end}}

View file

@ -0,0 +1,67 @@
{{define "title"}}Products - CMC Sales{{end}}
{{define "content"}}
<div class="level">
<div class="level-left">
<div class="level-item">
<h1 class="title">Products</h1>
</div>
</div>
<div class="level-right">
<div class="level-item">
<a href="/products/new" class="button is-primary">
<span class="icon">
<i class="fas fa-plus"></i>
</span>
<span>New Product</span>
</a>
</div>
</div>
</div>
<!-- Search and Filter Box -->
<div class="box">
<div class="columns">
<div class="column">
<div class="field has-addons">
<div class="control has-icons-left is-expanded">
<input class="input" type="text" placeholder="Search products..."
name="search" id="product-search"
hx-get="/products/search"
hx-trigger="keyup changed delay:500ms"
hx-target="#product-table-container">
<span class="icon is-left">
<i class="fas fa-search"></i>
</span>
</div>
<div class="control">
<button class="button is-info" onclick="document.getElementById('product-search').value=''; document.getElementById('product-search').dispatchEvent(new Event('keyup'))">
Clear
</button>
</div>
</div>
</div>
<div class="column is-narrow">
<div class="field">
<div class="control">
<div class="select">
<select name="category"
hx-get="/products/filter"
hx-trigger="change"
hx-target="#product-table-container">
<option value="">All Categories</option>
<option value="1">Category 1</option>
<option value="2">Category 2</option>
</select>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Product Table Container -->
<div id="product-table-container">
{{template "product-table" .}}
</div>
{{end}}

View file

@ -0,0 +1,125 @@
{{define "title"}}{{.Product.Title}} - CMC Sales{{end}}
{{define "content"}}
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li><a href="/">Home</a></li>
<li><a href="/products">Products</a></li>
<li class="is-active"><a href="#" aria-current="page">{{.Product.Title}}</a></li>
</ul>
</nav>
<div class="level">
<div class="level-left">
<div class="level-item">
<h1 class="title">{{.Product.Title}}</h1>
</div>
</div>
<div class="level-right">
<div class="level-item">
<a href="/products/{{.Product.ID}}/edit" class="button is-info">
<span class="icon">
<i class="fas fa-edit"></i>
</span>
<span>Edit</span>
</a>
</div>
</div>
</div>
<div class="columns">
<div class="column is-8">
<div class="box">
<h2 class="subtitle">Product Information</h2>
<table class="table is-fullwidth">
<tbody>
<tr>
<th>Title:</th>
<td>{{.Product.Title}}</td>
</tr>
<tr>
<th>Item Code:</th>
<td><span class="tag is-info">{{.Product.ItemCode}}</span></td>
</tr>
<tr>
<th>Item Description:</th>
<td>{{.Product.ItemDescription}}</td>
</tr>
<tr>
<th>Model Number:</th>
<td>{{if .Product.ModelNumber.Valid}}{{.Product.ModelNumber.String}}{{else}}<span class="has-text-grey">Not specified</span>{{end}}</td>
</tr>
<tr>
<th>Stock Type:</th>
<td>
{{if .Product.Stock}}
<span class="tag is-success">Stock</span>
{{else}}
<span class="tag is-warning">Indent</span>
{{end}}
</td>
</tr>
<tr>
<th>Category:</th>
<td>Category {{.Product.ProductCategoryID}}</td>
</tr>
<tr>
<th>Principle:</th>
<td>Principle {{.Product.PrincipleID}}</td>
</tr>
</tbody>
</table>
</div>
{{if .Product.Description}}
<div class="box">
<h2 class="subtitle">Description</h2>
<div class="content">
<p>{{.Product.Description}}</p>
</div>
</div>
{{end}}
{{if .Product.Notes.Valid}}
<div class="box">
<h2 class="subtitle">Notes</h2>
<div class="content">
<p>{{.Product.Notes.String}}</p>
</div>
</div>
{{end}}
</div>
<div class="column is-4">
<div class="box">
<h2 class="subtitle">Quick Actions</h2>
<div class="buttons is-fullwidth">
<a href="/quotes/new?product_id={{.Product.ID}}" class="button is-primary is-fullwidth">
<span class="icon">
<i class="fas fa-file-invoice-dollar"></i>
</span>
<span>Add to Quote</span>
</a>
<a href="/purchase-orders/new?product_id={{.Product.ID}}" class="button is-info is-fullwidth">
<span class="icon">
<i class="fas fa-shopping-cart"></i>
</span>
<span>Create PO</span>
</a>
</div>
</div>
<div class="box">
<h2 class="subtitle">Product Usage</h2>
<div id="product-usage" hx-get="/api/products/{{.Product.ID}}/usage" hx-trigger="load">
<div class="has-text-centered">
<span class="icon">
<i class="fas fa-spinner fa-pulse"></i>
</span>
</div>
</div>
</div>
</div>
</div>
{{end}}

View file

@ -0,0 +1,78 @@
{{define "product-table"}}
<div class="table-container">
<table class="table is-fullwidth is-striped is-hoverable">
<thead>
<tr>
<th>ID</th>
<th>Title</th>
<th>Item Code</th>
<th>Description</th>
<th>Model Number</th>
<th>Stock Type</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{range .Products}}
<tr>
<td>{{.ID}}</td>
<td>
<a href="/products/{{.ID}}">{{.Title}}</a>
</td>
<td>
<span class="tag is-info">{{.ItemCode}}</span>
</td>
<td>{{truncate .Description 50}}</td>
<td>{{if .ModelNumber.Valid}}{{.ModelNumber.String}}{{else}}<span class="has-text-grey">-</span>{{end}}</td>
<td>
{{if .Stock}}
<span class="tag is-success">Stock</span>
{{else}}
<span class="tag is-warning">Indent</span>
{{end}}
</td>
<td>
<div class="buttons are-small">
<a href="/products/{{.ID}}/edit" class="button is-info is-outlined">
<span class="icon">
<i class="fas fa-edit"></i>
</span>
</a>
<button class="button is-danger is-outlined"
hx-delete="/products/{{.ID}}"
hx-confirm="Are you sure you want to delete this product?"
hx-target="closest tr"
hx-swap="outerHTML">
<span class="icon">
<i class="fas fa-trash"></i>
</span>
</button>
</div>
</td>
</tr>
{{else}}
<tr>
<td colspan="7" class="has-text-centered">
<p class="has-text-grey">No products found</p>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<!-- Pagination -->
{{if .Products}}
<nav class="pagination is-centered" role="navigation" aria-label="pagination">
<a class="pagination-previous" {{if eq .Page 1}}disabled{{end}}
hx-get="/products?page={{.PrevPage}}"
hx-target="#product-table-container">Previous</a>
<a class="pagination-next" {{if not .HasMore}}disabled{{end}}
hx-get="/products?page={{.NextPage}}"
hx-target="#product-table-container">Next</a>
<ul class="pagination-list">
<li><span class="pagination-ellipsis">Page {{.Page}}</span></li>
</ul>
</nav>
{{end}}
{{end}}

View file

@ -0,0 +1,177 @@
{{define "title"}}{{if .PurchaseOrder.ID}}Edit{{else}}New{{end}} Purchase Order - CMC Sales{{end}}
{{define "content"}}
<div class="columns is-centered">
<div class="column is-10">
<h1 class="title">{{if .PurchaseOrder.ID}}Edit{{else}}New{{end}} Purchase Order</h1>
<form {{if .PurchaseOrder.ID}}
hx-put="/purchase-orders/{{.PurchaseOrder.ID}}"
{{else}}
hx-post="/purchase-orders"
{{end}}
hx-target="#form-response">
<div class="columns">
<div class="column">
<div class="field">
<label class="label">PO Number</label>
<div class="control">
<input class="input" type="text" name="title"
placeholder="CMC PO Number"
value="{{.PurchaseOrder.Title}}" required>
</div>
</div>
<div class="field">
<label class="label">Issue Date</label>
<div class="control">
<input class="input" type="date" name="issue_date"
value="{{.PurchaseOrder.IssueDate}}" required>
</div>
</div>
<div class="field">
<label class="label">Dispatch Date</label>
<div class="control">
<input class="input" type="date" name="dispatch_date"
value="{{.PurchaseOrder.DispatchDate}}">
</div>
</div>
<div class="field">
<label class="label">Date Arrived</label>
<div class="control">
<input class="input" type="date" name="date_arrived"
value="{{.PurchaseOrder.DateArrived}}">
</div>
</div>
</div>
<div class="column">
<div class="field">
<label class="label">Principle Reference</label>
<div class="control">
<input class="input" type="text" name="principle_reference"
placeholder="Principle Reference"
value="{{.PurchaseOrder.PrincipleReference}}" required>
</div>
</div>
<div class="field">
<label class="label">Principle</label>
<div class="control">
<div class="select is-fullwidth">
<select name="principle_id">
<option value="1" {{if eq .PurchaseOrder.PrincipleID 1}}selected{{end}}>Principle 1</option>
<option value="2" {{if eq .PurchaseOrder.PrincipleID 2}}selected{{end}}>Principle 2</option>
<option value="3" {{if eq .PurchaseOrder.PrincipleID 3}}selected{{end}}>Principle 3</option>
</select>
</div>
</div>
</div>
<div class="field">
<label class="label">Currency</label>
<div class="control">
<div class="select is-fullwidth">
<select name="currency_id">
<option value="">Select Currency</option>
<option value="1" {{if .PurchaseOrder.CurrencyID.Valid}}{{if eq .PurchaseOrder.CurrencyID.Int32 1}}selected{{end}}{{end}}>AUD</option>
<option value="2" {{if .PurchaseOrder.CurrencyID.Valid}}{{if eq .PurchaseOrder.CurrencyID.Int32 2}}selected{{end}}{{end}}>USD</option>
<option value="3" {{if .PurchaseOrder.CurrencyID.Valid}}{{if eq .PurchaseOrder.CurrencyID.Int32 3}}selected{{end}}{{end}}>EUR</option>
</select>
</div>
</div>
</div>
<div class="field">
<label class="label">Dispatch By</label>
<div class="control">
<input class="input" type="text" name="dispatch_by"
placeholder="Dispatch method"
value="{{.PurchaseOrder.DispatchBy}}">
</div>
</div>
</div>
</div>
<div class="field">
<label class="label">Ordered From</label>
<div class="control">
<textarea class="textarea" name="ordered_from"
placeholder="Supplier address and contact details..."
rows="4" required>{{.PurchaseOrder.OrderedFrom}}</textarea>
</div>
</div>
<div class="field">
<label class="label">Deliver To</label>
<div class="control">
<textarea class="textarea" name="deliver_to"
placeholder="Delivery address..."
rows="4" required>{{.PurchaseOrder.DeliverTo}}</textarea>
</div>
</div>
<div class="field">
<label class="label">Description</label>
<div class="control">
<textarea class="textarea" name="description"
placeholder="Order description..."
rows="3">{{.PurchaseOrder.Description}}</textarea>
</div>
</div>
<div class="field">
<label class="label">Shipping Instructions</label>
<div class="control">
<textarea class="textarea" name="shipping_instructions"
placeholder="Special shipping instructions..."
rows="3">{{.PurchaseOrder.ShippingInstructions}}</textarea>
</div>
</div>
<div class="columns">
<div class="column">
<div class="field">
<label class="label">Related Jobs</label>
<div class="control">
<input class="input" type="text" name="jobs_text"
placeholder="Job references"
value="{{.PurchaseOrder.JobsText}}">
</div>
</div>
</div>
<div class="column">
<div class="field">
<label class="label">Freight Forwarder</label>
<div class="control">
<input class="input" type="text" name="freight_forwarder_text"
placeholder="Freight forwarder details"
value="{{.PurchaseOrder.FreightForwarderText}}">
</div>
</div>
</div>
</div>
<div id="form-response"></div>
<div class="field is-grouped">
<div class="control">
<button class="button is-primary" type="submit">
<span class="icon">
<i class="fas fa-save"></i>
</span>
<span>Save</span>
</button>
</div>
<div class="control">
<a href="/purchase-orders" class="button is-light">Cancel</a>
</div>
</div>
</form>
</div>
</div>
{{end}}

View file

@ -0,0 +1,68 @@
{{define "title"}}Purchase Orders - CMC Sales{{end}}
{{define "content"}}
<div class="level">
<div class="level-left">
<div class="level-item">
<h1 class="title">Purchase Orders</h1>
</div>
</div>
<div class="level-right">
<div class="level-item">
<a href="/purchase-orders/new" class="button is-primary">
<span class="icon">
<i class="fas fa-plus"></i>
</span>
<span>New Purchase Order</span>
</a>
</div>
</div>
</div>
<!-- Search and Filter Box -->
<div class="box">
<div class="columns">
<div class="column">
<div class="field has-addons">
<div class="control has-icons-left is-expanded">
<input class="input" type="text" placeholder="Search purchase orders..."
name="search" id="po-search"
hx-get="/purchase-orders/search"
hx-trigger="keyup changed delay:500ms"
hx-target="#po-table-container">
<span class="icon is-left">
<i class="fas fa-search"></i>
</span>
</div>
<div class="control">
<button class="button is-info" onclick="document.getElementById('po-search').value=''; document.getElementById('po-search').dispatchEvent(new Event('keyup'))">
Clear
</button>
</div>
</div>
</div>
<div class="column is-narrow">
<div class="field">
<div class="control">
<div class="select">
<select name="status"
hx-get="/purchase-orders/filter"
hx-trigger="change"
hx-target="#po-table-container">
<option value="">All Status</option>
<option value="pending">Pending</option>
<option value="dispatched">Dispatched</option>
<option value="arrived">Arrived</option>
</select>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Purchase Order Table Container -->
<div id="po-table-container">
{{template "purchase-order-table" .}}
</div>
{{end}}

View file

@ -0,0 +1,159 @@
{{define "title"}}PO {{.PurchaseOrder.Title}} - CMC Sales{{end}}
{{define "content"}}
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li><a href="/">Home</a></li>
<li><a href="/purchase-orders">Purchase Orders</a></li>
<li class="is-active"><a href="#" aria-current="page">{{.PurchaseOrder.Title}}</a></li>
</ul>
</nav>
<div class="level">
<div class="level-left">
<div class="level-item">
<h1 class="title">Purchase Order {{.PurchaseOrder.Title}}</h1>
</div>
</div>
<div class="level-right">
<div class="level-item">
<a href="/purchase-orders/{{.PurchaseOrder.ID}}/edit" class="button is-info">
<span class="icon">
<i class="fas fa-edit"></i>
</span>
<span>Edit</span>
</a>
</div>
</div>
</div>
<div class="columns">
<div class="column is-8">
<div class="box">
<h2 class="subtitle">Order Information</h2>
<table class="table is-fullwidth">
<tbody>
<tr>
<th>PO Number:</th>
<td>{{.PurchaseOrder.Title}}</td>
</tr>
<tr>
<th>Issue Date:</th>
<td>{{formatDate .PurchaseOrder.IssueDate}}</td>
</tr>
<tr>
<th>Dispatch Date:</th>
<td>{{formatDate .PurchaseOrder.DispatchDate}}</td>
</tr>
<tr>
<th>Date Arrived:</th>
<td>
{{if .PurchaseOrder.DateArrived}}
{{formatDate .PurchaseOrder.DateArrived}}
{{else}}
<span class="has-text-grey">Not arrived</span>
{{end}}
</td>
</tr>
<tr>
<th>Principle Reference:</th>
<td>{{.PurchaseOrder.PrincipleReference}}</td>
</tr>
<tr>
<th>Currency:</th>
<td>{{if .PurchaseOrder.CurrencyID.Valid}}Currency {{.PurchaseOrder.CurrencyID.Int32}}{{else}}<span class="has-text-grey">Not specified</span>{{end}}</td>
</tr>
<tr>
<th>Dispatch By:</th>
<td>{{.PurchaseOrder.DispatchBy}}</td>
</tr>
</tbody>
</table>
</div>
<div class="box">
<h2 class="subtitle">Ordered From</h2>
<div class="content">
<pre>{{.PurchaseOrder.OrderedFrom}}</pre>
</div>
</div>
<div class="box">
<h2 class="subtitle">Deliver To</h2>
<div class="content">
<pre>{{.PurchaseOrder.DeliverTo}}</pre>
</div>
</div>
{{if .PurchaseOrder.Description}}
<div class="box">
<h2 class="subtitle">Description</h2>
<div class="content">
<p>{{.PurchaseOrder.Description}}</p>
</div>
</div>
{{end}}
{{if .PurchaseOrder.ShippingInstructions}}
<div class="box">
<h2 class="subtitle">Shipping Instructions</h2>
<div class="content">
<p>{{.PurchaseOrder.ShippingInstructions}}</p>
</div>
</div>
{{end}}
</div>
<div class="column is-4">
<div class="box">
<h2 class="subtitle">Status</h2>
<div class="content">
{{if .PurchaseOrder.DateArrived}}
<span class="tag is-success is-large">Arrived</span>
{{else if .PurchaseOrder.DispatchDate}}
<span class="tag is-warning is-large">Dispatched</span>
{{else}}
<span class="tag is-info is-large">Pending</span>
{{end}}
</div>
</div>
<div class="box">
<h2 class="subtitle">Quick Actions</h2>
<div class="buttons is-fullwidth">
<a href="/purchase-orders/{{.PurchaseOrder.ID}}/revise" class="button is-primary is-fullwidth">
<span class="icon">
<i class="fas fa-copy"></i>
</span>
<span>Create Revision</span>
</a>
<a href="/purchase-orders/{{.PurchaseOrder.ID}}/pdf" class="button is-info is-fullwidth">
<span class="icon">
<i class="fas fa-file-pdf"></i>
</span>
<span>Export PDF</span>
</a>
</div>
</div>
{{if .PurchaseOrder.JobsText}}
<div class="box">
<h2 class="subtitle">Related Jobs</h2>
<div class="content">
<p>{{.PurchaseOrder.JobsText}}</p>
</div>
</div>
{{end}}
{{if .PurchaseOrder.FreightForwarderText}}
<div class="box">
<h2 class="subtitle">Freight Forwarder</h2>
<div class="content">
<p>{{.PurchaseOrder.FreightForwarderText}}</p>
</div>
</div>
{{end}}
</div>
</div>
{{end}}

View file

@ -0,0 +1,78 @@
{{define "purchase-order-table"}}
<div class="table-container">
<table class="table is-fullwidth is-striped is-hoverable">
<thead>
<tr>
<th>PO Number</th>
<th>Issue Date</th>
<th>Dispatch Date</th>
<th>Ordered From</th>
<th>Description</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{range .PurchaseOrders}}
<tr>
<td>
<a href="/purchase-orders/{{.ID}}">{{.Title}}</a>
</td>
<td>{{formatDate .IssueDate}}</td>
<td>{{formatDate .DispatchDate}}</td>
<td>{{truncate .OrderedFrom 30}}</td>
<td>{{truncate .Description 50}}</td>
<td>
{{if .DateArrived}}
<span class="tag is-success">Arrived</span>
{{else if .DispatchDate}}
<span class="tag is-warning">Dispatched</span>
{{else}}
<span class="tag is-info">Pending</span>
{{end}}
</td>
<td>
<div class="buttons are-small">
<a href="/purchase-orders/{{.ID}}/edit" class="button is-info is-outlined">
<span class="icon">
<i class="fas fa-edit"></i>
</span>
</a>
<button class="button is-danger is-outlined"
hx-delete="/purchase-orders/{{.ID}}"
hx-confirm="Are you sure you want to delete this purchase order?"
hx-target="closest tr"
hx-swap="outerHTML">
<span class="icon">
<i class="fas fa-trash"></i>
</span>
</button>
</div>
</td>
</tr>
{{else}}
<tr>
<td colspan="7" class="has-text-centered">
<p class="has-text-grey">No purchase orders found</p>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<!-- Pagination -->
{{if .PurchaseOrders}}
<nav class="pagination is-centered" role="navigation" aria-label="pagination">
<a class="pagination-previous" {{if eq .Page 1}}disabled{{end}}
hx-get="/purchase-orders?page={{.PrevPage}}"
hx-target="#po-table-container">Previous</a>
<a class="pagination-next" {{if not .HasMore}}disabled{{end}}
hx-get="/purchase-orders?page={{.NextPage}}"
hx-target="#po-table-container">Next</a>
<ul class="pagination-list">
<li><span class="pagination-ellipsis">Page {{.Page}}</span></li>
</ul>
</nav>
{{end}}
{{end}}