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
|
# 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
|
``` shell
|
||||||
git clone git@code.springupsoftware.com:cmc/cmc-sales.git
|
git clone git@code.springupsoftware.com:cmc/cmc-sales.git
|
||||||
cd cmc-sales
|
cd cmc-sales
|
||||||
|
|
||||||
|
# Easy way - use the setup script
|
||||||
|
./start-development.sh
|
||||||
|
|
||||||
|
# Manual way
|
||||||
rsync -avz --progress cmc@sales.cmctechnologies.com.au:~/backups .
|
rsync -avz --progress cmc@sales.cmctechnologies.com.au:~/backups .
|
||||||
docker compose up
|
docker compose up --build
|
||||||
# DB password is in config/database.php because..reasons.
|
|
||||||
# TODO move it to an environment var and rotate it..
|
|
||||||
gunzip < backups/backup_*.sql.gz | mariadb -h 127.0.0.1 -u cmc -p cmc
|
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
|
## Install a new server
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,12 @@ server {
|
||||||
auth_basic_user_file /etc/nginx/userpasswd;
|
auth_basic_user_file /etc/nginx/userpasswd;
|
||||||
auth_basic "Restricted";
|
auth_basic "Restricted";
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://cmc:80;
|
proxy_pass http://cmc-php:80;
|
||||||
proxy_read_timeout 300s;
|
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;
|
listen 0.0.0.0:80;
|
||||||
|
|
|
||||||
|
|
@ -3,21 +3,22 @@ services:
|
||||||
image: nginx:latest
|
image: nginx:latest
|
||||||
hostname: nginx
|
hostname: nginx
|
||||||
ports:
|
ports:
|
||||||
- "80:80" # Expose HTTP traffic
|
- "80:80" # CakePHP app (cmclocal)
|
||||||
volumes:
|
volumes:
|
||||||
- ./conf/nginx-site.conf:/etc/nginx/conf.d/cmc.conf
|
- ./conf/nginx-site.conf:/etc/nginx/conf.d/cmc.conf
|
||||||
# todo setup site config.
|
|
||||||
- ./userpasswd:/etc/nginx/userpasswd:ro
|
- ./userpasswd:/etc/nginx/userpasswd:ro
|
||||||
|
depends_on:
|
||||||
|
- cmc-php
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- cmc-network
|
- cmc-network
|
||||||
|
|
||||||
cmc:
|
cmc-php:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
platform: linux/amd64
|
platform: linux/amd64
|
||||||
|
container_name: cmc-php
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
volumes:
|
volumes:
|
||||||
|
|
@ -25,6 +26,7 @@ services:
|
||||||
- ./app/webroot/attachments_files:/var/www/cmc-sales/app/webroot/attachments_files
|
- ./app/webroot/attachments_files:/var/www/cmc-sales/app/webroot/attachments_files
|
||||||
networks:
|
networks:
|
||||||
- cmc-network
|
- cmc-network
|
||||||
|
restart: unless-stopped
|
||||||
develop:
|
develop:
|
||||||
watch:
|
watch:
|
||||||
- action: rebuild
|
- action: rebuild
|
||||||
|
|
@ -32,32 +34,6 @@ services:
|
||||||
ignore:
|
ignore:
|
||||||
- ./app/webroot
|
- ./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:
|
db:
|
||||||
image: mariadb:latest
|
image: mariadb:latest
|
||||||
container_name: cmc-db
|
container_name: cmc-db
|
||||||
|
|
@ -73,6 +49,34 @@ services:
|
||||||
networks:
|
networks:
|
||||||
- cmc-network
|
- 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:
|
volumes:
|
||||||
db_data:
|
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