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" }}
-
+
{{ .Data.Initials }}
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 @@
diff --git a/internal/templates/pages/equipment/new.tmpl b/internal/templates/pages/equipment/new.tmpl index 6f82574..12258bf 100644 --- a/internal/templates/pages/equipment/new.tmpl +++ b/internal/templates/pages/equipment/new.tmpl @@ -159,7 +159,7 @@
{{ with index .Form.Errors "purchase_price" }} @@ -170,7 +170,7 @@
{{ with index .Form.Errors "rental_price" }} diff --git a/internal/templates/pages/equipment/pricing.tmpl b/internal/templates/pages/equipment/pricing.tmpl index 497e97c..17ee133 100644 --- a/internal/templates/pages/equipment/pricing.tmpl +++ b/internal/templates/pages/equipment/pricing.tmpl @@ -22,7 +22,7 @@
diff --git a/internal/templates/pages/orgs/index.tmpl b/internal/templates/pages/orgs/index.tmpl index 30b92c8..a67baff 100644 --- a/internal/templates/pages/orgs/index.tmpl +++ b/internal/templates/pages/orgs/index.tmpl @@ -41,10 +41,10 @@ + data-tip="Go to dashboard"> {{ template "arrow-right-icon" . }} - {{ template "settings-icon" . }} diff --git a/internal/templates/pages/orgs/settings.tmpl b/internal/templates/pages/orgs/localization.tmpl similarity index 99% rename from internal/templates/pages/orgs/settings.tmpl rename to internal/templates/pages/orgs/localization.tmpl index d33d01a..46d8fcc 100644 --- a/internal/templates/pages/orgs/settings.tmpl +++ b/internal/templates/pages/orgs/localization.tmpl @@ -11,7 +11,7 @@

Regional defaults for currency, VAT rate, and timezone.

-
+