Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
272 changes: 102 additions & 170 deletions cmd/web/handlers:equipment.go

Large diffs are not rendered by default.

30 changes: 30 additions & 0 deletions cmd/web/handlers:equipment:categories.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,18 @@ import (
"github.com/bit8bytes/gearberg/internal/database"
"github.com/bit8bytes/gearberg/internal/equipment/categories"
"github.com/bit8bytes/gearberg/internal/httperr"
"github.com/bit8bytes/gearberg/internal/templates/fragments"
"github.com/bit8bytes/gearberg/internal/templates/pages"
"github.com/bit8bytes/gearberg/pkg/htmx"
"github.com/segmentio/ksuid"
)

type equipmentCategoriesData struct {
OrgID string
Categories []categories.EquipmentCategory
MaxCategories int
SelectedID string
SelectedName string
}

type equipmentCategoryData struct {
Expand Down Expand Up @@ -205,3 +209,29 @@ func (app *application) postDeleteEquipmentCategory(w http.ResponseWriter, r *ht
http.Redirect(w, r, "/orgs/"+url.PathEscape(orgID)+"/settings/equipment-categories", http.StatusSeeOther)
return nil
}

func (app *application) getEquipmentCategoriesFragment(w http.ResponseWriter, r *http.Request) *httperr.Error {
ctx := r.Context()
orgID := r.PathValue("org_id")

if !htmx.IsRequest(r) {
http.Redirect(w, r, "/orgs/"+url.PathEscape(orgID)+"/settings/equipment-categories", http.StatusSeeOther)
return nil
}

selected := r.URL.Query().Get("selected")
selectedName := r.URL.Query().Get("selected_name")

cats, err := app.services.equipmentcategories.GetByOrgID(ctx, orgID)
if err != nil {
return &httperr.Error{
Error: fmt.Errorf("getEquipmentCategoriesFragment: %w", err),
Message: "Failed to retrieve categories.",
Code: http.StatusInternalServerError,
}
}

tmplData := app.html.TemplateData(r)
tmplData.Data = equipmentCategoriesData{OrgID: orgID, Categories: cats, SelectedID: selected, SelectedName: selectedName}
return app.html.RenderFragment(w, r, http.StatusOK, fragments.EquipmentCategories, tmplData)
}
35 changes: 10 additions & 25 deletions cmd/web/handlers:equipment:export.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package main

import (
"encoding/csv"
"fmt"
"net/http"
"strconv"

Expand Down Expand Up @@ -47,34 +46,20 @@ func (app *application) getEquipmentExport(w http.ResponseWriter, r *http.Reques
item.CategoryName,
mfrByID[item.ManufacturerID],
item.LocationName,
exportPrice(item.RentalPrice),
exportPrice(item.PurchasePrice),
item.Pricing.RentalPrice.ToDecimal(),
item.Pricing.PurchasePrice.ToDecimal(),
item.Notes,
exportOptInt(item.WeightG),
exportOptInt(item.WidthMM),
exportOptInt(item.HeightMM),
exportOptInt(item.DepthMM),
exportOptInt(item.VoltageV),
exportOptInt(item.CurrentMA),
exportOptInt(item.PowerMW),
exportOptInt(item.WireGaugeMM2X100),
item.Properties.Weight.ToKG(),
item.Properties.Width.ToCM(),
item.Properties.Height.ToCM(),
item.Properties.Depth.ToCM(),
item.Properties.Voltage.ToV(),
item.Properties.Current.ToA(),
item.Properties.Power.ToW(),
item.Properties.WireGauge.String(),
strconv.FormatInt(item.TotalStock, 10),
})
}
cw.Flush()
return nil
}

func exportPrice(v *int64) string {
if v == nil {
return ""
}
return fmt.Sprintf("%.2f", float64(*v)/100)
}

