Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
fbdefac
refactor: prepare dashboard page. Settings had to move because of pat…
TobiasGleiter Aug 24, 2026
b25f143
style: added widgets for overdue and soon inspections #35
TobiasGleiter Aug 24, 2026
a45d3a3
style: added See All buttons to navigate to the equipment page where …
TobiasGleiter Aug 24, 2026
df95eeb
feat: upcoming & overdue inspections (widget) #35
TobiasGleiter Aug 24, 2026
03627e1
feat: add inspection filter to equipment page #35
TobiasGleiter Aug 25, 2026
3452b00
refactor: indicate inspection status with red, organge, adn green to …
TobiasGleiter Aug 25, 2026
b22f155
style: add status column and show overdue and due soon icons
TobiasGleiter Aug 25, 2026
1dbae8d
style: add quick links to naviagte to details, pricing, properties, u…
TobiasGleiter Aug 25, 2026
30b74ad
style: show user initals (derived from email) in the top right menu
TobiasGleiter Aug 25, 2026
d091b8d
chore: implement quick stats #34
TobiasGleiter Aug 25, 2026
009e143
style: use tabs for inspections and made metrics titles bigger
TobiasGleiter Aug 26, 2026
4c7056c
style: show inspection status up to date
TobiasGleiter Aug 26, 2026
ac86e59
refactor: rm inspections overdue and soon overdue funcs to reduce code
TobiasGleiter Aug 27, 2026
f69ea76
style: remove inspections overdue and overdue soon icons
TobiasGleiter Aug 27, 2026
5388c82
chore: show Today, Tomorrow, or Yesterday instead of 0d
TobiasGleiter Aug 27, 2026
d286b55
refactor: rm org-currency htmx fragment because it adds unneccessary …
TobiasGleiter Aug 27, 2026
e8271a8
chore: redirect to equipment page if only one org exists
TobiasGleiter Aug 27, 2026
3a9822e
chore: handle orgs.List error explicitly by logging
TobiasGleiter Aug 27, 2026
78c9570
style: inspection status can be used to navigate to units page
TobiasGleiter Aug 27, 2026
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
60 changes: 60 additions & 0 deletions cmd/web/handlers:dashboard.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// Copyright (C) 2026 Tobias Gleiter
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package main

import (
"fmt"
"net/http"

"github.com/bit8bytes/gearberg/internal/httperr"
"github.com/bit8bytes/gearberg/internal/templates/pages"
)

type dashboardData struct {
OrgID string
TotalValue string
TotalStock int64
EquipmentOverdue int64
EquipmentOverdueSoon int64
}

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

orgSettings, err := app.services.orgsettings.Get(r.Context(), orgID)
if err != nil {
return httperr.InternalServerError(err)
}

stats, err := app.services.equipment.Stats(r.Context(), orgID)
if err != nil {
return httperr.InternalServerError(err)
}

currency := ""
if orgSettings != nil {
currency = orgSettings.Currency.Symbol()
}

tmplData := app.html.TemplateData(r)
tmplData.Data = dashboardData{
OrgID: orgID,
TotalValue: fmt.Sprintf("%s %.2f", currency, float64(stats.TotalValue)/100),
TotalStock: stats.TotalStock,
EquipmentOverdue: stats.EquipmentOverdue,
EquipmentOverdueSoon: stats.EquipmentOverdueSoon,
}
return app.html.Render(w, r, http.StatusOK, pages.Dashboard, tmplData)
}
78 changes: 67 additions & 11 deletions cmd/web/handlers:equipment.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ type equipmentData struct {
Filtered bool
Query string
Category string
Inspection string
Sort string
PageBaseURL template.URL
PrintURL template.URL
Expand All @@ -94,6 +95,7 @@ func (app *application) getEquipment(w http.ResponseWriter, r *http.Request) *ht
qs := r.URL.Query()
query := qs.Get("q")
category := qs.Get("category")
inspection := qs.Get("inspection")
sort := qs.Get("sort")

page, err := strconv.Atoi(qs.Get("page"))
Expand All @@ -102,10 +104,11 @@ func (app *application) getEquipment(w http.ResponseWriter, r *http.Request) *ht
}

