diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..aa86196 --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# Server Configuration +PORT=3000 + +# Database +DB_PATH=./db/dev.db + +# Authentication +# Generate a secure random secret: openssl rand -hex 32 +JWT_SECRET=change_me_to_a_secure_random_string + +# Cookie Settings +# Set your domain for production (e.g., .example.com) +COOKIE_DOMAIN= +# Set to true when using HTTPS +COOKIE_SECURE=false diff --git a/.gitignore b/.gitignore index 65b8690..e0ea5a5 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,7 @@ main # Tailwind CSS cmd/web/assets/css/output.css tailwindcss +node_modules/ +# Docker image tarball +image.tar diff --git a/Dockerfile b/Dockerfile index 10c438e..6d05ad3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,23 +1,25 @@ -FROM golang:1.24.4-alpine AS build -RUN apk add --no-cache curl libstdc++ libgcc alpine-sdk +FROM golang:1.25.1-alpine AS build +RUN apk add --no-cache curl libstdc++ libgcc alpine-sdk npm WORKDIR /app +# Install sass for SCSS compilation +RUN npm install -g sass + COPY go.mod go.sum ./ RUN go mod download COPY . . -RUN go install github.com/a-h/templ/cmd/templ@latest && \ - templ generate && \ - curl -sL https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64-musl -o tailwindcss && \ - chmod +x tailwindcss && \ - ./tailwindcss -i cmd/web/styles/input.css -o cmd/web/assets/css/output.css + +# Compile SCSS to CSS +RUN sass internal/web/assets/scss/main.scss internal/web/assets/css/output.css --style=compressed RUN CGO_ENABLED=1 GOOS=linux go build -o main cmd/api/main.go FROM alpine:3.20.1 AS prod WORKDIR /app COPY --from=build /app/main /app/main +COPY --from=build /app/internal/web/assets /app/internal/web/assets EXPOSE ${PORT} CMD ["./main"] diff --git a/Makefile b/Makefile index d23af9a..3cfb0f4 100644 --- a/Makefile +++ b/Makefile @@ -1,78 +1,66 @@ -# Simple Makefile for a Go project +# ============================================ +# BILLIT - Makefile +# ============================================ + +.PHONY: all build run dev clean scss scss-watch test docker-build docker-run + +# Variables +APP_NAME := billit +MAIN_PATH := ./cmd/api +SCSS_DIR := internal/web/assets/scss +CSS_DIR := internal/web/assets/css + +# Default target +all: scss build # Build the application -all: build test -templ-install: - @if ! command -v templ > /dev/null; then \ - read -p "Go's 'templ' is not installed on your machine. Do you want to install it? [Y/n] " choice; \ - if [ "$$choice" != "n" ] && [ "$$choice" != "N" ]; then \ - go install github.com/a-h/templ/cmd/templ@latest; \ - if [ ! -x "$$(command -v templ)" ]; then \ - echo "templ installation failed. Exiting..."; \ - exit 1; \ - fi; \ - else \ - echo "You chose not to install templ. Exiting..."; \ - exit 1; \ - fi; \ - fi -tailwind-install: - - @if [ ! -f tailwindcss ]; then curl -sL https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-macos-x64 -o tailwindcss; fi - @chmod +x tailwindcss - -build: tailwind-install templ-install - @echo "Building..." - @templ generate - @./tailwindcss -i cmd/web/styles/input.css -o cmd/web/assets/css/output.css - @CGO_ENABLED=1 GOOS=linux go build -o main cmd/api/main.go +build: + @echo "Building $(APP_NAME)..." + go build -o bin/$(APP_NAME) $(MAIN_PATH) # Run the application -run: - @go run cmd/api/main.go -# Create DB container -docker-run: - @if docker compose up --build 2>/dev/null; then \ - : ; \ - else \ - echo "Falling back to Docker Compose V1"; \ - docker-compose up --build; \ - fi +run: scss + @echo "Running $(APP_NAME)..." + go run $(MAIN_PATH) -# Shutdown DB container -docker-down: - @if docker compose down 2>/dev/null; then \ - : ; \ - else \ - echo "Falling back to Docker Compose V1"; \ - docker-compose down; \ - fi +# Development mode with hot reload +dev: + @echo "Starting development server..." + air -# Test the application -test: - @echo "Testing..." - @go test ./... -v +# Compile SCSS to CSS +scss: + @echo "Compiling SCSS..." + sass $(SCSS_DIR)/main.scss $(CSS_DIR)/output.css --style=compressed -# Clean the binary +# Watch SCSS for changes +scss-watch: + @echo "Watching SCSS for changes..." + sass $(SCSS_DIR)/main.scss $(CSS_DIR)/output.css --style=compressed --watch + +# Clean build artifacts clean: @echo "Cleaning..." - @rm -f main + rm -rf bin/ + rm -f $(CSS_DIR)/output.css + rm -f $(CSS_DIR)/output.css.map -# Live Reload -watch: - @if command -v air > /dev/null; then \ - air; \ - echo "Watching...";\ - else \ - read -p "Go's 'air' is not installed on your machine. Do you want to install it? [Y/n] " choice; \ - if [ "$$choice" != "n" ] && [ "$$choice" != "N" ]; then \ - go install github.com/air-verse/air@latest; \ - air; \ - echo "Watching...";\ - else \ - echo "You chose not to install air. Exiting..."; \ - exit 1; \ - fi; \ - fi +# Run tests +test: + @echo "Running tests..." + go test ./... -.PHONY: all build run test clean watch tailwind-install templ-install +# Docker build +docker-build: scss + @echo "Building Docker image..." + docker compose -f compose.build.yml build + +# Docker run +docker-run: + @echo "Running Docker container..." + docker compose up + +# Build for production +release: scss + @echo "Building for production..." + CGO_ENABLED=1 GOOS=linux go build -ldflags="-s -w" -o bin/$(APP_NAME) $(MAIN_PATH) diff --git a/cmd/web/base.templ b/cmd/web/base.templ deleted file mode 100644 index eaea35b..0000000 --- a/cmd/web/base.templ +++ /dev/null @@ -1,19 +0,0 @@ -package web - -templ Base() { - - - - - - Go Blueprint Hello - - - - -
- { children... } -
- - -} diff --git a/cmd/web/hello.go b/cmd/web/hello.go deleted file mode 100644 index 98cd24e..0000000 --- a/cmd/web/hello.go +++ /dev/null @@ -1,21 +0,0 @@ -package web - -import ( - "log" - "net/http" -) - -func HelloWebHandler(w http.ResponseWriter, r *http.Request) { - err := r.ParseForm() - if err != nil { - http.Error(w, "Bad Request", http.StatusBadRequest) - } - - name := r.FormValue("name") - component := HelloPost(name) - err = component.Render(r.Context(), w) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - log.Fatalf("Error rendering in HelloWebHandler: %e", err) - } -} diff --git a/cmd/web/hello.templ b/cmd/web/hello.templ deleted file mode 100644 index f5f5d72..0000000 --- a/cmd/web/hello.templ +++ /dev/null @@ -1,17 +0,0 @@ -package web - -templ HelloForm() { - @Base() { -
- - -
-
- } -} - -templ HelloPost(name string) { -
-

Hello, { name }

-
-} diff --git a/cmd/web/styles/input.css b/cmd/web/styles/input.css deleted file mode 100644 index 73a943c..0000000 --- a/cmd/web/styles/input.css +++ /dev/null @@ -1 +0,0 @@ -@import "tailwindcss" diff --git a/compose.build.yml b/compose.build.yml new file mode 100644 index 0000000..2fae6fe --- /dev/null +++ b/compose.build.yml @@ -0,0 +1,11 @@ +# Build-only compose file +# Usage: docker compose -f compose.build.yml build + +services: + app: + build: + context: . + dockerfile: Dockerfile + target: prod + image: billit:latest + platform: linux/amd64 diff --git a/compose.deploy.yml b/compose.deploy.yml new file mode 100644 index 0000000..b4fd66f --- /dev/null +++ b/compose.deploy.yml @@ -0,0 +1,18 @@ +# Deployment compose file +# Usage: docker compose -f compose.deploy.yml up -d + +services: + app: + image: billit:latest + container_name: billit + restart: unless-stopped + ports: + - "3020:3000" + env_file: + - .env + volumes: + - db:/app/db + +volumes: + db: + driver: local diff --git a/db/dev.db b/db/dev.db new file mode 100644 index 0000000..7570bd5 Binary files /dev/null and b/db/dev.db differ diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 8e0db6b..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,17 +0,0 @@ -services: - app: - build: - context: . - dockerfile: Dockerfile - target: prod - restart: unless-stopped - ports: - - ${PORT}:${PORT} - environment: - APP_ENV: ${APP_ENV} - PORT: ${PORT} - BLUEPRINT_DB_URL: ${BLUEPRINT_DB_URL} - volumes: - - sqlite_bp:/app/db -volumes: - sqlite_bp: diff --git a/go.mod b/go.mod index d3b6d64..5f16b8b 100644 --- a/go.mod +++ b/go.mod @@ -4,18 +4,21 @@ go 1.25.1 require ( github.com/a-h/templ v0.3.960 + github.com/golang-jwt/jwt/v5 v5.3.0 github.com/joho/godotenv v1.5.1 github.com/labstack/echo/v4 v4.13.4 github.com/mattn/go-sqlite3 v1.14.32 + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e ) require ( + github.com/google/uuid v1.6.0 github.com/labstack/gommon v0.4.2 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect - golang.org/x/crypto v0.45.0 // indirect + golang.org/x/crypto v0.45.0 golang.org/x/net v0.47.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/text v0.31.0 // indirect diff --git a/go.sum b/go.sum index 349e24a..07c1f93 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,12 @@ github.com/a-h/templ v0.3.960 h1:trshEpGa8clF5cdI39iY4ZrZG8Z/QixyzEyUnA7feTM= github.com/a-h/templ v0.3.960/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA= @@ -18,6 +22,8 @@ github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuE github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= diff --git a/internal/api/handlers.go b/internal/api/handlers.go new file mode 100644 index 0000000..41d977a --- /dev/null +++ b/internal/api/handlers.go @@ -0,0 +1,28 @@ +package api + +import ( + "billit/internal/database" + "net/http" + + "github.com/labstack/echo/v4" +) + +// Handlers holds dependencies for API handlers +type Handlers struct { + db database.Service +} + +// NewHandlers creates API handlers with db access +func NewHandlers(db database.Service) *Handlers { + return &Handlers{db: db} +} + +// HealthHandler returns the health status +func (h *Handlers) HealthHandler(c echo.Context) error { + return c.JSON(http.StatusOK, h.db.Health()) +} + +// Note: Product and Invoice API endpoints are disabled. +// All operations go through the authenticated web UI. +// To re-enable API access, add API authentication and update these handlers +// to accept userID from authenticated API requests. diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..8a38724 --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,211 @@ +package auth + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "net/http" + "os" + "time" + + "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/bcrypt" +) + +var ( + ErrInvalidCredentials = errors.New("invalid email or password") + ErrInvalidToken = errors.New("invalid or expired token") + ErrUserNotFound = errors.New("user not found") + ErrUserExists = errors.New("user already exists") +) + +// Config holds auth configuration +type Config struct { + JWTSecret []byte + CookieDomain string + CookieSecure bool + TokenDuration time.Duration +} + +// User represents an authenticated user +type User struct { + ID string `json:"id"` + Email string `json:"email"` + Password string `json:"-"` // Never expose password hash + CompanyDetails string `json:"company_details"` + BankDetails string `json:"bank_details"` + CreatedAt string `json:"created_at"` +} + +// Claims represents JWT claims +type Claims struct { + UserID string `json:"user_id"` + Email string `json:"email"` + jwt.RegisteredClaims +} + +// Service handles authentication +type Service struct { + config Config + users UserStore +} + +// UserStore interface for user persistence +type UserStore interface { + CreateUser(email, passwordHash string) (*User, error) + GetUserByEmail(email string) (*User, error) + GetUserByID(id string) (*User, error) +} + +// NewService creates a new auth service +func NewService(users UserStore) *Service { + secret := os.Getenv("JWT_SECRET") + if secret == "" { + // Generate a random secret if not provided (not recommended for production) + b := make([]byte, 32) + rand.Read(b) + secret = hex.EncodeToString(b) + } + + domain := os.Getenv("COOKIE_DOMAIN") + secure := os.Getenv("COOKIE_SECURE") == "true" + + return &Service{ + config: Config{ + JWTSecret: []byte(secret), + CookieDomain: domain, + CookieSecure: secure, + TokenDuration: 24 * time.Hour, + }, + users: users, + } +} + +// HashPassword hashes a password using bcrypt with high cost +func HashPassword(password string) (string, error) { + // Use cost of 12 for good security/performance balance + hash, err := bcrypt.GenerateFromPassword([]byte(password), 12) + if err != nil { + return "", err + } + return string(hash), nil +} + +// CheckPassword verifies a password against a hash +func CheckPassword(password, hash string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + return err == nil +} + +// Register creates a new user account +func (s *Service) Register(email, password string) (*User, error) { + // Check if user exists + existing, _ := s.users.GetUserByEmail(email) + if existing != nil { + return nil, ErrUserExists + } + + // Validate password strength + if len(password) < 8 { + return nil, errors.New("password must be at least 8 characters") + } + + // Hash password + hash, err := HashPassword(password) + if err != nil { + return nil, err + } + + return s.users.CreateUser(email, hash) +} + +// Login authenticates a user and returns a JWT token +func (s *Service) Login(email, password string) (string, error) { + user, err := s.users.GetUserByEmail(email) + if err != nil || user == nil { + return "", ErrInvalidCredentials + } + + if !CheckPassword(password, user.Password) { + return "", ErrInvalidCredentials + } + + return s.generateToken(user) +} + +// generateToken creates a new JWT token for a user +func (s *Service) generateToken(user *User) (string, error) { + now := time.Now() + claims := &Claims{ + UserID: user.ID, + Email: user.Email, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(now.Add(s.config.TokenDuration)), + IssuedAt: jwt.NewNumericDate(now), + NotBefore: jwt.NewNumericDate(now), + Issuer: "billit", + Subject: user.ID, + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(s.config.JWTSecret) +} + +// ValidateToken validates a JWT token and returns the claims +func (s *Service) ValidateToken(tokenString string) (*Claims, error) { + token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, ErrInvalidToken + } + return s.config.JWTSecret, nil + }) + + if err != nil { + return nil, ErrInvalidToken + } + + if claims, ok := token.Claims.(*Claims); ok && token.Valid { + return claims, nil + } + + return nil, ErrInvalidToken +} + +// CreateAuthCookie creates an HTTP-only secure cookie for the token +func (s *Service) CreateAuthCookie(token string) *http.Cookie { + return &http.Cookie{ + Name: "auth_token", + Value: token, + Path: "/", + Domain: s.config.CookieDomain, + MaxAge: int(s.config.TokenDuration.Seconds()), + HttpOnly: true, // Prevents JavaScript access + Secure: s.config.CookieSecure, // Only send over HTTPS in production + SameSite: http.SameSiteStrictMode, + } +} + +// ClearAuthCookie returns a cookie that clears the auth token +func (s *Service) ClearAuthCookie() *http.Cookie { + return &http.Cookie{ + Name: "auth_token", + Value: "", + Path: "/", + Domain: s.config.CookieDomain, + MaxAge: -1, + HttpOnly: true, + Secure: s.config.CookieSecure, + SameSite: http.SameSiteStrictMode, + } +} + +// GetUserFromToken retrieves the user from a valid token +func (s *Service) GetUserFromToken(tokenString string) (*User, error) { + claims, err := s.ValidateToken(tokenString) + if err != nil { + return nil, err + } + + return s.users.GetUserByID(claims.UserID) +} diff --git a/internal/auth/store.go b/internal/auth/store.go new file mode 100644 index 0000000..2ab928d --- /dev/null +++ b/internal/auth/store.go @@ -0,0 +1,69 @@ +package auth + +import ( + "billit/internal/database" +) + +// DBUserStore adapts database.Service to auth.UserStore interface +type DBUserStore struct { + db database.Service +} + +// NewDBUserStore creates a new user store backed by the database +func NewDBUserStore(db database.Service) *DBUserStore { + return &DBUserStore{db: db} +} + +// CreateUser creates a new user +func (s *DBUserStore) CreateUser(email, passwordHash string) (*User, error) { + dbUser, err := s.db.CreateUser(email, passwordHash) + if err != nil { + return nil, err + } + return &User{ + ID: dbUser.ID, + Email: dbUser.Email, + Password: dbUser.Password, + CompanyDetails: dbUser.CompanyDetails, + BankDetails: dbUser.BankDetails, + CreatedAt: dbUser.CreatedAt, + }, nil +} + +// GetUserByEmail retrieves a user by email +func (s *DBUserStore) GetUserByEmail(email string) (*User, error) { + dbUser, err := s.db.GetUserByEmail(email) + if err != nil { + return nil, err + } + if dbUser == nil { + return nil, nil + } + return &User{ + ID: dbUser.ID, + Email: dbUser.Email, + Password: dbUser.Password, + CompanyDetails: dbUser.CompanyDetails, + BankDetails: dbUser.BankDetails, + CreatedAt: dbUser.CreatedAt, + }, nil +} + +// GetUserByID retrieves a user by ID +func (s *DBUserStore) GetUserByID(id string) (*User, error) { + dbUser, err := s.db.GetUserByID(id) + if err != nil { + return nil, err + } + if dbUser == nil { + return nil, nil + } + return &User{ + ID: dbUser.ID, + Email: dbUser.Email, + Password: dbUser.Password, + CompanyDetails: dbUser.CompanyDetails, + BankDetails: dbUser.BankDetails, + CreatedAt: dbUser.CreatedAt, + }, nil +} diff --git a/internal/database/database.go b/internal/database/database.go index 5edb5ff..79f567b 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -3,9 +3,11 @@ package database import ( "context" "database/sql" + "encoding/json" "fmt" "log" "os" + "path/filepath" "strconv" "time" @@ -13,15 +15,88 @@ import ( _ "github.com/mattn/go-sqlite3" ) +// Product represents a product in the database +type Product struct { + SKU string `json:"sku"` + Name string `json:"name"` + HSNCode string `json:"hsn_code"` + BasePrice float64 `json:"base_price"` + WholesalePrice float64 `json:"wholesale_price"` + GSTRate float64 `json:"gst_rate"` + SmallOrderQty int `json:"small_order_qty"` + SmallOrderFee float64 `json:"small_order_fee"` // Convenience fee for orders below SmallOrderQty + Unit string `json:"unit"` // Unit of measurement (e.g., "pcs", "kg", "box") + UserID string `json:"user_id"` + CreatedAt string `json:"created_at"` +} + +// Invoice represents a stored invoice +type Invoice struct { + ID string `json:"id"` // UUID + HumanReadableID string `json:"human_readable_id"` // Formatted ID like INV/12-2025/001 + Data string `json:"data"` // JSON blob of invoice details + UserID string `json:"user_id"` + CreatedAt string `json:"created_at"` +} + +// User represents an authenticated user +type User struct { + ID string `json:"id"` + Email string `json:"email"` + Password string `json:"-"` + CompanyDetails string `json:"company_details"` // Multiline company details for invoice header + BankDetails string `json:"bank_details"` // Multiline bank details for invoice footer + InvoicePrefix string `json:"invoice_prefix"` // Prefix for invoice IDs (e.g., INV, BILL) + InvoiceCounter int `json:"invoice_counter"` // Auto-incrementing counter for invoice serial numbers + CreatedAt string `json:"created_at"` +} + +// BuyerDetails represents a buyer/customer for invoices +type BuyerDetails struct { + ID string `json:"id"` + UserID string `json:"user_id"` + Name string `json:"name"` // Display name for selection + Details string `json:"details"` // Multiline buyer details + CreatedAt string `json:"created_at"` +} + +// Service represents a service that interacts with a database. // Service represents a service that interacts with a database. type Service interface { // Health returns a map of health status information. - // The keys and values in the map are service-specific. Health() map[string]string // Close terminates the database connection. - // It returns an error if the connection cannot be closed. Close() error + + // Product operations (user-scoped) + CreateProduct(p Product, userID string) error + UpdateProduct(p Product, userID string) error + GetAllProducts(userID string) ([]Product, error) + GetProductBySKU(sku string, userID string) (*Product, error) + DeleteProduct(sku string, userID string) error + + // Invoice operations (user-scoped) + CreateInvoice(id string, humanReadableID string, data interface{}, userID string) error + GetInvoice(id string, userID string) (*Invoice, error) + GetAllInvoices(userID string) ([]Invoice, error) + GetRecentProducts(userID string, limit int) ([]Product, error) + GetRecentInvoices(userID string, limit int) ([]Invoice, error) + GetNextInvoiceNumber(userID string) (string, error) // Returns formatted invoice ID and increments counter + + // User operations + CreateUser(email, passwordHash string) (*User, error) + GetUserByEmail(email string) (*User, error) + GetUserByID(id string) (*User, error) + UpdateUserPassword(id string, passwordHash string) error + UpdateUserDetails(id string, companyDetails string, bankDetails string, invoicePrefix string) error + + // Buyer details operations + CreateBuyerDetails(userID string, name string, details string) (*BuyerDetails, error) + UpdateBuyerDetails(id string, userID string, name string, details string) error + GetBuyerDetails(id string, userID string) (*BuyerDetails, error) + GetAllBuyerDetails(userID string) ([]BuyerDetails, error) + DeleteBuyerDetails(id string, userID string) error } type service struct { @@ -29,7 +104,7 @@ type service struct { } var ( - dburl = os.Getenv("BLUEPRINT_DB_URL") + dburl = os.Getenv("DB_PATH") dbInstance *service ) @@ -39,19 +114,400 @@ func New() Service { return dbInstance } + // Ensure the directory for the database file exists + if dburl != "" && dburl != ":memory:" { + dir := filepath.Dir(dburl) + if dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0755); err != nil { + log.Fatalf("Failed to create database directory: %v", err) + } + } + } + db, err := sql.Open("sqlite3", dburl) if err != nil { - // This will not be a connection error, but a DSN parse error or - // another initialization error. log.Fatal(err) } dbInstance = &service{ db: db, } + + // Initialize tables + dbInstance.initTables() + return dbInstance } +func (s *service) initTables() { + // Products table with user ownership + _, err := s.db.Exec(` + CREATE TABLE IF NOT EXISTS products ( + sku TEXT NOT NULL, + name TEXT NOT NULL, + hsn_code TEXT, + base_price REAL NOT NULL, + wholesale_price REAL, + gst_rate REAL NOT NULL DEFAULT 0.18, + small_order_qty INTEGER DEFAULT 1, + small_order_fee REAL DEFAULT 0, + unit TEXT DEFAULT 'pcs', + user_id TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (sku, user_id) + ) + `) + if err != nil { + log.Printf("Error creating products table: %v", err) + } + + // Add user_id column if not exists (migration for existing DBs) + s.db.Exec(`ALTER TABLE products ADD COLUMN user_id TEXT DEFAULT ''`) + // Add unit column if not exists (migration for existing DBs) + s.db.Exec(`ALTER TABLE products ADD COLUMN unit TEXT DEFAULT 'pcs'`) + + // Invoices table with user ownership + _, err = s.db.Exec(` + CREATE TABLE IF NOT EXISTS invoices ( + id TEXT PRIMARY KEY, + human_readable_id TEXT DEFAULT '', + data TEXT NOT NULL, + user_id TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `) + if err != nil { + log.Printf("Error creating invoices table: %v", err) + } + + // Add columns if not exists (migration for existing DBs) + s.db.Exec(`ALTER TABLE invoices ADD COLUMN user_id TEXT DEFAULT ''`) + s.db.Exec(`ALTER TABLE invoices ADD COLUMN human_readable_id TEXT DEFAULT ''`) + + // Create index on user_id for fast lookups + s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_products_user ON products(user_id)`) + s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_invoices_user ON invoices(user_id)`) + + // Users table + _, err = s.db.Exec(` + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + password TEXT NOT NULL, + company_details TEXT DEFAULT '', + bank_details TEXT DEFAULT '', + invoice_prefix TEXT DEFAULT 'INV', + invoice_counter INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `) + if err != nil { + log.Printf("Error creating users table: %v", err) + } + + // Add columns if not exists (migration for existing DBs) + s.db.Exec(`ALTER TABLE users ADD COLUMN company_details TEXT DEFAULT ''`) + s.db.Exec(`ALTER TABLE users ADD COLUMN bank_details TEXT DEFAULT ''`) + s.db.Exec(`ALTER TABLE users ADD COLUMN invoice_prefix TEXT DEFAULT 'INV'`) + s.db.Exec(`ALTER TABLE users ADD COLUMN invoice_counter INTEGER DEFAULT 0`) + s.db.Exec(`ALTER TABLE users ADD COLUMN invoice_prefix TEXT DEFAULT 'INV'`) + s.db.Exec(`ALTER TABLE users ADD COLUMN invoice_counter INTEGER DEFAULT 0`) + + // Create index on email for fast lookups + _, err = s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)`) + if err != nil { + log.Printf("Error creating users email index: %v", err) + } + + // Buyer details table + _, err = s.db.Exec(` + CREATE TABLE IF NOT EXISTS buyer_details ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + details TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `) + if err != nil { + log.Printf("Error creating buyer_details table: %v", err) + } + + // Create index on user_id for fast lookups + s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_buyer_details_user ON buyer_details(user_id)`) +} + +// CreateProduct inserts a new product for a user +func (s *service) CreateProduct(p Product, userID string) error { + _, err := s.db.Exec(` + INSERT INTO products (sku, name, hsn_code, base_price, wholesale_price, gst_rate, small_order_qty, small_order_fee, unit, user_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, p.SKU, p.Name, p.HSNCode, p.BasePrice, p.WholesalePrice, p.GSTRate, p.SmallOrderQty, p.SmallOrderFee, p.Unit, userID) + return err +} + +// UpdateProduct updates an existing product for a user +func (s *service) UpdateProduct(p Product, userID string) error { + _, err := s.db.Exec(` + UPDATE products SET name=?, hsn_code=?, base_price=?, wholesale_price=?, gst_rate=?, small_order_qty=?, small_order_fee=?, unit=? + WHERE sku=? AND user_id=? + `, p.Name, p.HSNCode, p.BasePrice, p.WholesalePrice, p.GSTRate, p.SmallOrderQty, p.SmallOrderFee, p.Unit, p.SKU, userID) + return err +} + +// GetAllProducts returns all products for a user +func (s *service) GetAllProducts(userID string) ([]Product, error) { + rows, err := s.db.Query(`SELECT sku, name, hsn_code, base_price, wholesale_price, gst_rate, small_order_qty, small_order_fee, unit, user_id, created_at FROM products WHERE user_id=? ORDER BY name`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var products []Product + for rows.Next() { + var p Product + if err := rows.Scan(&p.SKU, &p.Name, &p.HSNCode, &p.BasePrice, &p.WholesalePrice, &p.GSTRate, &p.SmallOrderQty, &p.SmallOrderFee, &p.Unit, &p.UserID, &p.CreatedAt); err != nil { + return nil, err + } + products = append(products, p) + } + return products, nil +} + +// GetProductBySKU returns a single product by SKU for a user +func (s *service) GetProductBySKU(sku string, userID string) (*Product, error) { + var p Product + err := s.db.QueryRow(`SELECT sku, name, hsn_code, base_price, wholesale_price, gst_rate, small_order_qty, small_order_fee, unit, user_id, created_at FROM products WHERE sku=? AND user_id=?`, sku, userID). + Scan(&p.SKU, &p.Name, &p.HSNCode, &p.BasePrice, &p.WholesalePrice, &p.GSTRate, &p.SmallOrderQty, &p.SmallOrderFee, &p.Unit, &p.UserID, &p.CreatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return &p, nil +} + +// DeleteProduct removes a product by SKU for a user +func (s *service) DeleteProduct(sku string, userID string) error { + _, err := s.db.Exec(`DELETE FROM products WHERE sku=? AND user_id=?`, sku, userID) + return err +} + +// GetNextInvoiceNumber generates the next invoice ID in format PREFIX/MMM-YYYY/XXX and increments the counter +func (s *service) GetNextInvoiceNumber(userID string) (string, error) { + var prefix string + var counter int + + // Get current prefix and counter + err := s.db.QueryRow(`SELECT COALESCE(invoice_prefix, 'INV'), COALESCE(invoice_counter, 0) FROM users WHERE id = ?`, userID). + Scan(&prefix, &counter) + if err != nil { + return "", err + } + + // Increment counter + counter++ + + // Update counter in database + _, err = s.db.Exec(`UPDATE users SET invoice_counter = ? WHERE id = ?`, counter, userID) + if err != nil { + return "", err + } + + // Generate formatted invoice ID: PREFIX/MMM-YYYY/XXX + now := time.Now() + humanReadableID := fmt.Sprintf("%s/%s-%d/%03d", prefix, now.Month().String()[:3], now.Year(), counter) + + return humanReadableID, nil +} + +// CreateInvoice stores an invoice with UUID and human-readable ID for a user +func (s *service) CreateInvoice(id string, humanReadableID string, data interface{}, userID string) error { + jsonData, err := json.Marshal(data) + if err != nil { + return err + } + _, err = s.db.Exec(`INSERT INTO invoices (id, human_readable_id, data, user_id) VALUES (?, ?, ?, ?)`, id, humanReadableID, string(jsonData), userID) + return err +} + +// GetInvoice retrieves an invoice by ID for a user +func (s *service) GetInvoice(id string, userID string) (*Invoice, error) { + var inv Invoice + err := s.db.QueryRow(`SELECT id, COALESCE(human_readable_id, ''), data, user_id, created_at FROM invoices WHERE id=? AND user_id=?`, id, userID). + Scan(&inv.ID, &inv.HumanReadableID, &inv.Data, &inv.UserID, &inv.CreatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return &inv, nil +} + +// GetAllInvoices retrieves all invoices for a user +func (s *service) GetAllInvoices(userID string) ([]Invoice, error) { + rows, err := s.db.Query(`SELECT id, COALESCE(human_readable_id, ''), data, user_id, created_at FROM invoices WHERE user_id=? ORDER BY created_at DESC`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var invoices []Invoice + for rows.Next() { + var inv Invoice + if err := rows.Scan(&inv.ID, &inv.HumanReadableID, &inv.Data, &inv.UserID, &inv.CreatedAt); err != nil { + return nil, err + } + invoices = append(invoices, inv) + } + return invoices, nil +} + +// GetRecentProducts returns the most recently added products for a user +func (s *service) GetRecentProducts(userID string, limit int) ([]Product, error) { + rows, err := s.db.Query(`SELECT sku, name, hsn_code, base_price, wholesale_price, gst_rate, small_order_qty, small_order_fee, unit, user_id, created_at FROM products WHERE user_id=? ORDER BY created_at DESC LIMIT ?`, userID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var products []Product + for rows.Next() { + var p Product + if err := rows.Scan(&p.SKU, &p.Name, &p.HSNCode, &p.BasePrice, &p.WholesalePrice, &p.GSTRate, &p.SmallOrderQty, &p.SmallOrderFee, &p.Unit, &p.UserID, &p.CreatedAt); err != nil { + return nil, err + } + products = append(products, p) + } + return products, nil +} + +// GetRecentInvoices returns the most recently generated invoices for a user +func (s *service) GetRecentInvoices(userID string, limit int) ([]Invoice, error) { + rows, err := s.db.Query(`SELECT id, COALESCE(human_readable_id, ''), data, user_id, created_at FROM invoices WHERE user_id=? ORDER BY created_at DESC LIMIT ?`, userID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var invoices []Invoice + for rows.Next() { + var inv Invoice + if err := rows.Scan(&inv.ID, &inv.HumanReadableID, &inv.Data, &inv.UserID, &inv.CreatedAt); err != nil { + return nil, err + } + invoices = append(invoices, inv) + } + return invoices, nil +} + +// CreateUser creates a new user with hashed password +func (s *service) CreateUser(email, passwordHash string) (*User, error) { + id := fmt.Sprintf("%d", time.Now().UnixNano()) + _, err := s.db.Exec(`INSERT INTO users (id, email, password) VALUES (?, ?, ?)`, id, email, passwordHash) + if err != nil { + return nil, err + } + return s.GetUserByID(id) +} + +// GetUserByEmail retrieves a user by email +func (s *service) GetUserByEmail(email string) (*User, error) { + var u User + err := s.db.QueryRow(`SELECT id, email, password, COALESCE(company_details, ''), COALESCE(bank_details, ''), COALESCE(invoice_prefix, 'INV'), COALESCE(invoice_counter, 0), created_at FROM users WHERE email = ?`, email). + Scan(&u.ID, &u.Email, &u.Password, &u.CompanyDetails, &u.BankDetails, &u.InvoicePrefix, &u.InvoiceCounter, &u.CreatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return &u, nil +} + +// GetUserByID retrieves a user by ID +func (s *service) GetUserByID(id string) (*User, error) { + var u User + err := s.db.QueryRow(`SELECT id, email, password, COALESCE(company_details, ''), COALESCE(bank_details, ''), COALESCE(invoice_prefix, 'INV'), COALESCE(invoice_counter, 0), created_at FROM users WHERE id = ?`, id). + Scan(&u.ID, &u.Email, &u.Password, &u.CompanyDetails, &u.BankDetails, &u.InvoicePrefix, &u.InvoiceCounter, &u.CreatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return &u, nil +} + +// UpdateUserPassword updates a user's password hash +func (s *service) UpdateUserPassword(id string, passwordHash string) error { + _, err := s.db.Exec(`UPDATE users SET password = ? WHERE id = ?`, passwordHash, id) + return err +} + +// UpdateUserDetails updates a user's company and bank details +func (s *service) UpdateUserDetails(id string, companyDetails string, bankDetails string, invoicePrefix string) error { + _, err := s.db.Exec(`UPDATE users SET company_details = ?, bank_details = ?, invoice_prefix = ? WHERE id = ?`, companyDetails, bankDetails, invoicePrefix, id) + return err +} + +// CreateBuyerDetails creates a new buyer details entry +func (s *service) CreateBuyerDetails(userID string, name string, details string) (*BuyerDetails, error) { + id := fmt.Sprintf("%d", time.Now().UnixNano()) + _, err := s.db.Exec(`INSERT INTO buyer_details (id, user_id, name, details) VALUES (?, ?, ?, ?)`, id, userID, name, details) + if err != nil { + return nil, err + } + return s.GetBuyerDetails(id, userID) +} + +// UpdateBuyerDetails updates an existing buyer details entry +func (s *service) UpdateBuyerDetails(id string, userID string, name string, details string) error { + _, err := s.db.Exec(`UPDATE buyer_details SET name = ?, details = ? WHERE id = ? AND user_id = ?`, name, details, id, userID) + return err +} + +// GetBuyerDetails retrieves a buyer details entry by ID +func (s *service) GetBuyerDetails(id string, userID string) (*BuyerDetails, error) { + var b BuyerDetails + err := s.db.QueryRow(`SELECT id, user_id, name, details, created_at FROM buyer_details WHERE id = ? AND user_id = ?`, id, userID). + Scan(&b.ID, &b.UserID, &b.Name, &b.Details, &b.CreatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return &b, nil +} + +// GetAllBuyerDetails retrieves all buyer details for a user +func (s *service) GetAllBuyerDetails(userID string) ([]BuyerDetails, error) { + rows, err := s.db.Query(`SELECT id, user_id, name, details, created_at FROM buyer_details WHERE user_id = ? ORDER BY name`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var buyers []BuyerDetails + for rows.Next() { + var b BuyerDetails + if err := rows.Scan(&b.ID, &b.UserID, &b.Name, &b.Details, &b.CreatedAt); err != nil { + return nil, err + } + buyers = append(buyers, b) + } + return buyers, nil +} + +// DeleteBuyerDetails removes a buyer details entry +func (s *service) DeleteBuyerDetails(id string, userID string) error { + _, err := s.db.Exec(`DELETE FROM buyer_details WHERE id = ? AND user_id = ?`, id, userID) + return err +} + // Health checks the health of the database connection by pinging the database. // It returns a map with keys indicating various health statistics. func (s *service) Health() map[string]string { diff --git a/internal/gst/calculator.go b/internal/gst/calculator.go new file mode 100644 index 0000000..107aa3a --- /dev/null +++ b/internal/gst/calculator.go @@ -0,0 +1,158 @@ +package gst + +import ( + "math" +) + +// Rate represents standard GST rates +type Rate float64 + +const ( + Rate0 Rate = 0.0 + Rate5 Rate = 0.05 + Rate12 Rate = 0.12 + Rate18 Rate = 0.18 + Rate28 Rate = 0.28 +) + +// CustomerType distinguishes between B2B (Wholesale) and B2C (Retail) +type CustomerType string + +const ( + CustomerWholesale CustomerType = "wholesale" + CustomerRetail CustomerType = "retail" +) + +// Product represents a catalog item +type Product struct { + SKU string + Name string + HSNCode string + BasePrice float64 // Price before tax + WholesalePrice float64 // Discounted price for B2B + GSTRate Rate + SmallOrderQty int // Minimum quantity threshold + SmallOrderFee float64 // Convenience fee when quantity is below threshold + Unit string // Unit of measurement (e.g., "pcs", "kg", "box") +} + +// LineItem represents a single row in the invoice +type LineItem struct { + Product Product + Quantity int + UnitPrice float64 // Actual price applied (wholesale vs retail) + TaxableVal float64 // Quantity * UnitPrice + CGSTAmount float64 + SGSTAmount float64 + IGSTAmount float64 + TotalAmount float64 +} + +// Invoice represents the full bill +type Invoice struct { + LineItems []LineItem + SubTotal float64 + TotalCGST float64 + TotalSGST float64 + TotalIGST float64 + ConvenienceFee float64 // Flat fee for small orders (before tax) + ConvenienceFeeTax float64 // GST on convenience fee (18% fixed) + GrandTotal float64 + CustomerType CustomerType + IsInterState bool // True if selling to a different state (IGST applies) + CompanyDetails string // Multiline company details (displayed above invoice table) + BuyerDetails string // Multiline buyer details (displayed above bank details) + BuyerName string // Buyer's name + BankDetails string // Multiline bank details (displayed at bottom of invoice) +} + +// Calculator handles the GST logic +type Calculator struct{} + +// NewCalculator creates a new calculator instance +func NewCalculator() *Calculator { + return &Calculator{} +} + +// CalculateLineItem computes taxes for a single line +func (c *Calculator) CalculateLineItem(p Product, qty int, custType CustomerType, isInterState bool) LineItem { + // Determine price based on customer type + price := p.BasePrice + if custType == CustomerWholesale { + price = p.WholesalePrice + } + + taxableVal := price * float64(qty) + rate := float64(p.GSTRate) + + var cgst, sgst, igst float64 + + if isInterState { + igst = taxableVal * rate + } else { + // Intra-state: Split tax between Center and State + halfRate := rate / 2 + cgst = taxableVal * halfRate + sgst = taxableVal * halfRate + } + + total := taxableVal + cgst + sgst + igst + + return LineItem{ + Product: p, + Quantity: qty, + UnitPrice: price, + TaxableVal: round(taxableVal), + CGSTAmount: round(cgst), + SGSTAmount: round(sgst), + IGSTAmount: round(igst), + TotalAmount: round(total), + } +} + +// CalculateInvoice computes totals for the entire invoice +func (c *Calculator) CalculateInvoice(items []LineItem, fee float64, isInterState bool) Invoice { + inv := Invoice{ + LineItems: items, + ConvenienceFee: fee, + IsInterState: isInterState, + } + + for _, item := range items { + inv.SubTotal += item.TaxableVal + inv.TotalCGST += item.CGSTAmount + inv.TotalSGST += item.SGSTAmount + inv.TotalIGST += item.IGSTAmount + } + + // Convenience fee is taxable at 18% fixed rate + if fee > 0 { + feeTax := fee * 0.18 // 18% GST on convenience fee + inv.ConvenienceFeeTax = round(feeTax) + // Add convenience fee to taxable subtotal + inv.SubTotal += fee + // Add convenience fee tax to appropriate tax fields + if isInterState { + inv.TotalIGST += inv.ConvenienceFeeTax + } else { + // Split between CGST and SGST (9% each) + inv.TotalCGST += round(feeTax / 2) + inv.TotalSGST += round(feeTax / 2) + } + } + + inv.GrandTotal = inv.SubTotal + inv.TotalCGST + inv.TotalSGST + inv.TotalIGST + + // Rounding final totals + inv.SubTotal = round(inv.SubTotal) + inv.TotalCGST = round(inv.TotalCGST) + inv.TotalSGST = round(inv.TotalSGST) + inv.TotalIGST = round(inv.TotalIGST) + inv.GrandTotal = round(inv.GrandTotal) + + return inv +} + +func round(num float64) float64 { + return math.Round(num*100) / 100 +} diff --git a/internal/gst/calculator_test.go b/internal/gst/calculator_test.go new file mode 100644 index 0000000..37018f3 --- /dev/null +++ b/internal/gst/calculator_test.go @@ -0,0 +1,122 @@ +package gst + +import ( + "testing" +) + +func TestCalculateLineItem(t *testing.T) { + c := NewCalculator() + + product := Product{ + SKU: "TEST01", + BasePrice: 100, + WholesalePrice: 90, + GSTRate: Rate18, + } + + // Case 1: Retail, Intra-state + item := c.CalculateLineItem(product, 2, CustomerRetail, false) + if item.UnitPrice != 100 { + t.Errorf("Expected UnitPrice 100, got %f", item.UnitPrice) + } + if item.TaxableVal != 200 { + t.Errorf("Expected TaxableVal 200, got %f", item.TaxableVal) + } + if item.CGSTAmount != 18 { // 9% of 200 + t.Errorf("Expected CGST 18, got %f", item.CGSTAmount) + } + if item.IGSTAmount != 0 { + t.Errorf("Expected IGST 0, got %f", item.IGSTAmount) + } + if item.TotalAmount != 236 { // 200 + 18 + 18 + t.Errorf("Expected Total 236, got %f", item.TotalAmount) + } + + // Case 2: Wholesale, Inter-state + item = c.CalculateLineItem(product, 10, CustomerWholesale, true) + if item.UnitPrice != 90 { + t.Errorf("Expected UnitPrice 90, got %f", item.UnitPrice) + } + if item.TaxableVal != 900 { + t.Errorf("Expected TaxableVal 900, got %f", item.TaxableVal) + } + if item.CGSTAmount != 0 { + t.Errorf("Expected CGST 0, got %f", item.CGSTAmount) + } + if item.IGSTAmount != 162 { // 18% of 900 + t.Errorf("Expected IGST 162, got %f", item.IGSTAmount) + } + if item.TotalAmount != 1062 { // 900 + 162 + t.Errorf("Expected Total 1062, got %f", item.TotalAmount) + } +} + +func TestCalculateInvoice(t *testing.T) { + c := NewCalculator() + product := Product{SKU: "TEST01", BasePrice: 100, GSTRate: Rate18} + + item1 := c.CalculateLineItem(product, 1, CustomerRetail, false) + item2 := c.CalculateLineItem(product, 1, CustomerRetail, false) + + // Test with convenience fee (intra-state) + invoice := c.CalculateInvoice([]LineItem{item1, item2}, 50, false) + + // Convenience fee is taxed at 18% + // SubTotal should include convenience fee: 100 + 100 + 50 = 250 + expectedSubTotal := 250.0 + if invoice.SubTotal != expectedSubTotal { + t.Errorf("Expected SubTotal %f, got %f", expectedSubTotal, invoice.SubTotal) + } + + // Convenience fee tax: 50 * 0.18 = 9 + expectedFeeTax := 9.0 + if invoice.ConvenienceFeeTax != expectedFeeTax { + t.Errorf("Expected ConvenienceFeeTax %f, got %f", expectedFeeTax, invoice.ConvenienceFeeTax) + } + + // Total CGST: 9 + 9 + 4.5 = 22.5 (from items + half of fee tax) + expectedCGST := 22.5 + if invoice.TotalCGST != expectedCGST { + t.Errorf("Expected TotalCGST %f, got %f", expectedCGST, invoice.TotalCGST) + } + + // GrandTotal: SubTotal + CGST + SGST = 250 + 22.5 + 22.5 = 295 + expectedTotal := 295.0 + if invoice.GrandTotal != expectedTotal { + t.Errorf("Expected GrandTotal %f, got %f", expectedTotal, invoice.GrandTotal) + } +} + +func TestCalculateInvoiceInterState(t *testing.T) { + c := NewCalculator() + product := Product{SKU: "TEST01", BasePrice: 100, GSTRate: Rate18} + + item := c.CalculateLineItem(product, 1, CustomerRetail, true) + + // Test with convenience fee (inter-state) + invoice := c.CalculateInvoice([]LineItem{item}, 50, true) + + // SubTotal: 100 + 50 = 150 + expectedSubTotal := 150.0 + if invoice.SubTotal != expectedSubTotal { + t.Errorf("Expected SubTotal %f, got %f", expectedSubTotal, invoice.SubTotal) + } + + // Convenience fee tax: 50 * 0.18 = 9 + expectedFeeTax := 9.0 + if invoice.ConvenienceFeeTax != expectedFeeTax { + t.Errorf("Expected ConvenienceFeeTax %f, got %f", expectedFeeTax, invoice.ConvenienceFeeTax) + } + + // Total IGST: 18 + 9 = 27 (from item + fee tax) + expectedIGST := 27.0 + if invoice.TotalIGST != expectedIGST { + t.Errorf("Expected TotalIGST %f, got %f", expectedIGST, invoice.TotalIGST) + } + + // GrandTotal: SubTotal + IGST = 150 + 27 = 177 + expectedTotal := 177.0 + if invoice.GrandTotal != expectedTotal { + t.Errorf("Expected GrandTotal %f, got %f", expectedTotal, invoice.GrandTotal) + } +} diff --git a/internal/server/routes.go b/internal/server/routes.go index a34efab..130bfc1 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -3,8 +3,10 @@ package server import ( "net/http" - "billit/cmd/web" - "github.com/a-h/templ" + "billit/internal/api" + "billit/internal/auth" + "billit/internal/web" + "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" ) @@ -22,27 +24,106 @@ func (s *Server) RegisterRoutes() http.Handler { MaxAge: 300, })) + // Static files fileServer := http.FileServer(http.FS(web.Files)) e.GET("/assets/*", echo.WrapHandler(fileServer)) - e.GET("/web", echo.WrapHandler(templ.Handler(web.HelloForm()))) - e.POST("/hello", echo.WrapHandler(http.HandlerFunc(web.HelloWebHandler))) + // ======================================== + // Auth Setup + // ======================================== + userStore := auth.NewDBUserStore(s.db) + authService := auth.NewService(userStore) + authHandlers := web.NewAuthHandlers(authService) - e.GET("/", s.HelloWorldHandler) + // ======================================== + // API Routes (JSON responses) - Health only, products/invoice via web UI + // ======================================== + apiHandlers := api.NewHandlers(s.db) + apiGroup := e.Group("/api") + { + apiGroup.GET("/health", apiHandlers.HealthHandler) + } - e.GET("/health", s.healthHandler) + // ======================================== + // Public Web Routes (no auth required) + // ======================================== + e.GET("/", authHandlers.LoginPageHandler) + e.POST("/login", authHandlers.LoginHandler) + e.GET("/register", authHandlers.RegisterPageHandler) + e.POST("/register", authHandlers.RegisterHandler) + e.GET("/logout", authHandlers.LogoutHandler) + + // ======================================== + // Protected Web Routes (auth required) + // ======================================== + protected := e.Group("") + protected.Use(authHandlers.AuthMiddleware) + + // Home + homeHandlers := web.NewHomeHandlers(s.db) + protected.GET("/home", homeHandlers.HomePageHandler) + + // Account routes + accountHandlers := web.NewAccountHandlers(s.db, authService) + protected.GET("/account", accountHandlers.AccountPageHandler) + protected.POST("/account/details", accountHandlers.UpdateDetailsHandler) + protected.POST("/account/password", accountHandlers.ChangePasswordHandler) + + // Buyer routes + buyerHandlers := web.NewBuyerHandlers(s.db) + protected.GET("/buyer", buyerHandlers.BuyerListHandler) + protected.GET("/buyer/create", buyerHandlers.BuyerCreatePageHandler) + protected.POST("/buyer/create", buyerHandlers.BuyerCreateHandler) + protected.GET("/buyer/edit/:id", buyerHandlers.BuyerEditPageHandler) + protected.POST("/buyer/edit/:id", buyerHandlers.BuyerUpdateHandler) + protected.DELETE("/buyer/:id", buyerHandlers.BuyerDeleteHandler) + + // Invoices list + invoicesHandlers := web.NewInvoicesHandlers(s.db) + protected.GET("/invoice", invoicesHandlers.InvoicesListHandler) + + // Product routes (web UI) + productHandlers := web.NewProductHandlers(s.db) + protected.GET("/product", productHandlers.ProductListHandler) + protected.GET("/product/create", productHandlers.ProductCreatePageHandler) + protected.POST("/product/create", productHandlers.ProductCreateHandler) + protected.GET("/product/edit/:sku", productHandlers.ProductEditPageHandler) + protected.POST("/product/edit/:sku", productHandlers.ProductUpdateHandler) + protected.DELETE("/product/:sku", productHandlers.ProductDeleteHandler) + + // Billing routes (web UI) + billingHandlers := web.NewBillingHandlers(s.db) + protected.GET("/billing", billingHandlers.BillingPageHandler) + protected.POST("/billing/calculate", billingHandlers.CalculateBillHandler) + protected.POST("/billing/generate", billingHandlers.GenerateBillHandler) + protected.GET("/billing/add-row", billingHandlers.AddProductRowHandler) + + // Invoice view (protected - only owner can view) + protected.GET("/invoice/:id", billingHandlers.ShowInvoiceHandler) + + // Legacy health check (kept for backward compatibility) + e.GET("/health", apiHandlers.HealthHandler) + + // Custom 404 handler for Echo HTTP errors + e.HTTPErrorHandler = func(err error, c echo.Context) { + if he, ok := err.(*echo.HTTPError); ok { + switch he.Code { + case http.StatusNotFound: + _ = web.RenderNotFound(c, "") + return + case http.StatusInternalServerError: + _ = web.RenderServerError(c, "") + return + } + } + // Default error handler for other cases + e.DefaultHTTPErrorHandler(err, c) + } + + // Catch-all for undefined routes (must be last) + e.RouteNotFound("/*", func(c echo.Context) error { + return web.RenderNotFound(c, "") + }) return e } - -func (s *Server) HelloWorldHandler(c echo.Context) error { - resp := map[string]string{ - "message": "Hello World", - } - - return c.JSON(http.StatusOK, resp) -} - -func (s *Server) healthHandler(c echo.Context) error { - return c.JSON(http.StatusOK, s.db.Health()) -} diff --git a/internal/server/routes_test.go b/internal/server/routes_test.go index 913a5d9..c587bc2 100644 --- a/internal/server/routes_test.go +++ b/internal/server/routes_test.go @@ -1,39 +1,34 @@ package server import ( - "encoding/json" - "github.com/labstack/echo/v4" "net/http" "net/http/httptest" - "reflect" "testing" + + "billit/internal/database" + + "github.com/labstack/echo/v4" ) -func TestHandler(t *testing.T) { - e := echo.New() +func TestHomeRoute(t *testing.T) { + // Create a minimal server with db for testing + db := database.New() + s := &Server{db: db} + handler := s.RegisterRoutes() + req := httptest.NewRequest(http.MethodGet, "/", nil) resp := httptest.NewRecorder() - c := e.NewContext(req, resp) - s := &Server{} - // Assertions - if err := s.HelloWorldHandler(c); err != nil { - t.Errorf("handler() error = %v", err) - return - } + handler.ServeHTTP(resp, req) + if resp.Code != http.StatusOK { - t.Errorf("handler() wrong status code = %v", resp.Code) - return - } - expected := map[string]string{"message": "Hello World"} - var actual map[string]string - // Decode the response body into the actual map - if err := json.NewDecoder(resp.Body).Decode(&actual); err != nil { - t.Errorf("handler() error decoding response body: %v", err) - return - } - // Compare the decoded response with the expected value - if !reflect.DeepEqual(expected, actual) { - t.Errorf("handler() wrong response body. expected = %v, actual = %v", expected, actual) - return + t.Errorf("home route wrong status code = %v, want %v", resp.Code, http.StatusOK) + } +} + +func TestRouterSetup(t *testing.T) { + // Test that Echo router can be set up without panic + e := echo.New() + if e == nil { + t.Error("failed to create echo instance") } } diff --git a/internal/web/account.go b/internal/web/account.go new file mode 100644 index 0000000..98a7251 --- /dev/null +++ b/internal/web/account.go @@ -0,0 +1,107 @@ +package web + +import ( + "billit/internal/auth" + "billit/internal/database" + "net/http" + + "github.com/labstack/echo/v4" +) + +// AccountHandlers holds references for account operations +type AccountHandlers struct { + db database.Service + auth *auth.Service +} + +// NewAccountHandlers creates handlers with db and auth access +func NewAccountHandlers(db database.Service, authService *auth.Service) *AccountHandlers { + return &AccountHandlers{db: db, auth: authService} +} + +// AccountPageHandler renders the /account page +func (h *AccountHandlers) AccountPageHandler(c echo.Context) error { + userID := getUserID(c) + if userID == "" { + return c.Redirect(http.StatusFound, "/") + } + + user, err := h.db.GetUserByID(userID) + if err != nil || user == nil { + return RenderServerError(c, "Failed to load account details.") + } + + return Render(c, AccountPage(user.Email, user.CreatedAt, user.CompanyDetails, user.BankDetails, user.InvoicePrefix, "", "")) +} + +// UpdateDetailsHandler handles POST /account/details +func (h *AccountHandlers) UpdateDetailsHandler(c echo.Context) error { + userID := getUserID(c) + if userID == "" { + return c.Redirect(http.StatusFound, "/") + } + + user, err := h.db.GetUserByID(userID) + if err != nil || user == nil { + return RenderServerError(c, "Failed to load account details.") + } + + companyDetails := c.FormValue("company_details") + bankDetails := c.FormValue("bank_details") + invoicePrefix := c.FormValue("invoice_prefix") + if invoicePrefix == "" { + invoicePrefix = "INV" // Default prefix + } + + err = h.db.UpdateUserDetails(userID, companyDetails, bankDetails, invoicePrefix) + if err != nil { + return Render(c, AccountPage(user.Email, user.CreatedAt, user.CompanyDetails, user.BankDetails, user.InvoicePrefix, "", "Failed to update details")) + } + + return Render(c, AccountPage(user.Email, user.CreatedAt, companyDetails, bankDetails, invoicePrefix, "Details updated successfully", "")) +} + +// ChangePasswordHandler handles POST /account/password +func (h *AccountHandlers) ChangePasswordHandler(c echo.Context) error { + userID := getUserID(c) + if userID == "" { + return c.Redirect(http.StatusFound, "/") + } + + user, err := h.db.GetUserByID(userID) + if err != nil || user == nil { + return RenderServerError(c, "Failed to load account details.") + } + + currentPassword := c.FormValue("current_password") + newPassword := c.FormValue("new_password") + confirmPassword := c.FormValue("confirm_password") + + // Validate current password + if !auth.CheckPassword(currentPassword, user.Password) { + return Render(c, AccountPage(user.Email, user.CreatedAt, user.CompanyDetails, user.BankDetails, user.InvoicePrefix, "", "Current password is incorrect")) + } + + // Validate new password + if len(newPassword) < 8 { + return Render(c, AccountPage(user.Email, user.CreatedAt, user.CompanyDetails, user.BankDetails, user.InvoicePrefix, "", "New password must be at least 8 characters")) + } + + if newPassword != confirmPassword { + return Render(c, AccountPage(user.Email, user.CreatedAt, user.CompanyDetails, user.BankDetails, user.InvoicePrefix, "", "New passwords do not match")) + } + + // Hash new password + hash, err := auth.HashPassword(newPassword) + if err != nil { + return Render(c, AccountPage(user.Email, user.CreatedAt, user.CompanyDetails, user.BankDetails, user.InvoicePrefix, "", "Failed to update password")) + } + + // Update password in database + err = h.db.UpdateUserPassword(userID, hash) + if err != nil { + return Render(c, AccountPage(user.Email, user.CreatedAt, user.CompanyDetails, user.BankDetails, user.InvoicePrefix, "", "Failed to update password")) + } + + return Render(c, AccountPage(user.Email, user.CreatedAt, user.CompanyDetails, user.BankDetails, user.InvoicePrefix, "Password changed successfully", "")) +} diff --git a/internal/web/assets/css/output.css b/internal/web/assets/css/output.css new file mode 100644 index 0000000..2f8ba1e --- /dev/null +++ b/internal/web/assets/css/output.css @@ -0,0 +1 @@ +*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}html{font-size:16px;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;font-size:.8125rem;line-height:1.4;color:#212121;background-color:#fff;min-height:100vh}h1,h2,h3,h4,h5,h6{font-weight:700;line-height:1.2;margin:0}h1{font-size:1.25rem}h2{font-size:1.125rem}h3{font-size:1rem}h4{font-size:.875rem}h5{font-size:.8125rem}h6{font-size:.75rem}p{margin:0}a{color:#0d47a1;text-decoration:none}a:hover{text-decoration:underline}ul,ol{list-style:none}img{max-width:100%;height:auto}table{border-collapse:collapse;width:100%}input,select,textarea,button{font-family:inherit;font-size:inherit;line-height:inherit}:focus{outline:2px solid #0d47a1;outline-offset:1px}:focus:not(:focus-visible){outline:none}:focus-visible{outline:2px solid #0d47a1;outline-offset:1px}::selection{background:#e65100;color:#fff}::-webkit-scrollbar{width:8px;height:8px}::-webkit-scrollbar-track{background:#f5f5f5}::-webkit-scrollbar-thumb{background:#bdbdbd}::-webkit-scrollbar-thumb:hover{background:#9e9e9e}.hidden{display:none !important}.block{display:block}.inline{display:inline}.inline-block{display:inline-block}.flex{display:flex}.inline-flex{display:inline-flex}.grid{display:grid}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.flex-1{flex:1}.flex-grow{flex-grow:1}.flex-shrink-0{flex-shrink:0}.items-start{align-items:flex-start}.items-center{align-items:center}.items-end{align-items:flex-end}.items-stretch{align-items:stretch}.justify-start{justify-content:flex-start}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.justify-between{justify-content:space-between}.gap-1{gap:.125rem}.gap-2{gap:.25rem}.gap-3{gap:.375rem}.gap-4{gap:.5rem}.gap-6{gap:.75rem}.gap-8{gap:1rem}.text-xs{font-size:.6875rem}.text-sm{font-size:.75rem}.text-base{font-size:.8125rem}.text-md{font-size:.875rem}.text-lg{font-size:1rem}.text-xl{font-size:1.125rem}.text-2xl{font-size:1.25rem}.text-3xl{font-size:1.5rem}.font-normal{font-weight:400}.font-medium{font-weight:500}.font-semibold{font-weight:600}.font-bold{font-weight:700}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.uppercase{text-transform:uppercase}.lowercase{text-transform:lowercase}.capitalize{text-transform:capitalize}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.whitespace-nowrap{white-space:nowrap}.text-black{color:#000}.text-white{color:#fff}.text-gray{color:#757575}.text-gray-dark{color:#424242}.text-gray-light{color:#9e9e9e}.text-primary{color:#e65100}.text-accent{color:#0d47a1}.text-success{color:#2e7d32}.text-warning{color:#f57c00}.text-error{color:#c62828}.bg-white{background-color:#fff}.bg-gray-50{background-color:#fafafa}.bg-gray-100{background-color:#f5f5f5}.bg-gray-200{background-color:#eee}.bg-black{background-color:#000}.bg-primary{background-color:#e65100}.m-0{margin:0}.m-2{margin:.25rem}.m-4{margin:.5rem}.m-8{margin:1rem}.mt-0{margin-top:0}.mt-2{margin-top:.25rem}.mt-4{margin-top:.5rem}.mt-6{margin-top:.75rem}.mt-8{margin-top:1rem}.mt-12{margin-top:1.5rem}.mb-0{margin-bottom:0}.mb-2{margin-bottom:.25rem}.mb-4{margin-bottom:.5rem}.mb-6{margin-bottom:.75rem}.mb-8{margin-bottom:1rem}.ml-2{margin-left:.25rem}.ml-4{margin-left:.5rem}.mr-2{margin-right:.25rem}.mr-4{margin-right:.5rem}.mx-auto{margin-left:auto;margin-right:auto}.p-0{padding:0}.p-2{padding:.25rem}.p-3{padding:.375rem}.p-4{padding:.5rem}.p-6{padding:.75rem}.p-8{padding:1rem}.px-2{padding-left:.25rem;padding-right:.25rem}.px-4{padding-left:.5rem;padding-right:.5rem}.px-6{padding-left:.75rem;padding-right:.75rem}.px-8{padding-left:1rem;padding-right:1rem}.py-2{padding-top:.25rem;padding-bottom:.25rem}.py-3{padding-top:.375rem;padding-bottom:.375rem}.py-4{padding-top:.5rem;padding-bottom:.5rem}.py-6{padding-top:.75rem;padding-bottom:.75rem}.py-8{padding-top:1rem;padding-bottom:1rem}.w-full{width:100%}.w-auto{width:auto}.h-full{height:100%}.h-screen{height:100vh}.min-h-screen{min-height:100vh}.border{border:1px solid #e0e0e0}.border-0{border:none}.border-t{border-top:1px solid #e0e0e0}.border-b{border-bottom:1px solid #e0e0e0}.border-l{border-left:1px solid #e0e0e0}.border-r{border-right:1px solid #e0e0e0}.border-dark{border-color:#bdbdbd}.border-2{border-width:2px}.relative{position:relative}.absolute{position:absolute}.fixed{position:fixed}.sticky{position:sticky}.top-0{top:0}.right-0{right:0}.bottom-0{bottom:0}.left-0{left:0}.inset-0{top:0;right:0;bottom:0;left:0}.z-10{z-index:10}.z-20{z-index:20}.z-50{z-index:50}.z-100{z-index:100}.overflow-hidden{overflow:hidden}.overflow-auto{overflow:auto}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.shadow{box-shadow:0 1px 2px rgba(0,0,0,.1)}.shadow-md{box-shadow:0 2px 4px rgba(0,0,0,.1)}.shadow-lg{box-shadow:0 4px 8px rgba(0,0,0,.15)}.shadow-none{box-shadow:none}.cursor-pointer{cursor:pointer}.cursor-default{cursor:default}.opacity-50{opacity:.5}.opacity-75{opacity:.75}@media print{.no-print{display:none !important}.print-only{display:block !important}}.print-only{display:none}.container{width:100%;max-width:1280px;margin:0 auto;padding:0 1rem}.container-sm{max-width:640px}.container-md{max-width:768px}.page{padding:1rem 0}.page-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:1rem;padding-bottom:.5rem;border-bottom:2px solid #000}.page-title{font-size:1.125rem;font-weight:700;text-transform:uppercase;letter-spacing:.5px}.header{background:#000;color:#fff;height:40px;position:sticky;top:0;z-index:100}.header-inner{display:flex;align-items:center;justify-content:space-between;height:100%;max-width:1280px;margin:0 auto;padding:0 1rem}.header-logo{font-size:1rem;font-weight:700;color:#e65100;text-decoration:none;text-transform:uppercase;letter-spacing:1px}.header-logo:hover{color:#ff6d00;text-decoration:none}.header-nav{display:flex;align-items:center;gap:.125rem}.header-link{display:inline-flex;align-items:center;height:40px;padding:0 .75rem;color:#e0e0e0;font-size:.75rem;font-weight:500;text-decoration:none;text-transform:uppercase;letter-spacing:.5px;border-bottom:2px solid rgba(0,0,0,0);transition:all .1s ease}.header-link:hover{color:#fff;background:hsla(0,0%,100%,.1);text-decoration:none}.header-link.active{color:#e65100;border-bottom-color:#e65100}.btn{display:inline-flex;align-items:center;justify-content:center;height:28px;padding:.25rem .75rem;font-size:.75rem;font-weight:600;text-transform:uppercase;letter-spacing:.5px;text-decoration:none;border:1px solid rgba(0,0,0,0);border-radius:0;cursor:pointer;transition:all .1s ease;white-space:nowrap}.btn:hover{text-decoration:none}.btn:disabled{opacity:.5;cursor:not-allowed}.btn-primary{background:#e65100;color:#fff;border-color:#e65100}.btn-primary:hover:not(:disabled){background:#bf360c;border-color:#bf360c}.btn-secondary{background:#424242;color:#fff;border-color:#424242}.btn-secondary:hover:not(:disabled){background:#000;border-color:#000}.btn-outline{background:rgba(0,0,0,0);color:#424242;border-color:#bdbdbd}.btn-outline:hover:not(:disabled){background:#f5f5f5;border-color:#757575}.btn-danger{background:#c62828;color:#fff;border-color:#c62828}.btn-danger:hover:not(:disabled){background:hsl(0,66.3865546218%,36.6666666667%);border-color:hsl(0,66.3865546218%,36.6666666667%)}.btn-link{background:none;color:#0d47a1;border:none;padding:0;height:auto;text-transform:none;letter-spacing:normal;font-weight:400}.btn-link:hover:not(:disabled){text-decoration:underline}.btn-sm{height:24px;padding:.125rem .5rem;font-size:.6875rem}.btn-lg{height:36px;padding:.5rem 1rem;font-size:.875rem}.form-group{margin-bottom:.75rem}.form-row{display:grid;gap:.75rem}.form-row.cols-2{grid-template-columns:repeat(2, 1fr)}.form-row.cols-3{grid-template-columns:repeat(3, 1fr)}.form-row.cols-4{grid-template-columns:repeat(4, 1fr)}.form-row.cols-5{grid-template-columns:repeat(5, 1fr)}.form-label{display:block;margin-bottom:.25rem;font-size:.6875rem;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:#616161}.form-input,.form-select,.form-textarea{display:block;width:100%;height:28px;padding:.25rem .5rem;font-size:.8125rem;color:#212121;background:#fff;border:1px solid #e0e0e0;border-radius:0;transition:border-color .1s ease}.form-input:focus,.form-select:focus,.form-textarea:focus{border-color:#0d47a1;outline:none}.form-input::placeholder,.form-select::placeholder,.form-textarea::placeholder{color:#bdbdbd}.form-input:disabled,.form-select:disabled,.form-textarea:disabled{background:#f5f5f5;cursor:not-allowed}.form-select{appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%23757575' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right .25rem center;background-size:16px;padding-right:1.5rem}.form-textarea{height:auto;min-height:80px;resize:vertical}.form-hint{margin-top:.25rem;font-size:.6875rem;color:#9e9e9e}.form-error{margin-top:.25rem;font-size:.6875rem;color:#c62828}.table-wrapper{overflow-x:auto;border:1px solid #e0e0e0}.table{width:100%;border-collapse:collapse;font-size:.75rem}.table th,.table td{padding:.375rem .5rem;text-align:left;border-bottom:1px solid #e0e0e0;vertical-align:middle}.table th{background:#f5f5f5;font-weight:600;text-transform:uppercase;letter-spacing:.3px;font-size:.6875rem;color:#616161;white-space:nowrap}.table tbody tr:hover{background:#fafafa}.table tbody tr:last-child td{border-bottom:none}.table-numeric{text-align:right;font-family:"SF Mono","Monaco","Inconsolata","Fira Mono","Droid Sans Mono",monospace;font-size:.75rem}.table-actions{text-align:right;white-space:nowrap}.table-empty{padding:1.5rem !important;text-align:center;color:#9e9e9e}.card{background:#fff;border:1px solid #e0e0e0}.card-header{display:flex;align-items:center;justify-content:space-between;padding:.5rem .75rem;background:#f5f5f5;border-bottom:1px solid #e0e0e0}.card-title{font-size:.75rem;font-weight:700;text-transform:uppercase;letter-spacing:.5px}.card-body{padding:.75rem}.panel{background:#fff;border:1px solid #e0e0e0;margin-bottom:1rem}.panel-header{padding:.375rem .5rem;background:#000;color:#fff;font-size:.6875rem;font-weight:700;text-transform:uppercase;letter-spacing:.5px}.panel-body{padding:.5rem}.alert{padding:.5rem .75rem;border:1px solid;margin-bottom:.75rem;font-size:.75rem}.alert-error{background:hsl(0,66.3865546218%,91.6666666667%);border-color:#c62828;color:#c62828}.alert-success{background:rgb(193.5964912281,232.4035087719,195.5614035088);border-color:#2e7d32;color:#2e7d32}.alert-warning{background:rgb(255,224.8734693878,194);border-color:#f57c00;color:rgb(168.5,85.2816326531,0)}.alert-info{background:rgb(194.1549295775,219.5070422535,248.3450704225);border-color:#1565c0;color:#1565c0}.badge{display:inline-block;padding:.125rem .375rem;font-size:.6875rem;font-weight:600;text-transform:uppercase;letter-spacing:.3px;background:#eee;color:#616161}.badge-primary{background:#e65100;color:#fff}.badge-success{background:#2e7d32;color:#fff}.invoice{background:#fff}.invoice-header{display:flex;justify-content:space-between;padding-bottom:.75rem;margin-bottom:.75rem;border-bottom:2px solid #000}.invoice-title{font-size:1.25rem;font-weight:700;text-transform:uppercase;letter-spacing:1px}.invoice-meta{text-align:right;font-size:.875rem;color:#757575}.invoice-table{width:100%;margin-bottom:1rem;border:1px solid #000}.invoice-table th,.invoice-table td{padding:.375rem .5rem;border:1px solid #e0e0e0}.invoice-table th{background:#000;color:#fff;font-size:.6875rem;font-weight:700;text-transform:uppercase;letter-spacing:.3px;text-align:left}.invoice-table tbody tr:nth-child(even){background:#fafafa}.invoice-summary{display:flex;justify-content:flex-end}.invoice-totals{width:300px;margin-left:auto;border:1px solid #000}.invoice-totals .row{display:flex;justify-content:space-between;padding:.375rem .5rem;border-bottom:1px solid #e0e0e0;font-size:.75rem}.invoice-totals .row:last-child{border-bottom:none}.invoice-totals .row-total{background:#000;color:#fff;font-weight:700;font-size:.875rem}.invoice-qr{display:flex;justify-content:flex-end;padding:1rem}.product-row{padding:.5rem;border:1px solid #e0e0e0;margin-bottom:.25rem;background:#fafafa}.product-row:hover{background:#fff}.product-row-grid{display:grid;grid-template-columns:3fr 1fr auto;gap:.5rem;align-items:end}.auth-page{display:flex;align-items:center;justify-content:center;min-height:100vh;background:#f5f5f5}.auth-card{width:100%;max-width:380px;background:#fff;border:2px solid #000}.auth-header{padding:1rem 1rem .75rem;text-align:center;border-bottom:1px solid #e0e0e0}.auth-logo{font-size:1.5rem;font-weight:700;color:#e65100;text-transform:uppercase;letter-spacing:2px;margin-bottom:.25rem}.auth-subtitle{font-size:.75rem;color:#757575;text-transform:uppercase;letter-spacing:.5px}.auth-body{padding:1rem}.auth-footer{padding:.5rem 1rem;text-align:center;background:#fafafa;border-top:1px solid #e0e0e0;font-size:.75rem}.home-grid{display:grid;grid-template-columns:repeat(auto-fit, minmax(250px, 1fr));gap:.75rem}.home-card{display:block;padding:.75rem;background:#fff;border:2px solid #e0e0e0;text-decoration:none;transition:all .1s ease}.home-card:hover{border-color:#000;text-decoration:none}.home-card-icon{width:32px;height:32px;margin-bottom:.5rem;color:#e65100}.home-card-title{font-size:1rem;font-weight:700;color:#000;margin-bottom:.25rem}.home-card-desc{font-size:.75rem;color:#757575}.logged-in-as{font-size:.75rem;color:#757575}.logged-in-as strong{color:#000;font-weight:500}.home-sections{margin-top:1rem;display:flex;flex-direction:column;gap:1rem}.home-section{background:#fff;border:1px solid #e0e0e0;padding:.75rem}.section-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:.5rem}.section-title{font-size:1rem;font-weight:600;color:#000;margin:0}.recent-list{display:flex;flex-direction:column;gap:.375rem}.recent-item{display:flex;justify-content:space-between;align-items:center;padding:.375rem;background:#fafafa;border:1px solid #e0e0e0;transition:background .1s ease}.recent-item:hover{background:#f5f5f5}.recent-item-main{display:flex;flex-direction:column;gap:.125rem;text-decoration:none;color:inherit}a.recent-item-main:hover{text-decoration:none}.recent-item-title{font-weight:500;color:#000}.recent-item-sub{font-size:.75rem;color:#9e9e9e}.recent-item-value{font-weight:500;color:#000;font-family:"SF Mono","Monaco","Inconsolata","Fira Mono","Droid Sans Mono",monospace}.text-accent{color:#e65100;text-decoration:none}.text-accent:hover{text-decoration:underline}.text-muted{color:#757575;font-size:.75rem}@media(max-width: 768px){.container{padding:0 .5rem}.header-inner{padding:0 .5rem}.form-row.cols-2,.form-row.cols-3,.form-row.cols-4,.form-row.cols-5{grid-template-columns:1fr}.page-header{flex-direction:column;align-items:flex-start;gap:.5rem}.product-row-grid{grid-template-columns:1fr}}.error-page{display:flex;align-items:center;justify-content:center;min-height:100vh;padding:1rem;background:#f5f5f5}.error-card{text-align:center;padding:1.5rem 1rem;background:#fff;border:1px solid #e0e0e0;max-width:500px;width:100%}.error-code{font-size:6rem;font-weight:700;line-height:1;color:#e0e0e0;margin-bottom:.5rem}.error-title{font-size:1.125rem;font-weight:700;color:#000;margin-bottom:.5rem}.error-message{font-size:.875rem;color:#757575;margin-bottom:1rem;line-height:1.5}.error-actions{display:flex;gap:.5rem;justify-content:center}.account-grid{display:grid;grid-template-columns:1fr;gap:.75rem}@media(min-width: 768px){.account-grid{grid-template-columns:1fr 1fr}}.account-section{background:#fff;border:1px solid #e0e0e0;padding:.75rem;display:flex;flex-direction:column}.account-details{display:flex;flex-direction:column;gap:.5rem}.account-actions{display:flex;justify-content:flex-end;margin-top:auto;padding-top:.5rem}.detail-row{display:flex;justify-content:space-between;align-items:center;padding:.375rem 0;border-bottom:1px solid #eee}.detail-row:last-child{border-bottom:none}.detail-label{font-weight:500;color:#757575}.detail-value{color:#000}.password-form{display:flex;flex-direction:column;gap:.5rem}.empty-state{text-align:center;padding:1.5rem .75rem;background:#fff;border:1px solid #e0e0e0}.empty-state-icon{font-size:3rem;margin-bottom:.5rem}.empty-state-title{font-size:1rem;font-weight:700;color:#000;margin-bottom:.25rem}.empty-state-desc{font-size:.875rem;color:#757575;margin-bottom:.75rem}.dialog-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:1000;opacity:0;visibility:hidden;transition:opacity .15s ease,visibility .15s ease}.dialog-overlay.dialog-open{opacity:1;visibility:visible}.dialog-box{background:#fff;border:2px solid #000;width:100%;max-width:400px;margin:.5rem;transform:scale(0.95);transition:transform .15s ease}.dialog-open .dialog-box{transform:scale(1)}.dialog-header{padding:.5rem .75rem;border-bottom:1px solid #e0e0e0;background:#f5f5f5}.dialog-title{font-size:.875rem;font-weight:700;text-transform:uppercase;letter-spacing:.5px;margin:0}.dialog-body{padding:.75rem}.dialog-message{font-size:.875rem;color:#424242;margin:0;line-height:1.5}.dialog-footer{display:flex;gap:.375rem;justify-content:flex-end;padding:.5rem .75rem;border-top:1px solid #e0e0e0;background:#f5f5f5}.disclaimer-content{font-size:.75rem;line-height:1.6}.disclaimer-content ul{list-style:none;padding-left:0;margin:0}.disclaimer-content li{padding:.25rem 0;padding-left:.5rem;border-left:3px solid #e65100;margin-bottom:.25rem;background:#fafafa}@media print{.no-print,.header,.btn,button{display:none !important;visibility:hidden !important}body{background:#fff !important;color:#000 !important;font-size:11pt}@page{margin:1cm;size:A4}.page-break{page-break-before:always}.avoid-break{page-break-inside:avoid}a{color:#000 !important;text-decoration:none !important}table{border-collapse:collapse}th,td{border:1px solid #000 !important}.invoice{max-width:100%;padding:0;margin:0}.invoice-header{border-bottom:2px solid #000}.invoice-table th{background:#e0e0e0 !important;color:#000 !important;-webkit-print-color-adjust:exact;print-color-adjust:exact}.invoice-totals .row-total{background:#e0e0e0 !important;color:#000 !important;-webkit-print-color-adjust:exact;print-color-adjust:exact}.invoice-qr{position:fixed;bottom:0;left:0;right:0;text-align:center;padding:10px;border-top:1px solid #ccc}}/*# sourceMappingURL=output.css.map */ diff --git a/internal/web/assets/css/output.css.map b/internal/web/assets/css/output.css.map new file mode 100644 index 0000000..7a72542 --- /dev/null +++ b/internal/web/assets/css/output.css.map @@ -0,0 +1 @@ +{"version":3,"sourceRoot":"","sources":["../scss/_base.scss","../scss/_variables.scss","../scss/_utilities.scss","../scss/_components.scss","../scss/_print.scss"],"names":[],"mappings":"AAMA,qBACE,sBACA,SACA,UAGF,KACE,eACA,mCACA,kCAGF,KACE,YCiBiB,qFDhBjB,UCqBe,SDpBf,YCiCiB,IDhCjB,MCLe,QDMf,iBChBY,KDiBZ,iBAIF,kBACE,YCsBiB,IDrBjB,YCuBkB,IDtBlB,SAGF,aCWgB,QDVhB,aCSe,SDRf,aCOe,KDNf,aCKe,QDJf,aCGiB,SDFjB,aCCe,ODCf,EACE,SAGF,EACE,MCrBa,QDsBb,qBAEA,QACE,0BAKJ,MACE,gBAIF,IACE,eACA,YAIF,MACE,yBACA,WAIF,6BACE,oBACA,kBACA,oBAIF,OACE,0BACA,mBAGF,2BACE,aAGF,eACE,0BACA,mBAIF,YACE,WC3Ec,QD4Ed,MCzFY,KD6Fd,oBACE,UACA,WAGF,0BACE,WCjGe,QDoGjB,0BACE,WClGe,QDoGf,gCACE,WCpGa,QCNjB,gCACA,qBACA,uBACA,mCACA,mBACA,iCACA,mBAGA,6BACA,gCACA,0BACA,eACA,uBACA,6BAEA,oCACA,iCACA,gCACA,mCAEA,0CACA,uCACA,sCACA,+CAEA,WD0BY,QCzBZ,WD0BY,OCzBZ,WD0BY,QCzBZ,WD0BY,MCzBZ,WD2BY,OC1BZ,WD2BY,KCxBZ,mBDFe,SCGf,mBDFe,OCGf,qBDFiB,SCGjB,mBDFe,QCGf,mBDFe,KCGf,mBDFe,SCGf,oBDFgB,QCGhB,oBDFgB,OCIhB,yBDFqB,ICGrB,yBDFqB,ICGrB,2BDFuB,ICGvB,uBDFmB,ICInB,2BACA,+BACA,6BAEA,oCACA,oCACA,sCAEA,UACE,gBACA,uBACA,mBAGF,sCAGA,kBDlEc,KCmEd,kBDlEc,KCmEd,iBD5DiB,QC6DjB,sBD3DiB,QC4DjB,uBD/DiB,QCgEjB,oBDzDgB,QC0DhB,mBDrDe,QCsDf,oBDjDgB,QCkDhB,oBDjDgB,QCkDhB,kBDjDc,QCmDd,2BD5Ec,KC6Ed,6BD5EgB,QC6EhB,8BD5EiB,QC6EjB,8BD5EiB,KC6EjB,2BDjFc,KCkFd,6BDpEgB,QCuEhB,YDjCY,ECkCZ,YDhCY,OCiCZ,YD/BY,MCgCZ,YD7BY,KC+BZ,iBDtCY,ECuCZ,iBDrCY,OCsCZ,iBDpCY,MCqCZ,iBDnCY,OCoCZ,iBDnCY,KCoCZ,kBDlCa,OCoCb,oBD7CY,EC8CZ,oBD5CY,OC6CZ,oBD3CY,MC4CZ,oBD1CY,OC2CZ,oBD1CY,KC4CZ,kBDjDY,OCkDZ,kBDhDY,MCiDZ,mBDnDY,OCoDZ,mBDlDY,MCoDZ,4CAGA,aD3DY,EC4DZ,aD1DY,OC2DZ,aD1DY,QC2DZ,aD1DY,MC2DZ,aDzDY,OC0DZ,aDzDY,KC2DZ,mBDhEY,OCgEsB,cDhEtB,OCiEZ,mBD/DY,MC+DsB,cD/DtB,MCgEZ,mBD9DY,OC8DsB,cD9DtB,OC+DZ,mBD9DY,KC8DsB,cD9DtB,KCgEZ,kBDrEY,OCqEqB,eDrErB,OCsEZ,kBDrEY,QCqEqB,eDrErB,QCsEZ,kBDrEY,MCqEqB,eDrErB,MCsEZ,kBDpEY,OCoEqB,eDpErB,OCqEZ,kBDpEY,KCoEqB,eDpErB,KCuEZ,mBACA,mBACA,oBACA,uBACA,+BAGA,iCACA,sBACA,uCACA,0CACA,wCACA,yCACA,0BDzIiB,QC0IjB,uBD7EiB,ICgFjB,4BACA,4BACA,sBACA,wBAEA,aACA,iBACA,mBACA,eACA,uCAGA,iBACA,iBACA,iBACA,mBAGA,iCACA,6BACA,iCACA,iCAGA,mBDlGY,yBCmGZ,sBDlGY,yBCmGZ,sBDlGY,0BCmGZ,6BAGA,+BACA,+BAGA,uBACA,wBAGA,aACE,kCACA,sCAGF,yBCxLA,WACE,WACA,UF8Ea,OE7Eb,cACA,eAGF,cACE,UFqEa,MElEf,cACE,UFkEa,ME/Df,MACE,eAGF,aACE,aACA,mBACA,8BACA,cF8BU,KE7BV,eF0BU,MEzBV,6BAGF,YACE,UFGa,SEFb,YFSiB,IERjB,yBACA,oBAOF,QACE,WF9CY,KE+CZ,MF9CY,KE+CZ,OF0Cc,KEzCd,gBACA,MACA,YAGF,cACE,aACA,mBACA,8BACA,YACA,UF2Ba,OE1Bb,cACA,eAGF,aACE,UF5Ba,KE6Bb,YFrBiB,IEsBjB,MFrDc,QEsDd,qBACA,yBACA,mBAEA,mBACE,MFzDkB,QE0DlB,qBAIJ,YACE,aACA,mBACA,IF5BU,QE+BZ,aACE,oBACA,mBACA,OFGc,KEFd,iBACA,MFpFe,QEqFf,UFxDa,OEyDb,YFhDmB,IEiDnB,qBACA,yBACA,oBACA,sCACA,wBAEA,mBACE,MFlGU,KEmGV,8BACA,qBAGF,oBACE,MF3FY,QE4FZ,oBF5FY,QEoGhB,KACE,oBACA,mBACA,uBACA,OFVW,KEWX,QFVY,cEWZ,UFtFa,OEuFb,YF7EqB,IE8ErB,yBACA,oBACA,qBACA,+BACA,cFxDc,EEyDd,eACA,wBACA,mBAEA,WACE,qBAGF,cACE,WACA,mBAIJ,aACE,WFhIc,QEiId,MF9IY,KE+IZ,aFlIc,QEoId,kCACE,WFpIiB,QEqIjB,aFrIiB,QEyIrB,eACE,WF/Ie,QEgJf,MFzJY,KE0JZ,aFjJe,QEmJf,oCACE,WF9JU,KE+JV,aF/JU,KEmKd,aACE,yBACA,MF3Je,QE4Jf,aFhKe,QEkKf,kCACE,WFtKa,QEuKb,aFlKa,QEsKjB,YACE,WFrJY,QEsJZ,MF/KY,KEgLZ,aFvJY,QEyJZ,iCACE,gDACA,kDAIJ,UACE,gBACA,MFxKa,QEyKb,YACA,UACA,YACA,oBACA,sBACA,YFvJmB,IEyJnB,+BACE,0BAIJ,QACE,YACA,sBACA,UF1Ka,SE6Kf,QACE,YACA,mBACA,UF7Ka,QEoLf,YACE,cF/JU,OEkKZ,UACE,aACA,IFpKU,OEsKV,sDACA,sDACA,sDACA,sDAGF,YACE,cACA,cFlLU,OEmLV,UFxMa,SEyMb,YF9LqB,IE+LrB,yBACA,oBACA,MFpOe,QEuOjB,wCAGE,cACA,WACA,OFhJa,KEiJb,QFhJc,aEiJd,UFpNe,SEqNf,MF7Oe,QE8Of,WFxPY,KEyPZ,yBACA,cFrLc,EEsLd,iCAEA,0DACE,aF5OW,QE6OX,aAGF,+EACE,MF9Pa,QEiQf,mEACE,WFrQa,QEsQb,mBAIJ,aACE,gBACA,mPACA,4BACA,wCACA,qBACA,cFtNW,OEyNb,eACE,YACA,gBACA,gBAGF,WACE,WFvOU,OEwOV,UF7Pa,SE8Pb,MFxRe,QE2RjB,YACE,WF7OU,OE8OV,UFnQa,SEoQb,MF3QY,QEkRd,eACE,gBACA,yBAGF,OACE,WACA,yBACA,UFlRa,OEqRf,oBAEE,QFvNmB,cEwNnB,gBACA,gCACA,sBAGF,UACE,WF7Te,QE8Tf,YFrRqB,IEsRrB,yBACA,oBACA,UFnSa,SEoSb,MF5Te,QE6Tf,mBAGF,sBACE,WFxUc,QE2UhB,8BACE,mBAGF,eACE,iBACA,YFpTiB,yEEqTjB,UFlTa,OEqTf,eACE,iBACA,mBAGF,aACE,0BACA,kBACA,MFxVe,QE+VjB,MACE,WFtWY,KEuWZ,yBAGF,aACE,aACA,mBACA,8BACA,qBACA,WF7We,QE8Wf,gCAGF,YACE,UFnVa,OEoVb,YFzUiB,IE0UjB,yBACA,oBAGF,WACE,QFlUU,OEqUZ,OACE,WF/XY,KEgYZ,yBACA,cFvUU,KE0UZ,cACE,sBACA,WFvYY,KEwYZ,MFvYY,KEwYZ,UFxWa,SEyWb,YF7ViB,IE8VjB,yBACA,oBAGF,YACE,QFxVU,ME+VZ,OACE,qBACA,iBACA,cFhWU,OEiWV,UFzXa,OE4Xf,aACE,gDACA,aFtYY,QEuYZ,MFvYY,QE0Yd,eACE,6DACA,aF9Yc,QE+Yd,MF/Yc,QEkZhB,eACE,uCACA,aFnZc,QEoZd,iCAGF,YACE,6DACA,aFvZW,QEwZX,MFxZW,QE+Zb,OACE,qBACA,wBACA,UF5Za,SE6Zb,YFlZqB,IEmZrB,yBACA,oBACA,WF7be,KE8bf,MFzbe,QE4bjB,eACE,WFxbc,QEybd,MFtcY,KEycd,eACE,WFnbc,QEobd,MF3cY,KEkdd,SACE,WFndY,KEsdd,gBACE,aACA,8BACA,eFhaU,OEiaV,cFjaU,OEkaV,6BAGF,eACE,UFzbc,QE0bd,YFpbiB,IEqbjB,yBACA,mBAGF,cACE,iBACA,UFpca,QEqcb,MFjee,QEoejB,eACE,WACA,cFnbU,KEobV,sBAEA,oCACE,sBACA,yBAGF,kBACE,WFvfU,KEwfV,MFvfU,KEwfV,UFxdW,SEydX,YF7ce,IE8cf,yBACA,oBACA,gBAGF,wCACE,WF/fY,QEmgBhB,iBACE,aACA,yBAGF,gBACE,YACA,iBACA,sBAEA,qBACE,aACA,8BACA,sBACA,gCACA,UFlfW,OEofX,gCACE,mBAIJ,2BACE,WF5hBU,KE6hBV,MF5hBU,KE6hBV,YFjfe,IEkff,UF3fW,QE+ff,YACE,aACA,yBACA,QF3eU,KEkfZ,aACE,QFtfU,MEufV,yBACA,cF1fU,OE2fV,WF/iBc,QEijBd,mBACE,WFnjBU,KEujBd,kBACE,aACA,mCACA,IFngBU,MEogBV,gBAOF,WACE,aACA,mBACA,uBACA,iBACA,WFrkBe,QEwkBjB,WACE,WACA,gBACA,WF7kBY,KE8kBZ,sBAGF,aACE,yBACA,kBACA,gCAGF,WACE,UFjjBc,OEkjBd,YF7iBiB,IE8iBjB,MF7kBc,QE8kBd,yBACA,mBACA,cFxiBU,OE2iBZ,eACE,UFhkBa,OEikBb,MF3lBe,QE4lBf,yBACA,oBAGF,WACE,QF9iBU,KEijBZ,aACE,mBACA,kBACA,WF7mBc,QE8mBd,6BACA,UF/kBa,OEslBf,WACE,aACA,2DACA,IFjkBU,OEokBZ,WACE,cACA,QFtkBU,OEukBV,WFhoBY,KEioBZ,yBACA,qBACA,wBAEA,iBACE,aFvoBU,KEwoBV,qBAIJ,gBACE,WACA,YACA,cFvlBU,MEwlBV,MFloBc,QEqoBhB,iBACE,UF/mBa,KEgnBb,YFxmBiB,IEymBjB,MFtpBY,KEupBZ,cFjmBU,OEomBZ,gBACE,UFznBa,OE0nBb,MFppBe,QEupBjB,cACE,UF9nBa,OE+nBb,MFzpBe,QE2pBf,qBACE,MFpqBU,KEqqBV,YF1nBiB,IE8nBrB,eACE,WF/mBU,KEgnBV,aACA,sBACA,IFlnBU,KEqnBZ,cACE,WFhrBY,KEirBZ,yBACA,QFznBU,OE4nBZ,gBACE,aACA,8BACA,mBACA,cFloBU,MEqoBZ,eACE,UFzpBa,KE0pBb,YFnpBqB,IEopBrB,MFhsBY,KEisBZ,SAGF,aACE,aACA,sBACA,IFhpBU,QEmpBZ,aACE,aACA,8BACA,mBACA,QFvpBU,QEwpBV,WF7sBc,QE8sBd,yBACA,+BAEA,mBACE,WFjtBa,QEqtBjB,kBACE,aACA,sBACA,IFtqBU,QEuqBV,qBACA,cAIA,yBACE,qBAIJ,mBACE,YF5rBmB,IE6rBnB,MFxuBY,KE2uBd,iBACE,UF1sBa,OE2sBb,MFtuBe,QEyuBjB,mBACE,YFtsBmB,IEusBnB,MFlvBY,KEmvBZ,YFptBiB,yEEutBnB,aACE,MFzuBc,QE0uBd,qBAEA,mBACE,0BAIJ,YACE,MFxvBe,QEyvBf,UF/tBa,OEsuBf,yBACE,WACE,gBAGF,cACE,gBAIA,oEAIE,0BAIJ,aACE,sBACA,uBACA,IFruBQ,MEwuBV,kBACE,2BAQJ,YACE,aACA,mBACA,uBACA,iBACA,QFnvBU,KEovBV,WF5yBe,QE+yBjB,YACE,kBACA,oBACA,WFpzBY,KEqzBZ,yBACA,gBACA,WAGF,YACE,eACA,YFhxBiB,IEixBjB,cACA,MF1zBe,QE2zBf,cFxwBU,ME2wBZ,aACE,UF9xBa,SE+xBb,YFxxBiB,IEyxBjB,MFt0BY,KEu0BZ,cF/wBU,MEkxBZ,eACE,UFvyBa,QEwyBb,MFp0Be,QEq0Bf,cFlxBU,KEmxBV,gBAGF,eACE,aACA,IF3xBU,ME4xBV,uBAOF,cACE,aACA,0BACA,IFpyBU,OEsyBV,yBALF,cAMI,+BAIJ,iBACE,WFr2BY,KEs2BZ,yBACA,QF9yBU,OE+yBV,aACA,sBAGF,iBACE,aACA,sBACA,IFxzBU,ME2zBZ,iBACE,aACA,yBACA,gBACA,YF/zBU,MEk0BZ,YACE,aACA,8BACA,mBACA,kBACA,6BAEA,uBACE,mBAIJ,cACE,YF51BmB,IE61BnB,MFh4Be,QEm4BjB,cACE,MF54BY,KE+4Bd,eACE,aACA,sBACA,IF11BU,MEi2BZ,aACE,kBACA,sBACA,WF35BY,KE45BZ,yBAGF,kBACE,eACA,cF12BU,ME62BZ,mBACE,UFj4Ba,KEk4Bb,YF13BiB,IE23BjB,MFx6BY,KEy6BZ,cFn3BU,OEs3BZ,kBACE,UFz4Ba,QE04Bb,MFt6Be,QEu6Bf,cFr3BU,OE43BZ,gBACE,eACA,MACA,OACA,QACA,SACA,0BACA,aACA,mBACA,uBACA,aACA,UACA,kBACA,kDAGF,4BACE,UACA,mBAGF,YACE,WF38BY,KE48BZ,sBACA,WACA,gBACA,OFx5BU,MEy5BV,sBACA,+BAGF,yBACE,mBAGF,eACE,qBACA,gCACA,WFz9Be,QE49BjB,cACE,UF57Ba,QE67Bb,YFp7BiB,IEq7BjB,yBACA,oBACA,SAGF,aACE,QF96BU,OEi7BZ,gBACE,UFx8Ba,QEy8Bb,MFn+Be,QEo+Bf,SACA,gBAGF,eACE,aACA,IF77BU,QE87BV,yBACA,qBACA,6BACA,WFr/Be,QEy/BjB,oBACE,UF39Ba,OE49Bb,gBAEA,uBACE,gBACA,eACA,SAGF,uBACE,iBACA,aFh9BQ,MEi9BR,8BACA,cFp9BQ,OEq9BR,WFzgCY,QGFhB,aAEE,8BAIE,wBACA,6BAIF,KACE,2BACA,sBACA,eAIF,MACE,WACA,QAIF,YACE,yBAGF,aACE,wBAIF,EACE,sBACA,gCAIF,MACE,yBAGF,MACE,iCAIF,SACE,eACA,UACA,SAGF,gBACE,6BAIA,kBACE,8BACA,sBACA,iCACA,yBAKF,2BACE,8BACA,sBACA,iCACA,yBAKJ,YACE,eACA,SACA,OACA,QACA,kBACA,aACA","file":"output.css"} \ No newline at end of file diff --git a/internal/web/assets/js/dialog.js b/internal/web/assets/js/dialog.js new file mode 100644 index 0000000..c37caa8 --- /dev/null +++ b/internal/web/assets/js/dialog.js @@ -0,0 +1,249 @@ +// Dialog component for Billit +// Replaces browser confirm/alert dialogs with custom styled modals + +(function() { + 'use strict'; + + // Dialog state + let currentResolve = null; + let currentElement = null; + + // Create dialog HTML structure + function createDialogElement() { + const dialog = document.createElement('div'); + dialog.id = 'dialog'; + dialog.className = 'dialog-overlay'; + dialog.innerHTML = ` +
+
+