func exportOptInt(v *int64) string {
if v == nil {
return ""
}
return strconv.FormatInt(*v, 10)
}
12 changes: 6 additions & 6 deletions cmd/web/handlers:equipment:import.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ func (app *application) postEquipmentImport(w http.ResponseWriter, r *http.Reque

rawRows, parseErr := parseImportCSV(f)
if parseErr != "" {
return reRender("Failed to parse CSV.")
return reRender(parseErr)
}

importID, err := app.services.equipmentImports.Stage(ctx, orgID, rawRows)
Expand Down Expand Up @@ -125,9 +125,9 @@ func (app *application) postEquipmentImportConfirm(w http.ResponseWriter, r *htt
return nil
}

const importTemplateCSV = "Name,Type,Usage,Category,Manufacturer,Location,Rental Price,Resale Price,Notes,Weight (g),Width (mm),Height (mm),Depth (mm),Voltage (V),Current (mA),Power (mW),Wire Gauge (mm² ×100),Quantity\n" +
"Shure SM58,Bulk,Rental,Audio,Shure,Main Warehouse,15.00,99.00,Cardioid dynamic vocal microphone,298,47,47,162,,,,,7\n" +
"Sony SRS-XB43,Serialized,Rental,Audio,Sony,Main Warehouse,25.00,180.00,Portable Bluetooth speaker with extra bass,900,220,220,95,5,2400,12000,,4\n"
const importTemplateCSV = "Name,Type,Usage,Category,Manufacturer,Location,Rental Price,Resale Price,Notes,Weight (kg),Width (cm),Height (cm),Depth (cm),Voltage (V),Current (A),Power (W),Wire Gauge (mm² ×100),Quantity\n" +
"Shure SM58,Bulk,Rental,Audio,Shure,Main Warehouse,15.00,99.00,Cardioid dynamic vocal microphone,0.298,4.7,4.7,16.2,,,,,7\n" +
"Sony SRS-XB43,Serialized,Rental,Audio,Sony,Main Warehouse,25.00,180.00,Portable Bluetooth speaker with extra bass,0.9,22,22,9.5,5,2.4,12,,4\n"

// getEquipmentImportTemplate serves a ready-to-fill CSV template for download.
func (app *application) getEquipmentImportTemplate(w http.ResponseWriter, _ *http.Request) *httperr.Error {
Expand Down Expand Up @@ -199,8 +199,8 @@ func readImportRows(cr *csv.Reader) ([]imports.RawRow, string) {
HeightMm: strings.TrimSpace(record[11]),
DepthMm: strings.TrimSpace(record[12]),
VoltageV: strings.TrimSpace(record[13]),
CurrentMa: strings.TrimSpace(record[14]),
PowerMw: strings.TrimSpace(record[15]),
CurrentA: strings.TrimSpace(record[14]),
PowerW: strings.TrimSpace(record[15]),
WireGaugeMM2X100: strings.TrimSpace(record[16]),
Quantity: strings.TrimSpace(record[17]),
})
Expand Down
30 changes: 30 additions & 0 deletions cmd/web/handlers:equipment:locations.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,18 @@ import (
"github.com/bit8bytes/gearberg/internal/database"
"github.com/bit8bytes/gearberg/internal/equipment/locations"
"github.com/bit8bytes/gearberg/internal/httperr"
"github.com/bit8bytes/gearberg/internal/templates/fragments"
"github.com/bit8bytes/gearberg/internal/templates/pages"
"github.com/bit8bytes/gearberg/pkg/htmx"
"github.com/segmentio/ksuid"
)

type locationsData struct {
OrgID string
Locations []locations.Location
MaxLocations int
SelectedID string
SelectedName string
}

type locationData struct {
Expand Down Expand Up @@ -192,3 +196,29 @@ func (app *application) postDeleteLocation(w http.ResponseWriter, r *http.Reques
http.Redirect(w, r, dest, http.StatusSeeOther) //nolint:gosec // dest is a hard-coded path with org_id from path value.
return nil
}

func (app *application) getEquipmentLocationsFragment(w http.ResponseWriter, r *http.Request) *httperr.Error {
ctx := r.Context()
orgID := r.PathValue("org_id")

if !htmx.IsRequest(r) {
http.Redirect(w, r, "/orgs/"+url.PathEscape(orgID)+"/settings/locations", http.StatusSeeOther)
return nil
}

selected := r.URL.Query().Get("selected")
selectedName := r.URL.Query().Get("selected_name")

locs, err := app.services.locations.GetByOrgID(ctx, orgID)
if err != nil {
return &httperr.Error{
Error: fmt.Errorf("getEquipmentLocationsFragment: %w", err),
Message: "Failed to retrieve locations.",
Code: http.StatusInternalServerError,
}
}

tmplData := app.html.TemplateData(r)
tmplData.Data = locationsData{OrgID: orgID, Locations: locs, SelectedID: selected, SelectedName: selectedName}
return app.html.RenderFragment(w, r, http.StatusOK, fragments.WarehouseLocations, tmplData)
}
30 changes: 30 additions & 0 deletions cmd/web/handlers:equipment:manufacturers.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,18 @@ import (
"github.com/bit8bytes/gearberg/internal/database"
"github.com/bit8bytes/gearberg/internal/equipment/manufacturers"
"github.com/bit8bytes/gearberg/internal/httperr"
"github.com/bit8bytes/gearberg/internal/templates/fragments"
"github.com/bit8bytes/gearberg/internal/templates/pages"
"github.com/bit8bytes/gearberg/pkg/htmx"
"github.com/segmentio/ksuid"
)

type manufacturersData struct {
OrgID string
Manufacturers []manufacturers.Manufacturer
MaxManufacturers int
SelectedID string
SelectedName string
}

type manufacturerData struct {
Expand Down Expand Up @@ -207,3 +211,29 @@ func (app *application) postDeleteManufacturer(w http.ResponseWriter, r *http.Re
http.Redirect(w, r, dest, http.StatusSeeOther) //nolint:gosec
return nil
}

func (app *application) getEquipmentManufacturersFragment(w http.ResponseWriter, r *http.Request) *httperr.Error {
ctx := r.Context()
orgID := r.PathValue("org_id")

if !htmx.IsRequest(r) {
http.Redirect(w, r, "/orgs/"+url.PathEscape(orgID)+"/settings/manufacturers", http.StatusSeeOther)
return nil
}

selected := r.URL.Query().Get("selected")
selectedName := r.URL.Query().Get("selected_name")

mfrs, err := app.services.manufacturers.GetByOrgID(ctx, orgID)
if err != nil {
return &httperr.Error{
Error: fmt.Errorf("getEquipmentManufacturersFragment: %w", err),
Message: "Failed to retrieve manufacturers.",
Code: http.StatusInternalServerError,
}
}

tmplData := app.html.TemplateData(r)
tmplData.Data = manufacturersData{OrgID: orgID, Manufacturers: mfrs, SelectedID: selected, SelectedName: selectedName}
return app.html.RenderFragment(w, r, http.StatusOK, fragments.EquipmentManufacturers, tmplData)
}
16 changes: 16 additions & 0 deletions cmd/web/handlers:orgs.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"github.com/bit8bytes/gearberg/internal/database"
"github.com/bit8bytes/gearberg/internal/httperr"
"github.com/bit8bytes/gearberg/internal/orgs"
"github.com/bit8bytes/gearberg/internal/orgs/settings"
"github.com/bit8bytes/gearberg/internal/storage"
"github.com/bit8bytes/gearberg/internal/templates/pages"
"github.com/segmentio/ksuid"
Expand Down Expand Up @@ -84,6 +85,21 @@ func (app *application) postOrgsNew(w http.ResponseWriter, r *http.Request) *htt
}
}

_, err = app.services.orgsettings.Upsert(ctx, settings.UpsertOrgSettings{
ID: ksuid.New().String(),
OrgID: org.ID,
Currency: app.options.DefaultCurrency,
VatRate: app.options.DefaultVatRateBasisPoints(),
Timezone: app.options.DefaultTimezone,
})
if err != nil {
return &httperr.Error{
Error: fmt.Errorf("postOrgsNew: seed default settings: %w", err),
Message: "Failed to initialise org settings.",
Code: http.StatusInternalServerError,
}
}

http.Redirect(w, r, fmt.Sprintf("/orgs/%s/equipment", org.ID), http.StatusSeeOther)
return nil
}
Expand Down
36 changes: 36 additions & 0 deletions cmd/web/handlers:orgs:settings.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
package main

