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() {
-
-
-
-
+
+ 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, "
+
+
+ `, 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 := `
-- Select Product -- `
+ for _, p := range products {
+ productOptions += fmt.Sprintf(`
%s (₹%.2f) `, p.SKU, p.Name, p.BasePrice)
+ }
+
+ rowHTML := fmt.Sprintf(`
+
+
+
+ Product
+ %s
+
+
+ Qty
+
+
+
+ ×
+
+
+
`, 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: [],
-}