diff --git a/cmd/web/handlers:dashboard.go b/cmd/web/handlers:dashboard.go
new file mode 100644
index 0000000..d2d0510
--- /dev/null
+++ b/cmd/web/handlers:dashboard.go
@@ -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 .
+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)
+}
diff --git a/cmd/web/handlers:equipment.go b/cmd/web/handlers:equipment.go
index 346b56d..8135338 100644
--- a/cmd/web/handlers:equipment.go
+++ b/cmd/web/handlers:equipment.go
@@ -77,6 +77,7 @@ type equipmentData struct {
Filtered bool
Query string
Category string
+ Inspection string
Sort string
PageBaseURL template.URL
PrintURL template.URL
@@ -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"))
@@ -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)
@@ -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,
}
@@ -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)
}
@@ -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)
@@ -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)
}
@@ -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
@@ -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)
}
@@ -430,6 +468,16 @@ 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{
@@ -437,6 +485,7 @@ func (app *application) postEquipmentItemPricing(w http.ResponseWriter, r *http.
Item: item,
ID: itemID,
ActiveTab: "pricing",
+ Currency: currency,
}
return app.html.Render(w, r, http.StatusUnprocessableEntity, pages.EquipmentPricing, data)
}
@@ -756,7 +805,7 @@ 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) + "&"
@@ -764,6 +813,9 @@ func equipmentPageURL(orgID, query, category, sort string) string {
if query != "" {
base += "q=" + url.QueryEscape(query) + "&"
}
+ if inspection != "" {
+ base += "inspection=" + url.QueryEscape(inspection) + "&"
+ }
if sort != "" {
base += "sort=" + url.QueryEscape(sort) + "&"
}
@@ -771,7 +823,7 @@ func equipmentPageURL(orgID, query, category, sort string) string {
}
// 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 != "" {
@@ -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)
}
diff --git a/cmd/web/handlers:login.go b/cmd/web/handlers:login.go
index 77939b7..ef80905 100644
--- a/cmd/web/handlers:login.go
+++ b/cmd/web/handlers:login.go
@@ -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 {
diff --git a/cmd/web/handlers:orgs.go b/cmd/web/handlers:orgs.go
index 0c849c0..41a5dc1 100644
--- a/cmd/web/handlers:orgs.go
+++ b/cmd/web/handlers:orgs.go
@@ -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,
@@ -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
}
diff --git a/cmd/web/handlers:orgs:settings.go b/cmd/web/handlers:orgs:settings.go
index 22df88f..05bcb27 100644
--- a/cmd/web/handlers:orgs:settings.go
+++ b/cmd/web/handlers:orgs:settings.go
@@ -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")
diff --git a/cmd/web/routes.go b/cmd/web/routes.go
index 34ed866..49b8d50 100644
--- a/cmd/web/routes.go
+++ b/cmd/web/routes.go
@@ -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))))
@@ -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))))
@@ -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))))
@@ -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))))
diff --git a/internal/assets/dist/index.css b/internal/assets/dist/index.css
index a063139..cb33b97 100644
--- a/internal/assets/dist/index.css
+++ b/internal/assets/dist/index.css
@@ -6589,6 +6589,9 @@
.h-6 {
height: calc(var(--spacing) * 6);
}
+ .h-8 {
+ height: calc(var(--spacing) * 8);
+ }
.h-10 {
height: calc(var(--spacing) * 10);
}
@@ -6768,6 +6771,9 @@
.w-1\/2 {
width: calc(1 / 2 * 100%);
}
+ .w-3 {
+ width: calc(var(--spacing) * 3);
+ }
.w-4 {
width: calc(var(--spacing) * 4);
}
@@ -6777,9 +6783,15 @@
.w-6 {
width: calc(var(--spacing) * 6);
}
+ .w-8 {
+ width: calc(var(--spacing) * 8);
+ }
.w-10 {
width: calc(var(--spacing) * 10);
}
+ .w-12 {
+ width: calc(var(--spacing) * 12);
+ }
.w-14 {
width: calc(var(--spacing) * 14);
}
@@ -6864,6 +6876,9 @@
.min-w-48 {
min-width: calc(var(--spacing) * 48);
}
+ .min-w-56 {
+ min-width: calc(var(--spacing) * 56);
+ }
.flex-1 {
flex: 1;
}
@@ -7807,6 +7822,9 @@
.bg-blue-400 {
background-color: var(--color-blue-400);
}
+ .bg-error {
+ background-color: var(--color-error);
+ }
.bg-gray-950 {
background-color: var(--color-gray-950);
}
@@ -7828,12 +7846,18 @@
.bg-red-500\! {
background-color: var(--color-red-500) !important;
}
+ .bg-success {
+ background-color: var(--color-success);
+ }
.bg-teal-400 {
background-color: var(--color-teal-400);
}
.bg-transparent {
background-color: transparent;
}
+ .bg-warning {
+ background-color: var(--color-warning);
+ }
.divider-accent {
@layer daisyui.l1.l2 {
&:before, &:after {
@@ -8134,6 +8158,9 @@
.p-4 {
padding: calc(var(--spacing) * 4);
}
+ .p-5 {
+ padding: calc(var(--spacing) * 5);
+ }
.p-6 {
padding: calc(var(--spacing) * 6);
}
@@ -9087,6 +9114,9 @@
.text-error {
color: var(--color-error);
}
+ .text-error-content {
+ color: var(--color-error-content);
+ }
.text-gray-800 {
color: var(--color-gray-800);
}
@@ -9117,12 +9147,18 @@
.text-success {
color: var(--color-success);
}
+ .text-success-content {
+ color: var(--color-success-content);
+ }
.text-teal-800 {
color: var(--color-teal-800);
}
.text-warning {
color: var(--color-warning);
}
+ .text-warning-content {
+ color: var(--color-warning-content);
+ }
.text-white {
color: var(--color-white);
}
diff --git a/internal/database/queries/gen/equipment/equipment.sql.go b/internal/database/queries/gen/equipment/equipment.sql.go
index add2df3..fd06489 100644
--- a/internal/database/queries/gen/equipment/equipment.sql.go
+++ b/internal/database/queries/gen/equipment/equipment.sql.go
@@ -449,60 +449,73 @@ SELECT
e.wire_gauge_mm2_x100,
e.updated_at,
e.created_at,
- COUNT(*) OVER() AS total_records
+ COUNT(*) OVER() AS total_records,
+ insp.min_next_inspection_at
FROM equipment e
LEFT JOIN equipment_categories ec ON ec.id = e.category_id
LEFT JOIN warehouse_locations wl ON wl.id = e.location_id
LEFT JOIN equipment_types et ON et.id = e.equipment_type_id
LEFT JOIN tracking_types tt ON tt.id = e.tracking_type_id
+LEFT JOIN (
+ SELECT equipment_id, MIN(next_inspection_at) AS min_next_inspection_at
+ FROM equipment_serialized_items
+ WHERE next_inspection_at IS NOT NULL
+ GROUP BY equipment_id
+) insp ON insp.equipment_id = e.id
WHERE e.org_id = ?1
AND (?2 = '' OR e.name LIKE '%' || ?2 || '%' OR EXISTS (SELECT 1 FROM equipment_serialized_items esi WHERE esi.equipment_id = e.id AND esi.serial_number LIKE '%' || ?2 || '%'))
AND (?3 = '' OR ec.name = ?3)
AND (?4 = -1 OR e.is_archived = ?4)
+ AND (?5 = ''
+ OR (?5 = 'overdue' AND EXISTS (SELECT 1 FROM equipment_serialized_items esi WHERE esi.equipment_id = e.id AND esi.next_inspection_at IS NOT NULL AND esi.next_inspection_at < unixepoch()))
+ OR (?5 = 'due-30d' AND EXISTS (SELECT 1 FROM equipment_serialized_items esi WHERE esi.equipment_id = e.id AND esi.next_inspection_at IS NOT NULL AND esi.next_inspection_at >= unixepoch() AND esi.next_inspection_at <= unixepoch() + 30 * 86400))
+ OR (?5 = 'up-to-date' AND EXISTS (SELECT 1 FROM equipment_serialized_items esi WHERE esi.equipment_id = e.id AND esi.next_inspection_at IS NOT NULL AND esi.next_inspection_at > unixepoch() + 30 * 86400)))
ORDER BY category_name ASC, e.name ASC
-LIMIT ?6 OFFSET ?5
+LIMIT ?7 OFFSET ?6
`
type ListParams struct {
- OrgID string
- NameQuery interface{}
- Category interface{}
- IsArchived interface{}
- PageOffset int64
- PageLimit int64
+ OrgID string
+ NameQuery interface{}
+ Category interface{}
+ IsArchived interface{}
+ InspectionFilter interface{}
+ PageOffset int64
+ PageLimit int64
}
type ListRow struct {
- ID string
- OrgID string
- Name string
- CategoryID sql.NullString
- CategoryName string
- ManufacturerID sql.NullString
- LocationID sql.NullString
- LocationName string
- StorageObjectID sql.NullString
- EquipmentTypeID int64
- EquipmentTypeName string
- TrackingTypeID sql.NullInt64
- TrackingTypeName string
- UsageTypeID int64
- TotalStock int64
- IsArchived int64
- RentalPrice *units.Cents
- ResalePrice *units.Cents
- Notes sql.NullString
- WeightG *units.Grams
- WidthMm *units.Millimeters
- HeightMm *units.Millimeters
- DepthMm *units.Millimeters
- VoltageMv *units.Millivolts
- CurrentMa *units.Milliamps
- PowerMw *units.Milliwatts
- WireGaugeMm2X100 *units.WireGauge
- UpdatedAt int64
- CreatedAt int64
- TotalRecords int64
+ ID string
+ OrgID string
+ Name string
+ CategoryID sql.NullString
+ CategoryName string
+ ManufacturerID sql.NullString
+ LocationID sql.NullString
+ LocationName string
+ StorageObjectID sql.NullString
+ EquipmentTypeID int64
+ EquipmentTypeName string
+ TrackingTypeID sql.NullInt64
+ TrackingTypeName string
+ UsageTypeID int64
+ TotalStock int64
+ IsArchived int64
+ RentalPrice *units.Cents
+ ResalePrice *units.Cents
+ Notes sql.NullString
+ WeightG *units.Grams
+ WidthMm *units.Millimeters
+ HeightMm *units.Millimeters
+ DepthMm *units.Millimeters
+ VoltageMv *units.Millivolts
+ CurrentMa *units.Milliamps
+ PowerMw *units.Milliwatts
+ WireGaugeMm2X100 *units.WireGauge
+ UpdatedAt int64
+ CreatedAt int64
+ TotalRecords int64
+ MinNextInspectionAt interface{}
}
func (q *Queries) List(ctx context.Context, arg ListParams) ([]ListRow, error) {
@@ -511,6 +524,7 @@ func (q *Queries) List(ctx context.Context, arg ListParams) ([]ListRow, error) {
arg.NameQuery,
arg.Category,
arg.IsArchived,
+ arg.InspectionFilter,
arg.PageOffset,
arg.PageLimit,
)
@@ -552,6 +566,7 @@ func (q *Queries) List(ctx context.Context, arg ListParams) ([]ListRow, error) {
&i.UpdatedAt,
&i.CreatedAt,
&i.TotalRecords,
+ &i.MinNextInspectionAt,
); err != nil {
return nil, err
}
@@ -694,6 +709,72 @@ func (q *Queries) ListBySerialNumber(ctx context.Context, arg ListBySerialNumber
return items, nil
}
+const stats = `-- name: Stats :one
+SELECT
+ COALESCE(
+ (SELECT SUM(ebi.purchase_price * ebi.quantity)
+ FROM equipment_bulk_items ebi
+ JOIN equipment e ON e.id = ebi.equipment_id
+ WHERE e.org_id = ?1 AND e.is_archived = 0),
+ 0
+ ) +
+ COALESCE(
+ (SELECT SUM(esi.purchase_price)
+ FROM equipment_serialized_items esi
+ WHERE esi.org_id = ?1),
+ 0
+ ) AS total_value,
+ COALESCE(
+ (SELECT SUM(ebi.quantity)
+ FROM equipment_bulk_items ebi
+ JOIN equipment e ON e.id = ebi.equipment_id
+ WHERE e.org_id = ?1 AND e.is_archived = 0),
+ 0
+ ) +
+ COALESCE(
+ (SELECT COUNT(*)
+ FROM equipment_serialized_items esi
+ WHERE esi.org_id = ?1),
+ 0
+ ) AS total_stock,
+ CAST(COALESCE(
+ (SELECT COUNT(*)
+ FROM equipment_serialized_items esi
+ WHERE esi.org_id = ?1
+ AND esi.next_inspection_at IS NOT NULL
+ AND esi.next_inspection_at < unixepoch()),
+ 0
+ ) AS INTEGER) AS equipment_overdue,
+ CAST(COALESCE(
+ (SELECT COUNT(*)
+ FROM equipment_serialized_items esi
+ WHERE esi.org_id = ?1
+ AND esi.next_inspection_at IS NOT NULL
+ AND esi.next_inspection_at >= unixepoch()
+ AND esi.next_inspection_at <= unixepoch() + 30 * 86400),
+ 0
+ ) AS INTEGER) AS equipment_overdue_soon
+`
+
+type StatsRow struct {
+ TotalValue int64
+ TotalStock int64
+ EquipmentOverdue int64
+ EquipmentOverdueSoon int64
+}
+
+func (q *Queries) Stats(ctx context.Context, orgID string) (StatsRow, error) {
+ row := q.db.QueryRowContext(ctx, stats, orgID)
+ var i StatsRow
+ err := row.Scan(
+ &i.TotalValue,
+ &i.TotalStock,
+ &i.EquipmentOverdue,
+ &i.EquipmentOverdueSoon,
+ )
+ return i, err
+}
+
const updateArchived = `-- name: UpdateArchived :exec
UPDATE equipment
SET
diff --git a/internal/database/queries/gen/equipment/querier.go b/internal/database/queries/gen/equipment/querier.go
index 3aad71b..3447910 100644
--- a/internal/database/queries/gen/equipment/querier.go
+++ b/internal/database/queries/gen/equipment/querier.go
@@ -16,6 +16,7 @@ type Querier interface {
GetByID(ctx context.Context, id string) (GetByIDRow, error)
List(ctx context.Context, arg ListParams) ([]ListRow, error)
ListBySerialNumber(ctx context.Context, arg ListBySerialNumberParams) ([]ListBySerialNumberRow, error)
+ Stats(ctx context.Context, orgID string) (StatsRow, error)
UpdateArchived(ctx context.Context, arg UpdateArchivedParams) error
UpdateDetails(ctx context.Context, arg UpdateDetailsParams) error
UpdatePricing(ctx context.Context, arg UpdatePricingParams) error
diff --git a/internal/database/queries/sqlite/equipment.sql b/internal/database/queries/sqlite/equipment.sql
index 714f335..f85e9ad 100644
--- a/internal/database/queries/sqlite/equipment.sql
+++ b/internal/database/queries/sqlite/equipment.sql
@@ -33,16 +33,27 @@ SELECT
e.wire_gauge_mm2_x100,
e.updated_at,
e.created_at,
- COUNT(*) OVER() AS total_records
+ COUNT(*) OVER() AS total_records,
+ insp.min_next_inspection_at
FROM equipment e
LEFT JOIN equipment_categories ec ON ec.id = e.category_id
LEFT JOIN warehouse_locations wl ON wl.id = e.location_id
LEFT JOIN equipment_types et ON et.id = e.equipment_type_id
LEFT JOIN tracking_types tt ON tt.id = e.tracking_type_id
+LEFT JOIN (
+ SELECT equipment_id, MIN(next_inspection_at) AS min_next_inspection_at
+ FROM equipment_serialized_items
+ WHERE next_inspection_at IS NOT NULL
+ GROUP BY equipment_id
+) insp ON insp.equipment_id = e.id
WHERE e.org_id = sqlc.arg(org_id)
AND (sqlc.arg(name_query) = '' OR e.name LIKE '%' || sqlc.arg(name_query) || '%' OR EXISTS (SELECT 1 FROM equipment_serialized_items esi WHERE esi.equipment_id = e.id AND esi.serial_number LIKE '%' || sqlc.arg(name_query) || '%'))
AND (sqlc.arg(category) = '' OR ec.name = sqlc.arg(category))
AND (sqlc.arg(is_archived) = -1 OR e.is_archived = sqlc.arg(is_archived))
+ AND (sqlc.arg(inspection_filter) = ''
+ OR (sqlc.arg(inspection_filter) = 'overdue' AND EXISTS (SELECT 1 FROM equipment_serialized_items esi WHERE esi.equipment_id = e.id AND esi.next_inspection_at IS NOT NULL AND esi.next_inspection_at < unixepoch()))
+ OR (sqlc.arg(inspection_filter) = 'due-30d' AND EXISTS (SELECT 1 FROM equipment_serialized_items esi WHERE esi.equipment_id = e.id AND esi.next_inspection_at IS NOT NULL AND esi.next_inspection_at >= unixepoch() AND esi.next_inspection_at <= unixepoch() + 30 * 86400))
+ OR (sqlc.arg(inspection_filter) = 'up-to-date' AND EXISTS (SELECT 1 FROM equipment_serialized_items esi WHERE esi.equipment_id = e.id AND esi.next_inspection_at IS NOT NULL AND esi.next_inspection_at > unixepoch() + 30 * 86400)))
ORDER BY category_name ASC, e.name ASC
LIMIT sqlc.arg(page_limit) OFFSET sqlc.arg(page_offset);
@@ -90,6 +101,52 @@ LIMIT sqlc.arg(page_limit) OFFSET sqlc.arg(page_offset);
SELECT COUNT(*) FROM equipment
WHERE org_id = ?;
+-- name: Stats :one
+SELECT
+ COALESCE(
+ (SELECT SUM(ebi.purchase_price * ebi.quantity)
+ FROM equipment_bulk_items ebi
+ JOIN equipment e ON e.id = ebi.equipment_id
+ WHERE e.org_id = sqlc.arg(org_id) AND e.is_archived = 0),
+ 0
+ ) +
+ COALESCE(
+ (SELECT SUM(esi.purchase_price)
+ FROM equipment_serialized_items esi
+ WHERE esi.org_id = sqlc.arg(org_id)),
+ 0
+ ) AS total_value,
+ COALESCE(
+ (SELECT SUM(ebi.quantity)
+ FROM equipment_bulk_items ebi
+ JOIN equipment e ON e.id = ebi.equipment_id
+ WHERE e.org_id = sqlc.arg(org_id) AND e.is_archived = 0),
+ 0
+ ) +
+ COALESCE(
+ (SELECT COUNT(*)
+ FROM equipment_serialized_items esi
+ WHERE esi.org_id = sqlc.arg(org_id)),
+ 0
+ ) AS total_stock,
+ CAST(COALESCE(
+ (SELECT COUNT(*)
+ FROM equipment_serialized_items esi
+ WHERE esi.org_id = sqlc.arg(org_id)
+ AND esi.next_inspection_at IS NOT NULL
+ AND esi.next_inspection_at < unixepoch()),
+ 0
+ ) AS INTEGER) AS equipment_overdue,
+ CAST(COALESCE(
+ (SELECT COUNT(*)
+ FROM equipment_serialized_items esi
+ WHERE esi.org_id = sqlc.arg(org_id)
+ AND esi.next_inspection_at IS NOT NULL
+ AND esi.next_inspection_at >= unixepoch()
+ AND esi.next_inspection_at <= unixepoch() + 30 * 86400),
+ 0
+ ) AS INTEGER) AS equipment_overdue_soon;
+
-- name: Create :one
INSERT INTO equipment (
id,
diff --git a/internal/equipment/model.go b/internal/equipment/model.go
index becf437..620a4e1 100644
--- a/internal/equipment/model.go
+++ b/internal/equipment/model.go
@@ -197,6 +197,7 @@ type Equipment struct {
IsArchived bool
Notes string
InspectionIntervalDays *int64
+ InspectionStatus InspectionStatus
Pricing Pricing
Properties Properties
CreatedAt int64
@@ -251,23 +252,84 @@ func (u *Unit) NextInspectionAtInput() string {
return time.Unix(*u.NextInspectionAt, 0).UTC().Format("2006-01-02")
}
-// NextInspectionLabel returns a human-readable countdown: "Xd overdue", "in Xd", or "" when nil.
+// NextInspectionLabel returns a human-readable countdown based on calendar dates:
+// "Today", "Tomorrow", "in Xd", "Yesterday", or "Xd overdue". Returns "" when nil.
func (u *Unit) NextInspectionLabel() string {
if u.NextInspectionAt == nil {
return ""
}
- days := (*u.NextInspectionAt - time.Now().Unix()) / 86400
- if days < 0 {
+ now := time.Now().UTC()
+ today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
+ due := time.Unix(*u.NextInspectionAt, 0).UTC()
+ dueDay := time.Date(due.Year(), due.Month(), due.Day(), 0, 0, 0, 0, time.UTC)
+ days := int(dueDay.Sub(today).Hours() / 24)
+ switch {
+ case days == 0:
+ return "Today"
+ case days == 1:
+ return "Tomorrow"
+ case days == -1:
+ return "Yesterday"
+ case days > 1:
+ return fmt.Sprintf("in %dd", days)
+ default:
return fmt.Sprintf("%dd overdue", -days)
}
- return fmt.Sprintf("in %dd", days)
}
-// IsInspectionOverdue returns true when the next inspection date has passed.
+// IsInspectionOverdue returns true when the next inspection calendar date is before today.
func (u *Unit) IsInspectionOverdue() bool {
- return u.NextInspectionAt != nil && *u.NextInspectionAt < time.Now().Unix()
+ if u.NextInspectionAt == nil {
+ return false
+ }
+ now := time.Now().UTC()
+ today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
+ due := time.Unix(*u.NextInspectionAt, 0).UTC()
+ dueDay := time.Date(due.Year(), due.Month(), due.Day(), 0, 0, 0, 0, time.UTC)
+ return dueDay.Before(today)
+}
+
+// InspectionStatus is the traffic-light health state of an equipment item
+// derived from its units' inspection dates.
+type InspectionStatus string
+
+const (
+ // InspectionStatusNone means no inspection date is set on any unit.
+ InspectionStatusNone InspectionStatus = ""
+ // InspectionStatusOverdue means at least one unit's inspection date has passed.
+ InspectionStatusOverdue InspectionStatus = "overdue"
+ // InspectionStatusDueSoon means at least one unit's inspection is due within 30 days.
+ InspectionStatusDueSoon InspectionStatus = "due-30d"
+ // InspectionStatusUpToDate means all unit inspections are more than 30 days away.
+ InspectionStatusUpToDate InspectionStatus = "up-to-date"
+)
+
+// NewInspectionStatus derives the status from the earliest unit inspection
+// timestamp. Returns InspectionStatusNone when minNextAt is nil.
+func NewInspectionStatus(minNextAt *int64) InspectionStatus {
+ if minNextAt == nil {
+ return InspectionStatusNone
+ }
+ now := time.Now().Unix()
+ switch {
+ case *minNextAt < now:
+ return InspectionStatusOverdue
+ case *minNextAt <= now+30*86400:
+ return InspectionStatusDueSoon
+ default:
+ return InspectionStatusUpToDate
+ }
}
+// IsOverdue reports whether the status is overdue.
+func (s InspectionStatus) IsOverdue() bool { return s == InspectionStatusOverdue }
+
+// IsDueSoon reports whether the status is due within 30 days.
+func (s InspectionStatus) IsDueSoon() bool { return s == InspectionStatusDueSoon }
+
+// IsUpToDate reports whether all inspections are more than 30 days away.
+func (s InspectionStatus) IsUpToDate() bool { return s == InspectionStatusUpToDate }
+
// IsActive returns true when the unit's is_active flag is set.
func (u *Unit) IsActive() bool { return u.StatusID == 1 }
@@ -277,6 +339,15 @@ type UnitStatusEntry struct {
Name string
}
+// DashboardStats holds aggregate inventory metrics shown on the dashboard.
+type DashboardStats struct {
+ // TotalValue is the sum of purchase_price × quantity for all units, in cents.
+ TotalValue units.Cents
+ TotalStock int64
+ EquipmentOverdue int64
+ EquipmentOverdueSoon int64
+}
+
// Label converts the snake_case Name to Title Case (e.g. "under_repair" → "Under Repair").
func (u UnitStatusEntry) Label() string {
words := strings.Fields(strings.ReplaceAll(u.Name, "_", " "))
diff --git a/internal/equipment/repository.go b/internal/equipment/repository.go
index a214639..6e60021 100644
--- a/internal/equipment/repository.go
+++ b/internal/equipment/repository.go
@@ -68,14 +68,15 @@ func (r *Repository) Count(ctx context.Context, orgID string) (int64, error) {
// only archived items are returned; otherwise only active items are returned.
// sortBy may be "code" to order by minimum unit code; otherwise orders by name.
// Returns total matching count.
-func (r *Repository) List(ctx context.Context, orgID, query, category string, showArchived bool, f pagination.Filters) ([]Equipment, int, error) {
+func (r *Repository) List(ctx context.Context, orgID, query, category, inspectionFilter string, showArchived bool, f pagination.Filters) ([]Equipment, int, error) {
rows, err := r.equipment.List(ctx, genequip.ListParams{
- OrgID: orgID,
- NameQuery: query,
- Category: category,
- IsArchived: database.Bool(showArchived),
- PageOffset: int64(f.Offset()),
- PageLimit: int64(f.Limit()),
+ OrgID: orgID,
+ NameQuery: query,
+ Category: category,
+ InspectionFilter: inspectionFilter,
+ IsArchived: database.Bool(showArchived),
+ PageOffset: int64(f.Offset()),
+ PageLimit: int64(f.Limit()),
})
if err != nil {
return nil, 0, fmt.Errorf("List: %w", err)
@@ -120,6 +121,13 @@ func listRowsToEquipment(rows []genequip.ListRow) ([]Equipment, int, error) {
},
UpdatedAt: row.UpdatedAt,
CreatedAt: row.CreatedAt,
+ InspectionStatus: func() InspectionStatus {
+ v, ok := row.MinNextInspectionAt.(int64)
+ if !ok {
+ return InspectionStatusNone
+ }
+ return NewInspectionStatus(&v)
+ }(),
})
}
return items, int(totalRecords), nil
@@ -677,3 +685,17 @@ func (r *Repository) ListContainersByMemberID(ctx context.Context, memberID stri
}
return items, nil
}
+
+// Stats returns dashboard aggregate counts and totals for the given org.
+func (r *Repository) Stats(ctx context.Context, orgID string) (DashboardStats, error) {
+ row, err := r.equipment.Stats(ctx, orgID)
+ if err != nil {
+ return DashboardStats{}, fmt.Errorf("Stats: %w", err)
+ }
+ return DashboardStats{
+ TotalValue: units.Cents(row.TotalValue),
+ TotalStock: row.TotalStock,
+ EquipmentOverdue: row.EquipmentOverdue,
+ EquipmentOverdueSoon: row.EquipmentOverdueSoon,
+ }, nil
+}
diff --git a/internal/equipment/service.go b/internal/equipment/service.go
index c737245..04539b0 100644
--- a/internal/equipment/service.go
+++ b/internal/equipment/service.go
@@ -129,16 +129,17 @@ func (s *Service) CreateWithUnits(ctx context.Context, base Base, unitList []Cre
// ListParams holds the parameters for listing equipment.
type ListParams struct {
- OrgID string
- Query string
- Category string
- Filters pagination.Filters
+ OrgID string
+ Query string
+ Category string
+ InspectionFilter string
+ Filters pagination.Filters
}
// List returns equipment for orgID filtered and paginated according to p.
// Returns all items when p.Filters.PageSize is math.MaxInt32.
func (s *Service) List(ctx context.Context, p ListParams) ([]Equipment, pagination.Metadata, error) {
- items, total, err := s.repo.List(ctx, p.OrgID, p.Query, p.Category, false, p.Filters)
+ items, total, err := s.repo.List(ctx, p.OrgID, p.Query, p.Category, p.InspectionFilter, false, p.Filters)
if err != nil {
return nil, pagination.Metadata{}, fmt.Errorf("List: %w", err)
}
@@ -149,7 +150,7 @@ func (s *Service) List(ctx context.Context, p ListParams) ([]Equipment, paginati
// ListNames returns the names of all equipment items belonging to orgID.
// Used by the import conflict-detection step.
func (s *Service) ListNames(ctx context.Context, orgID string) ([]string, error) {
- items, _, err := s.repo.List(ctx, orgID, "", "", false, pagination.Filters{Page: 1, PageSize: math.MaxInt32})
+ items, _, err := s.repo.List(ctx, orgID, "", "", "", false, pagination.Filters{Page: 1, PageSize: math.MaxInt32})
if err != nil {
return nil, fmt.Errorf("ListNames: %w", err)
}
@@ -174,7 +175,7 @@ func findByName(items []Equipment, name string) *Equipment {
// AssignContentByName resolves the member by name within the org then assigns it as content.
// Returns ErrNotFound when no equipment matches name.
func (s *Service) AssignContentByName(ctx context.Context, orgID, memberName string, a AssignContent) (*ContentItem, error) {
- all, _, err := s.repo.List(ctx, orgID, "", "", false, pagination.Filters{Page: 1, PageSize: math.MaxInt32})
+ all, _, err := s.repo.List(ctx, orgID, "", "", "", false, pagination.Filters{Page: 1, PageSize: math.MaxInt32})
if err != nil {
return nil, fmt.Errorf("AssignContentByName: %w", err)
}
@@ -378,3 +379,8 @@ func (s *Service) ListContainers(ctx context.Context, memberID string) ([]PartOf
}
return items, nil
}
+
+// Stats returns basic equipments statistics.
+func (s *Service) Stats(ctx context.Context, orgID string) (DashboardStats, error) {
+ return s.repo.Stats(ctx, orgID)
+}
diff --git a/internal/money/money.go b/internal/money/money.go
index 629357f..49db13c 100644
--- a/internal/money/money.go
+++ b/internal/money/money.go
@@ -35,6 +35,24 @@ type Currency string
// String returns the currency code as a plain string.
func (c Currency) String() string { return string(c) }
+var currencySymbols = map[Currency]string{
+ "USD": "$", "EUR": "€", "GBP": "£", "CHF": "Fr",
+ "CAD": "CA$", "AUD": "A$", "JPY": "¥", "CNY": "¥",
+ "INR": "₹", "BRL": "R$", "MXN": "MX$", "SEK": "kr",
+ "NOK": "kr", "DKK": "kr", "PLN": "zł", "CZK": "Kč",
+ "HUF": "Ft", "RON": "lei", "SGD": "S$", "HKD": "HK$",
+ "NZD": "NZ$", "ZAR": "R", "TRY": "₺", "AED": "د.إ",
+ "SAR": "﷼",
+}
+
+// Symbol returns the currency symbol (e.g. "€"), falling back to the ISO code when unknown.
+func (c Currency) Symbol() string {
+ if s, ok := currencySymbols[c]; ok {
+ return s
+ }
+ return string(c)
+}
+
// VatRate is a VAT rate stored as basis points (e.g. 1900 = 19%).
type VatRate int64
diff --git a/internal/templates/components/icons.tmpl b/internal/templates/components/icons.tmpl
index 23a030f..fea2c24 100644
--- a/internal/templates/components/icons.tmpl
+++ b/internal/templates/components/icons.tmpl
@@ -10,6 +10,10 @@
{{ end }}
+{{ define "dashboard-icon" }}
+
+{{ end }}
+
{{ define "equipment-icon" }}
{{ end }}
@@ -114,8 +118,8 @@
{{ end }}
-{{ define "log-inspection-icon"}}
-
+{{ define "wrench-icon"}}
+
{{ end }}
{{ define "delete-icon" }}
@@ -187,4 +191,16 @@
{{ define "arrow-right-icon" }}
+{{ end }}
+
+{{ define "pricing-icon" }}
+
+{{ end }}
+
+{{ define "properties-icon" }}
+
+{{ end }}
+
+{{ define "details-icon" }}
+
{{ end }}
\ No newline at end of file
diff --git a/internal/templates/fragments/account-header.tmpl b/internal/templates/fragments/account-header.tmpl
index 528ac0f..86ec256 100644
--- a/internal/templates/fragments/account-header.tmpl
+++ b/internal/templates/fragments/account-header.tmpl
@@ -1,7 +1,7 @@
{{ define "account-header" }}
-
diff --git a/internal/templates/fragments/equipment.tmpl b/internal/templates/fragments/equipment.tmpl
index 15cce8f..e38e645 100644
--- a/internal/templates/fragments/equipment.tmpl
+++ b/internal/templates/fragments/equipment.tmpl
@@ -4,7 +4,8 @@
|
- Name |
+ Name |
+ Status |
Category |
Stock |
Actions |
@@ -30,7 +31,28 @@
- {{ .Name }}
+ {{ .Name }}
+ |
+
+ {{ if .InspectionStatus.IsOverdue }}
+
+
+ {{ template "wrench-icon" . }}
+
+
+ {{ else if .InspectionStatus.IsDueSoon }}
+
+
+ {{ template "wrench-icon" . }}
+
+
+ {{ else if .InspectionStatus.IsUpToDate }}
+
+
+ {{ template "wrench-icon" . }}
+
+
+ {{ end }}
|
{{ if .CategoryName }}
@@ -61,10 +83,39 @@
-
- {{ template "edit-icon" . }}
- Edit Details
+ {{ template "details-icon" . }}
+ Details
+
+
+ -
+
+ {{ template "pricing-icon" . }}
+ Pricing
+
+
+ -
+
+ {{ template "properties-icon" . }}
+ Properties
+
+
+ {{ if eq .TrackingType.String "serialized" }}
+ -
+
+ {{ template "units-icon" . }}
+ Units
+
+
+ {{ end }}
+ {{ if eq .Type.String "kit" }}
+ -
+
+ {{ template "kit-icon" . }}
+ Content
+ {{ end }}
+
-
|