From 28733e22d3a6469dc82d838776dd96c5bf3489ca Mon Sep 17 00:00:00 2001 From: Arkaprabha Chakraborty Date: Sat, 6 Dec 2025 03:05:44 +0530 Subject: [PATCH] quite a lot of things --- .env.example | 15 + .gitignore | 3 + Dockerfile | 16 +- Makefile | 122 +-- cmd/web/base.templ | 19 - cmd/web/hello.go | 21 - cmd/web/hello.templ | 17 - cmd/web/styles/input.css | 1 - compose.build.yml | 11 + compose.deploy.yml | 18 + db/dev.db | Bin 0 -> 57344 bytes docker-compose.yml | 17 - go.mod | 5 +- go.sum | 6 + internal/api/handlers.go | 28 + internal/auth/auth.go | 211 ++++ internal/auth/store.go | 69 ++ internal/database/database.go | 466 ++++++++- internal/gst/calculator.go | 158 +++ internal/gst/calculator_test.go | 122 +++ internal/server/routes.go | 117 ++- internal/server/routes_test.go | 47 +- internal/web/account.go | 107 ++ internal/web/assets/css/output.css | 1 + internal/web/assets/css/output.css.map | 1 + internal/web/assets/js/dialog.js | 249 +++++ {cmd => internal}/web/assets/js/htmx.min.js | 0 internal/web/assets/scss/_base.scss | 116 +++ internal/web/assets/scss/_components.scss | 1044 +++++++++++++++++++ internal/web/assets/scss/_print.scss | 93 ++ internal/web/assets/scss/_utilities.scss | 197 ++++ internal/web/assets/scss/_variables.scss | 116 +++ internal/web/assets/scss/main.scss | 10 + internal/web/auth.go | 138 +++ internal/web/billing.go | 434 ++++++++ internal/web/buyer.go | 106 ++ {cmd => internal}/web/efs.go | 0 internal/web/home.go | 37 + internal/web/invoices.go | 32 + internal/web/product.go | 231 ++++ internal/web/render.go | 12 + tailwind.config.js | 5 - 42 files changed, 4214 insertions(+), 204 deletions(-) create mode 100644 .env.example delete mode 100644 cmd/web/base.templ delete mode 100644 cmd/web/hello.go delete mode 100644 cmd/web/hello.templ delete mode 100644 cmd/web/styles/input.css create mode 100644 compose.build.yml create mode 100644 compose.deploy.yml create mode 100644 db/dev.db delete mode 100644 docker-compose.yml create mode 100644 internal/api/handlers.go create mode 100644 internal/auth/auth.go create mode 100644 internal/auth/store.go create mode 100644 internal/gst/calculator.go create mode 100644 internal/gst/calculator_test.go create mode 100644 internal/web/account.go create mode 100644 internal/web/assets/css/output.css create mode 100644 internal/web/assets/css/output.css.map create mode 100644 internal/web/assets/js/dialog.js rename {cmd => internal}/web/assets/js/htmx.min.js (100%) create mode 100644 internal/web/assets/scss/_base.scss create mode 100644 internal/web/assets/scss/_components.scss create mode 100644 internal/web/assets/scss/_print.scss create mode 100644 internal/web/assets/scss/_utilities.scss create mode 100644 internal/web/assets/scss/_variables.scss create mode 100644 internal/web/assets/scss/main.scss create mode 100644 internal/web/auth.go create mode 100644 internal/web/billing.go create mode 100644 internal/web/buyer.go rename {cmd => internal}/web/efs.go (100%) create mode 100644 internal/web/home.go create mode 100644 internal/web/invoices.go create mode 100644 internal/web/product.go create mode 100644 internal/web/render.go delete mode 100644 tailwind.config.js 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 0000000000000000000000000000000000000000..7570bd5fb6ea2575171fffec5ec86f329056f57f GIT binary patch literal 57344 zcmeI*Pi))P9S3kyPOM0^qYiB*6oCPP$Y2|@V*b(pqF7y7NtDP+Y)P(zw$Lb%s#!~v zD^hX1AP2WY4%=m?0lf{_dDwmD0qwP?_S{=9*>%VL9w{oKEyp(Er4H~F9Gma)@jdeM z_@hnok*ThiO~(-0y?$471eJT6<9Y5wLEt!=lzcXhg&*fPOEQdj-fZ`&%jmMmRYT>tUaK%7jrYy@wIuLb1m&JJM?#CLmN0fH+=|J4s7mhIXWHxZJako z-N3fPZWvwa1$XkTBkp4*7`U5Ua2Htost^vx<8l6v)QiOI&T_w939!0ZFXwdY^K9ys z(t5op1Wb1|+urN-hkX&4)#`P3HEZABle{{7zWI5#OPZRs^U?yQ^{8jIjK*%?Xq%4@ z)lsT!de!))TD^hg7=79|YsK5ejZtSwZewTe&c(=d{Db&$-Ra^r6!%AT@yf|XWw&T# zYkE4qJjdIko*u5f7i)eZe5AYmIc`X@4)-%g!L@byP|dsjo<3+fUd?`D`bf?GoKekw z#;E3lt*LN)ZjS$Y%H5y*mi?cX0(+I8`|=%Yd(hRa21S_8_E0)D9O_)JXLZfdycvOg zi%E_a3CuZjCof-`ju+;R>tugh8nQoi`Rg0JYw_&5YvEh@KSKcvKi9LccLozS=9uHs zwrw?9J>5HG4Wq?okwEbv&@s3ijKsa|UdOOCqI^-pUFvh&Y4qth7F^~(6uQICoXXj{L#`(Jr}%K=xaP z9gCMkFY8yoUmCJMHATN*+4(#-OaJ)TUpe~12Lcd)00bZa0SG_<0uX=z1Rwx`*DA2h z&%BqL3x&db=p9wP^Kn^8NmaT;ZP7&!Tc|pQ(GhO-_vwmbGAk*mlq_-Iaew>Wx6u!F z|NcWX%KwEfLCBeOGMCP%StXgv+%FrT5f`cL`y_B>{H-KNR@~H}fNX!v_KofB*y_009U< z;9?0pTL^{gi9cUgRXy8G>*=hPXs6UvBAM2ugw{&86X{m6m24}mw4_UwYH@khXeHQP zpJlrF^F%D0mQiwyt}W(2c_KdW#!HL&Ct~$pUCfK#ABp0USfOVJC_VX(6V1O%-hx4293@Y^Aa0~D^}Sk-1}@0?z*!t z=4C4JBI}kx<&vzbI*qZV#^|P&_;g8JA83|iy5+rD9V2n zR6lF-P`Wghrlx8=N9*|I6*c|zGg@TObhE{LnyrmH&8ijispK%pY8^|mN@*LH#6r({ zWLTzQwTu^=6U?MF7V{Z}Z2Fp|4;#uzw5o-H?ew}vzqY^2to!amDkNR9OKjjjbq|wl zYaQEQEx8XMta?wfP;r|Ub+%3G^H2MqT4znL{$x+aoT)m0q2cbs+U!||yK7iGXR7J= zRTiI4IEe|~*Gwg;U!MJjXQ6UURkNC;HxPd~s<8eX}ia41R=ckC1WHz5v+5P`e?4Mlh zNBV{j1Rwwb2tWV=5P$##AOHafKmY<4L11C3&Y$MmZ)kq19y!gGFK_(+&s^;1i?DfE z2M9m_0uX=z1Rwwb2tWV=5P$##UP<8BQ)*8Rk|MztMAN&55>c_$mfB*y_009U< z00Izz00bZa0SLT4f%uflPdLvH9drKgz5o9j7rXZQRuL^h00Izz00bZa0SG_<0uX=z z1TKWY!8RXN7Y{D)?Y+aR6Hcbte^dJ5p3&6$J2y@1hN9h&l^cqg(Z0A>OK<+6znQZ4 z+v`1Zvp8rg<>iNaoz8w{*IeGIuib6e?)D0|l6M~7y}j4HKYB06Z1nV#r!P5HE4|_( z6nsxcQq4;W#{XZ4Jq{}Y0SG_<0uX=z1Rwwb2tWV=5I7%!WA6WZ=l{>S*z@yg2DLx{ z0uX=z1Rwwb2tWV=5P$##An;2I{Ay}35*)mM^Z#GEPtZODAOHafKmY;|fB*y_009U< z;JgKnp8tE#|9`{9zB%u{IsfGzvsVxAeB+o zTqY-HB}tm_MgsOqfSzTX@MeHR&;O_MvWn;bPwWX4fB*y_009U<00Izz00bZa0SLS~ z0%Onrk6%l``Tv{Kg;-MvKmY;|fB*y_009U<00Izzz=;B5=l^O}NzzyXDMMok{s*xn BA7lUk literal 0 HcmV?d00001 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: +

+
    +
  • + 1. FREE OF CHARGE & CPA EXEMPTION: This software is provided strictly "Free of Charge" and without any monetary consideration. It therefore does not constitute a "Service" under the Indian Consumer Protection Act, 2019. +
  • +
  • + 2. "AS IS" & NO WARRANTY: The software is provided "AS IS". The developer provides NO WARRANTY, express or implied, regarding its performance, accuracy, security, or suitability for any purpose. +
  • +
  • + 3. USER ASSUMPTION OF RISK: The developer is not liable for any financial losses, data corruption, calculation errors, or legal issues resulting from the use or misuse of this application. Users assume all associated risks and agree to indemnify and hold harmless the developer. +
  • +
+

+ 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: [], -}