import (
"fmt"
"net/http"
"net/url"

"github.com/bit8bytes/gearberg/internal/httperr"
"github.com/bit8bytes/gearberg/internal/orgs/settings"
"github.com/bit8bytes/gearberg/internal/templates/fragments"
"github.com/bit8bytes/gearberg/internal/templates/pages"
"github.com/bit8bytes/gearberg/pkg/htmx"
"github.com/segmentio/ksuid"
)

Expand All @@ -14,6 +18,38 @@ type orgSettingsData struct {
OrgID string
}

type orgCurrencyData struct {
Currency string
}

func (app *application) getOrgCurrencyFragment(w http.ResponseWriter, r *http.Request) *httperr.Error {
ctx := r.Context()
orgID := r.PathValue("org_id")

if !htmx.IsRequest(r) {
http.Redirect(w, r, "/orgs/"+url.PathEscape(orgID)+"/settings", http.StatusSeeOther)
return nil
}

s, err := app.services.orgsettings.GetByOrgID(ctx, orgID)
if err != nil {
return &httperr.Error{
Error: fmt.Errorf("getOrgCurrencyFragment: %w", err),
Message: "Failed to retrieve org settings.",
Code: http.StatusInternalServerError,
}
}

currency := ""
if s != nil {
currency = s.Currency
}

tmplData := app.html.TemplateData(r)
tmplData.Data = orgCurrencyData{Currency: currency}
return app.html.RenderFragment(w, r, http.StatusOK, fragments.OrgCurrency, tmplData)
}

