quite a lot of things

This commit is contained in:
Arkaprabha Chakraborty
2025-12-06 03:05:44 +05:30
parent 39c61b7790
commit 28733e22d3
42 changed files with 4214 additions and 204 deletions

107
internal/web/account.go Normal file
View File

@@ -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", ""))
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -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 = `
<div class="dialog-box">
<div class="dialog-header">
<h3 class="dialog-title"></h3>
</div>
<div class="dialog-body">
<p class="dialog-message"></p>
</div>
<div class="dialog-footer">
<button type="button" class="btn btn-outline dialog-cancel">Cancel</button>
<button type="button" class="btn btn-danger dialog-confirm">Confirm</button>
</div>
</div>
`;
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 = '<p class="dialog-message">' + escapeHtml(message) + '</p>';
}
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 = `
<div class="disclaimer-content">
<p style="font-weight: bold; margin-bottom: 15px;">
Please read these terms carefully before using this software. By proceeding, you agree to the conditions below:
</p>
<ul style="padding-left: 20px; line-height: 1.8; margin: 0;">
<li>
<strong>1. FREE OF CHARGE & CPA EXEMPTION:</strong> This software is provided strictly <strong>"Free of Charge"</strong> and without any monetary consideration. It therefore does not constitute a "Service" under the Indian Consumer Protection Act, 2019.
</li>
<li style="margin-top: 10px;">
<strong>2. "AS IS" & NO WARRANTY:</strong> The software is provided <strong>"AS IS"</strong>. The developer provides <strong>NO WARRANTY</strong>, express or implied, regarding its performance, accuracy, security, or suitability for any purpose.
</li>
<li style="margin-top: 10px;">
<strong>3. USER ASSUMPTION OF RISK:</strong> 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.
</li>
</ul>
<p style="font-size: 0.9em; font-style: italic; color: #666; margin-top: 15px; margin-bottom: 0;">
<small>Consult a qualified legal or financial advisor before relying on any data generated by this tool.</small>
</p>
</div>
`;
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();
}
})();

3521
internal/web/assets/js/htmx.min.js vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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;
}
}

View File

@@ -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; }

View File

@@ -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;

View File

@@ -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';

138
internal/web/auth.go Normal file
View File

@@ -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)
}
}

434
internal/web/billing.go Normal file
View File

@@ -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, "<!doctype html><html><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'><title>Invoice ")
fmt.Fprintf(w, "%s</title>", invoiceID[:8])
fmt.Fprint(w, "<link href='/assets/css/output.css' rel='stylesheet'>")
fmt.Fprint(w, `<style type="text/css">
@media print {
.no-print { display: none !important; }
.page-break { page-break-before: always; }
@page { margin: 1cm; size: A4; }
body { background: white !important; }
.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; }
}
.invoice-details-block { white-space: pre-wrap; font-size: 0.875rem; line-height: 1.4; }
.invoice-footer-section { margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid #e0e0e0; }
</style>`)
fmt.Fprint(w, "</head><body>")
fmt.Fprintf(w, `<div class="container page">
<div class="no-print" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1.5rem;padding-bottom:1rem;border-bottom:1px solid #e0e0e0;">
<a href="/invoice" class="text-accent">&larr; Back</a>
<button onclick="window.print()" class="btn btn-primary">Print Invoice</button>
</div>
<div class="invoice">
<div class="invoice-header">
<div>
<h1 class="invoice-title">Tax Invoice</h1>
</div>
<div class="invoice-meta">
<p>Invoice ID: %s</p>
</div>
<div class="invoice-meta">
<p>Date: %s</p>
</div>
</div>`, inv.HumanReadableID, strings.ReplaceAll(inv.CreatedAt, "T", " ")[0:10])
// Display company details above the invoice table
if invoice.CompanyDetails != "" {
fmt.Fprintf(w, `<div class="invoice-details-block" style="margin-bottom:1rem;"><strong>From:</strong><br>%s</div>`, 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, `<div class="invoice-footer-section" style="display:flex;justify-content:space-between;gap:2rem;">`)
// Left side: Billed To (50%)
fmt.Fprint(w, `<div style="flex:1;"><strong>Billed To:</strong><br>`)
if invoice.BuyerName != "" {
fmt.Fprintf(w, `<span class="invoice-details-block">%s</span>`, invoice.BuyerName)
}
if invoice.BuyerDetails != "" {
fmt.Fprintf(w, `<br><span class="invoice-details-block">%s</span>`, invoice.BuyerDetails)
}
fmt.Fprint(w, `</div>`)
// Right side: Total Amount (50%)
fmt.Fprintf(w, `<div style="flex:1;">
<p style="margin: 0.5rem 0;"><strong>Total Amount (before GST):</strong><br>%s</p>
<p style="margin: 0.5rem 0;"><strong>GST Amount:</strong><br>%s</p>
</div>`, numberToWords(invoice.SubTotal), numberToWords(totalGST))
} else {
fmt.Fprint(w, `<div class="invoice-footer-section">`)
// Total Amount takes 100%
fmt.Fprintf(w, `<div>
<p style="margin: 0.5rem 0;"><strong>Total Amount (before GST):</strong><br>%s</p>
<p style="margin: 0.5rem 0;"><strong>GST Amount:</strong><br>%s</p>
</div>`, numberToWords(invoice.SubTotal), numberToWords(totalGST))
}
fmt.Fprint(w, `</div>`)
// Bank details (left) and QR code (right) in the same section
fmt.Fprint(w, `<div class="invoice-footer-section" style="display:flex;justify-content:space-between;align-items:flex-start;">`)
if invoice.BankDetails != "" {
fmt.Fprintf(w, `<div style="flex:1;"><strong>Bank Details:</strong><br><span class="invoice-details-block">%s</span></div>`, invoice.BankDetails)
} else {
fmt.Fprint(w, `<div style="flex:1;"></div>`)
}
fmt.Fprintf(w, `<div style="margin-left:1rem;"><img src="data:image/png;base64,%s" alt="QR Code" style="width:80px;height:80px;"></div>`, qrBase64)
fmt.Fprint(w, `</div>`)
fmt.Fprint(w, "</div>")
fmt.Fprint(w, "</div></body></html>")
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 := `<option value="">-- Select Product --</option>`
for _, p := range products {
productOptions += fmt.Sprintf(`<option value="%s">%s (₹%.2f)</option>`, p.SKU, p.Name, p.BasePrice)
}
rowHTML := fmt.Sprintf(`
<div class="product-row">
<div class="product-row-grid">
<div class="form-group" style="margin:0;">
<label class="form-label">Product</label>
<select name="product_sku_%d" class="form-select">%s</select>
</div>
<div class="form-group" style="margin:0;">
<label class="form-label">Qty</label>
<input type="number" name="qty_%d" value="1" min="0" class="form-input">
</div>
<div style="padding-top:20px;">
<button type="button" onclick="this.closest('.product-row').remove()" class="btn btn-danger btn-sm">×</button>
</div>
</div>
</div>`, index, productOptions, index)
return c.HTML(http.StatusOK, rowHTML)
}

106
internal/web/buyer.go Normal file
View File

@@ -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)
}

6
internal/web/efs.go Normal file
View File

@@ -0,0 +1,6 @@
package web
import "embed"
//go:embed "assets"
var Files embed.FS

37
internal/web/home.go Normal file
View File

@@ -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))
}

32
internal/web/invoices.go Normal file
View File

@@ -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))
}

231
internal/web/product.go Normal file
View File

@@ -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")
}

12
internal/web/render.go Normal file
View File

@@ -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)
}