items, meta, err := app.services.equipment.List(ctx, equipment.ListParams{
OrgID: id,
Query: query,
Category: category,
Filters: pagination.Filters{Page: page, PageSize: 25},
OrgID: id,
Query: query,
Category: category,
InspectionFilter: inspection,
Filters: pagination.Filters{Page: page, PageSize: 25},
})
if err != nil {
return httperr.InternalServerError(err)
Expand All @@ -128,12 +131,13 @@ func (app *application) getEquipment(w http.ResponseWriter, r *http.Request) *ht
OrgID: id,
Categories: cats,
Inventories: items,
Filtered: query != "" || category != "",
Filtered: query != "" || category != "" || inspection != "",
Query: query,
Category: category,
Inspection: inspection,
Sort: sort,
PageBaseURL: template.URL(equipmentPageURL(id, query, category, sort)), // #nosec G203
PrintURL: template.URL(equipmentPrintURL(id, query, category, sort)), // #nosec G203
PageBaseURL: template.URL(equipmentPageURL(id, query, category, inspection, sort)), // #nosec G203
PrintURL: template.URL(equipmentPrintURL(id, query, category, inspection, sort)), // #nosec G203
Pagination: meta,
ImportSessionID: importSessionID,
}
Expand All @@ -151,13 +155,26 @@ type equipmentItemData struct {
Item *equipment.Equipment
ID string
ActiveTab string
Currency money.Currency
}

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

orgSettings, err := app.services.orgsettings.Get(ctx, id)
if err != nil {
return httperr.InternalServerError(err)
}

var currency money.Currency
if orgSettings != nil {
currency = orgSettings.Currency
}

data := app.html.TemplateData(r)
data.Form = equipment.NewCreateForm()
data.Data = equipmentItemData{OrgID: id}
data.Data = equipmentItemData{OrgID: id, Currency: currency}
return app.html.Render(w, r, http.StatusOK, pages.EquipmentNew, data)
}

Expand All @@ -166,6 +183,16 @@ func (app *application) postEquipmentNew(w http.ResponseWriter, r *http.Request)
ctx := r.Context()
id := r.PathValue("org_id")

orgSettings, err := app.services.orgsettings.Get(ctx, id)
if err != nil {
return httperr.InternalServerError(err)
}

var currency money.Currency
if orgSettings != nil {
currency = orgSettings.Currency
}

form, err := equipment.ParseForm(r)
if err != nil {
return httperr.BadRequest(err)
Expand All @@ -174,7 +201,7 @@ func (app *application) postEquipmentNew(w http.ResponseWriter, r *http.Request)
fail := func(f *equipment.NewForm) *httperr.Error {
data := app.html.TemplateData(r)
data.Form = f
data.Data = equipmentItemData{OrgID: id}
data.Data = equipmentItemData{OrgID: id, Currency: currency}
return app.html.Render(w, r, http.StatusUnprocessableEntity, pages.EquipmentNew, data)
}

Expand Down Expand Up @@ -402,6 +429,16 @@ func (app *application) getEquipmentItemPricing(w http.ResponseWriter, r *http.R

app.resolveItemURLs(item)

orgSettings, err := app.services.orgsettings.Get(ctx, orgID)
if err != nil {
return httperr.InternalServerError(err)
}

var currency money.Currency
if orgSettings != nil {
currency = orgSettings.Currency
}

data := app.html.TemplateData(r)
f := item.PricingForm()
data.Form = &f
Expand All @@ -410,6 +447,7 @@ func (app *application) getEquipmentItemPricing(w http.ResponseWriter, r *http.R
Item: item,
ID: itemID,
ActiveTab: "pricing",
Currency: currency,
}
return app.html.Render(w, r, http.StatusOK, pages.EquipmentPricing, data)
}
Expand All @@ -430,13 +468,24 @@ func (app *application) postEquipmentItemPricing(w http.ResponseWriter, r *http.
return httperr.InternalServerError(err)
}

orgSettings, err := app.services.orgsettings.Get(ctx, orgID)
if err != nil {
return httperr.InternalServerError(err)
}

var currency money.Currency
if orgSettings != nil {
currency = orgSettings.Currency
}

data := app.html.TemplateData(r)
data.Form = &form
data.Data = equipmentItemData{
OrgID: orgID,
Item: item,
ID: itemID,
ActiveTab: "pricing",
Currency: currency,
}
return app.html.Render(w, r, http.StatusUnprocessableEntity, pages.EquipmentPricing, data)
}
Expand Down Expand Up @@ -756,22 +805,25 @@ func (app *application) processEquipmentImage(r *http.Request, orgID, itemID str
}