+
+
+

+
+ +
+ `; + document.body.appendChild(dialog); + + // Event listeners + dialog.querySelector('.dialog-cancel').addEventListener('click', () => closeDialog(false)); + dialog.querySelector('.dialog-confirm').addEventListener('click', () => closeDialog(true)); + dialog.addEventListener('click', (e) => { + if (e.target === dialog) closeDialog(false); + }); + + // Escape key closes dialog + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && dialog.classList.contains('dialog-open')) { + closeDialog(false); + } + }); + + return dialog; + } + + // Get or create dialog element + function getDialog() { + return document.getElementById('dialog') || createDialogElement(); + } + + // Open dialog with options + function openDialog(options) { + const dialog = getDialog(); + const title = options.title || 'Confirm'; + const message = options.message || 'Are you sure?'; + const confirmText = options.confirmText || 'Confirm'; + const cancelText = options.cancelText || 'Cancel'; + const confirmClass = options.confirmClass || 'btn-danger'; + const html = options.html || null; + const wide = options.wide || false; + const allowClose = options.allowClose !== false; + + dialog.querySelector('.dialog-title').textContent = title; + + // Support HTML content + if (html) { + dialog.querySelector('.dialog-body').innerHTML = html; + } else { + dialog.querySelector('.dialog-body').innerHTML = '

' + escapeHtml(message) + '

'; + } + + dialog.querySelector('.dialog-confirm').textContent = confirmText; + dialog.querySelector('.dialog-confirm').className = 'btn ' + confirmClass + ' dialog-confirm'; + dialog.querySelector('.dialog-cancel').textContent = cancelText; + + // Show/hide cancel button for alert-style dialogs + dialog.querySelector('.dialog-cancel').style.display = options.showCancel !== false ? '' : 'none'; + + // Wide mode for larger content + dialog.querySelector('.dialog-box').style.maxWidth = wide ? '600px' : '400px'; + + // Store allowClose setting + dialog.dataset.allowClose = allowClose; + + dialog.classList.add('dialog-open'); + dialog.querySelector('.dialog-confirm').focus(); + + return new Promise((resolve) => { + currentResolve = resolve; + }); + } + + // Close dialog + function closeDialog(result) { + const dialog = getDialog(); + + // Check if closing is allowed (for disclaimer) + if (!result && dialog.dataset.allowClose === 'false') { + return; + } + + dialog.classList.remove('dialog-open'); + + if (currentResolve) { + currentResolve(result); + currentResolve = null; + } + + // If there's a pending HTMX request, trigger it + if (result && currentElement) { + htmx.trigger(currentElement, 'confirmed'); + } + currentElement = null; + } + + // Escape HTML for safe rendering + function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + // Public API + window.Dialog = { + confirm: function(options) { + if (typeof options === 'string') { + options = { message: options }; + } + return openDialog({ ...options, showCancel: true }); + }, + + alert: function(options) { + if (typeof options === 'string') { + options = { message: options }; + } + return openDialog({ + ...options, + showCancel: false, + confirmText: options.confirmText || 'OK', + confirmClass: options.confirmClass || 'btn-primary' + }); + }, + + // Custom dialog with HTML content + custom: function(options) { + return openDialog(options); + } + }; + + // HTMX integration: intercept hx-confirm and use custom dialog + // Elements can customize the dialog with data attributes: + // data-dialog-title="Custom Title" + // data-dialog-confirm="Button Text" + // data-dialog-class="btn-danger" (or btn-primary, etc.) + // If no data-dialog-* attributes are present, uses browser default confirm + document.addEventListener('htmx:confirm', function(e) { + const element = e.detail.elt; + + // Check if element wants custom dialog (has any data-dialog-* attribute) + const hasCustomDialog = element.dataset.dialogTitle || + element.dataset.dialogConfirm || + element.dataset.dialogClass; + + if (!hasCustomDialog) { + return; // Let default browser confirm handle it + } + + // Prevent default browser confirm + e.preventDefault(); + + const message = e.detail.question; + const title = element.dataset.dialogTitle || 'Confirm'; + const confirmText = element.dataset.dialogConfirm || 'Confirm'; + const confirmClass = element.dataset.dialogClass || 'btn-primary'; + + // Store element for later + currentElement = element; + + Dialog.confirm({ + title: title, + message: message, + confirmText: confirmText, + confirmClass: confirmClass + }).then(function(confirmed) { + if (confirmed) { + // Issue the request + e.detail.issueRequest(true); + } + currentElement = null; + }); + }); + + // Disclaimer dialog - show on first visit + function showDisclaimer() { + const DISCLAIMER_KEY = 'billit_disclaimer_accepted'; + + // Check if already accepted + if (localStorage.getItem(DISCLAIMER_KEY)) { + return; + } + + const disclaimerHTML = ` +
+