func (app *application) getOrgSettings(w http.ResponseWriter, r *http.Request) *httperr.Error {
ctx := r.Context()
id := r.PathValue("org_id")
Expand Down
4 changes: 2 additions & 2 deletions cmd/web/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,12 +85,12 @@ func runServe(args []string) error {

log := setupLogger(options.LogLevel.Level())

cache, err := parseTemplates()
base, cache, err := parseTemplates()
if err != nil {
return fmt.Errorf("load templates: %w", err)
}

html := htmlpkg.New(log, cache, revision)
html := htmlpkg.New(log, base, cache, revision)

db, err := setupDatabase(ctx, options)
if err != nil {
Expand Down
2 changes: 1 addition & 1 deletion cmd/web/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ import (

"github.com/bit8bytes/gearberg/internal/nonce"
"github.com/bit8bytes/gearberg/internal/storage"
"github.com/bit8bytes/gearberg/internal/tokens"
"github.com/bit8bytes/gearberg/internal/trace"
"github.com/bit8bytes/gearberg/pkg/tokens"
)

func withTrace(next http.Handler) http.Handler {
Expand Down
23 changes: 23 additions & 0 deletions cmd/web/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@ import (
"flag"
"fmt"
"log/slog"
"math"
"os"
"slices"

"github.com/bit8bytes/gearberg/internal/orgs/settings"
)

type options struct {
Expand All @@ -20,6 +23,9 @@ type options struct {
MaxOrgManufacturers int
MaxOrgLocations int
MaxStorageBytes int64
DefaultCurrency string
DefaultVatRate float64 // percentage e.g. 19.0
DefaultTimezone string
}

func registerCommonFlags(fs *flag.FlagSet, cfg *options) {
Expand All @@ -41,6 +47,9 @@ func parseServeOptions(args []string) (*options, error) {
fs.IntVar(&cfg.MaxOrgLocations, "max-locations", 100, "maximum number of locations per org")
fs.StringVar(&cfg.StorageDSN, "storage-dsn", envOr("STORAGE_DSN", "./var/data"), "storage backend DSN")
fs.Int64Var(&cfg.MaxStorageBytes, "max-storage-bytes", 1<<30, "maximum storage bytes per org (default 1 GiB)")
fs.StringVar(&cfg.DefaultCurrency, "default-currency", "EUR", "default currency for new org settings (ISO-4217)")
fs.Float64Var(&cfg.DefaultVatRate, "default-vat-rate", 19.0, "default VAT rate for new org settings (percentage)")
fs.StringVar(&cfg.DefaultTimezone, "default-timezone", "Europe/Berlin", "default timezone for new org settings (IANA)")

if err := fs.Parse(args); err != nil {
return nil, fmt.Errorf("parseServeOptions: %w", err)
Expand Down Expand Up @@ -82,9 +91,23 @@ func (cfg *options) validate() error {
if cfg.MaxOrgLocations <= 0 {
return fmt.Errorf("max locations must be greater than 0")
}
if !slices.Contains(settings.PermittedCurrencies, cfg.DefaultCurrency) {
return fmt.Errorf("default-currency %q is not a permitted ISO-4217 code", cfg.DefaultCurrency)
}
if cfg.DefaultVatRate < 0 || cfg.DefaultVatRate > 100 {
return fmt.Errorf("default-vat-rate must be between 0 and 100")
}
if !slices.Contains(settings.PermittedTimezones, cfg.DefaultTimezone) {
return fmt.Errorf("default-timezone %q is not a permitted IANA timezone", cfg.DefaultTimezone)
}
return nil
}

// DefaultVatRateBasisPoints converts DefaultVatRate from a percentage to basis points (e.g. 19.0 → 1900).
func (cfg *options) DefaultVatRateBasisPoints() int64 {
return int64(math.Round(cfg.DefaultVatRate * 100))
}

func parseVerifyOptions(args []string) (*options, error) {
cfg := &options{LogLevel: logLevel{level: slog.LevelError}}
fs := flag.NewFlagSet("verify", flag.ContinueOnError)
Expand Down
Loading