quite a lot of things
This commit is contained in:
107
internal/web/account.go
Normal file
107
internal/web/account.go
Normal 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", ""))
|
||||
}
|
||||
1
internal/web/assets/css/output.css
Normal file
1
internal/web/assets/css/output.css
Normal file
File diff suppressed because one or more lines are too long
1
internal/web/assets/css/output.css.map
Normal file
1
internal/web/assets/css/output.css.map
Normal file
File diff suppressed because one or more lines are too long
249
internal/web/assets/js/dialog.js
Normal file
249
internal/web/assets/js/dialog.js
Normal 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
3521
internal/web/assets/js/htmx.min.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
116
internal/web/assets/scss/_base.scss
Normal file
116
internal/web/assets/scss/_base.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
1044
internal/web/assets/scss/_components.scss
Normal file
1044
internal/web/assets/scss/_components.scss
Normal file
File diff suppressed because it is too large
Load Diff
93
internal/web/assets/scss/_print.scss
Normal file
93
internal/web/assets/scss/_print.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
197
internal/web/assets/scss/_utilities.scss
Normal file
197
internal/web/assets/scss/_utilities.scss
Normal 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; }
|
||||
116
internal/web/assets/scss/_variables.scss
Normal file
116
internal/web/assets/scss/_variables.scss
Normal 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;
|
||||
10
internal/web/assets/scss/main.scss
Normal file
10
internal/web/assets/scss/main.scss
Normal 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
138
internal/web/auth.go
Normal 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
434
internal/web/billing.go
Normal 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">← 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
106
internal/web/buyer.go
Normal 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
6
internal/web/efs.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package web
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed "assets"
|
||||
var Files embed.FS
|
||||
37
internal/web/home.go
Normal file
37
internal/web/home.go
Normal 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
32
internal/web/invoices.go
Normal 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
231
internal/web/product.go
Normal 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
12
internal/web/render.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user