+ Please read these terms carefully before using this software. By proceeding, you agree to the conditions below: +

+ +

+ Consult a qualified legal or financial advisor before relying on any data generated by this tool. +

+
+ `; + + Dialog.custom({ + title: '⚠️ GENERAL USE & NO LIABILITY DISCLAIMER', + html: disclaimerHTML, + confirmText: 'I Understand & Accept', + confirmClass: 'btn-primary', + showCancel: false, + wide: true, + allowClose: false + }).then(function(accepted) { + if (accepted) { + localStorage.setItem(DISCLAIMER_KEY, Date.now().toString()); + } + }); + } + + // Show disclaimer when DOM is ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', showDisclaimer); + } else { + showDisclaimer(); + } + +})(); diff --git a/cmd/web/assets/js/htmx.min.js b/internal/web/assets/js/htmx.min.js similarity index 100% rename from cmd/web/assets/js/htmx.min.js rename to internal/web/assets/js/htmx.min.js diff --git a/internal/web/assets/scss/_base.scss b/internal/web/assets/scss/_base.scss new file mode 100644 index 0000000..b280902 --- /dev/null +++ b/internal/web/assets/scss/_base.scss @@ -0,0 +1,116 @@ +// ============================================ +// BILLIT - Base/Reset Styles +// ============================================ + +@use 'variables' as *; + +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + font-size: 16px; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + font-family: $font-family-base; + font-size: $font-size-base; + line-height: $line-height-base; + color: $color-gray-900; + background-color: $color-white; + min-height: 100vh; +} + +// Typography +h1, h2, h3, h4, h5, h6 { + font-weight: $font-weight-bold; + line-height: $line-height-tight; + margin: 0; +} + +h1 { font-size: $font-size-2xl; } +h2 { font-size: $font-size-xl; } +h3 { font-size: $font-size-lg; } +h4 { font-size: $font-size-md; } +h5 { font-size: $font-size-base; } +h6 { font-size: $font-size-sm; } + +p { + margin: 0; +} + +a { + color: $color-accent; + text-decoration: none; + + &:hover { + text-decoration: underline; + } +} + +// Lists +ul, ol { + list-style: none; +} + +// Images +img { + max-width: 100%; + height: auto; +} + +// Tables +table { + border-collapse: collapse; + width: 100%; +} + +// Forms +input, select, textarea, button { + font-family: inherit; + font-size: inherit; + line-height: inherit; +} + +// Focus styles +:focus { + outline: 2px solid $color-accent; + outline-offset: 1px; +} + +:focus:not(:focus-visible) { + outline: none; +} + +:focus-visible { + outline: 2px solid $color-accent; + outline-offset: 1px; +} + +// Selection +::selection { + background: $color-primary; + color: $color-white; +} + +// Scrollbar (webkit) +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: $color-gray-100; +} + +::-webkit-scrollbar-thumb { + background: $color-gray-400; + + &:hover { + background: $color-gray-500; + } +} diff --git a/internal/web/assets/scss/_components.scss b/internal/web/assets/scss/_components.scss new file mode 100644 index 0000000..129ec7b --- /dev/null +++ b/internal/web/assets/scss/_components.scss @@ -0,0 +1,1044 @@ +// ============================================ +// BILLIT - Component Styles +// Industrial, Dense, McMaster-Carr Inspired +// ============================================ + +@use 'sass:color'; +@use 'variables' as *; + +// ============================================ +// LAYOUT +// ============================================ + +.container { + width: 100%; + max-width: $max-width-xl; + margin: 0 auto; + padding: 0 $spacing-8; +} + +.container-sm { + max-width: $max-width-sm; +} + +.container-md { + max-width: $max-width-md; +} + +.page { + padding: $spacing-8 0; +} + +.page-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: $spacing-8; + padding-bottom: $spacing-4; + border-bottom: $border-width-2 solid $color-black; +} + +.page-title { + font-size: $font-size-xl; + font-weight: $font-weight-bold; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +// ============================================ +// HEADER / NAV +// ============================================ + +.header { + background: $header-bg; + color: $header-text; + height: $header-height; + position: sticky; + top: 0; + z-index: 100; +} + +.header-inner { + display: flex; + align-items: center; + justify-content: space-between; + height: 100%; + max-width: $max-width-xl; + margin: 0 auto; + padding: 0 $spacing-8; +} + +.header-logo { + font-size: $font-size-lg; + font-weight: $font-weight-bold; + color: $color-primary; + text-decoration: none; + text-transform: uppercase; + letter-spacing: 1px; + + &:hover { + color: $color-primary-light; + text-decoration: none; + } +} + +.header-nav { + display: flex; + align-items: center; + gap: $spacing-1; +} + +.header-link { + display: inline-flex; + align-items: center; + height: $header-height; + padding: 0 $spacing-6; + color: $color-gray-300; + font-size: $font-size-sm; + font-weight: $font-weight-medium; + text-decoration: none; + text-transform: uppercase; + letter-spacing: 0.5px; + border-bottom: 2px solid transparent; + transition: all $transition-fast; + + &:hover { + color: $color-white; + background: rgba(255, 255, 255, 0.1); + text-decoration: none; + } + + &.active { + color: $color-primary; + border-bottom-color: $color-primary; + } +} + +// ============================================ +// BUTTONS +// ============================================ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + height: $btn-height; + padding: $btn-padding; + font-size: $font-size-sm; + font-weight: $font-weight-semibold; + text-transform: uppercase; + letter-spacing: 0.5px; + text-decoration: none; + border: $border-width solid transparent; + border-radius: $border-radius; + cursor: pointer; + transition: all $transition-fast; + white-space: nowrap; + + &:hover { + text-decoration: none; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +.btn-primary { + background: $color-primary; + color: $color-white; + border-color: $color-primary; + + &:hover:not(:disabled) { + background: $color-primary-dark; + border-color: $color-primary-dark; + } +} + +.btn-secondary { + background: $color-gray-800; + color: $color-white; + border-color: $color-gray-800; + + &:hover:not(:disabled) { + background: $color-black; + border-color: $color-black; + } +} + +.btn-outline { + background: transparent; + color: $color-gray-800; + border-color: $color-gray-400; + + &:hover:not(:disabled) { + background: $color-gray-100; + border-color: $color-gray-600; + } +} + +.btn-danger { + background: $color-error; + color: $color-white; + border-color: $color-error; + + &:hover:not(:disabled) { + background: color.adjust($color-error, $lightness: -10%); + border-color: color.adjust($color-error, $lightness: -10%); + } +} + +.btn-link { + background: none; + color: $color-accent; + border: none; + padding: 0; + height: auto; + text-transform: none; + letter-spacing: normal; + font-weight: $font-weight-normal; + + &:hover:not(:disabled) { + text-decoration: underline; + } +} + +.btn-sm { + height: 24px; + padding: $spacing-1 $spacing-4; + font-size: $font-size-xs; +} + +.btn-lg { + height: 36px; + padding: $spacing-4 $spacing-8; + font-size: $font-size-md; +} + +// ============================================ +// FORMS +// ============================================ + +.form-group { + margin-bottom: $spacing-6; +} + +.form-row { + display: grid; + gap: $spacing-6; + + &.cols-2 { grid-template-columns: repeat(2, 1fr); } + &.cols-3 { grid-template-columns: repeat(3, 1fr); } + &.cols-4 { grid-template-columns: repeat(4, 1fr); } + &.cols-5 { grid-template-columns: repeat(5, 1fr); } +} + +.form-label { + display: block; + margin-bottom: $spacing-2; + font-size: $font-size-xs; + font-weight: $font-weight-semibold; + text-transform: uppercase; + letter-spacing: 0.5px; + color: $color-gray-700; +} + +.form-input, +.form-select, +.form-textarea { + display: block; + width: 100%; + height: $input-height; + padding: $input-padding; + font-size: $font-size-base; + color: $color-gray-900; + background: $input-bg; + border: $border-width solid $input-border; + border-radius: $border-radius; + transition: border-color $transition-fast; + + &:focus { + border-color: $input-focus-border; + outline: none; + } + + &::placeholder { + color: $color-gray-400; + } + + &:disabled { + background: $color-gray-100; + cursor: not-allowed; + } +} + +.form-select { + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%23757575' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right $spacing-2 center; + background-size: 16px; + padding-right: $spacing-12; +} + +.form-textarea { + height: auto; + min-height: 80px; + resize: vertical; +} + +.form-hint { + margin-top: $spacing-2; + font-size: $font-size-xs; + color: $color-gray-500; +} + +.form-error { + margin-top: $spacing-2; + font-size: $font-size-xs; + color: $color-error; +} + +// ============================================ +// TABLES - Dense, Data-focused +// ============================================ + +.table-wrapper { + overflow-x: auto; + border: $border-width solid $table-border; +} + +.table { + width: 100%; + border-collapse: collapse; + font-size: $font-size-sm; +} + +.table th, +.table td { + padding: $table-cell-padding; + text-align: left; + border-bottom: $border-width solid $table-border; + vertical-align: middle; +} + +.table th { + background: $table-header-bg; + font-weight: $font-weight-semibold; + text-transform: uppercase; + letter-spacing: 0.3px; + font-size: $font-size-xs; + color: $color-gray-700; + white-space: nowrap; +} + +.table tbody tr:hover { + background: $table-row-hover; +} + +.table tbody tr:last-child td { + border-bottom: none; +} + +.table-numeric { + text-align: right; + font-family: $font-family-mono; + font-size: $font-size-sm; +} + +.table-actions { + text-align: right; + white-space: nowrap; +} + +.table-empty { + padding: $spacing-12 !important; + text-align: center; + color: $color-gray-500; +} + +// ============================================ +// CARDS / PANELS +// ============================================ + +.card { + background: $color-white; + border: $border-width solid $border-color; +} + +.card-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: $spacing-4 $spacing-6; + background: $color-gray-100; + border-bottom: $border-width solid $border-color; +} + +.card-title { + font-size: $font-size-sm; + font-weight: $font-weight-bold; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.card-body { + padding: $spacing-6; +} + +.panel { + background: $color-white; + border: $border-width solid $border-color; + margin-bottom: $spacing-8; +} + +.panel-header { + padding: $spacing-3 $spacing-4; + background: $color-black; + color: $color-white; + font-size: $font-size-xs; + font-weight: $font-weight-bold; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.panel-body { + padding: $spacing-4; +} + +// ============================================ +// ALERTS / MESSAGES +// ============================================ + +.alert { + padding: $spacing-4 $spacing-6; + border: $border-width solid; + margin-bottom: $spacing-6; + font-size: $font-size-sm; +} + +.alert-error { + background: color.adjust($color-error, $lightness: 45%); + border-color: $color-error; + color: $color-error; +} + +.alert-success { + background: color.adjust($color-success, $lightness: 50%); + border-color: $color-success; + color: $color-success; +} + +.alert-warning { + background: color.adjust($color-warning, $lightness: 40%); + border-color: $color-warning; + color: color.adjust($color-warning, $lightness: -15%); +} + +.alert-info { + background: color.adjust($color-info, $lightness: 45%); + border-color: $color-info; + color: $color-info; +} + +// ============================================ +// BADGES / TAGS +// ============================================ + +.badge { + display: inline-block; + padding: $spacing-1 $spacing-3; + font-size: $font-size-xs; + font-weight: $font-weight-semibold; + text-transform: uppercase; + letter-spacing: 0.3px; + background: $color-gray-200; + color: $color-gray-700; +} + +.badge-primary { + background: $color-primary; + color: $color-white; +} + +.badge-success { + background: $color-success; + color: $color-white; +} + +// ============================================ +// INVOICE SPECIFIC +// ============================================ + +.invoice { + background: $color-white; +} + +.invoice-header { + display: flex; + justify-content: space-between; + padding-bottom: $spacing-6; + margin-bottom: $spacing-6; + border-bottom: $border-width-2 solid $color-black; +} + +.invoice-title { + font-size: $font-size-2xl; + font-weight: $font-weight-bold; + text-transform: uppercase; + letter-spacing: 1px; +} + +.invoice-meta { + text-align: right; + font-size: $font-size-md; + color: $color-gray-600; +} + +.invoice-table { + width: 100%; + margin-bottom: $spacing-8; + border: $border-width solid $color-black; + + th, td { + padding: $spacing-3 $spacing-4; + border: $border-width solid $color-gray-300; + } + + th { + background: $color-black; + color: $color-white; + font-size: $font-size-xs; + font-weight: $font-weight-bold; + text-transform: uppercase; + letter-spacing: 0.3px; + text-align: left; + } + + tbody tr:nth-child(even) { + background: $color-gray-50; + } +} + +.invoice-summary { + display: flex; + justify-content: flex-end; +} + +.invoice-totals { + width: 300px; + margin-left: auto; + border: $border-width solid $color-black; + + .row { + display: flex; + justify-content: space-between; + padding: $spacing-3 $spacing-4; + border-bottom: $border-width solid $color-gray-300; + font-size: $font-size-sm; + + &:last-child { + border-bottom: none; + } + } + + .row-total { + background: $color-black; + color: $color-white; + font-weight: $font-weight-bold; + font-size: $font-size-md; + } +} + +.invoice-qr { + display: flex; + justify-content: flex-end; + padding: $spacing-8; +} + +// ============================================ +// PRODUCT ROW (for billing form) +// ============================================ + +.product-row { + padding: $spacing-4; + border: $border-width solid $border-color; + margin-bottom: $spacing-2; + background: $color-gray-50; + + &:hover { + background: $color-white; + } +} + +.product-row-grid { + display: grid; + grid-template-columns: 3fr 1fr auto; + gap: $spacing-4; + align-items: end; +} + +// ============================================ +// LOGIN / AUTH +// ============================================ + +.auth-page { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + background: $color-gray-100; +} + +.auth-card { + width: 100%; + max-width: 380px; + background: $color-white; + border: $border-width-2 solid $color-black; +} + +.auth-header { + padding: $spacing-8 $spacing-8 $spacing-6; + text-align: center; + border-bottom: $border-width solid $border-color; +} + +.auth-logo { + font-size: $font-size-3xl; + font-weight: $font-weight-bold; + color: $color-primary; + text-transform: uppercase; + letter-spacing: 2px; + margin-bottom: $spacing-2; +} + +.auth-subtitle { + font-size: $font-size-sm; + color: $color-gray-600; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.auth-body { + padding: $spacing-8; +} + +.auth-footer { + padding: $spacing-4 $spacing-8; + text-align: center; + background: $color-gray-50; + border-top: $border-width solid $border-color; + font-size: $font-size-sm; +} + +// ============================================ +// HOME +// ============================================ + +.home-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: $spacing-6; +} + +.home-card { + display: block; + padding: $spacing-6; + background: $color-white; + border: $border-width-2 solid $border-color; + text-decoration: none; + transition: all $transition-fast; + + &:hover { + border-color: $color-black; + text-decoration: none; + } +} + +.home-card-icon { + width: 32px; + height: 32px; + margin-bottom: $spacing-4; + color: $color-primary; +} + +.home-card-title { + font-size: $font-size-lg; + font-weight: $font-weight-bold; + color: $color-black; + margin-bottom: $spacing-2; +} + +.home-card-desc { + font-size: $font-size-sm; + color: $color-gray-600; +} + +.logged-in-as { + font-size: $font-size-sm; + color: $color-gray-600; + + strong { + color: $color-black; + font-weight: $font-weight-medium; + } +} + +.home-sections { + margin-top: $spacing-8; + display: flex; + flex-direction: column; + gap: $spacing-8; +} + +.home-section { + background: $color-white; + border: $border-width solid $border-color; + padding: $spacing-6; +} + +.section-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: $spacing-4; +} + +.section-title { + font-size: $font-size-lg; + font-weight: $font-weight-semibold; + color: $color-black; + margin: 0; +} + +.recent-list { + display: flex; + flex-direction: column; + gap: $spacing-3; +} + +.recent-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: $spacing-3; + background: $color-gray-50; + border: $border-width solid $border-color; + transition: background $transition-fast; + + &:hover { + background: $color-gray-100; + } +} + +.recent-item-main { + display: flex; + flex-direction: column; + gap: $spacing-1; + text-decoration: none; + color: inherit; +} + +a.recent-item-main { + &:hover { + text-decoration: none; + } +} + +.recent-item-title { + font-weight: $font-weight-medium; + color: $color-black; +} + +.recent-item-sub { + font-size: $font-size-sm; + color: $color-gray-500; +} + +.recent-item-value { + font-weight: $font-weight-medium; + color: $color-black; + font-family: $font-family-mono; +} + +.text-accent { + color: $color-primary; + text-decoration: none; + + &:hover { + text-decoration: underline; + } +} + +.text-muted { + color: $color-gray-600; + font-size: $font-size-sm; +} + +// ============================================ +// RESPONSIVE +// ============================================ + +@media (max-width: 768px) { + .container { + padding: 0 $spacing-4; + } + + .header-inner { + padding: 0 $spacing-4; + } + + .form-row { + &.cols-2, + &.cols-3, + &.cols-4, + &.cols-5 { + grid-template-columns: 1fr; + } + } + + .page-header { + flex-direction: column; + align-items: flex-start; + gap: $spacing-4; + } + + .product-row-grid { + grid-template-columns: 1fr; + } +} + +// ============================================ +// ERROR PAGE +// ============================================ + +.error-page { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + padding: $spacing-8; + background: $color-gray-100; +} + +.error-card { + text-align: center; + padding: $spacing-12 $spacing-8; + background: $color-white; + border: $border-width solid $border-color; + max-width: 500px; + width: 100%; +} + +.error-code { + font-size: 6rem; + font-weight: $font-weight-bold; + line-height: 1; + color: $color-gray-300; + margin-bottom: $spacing-4; +} + +.error-title { + font-size: $font-size-xl; + font-weight: $font-weight-bold; + color: $color-black; + margin-bottom: $spacing-4; +} + +.error-message { + font-size: $font-size-md; + color: $color-gray-600; + margin-bottom: $spacing-8; + line-height: 1.5; +} + +.error-actions { + display: flex; + gap: $spacing-4; + justify-content: center; +} + +// ============================================ +// ACCOUNT PAGE +// ============================================ + +.account-grid { + display: grid; + grid-template-columns: 1fr; + gap: $spacing-6; + + @media (min-width: 768px) { + grid-template-columns: 1fr 1fr; + } +} + +.account-section { + background: $color-white; + border: $border-width solid $border-color; + padding: $spacing-6; + display: flex; + flex-direction: column; +} + +.account-details { + display: flex; + flex-direction: column; + gap: $spacing-4; +} + +.account-actions { + display: flex; + justify-content: flex-end; + margin-top: auto; + padding-top: $spacing-4; +} + +.detail-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: $spacing-3 0; + border-bottom: 1px solid $color-gray-200; + + &:last-child { + border-bottom: none; + } +} + +.detail-label { + font-weight: $font-weight-medium; + color: $color-gray-600; +} + +.detail-value { + color: $color-black; +} + +.password-form { + display: flex; + flex-direction: column; + gap: $spacing-4; +} + +// ============================================ +// EMPTY STATE +// ============================================ + +.empty-state { + text-align: center; + padding: $spacing-12 $spacing-6; + background: $color-white; + border: $border-width solid $border-color; +} + +.empty-state-icon { + font-size: 3rem; + margin-bottom: $spacing-4; +} + +.empty-state-title { + font-size: $font-size-lg; + font-weight: $font-weight-bold; + color: $color-black; + margin-bottom: $spacing-2; +} + +.empty-state-desc { + font-size: $font-size-md; + color: $color-gray-600; + margin-bottom: $spacing-6; +} + +// ============================================ +// DIALOG / MODAL +// ============================================ + +.dialog-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + opacity: 0; + visibility: hidden; + transition: opacity 0.15s ease, visibility 0.15s ease; +} + +.dialog-overlay.dialog-open { + opacity: 1; + visibility: visible; +} + +.dialog-box { + background: $color-white; + border: $border-width-2 solid $color-black; + width: 100%; + max-width: 400px; + margin: $spacing-4; + transform: scale(0.95); + transition: transform 0.15s ease; +} + +.dialog-open .dialog-box { + transform: scale(1); +} + +.dialog-header { + padding: $spacing-4 $spacing-6; + border-bottom: $border-width solid $border-color; + background: $color-gray-100; +} + +.dialog-title { + font-size: $font-size-md; + font-weight: $font-weight-bold; + text-transform: uppercase; + letter-spacing: 0.5px; + margin: 0; +} + +.dialog-body { + padding: $spacing-6; +} + +.dialog-message { + font-size: $font-size-md; + color: $color-gray-800; + margin: 0; + line-height: 1.5; +} + +.dialog-footer { + display: flex; + gap: $spacing-3; + justify-content: flex-end; + padding: $spacing-4 $spacing-6; + border-top: $border-width solid $border-color; + background: $color-gray-100; +} + +// Disclaimer specific styles +.disclaimer-content { + font-size: $font-size-sm; + line-height: 1.6; + + ul { + list-style: none; + padding-left: 0; + margin: 0; + } + + li { + padding: $spacing-2 0; + padding-left: $spacing-4; + border-left: 3px solid $color-primary; + margin-bottom: $spacing-2; + background: $color-gray-50; + } +} diff --git a/internal/web/assets/scss/_print.scss b/internal/web/assets/scss/_print.scss new file mode 100644 index 0000000..4fe2d58 --- /dev/null +++ b/internal/web/assets/scss/_print.scss @@ -0,0 +1,93 @@ +// ============================================ +// BILLIT - Print Styles +// ============================================ + +@use 'variables' as *; + +@media print { + // Hide non-printable elements + .no-print, + .header, + .btn, + button { + display: none !important; + visibility: hidden !important; + } + + // Reset backgrounds + body { + background: white !important; + color: black !important; + font-size: 11pt; + } + + // Page setup + @page { + margin: 1cm; + size: A4; + } + + // Page breaks + .page-break { + page-break-before: always; + } + + .avoid-break { + page-break-inside: avoid; + } + + // Links + a { + color: black !important; + text-decoration: none !important; + } + + // Tables + table { + border-collapse: collapse; + } + + th, td { + border: 1px solid #000 !important; + } + + // Invoice specific + .invoice { + max-width: 100%; + padding: 0; + margin: 0; + } + + .invoice-header { + border-bottom: 2px solid black; + } + + .invoice-table { + th { + background: #e0e0e0 !important; + color: black !important; + -webkit-print-color-adjust: exact; + print-color-adjust: exact; + } + } + + .invoice-totals { + .row-total { + background: #e0e0e0 !important; + color: black !important; + -webkit-print-color-adjust: exact; + print-color-adjust: exact; + } + } + + // QR code footer + .invoice-qr { + position: fixed; + bottom: 0; + left: 0; + right: 0; + text-align: center; + padding: 10px; + border-top: 1px solid #ccc; + } +} diff --git a/internal/web/assets/scss/_utilities.scss b/internal/web/assets/scss/_utilities.scss new file mode 100644 index 0000000..3ea7d2c --- /dev/null +++ b/internal/web/assets/scss/_utilities.scss @@ -0,0 +1,197 @@ +// ============================================ +// BILLIT - Utility Classes +// ============================================ + +@use 'variables' as *; + +// Display +.hidden { display: none !important; } +.block { display: block; } +.inline { display: inline; } +.inline-block { display: inline-block; } +.flex { display: flex; } +.inline-flex { display: inline-flex; } +.grid { display: grid; } + +// Flex utilities +.flex-row { flex-direction: row; } +.flex-col { flex-direction: column; } +.flex-wrap { flex-wrap: wrap; } +.flex-1 { flex: 1; } +.flex-grow { flex-grow: 1; } +.flex-shrink-0 { flex-shrink: 0; } + +.items-start { align-items: flex-start; } +.items-center { align-items: center; } +.items-end { align-items: flex-end; } +.items-stretch { align-items: stretch; } + +.justify-start { justify-content: flex-start; } +.justify-center { justify-content: center; } +.justify-end { justify-content: flex-end; } +.justify-between { justify-content: space-between; } + +.gap-1 { gap: $spacing-1; } +.gap-2 { gap: $spacing-2; } +.gap-3 { gap: $spacing-3; } +.gap-4 { gap: $spacing-4; } +.gap-6 { gap: $spacing-6; } +.gap-8 { gap: $spacing-8; } + +// Text +.text-xs { font-size: $font-size-xs; } +.text-sm { font-size: $font-size-sm; } +.text-base { font-size: $font-size-base; } +.text-md { font-size: $font-size-md; } +.text-lg { font-size: $font-size-lg; } +.text-xl { font-size: $font-size-xl; } +.text-2xl { font-size: $font-size-2xl; } +.text-3xl { font-size: $font-size-3xl; } + +.font-normal { font-weight: $font-weight-normal; } +.font-medium { font-weight: $font-weight-medium; } +.font-semibold { font-weight: $font-weight-semibold; } +.font-bold { font-weight: $font-weight-bold; } + +.text-left { text-align: left; } +.text-center { text-align: center; } +.text-right { text-align: right; } + +.uppercase { text-transform: uppercase; } +.lowercase { text-transform: lowercase; } +.capitalize { text-transform: capitalize; } + +.truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.whitespace-nowrap { white-space: nowrap; } + +// Colors +.text-black { color: $color-black; } +.text-white { color: $color-white; } +.text-gray { color: $color-gray-600; } +.text-gray-dark { color: $color-gray-800; } +.text-gray-light { color: $color-gray-500; } +.text-primary { color: $color-primary; } +.text-accent { color: $color-accent; } +.text-success { color: $color-success; } +.text-warning { color: $color-warning; } +.text-error { color: $color-error; } + +.bg-white { background-color: $color-white; } +.bg-gray-50 { background-color: $color-gray-50; } +.bg-gray-100 { background-color: $color-gray-100; } +.bg-gray-200 { background-color: $color-gray-200; } +.bg-black { background-color: $color-black; } +.bg-primary { background-color: $color-primary; } + +// Spacing - Margin +.m-0 { margin: $spacing-0; } +.m-2 { margin: $spacing-2; } +.m-4 { margin: $spacing-4; } +.m-8 { margin: $spacing-8; } + +.mt-0 { margin-top: $spacing-0; } +.mt-2 { margin-top: $spacing-2; } +.mt-4 { margin-top: $spacing-4; } +.mt-6 { margin-top: $spacing-6; } +.mt-8 { margin-top: $spacing-8; } +.mt-12 { margin-top: $spacing-12; } + +.mb-0 { margin-bottom: $spacing-0; } +.mb-2 { margin-bottom: $spacing-2; } +.mb-4 { margin-bottom: $spacing-4; } +.mb-6 { margin-bottom: $spacing-6; } +.mb-8 { margin-bottom: $spacing-8; } + +.ml-2 { margin-left: $spacing-2; } +.ml-4 { margin-left: $spacing-4; } +.mr-2 { margin-right: $spacing-2; } +.mr-4 { margin-right: $spacing-4; } + +.mx-auto { margin-left: auto; margin-right: auto; } + +// Spacing - Padding +.p-0 { padding: $spacing-0; } +.p-2 { padding: $spacing-2; } +.p-3 { padding: $spacing-3; } +.p-4 { padding: $spacing-4; } +.p-6 { padding: $spacing-6; } +.p-8 { padding: $spacing-8; } + +.px-2 { padding-left: $spacing-2; padding-right: $spacing-2; } +.px-4 { padding-left: $spacing-4; padding-right: $spacing-4; } +.px-6 { padding-left: $spacing-6; padding-right: $spacing-6; } +.px-8 { padding-left: $spacing-8; padding-right: $spacing-8; } + +.py-2 { padding-top: $spacing-2; padding-bottom: $spacing-2; } +.py-3 { padding-top: $spacing-3; padding-bottom: $spacing-3; } +.py-4 { padding-top: $spacing-4; padding-bottom: $spacing-4; } +.py-6 { padding-top: $spacing-6; padding-bottom: $spacing-6; } +.py-8 { padding-top: $spacing-8; padding-bottom: $spacing-8; } + +// Width/Height +.w-full { width: 100%; } +.w-auto { width: auto; } +.h-full { height: 100%; } +.h-screen { height: 100vh; } +.min-h-screen { min-height: 100vh; } + +// Borders +.border { border: $border-width solid $border-color; } +.border-0 { border: none; } +.border-t { border-top: $border-width solid $border-color; } +.border-b { border-bottom: $border-width solid $border-color; } +.border-l { border-left: $border-width solid $border-color; } +.border-r { border-right: $border-width solid $border-color; } +.border-dark { border-color: $border-color-dark; } +.border-2 { border-width: $border-width-2; } + +// Position +.relative { position: relative; } +.absolute { position: absolute; } +.fixed { position: fixed; } +.sticky { position: sticky; } + +.top-0 { top: 0; } +.right-0 { right: 0; } +.bottom-0 { bottom: 0; } +.left-0 { left: 0; } +.inset-0 { top: 0; right: 0; bottom: 0; left: 0; } + +// Z-index +.z-10 { z-index: 10; } +.z-20 { z-index: 20; } +.z-50 { z-index: 50; } +.z-100 { z-index: 100; } + +// Overflow +.overflow-hidden { overflow: hidden; } +.overflow-auto { overflow: auto; } +.overflow-x-auto { overflow-x: auto; } +.overflow-y-auto { overflow-y: auto; } + +// Shadows +.shadow { box-shadow: $shadow-sm; } +.shadow-md { box-shadow: $shadow-md; } +.shadow-lg { box-shadow: $shadow-lg; } +.shadow-none { box-shadow: none; } + +// Cursor +.cursor-pointer { cursor: pointer; } +.cursor-default { cursor: default; } + +// Opacity +.opacity-50 { opacity: 0.5; } +.opacity-75 { opacity: 0.75; } + +// Print utilities +@media print { + .no-print { display: none !important; } + .print-only { display: block !important; } +} + +.print-only { display: none; } diff --git a/internal/web/assets/scss/_variables.scss b/internal/web/assets/scss/_variables.scss new file mode 100644 index 0000000..173178a --- /dev/null +++ b/internal/web/assets/scss/_variables.scss @@ -0,0 +1,116 @@ +// ============================================ +// BILLIT - McMaster-Carr Inspired Design System +// Industrial, Dense, Functional, No Roundedness +// ============================================ + +// Colors - Industrial palette +$color-black: #000000; +$color-white: #ffffff; +$color-gray-50: #fafafa; +$color-gray-100: #f5f5f5; +$color-gray-200: #eeeeee; +$color-gray-300: #e0e0e0; +$color-gray-400: #bdbdbd; +$color-gray-500: #9e9e9e; +$color-gray-600: #757575; +$color-gray-700: #616161; +$color-gray-800: #424242; +$color-gray-900: #212121; + +// Primary - Industrial orange (McMaster signature) +$color-primary: #e65100; +$color-primary-dark: #bf360c; +$color-primary-light: #ff6d00; + +// Accent - Deep blue for links/actions +$color-accent: #0d47a1; +$color-accent-dark: #002171; +$color-accent-light: #1565c0; + +// Status colors +$color-success: #2e7d32; +$color-warning: #f57c00; +$color-error: #c62828; +$color-info: #1565c0; + +// Typography +$font-family-base: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; +$font-family-mono: "SF Mono", "Monaco", "Inconsolata", "Fira Mono", "Droid Sans Mono", monospace; + +$font-size-xs: 0.6875rem; // 11px +$font-size-sm: 0.75rem; // 12px +$font-size-base: 0.8125rem; // 13px +$font-size-md: 0.875rem; // 14px +$font-size-lg: 1rem; // 16px +$font-size-xl: 1.125rem; // 18px +$font-size-2xl: 1.25rem; // 20px +$font-size-3xl: 1.5rem; // 24px + +$font-weight-normal: 400; +$font-weight-medium: 500; +$font-weight-semibold: 600; +$font-weight-bold: 700; + +$line-height-tight: 1.2; +$line-height-base: 1.4; +$line-height-loose: 1.6; + +// Spacing - Dense, compact +$spacing-0: 0; +$spacing-1: 0.125rem; // 2px +$spacing-2: 0.25rem; // 4px +$spacing-3: 0.375rem; // 6px +$spacing-4: 0.5rem; // 8px +$spacing-5: 0.625rem; // 10px +$spacing-6: 0.75rem; // 12px +$spacing-8: 1rem; // 16px +$spacing-10: 1.25rem; // 20px +$spacing-12: 1.5rem; // 24px +$spacing-16: 2rem; // 32px +$spacing-20: 2.5rem; // 40px + +// Borders - Sharp, no radius +$border-width: 1px; +$border-width-2: 2px; +$border-color: $color-gray-300; +$border-color-dark: $color-gray-400; +$border-radius: 0; // NO ROUNDEDNESS + +// Shadows - Minimal +$shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.1); +$shadow-md: 0 2px 4px rgba(0, 0, 0, 0.1); +$shadow-lg: 0 4px 8px rgba(0, 0, 0, 0.15); + +// Transitions +$transition-fast: 0.1s ease; +$transition-base: 0.15s ease; +$transition-slow: 0.25s ease; + +// Layout +$max-width-sm: 640px; +$max-width-md: 768px; +$max-width-lg: 1024px; +$max-width-xl: 1280px; +$max-width-2xl: 1536px; + +// Header +$header-height: 40px; +$header-bg: $color-black; +$header-text: $color-white; + +// Table +$table-header-bg: $color-gray-100; +$table-border: $color-gray-300; +$table-row-hover: $color-gray-50; +$table-cell-padding: $spacing-3 $spacing-4; + +// Form inputs +$input-height: 28px; +$input-padding: $spacing-2 $spacing-4; +$input-border: $border-color; +$input-focus-border: $color-accent; +$input-bg: $color-white; + +// Buttons +$btn-height: 28px; +$btn-padding: $spacing-2 $spacing-6; diff --git a/internal/web/assets/scss/main.scss b/internal/web/assets/scss/main.scss new file mode 100644 index 0000000..fcf653c --- /dev/null +++ b/internal/web/assets/scss/main.scss @@ -0,0 +1,10 @@ +// ============================================ +// BILLIT - Main SCSS Entry Point +// McMaster-Carr Inspired Design System +// ============================================ + +@use 'variables' as *; +@use 'base'; +@use 'utilities'; +@use 'components'; +@use 'print'; diff --git a/internal/web/auth.go b/internal/web/auth.go new file mode 100644 index 0000000..592a048 --- /dev/null +++ b/internal/web/auth.go @@ -0,0 +1,138 @@ +package web + +import ( + "billit/internal/auth" + "net/http" + "net/url" + "strings" + + "github.com/labstack/echo/v4" +) + +// AuthHandlers holds auth service reference +type AuthHandlers struct { + auth *auth.Service +} + +// NewAuthHandlers creates handlers with auth service +func NewAuthHandlers(authService *auth.Service) *AuthHandlers { + return &AuthHandlers{auth: authService} +} + +// LoginPageHandler renders the login page (home page) +func (h *AuthHandlers) LoginPageHandler(c echo.Context) error { + // Check if already logged in + cookie, err := c.Cookie("auth_token") + if err == nil && cookie.Value != "" { + _, err := h.auth.ValidateToken(cookie.Value) + if err == nil { + // Already logged in, redirect to home + return c.Redirect(http.StatusFound, "/home") + } + } + // Capture redirect URL from query param + redirectURL := c.QueryParam("redirect") + return Render(c, LoginPage("", "", redirectURL)) +} + +// LoginHandler handles login form submission +func (h *AuthHandlers) LoginHandler(c echo.Context) error { + email := strings.TrimSpace(c.FormValue("email")) + password := c.FormValue("password") + redirectURL := c.FormValue("redirect") + + if email == "" || password == "" { + return Render(c, LoginPage("Email and password are required", email, redirectURL)) + } + + token, err := h.auth.Login(email, password) + if err != nil { + return Render(c, LoginPage("Invalid email or password", email, redirectURL)) + } + + // Set HTTP-only cookie + cookie := h.auth.CreateAuthCookie(token) + c.SetCookie(cookie) + + // Redirect to original URL or home page + if redirectURL != "" && strings.HasPrefix(redirectURL, "/") { + return c.Redirect(http.StatusFound, redirectURL) + } + return c.Redirect(http.StatusFound, "/home") +} + +// RegisterPageHandler renders the registration page +func (h *AuthHandlers) RegisterPageHandler(c echo.Context) error { + return Render(c, RegisterPage("", "")) +} + +// RegisterHandler handles registration form submission +func (h *AuthHandlers) RegisterHandler(c echo.Context) error { + email := strings.TrimSpace(c.FormValue("email")) + password := c.FormValue("password") + confirmPassword := c.FormValue("confirm_password") + + if email == "" || password == "" { + return Render(c, RegisterPage("Email and password are required", email)) + } + + if password != confirmPassword { + return Render(c, RegisterPage("Passwords do not match", email)) + } + + if len(password) < 8 { + return Render(c, RegisterPage("Password must be at least 8 characters", email)) + } + + _, err := h.auth.Register(email, password) + if err != nil { + if err == auth.ErrUserExists { + return Render(c, RegisterPage("An account with this email already exists", email)) + } + return Render(c, RegisterPage(err.Error(), email)) + } + + // Auto-login after registration + token, err := h.auth.Login(email, password) + if err != nil { + return c.Redirect(http.StatusFound, "/") + } + + cookie := h.auth.CreateAuthCookie(token) + c.SetCookie(cookie) + + return c.Redirect(http.StatusFound, "/home") +} + +// LogoutHandler clears the auth cookie and redirects to login +func (h *AuthHandlers) LogoutHandler(c echo.Context) error { + cookie := h.auth.ClearAuthCookie() + c.SetCookie(cookie) + return c.Redirect(http.StatusFound, "/") +} + +// AuthMiddleware protects routes that require authentication +func (h *AuthHandlers) AuthMiddleware(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + cookie, err := c.Cookie("auth_token") + if err != nil || cookie.Value == "" { + // No cookie - redirect to login with original URL for post-login redirect + redirectPath := url.QueryEscape(c.Request().URL.RequestURI()) + return c.Redirect(http.StatusFound, "/?redirect="+redirectPath) + } + + claims, err := h.auth.ValidateToken(cookie.Value) + if err != nil { + // Invalid/expired token - show session expired dialog + c.SetCookie(h.auth.ClearAuthCookie()) + redirectPath := url.QueryEscape(c.Request().URL.RequestURI()) + return Render(c, SessionExpiredPage(redirectPath)) + } + + // Store user info in context + c.Set("user_id", claims.UserID) + c.Set("user_email", claims.Email) + + return next(c) + } +} diff --git a/internal/web/billing.go b/internal/web/billing.go new file mode 100644 index 0000000..52c47e0 --- /dev/null +++ b/internal/web/billing.go @@ -0,0 +1,434 @@ +package web + +import ( + "billit/internal/database" + "billit/internal/gst" + "encoding/base64" + "encoding/json" + "fmt" + "math" + "net/http" + "strconv" + "strings" + + "github.com/google/uuid" + "github.com/labstack/echo/v4" + qrcode "github.com/skip2/go-qrcode" +) + +// numberToWords converts a number to Indian English words (supports up to crores) +func numberToWords(n float64) string { + if n == 0 { + return "Zero" + } + + ones := []string{"", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", + "Ten", "Eleven", "Twelve", "Thirteen", "Fourteen", "Fifteen", "Sixteen", "Seventeen", "Eighteen", "Nineteen"} + tens := []string{"", "", "Twenty", "Thirty", "Forty", "Fifty", "Sixty", "Seventy", "Eighty", "Ninety"} + + convertLessThanHundred := func(n int) string { + if n < 20 { + return ones[n] + } + if n%10 == 0 { + return tens[n/10] + } + return tens[n/10] + " " + ones[n%10] + } + + convertLessThanThousand := func(n int) string { + if n < 100 { + return convertLessThanHundred(n) + } + if n%100 == 0 { + return ones[n/100] + " Hundred" + } + return ones[n/100] + " Hundred " + convertLessThanHundred(n%100) + } + // Split into rupees and paise + rupees := int(math.Floor(n)) + paise := int(math.Round((n - float64(rupees)) * 100)) + + var result string + + if rupees >= 10000000 { // Crores + crores := rupees / 10000000 + rupees = rupees % 10000000 + result += convertLessThanThousand(crores) + " Crore " + } + if rupees >= 100000 { // Lakhs + lakhs := rupees / 100000 + rupees = rupees % 100000 + result += convertLessThanHundred(lakhs) + " Lakh " + } + if rupees >= 1000 { // Thousands + thousands := rupees / 1000 + rupees = rupees % 1000 + result += convertLessThanHundred(thousands) + " Thousand " + } + if rupees > 0 { + result += convertLessThanThousand(rupees) + } + + result = strings.TrimSpace(result) + if result == "" { + result = "Zero" + } + result += " Rupees" + + if paise > 0 { + result += " and " + convertLessThanHundred(paise) + " Paise" + } + + return result + " Only" +} + +// BillingHandlers holds db reference for billing operations +type BillingHandlers struct { + db database.Service +} + +// NewBillingHandlers creates handlers with db access +func NewBillingHandlers(db database.Service) *BillingHandlers { + return &BillingHandlers{db: db} +} + +// BillingPageHandler renders the /billing page for creating bills +func (h *BillingHandlers) BillingPageHandler(c echo.Context) error { + userID := getUserID(c) + products, err := h.db.GetAllProducts(userID) + if err != nil { + products = []database.Product{} + } + buyers, err := h.db.GetAllBuyerDetails(userID) + if err != nil { + buyers = []database.BuyerDetails{} + } + return Render(c, BillingPage(products, buyers)) +} + +// CalculateBillHandler calculates the bill (HTMX endpoint) +func (h *BillingHandlers) CalculateBillHandler(c echo.Context) error { + userID := getUserID(c) + customerType := c.FormValue("customer_type") + regionType := c.FormValue("region_type") + includeConvenienceFee := c.FormValue("include_convenience_fee") == "yes" + + cType := gst.CustomerRetail + if customerType == "wholesale" { + cType = gst.CustomerWholesale + } + isInterState := regionType == "inter" + + calculator := gst.NewCalculator() + var items []gst.LineItem + var totalFee float64 + + // Support up to 50 product slots for dynamic adding + for i := 0; i < 50; i++ { + sku := c.FormValue("product_sku_" + strconv.Itoa(i)) + qtyStr := c.FormValue("qty_" + strconv.Itoa(i)) + + if sku == "" { + continue + } + + qty, err := strconv.Atoi(qtyStr) + if err != nil || qty <= 0 { + continue + } + + // Get product from DB (user-scoped) + dbProduct, err := h.db.GetProductBySKU(sku, userID) + if err != nil || dbProduct == nil { + continue + } + + // Convert to gst.Product + product := gst.Product{ + SKU: dbProduct.SKU, + Name: dbProduct.Name, + HSNCode: dbProduct.HSNCode, + BasePrice: dbProduct.BasePrice, + WholesalePrice: dbProduct.WholesalePrice, + GSTRate: gst.Rate(dbProduct.GSTRate), + SmallOrderQty: dbProduct.SmallOrderQty, + SmallOrderFee: dbProduct.SmallOrderFee, + Unit: dbProduct.Unit, + } + + lineItem := calculator.CalculateLineItem(product, qty, cType, isInterState) + items = append(items, lineItem) + + // Apply per-product convenience fee if quantity is below threshold and checkbox is checked + if includeConvenienceFee && product.SmallOrderQty > 0 && qty < product.SmallOrderQty && product.SmallOrderFee > 0 { + totalFee += product.SmallOrderFee + } + } + + invoice := calculator.CalculateInvoice(items, totalFee, isInterState) + invoice.CustomerType = cType + + return Render(c, InvoiceSummary(invoice)) +} + +// GenerateBillHandler generates final invoice with UUID and persists to DB +func (h *BillingHandlers) GenerateBillHandler(c echo.Context) error { + userID := getUserID(c) + if userID == "" { + return c.Redirect(http.StatusFound, "/") + } + + customerType := c.FormValue("customer_type") + regionType := c.FormValue("region_type") + buyerID := c.FormValue("buyer_id") + includeConvenienceFee := c.FormValue("include_convenience_fee") == "yes" + + cType := gst.CustomerRetail + if customerType == "wholesale" { + cType = gst.CustomerWholesale + } + isInterState := regionType == "inter" + + calculator := gst.NewCalculator() + var items []gst.LineItem + var totalFee float64 + + for i := 0; i < 50; i++ { + sku := c.FormValue("product_sku_" + strconv.Itoa(i)) + qtyStr := c.FormValue("qty_" + strconv.Itoa(i)) + + if sku == "" { + continue + } + + qty, err := strconv.Atoi(qtyStr) + if err != nil || qty <= 0 { + continue + } + + dbProduct, err := h.db.GetProductBySKU(sku, userID) + if err != nil || dbProduct == nil { + continue + } + + product := gst.Product{ + SKU: dbProduct.SKU, + Name: dbProduct.Name, + HSNCode: dbProduct.HSNCode, + BasePrice: dbProduct.BasePrice, + WholesalePrice: dbProduct.WholesalePrice, + GSTRate: gst.Rate(dbProduct.GSTRate), + SmallOrderQty: dbProduct.SmallOrderQty, + SmallOrderFee: dbProduct.SmallOrderFee, + Unit: dbProduct.Unit, + } + + lineItem := calculator.CalculateLineItem(product, qty, cType, isInterState) + items = append(items, lineItem) + + // Apply per-product convenience fee if checkbox is checked + if includeConvenienceFee && product.SmallOrderQty > 0 && qty < product.SmallOrderQty && product.SmallOrderFee > 0 { + totalFee += product.SmallOrderFee + } + } + + if len(items) == 0 { + return c.String(http.StatusBadRequest, "No products selected") + } + + invoice := calculator.CalculateInvoice(items, totalFee, isInterState) + invoice.CustomerType = cType + + // Get user's company and bank details + user, err := h.db.GetUserByID(userID) + if err == nil && user != nil { + invoice.CompanyDetails = user.CompanyDetails + invoice.BankDetails = user.BankDetails + } + + // Get buyer details if selected + if buyerID != "" { + buyer, err := h.db.GetBuyerDetails(buyerID, userID) + if err == nil && buyer != nil { + invoice.BuyerName = buyer.Name + invoice.BuyerDetails = buyer.Details + } + } + + // Generate UUID for invoice + invoiceID := uuid.New().String() + + // Generate human-readable invoice ID + humanReadableID, err := h.db.GetNextInvoiceNumber(userID) + if err != nil { + return c.String(http.StatusInternalServerError, "failed to generate invoice number") + } + + // Persist to DB (user-scoped) + if err := h.db.CreateInvoice(invoiceID, humanReadableID, invoice, userID); err != nil { + return c.String(http.StatusInternalServerError, "failed to save invoice") + } + + // Redirect to invoice view + c.Response().Header().Set("HX-Redirect", fmt.Sprintf("/invoice/%s", invoiceID)) + return c.NoContent(http.StatusOK) +} + +// ShowInvoiceHandler displays the invoice by UUID (requires auth) +func (h *BillingHandlers) ShowInvoiceHandler(c echo.Context) error { + userID := getUserID(c) + if userID == "" { + return c.Redirect(http.StatusFound, "/") + } + + invoiceID := c.Param("id") + + inv, err := h.db.GetInvoice(invoiceID, userID) + if err != nil || inv == nil { + return RenderNotFound(c, "Invoice not found or you don't have access to it.") + } + + // Parse the JSON data back into Invoice struct + var invoice gst.Invoice + if err := json.Unmarshal([]byte(inv.Data), &invoice); err != nil { + return c.String(http.StatusInternalServerError, "failed to parse invoice data") + } + + // Generate QR code for invoice URL + invoiceURL := fmt.Sprintf("%s://%s/invoice/%s", c.Scheme(), c.Request().Host, invoiceID) + qrPNG, err := qrcode.Encode(invoiceURL, qrcode.Medium, 100) + if err != nil { + return c.String(http.StatusInternalServerError, "failed to generate QR code") + } + qrBase64 := base64.StdEncoding.EncodeToString(qrPNG) + + // Render printable invoice page with multi-page support + c.Response().Header().Set(echo.HeaderContentType, echo.MIMETextHTML) + w := c.Response().Writer + fmt.Fprint(w, "Invoice ") + fmt.Fprintf(w, "%s", invoiceID[:8]) + fmt.Fprint(w, "") + fmt.Fprint(w, ``) + fmt.Fprint(w, "") + fmt.Fprintf(w, `
+
+ ← Back + +
+
+
+
+

