diff --git a/.github/workflows/pull-request-checks.yml b/.github/workflows/pull-request-checks.yml
index 79f8fc9..f8aa24d 100644
--- a/.github/workflows/pull-request-checks.yml
+++ b/.github/workflows/pull-request-checks.yml
@@ -12,16 +12,16 @@ on:
jobs:
# nixos/web.nix uses buildGoModule which compiles the Go binary (no separate build job needed)
- # and runs `go test -short` via doCheck = true (no separate test job needed).
+ # and runs `go test -short` via doCheck = true (no separate test job needed).
# nixos/web.docker.nix builds the Docker image which includes the compiled binary.
- build:
+ build-amd64:
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@v7
- name: Install Nix
uses: cachix/install-nix-action@v31
@@ -39,9 +39,34 @@ jobs:
- name: Build binary
run: nix build .#default
- - name: Build Docker image
+ - name: Build Docker image (amd64)
run: nix build .#docker-amd64
-
- - name: Build Docker image
+
+ build-arm64:
+ runs-on: ubuntu-24.04-arm
+ timeout-minutes: 15
+ permissions:
+ contents: read
+
+ steps:
+ - uses: actions/checkout@v7
+
+ - name: Install Nix
+ uses: cachix/install-nix-action@v31
+ with:
+ extra_nix_config: |
+ experimental-features = nix-command flakes
+
+ - name: Cache Nix store
+ uses: nix-community/cache-nix-action@v7
+ with:
+ primary-key: nix-${{ runner.os }}-arm64-${{ hashFiles('flake.lock', '**/*.nix', '**/*.go') }}
+ restore-prefixes-first-match: nix-${{ runner.os }}-arm64-
+ gc-max-store-size-linux: 5368709120 # 5 GB
+
+ - name: Build binary
+ run: nix build .#default
+
+ - name: Build Docker image (arm64)
run: nix build .#docker-arm64
diff --git a/cmd/web/handlers:equipment.go b/cmd/web/handlers:equipment.go
index e84f319..346b56d 100644
--- a/cmd/web/handlers:equipment.go
+++ b/cmd/web/handlers:equipment.go
@@ -71,16 +71,17 @@ func (app *application) getEquipmentPartOfFragment(w http.ResponseWriter, r *htt
}
type equipmentData struct {
- OrgID string
- Categories []categories.Category
- Inventories []equipment.Equipment
- Filtered bool
- Query string
- Category string
- Sort string
- PageBaseURL template.URL
- PrintURL template.URL
- Pagination pagination.Metadata
+ OrgID string
+ Categories []categories.Category
+ Inventories []equipment.Equipment
+ Filtered bool
+ Query string
+ Category string
+ Sort string
+ PageBaseURL template.URL
+ PrintURL template.URL
+ Pagination pagination.Metadata
+ ImportSessionID string
}
func (app *application) getEquipment(w http.ResponseWriter, r *http.Request) *httperr.Error {
@@ -117,18 +118,24 @@ func (app *application) getEquipment(w http.ResponseWriter, r *http.Request) *ht
return httperr.InternalServerError(err)
}
+ var importSessionID string
+ if session, err := app.services.equipmentImports.GetStagedSession(ctx, id); err == nil {
+ importSessionID = session.ID
+ }
+
tmpl := app.html.TemplateData(r)
tmpl.Data = equipmentData{
- OrgID: id,
- Categories: cats,
- Inventories: items,
- Filtered: query != "" || category != "",
- Query: query,
- Category: category,
- Sort: sort,
- PageBaseURL: template.URL(equipmentPageURL(id, query, category, sort)), // #nosec G203
- PrintURL: template.URL(equipmentPrintURL(id, query, category, sort)), // #nosec G203
- Pagination: meta,
+ OrgID: id,
+ Categories: cats,
+ Inventories: items,
+ Filtered: query != "" || category != "",
+ Query: query,
+ Category: category,
+ Sort: sort,
+ PageBaseURL: template.URL(equipmentPageURL(id, query, category, sort)), // #nosec G203
+ PrintURL: template.URL(equipmentPrintURL(id, query, category, sort)), // #nosec G203
+ Pagination: meta,
+ ImportSessionID: importSessionID,
}
// HTMX live-search: return only the results fragment so the page URL
diff --git a/cmd/web/handlers:equipment:export.go b/cmd/web/handlers:equipment:export.go
deleted file mode 100644
index b248ccf..0000000
--- a/cmd/web/handlers:equipment:export.go
+++ /dev/null
@@ -1,81 +0,0 @@
-// 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 (
- "encoding/csv"
- "math"
- "net/http"
-
- "github.com/bit8bytes/gearberg/internal/equipment"
- "github.com/bit8bytes/gearberg/internal/equipment/tracking"
- "github.com/bit8bytes/gearberg/internal/equipmentimports"
- "github.com/bit8bytes/gearberg/internal/httperr"
- "github.com/bit8bytes/gearberg/internal/pagination"
-)
-
-// exportColumnCount must equal len(imports.ExpectedHeaders). This line fails to
-// compile if RowsForItem is updated without updating ExpectedHeaders (or vice
-// versa), catching column-count drift at build time.
-const exportColumnCount = 26
-
-var _ = [1]struct{}{}[exportColumnCount-len(equipmentimports.ExpectedHeaders)]
-
-func (app *application) getEquipmentExport(w http.ResponseWriter, r *http.Request) *httperr.Error {
- ctx := r.Context()
- orgID := r.PathValue("org_id")
-
- items, _, err := app.services.equipment.List(ctx, equipment.ListParams{OrgID: orgID, Filters: pagination.Filters{Page: 1, PageSize: math.MaxInt32}})
- if err != nil {
- return httperr.InternalServerError(err)
- }
-
- mfrs, err := app.services.manufacturers.List(ctx, orgID)
- if err != nil {
- return httperr.InternalServerError(err)
- }
- mfrByID := make(map[string]string, len(mfrs))
- for _, m := range mfrs {
- mfrByID[m.ID] = m.Name
- }
-
- w.Header().Set("Content-Type", "text/csv; charset=utf-8")
- w.Header().Set("Content-Disposition", `attachment; filename="gearberg-equipment-export.csv"`)
- // UTF-8 BOM so Excel opens the file with correct encoding.
- _, _ = w.Write([]byte{0xEF, 0xBB, 0xBF})
-
- cw := csv.NewWriter(w)
- _ = cw.Write(equipmentimports.ExpectedHeaders)
-
- for _, item := range items {
- if item.IsArchived || item.TotalStock == 0 {
- continue
- }
-
- var units []equipment.Unit
- if item.TrackingType == tracking.Serialized {
- units, err = app.services.equipment.ListUnits(ctx, item.ID)
- if err != nil {
- return httperr.InternalServerError(err)
- }
- }
-
- for _, row := range equipmentimports.RowsForItem(item, mfrByID[item.ManufacturerID], units) {
- _ = cw.Write(row)
- }
- }
- cw.Flush()
- return nil
-}
diff --git a/cmd/web/handlers:equipment:import.go b/cmd/web/handlers:equipment:import.go
deleted file mode 100644
index 0b643b2..0000000
--- a/cmd/web/handlers:equipment:import.go
+++ /dev/null
@@ -1,131 +0,0 @@
-// 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 (
- "net/http"
- "net/url"
-
- "github.com/bit8bytes/gearberg/internal/equipmentimports"
- "github.com/bit8bytes/gearberg/internal/httperr"
- "github.com/bit8bytes/gearberg/internal/templates/pages"
-)
-
-type equipmentImportData struct {
- OrgID string
- Error string
-}
-
-type equipmentImportPreviewData struct {
- OrgID string
- ImportID string
- Rows []equipmentimports.GroupedRow
- CountNew int
- CountError int
-}
-
-// getEquipmentImport serves the upload form when no ?id= param is present,
-// or the staging preview when ?id= is set (after a successful upload).
-func (app *application) getEquipmentImport(w http.ResponseWriter, r *http.Request) *httperr.Error {
- orgID := r.PathValue("org_id")
- importID := r.URL.Query().Get("id")
- if importID == "" {
- data := app.html.TemplateData(r)
- data.Data = equipmentImportData{OrgID: orgID}
- return app.html.Render(w, r, http.StatusOK, pages.EquipmentImport, data)
- }
- return app.renderImportPreview(w, r, orgID, importID)
-}
-
-func (app *application) postEquipmentImport(w http.ResponseWriter, r *http.Request) *httperr.Error {
- ctx := r.Context()
- orgID := r.PathValue("org_id")
-
- fail := func(msg string) *httperr.Error {
- data := app.html.TemplateData(r)
- data.Data = equipmentImportData{OrgID: orgID, Error: msg}
- return app.html.Render(w, r, http.StatusUnprocessableEntity, pages.EquipmentImport, data)
- }
-
- const maxImportBytes = 32 << 20 // 32 MiB
- if err := r.ParseMultipartForm(maxImportBytes); err != nil { //nolint:gosec // maxImportBytes is a bounded constant (32 MiB)
- return fail("Could not parse form.")
- }
-
- f, _, err := r.FormFile("file")
- if err != nil {
- return fail("No file uploaded.")
- }
- defer func() { _ = f.Close() }()
-
- rawRows, parseErr := equipmentimports.ParseCSV(f)
- if parseErr != nil {
- return fail(parseErr.Error())
- }
-
- importID, err := app.services.equipmentImports.Stage(ctx, orgID, rawRows)
- if err != nil {
- return httperr.InternalServerError(err)
- }
-
- http.Redirect(w, r, "/orgs/"+url.PathEscape(orgID)+"/equipment/import?id="+url.QueryEscape(importID), http.StatusSeeOther)
- return nil
-}
-
-func (app *application) renderImportPreview(w http.ResponseWriter, r *http.Request, orgID, importID string) *httperr.Error {
- ctx := r.Context()
-
- staged, err := app.services.equipmentImports.ListStaged(ctx, importID)
- if err != nil {
- return httperr.InternalServerError(err)
- }
-
- previewRows, cntNew, cntError := equipmentimports.GroupRows(staged)
-
- data := app.html.TemplateData(r)
- data.Data = equipmentImportPreviewData{
- OrgID: orgID,
- ImportID: importID,
- Rows: previewRows,
- CountNew: cntNew,
- CountError: cntError,
- }
- return app.html.Render(w, r, http.StatusOK, pages.EquipmentImportPreview, data)
-}
-
-func (app *application) postEquipmentImportConfirm(w http.ResponseWriter, r *http.Request) *httperr.Error {
- ctx := r.Context()
- orgID := r.PathValue("org_id")
-
- if err := r.ParseForm(); err != nil {
- return httperr.BadRequest(err)
- }
- importID := r.FormValue("import_id")
-
- if err := app.services.equipmentImports.Commit(ctx, importID, orgID); err != nil {
- return httperr.InternalServerError(err)
- }
-
- http.Redirect(w, r, "/orgs/"+url.PathEscape(orgID)+"/equipment", http.StatusSeeOther)
- return nil
-}
-
-// getEquipmentImportTemplate serves a ready-to-fill CSV template for download.
-func (app *application) getEquipmentImportTemplate(w http.ResponseWriter, _ *http.Request) *httperr.Error {
- w.Header().Set("Content-Type", "text/csv; charset=utf-8")
- w.Header().Set("Content-Disposition", `attachment; filename="gearberg-import-template.csv"`)
- _, _ = w.Write(equipmentimports.TemplateCSV)
- return nil
-}
diff --git a/cmd/web/handlers:equipment:import_test.go b/cmd/web/handlers:equipment:import_test.go
deleted file mode 100644
index ae4ace7..0000000
--- a/cmd/web/handlers:equipment:import_test.go
+++ /dev/null
@@ -1,134 +0,0 @@
-// 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 (
- "net/http"
- "net/url"
- "strings"
- "testing"
-
- "github.com/bit8bytes/gearberg/internal/equipmentimports"
-)
-
-func TestEquipmentImportExportRoundTrip(t *testing.T) {
- alice.signin(t)
- importID := roundTripUpload(t)
- roundTripConfirm(t, importID)
- body := roundTripExport(t)
- alice.signout(t)
- assertRoundTrip(t, body)
-}
-
-func roundTripUpload(t *testing.T) string {
- t.Helper()
- code, header, _ := ts.postFile(t, "/orgs/"+alice.orgID+"/equipment/import", "file", "template.csv", equipmentimports.TemplateCSV)
- if code != http.StatusSeeOther {
- t.Fatalf("upload: want 303, got %d", code)
- }
- return importIDFromLocation(t, header.Get("Location"))
-}
-
-func roundTripConfirm(t *testing.T, importID string) {
- t.Helper()
- code, _, _ := ts.postForm(t, "/orgs/"+alice.orgID+"/equipment/import/confirm", url.Values{"import_id": {importID}})
- if code != http.StatusSeeOther {
- t.Fatalf("confirm: want 303, got %d", code)
- }
-}
-
-func roundTripExport(t *testing.T) []byte {
- t.Helper()
- code, _, body := ts.get(t, "/orgs/"+alice.orgID+"/equipment/export")
- if code != http.StatusOK {
- t.Fatalf("export: want 200, got %d", code)
- }
- return body
-}
-
-func assertRoundTrip(t *testing.T, body []byte) {
- t.Helper()
- rows := parseExportCSV(t, body)
- assertExportCounts(t, rows)
- assertShureQuantity(t, rows)
- assertSonySerials(t, rows)
-}
-
-func parseExportCSV(t *testing.T, body []byte) []equipmentimports.ProcessedRow {
- t.Helper()
- // Strip the UTF-8 BOM the handler prepends so ParseCSV sees clean bytes.
- if len(body) >= 3 && body[0] == 0xEF && body[1] == 0xBB && body[2] == 0xBF {
- body = body[3:]
- }
- rows, err := equipmentimports.ParseCSV(strings.NewReader(string(body)))
- if err != nil {
- t.Fatalf("ParseCSV on export: %v", err)
- }
- return rows
-}
-
-func assertExportCounts(t *testing.T, rows []equipmentimports.ProcessedRow) {
- t.Helper()
- counts := make(map[string]int)
- for _, r := range rows {
- counts[r.Data.Name]++
- }
- if counts["Shure SM58"] != 1 {
- t.Errorf("Shure SM58: want 1 export row, got %d", counts["Shure SM58"])
- }
- if counts["Sony A7 IV"] != 2 {
- t.Errorf("Sony A7 IV: want 2 export rows (one per unit), got %d", counts["Sony A7 IV"])
- }
- if counts["Pelican 1510 Case"] != 1 {
- t.Errorf("Pelican 1510 Case: want 1 export row, got %d", counts["Pelican 1510 Case"])
- }
-}
-
-func assertShureQuantity(t *testing.T, rows []equipmentimports.ProcessedRow) {
- t.Helper()
- for _, r := range rows {
- if r.Data.Name == "Shure SM58" && r.Data.Quantity != "7" {
- t.Errorf("Shure SM58: want quantity 7, got %q", r.Data.Quantity)
- }
- }
-}
-
-func assertSonySerials(t *testing.T, rows []equipmentimports.ProcessedRow) {
- t.Helper()
- serials := make(map[string]bool)
- for _, r := range rows {
- if r.Data.Name == "Sony A7 IV" {
- serials[r.Data.UnitSerialNumber] = true
- }
- }
- for _, want := range []string{"SN-A7IV-001", "SN-A7IV-002"} {
- if !serials[want] {
- t.Errorf("Sony A7 IV: serial %q not found in export", want)
- }
- }
-}
-
-// importIDFromLocation extracts the ?id= query parameter from a redirect Location header.
-func importIDFromLocation(t *testing.T, loc string) string {
- t.Helper()
- for part := range strings.SplitSeq(loc, "?") {
- params, _ := url.ParseQuery(part)
- if id := params.Get("id"); id != "" {
- return id
- }
- }
- t.Fatalf("redirect location %q contains no import id", loc)
- return ""
-}
diff --git a/cmd/web/handlers:export.go b/cmd/web/handlers:export.go
new file mode 100644
index 0000000..2cb053d
--- /dev/null
+++ b/cmd/web/handlers:export.go
@@ -0,0 +1,31 @@
+// 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 (
+ "net/http"
+
+ "github.com/bit8bytes/gearberg/internal/httperr"
+)
+
+func (app *application) getExport(w http.ResponseWriter, r *http.Request) *httperr.Error {
+ orgID := r.PathValue("org_id")
+ w.Header().Set("Content-Type", "text/csv; charset=utf-8")
+ w.Header().Set("Content-Disposition", `attachment; filename="gearberg-export.csv"`)
+ if err := app.services.equipment.ExportCSV(r.Context(), w, orgID); err != nil {
+ return httperr.InternalServerError(err)
+ }
+ return nil
+}
diff --git a/cmd/web/handlers:import.go b/cmd/web/handlers:import.go
new file mode 100644
index 0000000..3d186f2
--- /dev/null
+++ b/cmd/web/handlers:import.go
@@ -0,0 +1,200 @@
+// 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 (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/url"
+
+ "github.com/bit8bytes/gearberg/internal/flash"
+ "github.com/bit8bytes/gearberg/internal/httperr"
+ imports "github.com/bit8bytes/gearberg/internal/import"
+ "github.com/bit8bytes/gearberg/internal/templates/pages"
+)
+
+const importMaxBytes = 32 << 20 // 32 MiB
+
+type importReviewRow struct {
+ ID string
+ RowNumber int64
+ Name string
+ Status string
+ ErrorMessage string
+ Action string
+}
+
+type importReviewData struct {
+ OrgID string
+ SessionID string
+ Rows []importReviewRow
+ CountReady int
+ CountPending int
+ CountError int
+ Step int
+}
+
+// getImportTemplate serves the template CSV for download.
+func (app *application) getImportTemplate(w http.ResponseWriter, _ *http.Request) *httperr.Error {
+ w.Header().Set("Content-Type", "text/csv; charset=utf-8")
+ w.Header().Set("Content-Disposition", `attachment; filename="gearberg-import-template.csv"`)
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write(imports.TemplateCSV)
+ return nil
+}
+
+// getImport serves the upload form, or redirects to the review page if a staged session exists.
+func (app *application) getImport(w http.ResponseWriter, r *http.Request) *httperr.Error {
+ ctx := r.Context()
+ orgID := r.PathValue("org_id")
+
+ session, err := app.services.equipmentImports.GetStagedSession(ctx, orgID)
+ if err == nil {
+ http.Redirect(w, r, "/orgs/"+url.PathEscape(orgID)+"/import/"+url.PathEscape(session.ID), http.StatusSeeOther)
+ return nil
+ }
+ if !errors.Is(err, imports.ErrNotFound) {
+ return httperr.InternalServerError(err)
+ }
+
+ http.Redirect(w, r, "/orgs/"+url.PathEscape(orgID)+"/equipment", http.StatusSeeOther)
+ return nil
+}
+
+// postImport parses the uploaded file, creates an import session, and redirects
+// to the review page.
+func (app *application) postImport(w http.ResponseWriter, r *http.Request) *httperr.Error {
+ ctx := r.Context()
+ orgID := r.PathValue("org_id")
+
+ if _, err := app.services.equipmentImports.GetStagedSession(ctx, orgID); err == nil {
+ return httperr.BadRequest(fmt.Errorf("an import session is already in progress"))
+ }
+
+ if err := r.ParseMultipartForm(importMaxBytes); err != nil { //nolint:gosec // bound is the importMaxBytes constant
+ return httperr.BadRequest(fmt.Errorf("file too large or malformed: %w", err))
+ }
+
+ file, _, err := r.FormFile("file")
+ if err != nil {
+ return httperr.BadRequest(fmt.Errorf("missing file: %w", err))
+ }
+ defer func() { _ = file.Close() }()
+
+ session, err := app.services.equipmentImports.StartSession(ctx, orgID, imports.FormatCSV, file)
+ if err != nil {
+ return httperr.InternalServerError(err)
+ }
+
+ http.Redirect(w, r, "/orgs/"+url.PathEscape(orgID)+"/import/"+url.PathEscape(session.ID), http.StatusSeeOther)
+ return nil
+}
+
+// postImportDelete deletes the current staged session and redirects to the upload form.
+func (app *application) postImportDelete(w http.ResponseWriter, r *http.Request) *httperr.Error {
+ ctx := r.Context()
+ orgID := r.PathValue("org_id")
+ sessionID := r.PathValue("id")
+
+ if err := app.services.equipmentImports.Delete(ctx, sessionID); err != nil {
+ return httperr.InternalServerError(err)
+ }
+
+ http.Redirect(w, r, "/orgs/"+url.PathEscape(orgID)+"/import", http.StatusSeeOther)
+ return nil
+}
+
+// getImportReview shows staged rows for user review.
+func (app *application) getImportReview(w http.ResponseWriter, r *http.Request) *httperr.Error {
+ ctx := r.Context()
+ orgID := r.PathValue("org_id")
+ sessionID := r.PathValue("id")
+
+ rows, err := app.services.equipmentImports.ListData(ctx, sessionID)
+ if err != nil {
+ return httperr.InternalServerError(err)
+ }
+
+ data := importReviewData{
+ OrgID: orgID,
+ SessionID: sessionID,
+ Rows: make([]importReviewRow, 0, len(rows)),
+ Step: 3,
+ }
+
+ for _, row := range rows {
+ var fields map[string]string
+ _ = json.Unmarshal([]byte(row.Data), &fields)
+ name := fields["Name"]
+
+ switch row.Status {
+ case imports.RowStatusError:
+ data.CountError++
+ case imports.RowStatusValid:
+ if row.Action != imports.ActionSkip {
+ data.CountReady++
+ }
+ default:
+ data.CountPending++
+ }
+
+ data.Rows = append(data.Rows, importReviewRow{
+ ID: row.ID,
+ RowNumber: row.RowNumber,
+ Name: name,
+ Status: string(row.Status),
+ ErrorMessage: row.ErrorMessage,
+ Action: string(row.Action),
+ })
+ }
+
+ tmpl := app.html.TemplateData(r)
+ tmpl.Data = data
+ return app.html.Render(w, r, http.StatusOK, pages.ImportReview, tmpl)
+}
+
+// postImportReview applies a single row decision (create/skip) and redirects back.
+func (app *application) postImportReview(w http.ResponseWriter, r *http.Request) *httperr.Error {
+ ctx := r.Context()
+ orgID := r.PathValue("org_id")
+ sessionID := r.PathValue("id")
+
+ rowID := r.FormValue("row_id")
+ action := imports.Action(r.FormValue("action"))
+
+ if err := app.services.equipmentImports.Review(ctx, []imports.Decision{{RowID: rowID, Action: action}}); err != nil {
+ return httperr.InternalServerError(err)
+ }
+
+ http.Redirect(w, r, "/orgs/"+url.PathEscape(orgID)+"/import/"+url.PathEscape(sessionID), http.StatusSeeOther)
+ return nil
+}
+
+// postImportConfirm commits the import and redirects to the equipment list.
+func (app *application) postImportConfirm(w http.ResponseWriter, r *http.Request) *httperr.Error {
+ ctx := r.Context()
+ orgID := r.PathValue("org_id")
+ sessionID := r.PathValue("id")
+
+ if err := app.services.equipmentImports.Commit(ctx, sessionID); err != nil {
+ return httperr.InternalServerError(err)
+ }
+
+ app.flash.Put(ctx, "Equipment imported successfully.", flash.Success)
+ http.Redirect(w, r, "/orgs/"+url.PathEscape(orgID)+"/equipment", http.StatusSeeOther)
+ return nil
+}
diff --git a/cmd/web/main.go b/cmd/web/main.go
index bdde763..205480c 100644
--- a/cmd/web/main.go
+++ b/cmd/web/main.go
@@ -27,6 +27,7 @@ import (
"syscall"
"time"
+ "github.com/bit8bytes/gearberg/internal/flash"
htmlpkg "github.com/bit8bytes/gearberg/internal/html"
"github.com/bit8bytes/gearberg/internal/templates"
"github.com/bit8bytes/gearberg/internal/templates/pages"
@@ -51,6 +52,8 @@ type application struct {
services *services
+ flash flash.Manager
+
// oidcProviders is keyed by provider name. Providers are configured at
// startup because OIDC discovery is a network call that should not happen per-request.
oidcProviders map[string]*oidcProvider
@@ -114,8 +117,6 @@ func runServe(args []string) error {
return fmt.Errorf("load templates: %w", err)
}
- html := htmlpkg.New(log, base, cache, revision)
-
db, err := setupDatabase(ctx, options)
if err != nil {
return fmt.Errorf("setup database: %w", err)
@@ -133,6 +134,9 @@ func runServe(args []string) error {
return fmt.Errorf("setup session manager: %w", err)
}
sessionManager := setupSessionManager(scsMgr)
+ flashManager := flash.NewSessionManager(sessionManager)
+
+ html := htmlpkg.New(log, base, cache, revision, htmlpkg.WithFlashManager(flashManager))
mailer := setupMailer(options, log)
@@ -156,6 +160,7 @@ func runServe(args []string) error {
html: html,
db: db,
session: sessionManager,
+ flash: flashManager,
services: services,
oidcProviders: oidcProviders,
}
diff --git a/cmd/web/routes.go b/cmd/web/routes.go
index 4609744..34ed866 100644
--- a/cmd/web/routes.go
+++ b/cmd/web/routes.go
@@ -80,14 +80,19 @@ func (app *application) routes() (http.Handler, error) {
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))))
+ mux.Handle("POST /orgs/{org_id}/import", app.withLogin(app.withPermission(app.html.Handle(app.postImport))))
+ mux.Handle("GET /orgs/{org_id}/import/{id}", app.withLogin(app.withPermission(app.html.Handle(app.getImportReview))))
+ mux.Handle("POST /orgs/{org_id}/import/{id}/review", app.withLogin(app.withPermission(app.html.Handle(app.postImportReview))))
+ mux.Handle("POST /orgs/{org_id}/import/{id}/confirm", app.withLogin(app.withPermission(app.html.Handle(app.postImportConfirm))))
+ 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}/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))))
- mux.Handle("GET /orgs/{org_id}/equipment/export", app.withLogin(app.withPermission(app.html.Handle(app.getEquipmentExport))))
- mux.Handle("GET /orgs/{org_id}/equipment/import", app.withLogin(app.withPermission(app.html.Handle(app.getEquipmentImport))))
- mux.Handle("GET /orgs/{org_id}/equipment/import/template", app.withLogin(app.withPermission(app.html.Handle(app.getEquipmentImportTemplate))))
- mux.Handle("POST /orgs/{org_id}/equipment/import", app.withLogin(app.withPermission(app.html.Handle(app.postEquipmentImport))))
- mux.Handle("POST /orgs/{org_id}/equipment/import/confirm", app.withLogin(app.withPermission(app.html.Handle(app.postEquipmentImportConfirm))))
mux.Handle("GET /orgs/{org_id}/equipment/new", app.withLogin(app.withPermission(app.html.Handle(app.getEquipmentNew))))
mux.Handle("POST /orgs/{org_id}/equipment/new", app.withLogin(app.withPermission(app.withCheckQuota(app.html.Handle(app.postEquipmentNew)))))
mux.Handle("GET /orgs/{org_id}/equipment/{id}", app.withLogin(app.withPermission(app.html.Handle(app.getEquipmentItem))))
diff --git a/cmd/web/setup.go b/cmd/web/setup.go
index f326c6e..0fdc3aa 100644
--- a/cmd/web/setup.go
+++ b/cmd/web/setup.go
@@ -38,8 +38,8 @@ import (
"github.com/bit8bytes/gearberg/internal/database"
"github.com/bit8bytes/gearberg/internal/database/migrations"
"github.com/bit8bytes/gearberg/internal/equipment"
- invimports "github.com/bit8bytes/gearberg/internal/equipmentimports"
"github.com/bit8bytes/gearberg/internal/federated"
+ imports "github.com/bit8bytes/gearberg/internal/import"
"github.com/bit8bytes/gearberg/internal/locations"
"github.com/bit8bytes/gearberg/internal/manufacturers"
"github.com/bit8bytes/gearberg/internal/orgs"
@@ -122,6 +122,7 @@ func setupSCS(db *sql.DB) (*scs.SessionManager, error) {
// sessionManager abstracts the SCS session store for testing and dependency inversion.
type sessionManager interface {
GetString(ctx context.Context, key string) string
+ PopString(ctx context.Context, key string) string
Put(ctx context.Context, key string, val any)
Destroy(ctx context.Context) error
LoadAndSave(next http.Handler) http.Handler
@@ -140,6 +141,10 @@ func (s scsSession) GetString(ctx context.Context, key string) string {
return s.mgr.GetString(ctx, key)
}
+func (s scsSession) PopString(ctx context.Context, key string) string {
+ return s.mgr.PopString(ctx, key)
+}
+
func (s scsSession) Put(ctx context.Context, key string, val any) {
s.mgr.Put(ctx, key, val)
}
@@ -233,7 +238,7 @@ type services struct {
manufacturers *manufacturers.Service
locations *locations.Service
equipment *equipment.Service
- equipmentImports *invimports.Service
+ equipmentImports *imports.Service
storageManager *storage.Manager
}
@@ -268,8 +273,10 @@ func setupServices(db *sql.DB, opts *options, logger *slog.Logger, m mailer) (*s
inventoryRepo := equipment.NewRepository(db)
inventorySvc := equipment.NewService(inventoryRepo, db)
- equipmentImportsRepo := invimports.NewRepository(db)
- equipmentImportsSvc := invimports.NewService(equipmentImportsRepo, db, inventoryRepo, equipmentcategoriesSvc, manufacturersSvc, locationsSvc)
+ equipmentImportPipeline := equipment.NewImportPipeline(inventorySvc, equipmentcategoriesSvc, manufacturersSvc, locationsSvc)
+
+ importsRepo := imports.NewRepository(db)
+ equipmentImportsSvc := imports.NewService(db, importsRepo, equipmentImportPipeline, equipmentImportPipeline, "equipment")
store, err := storage.Open("local", opts.StorageDSN, logger)
if err != nil {
diff --git a/cmd/web/testutils_test.go b/cmd/web/testutils_test.go
index 2eb0b20..be26e2b 100644
--- a/cmd/web/testutils_test.go
+++ b/cmd/web/testutils_test.go
@@ -71,13 +71,13 @@ func newTestServer(h http.Handler) *testServer {
return &testServer{ts}
}
-func (ts *testServer) signout(t *testing.T) {
+func (ts *testServer) signout(t *testing.T) { //nolint:unused
if code, _, _ := ts.postForm(t, "/signout", url.Values{}); code != http.StatusSeeOther {
t.Fatalf("signout failed: expected redirect, got status %d", code)
}
}
-func (ts *testServer) signin(t *testing.T, email, password string) {
+func (ts *testServer) signin(t *testing.T, email, password string) { //nolint:unused
form := url.Values{}
form.Add("email", email)
form.Add("password", password)
@@ -86,7 +86,7 @@ func (ts *testServer) signin(t *testing.T, email, password string) {
}
}
-func (ts *testServer) get(t *testing.T, urlPath string) (code int, header http.Header, body []byte) {
+func (ts *testServer) get(t *testing.T, urlPath string) (code int, header http.Header, body []byte) { //nolint:unused
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, ts.URL+urlPath, http.NoBody)
if err != nil {
t.Fatal(err)
@@ -106,7 +106,7 @@ func (ts *testServer) get(t *testing.T, urlPath string) (code int, header http.H
return rs.StatusCode, rs.Header, body
}
-func (ts *testServer) postForm(tb testing.TB, urlPath string, form url.Values) (code int, header http.Header, body []byte) {
+func (ts *testServer) postForm(tb testing.TB, urlPath string, form url.Values) (code int, header http.Header, body []byte) { //nolint:unused
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, ts.URL+urlPath, strings.NewReader(form.Encode()))
if err != nil {
tb.Fatal(err)
@@ -135,7 +135,7 @@ type testUser struct {
ts *testServer
}
-func (u *testUser) signin(t *testing.T) {
+func (u *testUser) signin(t *testing.T) { //nolint:unused
t.Helper()
// Always clear any active session first. The /signin route is wrapped with
// the guest middleware, which silently redirects authenticated users to /home
@@ -145,13 +145,13 @@ func (u *testUser) signin(t *testing.T) {
u.ts.signin(t, u.email, u.password)
}
-func (u *testUser) signout(t *testing.T) {
+func (u *testUser) signout(t *testing.T) { //nolint:unused
t.Helper()
u.ts.signout(t)
}
// postFile uploads a single file as a multipart/form-data POST request.
-func (ts *testServer) postFile(t *testing.T, urlPath, fieldName, fileName string, content []byte) (code int, header http.Header, body []byte) {
+func (ts *testServer) postFile(t *testing.T, urlPath, fieldName, fileName string, content []byte) (code int, header http.Header, body []byte) { //nolint:unused
t.Helper()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
diff --git a/internal/assets/dist/index.css b/internal/assets/dist/index.css
index 3f2b8cc..a063139 100644
--- a/internal/assets/dist/index.css
+++ b/internal/assets/dist/index.css
@@ -5687,6 +5687,12 @@
gap: calc(0.25rem * 2);
}
}
+ .mt-0 {
+ margin-top: calc(var(--spacing) * 0);
+ }
+ .mt-0\.5 {
+ margin-top: calc(var(--spacing) * 0.5);
+ }
.mt-1 {
margin-top: calc(var(--spacing) * 1);
}
@@ -6524,6 +6530,14 @@
width: calc(var(--spacing) * 6);
height: calc(var(--spacing) * 6);
}
+ .size-8 {
+ width: calc(var(--spacing) * 8);
+ height: calc(var(--spacing) * 8);
+ }
+ .size-10 {
+ width: calc(var(--spacing) * 10);
+ height: calc(var(--spacing) * 10);
+ }
.status-lg {
@layer daisyui.l1.l2 {
width: calc(0.25rem * 3);
@@ -6602,6 +6616,12 @@
.min-h-24 {
min-height: calc(var(--spacing) * 24);
}
+ .min-h-40 {
+ min-height: calc(var(--spacing) * 40);
+ }
+ .min-h-52 {
+ min-height: calc(var(--spacing) * 52);
+ }
.min-h-\[calc\(100vh-4rem\)\] {
min-height: calc(100vh - 4rem);
}
@@ -7769,6 +7789,12 @@
.bg-base-200 {
background-color: var(--color-base-200);
}
+ .bg-base-200\/40 {
+ background-color: var(--color-base-200);
+ @supports (color: color-mix(in lab, red, red)) {
+ background-color: color-mix(in oklab, var(--color-base-200) 40%, transparent);
+ }
+ }
.bg-base-200\/50 {
background-color: var(--color-base-200);
@supports (color: color-mix(in lab, red, red)) {
@@ -8093,6 +8119,9 @@
}
}
}
+ .p-0 {
+ padding: calc(var(--spacing) * 0);
+ }
.p-1 {
padding: calc(var(--spacing) * 1);
}
@@ -8246,6 +8275,9 @@
.px-6 {
padding-inline: calc(var(--spacing) * 6);
}
+ .px-8 {
+ padding-inline: calc(var(--spacing) * 8);
+ }
.px-10 {
padding-inline: calc(var(--spacing) * 10);
}
@@ -8320,6 +8352,9 @@
.pb-4 {
padding-bottom: calc(var(--spacing) * 4);
}
+ .pb-6 {
+ padding-bottom: calc(var(--spacing) * 6);
+ }
.pl-2 {
padding-left: calc(var(--spacing) * 2);
}
@@ -10053,6 +10088,16 @@
--tw-ring-offset-shadow: var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
}
}
+ .hover\:border-primary\/40 {
+ &:hover {
+ @media (hover: hover) {
+ border-color: var(--color-primary);
+ @supports (color: color-mix(in lab, red, red)) {
+ border-color: color-mix(in oklab, var(--color-primary) 40%, transparent);
+ }
+ }
+ }
+ }
.hover\:bg-base-200 {
&:hover {
@media (hover: hover) {
@@ -10070,6 +10115,16 @@
}
}
}
+ .hover\:bg-base-200\/80 {
+ &:hover {
+ @media (hover: hover) {
+ background-color: var(--color-base-200);
+ @supports (color: color-mix(in lab, red, red)) {
+ background-color: color-mix(in oklab, var(--color-base-200) 80%, transparent);
+ }
+ }
+ }
+ }
.hover\:underline {
&:hover {
@media (hover: hover) {
diff --git a/internal/assets/dist/index.js b/internal/assets/dist/index.js
index 6b7dfb4..e6add92 100644
--- a/internal/assets/dist/index.js
+++ b/internal/assets/dist/index.js
@@ -319,4 +319,11 @@ document.addEventListener("click", (e) => {
btn.textContent = isPassword ? "Hide" : "Show";
btn.setAttribute("aria-pressed", isPassword ? "true" : "false");
btn.setAttribute("aria-label", isPassword ? "Hide password" : "Show password");
+});
+
+// Auto-dismiss a toast after N seconds
+// Trigger:
+document.querySelectorAll("[data-dismiss-after]").forEach((el) => {
+ const delay = (parseInt(el.dataset.dismissAfter, 10) || 4) * 1000;
+ setTimeout(() => el.remove(), delay);
});
\ No newline at end of file
diff --git a/internal/database/migrations/sqlite/00020_equipment_imports.sql b/internal/database/migrations/sqlite/00020_equipment_imports.sql
deleted file mode 100644
index cc0d0b7..0000000
--- a/internal/database/migrations/sqlite/00020_equipment_imports.sql
+++ /dev/null
@@ -1,52 +0,0 @@
--- +goose Up
--- +goose StatementBegin
-CREATE TABLE equipment_imports (
- id TEXT NOT NULL PRIMARY KEY,
- import_id TEXT NOT NULL,
- org_id TEXT NOT NULL,
- row_number INTEGER NOT NULL,
- status TEXT NOT NULL,
- error_message TEXT NOT NULL DEFAULT '',
- action TEXT NOT NULL DEFAULT 'create',
- existing_equipment_id TEXT,
- existing_item_id TEXT,
- created_at INTEGER NOT NULL DEFAULT (unixepoch()),
- -- raw CSV columns; all TEXT to preserve input before validation
- name TEXT NOT NULL DEFAULT '',
- -- Labels are defined by Gearberg and should match the seeded once
- type_label TEXT NOT NULL DEFAULT '', -- Bulk/Serialized
- tracking_label TEXT NOT NULL DEFAULT '', -- Physical/Virtual
- usage_type_label TEXT NOT NULL DEFAULT '', -- Rental/Resale
- -- User provided fields:
- category_name TEXT NOT NULL DEFAULT '',
- manufacturer_name TEXT NOT NULL DEFAULT '',
- location_name TEXT NOT NULL DEFAULT '',
- purchase_price TEXT NOT NULL DEFAULT '',
- rental_price TEXT NOT NULL DEFAULT '',
- resale_price TEXT NOT NULL DEFAULT '',
- notes TEXT NOT NULL DEFAULT '',
- weight_g TEXT NOT NULL DEFAULT '',
- width_mm TEXT NOT NULL DEFAULT '',
- height_mm TEXT NOT NULL DEFAULT '',
- depth_mm TEXT NOT NULL DEFAULT '',
- voltage_mv TEXT NOT NULL DEFAULT '',
- current_ma TEXT NOT NULL DEFAULT '',
- power_mw TEXT NOT NULL DEFAULT '',
- wire_gauge_mm2_x100 TEXT NOT NULL DEFAULT '',
- quantity TEXT NOT NULL DEFAULT '1',
- equipment_type_label TEXT NOT NULL DEFAULT '',
- -- Units for serialized equipment items
- unit_serial_number TEXT NOT NULL DEFAULT '',
- unit_manufacturer_serial TEXT NOT NULL DEFAULT '',
- unit_purchase_price TEXT NOT NULL DEFAULT '',
- unit_purchased_at TEXT NOT NULL DEFAULT '',
- next_inspection_at TEXT NOT NULL DEFAULT '',
- unit_is_active TEXT NOT NULL DEFAULT '1',
- unit_remark TEXT NOT NULL DEFAULT ''
-) STRICT;
--- +goose StatementEnd
-
--- +goose Down
--- +goose StatementBegin
-DROP TABLE IF EXISTS equipment_imports;
--- +goose StatementEnd
diff --git a/internal/database/migrations/sqlite/00025_import_sessions.sql b/internal/database/migrations/sqlite/00025_import_sessions.sql
new file mode 100644
index 0000000..3554b2c
--- /dev/null
+++ b/internal/database/migrations/sqlite/00025_import_sessions.sql
@@ -0,0 +1,23 @@
+-- +goose Up
+-- +goose StatementBegin
+CREATE TABLE import_sessions (
+ id TEXT NOT NULL PRIMARY KEY,
+ org_id TEXT NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
+ format TEXT NOT NULL, -- csv | json | excel
+ status TEXT NOT NULL DEFAULT 'draft'
+ CHECK (status IN (
+ 'draft', -- uploaded, awaiting column mapping
+ 'mapped', -- mappings saved, awaiting validation
+ 'ready', -- validated, awaiting user commit
+ 'committed' -- import written to inventory
+ )),
+ target_entity TEXT NOT NULL, -- equipment
+ updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
+ created_at INTEGER NOT NULL DEFAULT (unixepoch())
+) STRICT;
+-- +goose StatementEnd
+
+-- +goose Down
+-- +goose StatementBegin
+DROP TABLE IF EXISTS import_sessions;
+-- +goose StatementEnd
diff --git a/internal/database/migrations/sqlite/00026_import_data.sql b/internal/database/migrations/sqlite/00026_import_data.sql
new file mode 100644
index 0000000..fdda67c
--- /dev/null
+++ b/internal/database/migrations/sqlite/00026_import_data.sql
@@ -0,0 +1,22 @@
+-- +goose Up
+-- +goose StatementBegin
+CREATE TABLE import_data (
+ id TEXT NOT NULL PRIMARY KEY,
+ session_id TEXT NOT NULL REFERENCES import_sessions(id) ON DELETE CASCADE,
+ row_number INTEGER NOT NULL CHECK (row_number > 0),
+ data TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(data)),
+ status TEXT NOT NULL DEFAULT 'new'
+ CHECK (status IN ('new', 'valid', 'error')),
+ error_message TEXT NOT NULL DEFAULT '',
+ action TEXT NOT NULL DEFAULT 'pending'
+ CHECK (action IN ('pending', 'create', 'skip')),
+ updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
+ created_at INTEGER NOT NULL DEFAULT (unixepoch()),
+ UNIQUE (session_id, row_number)
+) STRICT;
+-- +goose StatementEnd
+
+-- +goose Down
+-- +goose StatementBegin
+DROP TABLE IF EXISTS import_data;
+-- +goose StatementEnd
diff --git a/internal/database/migrations/sqlite/00027_import_mappings.sql b/internal/database/migrations/sqlite/00027_import_mappings.sql
new file mode 100644
index 0000000..41a798a
--- /dev/null
+++ b/internal/database/migrations/sqlite/00027_import_mappings.sql
@@ -0,0 +1,17 @@
+-- +goose Up
+-- +goose StatementBegin
+CREATE TABLE import_mappings (
+ id TEXT NOT NULL PRIMARY KEY,
+ session_id TEXT NOT NULL REFERENCES import_sessions(id) ON DELETE CASCADE,
+ source_col TEXT NOT NULL, -- user column name e.g. "Bezeichnung"
+ target_field TEXT NOT NULL, -- internal field name e.g. "equipment.name"
+ UNIQUE (session_id, source_col)
+) STRICT;
+
+CREATE INDEX import_mappings_session_id_idx ON import_mappings (session_id);
+-- +goose StatementEnd
+
+-- +goose Down
+-- +goose StatementBegin
+DROP TABLE IF EXISTS import_mappings;
+-- +goose StatementEnd
diff --git a/internal/database/queries/gen/equipment/equipment.sql.go b/internal/database/queries/gen/equipment/equipment.sql.go
index cb8dd26..add2df3 100644
--- a/internal/database/queries/gen/equipment/equipment.sql.go
+++ b/internal/database/queries/gen/equipment/equipment.sql.go
@@ -208,6 +208,104 @@ func (q *Queries) Delete(ctx context.Context, id string) error {
return err
}
+const export = `-- name: Export :many
+SELECT
+ e.id,
+ e.name,
+ COALESCE(tt.name, '') AS tracking_type,
+ COALESCE(ut.name, '') AS usage_type,
+ COALESCE(ec.name, '') AS category,
+ COALESCE(em.name, '') AS manufacturer,
+ COALESCE(wl.name, '') AS location,
+ COALESCE(et.name, '') AS equipment_type,
+ e.rental_price,
+ e.resale_price,
+ e.notes,
+ e.weight_g,
+ e.width_mm,
+ e.height_mm,
+ e.depth_mm,
+ e.voltage_mv,
+ e.current_ma,
+ e.power_mw,
+ e.wire_gauge_mm2_x100
+FROM equipment e
+LEFT JOIN equipment_categories ec ON ec.id = e.category_id
+LEFT JOIN equipment_manufacturers em ON em.id = e.manufacturer_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 usage_types ut ON ut.id = e.usage_type_id
+WHERE e.org_id = ?1
+ AND e.is_archived = 0
+ORDER BY ec.name ASC, e.name ASC
+`
+
+type ExportRow struct {
+ ID string
+ Name string
+ TrackingType string
+ UsageType string
+ Category string
+ Manufacturer string
+ Location string
+ EquipmentType string
+ 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
+}
+
+func (q *Queries) Export(ctx context.Context, orgID string) ([]ExportRow, error) {
+ rows, err := q.db.QueryContext(ctx, export, orgID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []ExportRow
+ for rows.Next() {
+ var i ExportRow
+ if err := rows.Scan(
+ &i.ID,
+ &i.Name,
+ &i.TrackingType,
+ &i.UsageType,
+ &i.Category,
+ &i.Manufacturer,
+ &i.Location,
+ &i.EquipmentType,
+ &i.RentalPrice,
+ &i.ResalePrice,
+ &i.Notes,
+ &i.WeightG,
+ &i.WidthMm,
+ &i.HeightMm,
+ &i.DepthMm,
+ &i.VoltageMv,
+ &i.CurrentMa,
+ &i.PowerMw,
+ &i.WireGaugeMm2X100,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
const getByID = `-- name: GetByID :one
SELECT
e.id,
diff --git a/internal/database/queries/gen/equipment/querier.go b/internal/database/queries/gen/equipment/querier.go
index 950de1a..3aad71b 100644
--- a/internal/database/queries/gen/equipment/querier.go
+++ b/internal/database/queries/gen/equipment/querier.go
@@ -12,6 +12,7 @@ type Querier interface {
CountByOrgID(ctx context.Context, orgID string) (int64, error)
Create(ctx context.Context, arg CreateParams) (CreateRow, error)
Delete(ctx context.Context, id string) error
+ Export(ctx context.Context, orgID string) ([]ExportRow, error)
GetByID(ctx context.Context, id string) (GetByIDRow, error)
List(ctx context.Context, arg ListParams) ([]ListRow, error)
ListBySerialNumber(ctx context.Context, arg ListBySerialNumberParams) ([]ListBySerialNumberRow, error)
diff --git a/internal/database/queries/gen/equipmentimports/equipment_imports.sql.go b/internal/database/queries/gen/equipmentimports/equipment_imports.sql.go
deleted file mode 100644
index 38bbbd0..0000000
--- a/internal/database/queries/gen/equipmentimports/equipment_imports.sql.go
+++ /dev/null
@@ -1,344 +0,0 @@
-// Code generated by sqlc. DO NOT EDIT.
-// versions:
-// sqlc v1.31.1
-// source: equipment_imports.sql
-
-package equipmentimports
-
-import (
- "context"
- "database/sql"
-)
-
-const deleteImportsByImportID = `-- name: DeleteImportsByImportID :exec
-DELETE FROM equipment_imports WHERE import_id = ?
-`
-
-func (q *Queries) DeleteImportsByImportID(ctx context.Context, importID string) error {
- _, err := q.db.ExecContext(ctx, deleteImportsByImportID, importID)
- return err
-}
-
-const deleteImportsByOrgID = `-- name: DeleteImportsByOrgID :exec
-DELETE FROM equipment_imports WHERE org_id = ?
-`
-
-func (q *Queries) DeleteImportsByOrgID(ctx context.Context, orgID string) error {
- _, err := q.db.ExecContext(ctx, deleteImportsByOrgID, orgID)
- return err
-}
-
-const getImportRow = `-- name: GetImportRow :one
-SELECT id, import_id, org_id, row_number, status, error_message, "action", existing_equipment_id, existing_item_id, created_at, name, type_label, tracking_label, usage_type_label, category_name, manufacturer_name, location_name, purchase_price, rental_price, resale_price, notes, weight_g, width_mm, height_mm, depth_mm, voltage_mv, current_ma, power_mw, wire_gauge_mm2_x100, quantity, equipment_type_label, unit_serial_number, unit_manufacturer_serial, unit_purchase_price, unit_purchased_at, next_inspection_at, unit_is_active, unit_remark FROM equipment_imports WHERE id = ?
-`
-
-func (q *Queries) GetImportRow(ctx context.Context, id string) (EquipmentImport, error) {
- row := q.db.QueryRowContext(ctx, getImportRow, id)
- var i EquipmentImport
- err := row.Scan(
- &i.ID,
- &i.ImportID,
- &i.OrgID,
- &i.RowNumber,
- &i.Status,
- &i.ErrorMessage,
- &i.Action,
- &i.ExistingEquipmentID,
- &i.ExistingItemID,
- &i.CreatedAt,
- &i.Name,
- &i.TypeLabel,
- &i.TrackingLabel,
- &i.UsageTypeLabel,
- &i.CategoryName,
- &i.ManufacturerName,
- &i.LocationName,
- &i.PurchasePrice,
- &i.RentalPrice,
- &i.ResalePrice,
- &i.Notes,
- &i.WeightG,
- &i.WidthMm,
- &i.HeightMm,
- &i.DepthMm,
- &i.VoltageMv,
- &i.CurrentMa,
- &i.PowerMw,
- &i.WireGaugeMm2X100,
- &i.Quantity,
- &i.EquipmentTypeLabel,
- &i.UnitSerialNumber,
- &i.UnitManufacturerSerial,
- &i.UnitPurchasePrice,
- &i.UnitPurchasedAt,
- &i.NextInspectionAt,
- &i.UnitIsActive,
- &i.UnitRemark,
- )
- return i, err
-}
-
-const insertImportRow = `-- name: InsertImportRow :one
-INSERT INTO equipment_imports (
- id,
- import_id,
- org_id,
- row_number,
- status,
- error_message,
- action,
- existing_equipment_id,
- existing_item_id,
- name,
- type_label,
- tracking_label,
- usage_type_label,
- category_name,
- manufacturer_name,
- location_name,
- purchase_price,
- rental_price,
- resale_price,
- notes,
- weight_g,
- width_mm,
- height_mm,
- depth_mm,
- voltage_mv,
- current_ma,
- power_mw,
- wire_gauge_mm2_x100,
- quantity,
- equipment_type_label,
- unit_serial_number,
- unit_manufacturer_serial,
- unit_purchase_price,
- unit_purchased_at,
- next_inspection_at,
- unit_is_active,
- unit_remark
-) VALUES (
- ?, ?, ?, ?, ?, ?, ?, ?, ?,
- ?, ?, ?, ?, ?, ?, ?, ?, ?,
- ?, ?, ?, ?, ?, ?, ?, ?, ?,
- ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
-) RETURNING id, import_id, org_id, row_number, status, error_message, "action", existing_equipment_id, existing_item_id, created_at, name, type_label, tracking_label, usage_type_label, category_name, manufacturer_name, location_name, purchase_price, rental_price, resale_price, notes, weight_g, width_mm, height_mm, depth_mm, voltage_mv, current_ma, power_mw, wire_gauge_mm2_x100, quantity, equipment_type_label, unit_serial_number, unit_manufacturer_serial, unit_purchase_price, unit_purchased_at, next_inspection_at, unit_is_active, unit_remark
-`
-
-type InsertImportRowParams struct {
- ID string
- ImportID string
- OrgID string
- RowNumber int64
- Status string
- ErrorMessage string
- Action string
- ExistingEquipmentID sql.NullString
- ExistingItemID sql.NullString
- Name string
- TypeLabel string
- TrackingLabel string
- UsageTypeLabel string
- CategoryName string
- ManufacturerName string
- LocationName string
- PurchasePrice string
- RentalPrice string
- ResalePrice string
- Notes string
- WeightG string
- WidthMm string
- HeightMm string
- DepthMm string
- VoltageMv string
- CurrentMa string
- PowerMw string
- WireGaugeMm2X100 string
- Quantity string
- EquipmentTypeLabel string
- UnitSerialNumber string
- UnitManufacturerSerial string
- UnitPurchasePrice string
- UnitPurchasedAt string
- NextInspectionAt string
- UnitIsActive string
- UnitRemark string
-}
-
-func (q *Queries) InsertImportRow(ctx context.Context, arg InsertImportRowParams) (EquipmentImport, error) {
- row := q.db.QueryRowContext(ctx, insertImportRow,
- arg.ID,
- arg.ImportID,
- arg.OrgID,
- arg.RowNumber,
- arg.Status,
- arg.ErrorMessage,
- arg.Action,
- arg.ExistingEquipmentID,
- arg.ExistingItemID,
- arg.Name,
- arg.TypeLabel,
- arg.TrackingLabel,
- arg.UsageTypeLabel,
- arg.CategoryName,
- arg.ManufacturerName,
- arg.LocationName,
- arg.PurchasePrice,
- arg.RentalPrice,
- arg.ResalePrice,
- arg.Notes,
- arg.WeightG,
- arg.WidthMm,
- arg.HeightMm,
- arg.DepthMm,
- arg.VoltageMv,
- arg.CurrentMa,
- arg.PowerMw,
- arg.WireGaugeMm2X100,
- arg.Quantity,
- arg.EquipmentTypeLabel,
- arg.UnitSerialNumber,
- arg.UnitManufacturerSerial,
- arg.UnitPurchasePrice,
- arg.UnitPurchasedAt,
- arg.NextInspectionAt,
- arg.UnitIsActive,
- arg.UnitRemark,
- )
- var i EquipmentImport
- err := row.Scan(
- &i.ID,
- &i.ImportID,
- &i.OrgID,
- &i.RowNumber,
- &i.Status,
- &i.ErrorMessage,
- &i.Action,
- &i.ExistingEquipmentID,
- &i.ExistingItemID,
- &i.CreatedAt,
- &i.Name,
- &i.TypeLabel,
- &i.TrackingLabel,
- &i.UsageTypeLabel,
- &i.CategoryName,
- &i.ManufacturerName,
- &i.LocationName,
- &i.PurchasePrice,
- &i.RentalPrice,
- &i.ResalePrice,
- &i.Notes,
- &i.WeightG,
- &i.WidthMm,
- &i.HeightMm,
- &i.DepthMm,
- &i.VoltageMv,
- &i.CurrentMa,
- &i.PowerMw,
- &i.WireGaugeMm2X100,
- &i.Quantity,
- &i.EquipmentTypeLabel,
- &i.UnitSerialNumber,
- &i.UnitManufacturerSerial,
- &i.UnitPurchasePrice,
- &i.UnitPurchasedAt,
- &i.NextInspectionAt,
- &i.UnitIsActive,
- &i.UnitRemark,
- )
- return i, err
-}
-
-const listImportRowsByImportID = `-- name: ListImportRowsByImportID :many
-SELECT id, import_id, org_id, row_number, status, error_message, "action", existing_equipment_id, existing_item_id, created_at, name, type_label, tracking_label, usage_type_label, category_name, manufacturer_name, location_name, purchase_price, rental_price, resale_price, notes, weight_g, width_mm, height_mm, depth_mm, voltage_mv, current_ma, power_mw, wire_gauge_mm2_x100, quantity, equipment_type_label, unit_serial_number, unit_manufacturer_serial, unit_purchase_price, unit_purchased_at, next_inspection_at, unit_is_active, unit_remark FROM equipment_imports WHERE import_id = ? ORDER BY row_number ASC
-`
-
-func (q *Queries) ListImportRowsByImportID(ctx context.Context, importID string) ([]EquipmentImport, error) {
- rows, err := q.db.QueryContext(ctx, listImportRowsByImportID, importID)
- if err != nil {
- return nil, err
- }
- defer rows.Close()
- var items []EquipmentImport
- for rows.Next() {
- var i EquipmentImport
- if err := rows.Scan(
- &i.ID,
- &i.ImportID,
- &i.OrgID,
- &i.RowNumber,
- &i.Status,
- &i.ErrorMessage,
- &i.Action,
- &i.ExistingEquipmentID,
- &i.ExistingItemID,
- &i.CreatedAt,
- &i.Name,
- &i.TypeLabel,
- &i.TrackingLabel,
- &i.UsageTypeLabel,
- &i.CategoryName,
- &i.ManufacturerName,
- &i.LocationName,
- &i.PurchasePrice,
- &i.RentalPrice,
- &i.ResalePrice,
- &i.Notes,
- &i.WeightG,
- &i.WidthMm,
- &i.HeightMm,
- &i.DepthMm,
- &i.VoltageMv,
- &i.CurrentMa,
- &i.PowerMw,
- &i.WireGaugeMm2X100,
- &i.Quantity,
- &i.EquipmentTypeLabel,
- &i.UnitSerialNumber,
- &i.UnitManufacturerSerial,
- &i.UnitPurchasePrice,
- &i.UnitPurchasedAt,
- &i.NextInspectionAt,
- &i.UnitIsActive,
- &i.UnitRemark,
- ); err != nil {
- return nil, err
- }
- items = append(items, i)
- }
- if err := rows.Close(); err != nil {
- return nil, err
- }
- if err := rows.Err(); err != nil {
- return nil, err
- }
- return items, nil
-}
-
-const updateImportRowAction = `-- name: UpdateImportRowAction :exec
-UPDATE equipment_imports SET action = ? WHERE id = ?
-`
-
-type UpdateImportRowActionParams struct {
- Action string
- ID string
-}
-
-func (q *Queries) UpdateImportRowAction(ctx context.Context, arg UpdateImportRowActionParams) error {
- _, err := q.db.ExecContext(ctx, updateImportRowAction, arg.Action, arg.ID)
- return err
-}
-
-const updateImportRowSerialNumber = `-- name: UpdateImportRowSerialNumber :exec
-UPDATE equipment_imports SET unit_serial_number = ? WHERE id = ?
-`
-
-type UpdateImportRowSerialNumberParams struct {
- UnitSerialNumber string
- ID string
-}
-
-func (q *Queries) UpdateImportRowSerialNumber(ctx context.Context, arg UpdateImportRowSerialNumberParams) error {
- _, err := q.db.ExecContext(ctx, updateImportRowSerialNumber, arg.UnitSerialNumber, arg.ID)
- return err
-}
diff --git a/internal/database/queries/gen/equipmentimports/models.go b/internal/database/queries/gen/equipmentimports/models.go
deleted file mode 100644
index 5f0f453..0000000
--- a/internal/database/queries/gen/equipmentimports/models.go
+++ /dev/null
@@ -1,50 +0,0 @@
-// Code generated by sqlc. DO NOT EDIT.
-// versions:
-// sqlc v1.31.1
-
-package equipmentimports
-
-import (
- "database/sql"
-)
-
-type EquipmentImport struct {
- ID string
- ImportID string
- OrgID string
- RowNumber int64
- Status string
- ErrorMessage string
- Action string
- ExistingEquipmentID sql.NullString
- ExistingItemID sql.NullString
- CreatedAt int64
- Name string
- TypeLabel string
- TrackingLabel string
- UsageTypeLabel string
- CategoryName string
- ManufacturerName string
- LocationName string
- PurchasePrice string
- RentalPrice string
- ResalePrice string
- Notes string
- WeightG string
- WidthMm string
- HeightMm string
- DepthMm string
- VoltageMv string
- CurrentMa string
- PowerMw string
- WireGaugeMm2X100 string
- Quantity string
- EquipmentTypeLabel string
- UnitSerialNumber string
- UnitManufacturerSerial string
- UnitPurchasePrice string
- UnitPurchasedAt string
- NextInspectionAt string
- UnitIsActive string
- UnitRemark string
-}
diff --git a/internal/database/queries/gen/equipmentimports/querier.go b/internal/database/queries/gen/equipmentimports/querier.go
deleted file mode 100644
index 0dd37e6..0000000
--- a/internal/database/queries/gen/equipmentimports/querier.go
+++ /dev/null
@@ -1,21 +0,0 @@
-// Code generated by sqlc. DO NOT EDIT.
-// versions:
-// sqlc v1.31.1
-
-package equipmentimports
-
-import (
- "context"
-)
-
-type Querier interface {
- DeleteImportsByImportID(ctx context.Context, importID string) error
- DeleteImportsByOrgID(ctx context.Context, orgID string) error
- GetImportRow(ctx context.Context, id string) (EquipmentImport, error)
- InsertImportRow(ctx context.Context, arg InsertImportRowParams) (EquipmentImport, error)
- ListImportRowsByImportID(ctx context.Context, importID string) ([]EquipmentImport, error)
- UpdateImportRowAction(ctx context.Context, arg UpdateImportRowActionParams) error
- UpdateImportRowSerialNumber(ctx context.Context, arg UpdateImportRowSerialNumberParams) error
-}
-
-var _ Querier = (*Queries)(nil)
diff --git a/internal/database/queries/gen/equipmentimports/db.go b/internal/database/queries/gen/importdata/db.go
similarity index 95%
rename from internal/database/queries/gen/equipmentimports/db.go
rename to internal/database/queries/gen/importdata/db.go
index 6d8a203..119dea5 100644
--- a/internal/database/queries/gen/equipmentimports/db.go
+++ b/internal/database/queries/gen/importdata/db.go
@@ -2,7 +2,7 @@
// versions:
// sqlc v1.31.1
-package equipmentimports
+package importdata
import (
"context"
diff --git a/internal/database/queries/gen/importdata/import_data.sql.go b/internal/database/queries/gen/importdata/import_data.sql.go
new file mode 100644
index 0000000..e8e9aa0
--- /dev/null
+++ b/internal/database/queries/gen/importdata/import_data.sql.go
@@ -0,0 +1,219 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.31.1
+// source: import_data.sql
+
+package importdata
+
+import (
+ "context"
+)
+
+const deleteDataBySession = `-- name: DeleteDataBySession :exec
+DELETE FROM import_data
+WHERE session_id = ?
+`
+
+func (q *Queries) DeleteDataBySession(ctx context.Context, sessionID string) error {
+ _, err := q.db.ExecContext(ctx, deleteDataBySession, sessionID)
+ return err
+}
+
+const getData = `-- name: GetData :one
+SELECT id, session_id, row_number, data, status, error_message, "action", updated_at, created_at FROM import_data
+WHERE id = ?
+`
+
+func (q *Queries) GetData(ctx context.Context, id string) (ImportDatum, error) {
+ row := q.db.QueryRowContext(ctx, getData, id)
+ var i ImportDatum
+ err := row.Scan(
+ &i.ID,
+ &i.SessionID,
+ &i.RowNumber,
+ &i.Data,
+ &i.Status,
+ &i.ErrorMessage,
+ &i.Action,
+ &i.UpdatedAt,
+ &i.CreatedAt,
+ )
+ return i, err
+}
+
+const insertData = `-- name: InsertData :one
+INSERT INTO import_data (id, session_id, row_number, data)
+VALUES (?, ?, ?, ?)
+RETURNING id, session_id, row_number, data, status, error_message, "action", updated_at, created_at
+`
+
+type InsertDataParams struct {
+ ID string
+ SessionID string
+ RowNumber int64
+ Data string
+}
+
+func (q *Queries) InsertData(ctx context.Context, arg InsertDataParams) (ImportDatum, error) {
+ row := q.db.QueryRowContext(ctx, insertData,
+ arg.ID,
+ arg.SessionID,
+ arg.RowNumber,
+ arg.Data,
+ )
+ var i ImportDatum
+ err := row.Scan(
+ &i.ID,
+ &i.SessionID,
+ &i.RowNumber,
+ &i.Data,
+ &i.Status,
+ &i.ErrorMessage,
+ &i.Action,
+ &i.UpdatedAt,
+ &i.CreatedAt,
+ )
+ return i, err
+}
+
+const listData = `-- name: ListData :many
+SELECT id, session_id, row_number, data, status, error_message, "action", updated_at, created_at FROM import_data
+WHERE session_id = ?
+ORDER BY row_number ASC
+`
+
+func (q *Queries) ListData(ctx context.Context, sessionID string) ([]ImportDatum, error) {
+ rows, err := q.db.QueryContext(ctx, listData, sessionID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []ImportDatum
+ for rows.Next() {
+ var i ImportDatum
+ if err := rows.Scan(
+ &i.ID,
+ &i.SessionID,
+ &i.RowNumber,
+ &i.Data,
+ &i.Status,
+ &i.ErrorMessage,
+ &i.Action,
+ &i.UpdatedAt,
+ &i.CreatedAt,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const listDataByAction = `-- name: ListDataByAction :many
+SELECT id, session_id, row_number, data, status, error_message, "action", updated_at, created_at FROM import_data
+WHERE session_id = ? AND action = ?
+ORDER BY row_number ASC
+`
+
+type ListDataByActionParams struct {
+ SessionID string
+ Action string
+}
+
+func (q *Queries) ListDataByAction(ctx context.Context, arg ListDataByActionParams) ([]ImportDatum, error) {
+ rows, err := q.db.QueryContext(ctx, listDataByAction, arg.SessionID, arg.Action)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []ImportDatum
+ for rows.Next() {
+ var i ImportDatum
+ if err := rows.Scan(
+ &i.ID,
+ &i.SessionID,
+ &i.RowNumber,
+ &i.Data,
+ &i.Status,
+ &i.ErrorMessage,
+ &i.Action,
+ &i.UpdatedAt,
+ &i.CreatedAt,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const updateDataAction = `-- name: UpdateDataAction :one
+UPDATE import_data
+SET action = ?, updated_at = unixepoch()
+WHERE id = ?
+RETURNING id, session_id, row_number, data, status, error_message, "action", updated_at, created_at
+`
+
+type UpdateDataActionParams struct {
+ Action string
+ ID string
+}
+
+func (q *Queries) UpdateDataAction(ctx context.Context, arg UpdateDataActionParams) (ImportDatum, error) {
+ row := q.db.QueryRowContext(ctx, updateDataAction, arg.Action, arg.ID)
+ var i ImportDatum
+ err := row.Scan(
+ &i.ID,
+ &i.SessionID,
+ &i.RowNumber,
+ &i.Data,
+ &i.Status,
+ &i.ErrorMessage,
+ &i.Action,
+ &i.UpdatedAt,
+ &i.CreatedAt,
+ )
+ return i, err
+}
+
+const updateDataStatus = `-- name: UpdateDataStatus :one
+UPDATE import_data
+SET status = ?, error_message = ?, updated_at = unixepoch()
+WHERE id = ?
+RETURNING id, session_id, row_number, data, status, error_message, "action", updated_at, created_at
+`
+
+type UpdateDataStatusParams struct {
+ Status string
+ ErrorMessage string
+ ID string
+}
+
+func (q *Queries) UpdateDataStatus(ctx context.Context, arg UpdateDataStatusParams) (ImportDatum, error) {
+ row := q.db.QueryRowContext(ctx, updateDataStatus, arg.Status, arg.ErrorMessage, arg.ID)
+ var i ImportDatum
+ err := row.Scan(
+ &i.ID,
+ &i.SessionID,
+ &i.RowNumber,
+ &i.Data,
+ &i.Status,
+ &i.ErrorMessage,
+ &i.Action,
+ &i.UpdatedAt,
+ &i.CreatedAt,
+ )
+ return i, err
+}
diff --git a/internal/database/queries/gen/importdata/models.go b/internal/database/queries/gen/importdata/models.go
new file mode 100644
index 0000000..dd0d16e
--- /dev/null
+++ b/internal/database/queries/gen/importdata/models.go
@@ -0,0 +1,17 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.31.1
+
+package importdata
+
+type ImportDatum struct {
+ ID string
+ SessionID string
+ RowNumber int64
+ Data string
+ Status string
+ ErrorMessage string
+ Action string
+ UpdatedAt int64
+ CreatedAt int64
+}
diff --git a/internal/database/queries/gen/importdata/querier.go b/internal/database/queries/gen/importdata/querier.go
new file mode 100644
index 0000000..6f38817
--- /dev/null
+++ b/internal/database/queries/gen/importdata/querier.go
@@ -0,0 +1,21 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.31.1
+
+package importdata
+
+import (
+ "context"
+)
+
+type Querier interface {
+ DeleteDataBySession(ctx context.Context, sessionID string) error
+ GetData(ctx context.Context, id string) (ImportDatum, error)
+ InsertData(ctx context.Context, arg InsertDataParams) (ImportDatum, error)
+ ListData(ctx context.Context, sessionID string) ([]ImportDatum, error)
+ ListDataByAction(ctx context.Context, arg ListDataByActionParams) ([]ImportDatum, error)
+ UpdateDataAction(ctx context.Context, arg UpdateDataActionParams) (ImportDatum, error)
+ UpdateDataStatus(ctx context.Context, arg UpdateDataStatusParams) (ImportDatum, error)
+}
+
+var _ Querier = (*Queries)(nil)
diff --git a/internal/database/queries/gen/importmappings/db.go b/internal/database/queries/gen/importmappings/db.go
new file mode 100644
index 0000000..e93767f
--- /dev/null
+++ b/internal/database/queries/gen/importmappings/db.go
@@ -0,0 +1,31 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.31.1
+
+package importmappings
+
+import (
+ "context"
+ "database/sql"
+)
+
+type DBTX interface {
+ ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
+ PrepareContext(context.Context, string) (*sql.Stmt, error)
+ QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
+ QueryRowContext(context.Context, string, ...interface{}) *sql.Row
+}
+
+func New(db DBTX) *Queries {
+ return &Queries{db: db}
+}
+
+type Queries struct {
+ db DBTX
+}
+
+func (q *Queries) WithTx(tx *sql.Tx) *Queries {
+ return &Queries{
+ db: tx,
+ }
+}
diff --git a/internal/database/queries/gen/importmappings/import_mappings.sql.go b/internal/database/queries/gen/importmappings/import_mappings.sql.go
new file mode 100644
index 0000000..82b1230
--- /dev/null
+++ b/internal/database/queries/gen/importmappings/import_mappings.sql.go
@@ -0,0 +1,84 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.31.1
+// source: import_mappings.sql
+
+package importmappings
+
+import (
+ "context"
+)
+
+const deleteMappingsBySession = `-- name: DeleteMappingsBySession :exec
+DELETE FROM import_mappings
+WHERE session_id = ?
+`
+
+func (q *Queries) DeleteMappingsBySession(ctx context.Context, sessionID string) error {
+ _, err := q.db.ExecContext(ctx, deleteMappingsBySession, sessionID)
+ return err
+}
+
+const insertMapping = `-- name: InsertMapping :one
+INSERT INTO import_mappings (id, session_id, source_col, target_field)
+VALUES (?, ?, ?, ?)
+RETURNING id, session_id, source_col, target_field
+`
+
+type InsertMappingParams struct {
+ ID string
+ SessionID string
+ SourceCol string
+ TargetField string
+}
+
+func (q *Queries) InsertMapping(ctx context.Context, arg InsertMappingParams) (ImportMapping, error) {
+ row := q.db.QueryRowContext(ctx, insertMapping,
+ arg.ID,
+ arg.SessionID,
+ arg.SourceCol,
+ arg.TargetField,
+ )
+ var i ImportMapping
+ err := row.Scan(
+ &i.ID,
+ &i.SessionID,
+ &i.SourceCol,
+ &i.TargetField,
+ )
+ return i, err
+}
+
+const listMappings = `-- name: ListMappings :many
+SELECT id, session_id, source_col, target_field FROM import_mappings
+WHERE session_id = ?
+ORDER BY source_col ASC
+`
+
+func (q *Queries) ListMappings(ctx context.Context, sessionID string) ([]ImportMapping, error) {
+ rows, err := q.db.QueryContext(ctx, listMappings, sessionID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []ImportMapping
+ for rows.Next() {
+ var i ImportMapping
+ if err := rows.Scan(
+ &i.ID,
+ &i.SessionID,
+ &i.SourceCol,
+ &i.TargetField,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
diff --git a/internal/database/queries/gen/importmappings/models.go b/internal/database/queries/gen/importmappings/models.go
new file mode 100644
index 0000000..343ba86
--- /dev/null
+++ b/internal/database/queries/gen/importmappings/models.go
@@ -0,0 +1,12 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.31.1
+
+package importmappings
+
+type ImportMapping struct {
+ ID string
+ SessionID string
+ SourceCol string
+ TargetField string
+}
diff --git a/internal/database/queries/gen/importmappings/querier.go b/internal/database/queries/gen/importmappings/querier.go
new file mode 100644
index 0000000..cc7a741
--- /dev/null
+++ b/internal/database/queries/gen/importmappings/querier.go
@@ -0,0 +1,17 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.31.1
+
+package importmappings
+
+import (
+ "context"
+)
+
+type Querier interface {
+ DeleteMappingsBySession(ctx context.Context, sessionID string) error
+ InsertMapping(ctx context.Context, arg InsertMappingParams) (ImportMapping, error)
+ ListMappings(ctx context.Context, sessionID string) ([]ImportMapping, error)
+}
+
+var _ Querier = (*Queries)(nil)
diff --git a/internal/database/queries/gen/importsessions/db.go b/internal/database/queries/gen/importsessions/db.go
new file mode 100644
index 0000000..1ad75c1
--- /dev/null
+++ b/internal/database/queries/gen/importsessions/db.go
@@ -0,0 +1,31 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.31.1
+
+package importsessions
+
+import (
+ "context"
+ "database/sql"
+)
+
+type DBTX interface {
+ ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
+ PrepareContext(context.Context, string) (*sql.Stmt, error)
+ QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
+ QueryRowContext(context.Context, string, ...interface{}) *sql.Row
+}
+
+func New(db DBTX) *Queries {
+ return &Queries{db: db}
+}
+
+type Queries struct {
+ db DBTX
+}
+
+func (q *Queries) WithTx(tx *sql.Tx) *Queries {
+ return &Queries{
+ db: tx,
+ }
+}
diff --git a/internal/database/queries/gen/importsessions/import_sessions.sql.go b/internal/database/queries/gen/importsessions/import_sessions.sql.go
new file mode 100644
index 0000000..03a06d9
--- /dev/null
+++ b/internal/database/queries/gen/importsessions/import_sessions.sql.go
@@ -0,0 +1,129 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.31.1
+// source: import_sessions.sql
+
+package importsessions
+
+import (
+ "context"
+)
+
+const deleteSession = `-- name: DeleteSession :exec
+DELETE FROM import_sessions
+WHERE id = ?
+`
+
+func (q *Queries) DeleteSession(ctx context.Context, id string) error {
+ _, err := q.db.ExecContext(ctx, deleteSession, id)
+ return err
+}
+
+const getSession = `-- name: GetSession :one
+SELECT id, org_id, format, status, target_entity, updated_at, created_at FROM import_sessions
+WHERE id = ?
+`
+
+func (q *Queries) GetSession(ctx context.Context, id string) (ImportSession, error) {
+ row := q.db.QueryRowContext(ctx, getSession, id)
+ var i ImportSession
+ err := row.Scan(
+ &i.ID,
+ &i.OrgID,
+ &i.Format,
+ &i.Status,
+ &i.TargetEntity,
+ &i.UpdatedAt,
+ &i.CreatedAt,
+ )
+ return i, err
+}
+
+const getStagedSession = `-- name: GetStagedSession :one
+SELECT id, org_id, format, status, target_entity, updated_at, created_at FROM import_sessions
+WHERE org_id = ? AND status = ?
+ORDER BY created_at DESC
+LIMIT 1
+`
+
+type GetStagedSessionParams struct {
+ OrgID string
+ Status string
+}
+
+func (q *Queries) GetStagedSession(ctx context.Context, arg GetStagedSessionParams) (ImportSession, error) {
+ row := q.db.QueryRowContext(ctx, getStagedSession, arg.OrgID, arg.Status)
+ var i ImportSession
+ err := row.Scan(
+ &i.ID,
+ &i.OrgID,
+ &i.Format,
+ &i.Status,
+ &i.TargetEntity,
+ &i.UpdatedAt,
+ &i.CreatedAt,
+ )
+ return i, err
+}
+
+const insertSession = `-- name: InsertSession :one
+INSERT INTO import_sessions (id, org_id, format, status, target_entity)
+VALUES (?, ?, ?, ?, ?)
+RETURNING id, org_id, format, status, target_entity, updated_at, created_at
+`
+
+type InsertSessionParams struct {
+ ID string
+ OrgID string
+ Format string
+ Status string
+ TargetEntity string
+}
+
+func (q *Queries) InsertSession(ctx context.Context, arg InsertSessionParams) (ImportSession, error) {
+ row := q.db.QueryRowContext(ctx, insertSession,
+ arg.ID,
+ arg.OrgID,
+ arg.Format,
+ arg.Status,
+ arg.TargetEntity,
+ )
+ var i ImportSession
+ err := row.Scan(
+ &i.ID,
+ &i.OrgID,
+ &i.Format,
+ &i.Status,
+ &i.TargetEntity,
+ &i.UpdatedAt,
+ &i.CreatedAt,
+ )
+ return i, err
+}
+
+const updateSessionStatus = `-- name: UpdateSessionStatus :one
+UPDATE import_sessions
+SET status = ?, updated_at = unixepoch()
+WHERE id = ?
+RETURNING id, org_id, format, status, target_entity, updated_at, created_at
+`
+
+type UpdateSessionStatusParams struct {
+ Status string
+ ID string
+}
+
+func (q *Queries) UpdateSessionStatus(ctx context.Context, arg UpdateSessionStatusParams) (ImportSession, error) {
+ row := q.db.QueryRowContext(ctx, updateSessionStatus, arg.Status, arg.ID)
+ var i ImportSession
+ err := row.Scan(
+ &i.ID,
+ &i.OrgID,
+ &i.Format,
+ &i.Status,
+ &i.TargetEntity,
+ &i.UpdatedAt,
+ &i.CreatedAt,
+ )
+ return i, err
+}
diff --git a/internal/database/queries/gen/importsessions/models.go b/internal/database/queries/gen/importsessions/models.go
new file mode 100644
index 0000000..0b41541
--- /dev/null
+++ b/internal/database/queries/gen/importsessions/models.go
@@ -0,0 +1,15 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.31.1
+
+package importsessions
+
+type ImportSession struct {
+ ID string
+ OrgID string
+ Format string
+ Status string
+ TargetEntity string
+ UpdatedAt int64
+ CreatedAt int64
+}
diff --git a/internal/database/queries/gen/importsessions/querier.go b/internal/database/queries/gen/importsessions/querier.go
new file mode 100644
index 0000000..f5daf3e
--- /dev/null
+++ b/internal/database/queries/gen/importsessions/querier.go
@@ -0,0 +1,19 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.31.1
+
+package importsessions
+
+import (
+ "context"
+)
+
+type Querier interface {
+ DeleteSession(ctx context.Context, id string) error
+ GetSession(ctx context.Context, id string) (ImportSession, error)
+ GetStagedSession(ctx context.Context, arg GetStagedSessionParams) (ImportSession, error)
+ InsertSession(ctx context.Context, arg InsertSessionParams) (ImportSession, error)
+ UpdateSessionStatus(ctx context.Context, arg UpdateSessionStatusParams) (ImportSession, error)
+}
+
+var _ Querier = (*Queries)(nil)
diff --git a/internal/database/queries/sqlite/equipment.sql b/internal/database/queries/sqlite/equipment.sql
index c7d7543..714f335 100644
--- a/internal/database/queries/sqlite/equipment.sql
+++ b/internal/database/queries/sqlite/equipment.sql
@@ -242,6 +242,38 @@ WHERE id = sqlc.arg(id);
DELETE FROM equipment
WHERE id = ?;
+-- name: Export :many
+SELECT
+ e.id,
+ e.name,
+ COALESCE(tt.name, '') AS tracking_type,
+ COALESCE(ut.name, '') AS usage_type,
+ COALESCE(ec.name, '') AS category,
+ COALESCE(em.name, '') AS manufacturer,
+ COALESCE(wl.name, '') AS location,
+ COALESCE(et.name, '') AS equipment_type,
+ e.rental_price,
+ e.resale_price,
+ e.notes,
+ e.weight_g,
+ e.width_mm,
+ e.height_mm,
+ e.depth_mm,
+ e.voltage_mv,
+ e.current_ma,
+ e.power_mw,
+ e.wire_gauge_mm2_x100
+FROM equipment e
+LEFT JOIN equipment_categories ec ON ec.id = e.category_id
+LEFT JOIN equipment_manufacturers em ON em.id = e.manufacturer_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 usage_types ut ON ut.id = e.usage_type_id
+WHERE e.org_id = sqlc.arg(org_id)
+ AND e.is_archived = 0
+ORDER BY ec.name ASC, e.name ASC;
+
-- name: UpdateArchived :exec
UPDATE equipment
SET
diff --git a/internal/database/queries/sqlite/equipment_imports.sql b/internal/database/queries/sqlite/equipment_imports.sql
deleted file mode 100644
index 1903e3a..0000000
--- a/internal/database/queries/sqlite/equipment_imports.sql
+++ /dev/null
@@ -1,63 +0,0 @@
--- name: InsertImportRow :one
-INSERT INTO equipment_imports (
- id,
- import_id,
- org_id,
- row_number,
- status,
- error_message,
- action,
- existing_equipment_id,
- existing_item_id,
- name,
- type_label,
- tracking_label,
- usage_type_label,
- category_name,
- manufacturer_name,
- location_name,
- purchase_price,
- rental_price,
- resale_price,
- notes,
- weight_g,
- width_mm,
- height_mm,
- depth_mm,
- voltage_mv,
- current_ma,
- power_mw,
- wire_gauge_mm2_x100,
- quantity,
- equipment_type_label,
- unit_serial_number,
- unit_manufacturer_serial,
- unit_purchase_price,
- unit_purchased_at,
- next_inspection_at,
- unit_is_active,
- unit_remark
-) VALUES (
- ?, ?, ?, ?, ?, ?, ?, ?, ?,
- ?, ?, ?, ?, ?, ?, ?, ?, ?,
- ?, ?, ?, ?, ?, ?, ?, ?, ?,
- ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
-) RETURNING *;
-
--- name: DeleteImportsByOrgID :exec
-DELETE FROM equipment_imports WHERE org_id = ?;
-
--- name: DeleteImportsByImportID :exec
-DELETE FROM equipment_imports WHERE import_id = ?;
-
--- name: ListImportRowsByImportID :many
-SELECT * FROM equipment_imports WHERE import_id = ? ORDER BY row_number ASC;
-
--- name: GetImportRow :one
-SELECT * FROM equipment_imports WHERE id = ?;
-
--- name: UpdateImportRowAction :exec
-UPDATE equipment_imports SET action = ? WHERE id = ?;
-
--- name: UpdateImportRowSerialNumber :exec
-UPDATE equipment_imports SET unit_serial_number = ? WHERE id = ?;
diff --git a/internal/database/queries/sqlite/import_data.sql b/internal/database/queries/sqlite/import_data.sql
new file mode 100644
index 0000000..ca125b8
--- /dev/null
+++ b/internal/database/queries/sqlite/import_data.sql
@@ -0,0 +1,34 @@
+-- name: InsertData :one
+INSERT INTO import_data (id, session_id, row_number, data)
+VALUES (?, ?, ?, ?)
+RETURNING *;
+
+-- name: GetData :one
+SELECT * FROM import_data
+WHERE id = ?;
+
+-- name: ListData :many
+SELECT * FROM import_data
+WHERE session_id = ?
+ORDER BY row_number ASC;
+
+-- name: ListDataByAction :many
+SELECT * FROM import_data
+WHERE session_id = ? AND action = ?
+ORDER BY row_number ASC;
+
+-- name: UpdateDataAction :one
+UPDATE import_data
+SET action = ?, updated_at = unixepoch()
+WHERE id = ?
+RETURNING *;
+
+-- name: UpdateDataStatus :one
+UPDATE import_data
+SET status = ?, error_message = ?, updated_at = unixepoch()
+WHERE id = ?
+RETURNING *;
+
+-- name: DeleteDataBySession :exec
+DELETE FROM import_data
+WHERE session_id = ?;
diff --git a/internal/database/queries/sqlite/import_mappings.sql b/internal/database/queries/sqlite/import_mappings.sql
new file mode 100644
index 0000000..6f53cdc
--- /dev/null
+++ b/internal/database/queries/sqlite/import_mappings.sql
@@ -0,0 +1,13 @@
+-- name: InsertMapping :one
+INSERT INTO import_mappings (id, session_id, source_col, target_field)
+VALUES (?, ?, ?, ?)
+RETURNING *;
+
+-- name: ListMappings :many
+SELECT * FROM import_mappings
+WHERE session_id = ?
+ORDER BY source_col ASC;
+
+-- name: DeleteMappingsBySession :exec
+DELETE FROM import_mappings
+WHERE session_id = ?;
diff --git a/internal/database/queries/sqlite/import_sessions.sql b/internal/database/queries/sqlite/import_sessions.sql
new file mode 100644
index 0000000..681f393
--- /dev/null
+++ b/internal/database/queries/sqlite/import_sessions.sql
@@ -0,0 +1,24 @@
+-- name: InsertSession :one
+INSERT INTO import_sessions (id, org_id, format, status, target_entity)
+VALUES (?, ?, ?, ?, ?)
+RETURNING *;
+
+-- name: GetSession :one
+SELECT * FROM import_sessions
+WHERE id = ?;
+
+-- name: UpdateSessionStatus :one
+UPDATE import_sessions
+SET status = ?, updated_at = unixepoch()
+WHERE id = ?
+RETURNING *;
+
+-- name: GetStagedSession :one
+SELECT * FROM import_sessions
+WHERE org_id = ? AND status = ?
+ORDER BY created_at DESC
+LIMIT 1;
+
+-- name: DeleteSession :exec
+DELETE FROM import_sessions
+WHERE id = ?;
diff --git a/internal/equipment/export.go b/internal/equipment/export.go
new file mode 100644
index 0000000..9a82807
--- /dev/null
+++ b/internal/equipment/export.go
@@ -0,0 +1,135 @@
+// 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 equipment
+
+import (
+ "context"
+ "encoding/csv"
+ "fmt"
+ "io"
+ "strconv"
+ "time"
+
+ "github.com/bit8bytes/gearberg/internal/equipment/tracking"
+ "github.com/bit8bytes/gearberg/internal/equipment/usage"
+)
+
+// ExportRow is one CSV row matching the import template column order exactly.
+type ExportRow struct {
+ Name string
+ EquipmentType string // Standard or Kit
+ TrackingType string // Bulk or Serialized
+ Usage string
+ Category string
+ Manufacturer string
+ Location string
+ RentalPrice string
+ ResalePrice string
+ Notes string
+ WeightKg string
+ WidthCm string
+ HeightCm string
+ DepthCm string
+ VoltageV string
+ CurrentA string
+ PowerW string
+ WireGauge string
+ Quantity string
+ // unit fields (serialized only)
+ UnitSerialNumber string
+ UnitManufacturerSerial string
+ UnitPurchasePrice string
+ UnitPurchasedAt string
+ NextInspectionAt string
+ UnitActive string
+ UnitRemark string
+}
+
+var exportHeaders = []string{
+ "Name", "Type", "Tracking Type", "Usage", "Category", "Manufacturer", "Location",
+ "Rental Price", "Resale Price", "Notes",
+ "Weight (kg)", "Width (cm)", "Height (cm)", "Depth (cm)",
+ "Voltage (V)", "Current (A)", "Power (W)", "Wire Gauge (mm² ×100)",
+ "Quantity",
+ "Unit Serial Number", "Unit Manufacturer Serial", "Unit Purchase Price",
+ "Unit Purchased At", "Next Inspection At", "Unit Active", "Unit Remark",
+}
+
+// WriteExportCSV writes rows to w in import-template column order.
+func WriteExportCSV(w io.Writer, rows []ExportRow) error {
+ cw := csv.NewWriter(w)
+ if err := cw.Write(exportHeaders); err != nil {
+ return fmt.Errorf("WriteExportCSV: write header: %w", err)
+ }
+ for _, r := range rows {
+ record := []string{
+ r.Name, r.EquipmentType, r.TrackingType, r.Usage, r.Category, r.Manufacturer, r.Location,
+ r.RentalPrice, r.ResalePrice, r.Notes,
+ r.WeightKg, r.WidthCm, r.HeightCm, r.DepthCm,
+ r.VoltageV, r.CurrentA, r.PowerW, r.WireGauge,
+ r.Quantity,
+ r.UnitSerialNumber, r.UnitManufacturerSerial, r.UnitPurchasePrice,
+ r.UnitPurchasedAt, r.NextInspectionAt, r.UnitActive, r.UnitRemark,
+ }
+ if err := cw.Write(record); err != nil {
+ return fmt.Errorf("WriteExportCSV: write row: %w", err)
+ }
+ }
+ cw.Flush()
+ if err := cw.Error(); err != nil {
+ return fmt.Errorf("WriteExportCSV: flush: %w", err)
+ }
+ return nil
+}
+
+// ExportCSV fetches all non-archived equipment for orgID and writes them as CSV to w.
+func (s *Service) ExportCSV(ctx context.Context, w io.Writer, orgID string) error {
+ rows, err := s.repo.Export(ctx, orgID)
+ if err != nil {
+ return fmt.Errorf("ExportCSV: %w", err)
+ }
+ return WriteExportCSV(w, rows)
+}
+
+func formatUnixDate(v *int64) string {
+ if v == nil {
+ return ""
+ }
+ return time.Unix(*v, 0).UTC().Format("2006-01-02")
+}
+
+func equipmentTypeLabel(name string) string {
+ return ParseOrDefault(name).String()
+}
+
+func formatActive(isActive int64) string {
+ if isActive == 1 {
+ return "true"
+ }
+ return "false"
+}
+
+func usageLabel(name string) string {
+ return usage.Parse(name).Label()
+}
+
+func trackingLabel(name string) string {
+ return tracking.Parse(name).Label()
+}
+
+func formatQuantity(q int64) string {
+ return strconv.FormatInt(q, 10)
+}
diff --git a/internal/equipment/import.go b/internal/equipment/import.go
new file mode 100644
index 0000000..94292d4
--- /dev/null
+++ b/internal/equipment/import.go
@@ -0,0 +1,246 @@
+// 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 equipment
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "strings"
+
+ "github.com/bit8bytes/gearberg/internal/equipment/usage"
+ imports "github.com/bit8bytes/gearberg/internal/import"
+ "github.com/bit8bytes/gearberg/internal/serial"
+ "github.com/bit8bytes/gearberg/internal/uid"
+ "github.com/bit8bytes/gearberg/internal/units"
+)
+
+// Upserter resolves or creates a named entity within an org.
+type Upserter interface {
+ Upsert(ctx context.Context, orgID, name string) (string, error)
+}
+
+// ImportPipeline bundles the org-agnostic validation steps and commit handler
+// for equipment imports. Build once at startup; orgID is supplied per request.
+type ImportPipeline struct {
+ equip *Service
+ categories Upserter
+ manufacturers Upserter
+ locations Upserter
+}
+
+// NewImportPipeline returns a stateless ImportPipeline. Wire it once in
+// setupServices and inject it into the handler via the services struct.
+func NewImportPipeline(equip *Service, cats, mfrs, locs Upserter) *ImportPipeline {
+ return &ImportPipeline{
+ equip: equip,
+ categories: cats,
+ manufacturers: mfrs,
+ locations: locs,
+ }
+}
+
+// StepsFor returns the validation steps bound to orgID for a single session.
+func (p *ImportPipeline) StepsFor(orgID string) []imports.Step {
+ return []imports.Step{p.equip.conflictStep(orgID)}
+}
+
+// Commit implements imports.CommitHandler.
+func (p *ImportPipeline) Commit(ctx context.Context, orgID string, rows []imports.Row, mappings []imports.Mapping) error {
+ fieldFor := make(map[string]string, len(mappings))
+ for _, m := range mappings {
+ fieldFor[m.SourceCol] = m.TargetField
+ }
+
+ type serializedGroup struct {
+ fields map[string]string
+ units []map[string]string
+ }
+ bulkRows := make([]map[string]string, 0)
+ serializedByName := make(map[string]*serializedGroup)
+ var serializedOrder []string
+
+ for _, row := range rows {
+ var raw map[string]string
+ if err := json.Unmarshal([]byte(row.Data), &raw); err != nil {
+ return fmt.Errorf("Commit: unmarshal row %d: %w", row.RowNumber, err)
+ }
+ fields := applyMappings(raw, fieldFor)
+
+ typeLabel := strings.ToLower(strings.TrimSpace(fields["Tracking Type"]))
+ if typeLabel == "serialized" {
+ key := strings.ToLower(strings.TrimSpace(fields["Name"]))
+ if _, ok := serializedByName[key]; !ok {
+ serializedByName[key] = &serializedGroup{fields: fields}
+ serializedOrder = append(serializedOrder, key)
+ }
+ serializedByName[key].units = append(serializedByName[key].units, fields)
+ } else {
+ bulkRows = append(bulkRows, fields)
+ }
+ }
+
+ for _, fields := range bulkRows {
+ if err := p.commitBulk(ctx, orgID, fields); err != nil {
+ return fmt.Errorf("Commit: bulk %q: %w", fields["Name"], err)
+ }
+ }
+ for _, key := range serializedOrder {
+ g := serializedByName[key]
+ if err := p.commitSerialized(ctx, orgID, g.fields, g.units); err != nil {
+ return fmt.Errorf("Commit: serialized %q: %w", g.fields["Name"], err)
+ }
+ }
+ return nil
+}
+
+func (p *ImportPipeline) commitBulk(ctx context.Context, orgID string, fields map[string]string) error {
+ base, err := p.buildBase(ctx, orgID, fields)
+ if err != nil {
+ return err
+ }
+ qty := max(ParseQuantity(fields["Quantity"]), 1)
+ _, err = p.equip.Create(ctx, CreateEquipment{
+ Base: base,
+ ItemType: "bulk",
+ TotalStock: qty,
+ })
+ return err
+}
+
+func (p *ImportPipeline) commitSerialized(ctx context.Context, orgID string, fields map[string]string, unitFields []map[string]string) error {
+ base, err := p.buildBase(ctx, orgID, fields)
+ if err != nil {
+ return err
+ }
+ itemID := uid.New()
+ unitList := make([]CreateUnit, 0, len(unitFields))
+ for _, uf := range unitFields {
+ sn := strings.TrimSpace(uf["Unit Serial Number"])
+ if sn == "" {
+ sn = serial.New()
+ }
+ isActive := !strings.EqualFold(uf["Unit Active"], "false") && uf["Unit Active"] != "0"
+ var purchasePrice *units.Cents
+ if p := units.ParseCents(uf["Unit Purchase Price"]); p != nil {
+ purchasePrice = p
+ }
+ unitList = append(unitList, CreateUnit{
+ ID: uid.New(),
+ OrgID: orgID,
+ EquipmentID: itemID,
+ SerialNumber: sn,
+ ManufacturerSerialNumber: uf["Unit Manufacturer Serial"],
+ Remark: uf["Unit Remark"],
+ PurchasePrice: purchasePrice,
+ PurchasedAt: ParseDate(uf["Unit Purchased At"]),
+ NextInspectionAt: ParseDate(uf["Next Inspection At"]),
+ IsActive: isActive,
+ })
+ }
+ _, err = p.equip.CreateWithUnits(ctx, base, unitList)
+ return err
+}
+
+func (p *ImportPipeline) buildBase(ctx context.Context, orgID string, fields map[string]string) (Base, error) {
+ catID, err := p.categories.Upsert(ctx, orgID, fields["Category"])
+ if err != nil {
+ return Base{}, fmt.Errorf("category %q: %w", fields["Category"], err)
+ }
+ var mfrID string
+ if name := strings.TrimSpace(fields["Manufacturer"]); name != "" {
+ mfrID, err = p.manufacturers.Upsert(ctx, orgID, name)
+ if err != nil {
+ return Base{}, fmt.Errorf("manufacturer %q: %w", name, err)
+ }
+ }
+ var locID string
+ if name := strings.TrimSpace(fields["Location"]); name != "" {
+ locID, err = p.locations.Upsert(ctx, orgID, name)
+ if err != nil {
+ return Base{}, fmt.Errorf("location %q: %w", name, err)
+ }
+ }
+ return Base{
+ OrgID: orgID,
+ Name: fields["Name"],
+ UsageTypeID: usage.Rental.ID(),
+ CategoryID: catID,
+ ManufacturerID: mfrID,
+ LocationID: locID,
+ Notes: fields["Notes"],
+ EquipmentType: ParseOrDefault(strings.ToLower(strings.TrimSpace(fields["Type"]))),
+ Pricing: Pricing{
+ RentalPrice: units.ParseCents(fields["Rental Price"]),
+ },
+ Properties: Properties{
+ Weight: units.ParseGrams(fields["Weight (kg)"]),
+ Width: units.ParseMillimeters(fields["Width (cm)"]),
+ Height: units.ParseMillimeters(fields["Height (cm)"]),
+ Depth: units.ParseMillimeters(fields["Depth (cm)"]),
+ },
+ }, nil
+}
+
+// conflictStep returns a Step bound to orgID that marks rows whose "Name"
+// already exists in inventory as an error.
+// Serialized rows sharing a name within the same batch are not double-flagged.
+func (s *Service) conflictStep(orgID string) imports.Step {
+ return func(ctx context.Context, rows []imports.Row) ([]imports.Row, error) {
+ existing, err := s.ListNames(ctx, orgID)
+ if err != nil {
+ return nil, fmt.Errorf("conflictStep: list names: %w", err)
+ }
+ nameSet := make(map[string]struct{}, len(existing))
+ for _, n := range existing {
+ nameSet[strings.ToLower(n)] = struct{}{}
+ }
+
+ for i, row := range rows {
+ var fields map[string]string
+ if err := json.Unmarshal([]byte(row.Data), &fields); err != nil {
+ rows[i].Status = imports.RowStatusError
+ rows[i].ErrorMessage = fmt.Sprintf("invalid JSON: %v", err)
+ continue
+ }
+
+ name := strings.ToLower(strings.TrimSpace(fields["Name"]))
+ if name == "" {
+ rows[i].Status = imports.RowStatusError
+ rows[i].ErrorMessage = "Name is required"
+ continue
+ }
+
+ if _, conflict := nameSet[name]; conflict {
+ rows[i].Status = imports.RowStatusError
+ rows[i].ErrorMessage = fmt.Sprintf("equipment %q already exists in inventory", fields["Name"])
+ }
+ }
+ return rows, nil
+ }
+}
+
+func applyMappings(raw, fieldFor map[string]string) map[string]string {
+ out := make(map[string]string, len(raw))
+ for col, val := range raw {
+ if target, ok := fieldFor[col]; ok {
+ out[target] = val
+ } else {
+ out[col] = val
+ }
+ }
+ return out
+}
diff --git a/internal/equipment/repository.go b/internal/equipment/repository.go
index 477cba6..a214639 100644
--- a/internal/equipment/repository.go
+++ b/internal/equipment/repository.go
@@ -554,6 +554,64 @@ func (r *Repository) DeleteUnit(ctx context.Context, id string) error {
return nil
}
+// Export returns all non-archived equipment for orgID as a flat list of ExportRows,
+// one row per bulk item and one row per serialized unit.
+func (r *Repository) Export(ctx context.Context, orgID string) ([]ExportRow, error) {
+ rows, err := r.equipment.Export(ctx, orgID)
+ if err != nil {
+ return nil, fmt.Errorf("Export: %w", err)
+ }
+ result := make([]ExportRow, 0, len(rows))
+ for _, row := range rows {
+ base := ExportRow{
+ Name: row.Name,
+ EquipmentType: equipmentTypeLabel(row.EquipmentType),
+ TrackingType: trackingLabel(row.TrackingType),
+ Usage: usageLabel(row.UsageType),
+ Category: row.Category,
+ Manufacturer: row.Manufacturer,
+ Location: row.Location,
+ RentalPrice: row.RentalPrice.String(),
+ ResalePrice: row.ResalePrice.String(),
+ Notes: database.String(row.Notes),
+ WeightKg: row.WeightG.String(),
+ WidthCm: row.WidthMm.String(),
+ HeightCm: row.HeightMm.String(),
+ DepthCm: row.DepthMm.String(),
+ VoltageV: row.VoltageMv.String(),
+ CurrentA: row.CurrentMa.String(),
+ PowerW: row.PowerMw.String(),
+ WireGauge: row.WireGaugeMm2X100.String(),
+ }
+ switch tracking.Parse(row.TrackingType) {
+ case tracking.Bulk:
+ bulk, err := r.bulkItems.GetByEquipmentID(ctx, row.ID)
+ if err != nil {
+ return nil, fmt.Errorf("Export: bulk items for %s: %w", row.ID, err)
+ }
+ base.Quantity = formatQuantity(bulk.Quantity)
+ result = append(result, base)
+ case tracking.Serialized:
+ serialized, err := r.serializedItems.ListByEquipmentID(ctx, row.ID)
+ if err != nil {
+ return nil, fmt.Errorf("Export: serialized items for %s: %w", row.ID, err)
+ }
+ for _, u := range serialized {
+ r := base
+ r.UnitSerialNumber = u.SerialNumber
+ r.UnitManufacturerSerial = database.String(u.ManufacturerSerial)
+ r.UnitPurchasePrice = database.NullAs[units.Cents](u.PurchasePrice).String()
+ r.UnitPurchasedAt = formatUnixDate(database.Int64Ptr(u.PurchasedAt))
+ r.NextInspectionAt = formatUnixDate(database.Int64Ptr(u.NextInspectionAt))
+ r.UnitActive = formatActive(u.IsActive)
+ r.UnitRemark = database.String(u.Remark)
+ result = append(result, r)
+ }
+ }
+ }
+ return result, nil
+}
+
// ListContent returns all content items for the equipment definition with equipmentID.
func (r *Repository) ListContent(ctx context.Context, equipmentID string) ([]ContentItem, error) {
rows, err := r.content.ListByEquipmentID(ctx, equipmentID)
diff --git a/internal/equipment/service.go b/internal/equipment/service.go
index fbb314c..c737245 100644
--- a/internal/equipment/service.go
+++ b/internal/equipment/service.go
@@ -101,6 +101,32 @@ func (s *Service) Create(ctx context.Context, c CreateEquipment) (*Equipment, er
return item, nil
}
+// CreateWithUnits creates a serialized equipment item with explicitly provided
+// units, preserving serial numbers supplied by the caller (e.g. from import data).
+func (s *Service) CreateWithUnits(ctx context.Context, base Base, unitList []CreateUnit) (*Equipment, error) {
+ itemID := uid.New()
+ for i := range unitList {
+ unitList[i].EquipmentID = itemID
+ }
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return nil, fmt.Errorf("CreateWithUnits: %w", err)
+ }
+ defer func() { _ = tx.Rollback() }()
+ item, err := s.repo.CreateSerialized(ctx, tx, CreateSerializedEquipment{
+ ID: itemID,
+ Base: base,
+ Units: unitList,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("CreateWithUnits: %w", err)
+ }
+ if err := tx.Commit(); err != nil {
+ return nil, fmt.Errorf("CreateWithUnits: %w", err)
+ }
+ return item, nil
+}
+
// ListParams holds the parameters for listing equipment.
type ListParams struct {
OrgID string
@@ -120,6 +146,20 @@ func (s *Service) List(ctx context.Context, p ListParams) ([]Equipment, paginati
return items, meta, nil
}
+// 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})
+ if err != nil {
+ return nil, fmt.Errorf("ListNames: %w", err)
+ }
+ names := make([]string, len(items))
+ for i, item := range items {
+ names[i] = item.Name
+ }
+ return names, nil
+}
+
// findByName returns the first equipment item in items whose name matches name,
// or nil when no match is found.
func findByName(items []Equipment, name string) *Equipment {
diff --git a/internal/equipmentimports/csv.go b/internal/equipmentimports/csv.go
deleted file mode 100644
index c134769..0000000
--- a/internal/equipmentimports/csv.go
+++ /dev/null
@@ -1,65 +0,0 @@
-// 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 equipmentimports
-
-import (
- "context"
- "fmt"
- "io"
- "strings"
-
- pkgcsv "github.com/bit8bytes/gearberg/pkg/csv"
-)
-
-// columnAliases maps legacy column names to their current canonical name.
-// Old exports that used a different name for a column are accepted transparently.
-var columnAliases = map[string]string{
- // "Has Content" was a boolean column (TRUE/FALSE) replaced by "Equipment Type"
- // (Standard/Kit). Values are normalised in MapRecords.
- "Has Content": "Equipment Type",
-}
-
-// ParseCSV reads a CSV (with or without a UTF-8 BOM) and returns processed rows
-// with all values already converted to DB units (cents, grams, millimetres, etc.).
-// All columns in ExpectedHeaders must be present; order and extra columns are ignored.
-// Every row is initialised to StateValid; ImportID and OrgID are left empty —
-// Stage sets them before persisting.
-func ParseCSV(r io.Reader) ([]ProcessedRow, error) {
- rd := &pkgcsv.Reader{Aliases: columnAliases}
- records, err := rd.Read(context.Background(), r)
- if err != nil {
- return nil, fmt.Errorf("ParseCSV: %w", err)
- }
- for _, name := range ExpectedHeaders {
- if _, ok := records[0].Fields[name]; !ok {
- return nil, fmt.Errorf("ParseCSV: missing required column %q", name)
- }
- }
- return MapRecords(records, "", ""), nil
-}
-
-// normalizeEquipmentTypeLabel maps legacy boolean values from the old "Has Content"
-// column to the current Equipment Type labels used by TypeFromString.
-func normalizeEquipmentTypeLabel(v string) string {
- switch strings.ToUpper(strings.TrimSpace(v)) {
- case "TRUE", "1":
- return "Kit"
- case "FALSE", "0":
- return "Standard"
- default:
- return v
- }
-}
diff --git a/internal/equipmentimports/importer.go b/internal/equipmentimports/importer.go
deleted file mode 100644
index 96b18d9..0000000
--- a/internal/equipmentimports/importer.go
+++ /dev/null
@@ -1,119 +0,0 @@
-// 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 equipmentimports
-
-import (
- "context"
- "fmt"
- "io"
-
- "github.com/bit8bytes/gearberg/internal/units"
- pkgcsv "github.com/bit8bytes/gearberg/pkg/csv"
-)
-
-// dbStr converts a *T (any int64-backed unit type) to its raw integer string,
-// or "" if nil. Used to store DB-unit values in Row string fields.
-func dbStr[T ~int64](v *T) string {
- if v == nil {
- return ""
- }
- return fmt.Sprintf("%d", int64(*v))
-}
-
-// Inspector reads only the header layer of a file to discover column names for
-// the field-mapping UI step, without parsing the full dataset.
-type Inspector interface {
- InspectHeaders(ctx context.Context, r io.Reader) ([]string, error)
-}
-
-// Reader parses an input source into records.
-// Satisfied by *csv.Reader, or a future *json.Reader.
-type Reader interface {
- Read(ctx context.Context, r io.Reader) ([]pkgcsv.Record, error)
-}
-
-// Step is a single pipeline stage applied to a batch of ProcessedRows.
-// Steps are composable and run in order via RunValidation.
-// A Step must never return early on a per-row error; instead it mutates
-// the row's State to StateInvalid and appends to its Errors slice.
-type Step func(ctx context.Context, rows []ProcessedRow) ([]ProcessedRow, error)
-
-// Writer commits a processed batch to a destination.
-// Close must be called after Write to release any held resources.
-type Writer interface {
- Write(ctx context.Context, rows []Row) error
- Close() error
-}
-
-// RunValidation applies steps in order to rows, collecting validation errors
-// per row without aborting early. Returns the annotated batch.
-func RunValidation(ctx context.Context, rows []ProcessedRow, steps []Step) ([]ProcessedRow, error) {
- var err error
- for _, step := range steps {
- rows, err = step(ctx, rows)
- if err != nil {
- return nil, err
- }
- }
- return rows, nil
-}
-
-// MapRecords converts pkg/csv records into ProcessedRow values with all physical
-// and monetary quantities already expressed in DB units (cents, grams, millimetres,
-// millivolts, milliamps, milliwatts). Every row is initialised to StateValid;
-// subsequent Steps mark rows invalid when constraints are violated.
-// The caller supplies importID and orgID which are set on every row; IDs and
-// status fields are left zero so Stage can assign them.
-func MapRecords(records []pkgcsv.Record, importID, orgID string) []ProcessedRow {
- rows := make([]ProcessedRow, 0, len(records))
- for _, rec := range records {
- f := func(name string) string { return rec.Fields[name] }
- rows = append(rows, ProcessedRow{
- State: StateValid,
- Data: Row{
- ImportID: importID,
- OrgID: orgID,
- Name: f("Name"),
- TypeLabel: f("Type"),
- UsageTypeLabel: f("Usage"),
- CategoryName: f("Category"),
- ManufacturerName: f("Manufacturer"),
- LocationName: f("Location"),
- RentalPrice: dbStr(units.ParseCents(f("Rental Price"))),
- ResalePrice: dbStr(units.ParseCents(f("Resale Price"))),
- Notes: f("Notes"),
- WeightG: dbStr(units.ParseGrams(f("Weight (kg)"))),
- WidthMm: dbStr(units.ParseMillimeters(f("Width (cm)"))),
- HeightMm: dbStr(units.ParseMillimeters(f("Height (cm)"))),
- DepthMm: dbStr(units.ParseMillimeters(f("Depth (cm)"))),
- VoltageMv: dbStr(units.ParseVolts(f("Voltage (V)"))),
- CurrentMa: dbStr(units.ParseMilliamps(f("Current (A)"))),
- PowerMw: dbStr(units.ParseMilliwatts(f("Power (W)"))),
- WireGaugeMM2X100: dbStr(units.ParseWireGauge(f("Wire Gauge (mm² ×100)"))),
- Quantity: f("Quantity"),
- EquipmentTypeLabel: normalizeEquipmentTypeLabel(f("Equipment Type")),
- UnitSerialNumber: f("Unit Serial Number"),
- UnitManufacturerSerial: f("Unit Manufacturer Serial"),
- UnitPurchasePrice: dbStr(units.ParseCents(f("Unit Purchase Price"))),
- UnitPurchasedAt: f("Unit Purchased At"),
- NextInspectionAt: f("Next Inspection At"),
- UnitIsActive: f("Unit Active"),
- UnitRemark: f("Unit Remark"),
- },
- })
- }
- return rows
-}
diff --git a/internal/equipmentimports/model.go b/internal/equipmentimports/model.go
deleted file mode 100644
index b6123e8..0000000
--- a/internal/equipmentimports/model.go
+++ /dev/null
@@ -1,201 +0,0 @@
-// 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 equipmentimports handles CSV import staging and commit for inventory items.
-package equipmentimports
-
-import (
- _ "embed"
- "fmt"
- "strings"
-)
-
-// Status values for a staged import row.
-const (
- StatusNew = "new"
- StatusError = "error"
-)
-
-// Action values for a staged import row.
-const (
- ActionCreate = "create"
- ActionSkip = "skip"
-)
-
-// Row is a staged import row persisted in equipment_imports.
-type Row struct {
- ID string
- ImportID string
- OrgID string
- RowNumber int64
- Status string
- ErrorMessage string
- Action string
- ExistingEquipmentID *string
- ExistingItemID *string
- CreatedAt int64
- // Equipment fields
- Name string
- TypeLabel string
- TrackingLabel string
- UsageTypeLabel string
- CategoryName string
- ManufacturerName string
- LocationName string
- PurchasePrice string // equipment-level purchase price (reserved; not yet in CSV)
- RentalPrice string
- ResalePrice string
- Notes string
- WeightG string
- WidthMm string
- HeightMm string
- DepthMm string
- VoltageMv string
- CurrentMa string
- PowerMw string
- WireGaugeMM2X100 string
- Quantity string
- EquipmentTypeLabel string
- // Unit fields (serialized items only)
- UnitSerialNumber string
- UnitManufacturerSerial string
- UnitPurchasePrice string
- UnitPurchasedAt string
- NextInspectionAt string
- UnitIsActive string
- UnitRemark string
-}
-
-// GroupedRow is a display-oriented view of staged rows collapsed by equipment name.
-// Serialized rows that share a name are folded into one entry; Stock holds the
-// unit count for serialized items and the quantity string for bulk.
-type GroupedRow struct {
- RowNumber int64
- Name string
- TypeLabel string
- CategoryName string
- Stock string
- Status string
- ErrorMessage string
-}
-
-// GroupRows collapses serialized staging rows that share a name into one GroupedRow
-// and returns per-item counts for new and error items.
-func GroupRows(staged []Row) (rows []GroupedRow, cntNew, cntError int) {
- type group struct {
- row GroupedRow
- total int
- hasErr bool
- }
- seen := make(map[string]*group)
- var order []string
-
- for _, r := range staged {
- if !strings.EqualFold(r.TypeLabel, "serialized") {
- rows = append(rows, GroupedRow{
- RowNumber: r.RowNumber,
- Name: r.Name,
- TypeLabel: r.TypeLabel,
- CategoryName: r.CategoryName,
- Stock: r.Quantity,
- Status: r.Status,
- ErrorMessage: r.ErrorMessage,
- })
- if r.Status == StatusNew {
- cntNew++
- } else {
- cntError++
- }
- continue
- }
-
- key := strings.ToLower(r.Name)
- if _, ok := seen[key]; !ok {
- seen[key] = &group{row: GroupedRow{
- RowNumber: r.RowNumber,
- Name: r.Name,
- TypeLabel: r.TypeLabel,
- CategoryName: r.CategoryName,
- Status: StatusNew,
- }}
- order = append(order, key)
- }
- g := seen[key]
- g.total++
- if r.Status == StatusError && !g.hasErr {
- g.hasErr = true
- g.row.Status = StatusError
- g.row.ErrorMessage = r.ErrorMessage
- }
- }
-
- for _, key := range order {
- g := seen[key]
- g.row.Stock = fmt.Sprintf("%d", g.total)
- rows = append(rows, g.row)
- if g.hasErr {
- cntError++
- } else {
- cntNew++
- }
- }
- return
-}
-
-// Mapping links user-defined source column names to canonical Row field names.
-// Key: header from the uploaded file; Value: Row field name.
-type Mapping map[string]string
-
-// RowState classifies a ProcessedRow for the UI and the commit gate.
-type RowState string
-
-const (
- // StateValid marks a row that passed all validation checks and is safe to commit.
- StateValid RowState = "valid"
-
- // StateInvalid marks a row that failed one or more validation checks and must not be committed.
- StateInvalid RowState = "invalid"
-)
-
-// ValidationError is a structured, field-level error message surfaced to the UI.
-type ValidationError struct {
- Line int `json:"line"`
- Field string `json:"field"`
- Reason string `json:"reason"`
-}
-
-// ProcessedRow wraps a Row with its validation state and collected errors.
-// The pipeline operates on []ProcessedRow; Stage converts back to []Row for storage.
-type ProcessedRow struct {
- State RowState
- Errors []ValidationError
- Data Row
-}
-
-// TemplateCSV is the pre-filled example CSV file served to users as a download template.
-//
-//go:embed template.csv
-var TemplateCSV []byte
-
-// ExpectedHeaders are the exact column headers the import CSV must have.
-var ExpectedHeaders = []string{
- "Name", "Type", "Usage", "Category", "Manufacturer", "Location",
- "Rental Price", "Resale Price", "Notes",
- "Weight (kg)", "Width (cm)", "Height (cm)", "Depth (cm)", "Voltage (V)", "Current (A)", "Power (W)",
- "Wire Gauge (mm² ×100)",
- "Quantity", "Equipment Type",
- "Unit Serial Number", "Unit Manufacturer Serial", "Unit Purchase Price",
- "Unit Purchased At", "Next Inspection At", "Unit Active", "Unit Remark",
-}
diff --git a/internal/equipmentimports/repository.go b/internal/equipmentimports/repository.go
deleted file mode 100644
index 61cc74e..0000000
--- a/internal/equipmentimports/repository.go
+++ /dev/null
@@ -1,177 +0,0 @@
-// 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 equipmentimports provides imports functionality.
-package equipmentimports
-
-import (
- "context"
- "database/sql"
- "fmt"
-
- "github.com/bit8bytes/gearberg/internal/database"
- genimports "github.com/bit8bytes/gearberg/internal/database/queries/gen/equipmentimports"
-)
-
-// Repository provides data access for import staging rows.
-type Repository struct {
- q genimports.Querier
-}
-
-// NewRepository returns a new Repository.
-func NewRepository(db *sql.DB) *Repository {
- return &Repository{q: genimports.New(db)}
-}
-
-// Create inserts a staged import row.
-func (r *Repository) Create(ctx context.Context, row Row) (*Row, error) {
- rec, err := r.q.InsertImportRow(ctx, genimports.InsertImportRowParams{
- ID: row.ID,
- ImportID: row.ImportID,
- OrgID: row.OrgID,
- RowNumber: row.RowNumber,
- Status: row.Status,
- ErrorMessage: row.ErrorMessage,
- Action: row.Action,
- ExistingEquipmentID: database.NullString(row.ExistingEquipmentID),
- ExistingItemID: database.NullString(row.ExistingItemID),
- Name: row.Name,
- TypeLabel: row.TypeLabel,
- TrackingLabel: row.TrackingLabel,
- UsageTypeLabel: row.UsageTypeLabel,
- CategoryName: row.CategoryName,
- ManufacturerName: row.ManufacturerName,
- LocationName: row.LocationName,
- PurchasePrice: row.PurchasePrice,
- RentalPrice: row.RentalPrice,
- ResalePrice: row.ResalePrice,
- Notes: row.Notes,
- WeightG: row.WeightG,
- WidthMm: row.WidthMm,
- HeightMm: row.HeightMm,
- DepthMm: row.DepthMm,
- VoltageMv: row.VoltageMv,
- CurrentMa: row.CurrentMa,
- PowerMw: row.PowerMw,
- WireGaugeMm2X100: row.WireGaugeMM2X100,
- Quantity: row.Quantity,
- EquipmentTypeLabel: row.EquipmentTypeLabel,
- UnitSerialNumber: row.UnitSerialNumber,
- UnitManufacturerSerial: row.UnitManufacturerSerial,
- UnitPurchasePrice: row.UnitPurchasePrice,
- UnitPurchasedAt: row.UnitPurchasedAt,
- NextInspectionAt: row.NextInspectionAt,
- UnitIsActive: row.UnitIsActive,
- UnitRemark: row.UnitRemark,
- })
- if err != nil {
- return nil, fmt.Errorf("Create: %w", err)
- }
- out := fromRecord(rec)
- return &out, nil
-}
-
-// Get returns a single staged import row.
-func (r *Repository) Get(ctx context.Context, id string) (*Row, error) {
- rec, err := r.q.GetImportRow(ctx, id)
- if err != nil {
- return nil, fmt.Errorf("Get: %w", err)
- }
- out := fromRecord(rec)
- return &out, nil
-}
-
-// UpdateAction updates the action field of a staged import row.
-func (r *Repository) UpdateAction(ctx context.Context, id, action string) error {
- if err := r.q.UpdateImportRowAction(ctx, genimports.UpdateImportRowActionParams{
- ID: id,
- Action: action,
- }); err != nil {
- return fmt.Errorf("UpdateAction: %w", err)
- }
- return nil
-}
-
-// DeleteByOrgID deletes all staging rows for the org.
-func (r *Repository) DeleteByOrgID(ctx context.Context, orgID string) error {
- if err := r.q.DeleteImportsByOrgID(ctx, orgID); err != nil {
- return fmt.Errorf("DeleteByOrgID: %w", err)
- }
- return nil
-}
-
-// Delete deletes all staging rows for a specific import.
-func (r *Repository) Delete(ctx context.Context, importID string) error {
- if err := r.q.DeleteImportsByImportID(ctx, importID); err != nil {
- return fmt.Errorf("Delete: %w", err)
- }
- return nil
-}
-
-// List returns all staged rows for an import, ordered by row_number.
-func (r *Repository) List(ctx context.Context, importID string) ([]Row, error) {
- recs, err := r.q.ListImportRowsByImportID(ctx, importID)
- if err != nil {
- return nil, fmt.Errorf("List: %w", err)
- }
- rows := make([]Row, len(recs))
- for i, rec := range recs {
- rows[i] = fromRecord(rec)
- }
- return rows, nil
-}
-
-func fromRecord(rec genimports.EquipmentImport) Row {
- return Row{
- ID: rec.ID,
- ImportID: rec.ImportID,
- OrgID: rec.OrgID,
- RowNumber: rec.RowNumber,
- Status: rec.Status,
- ErrorMessage: rec.ErrorMessage,
- Action: rec.Action,
- ExistingEquipmentID: database.StringPtr(rec.ExistingEquipmentID),
- ExistingItemID: database.StringPtr(rec.ExistingItemID),
- CreatedAt: rec.CreatedAt,
- Name: rec.Name,
- TypeLabel: rec.TypeLabel,
- TrackingLabel: rec.TrackingLabel,
- UsageTypeLabel: rec.UsageTypeLabel,
- CategoryName: rec.CategoryName,
- ManufacturerName: rec.ManufacturerName,
- LocationName: rec.LocationName,
- PurchasePrice: rec.PurchasePrice,
- RentalPrice: rec.RentalPrice,
- ResalePrice: rec.ResalePrice,
- Notes: rec.Notes,
- WeightG: rec.WeightG,
- WidthMm: rec.WidthMm,
- HeightMm: rec.HeightMm,
- DepthMm: rec.DepthMm,
- VoltageMv: rec.VoltageMv,
- CurrentMa: rec.CurrentMa,
- PowerMw: rec.PowerMw,
- WireGaugeMM2X100: rec.WireGaugeMm2X100,
- Quantity: rec.Quantity,
- EquipmentTypeLabel: rec.EquipmentTypeLabel,
- UnitSerialNumber: rec.UnitSerialNumber,
- UnitManufacturerSerial: rec.UnitManufacturerSerial,
- UnitPurchasePrice: rec.UnitPurchasePrice,
- UnitPurchasedAt: rec.UnitPurchasedAt,
- NextInspectionAt: rec.NextInspectionAt,
- UnitIsActive: rec.UnitIsActive,
- UnitRemark: rec.UnitRemark,
- }
-}
diff --git a/internal/equipmentimports/rows.go b/internal/equipmentimports/rows.go
deleted file mode 100644
index 3a98f7f..0000000
--- a/internal/equipmentimports/rows.go
+++ /dev/null
@@ -1,103 +0,0 @@
-// 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 equipmentimports provides imports functionality.
-package equipmentimports
-
-import (
- "strconv"
- "time"
-
- "github.com/bit8bytes/gearberg/internal/equipment"
- "github.com/bit8bytes/gearberg/internal/equipment/tracking"
-)
-
-// headerIndex maps each ExpectedHeaders column name to its position.
-// Built once at package init so RowsForItem can write by name, not by magic number.
-var headerIndex = func() map[string]int {
- m := make(map[string]int, len(ExpectedHeaders))
- for i, name := range ExpectedHeaders {
- m[name] = i
- }
- return m
-}()
-
-func newRow(base []string) []string {
- row := make([]string, len(ExpectedHeaders))
- copy(row, base)
- return row
-}
-
-// RowsForItem returns the CSV data rows for one equipment item using the column
-// order defined by ExpectedHeaders.
-// Bulk items produce a single row; serialized items produce one row per unit.
-func RowsForItem(item equipment.Equipment, mfrName string, units []equipment.Unit) [][]string {
- h := headerIndex
- base := make([]string, len(ExpectedHeaders))
- base[h["Name"]] = item.Name
- base[h["Type"]] = item.TrackingType.Label()
- base[h["Usage"]] = item.UsageType.Label()
- base[h["Category"]] = item.CategoryName
- base[h["Manufacturer"]] = mfrName
- base[h["Location"]] = item.LocationName
- base[h["Rental Price"]] = item.Pricing.RentalPrice.String()
- base[h["Resale Price"]] = item.Pricing.PurchasePrice.String()
- base[h["Notes"]] = item.Notes
- base[h["Weight (kg)"]] = item.Properties.Weight.String()
- base[h["Width (cm)"]] = item.Properties.Width.String()
- base[h["Height (cm)"]] = item.Properties.Height.String()
- base[h["Depth (cm)"]] = item.Properties.Depth.String()
- base[h["Voltage (V)"]] = item.Properties.Voltage.String()
- base[h["Current (A)"]] = item.Properties.Current.String()
- base[h["Power (W)"]] = item.Properties.Power.String()
- base[h["Wire Gauge (mm² ×100)"]] = item.Properties.WireGauge.String()
- base[h["Equipment Type"]] = item.Type.Label()
-
- if item.TrackingType != tracking.Serialized {
- row := newRow(base)
- row[h["Quantity"]] = strconv.FormatInt(item.TotalStock, 10)
- return [][]string{row}
- }
-
- rows := make([][]string, 0, len(units))
- for _, u := range units {
- row := newRow(base)
- row[h["Unit Serial Number"]] = u.SerialNumber
- row[h["Unit Manufacturer Serial"]] = u.ManufacturerSerialNumber
- row[h["Unit Purchase Price"]] = u.PurchasePrice.String()
- row[h["Unit Purchased At"]] = formatExportDate(u.PurchasedAt)
- row[h["Next Inspection At"]] = formatExportDate(u.NextInspectionAt)
- row[h["Unit Active"]] = formatExportActive(u.IsActive())
- row[h["Unit Remark"]] = u.Remark
- rows = append(rows, row)
- }
- return rows
-}
-
-// formatExportDate formats a Unix timestamp pointer as YYYY-MM-DD, or "" if nil.
-func formatExportDate(ts *int64) string {
- if ts == nil {
- return ""
- }
- return time.Unix(*ts, 0).UTC().Format("2006-01-02")
-}
-
-// formatExportActive returns "TRUE" for active, "FALSE" for inactive.
-func formatExportActive(active bool) string {
- if active {
- return "TRUE"
- }
- return "FALSE"
-}
diff --git a/internal/equipmentimports/service.go b/internal/equipmentimports/service.go
deleted file mode 100644
index 8e0a860..0000000
--- a/internal/equipmentimports/service.go
+++ /dev/null
@@ -1,436 +0,0 @@
-// 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 equipmentimports provides imports functionality.
-package equipmentimports
-
-import (
- "context"
- "database/sql"
- "fmt"
- "math"
- "strconv"
- "strings"
-
- "github.com/bit8bytes/gearberg/internal/equipment"
- "github.com/bit8bytes/gearberg/internal/equipment/usage"
- "github.com/bit8bytes/gearberg/internal/pagination"
- "github.com/bit8bytes/gearberg/internal/serial"
- "github.com/bit8bytes/gearberg/internal/uid"
- "github.com/bit8bytes/gearberg/internal/units"
-)
-
-// Upserter resolves or creates a named entity within an org.
-type Upserter interface {
- Upsert(ctx context.Context, orgID, name string) (string, error)
-}
-
-// Service handles CSV import staging and commit.
-type Service struct {
- repo *Repository
- db *sql.DB
- equipment *equipment.Repository
- categories Upserter
- manufacturers Upserter
- locations Upserter
- steps []Step
-}
-
-// NewService returns a new Service with the default validation pipeline.
-func NewService(repo *Repository, db *sql.DB, equip *equipment.Repository, cats, mfrs, locs Upserter) *Service {
- return &Service{
- repo: repo,
- db: db,
- equipment: equip,
- categories: cats,
- manufacturers: mfrs,
- locations: locs,
- steps: []Step{validateStep},
- }
-}
-
-// validateStep marks rows that fail field-level validation as StateInvalid.
-func validateStep(_ context.Context, rows []ProcessedRow) ([]ProcessedRow, error) {
- for i, pr := range rows {
- if pr.State == StateInvalid {
- continue
- }
- if msg := validateRow(pr.Data); msg != "" {
- rows[i].State = StateInvalid
- rows[i].Errors = append(rows[i].Errors, ValidationError{
- Line: int(pr.Data.RowNumber),
- Field: "Name",
- Reason: msg,
- })
- }
- }
- return rows, nil
-}
-
-// Stage deletes any existing staging rows for the org, runs the import pipeline,
-// and persists the results. Returns the import_id grouping the new rows.
-func (s *Service) Stage(ctx context.Context, orgID string, processed []ProcessedRow) (string, error) {
- if err := s.repo.DeleteByOrgID(ctx, orgID); err != nil {
- return "", fmt.Errorf("Stage: %w", err)
- }
-
- existing, _, err := s.equipment.List(ctx, orgID, "", "", false, pagination.Filters{Page: 1, PageSize: math.MaxInt32})
- if err != nil {
- return "", fmt.Errorf("Stage: %w", err)
- }
- existingByName := make(map[string]string, len(existing))
- for _, item := range existing {
- existingByName[strings.ToLower(item.Name)] = item.ID
- }
-
- importID := uid.New()
- for i := range processed {
- processed[i].Data.ID = uid.New()
- processed[i].Data.ImportID = importID
- processed[i].Data.OrgID = orgID
- processed[i].Data.RowNumber = int64(i + 1)
- processed[i].Data.Status = StatusNew
- processed[i].Data.Action = ActionCreate
- }
-
- pipeline := make([]Step, len(s.steps)+1)
- copy(pipeline, s.steps)
- pipeline[len(s.steps)] = conflictStep(existingByName)
- processed, err = RunValidation(ctx, processed, pipeline)
- if err != nil {
- return "", fmt.Errorf("Stage: pipeline: %w", err)
- }
-
- rows := toRows(processed)
- for i, row := range rows {
- if _, err := s.repo.Create(ctx, row); err != nil {
- return "", fmt.Errorf("Stage: row %d: %w", i+1, err)
- }
- }
-
- return importID, nil
-}
-
-// toRows converts a validated []ProcessedRow back to []Row for storage,
-// mapping StateInvalid→StatusError and collecting the first error message.
-func toRows(processed []ProcessedRow) []Row {
- rows := make([]Row, len(processed))
- for i, pr := range processed {
- row := pr.Data
- if pr.State == StateInvalid {
- row.Status = StatusError
- row.Action = ActionSkip
- if len(pr.Errors) > 0 {
- row.ErrorMessage = pr.Errors[0].Reason
- }
- }
- rows[i] = row
- }
- return rows
-}
-
-// conflictStep returns a Step that marks rows whose name already exists in inventory.
-func conflictStep(existingByName map[string]string) Step {
- return func(_ context.Context, rows []ProcessedRow) ([]ProcessedRow, error) {
- for i, pr := range rows {
- if pr.State == StateInvalid {
- continue
- }
- if _, conflict := existingByName[strings.ToLower(pr.Data.Name)]; conflict {
- rows[i].State = StateInvalid
- rows[i].Errors = append(rows[i].Errors, ValidationError{
- Line: int(pr.Data.RowNumber),
- Field: "Name",
- Reason: "A gear item with this name already exists",
- })
- }
- }
- return rows, nil
- }
-}
-
-// ListStaged returns all staged rows for an import.
-func (s *Service) ListStaged(ctx context.Context, importID string) ([]Row, error) {
- rows, err := s.repo.List(ctx, importID)
- if err != nil {
- return nil, fmt.Errorf("ListStaged: %w", err)
- }
- return rows, nil
-}
-
-// commitLookups holds pre-resolved name→ID maps for the commit phase.
-type commitLookups struct {
- catsByName map[string]string
- mfrsByName map[string]string
- locsByName map[string]string
-}
-
-// ensureCommitLookups upserts every unique category, manufacturer,
-// and location name in the batch, creating them if they don't exist yet.
-func (s *Service) ensureCommitLookups(ctx context.Context, orgID string, rows []Row) (commitLookups, error) {
- lk := commitLookups{
- catsByName: make(map[string]string),
- mfrsByName: make(map[string]string),
- locsByName: make(map[string]string),
- }
- for _, row := range rows {
- if row.Status == StatusError || row.Action == ActionSkip {
- continue
- }
- if err := s.ensureCategory(ctx, &lk, orgID, row.CategoryName); err != nil {
- return commitLookups{}, fmt.Errorf("ensureCommitLookups: %w", err)
- }
- if name := strings.TrimSpace(row.ManufacturerName); name != "" {
- if err := s.ensureManufacturer(ctx, &lk, orgID, name); err != nil {
- return commitLookups{}, fmt.Errorf("ensureCommitLookups: %w", err)
- }
- }
- if name := strings.TrimSpace(row.LocationName); name != "" {
- if err := s.ensureLocation(ctx, &lk, orgID, name); err != nil {
- return commitLookups{}, fmt.Errorf("ensureCommitLookups: %w", err)
- }
- }
- }
- return lk, nil
-}
-
-func (s *Service) ensureCategory(ctx context.Context, lk *commitLookups, orgID, name string) error {
- key := strings.ToLower(name)
- if _, ok := lk.catsByName[key]; ok {
- return nil
- }
- id, err := s.categories.Upsert(ctx, orgID, name)
- if err != nil {
- return fmt.Errorf("category %q: %w", name, err)
- }
- lk.catsByName[key] = id
- return nil
-}
-
-func (s *Service) ensureManufacturer(ctx context.Context, lk *commitLookups, orgID, name string) error {
- key := strings.ToLower(name)
- if _, ok := lk.mfrsByName[key]; ok {
- return nil
- }
- id, err := s.manufacturers.Upsert(ctx, orgID, name)
- if err != nil {
- return fmt.Errorf("manufacturer %q: %w", name, err)
- }
- lk.mfrsByName[key] = id
- return nil
-}
-
-func (s *Service) ensureLocation(ctx context.Context, lk *commitLookups, orgID, name string) error {
- key := strings.ToLower(name)
- if _, ok := lk.locsByName[key]; ok {
- return nil
- }
- id, err := s.locations.Upsert(ctx, orgID, name)
- if err != nil {
- return fmt.Errorf("location %q: %w", name, err)
- }
- lk.locsByName[key] = id
- return nil
-}
-
-// Commit processes all staged rows for the import atomically: creates new items,
-// then deletes the staging rows — all within a single transaction so no partial
-// write is possible.
-func (s *Service) Commit(ctx context.Context, importID string, orgID string) error {
- rows, err := s.repo.List(ctx, importID)
- if err != nil {
- return fmt.Errorf("Commit: %w", err)
- }
-
- lk, err := s.ensureCommitLookups(ctx, orgID, rows)
- if err != nil {
- return fmt.Errorf("Commit: %w", err)
- }
-
- tx, err := s.db.BeginTx(ctx, nil)
- if err != nil {
- return fmt.Errorf("Commit: begin tx: %w", err)
- }
- defer func() { _ = tx.Rollback() }()
-
- if err := s.commitRows(ctx, tx, rows, lk); err != nil {
- return fmt.Errorf("Commit: %w", err)
- }
-
- if err := tx.Commit(); err != nil {
- return fmt.Errorf("Commit: %w", err)
- }
-
- // Staging rows are cleaned up after the inventory transaction commits.
- // A failure here leaves stale rows that DeleteByOrgID will clear on the next import.
- if err := s.repo.Delete(ctx, importID); err != nil {
- return fmt.Errorf("Commit: %w", err)
- }
- return nil
-}
-
-type serializedGroup struct {
- first Row
- rows []Row
-}
-
-// commitRows groups serialized rows by name and commits all bulk and serialized items.
-func (s *Service) commitRows(ctx context.Context, tx *sql.Tx, rows []Row, lk commitLookups) error {
- byName := make(map[string]*serializedGroup)
- var order []string
- for _, row := range rows {
- if row.Status == StatusError || row.Action == ActionSkip {
- continue
- }
- if !strings.EqualFold(row.TypeLabel, "serialized") {
- if err := s.commitBulkRow(ctx, tx, row, lk); err != nil {
- return fmt.Errorf("row %d: %w", row.RowNumber, err)
- }
- continue
- }
- key := strings.ToLower(row.Name)
- if _, ok := byName[key]; !ok {
- byName[key] = &serializedGroup{first: row}
- order = append(order, key)
- }
- byName[key].rows = append(byName[key].rows, row)
- }
- for _, key := range order {
- g := byName[key]
- if err := s.commitSerializedGroup(ctx, tx, g.first, g.rows, lk); err != nil {
- return fmt.Errorf("serialized %q: %w", g.first.Name, err)
- }
- }
- return nil
-}
-
-func (s *Service) resolveLookups(row Row, lk commitLookups) (catID, mfrID, locID string) {
- catID = lk.catsByName[strings.ToLower(row.CategoryName)]
- mfrID = lk.mfrsByName[strings.ToLower(row.ManufacturerName)]
- locID = lk.locsByName[strings.ToLower(row.LocationName)]
- return
-}
-
-// parseInt64 parses a string as int64, returning 0 for blank or invalid input.
-func parseInt64(s string) int64 {
- n, _ := strconv.ParseInt(strings.TrimSpace(s), 10, 64)
- return n
-}
-
-// ptrOf converts an int64 DB value to a typed pointer; returns nil when v is 0.
-func ptrOf[T ~int64](v int64) *T {
- if v == 0 {
- return nil
- }
- t := T(v)
- return &t
-}
-
-func buildBase(row Row, catID, mfrID, locID string) equipment.Base {
- return equipment.Base{
- OrgID: row.OrgID,
- UsageTypeID: usage.Rental.ID(),
- Name: row.Name,
- CategoryID: catID,
- ManufacturerID: mfrID,
- LocationID: locID,
- Notes: row.Notes,
- EquipmentType: equipment.ParseOrDefault(strings.ToLower(strings.TrimSpace(row.EquipmentTypeLabel))),
- Pricing: equipment.Pricing{
- PurchasePrice: ptrOf[units.Cents](parseInt64(row.ResalePrice)),
- RentalPrice: ptrOf[units.Cents](parseInt64(row.RentalPrice)),
- },
- Properties: equipment.Properties{
- Weight: ptrOf[units.Grams](parseInt64(row.WeightG)),
- Width: ptrOf[units.Millimeters](parseInt64(row.WidthMm)),
- Height: ptrOf[units.Millimeters](parseInt64(row.HeightMm)),
- Depth: ptrOf[units.Millimeters](parseInt64(row.DepthMm)),
- Power: ptrOf[units.Milliwatts](parseInt64(row.PowerMw)),
- Current: ptrOf[units.Milliamps](parseInt64(row.CurrentMa)),
- Voltage: ptrOf[units.Millivolts](parseInt64(row.VoltageMv)),
- WireGauge: ptrOf[units.WireGauge](parseInt64(row.WireGaugeMM2X100)),
- },
- }
-}
-
-func (s *Service) commitBulkRow(ctx context.Context, tx *sql.Tx, row Row, lk commitLookups) error {
- catID, mfrID, locID := s.resolveLookups(row, lk)
- base := buildBase(row, catID, mfrID, locID)
- if _, err := s.equipment.CreateBulk(ctx, tx, equipment.CreateBulkEquipment{
- ID: uid.New(),
- BulkItemID: uid.New(),
- Base: base,
- TotalStock: equipment.ParseQuantity(row.Quantity),
- }); err != nil {
- return fmt.Errorf("commitBulkRow: %w", err)
- }
- return nil
-}
-
-func buildUnit(row Row, equipmentID string) equipment.CreateUnit {
- sn := strings.TrimSpace(row.UnitSerialNumber)
- if sn == "" {
- sn = serial.New()
- }
- isActive := !strings.EqualFold(row.UnitIsActive, "false") && row.UnitIsActive != "0"
- return equipment.CreateUnit{
- ID: uid.New(),
- EquipmentID: equipmentID,
- SerialNumber: sn,
- ManufacturerSerialNumber: row.UnitManufacturerSerial,
- Remark: row.UnitRemark,
- PurchasePrice: ptrOf[units.Cents](parseInt64(row.UnitPurchasePrice)),
- PurchasedAt: equipment.ParseDate(row.UnitPurchasedAt),
- NextInspectionAt: equipment.ParseDate(row.NextInspectionAt),
- IsActive: isActive,
- }
-}
-
-// commitSerializedGroup creates one equipment item with one unit per row in the group.
-func (s *Service) commitSerializedGroup(ctx context.Context, tx *sql.Tx, first Row, rows []Row, lk commitLookups) error {
- catID, mfrID, locID := s.resolveLookups(first, lk)
- base := buildBase(first, catID, mfrID, locID)
- itemID := uid.New()
- unitList := make([]equipment.CreateUnit, 0, len(rows))
- for _, row := range rows {
- unitList = append(unitList, buildUnit(row, itemID))
- }
- if _, err := s.equipment.CreateSerialized(ctx, tx, equipment.CreateSerializedEquipment{
- ID: itemID,
- Base: base,
- Units: unitList,
- }); err != nil {
- return fmt.Errorf("commitSerializedGroup: %w", err)
- }
- return nil
-}
-
-func validateRow(row Row) string {
- if strings.TrimSpace(row.Name) == "" {
- return "Name is required"
- }
- tl := strings.TrimSpace(row.TypeLabel)
- if !strings.EqualFold(tl, "bulk") && !strings.EqualFold(tl, "serialized") {
- return fmt.Sprintf("Type must be Bulk or Serialized, got %q", tl)
- }
- ul := strings.TrimSpace(row.UsageTypeLabel)
- if !strings.EqualFold(ul, "rental") && !strings.EqualFold(ul, "sale") {
- return fmt.Sprintf("Usage must be Rental or Sale, got %q", ul)
- }
- if strings.TrimSpace(row.CategoryName) == "" {
- return "Category is required"
- }
- return ""
-}
diff --git a/internal/equipmentimports/template.csv b/internal/equipmentimports/template.csv
deleted file mode 100644
index 19864ec..0000000
--- a/internal/equipmentimports/template.csv
+++ /dev/null
@@ -1,5 +0,0 @@
-Name,Type,Usage,Category,Manufacturer,Location,Rental Price,Resale Price,Notes,Weight (kg),Width (cm),Height (cm),Depth (cm),Voltage (V),Current (A),Power (W),Wire Gauge (mm² ×100),Quantity,Equipment Type,Unit Serial Number,Unit Manufacturer Serial,Unit Purchase Price,Unit Purchased At,Next Inspection At,Unit Active,Unit Remark
-Shure SM58,Bulk,Rental,Audio,Shure,Main Warehouse,15.00,99.00,Cardioid dynamic vocal microphone,0.298,4.7,4.7,16.2,,,,,7,,,,,,,,
-Sony A7 IV,Serialized,Rental,Camera,Sony,Main Warehouse,80.00,2800.00,Full-frame mirrorless camera,0.659,13.1,9.6,8.0,,,,,,,SN-A7IV-001,7-000001,2800.00,2024-03-15,2025-03-15,TRUE,
-Sony A7 IV,Serialized,Rental,Camera,Sony,Main Warehouse,80.00,2800.00,Full-frame mirrorless camera,0.659,13.1,9.6,8.0,,,,,,,SN-A7IV-002,7-000002,2800.00,2024-03-15,2025-03-15,TRUE,Minor scratch on top plate
-Pelican 1510 Case,Serialized,Rental,Case,Pelican,Main Warehouse,10.00,120.00,Carry-on approved hard case,2.8,56.3,35.5,22.9,,,,,,Kit,PC-1510-001,,120.00,2024-01-10,,TRUE,
diff --git a/internal/flash/flash.go b/internal/flash/flash.go
new file mode 100644
index 0000000..bb8c54c
--- /dev/null
+++ b/internal/flash/flash.go
@@ -0,0 +1,44 @@
+// 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 flash provides one-shot notification messages stored in the session
+// and rendered as toasts on the next page load (post-redirect-get pattern).
+package flash
+
+import "context"
+
+// Type controls the DaisyUI alert style of a notification.
+type Type string
+
+// Notification types mapped to DaisyUI alert styles.
+const (
+ Success Type = "success"
+ Error Type = "error"
+)
+
+// Flash is a one-shot notification message displayed as a toast.
+type Flash struct {
+ Message string
+ Type Type
+}
+
+// Manager stores and retrieves flash notifications across requests.
+type Manager interface {
+ // Put stores a flash notification to be shown on the next page load.
+ Put(ctx context.Context, msg string, t Type)
+ // Pop retrieves and removes the flash notification.
+ // Returns nil if no flash is set.
+ Pop(ctx context.Context) *Flash
+}
diff --git a/internal/flash/session.go b/internal/flash/session.go
new file mode 100644
index 0000000..86cfba7
--- /dev/null
+++ b/internal/flash/session.go
@@ -0,0 +1,59 @@
+// 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 flash provides one-shot notification messages stored in the session.
+package flash
+
+import "context"
+
+const (
+ messageKey = "flash.message"
+ typeKey = "flash.type"
+)
+
+// sessionStore is the subset of the SCS session manager used by SessionManager.
+// *scs.SessionManager satisfies this interface.
+type sessionStore interface {
+ Put(ctx context.Context, key string, val any)
+ PopString(ctx context.Context, key string) string
+}
+
+// SessionManager is a flash.Manager backed by a session store.
+type SessionManager struct {
+ store sessionStore
+}
+
+// NewSessionManager returns a SessionManager backed by store.
+func NewSessionManager(store sessionStore) *SessionManager {
+ return &SessionManager{store: store}
+}
+
+// Put stores a flash notification to be shown on the next page load.
+func (s *SessionManager) Put(ctx context.Context, msg string, t Type) {
+ s.store.Put(ctx, messageKey, msg)
+ s.store.Put(ctx, typeKey, string(t))
+}
+
+// Pop retrieves and removes the flash notification. Returns nil if no flash is set.
+func (s *SessionManager) Pop(ctx context.Context) *Flash {
+ msg := s.store.PopString(ctx, messageKey)
+ if msg == "" {
+ return nil
+ }
+ return &Flash{
+ Message: msg,
+ Type: Type(s.store.PopString(ctx, typeKey)),
+ }
+}
diff --git a/internal/html/html.go b/internal/html/html.go
index f48fe08..ea06a7b 100644
--- a/internal/html/html.go
+++ b/internal/html/html.go
@@ -19,12 +19,14 @@ package html
import (
"bytes"
+ "context"
"fmt"
"html/template"
"log/slog"
"net/http"
"net/url"
+ "github.com/bit8bytes/gearberg/internal/flash"
"github.com/bit8bytes/gearberg/internal/httperr"
"github.com/bit8bytes/gearberg/internal/nonce"
"github.com/bit8bytes/gearberg/internal/templates/pages"
@@ -49,19 +51,41 @@ type TemplateData struct {
// OrgID is the active organisation from the URL path, populated on org-scoped pages.
// Empty on user-level pages (e.g. /settings/account). Used by the header partial.
OrgID string
+ // Flash is a one-shot notification popped from the session and rendered as a toast.
+ Flash *flash.Flash
}
+// Option configures an HTML renderer.
+type Option func(*HTML)
+
+// WithFlashManager wires a flash.Manager so that TemplateData automatically
+// pops any pending notification into the Flash field.
+func WithFlashManager(fm flash.Manager) Option {
+ return func(h *HTML) { h.flash = fm }
+}
+
+// noopFlash is used when no flash manager is configured.
+type noopFlash struct{}
+
+func (noopFlash) Put(_ context.Context, _ string, _ flash.Type) {}
+func (noopFlash) Pop(_ context.Context) *flash.Flash { return nil }
+
// HTML renders HTML responses and adapts httperr.HandlerFunc into standard http.HandlerFunc.
type HTML struct {
logger *slog.Logger
base *template.Template
cache map[string]*template.Template
revision string
+ flash flash.Manager
}
// New returns a Renderer backed by the given logger, template cache, and revision string.
-func New(logger *slog.Logger, base *template.Template, cache map[string]*template.Template, revision string) *HTML {
- return &HTML{logger: logger, base: base, cache: cache, revision: revision}
+func New(logger *slog.Logger, base *template.Template, cache map[string]*template.Template, revision string, opts ...Option) *HTML {
+ h := &HTML{logger: logger, base: base, cache: cache, revision: revision, flash: noopFlash{}}
+ for _, o := range opts {
+ o(h)
+ }
+ return h
}
// Handle adapts an httperr.HandlerFunc into a standard http.HandlerFunc. When the handler
@@ -132,5 +156,6 @@ func (rnd *HTML) TemplateData(r *http.Request) *TemplateData {
Nonce: nonce.From(r.Context()),
Revision: rnd.revision,
OrgID: r.PathValue("org_id"),
+ Flash: rnd.flash.Pop(r.Context()),
}
}
diff --git a/internal/import/csv.go b/internal/import/csv.go
new file mode 100644
index 0000000..237d0bf
--- /dev/null
+++ b/internal/import/csv.go
@@ -0,0 +1,68 @@
+// 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 imports
+
+import (
+ "context"
+ _ "embed"
+ "fmt"
+ "io"
+
+ pkgcsv "github.com/bit8bytes/gearberg/pkg/csv"
+)
+
+// TemplateCSV is the downloadable CSV template for users to fill out before importing.
+//
+//go:embed template.csv
+var TemplateCSV []byte
+
+// CSVReader adapts pkg/csv.Reader to the imports.Reader and imports.Inspector interfaces.
+type CSVReader struct {
+ r pkgcsv.Reader
+}
+
+func init() {
+ RegisterFormat(FormatCSV, &CSVReader{r: pkgcsv.Reader{}})
+}
+
+// NewCSVReader returns a CSVReader wrapping a pkg/csv.Reader.
+func NewCSVReader(r pkgcsv.Reader) *CSVReader {
+ return &CSVReader{r: r}
+}
+
+func (c *CSVReader) Read(ctx context.Context, r io.Reader) ([]RawRecord, error) {
+ recs, err := c.r.Read(ctx, r)
+ if err != nil {
+ return nil, fmt.Errorf("CSVReader.Read: %w", err)
+ }
+ out := make([]RawRecord, len(recs))
+ for i, rec := range recs {
+ out[i] = RawRecord{
+ RowNumber: rec.Line,
+ Fields: rec.Fields,
+ }
+ }
+ return out, nil
+}
+
+// InspectHeaders returns only the header row of the CSV without reading all records.
+func (c *CSVReader) InspectHeaders(ctx context.Context, r io.Reader) ([]string, error) {
+ headers, err := c.r.InspectHeaders(ctx, r)
+ if err != nil {
+ return nil, fmt.Errorf("CSVReader.InspectHeaders: %w", err)
+ }
+ return headers, nil
+}
diff --git a/internal/import/model.go b/internal/import/model.go
new file mode 100644
index 0000000..c50b9ed
--- /dev/null
+++ b/internal/import/model.go
@@ -0,0 +1,124 @@
+// 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 imports provides format-agnostic import functionality.
+package imports
+
+import (
+ "errors"
+ "fmt"
+)
+
+// ErrNotFound is returned when a session or row is not found.
+var ErrNotFound = errors.New("import: not found")
+
+// Format identifies the source file format.
+type Format string
+
+// Supported import file formats.
+const (
+ FormatCSV Format = "csv"
+ FormatJSON Format = "json"
+)
+
+var readers = map[Format]Reader{}
+
+// RegisterFormat registers a Reader for the given Format.
+// Call from an init() function in the format's package.
+func RegisterFormat(f Format, r Reader) {
+ readers[f] = r
+}
+
+func readerFor(f Format) (Reader, error) {
+ r, ok := readers[f]
+ if !ok {
+ return nil, fmt.Errorf("import: no reader registered for format %q", f)
+ }
+ return r, nil
+}
+
+// Status tracks the lifecycle of an import session.
+// Each value describes the state the session is in, not the action that caused it.
+type Status string
+
+// Import session lifecycle states.
+const (
+ StatusDraft Status = "draft" // uploaded, awaiting column mapping
+ StatusMapped Status = "mapped" // mappings saved, awaiting validation
+ StatusReady Status = "ready" // validated, awaiting user commit
+ StatusCommitted Status = "committed" // import written to inventory
+)
+
+// Action is the user's decision for a single row.
+type Action string
+
+// User decisions for a single import row.
+const (
+ ActionPending Action = "pending"
+ ActionCreate Action = "create"
+ ActionSkip Action = "skip"
+)
+
+// RowStatus is the system's assessment of a single row.
+type RowStatus string
+
+// System assessments for a single import row.
+const (
+ RowStatusNew RowStatus = "new"
+ RowStatusValid RowStatus = "valid"
+ RowStatusError RowStatus = "error"
+)
+
+// Session represents an import session.
+type Session struct {
+ ID string
+ OrgID string
+ Format Format
+ Status Status
+ TargetEntity string
+ CreatedAt int64
+}
+
+// Row is a single staged import row.
+type Row struct {
+ ID string
+ SessionID string
+ RowNumber int64
+ Data string // JSON blob of raw source columns
+ Status RowStatus
+ ErrorMessage string
+ Action Action
+}
+
+// Mapping is a user-defined column mapping for a session.
+type Mapping struct {
+ ID string
+ SessionID string
+ SourceCol string
+ TargetField string
+}
+
+// Decision is a user's action choice for a single row during review.
+type Decision struct {
+ RowID string
+ Action Action
+}
+
+// RawRecord is a parsed row from any source format.
+// Fields maps source column names to their raw string values.
+type RawRecord struct {
+ RowNumber int
+ Fields map[string]string
+}
diff --git a/internal/import/repository.go b/internal/import/repository.go
new file mode 100644
index 0000000..fd593c1
--- /dev/null
+++ b/internal/import/repository.go
@@ -0,0 +1,234 @@
+// 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 imports
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+
+ gendata "github.com/bit8bytes/gearberg/internal/database/queries/gen/importdata"
+ genmappings "github.com/bit8bytes/gearberg/internal/database/queries/gen/importmappings"
+ gensessions "github.com/bit8bytes/gearberg/internal/database/queries/gen/importsessions"
+)
+
+// Repository provides data access for import sessions, rows, and mappings.
+type Repository struct {
+ db *sql.DB
+ sessions *gensessions.Queries
+ data *gendata.Queries
+ mappings *genmappings.Queries
+}
+
+// NewRepository returns a new Repository.
+func NewRepository(db *sql.DB) *Repository {
+ return &Repository{
+ db: db,
+ sessions: gensessions.New(db),
+ data: gendata.New(db),
+ mappings: genmappings.New(db),
+ }
+}
+
+// InsertSessionTx inserts a new import session within an existing transaction.
+func (r *Repository) InsertSessionTx(ctx context.Context, tx *sql.Tx, s Session) (Session, error) {
+ row, err := r.sessions.WithTx(tx).InsertSession(ctx, gensessions.InsertSessionParams{
+ ID: s.ID,
+ OrgID: s.OrgID,
+ Format: string(s.Format),
+ Status: string(s.Status),
+ TargetEntity: s.TargetEntity,
+ })
+ if err != nil {
+ return Session{}, fmt.Errorf("InsertSessionTx: %w", err)
+ }
+ return sessionFromRecord(row), nil
+}
+
+// GetSession returns a session by ID, or ErrNotFound if none exists.
+func (r *Repository) GetSession(ctx context.Context, id string) (Session, error) {
+ row, err := r.sessions.GetSession(ctx, id)
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return Session{}, ErrNotFound
+ }
+ return Session{}, fmt.Errorf("GetSession: %w", err)
+ }
+ return sessionFromRecord(row), nil
+}
+
+// GetStagedSession returns the active ready session for an org, or ErrNotFound if none exists.
+func (r *Repository) GetStagedSession(ctx context.Context, orgID string) (Session, error) {
+ row, err := r.sessions.GetStagedSession(ctx, gensessions.GetStagedSessionParams{
+ OrgID: orgID,
+ Status: string(StatusReady),
+ })
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return Session{}, ErrNotFound
+ }
+ return Session{}, fmt.Errorf("GetStagedSession: %w", err)
+ }
+ return sessionFromRecord(row), nil
+}
+
+// DeleteSession deletes a session and cascades to its rows and mappings.
+func (r *Repository) DeleteSession(ctx context.Context, id string) error {
+ if err := r.sessions.DeleteSession(ctx, id); err != nil {
+ return fmt.Errorf("DeleteSession: %w", err)
+ }
+ return nil
+}
+
+// UpdateSessionStatus transitions a session to the given status and returns the updated record.
+func (r *Repository) UpdateSessionStatus(ctx context.Context, id string, status Status) (Session, error) {
+ row, err := r.sessions.UpdateSessionStatus(ctx, gensessions.UpdateSessionStatusParams{
+ Status: string(status),
+ ID: id,
+ })
+ if err != nil {
+ return Session{}, fmt.Errorf("UpdateSessionStatus: %w", err)
+ }
+ return sessionFromRecord(row), nil
+}
+
+// InsertDataTx inserts a staged import row within an existing transaction.
+func (r *Repository) InsertDataTx(ctx context.Context, tx *sql.Tx, row Row) (Row, error) {
+ rec, err := r.data.WithTx(tx).InsertData(ctx, gendata.InsertDataParams{
+ ID: row.ID,
+ SessionID: row.SessionID,
+ RowNumber: row.RowNumber,
+ Data: row.Data,
+ })
+ if err != nil {
+ return Row{}, fmt.Errorf("InsertDataTx: %w", err)
+ }
+ return dataFromRecord(rec), nil
+}
+
+// ListData returns all staged rows for a session.
+func (r *Repository) ListData(ctx context.Context, sessionID string) ([]Row, error) {
+ recs, err := r.data.ListData(ctx, sessionID)
+ if err != nil {
+ return nil, fmt.Errorf("ListData: %w", err)
+ }
+ rows := make([]Row, len(recs))
+ for i, rec := range recs {
+ rows[i] = dataFromRecord(rec)
+ }
+ return rows, nil
+}
+
+// ListDataByAction returns staged rows filtered by the given action.
+func (r *Repository) ListDataByAction(ctx context.Context, sessionID string, action Action) ([]Row, error) {
+ recs, err := r.data.ListDataByAction(ctx, gendata.ListDataByActionParams{
+ SessionID: sessionID,
+ Action: string(action),
+ })
+ if err != nil {
+ return nil, fmt.Errorf("ListDataByAction: %w", err)
+ }
+ rows := make([]Row, len(recs))
+ for i, rec := range recs {
+ rows[i] = dataFromRecord(rec)
+ }
+ return rows, nil
+}
+
+// UpdateDataStatus sets the validation status and error message for a row.
+func (r *Repository) UpdateDataStatus(ctx context.Context, id string, status RowStatus, errMsg string) error {
+ _, err := r.data.UpdateDataStatus(ctx, gendata.UpdateDataStatusParams{
+ Status: string(status),
+ ErrorMessage: errMsg,
+ ID: id,
+ })
+ if err != nil {
+ return fmt.Errorf("UpdateDataStatus: %w", err)
+ }
+ return nil
+}
+
+// UpdateDataAction sets the user-chosen action for a row and returns the updated record.
+func (r *Repository) UpdateDataAction(ctx context.Context, id string, action Action) (Row, error) {
+ rec, err := r.data.UpdateDataAction(ctx, gendata.UpdateDataActionParams{
+ Action: string(action),
+ ID: id,
+ })
+ if err != nil {
+ return Row{}, fmt.Errorf("UpdateDataAction: %w", err)
+ }
+ return dataFromRecord(rec), nil
+}
+
+// InsertMappingTx inserts a column mapping within an existing transaction.
+func (r *Repository) InsertMappingTx(ctx context.Context, tx *sql.Tx, m Mapping) (Mapping, error) {
+ rec, err := r.mappings.WithTx(tx).InsertMapping(ctx, genmappings.InsertMappingParams{
+ ID: m.ID,
+ SessionID: m.SessionID,
+ SourceCol: m.SourceCol,
+ TargetField: m.TargetField,
+ })
+ if err != nil {
+ return Mapping{}, fmt.Errorf("InsertMappingTx: %w", err)
+ }
+ return mappingFromRecord(rec), nil
+}
+
+// ListMappings returns all column mappings for a session.
+func (r *Repository) ListMappings(ctx context.Context, sessionID string) ([]Mapping, error) {
+ recs, err := r.mappings.ListMappings(ctx, sessionID)
+ if err != nil {
+ return nil, fmt.Errorf("ListMappings: %w", err)
+ }
+ ms := make([]Mapping, len(recs))
+ for i, rec := range recs {
+ ms[i] = mappingFromRecord(rec)
+ }
+ return ms, nil
+}
+
+func sessionFromRecord(r gensessions.ImportSession) Session {
+ return Session{
+ ID: r.ID,
+ OrgID: r.OrgID,
+ Format: Format(r.Format),
+ Status: Status(r.Status),
+ TargetEntity: r.TargetEntity,
+ CreatedAt: r.CreatedAt,
+ }
+}
+
+func dataFromRecord(r gendata.ImportDatum) Row {
+ return Row{
+ ID: r.ID,
+ SessionID: r.SessionID,
+ RowNumber: r.RowNumber,
+ Data: r.Data,
+ Status: RowStatus(r.Status),
+ ErrorMessage: r.ErrorMessage,
+ Action: Action(r.Action),
+ }
+}
+
+func mappingFromRecord(r genmappings.ImportMapping) Mapping {
+ return Mapping{
+ ID: r.ID,
+ SessionID: r.SessionID,
+ SourceCol: r.SourceCol,
+ TargetField: r.TargetField,
+ }
+}
diff --git a/internal/import/service.go b/internal/import/service.go
new file mode 100644
index 0000000..7944659
--- /dev/null
+++ b/internal/import/service.go
@@ -0,0 +1,267 @@
+// 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 imports
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "fmt"
+ "io"
+
+ "github.com/bit8bytes/gearberg/internal/uid"
+)
+
+// Reader parses an input source into raw records.
+// Satisfied by *csv.Reader or any future format reader.
+type Reader interface {
+ Read(ctx context.Context, r io.Reader) ([]RawRecord, error)
+}
+
+// Inspector reads only the header layer of a file to discover column names
+// for the field-mapping UI, without parsing the full dataset.
+type Inspector interface {
+ InspectHeaders(ctx context.Context, r io.Reader) ([]string, error)
+}
+
+// Step is a single validation stage applied to a batch of rows.
+// A Step must never return early on a per-row error; it sets the row's
+// status and error message and continues. Steps that need DB access close
+// over their own dependencies.
+type Step func(ctx context.Context, rows []Row) ([]Row, error)
+
+// CommitHandler writes committed rows to the target domain (e.g. equipment).
+type CommitHandler interface {
+ Commit(ctx context.Context, orgID string, rows []Row, mappings []Mapping) error
+}
+
+// StepProvider returns the validation steps for a given org.
+type StepProvider interface {
+ StepsFor(orgID string) []Step
+}
+
+// Service orchestrates the import pipeline.
+type Service struct {
+ db *sql.DB
+ repo *Repository
+ steps StepProvider
+ handler CommitHandler
+ entity string
+}
+
+// NewService returns a new Service.
+func NewService(db *sql.DB, repo *Repository, steps StepProvider, handler CommitHandler, entity string) *Service {
+ return &Service{db: db, repo: repo, steps: steps, handler: handler, entity: entity}
+}
+
+// StartSession parses the file, creates an import session, stores raw rows,
+// runs validation steps, and returns the staged Session.
+func (s *Service) StartSession(ctx context.Context, orgID string, format Format, r io.Reader) (Session, error) {
+ reader, err := readerFor(format)
+ if err != nil {
+ return Session{}, fmt.Errorf("StartSession: %w", err)
+ }
+ records, err := reader.Read(ctx, r)
+ if err != nil {
+ return Session{}, fmt.Errorf("StartSession: read: %w", err)
+ }
+
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return Session{}, fmt.Errorf("StartSession: begin tx: %w", err)
+ }
+ defer func() { _ = tx.Rollback() }()
+
+ session, rows, err := s.stageSession(ctx, tx, orgID, format, records)
+ if err != nil {
+ return Session{}, fmt.Errorf("StartSession: %w", err)
+ }
+
+ if err := tx.Commit(); err != nil {
+ return Session{}, fmt.Errorf("StartSession: commit: %w", err)
+ }
+
+ rows, err = validateRows(ctx, s.steps.StepsFor(orgID), rows)
+ if err != nil {
+ return Session{}, fmt.Errorf("StartSession: %w", err)
+ }
+
+ if err := persistRows(ctx, s.repo, rows); err != nil {
+ return Session{}, fmt.Errorf("StartSession: %w", err)
+ }
+
+ session, err = s.repo.UpdateSessionStatus(ctx, session.ID, StatusReady)
+ if err != nil {
+ return Session{}, fmt.Errorf("StartSession: %w", err)
+ }
+
+ return session, nil
+}
+
+func (s *Service) stageSession(ctx context.Context, tx *sql.Tx, orgID string, format Format, records []RawRecord) (Session, []Row, error) {
+ session, err := s.repo.InsertSessionTx(ctx, tx, Session{
+ ID: uid.New(),
+ OrgID: orgID,
+ Format: format,
+ Status: StatusDraft,
+ TargetEntity: s.entity,
+ })
+ if err != nil {
+ return Session{}, nil, fmt.Errorf("stageSession: %w", err)
+ }
+
+ // Auto-map source col → target field using the first record's keys.
+ // The mapping UI is not implemented yet, so we assume the source columns
+ // already match the internal field names (e.g. via the Gearberg template CSV).
+ // When the mapping UI is added, this block is replaced by user-defined mappings.
+ if len(records) > 0 {
+ for col := range records[0].Fields {
+ if _, err := s.repo.InsertMappingTx(ctx, tx, Mapping{
+ ID: uid.New(),
+ SessionID: session.ID,
+ SourceCol: col,
+ TargetField: col,
+ }); err != nil {
+ return Session{}, nil, fmt.Errorf("stageSession: auto-map %q: %w", col, err)
+ }
+ }
+ }
+
+ rows := make([]Row, 0, len(records))
+ for _, rec := range records {
+ blob, err := json.Marshal(rec.Fields)
+ if err != nil {
+ return Session{}, nil, fmt.Errorf("stageSession: marshal row %d: %w", rec.RowNumber, err)
+ }
+ row, err := s.repo.InsertDataTx(ctx, tx, Row{
+ ID: uid.New(),
+ SessionID: session.ID,
+ RowNumber: int64(rec.RowNumber),
+ Data: string(blob),
+ })
+ if err != nil {
+ return Session{}, nil, fmt.Errorf("stageSession: insert row %d: %w", rec.RowNumber, err)
+ }
+ rows = append(rows, row)
+ }
+
+ return session, rows, nil
+}
+
+// validateRows runs all steps against rows and assigns ActionCreate to rows
+// that passed all steps.
+func validateRows(ctx context.Context, steps []Step, rows []Row) ([]Row, error) {
+ rows, err := runSteps(ctx, rows, steps)
+ if err != nil {
+ return nil, fmt.Errorf("ValidateRows: %w", err)
+ }
+ for i, row := range rows {
+ if row.Status == RowStatusNew {
+ rows[i].Status = RowStatusValid
+ rows[i].Action = ActionCreate
+ }
+ }
+ return rows, nil
+}
+
+// persistRows writes the status and action of each row back to the database.
+func persistRows(ctx context.Context, repo *Repository, rows []Row) error {
+ for _, row := range rows {
+ if err := repo.UpdateDataStatus(ctx, row.ID, row.Status, row.ErrorMessage); err != nil {
+ return fmt.Errorf("PersistRows: row %s: %w", row.ID, err)
+ }
+ if _, err := repo.UpdateDataAction(ctx, row.ID, row.Action); err != nil {
+ return fmt.Errorf("PersistRows: action %s: %w", row.ID, err)
+ }
+ }
+ return nil
+}
+
+// GetStagedSession returns the active staged session for an org, or ErrNotFound if none exists.
+func (s *Service) GetStagedSession(ctx context.Context, orgID string) (Session, error) {
+ session, err := s.repo.GetStagedSession(ctx, orgID)
+ if err != nil {
+ return Session{}, fmt.Errorf("get staged session: %w", err)
+ }
+ return session, nil
+}
+
+// Delete removes a session and all its rows and mappings (via CASCADE).
+func (s *Service) Delete(ctx context.Context, sessionID string) error {
+ if err := s.repo.DeleteSession(ctx, sessionID); err != nil {
+ return fmt.Errorf("Delete: %w", err)
+ }
+ return nil
+}
+
+// ListData returns all staged rows for a session.
+func (s *Service) ListData(ctx context.Context, sessionID string) ([]Row, error) {
+ rows, err := s.repo.ListData(ctx, sessionID)
+ if err != nil {
+ return nil, fmt.Errorf("ListData: %w", err)
+ }
+ return rows, nil
+}
+
+// Review applies user decisions to staged rows.
+func (s *Service) Review(ctx context.Context, decisions []Decision) error {
+ for _, d := range decisions {
+ if _, err := s.repo.UpdateDataAction(ctx, d.RowID, d.Action); err != nil {
+ return fmt.Errorf("Review: row %s: %w", d.RowID, err)
+ }
+ }
+ return nil
+}
+
+// Commit finalises the import by delegating target-entity writes to the
+// CommitHandler provided at construction.
+func (s *Service) Commit(ctx context.Context, sessionID string) error {
+ session, err := s.repo.GetSession(ctx, sessionID)
+ if err != nil {
+ return fmt.Errorf("Commit: %w", err)
+ }
+
+ rows, err := s.repo.ListDataByAction(ctx, sessionID, ActionCreate)
+ if err != nil {
+ return fmt.Errorf("Commit: %w", err)
+ }
+
+ mappings, err := s.repo.ListMappings(ctx, sessionID)
+ if err != nil {
+ return fmt.Errorf("Commit: %w", err)
+ }
+
+ if err := s.handler.Commit(ctx, session.OrgID, rows, mappings); err != nil {
+ return fmt.Errorf("Commit: %w", err)
+ }
+
+ if _, err := s.repo.UpdateSessionStatus(ctx, sessionID, StatusCommitted); err != nil {
+ return fmt.Errorf("Commit: %w", err)
+ }
+
+ return nil
+}
+
+func runSteps(ctx context.Context, rows []Row, steps []Step) ([]Row, error) {
+ var err error
+ for _, step := range steps {
+ rows, err = step(ctx, rows)
+ if err != nil {
+ return nil, err
+ }
+ }
+ return rows, nil
+}
diff --git a/internal/import/template.csv b/internal/import/template.csv
new file mode 100644
index 0000000..201cb2b
--- /dev/null
+++ b/internal/import/template.csv
@@ -0,0 +1,5 @@
+Name,Type,Tracking Type,Usage,Category,Manufacturer,Location,Rental Price,Resale Price,Notes,Weight (kg),Width (cm),Height (cm),Depth (cm),Voltage (V),Current (A),Power (W),Wire Gauge (mm² ×100),Quantity,Unit Serial Number,Unit Manufacturer Serial,Unit Purchase Price,Unit Purchased At,Next Inspection At,Unit Active,Unit Remark
+Shure SM58,standard,bulk,Rental,Audio,Shure,Main Warehouse,15.00,99.00,Cardioid dynamic vocal microphone,0.298,4.7,4.7,16.2,,,,,7,,,,,,,,
+Sony A7 IV,standard,serialized,Rental,Camera,Sony,Main Warehouse,80.00,2800.00,Full-frame mirrorless camera,0.659,13.1,9.6,8.0,,,,,,SN-A7IV-001,7-000001,2800.00,2024-03-15,2025-03-15,true,
+Sony A7 IV,standard,serialized,Rental,Camera,Sony,Main Warehouse,80.00,2800.00,Full-frame mirrorless camera,0.659,13.1,9.6,8.0,,,,,,SN-A7IV-002,7-000002,2800.00,2024-03-15,2025-03-15,true,Minor scratch on top plate
+Pelican 1510 Case,kit,serialized,Rental,Case,Pelican,Main Warehouse,10.00,120.00,Carry-on approved hard case,2.8,56.3,35.5,22.9,,,,,,,PC-1510-001,,120.00,2024-01-10,,true,
diff --git a/internal/templates/layouts/equipment.tmpl b/internal/templates/layouts/equipment.tmpl
index 222897e..2add139 100644
--- a/internal/templates/layouts/equipment.tmpl
+++ b/internal/templates/layouts/equipment.tmpl
@@ -2,4 +2,11 @@
{{ template "header" . }}
{{template "page" .}}
{{ template "footer" . }}
+ {{ with .Flash }}
+
+ {{ end }}
{{end}}
diff --git a/internal/templates/pages/equipment/index.tmpl b/internal/templates/pages/equipment/index.tmpl
index 1052dc7..93b8fab 100644
--- a/internal/templates/pages/equipment/index.tmpl
+++ b/internal/templates/pages/equipment/index.tmpl
@@ -22,12 +22,21 @@