Add the new go app
This commit is contained in:
parent
d8b43cc9c7
commit
e3442c29cc
122
CLAUDE.md
Normal file
122
CLAUDE.md
Normal 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
50
Dockerfile.go
Normal 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"]
|
||||
46
README.md
46
README.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
9
go-app/.env.example
Normal 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
33
go-app/.gitignore
vendored
Normal 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
46
go-app/Makefile
Normal 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
123
go-app/README.md
Normal 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
176
go-app/cmd/server/main.go
Normal 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
9
go-app/go.mod
Normal 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
6
go-app/go.sum
Normal 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=
|
||||
250
go-app/internal/cmc/db/customers.sql.go
Normal file
250
go-app/internal/cmc/db/customers.sql.go
Normal 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
|
||||
}
|
||||
31
go-app/internal/cmc/db/db.go
Normal file
31
go-app/internal/cmc/db/db.go
Normal 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,
|
||||
}
|
||||
}
|
||||
1059
go-app/internal/cmc/db/enquiries.sql.go
Normal file
1059
go-app/internal/cmc/db/enquiries.sql.go
Normal file
File diff suppressed because it is too large
Load diff
237
go-app/internal/cmc/db/models.go
Normal file
237
go-app/internal/cmc/db/models.go
Normal 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"`
|
||||
}
|
||||
296
go-app/internal/cmc/db/products.sql.go
Normal file
296
go-app/internal/cmc/db/products.sql.go
Normal 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
|
||||
}
|
||||
375
go-app/internal/cmc/db/purchase_orders.sql.go
Normal file
375
go-app/internal/cmc/db/purchase_orders.sql.go
Normal 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
|
||||
}
|
||||
58
go-app/internal/cmc/db/querier.go
Normal file
58
go-app/internal/cmc/db/querier.go
Normal 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)
|
||||
205
go-app/internal/cmc/handlers/customers.go
Normal file
205
go-app/internal/cmc/handlers/customers.go
Normal 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(¶ms); 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(¶ms); 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)
|
||||
}
|
||||
344
go-app/internal/cmc/handlers/enquiry.go
Normal file
344
go-app/internal/cmc/handlers/enquiry.go
Normal 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)
|
||||
}
|
||||
603
go-app/internal/cmc/handlers/pages.go
Normal file
603
go-app/internal/cmc/handlers/pages.go
Normal 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)
|
||||
}
|
||||
}
|
||||
171
go-app/internal/cmc/handlers/products.go
Normal file
171
go-app/internal/cmc/handlers/products.go
Normal 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(¶ms); 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(¶ms); 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)
|
||||
}
|
||||
171
go-app/internal/cmc/handlers/purchase_orders.go
Normal file
171
go-app/internal/cmc/handlers/purchase_orders.go
Normal 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(¶ms); 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(¶ms); 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)
|
||||
}
|
||||
128
go-app/internal/cmc/templates/templates.go
Normal file
128
go-app/internal/cmc/templates/templates.go
Normal 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
BIN
go-app/server
Executable file
Binary file not shown.
46
go-app/sql/queries/customers.sql
Normal file
46
go-app/sql/queries/customers.sql
Normal 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;
|
||||
191
go-app/sql/queries/enquiries.sql
Normal file
191
go-app/sql/queries/enquiries.sql
Normal 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;
|
||||
52
go-app/sql/queries/products.sql
Normal file
52
go-app/sql/queries/products.sql
Normal 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;
|
||||
60
go-app/sql/queries/purchase_orders.sql
Normal file
60
go-app/sql/queries/purchase_orders.sql
Normal 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 ?;
|
||||
15
go-app/sql/schema/001_customers.sql
Normal file
15
go-app/sql/schema/001_customers.sql
Normal 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;
|
||||
15
go-app/sql/schema/002_products.sql
Normal file
15
go-app/sql/schema/002_products.sql
Normal 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;
|
||||
21
go-app/sql/schema/003_purchase_orders.sql
Normal file
21
go-app/sql/schema/003_purchase_orders.sql
Normal 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;
|
||||
26
go-app/sql/schema/004_users.sql
Normal file
26
go-app/sql/schema/004_users.sql
Normal 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;
|
||||
57
go-app/sql/schema/enquiries.sql
Normal file
57
go-app/sql/schema/enquiries.sql
Normal 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
14
go-app/sqlc.yaml
Normal 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
109
go-app/static/css/style.css
Normal 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
112
go-app/static/js/app.js
Normal 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'));
|
||||
}
|
||||
}
|
||||
});
|
||||
100
go-app/templates/customers/form.html
Normal file
100
go-app/templates/customers/form.html
Normal 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}}
|
||||
47
go-app/templates/customers/index.html
Normal file
47
go-app/templates/customers/index.html
Normal 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}}
|
||||
121
go-app/templates/customers/show.html
Normal file
121
go-app/templates/customers/show.html
Normal 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}}
|
||||
68
go-app/templates/customers/table.html
Normal file
68
go-app/templates/customers/table.html
Normal 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}}
|
||||
270
go-app/templates/enquiries/form.html
Normal file
270
go-app/templates/enquiries/form.html
Normal 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}}
|
||||
159
go-app/templates/enquiries/index.html
Normal file
159
go-app/templates/enquiries/index.html
Normal 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}}
|
||||
326
go-app/templates/enquiries/show.html
Normal file
326
go-app/templates/enquiries/show.html
Normal 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}}
|
||||
192
go-app/templates/enquiries/table.html
Normal file
192
go-app/templates/enquiries/table.html
Normal 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
120
go-app/templates/index.html
Normal 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}}
|
||||
106
go-app/templates/layouts/base.html
Normal file
106
go-app/templates/layouts/base.html
Normal 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> © 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}}
|
||||
6
go-app/templates/partials/notification.html
Normal file
6
go-app/templates/partials/notification.html
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{{define "notification"}}
|
||||
<div class="notification {{.Type}}">
|
||||
<button class="delete"></button>
|
||||
{{.Message}}
|
||||
</div>
|
||||
{{end}}
|
||||
134
go-app/templates/products/form.html
Normal file
134
go-app/templates/products/form.html
Normal 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}}
|
||||
67
go-app/templates/products/index.html
Normal file
67
go-app/templates/products/index.html
Normal 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}}
|
||||
125
go-app/templates/products/show.html
Normal file
125
go-app/templates/products/show.html
Normal 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}}
|
||||
78
go-app/templates/products/table.html
Normal file
78
go-app/templates/products/table.html
Normal 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}}
|
||||
177
go-app/templates/purchase-orders/form.html
Normal file
177
go-app/templates/purchase-orders/form.html
Normal 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}}
|
||||
68
go-app/templates/purchase-orders/index.html
Normal file
68
go-app/templates/purchase-orders/index.html
Normal 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}}
|
||||
159
go-app/templates/purchase-orders/show.html
Normal file
159
go-app/templates/purchase-orders/show.html
Normal 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}}
|
||||
78
go-app/templates/purchase-orders/table.html
Normal file
78
go-app/templates/purchase-orders/table.html
Normal 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}}
|
||||
Loading…
Reference in a new issue