Tax Invoice

+
+
+

Invoice ID: %s

+
+
+

Date: %s

+
+
`, inv.HumanReadableID, strings.ReplaceAll(inv.CreatedAt, "T", " ")[0:10]) + + // Display company details above the invoice table + if invoice.CompanyDetails != "" { + fmt.Fprintf(w, `
From:
%s
`, invoice.CompanyDetails) + } + + if err := PrintableInvoice(invoice).Render(c.Request().Context(), w); err != nil { + return err + } + + // Display buyer details and total amount in same section (50-50 split or 100% if no buyer) + totalGST := invoice.TotalCGST + invoice.TotalSGST + invoice.TotalIGST + hasBuyerInfo := invoice.BuyerName != "" || invoice.BuyerDetails != "" + + if hasBuyerInfo { + fmt.Fprint(w, `") + + fmt.Fprint(w, "
") + return nil +} + +// AddProductRowHandler returns HTML for a new product row (HTMX endpoint) +func (h *BillingHandlers) AddProductRowHandler(c echo.Context) error { + userID := getUserID(c) + indexStr := c.QueryParam("index") + index, err := strconv.Atoi(indexStr) + if err != nil { + index = 0 + } + + products, _ := h.db.GetAllProducts(userID) + + // Build product options HTML + productOptions := `` + for _, p := range products { + productOptions += fmt.Sprintf(``, p.SKU, p.Name, p.BasePrice) + } + + rowHTML := fmt.Sprintf(` +
+
+
+ + +
+
+ + +
+
+ +
+
+
`, index, productOptions, index) + + return c.HTML(http.StatusOK, rowHTML) +} diff --git a/internal/web/buyer.go b/internal/web/buyer.go new file mode 100644 index 0000000..90b2bac --- /dev/null +++ b/internal/web/buyer.go @@ -0,0 +1,106 @@ +package web + +import ( + "billit/internal/database" + "net/http" + + "github.com/labstack/echo/v4" +) + +// BuyerHandlers holds db reference for buyer operations +type BuyerHandlers struct { + db database.Service +} + +// NewBuyerHandlers creates handlers with db access +func NewBuyerHandlers(db database.Service) *BuyerHandlers { + return &BuyerHandlers{db: db} +} + +// BuyerListHandler renders the /buyer page with all buyers +func (h *BuyerHandlers) BuyerListHandler(c echo.Context) error { + userID := getUserID(c) + buyers, err := h.db.GetAllBuyerDetails(userID) + if err != nil { + return RenderServerError(c, "Failed to load buyers. Please try again.") + } + return Render(c, BuyerListPage(buyers)) +} + +// BuyerCreatePageHandler renders the /buyer/create form page +func (h *BuyerHandlers) BuyerCreatePageHandler(c echo.Context) error { + return Render(c, BuyerCreatePage()) +} + +// BuyerEditPageHandler renders the /buyer/edit/:id form page +func (h *BuyerHandlers) BuyerEditPageHandler(c echo.Context) error { + id := c.Param("id") + userID := getUserID(c) + + buyer, err := h.db.GetBuyerDetails(id, userID) + if err != nil || buyer == nil { + return RenderNotFound(c, "Buyer not found or you don't have access to it.") + } + return Render(c, BuyerEditPage(*buyer)) +} + +// BuyerCreateHandler handles POST /buyer/create +func (h *BuyerHandlers) BuyerCreateHandler(c echo.Context) error { + userID := getUserID(c) + if userID == "" { + return c.Redirect(http.StatusFound, "/") + } + + name := c.FormValue("name") + if name == "" { + return c.String(http.StatusBadRequest, "Name is required") + } + + details := c.FormValue("details") + + _, err := h.db.CreateBuyerDetails(userID, name, details) + if err != nil { + return c.String(http.StatusInternalServerError, "Failed to create buyer") + } + + return c.Redirect(http.StatusFound, "/buyer") +} + +// BuyerUpdateHandler handles POST /buyer/edit/:id +func (h *BuyerHandlers) BuyerUpdateHandler(c echo.Context) error { + userID := getUserID(c) + if userID == "" { + return c.Redirect(http.StatusFound, "/") + } + + id := c.Param("id") + name := c.FormValue("name") + if name == "" { + return c.String(http.StatusBadRequest, "Name is required") + } + + details := c.FormValue("details") + + err := h.db.UpdateBuyerDetails(id, userID, name, details) + if err != nil { + return c.String(http.StatusInternalServerError, "Failed to update buyer") + } + + return c.Redirect(http.StatusFound, "/buyer") +} + +// BuyerDeleteHandler handles DELETE /buyer/:id +func (h *BuyerHandlers) BuyerDeleteHandler(c echo.Context) error { + userID := getUserID(c) + if userID == "" { + return c.String(http.StatusUnauthorized, "Unauthorized") + } + + id := c.Param("id") + err := h.db.DeleteBuyerDetails(id, userID) + if err != nil { + return c.String(http.StatusInternalServerError, "Failed to delete buyer") + } + + return c.NoContent(http.StatusOK) +} diff --git a/cmd/web/efs.go b/internal/web/efs.go similarity index 100% rename from cmd/web/efs.go rename to internal/web/efs.go diff --git a/internal/web/home.go b/internal/web/home.go new file mode 100644 index 0000000..6553e65 --- /dev/null +++ b/internal/web/home.go @@ -0,0 +1,37 @@ +package web + +import ( + "billit/internal/database" + + "github.com/labstack/echo/v4" +) + +// HomeHandlers holds db reference for home page operations +type HomeHandlers struct { + db database.Service +} + +// NewHomeHandlers creates handlers with db access +func NewHomeHandlers(db database.Service) *HomeHandlers { + return &HomeHandlers{db: db} +} + +// HomePageHandler renders the home page with recent data +func (h *HomeHandlers) HomePageHandler(c echo.Context) error { + userID := getUserID(c) + userEmail, _ := c.Get("user_email").(string) + + // Get recent products (last 5) + recentProducts, err := h.db.GetRecentProducts(userID, 5) + if err != nil { + recentProducts = []database.Product{} + } + + // Get recent invoices (last 5) + recentInvoices, err := h.db.GetRecentInvoices(userID, 5) + if err != nil { + recentInvoices = []database.Invoice{} + } + + return Render(c, HomePage(userEmail, recentProducts, recentInvoices)) +} diff --git a/internal/web/invoices.go b/internal/web/invoices.go new file mode 100644 index 0000000..de93240 --- /dev/null +++ b/internal/web/invoices.go @@ -0,0 +1,32 @@ +package web + +import ( + "billit/internal/database" + "net/http" + + "github.com/labstack/echo/v4" +) + +// InvoicesHandlers holds db reference for invoice operations +type InvoicesHandlers struct { + db database.Service +} + +// NewInvoicesHandlers creates handlers with db access +func NewInvoicesHandlers(db database.Service) *InvoicesHandlers { + return &InvoicesHandlers{db: db} +} + +// InvoicesListHandler renders the /invoice page with all invoices +func (h *InvoicesHandlers) InvoicesListHandler(c echo.Context) error { + userID := getUserID(c) + if userID == "" { + return c.Redirect(http.StatusFound, "/") + } + + invoices, err := h.db.GetAllInvoices(userID) + if err != nil { + return RenderServerError(c, "Failed to load invoices. Please try again.") + } + return Render(c, InvoicesPage(invoices)) +} diff --git a/internal/web/product.go b/internal/web/product.go new file mode 100644 index 0000000..2c5045d --- /dev/null +++ b/internal/web/product.go @@ -0,0 +1,231 @@ +package web + +import ( + "billit/internal/database" + "net/http" + "strconv" + + "github.com/labstack/echo/v4" +) + +// ProductHandlers holds db reference for product operations +type ProductHandlers struct { + db database.Service +} + +// NewProductHandlers creates handlers with db access +func NewProductHandlers(db database.Service) *ProductHandlers { + return &ProductHandlers{db: db} +} + +// getUserID extracts user ID from context (set by auth middleware) +func getUserID(c echo.Context) string { + if uid, ok := c.Get("user_id").(string); ok { + return uid + } + return "" +} + +// ProductListHandler renders the /product page with all products +func (h *ProductHandlers) ProductListHandler(c echo.Context) error { + userID := getUserID(c) + products, err := h.db.GetAllProducts(userID) + if err != nil { + return RenderServerError(c, "Failed to load products. Please try again.") + } + return Render(c, ProductListPage(products)) +} + +// ProductCreatePageHandler renders the /product/create form page +func (h *ProductHandlers) ProductCreatePageHandler(c echo.Context) error { + return Render(c, ProductCreatePage()) +} + +// ProductEditPageHandler renders the /product/edit/:sku form page +func (h *ProductHandlers) ProductEditPageHandler(c echo.Context) error { + sku := c.Param("sku") + userID := getUserID(c) + + product, err := h.db.GetProductBySKU(sku, userID) + if err != nil || product == nil { + return RenderNotFound(c, "Product not found or you don't have access to it.") + } + return Render(c, ProductEditPage(*product)) +} + +// ProductCreateHandler handles POST /product/create +func (h *ProductHandlers) ProductCreateHandler(c echo.Context) error { + userID := getUserID(c) + if userID == "" { + return c.Redirect(http.StatusFound, "/") + } + + sku := c.FormValue("sku") + if sku == "" { + return c.String(http.StatusBadRequest, "SKU is required") + } + + name := c.FormValue("name") + if name == "" { + return c.String(http.StatusBadRequest, "Name is required") + } + + // Check if SKU already exists for this user + existing, _ := h.db.GetProductBySKU(sku, userID) + if existing != nil { + return c.String(http.StatusBadRequest, "A product with this SKU already exists") + } + + hsn := c.FormValue("hsn") + baseStr := c.FormValue("base_price") + wholesaleStr := c.FormValue("wholesale_price") + gstStr := c.FormValue("gst_rate") + smallQtyStr := c.FormValue("small_order_qty") + + base, _ := strconv.ParseFloat(baseStr, 64) + wholesale, _ := strconv.ParseFloat(wholesaleStr, 64) + if wholesale == 0 { + wholesale = base // default wholesale to base price + } + + gstRate := 0.18 // default 18% + switch gstStr { + case "0": + gstRate = 0.0 + case "5": + gstRate = 0.05 + case "12": + gstRate = 0.12 + case "18": + gstRate = 0.18 + case "28": + gstRate = 0.28 + } + + smallQty := 1 + if v, err := strconv.Atoi(smallQtyStr); err == nil && v > 0 { + smallQty = v + } + + smallFeeStr := c.FormValue("small_order_fee") + smallFee, _ := strconv.ParseFloat(smallFeeStr, 64) + + unit := c.FormValue("unit") + if unit == "" { + unit = "pcs" + } + + product := database.Product{ + SKU: sku, + Name: name, + HSNCode: hsn, + BasePrice: base, + WholesalePrice: wholesale, + GSTRate: gstRate, + SmallOrderQty: smallQty, + SmallOrderFee: smallFee, + Unit: unit, + } + + if err := h.db.CreateProduct(product, userID); err != nil { + return c.String(http.StatusInternalServerError, "failed to create product") + } + + // Redirect to product list + return c.Redirect(http.StatusSeeOther, "/product") +} + +// ProductUpdateHandler handles POST /product/edit/:sku +func (h *ProductHandlers) ProductUpdateHandler(c echo.Context) error { + userID := getUserID(c) + if userID == "" { + return c.Redirect(http.StatusFound, "/") + } + + sku := c.Param("sku") + + // Verify product belongs to user + existing, _ := h.db.GetProductBySKU(sku, userID) + if existing == nil { + return c.String(http.StatusNotFound, "Product not found") + } + + name := c.FormValue("name") + if name == "" { + return c.String(http.StatusBadRequest, "Name is required") + } + + hsn := c.FormValue("hsn") + baseStr := c.FormValue("base_price") + wholesaleStr := c.FormValue("wholesale_price") + gstStr := c.FormValue("gst_rate") + smallQtyStr := c.FormValue("small_order_qty") + + base, _ := strconv.ParseFloat(baseStr, 64) + wholesale, _ := strconv.ParseFloat(wholesaleStr, 64) + if wholesale == 0 { + wholesale = base + } + + gstRate := 0.18 + switch gstStr { + case "0": + gstRate = 0.0 + case "5": + gstRate = 0.05 + case "12": + gstRate = 0.12 + case "18": + gstRate = 0.18 + case "28": + gstRate = 0.28 + } + + smallQty := 1 + if v, err := strconv.Atoi(smallQtyStr); err == nil && v > 0 { + smallQty = v + } + + smallFeeStr := c.FormValue("small_order_fee") + smallFee, _ := strconv.ParseFloat(smallFeeStr, 64) + + unit := c.FormValue("unit") + if unit == "" { + unit = "pcs" + } + + product := database.Product{ + SKU: sku, + Name: name, + HSNCode: hsn, + BasePrice: base, + WholesalePrice: wholesale, + GSTRate: gstRate, + SmallOrderQty: smallQty, + SmallOrderFee: smallFee, + Unit: unit, + } + + if err := h.db.UpdateProduct(product, userID); err != nil { + return c.String(http.StatusInternalServerError, "failed to update product") + } + + return c.Redirect(http.StatusSeeOther, "/product") +} + +// ProductDeleteHandler handles DELETE /product/:sku +func (h *ProductHandlers) ProductDeleteHandler(c echo.Context) error { + userID := getUserID(c) + sku := c.Param("sku") + + if err := h.db.DeleteProduct(sku, userID); err != nil { + return c.String(http.StatusInternalServerError, "failed to delete product") + } + + // For HTMX, return empty to remove the row + if c.Request().Header.Get("HX-Request") == "true" { + return c.NoContent(http.StatusOK) + } + + return c.Redirect(http.StatusSeeOther, "/product") +} diff --git a/internal/web/render.go b/internal/web/render.go new file mode 100644 index 0000000..3bcb007 --- /dev/null +++ b/internal/web/render.go @@ -0,0 +1,12 @@ +package web + +import ( + "github.com/a-h/templ" + "github.com/labstack/echo/v4" +) + +// Render wraps templ component rendering for Echo +func Render(c echo.Context, component templ.Component) error { + c.Response().Header().Set(echo.HeaderContentType, echo.MIMETextHTML) + return component.Render(c.Request().Context(), c.Response().Writer) +} diff --git a/tailwind.config.js b/tailwind.config.js deleted file mode 100644 index fc44685..0000000 --- a/tailwind.config.js +++ /dev/null @@ -1,5 +0,0 @@ -module.exports = { - content: ["./**/*.html", "./**/*.templ", "./**/*.go",], - theme: { extend: {}, }, - plugins: [], -}