// equipmentPageURL builds the paginated base URL for the inventory list.
func equipmentPageURL(orgID, query, category, sort string) string {
func equipmentPageURL(orgID, query, category, inspection, sort string) string {
base := "/orgs/" + url.PathEscape(orgID) + "/equipment?"
if category != "" {
base += "category=" + url.QueryEscape(category) + "&"
}
if query != "" {
base += "q=" + url.QueryEscape(query) + "&"
}
if inspection != "" {
base += "inspection=" + url.QueryEscape(inspection) + "&"
}
if sort != "" {
base += "sort=" + url.QueryEscape(sort) + "&"
}
return base
}

// equipmentPrintURL builds the print URL with optional filter params.
func equipmentPrintURL(orgID, query, category, sort string) string {
func equipmentPrintURL(orgID, query, category, inspection, sort string) string {
base := "/orgs/" + url.PathEscape(orgID) + "/equipment/print"
sep := "?"
if category != "" {
Expand All @@ -782,6 +834,10 @@ func equipmentPrintURL(orgID, query, category, sort string) string {
base += sep + "q=" + url.QueryEscape(query)
sep = "&"
}
if inspection != "" {
base += sep + "inspection=" + url.QueryEscape(inspection)
sep = "&"
}
if sort != "" {
base += sep + "sort=" + url.QueryEscape(sort)
}
Expand Down
11 changes: 8 additions & 3 deletions cmd/web/handlers:login.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,16 @@ import (
// belong to exactly one org, otherwise to the organizations settings page.
func (app *application) redirectAfterLogin(ctx context.Context, w http.ResponseWriter, r *http.Request, accountID string) {
orgs, err := app.services.orgs.List(ctx, accountID)
if err == nil && len(orgs) == 1 {
if err != nil {
// It's better just warn in logs than breaking the login flow.
app.logger.Warn("redirectAfterLogin: list orgs", "error", err)
}

if len(orgs) == 1 {
http.Redirect(w, r, "/orgs/"+orgs[0].ID+"/equipment", http.StatusSeeOther) //nolint:gosec
return
} else {
http.Redirect(w, r, "/orgs/pick", http.StatusSeeOther)
}
http.Redirect(w, r, "/orgs/pick", http.StatusSeeOther)
}

func (app *application) getSignIn(w http.ResponseWriter, r *http.Request) *httperr.Error {
Expand Down
7 changes: 6 additions & 1 deletion cmd/web/handlers:orgs.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ func (app *application) getOrgPicker(w http.ResponseWriter, r *http.Request) *ht
return httperr.InternalServerError(err)
}

if len(allOrgs) == 1 {
http.Redirect(w, r, "/orgs/"+allOrgs[0].ID+"/equipment", http.StatusSeeOther)
return nil
}

data := app.html.TemplateData(r)
data.Data = orgsData{
Orgs: allOrgs,
Expand Down Expand Up @@ -159,7 +164,7 @@ func (app *application) postSettingsOrg(w http.ResponseWriter, r *http.Request)
return httperr.InternalServerError(err)
}

http.Redirect(w, r, "/orgs/"+id, http.StatusSeeOther) //nolint:gosec // id is a parsed and validated KSUID, not an open redirect
http.Redirect(w, r, "/orgs/"+id+"/settings", http.StatusSeeOther) //nolint:gosec // id is a parsed and validated KSUID, not an open redirect
return nil
}

Expand Down
32 changes: 0 additions & 32 deletions cmd/web/handlers:orgs:settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,48 +16,16 @@ package main

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

"github.com/bit8bytes/gearberg/internal/httperr"
"github.com/bit8bytes/gearberg/internal/money"
"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"
)

type orgSettingsData struct {
OrgID string
}

type orgCurrencyData struct {
Currency money.Currency
}

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.Get(ctx, orgID)
if err != nil {
return httperr.InternalServerError(err)
}

var currency money.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
13 changes: 6 additions & 7 deletions cmd/web/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,6 @@ func (app *application) routes() (http.Handler, error) {
mux.Handle("POST /orgs/new", app.withLogin(app.html.Handle(app.postOrgsNew)))

// For any route that includes org_id, the permissions of the account must be checked via [app.withPermission]
mux.Handle("DELETE /orgs/{org_id}", app.withLogin(app.withPermission(app.html.Handle(app.deleteOrg))))
mux.Handle("GET /orgs/{org_id}", app.withLogin(app.withPermission(app.html.Handle(app.getSettingsOrg))))
mux.Handle("POST /orgs/{org_id}", app.withLogin(app.withPermission(app.html.Handle(app.postSettingsOrg))))

// Import
mux.Handle("GET /orgs/{org_id}/import", app.withLogin(app.withPermission(app.html.Handle(app.getImport))))
mux.Handle("GET /orgs/{org_id}/import/template", app.withLogin(app.withPermission(app.html.Handle(app.getImportTemplate))))
Expand All @@ -90,6 +86,7 @@ func (app *application) routes() (http.Handler, error) {
mux.Handle("POST /orgs/{org_id}/import/{id}/delete", app.withLogin(app.withPermission(app.html.Handle(app.postImportDelete))))

// Equipment
mux.Handle("GET /orgs/{org_id}/", app.withLogin(app.withPermission(app.html.Handle(app.getDashboard))))
mux.Handle("GET /orgs/{org_id}/equipment", app.withLogin(app.withPermission(app.html.Handle(app.getEquipment))))
mux.Handle("GET /orgs/{org_id}/equipment/export", app.withLogin(app.withPermission(app.html.Handle(app.getExport))))
mux.Handle("GET /orgs/{org_id}/equipment/print", app.withLogin(app.withPermission(app.html.Handle(app.getEquipmentPrint))))
Expand All @@ -113,7 +110,6 @@ func (app *application) routes() (http.Handler, error) {
mux.Handle("POST /orgs/{org_id}/equipment/{id}/content", app.withLogin(app.withPermission(app.html.Handle(app.postEquipmentAssignContent))))
mux.Handle("POST /orgs/{org_id}/equipment/{id}/content/{content_id}/delete", app.withLogin(app.withPermission(app.html.Handle(app.postEquipmentRemoveContent))))
mux.Handle("GET /orgs/{org_id}/equipment/{id}/part-of", app.withLogin(app.withPermission(app.html.Handle(app.getEquipmentPartOfFragment))))
mux.Handle("GET /orgs/{org_id}/currency", app.withLogin(app.withPermission(app.html.Handle(app.getOrgCurrencyFragment))))
mux.Handle("GET /orgs/{org_id}/equipment-categories", app.withLogin(app.withPermission(app.html.Handle(app.getEquipmentCategoriesFragment))))
mux.Handle("GET /orgs/{org_id}/equipment-manufacturers", app.withLogin(app.withPermission(app.html.Handle(app.getEquipmentManufacturersFragment))))
mux.Handle("GET /orgs/{org_id}/warehouse-locations", app.withLogin(app.withPermission(app.html.Handle(app.getEquipmentLocationsFragment))))
Expand All @@ -127,8 +123,11 @@ func (app *application) routes() (http.Handler, error) {
mux.Handle("GET /settings/organizations", app.withLogin(app.html.Handle(app.getOrgs)))

// Org related settings
mux.Handle("GET /orgs/{org_id}/settings", app.withLogin(app.withPermission(app.html.Handle(app.getOrgSettings))))
mux.Handle("POST /orgs/{org_id}/settings", app.withLogin(app.withPermission(app.html.Handle(app.postOrgSettings))))
mux.Handle("DELETE /orgs/{org_id}/settings", app.withLogin(app.withPermission(app.html.Handle(app.deleteOrg))))
mux.Handle("GET /orgs/{org_id}/settings", app.withLogin(app.withPermission(app.html.Handle(app.getSettingsOrg))))
mux.Handle("POST /orgs/{org_id}/settings", app.withLogin(app.withPermission(app.html.Handle(app.postSettingsOrg))))
mux.Handle("GET /orgs/{org_id}/settings/localization", app.withLogin(app.withPermission(app.html.Handle(app.getOrgSettings))))
mux.Handle("POST /orgs/{org_id}/settings/localization", app.withLogin(app.withPermission(app.html.Handle(app.postOrgSettings))))
mux.Handle("GET /orgs/{org_id}/settings/equipment-categories", app.withLogin(app.withPermission(app.html.Handle(app.getEquipmentCategories))))
mux.Handle("GET /orgs/{org_id}/settings/equipment-categories/new", app.withLogin(app.withPermission(app.html.Handle(app.getEquipmentCategoryNew))))
mux.Handle("POST /orgs/{org_id}/settings/equipment-categories/new", app.withLogin(app.withPermission(app.html.Handle(app.postEquipmentCategoryNew))))
Expand Down
Loading