diff --git a/backend/app/api/handlers/v1/v1_ctrl_pdf_export.go b/backend/app/api/handlers/v1/v1_ctrl_pdf_export.go new file mode 100644 index 000000000..8b6bd232b --- /dev/null +++ b/backend/app/api/handlers/v1/v1_ctrl_pdf_export.go @@ -0,0 +1,253 @@ +package v1 + +import ( + "fmt" + "net/http" + "strings" + + "github.com/google/uuid" + "github.com/hay-kot/httpkit/errchain" + "github.com/hay-kot/httpkit/server" + "github.com/rs/zerolog/log" + "github.com/sysadminsmedia/homebox/backend/internal/core/services" + "github.com/sysadminsmedia/homebox/backend/internal/data/repo" + "github.com/sysadminsmedia/homebox/backend/internal/sys/validate" +) + +const ( + // maxPDFExportItems caps the number of items in a single PDF export + // to prevent excessive memory usage and request timeouts. + maxPDFExportItems = 500 +) + +// sanitizeFilename removes or escapes characters that could cause issues +// in HTTP Content-Disposition headers (e.g., header injection via quotes, +// newlines, or semicolons in user-controlled asset IDs). +func sanitizeFilename(name string) string { + replacer := strings.NewReplacer( + "\"", "'", + "\n", "", + "\r", "", + ";", "_", + ) + return replacer.Replace(name) +} + +// HandleItemExportPDF godoc +// +// @Summary Export Single Item as PDF +// @Tags Items +// @Produce application/pdf +// @Param id path string true "Item ID" +// @Param theme query string false "PDF theme (navy, modern, minimal, forest)" +// @Param photos query bool false "Include photos in export (default: true)" +// @Param owner query string false "Owner name for cover page" +// @Success 200 {file} file "PDF document" +// @Router /v1/items/{id}/export/pdf [GET] +// @Security Bearer +func (ctrl *V1Controller) HandleItemExportPDF() errchain.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) error { + // Parse the item ID from the URL path + itemID, err := ctrl.routeID(r) + if err != nil { + return err + } + + ctx := services.NewContext(r.Context()) + + // Build export options from query parameters + opts := services.PDFExportOptions{ + Theme: r.URL.Query().Get("theme"), + IncludePhotos: r.URL.Query().Get("photos") != "false", // default true + OwnerName: r.URL.Query().Get("owner"), + } + + // Generate the PDF using the export service + pdfSvc := services.NewPDFExportService(ctrl.repo) + pdfBytes, filename, err := pdfSvc.ExportSingleItem(ctx, ctx.GID, itemID, opts) + if err != nil { + log.Err(err).Msg("failed to export item as PDF") + return validate.NewRequestError(err, http.StatusInternalServerError) + } + + // Set response headers for PDF file download + w.Header().Set("Content-Type", "application/pdf") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", sanitizeFilename(filename))) + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(pdfBytes))) + + _, err = w.Write(pdfBytes) + return err + } +} + +// multiExportRequest is the JSON body for bulk PDF export requests. +// Clients send a list of item IDs to include in the report. +type multiExportRequest struct { + ItemIDs []string `json:"itemIds" validate:"required,min=1"` +} + +// HandleItemsExportPDF godoc +// +// @Summary Export Multiple Items as PDF +// @Tags Items +// @Accept json +// @Produce application/pdf +// @Param payload body multiExportRequest true "Item IDs to export" +// @Param theme query string false "PDF theme (navy, modern, minimal, forest)" +// @Param photos query bool false "Include photos in export (default: true)" +// @Param owner query string false "Owner name for cover page" +// @Success 200 {file} file "PDF document" +// @Router /v1/items/export/pdf [POST] +// @Security Bearer +func (ctrl *V1Controller) HandleItemsExportPDF() errchain.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) error { + ctx := services.NewContext(r.Context()) + + // Decode the request body containing item IDs + var body multiExportRequest + if err := server.Decode(r, &body); err != nil { + return validate.NewRequestError(err, http.StatusBadRequest) + } + + if len(body.ItemIDs) == 0 { + return validate.NewRequestError( + fmt.Errorf("at least one item ID is required"), + http.StatusBadRequest, + ) + } + + // Parse string UUIDs into uuid.UUID values + itemIDs, err := parseUUIDs(body.ItemIDs) + if err != nil { + return validate.NewRequestError(err, http.StatusBadRequest) + } + + // Build export options from query parameters + opts := services.PDFExportOptions{ + Theme: r.URL.Query().Get("theme"), + IncludePhotos: r.URL.Query().Get("photos") != "false", + OwnerName: r.URL.Query().Get("owner"), + } + + // Generate the multi-item PDF report + pdfSvc := services.NewPDFExportService(ctrl.repo) + pdfBytes, filename, err := pdfSvc.ExportMultipleItems(ctx, ctx.GID, itemIDs, opts) + if err != nil { + log.Err(err).Msg("failed to export items as PDF") + return validate.NewRequestError(err, http.StatusInternalServerError) + } + + // Set response headers for PDF file download + w.Header().Set("Content-Type", "application/pdf") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", sanitizeFilename(filename))) + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(pdfBytes))) + + _, err = w.Write(pdfBytes) + return err + } +} + +// HandleItemsExportAllPDF godoc +// +// @Summary Export All Items as PDF +// @Tags Items +// @Produce application/pdf +// @Param theme query string false "PDF theme (navy, modern, minimal, forest)" +// @Param photos query bool false "Include photos in export (default: true)" +// @Param owner query string false "Owner name for cover page" +// @Success 200 {file} file "PDF document" +// @Router /v1/items/export/pdf [GET] +// @Security Bearer +func (ctrl *V1Controller) HandleItemsExportAllPDF() errchain.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) error { + ctx := services.NewContext(r.Context()) + + // Query all items for the user's group with pagination disabled (-1 = all results) + allItems, err := ctrl.repo.Items.QueryByGroup(ctx, ctx.GID, repo.ItemQuery{ + Page: -1, + PageSize: -1, + }) + if err != nil { + log.Err(err).Msg("failed to query items for PDF export") + return validate.NewRequestError(err, http.StatusInternalServerError) + } + + if len(allItems.Items) == 0 { + return validate.NewRequestError( + fmt.Errorf("no items found to export"), + http.StatusNotFound, + ) + } + + // Enforce item limit to prevent excessive memory usage and timeouts + if len(allItems.Items) > maxPDFExportItems { + return validate.NewRequestError( + fmt.Errorf("too many items to export (%d); maximum is %d — use bulk export with specific item IDs instead", len(allItems.Items), maxPDFExportItems), + http.StatusBadRequest, + ) + } + + // Collect all item IDs from the query result + itemIDs := make([]uuid.UUID, len(allItems.Items)) + for i, item := range allItems.Items { + itemIDs[i] = item.ID + } + + // Build export options from query parameters + opts := services.PDFExportOptions{ + Theme: r.URL.Query().Get("theme"), + IncludePhotos: r.URL.Query().Get("photos") != "false", + OwnerName: r.URL.Query().Get("owner"), + } + + // Generate the full-inventory PDF report + pdfSvc := services.NewPDFExportService(ctrl.repo) + pdfBytes, filename, err := pdfSvc.ExportMultipleItems(ctx, ctx.GID, itemIDs, opts) + if err != nil { + log.Err(err).Msg("failed to export all items as PDF") + return validate.NewRequestError(err, http.StatusInternalServerError) + } + + // Set response headers for PDF file download + w.Header().Set("Content-Type", "application/pdf") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", sanitizeFilename(filename))) + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(pdfBytes))) + + _, err = w.Write(pdfBytes) + return err + } +} + +// HandlePDFThemes godoc +// +// @Summary Get Available PDF Themes +// @Tags Items +// @Produce json +// @Success 200 {object} map[string]string +// @Router /v1/items/export/pdf/themes [GET] +// @Security Bearer +func (ctrl *V1Controller) HandlePDFThemes() errchain.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) error { + // Return a map of theme key -> display name for the frontend to render + themes := make(map[string]string) + for key, theme := range services.PDFThemes { + themes[key] = theme.Name + } + + return server.JSON(w, http.StatusOK, themes) + } +} + +// parseUUIDs converts a slice of string UUIDs to uuid.UUID values. +// Returns an error if any string is not a valid UUID. +func parseUUIDs(strs []string) ([]uuid.UUID, error) { + ids := make([]uuid.UUID, 0, len(strs)) + for _, s := range strs { + id, err := uuid.Parse(s) + if err != nil { + return nil, fmt.Errorf("invalid UUID: %s", s) + } + ids = append(ids, id) + } + return ids, nil +} diff --git a/backend/app/api/routes.go b/backend/app/api/routes.go index 058cb43bc..9235761a5 100644 --- a/backend/app/api/routes.go +++ b/backend/app/api/routes.go @@ -145,6 +145,10 @@ func (a *app) mountRoutes(r *chi.Mux, chain *errchain.ErrChain, repos *repo.AllR r.Post("/items", chain.ToHandlerFunc(v1Ctrl.HandleItemsCreate(), userMW...)) r.Post("/items/import", chain.ToHandlerFunc(v1Ctrl.HandleItemsImport(), userMW...)) r.Get("/items/export", chain.ToHandlerFunc(v1Ctrl.HandleItemsExport(), userMW...)) + // PDF export endpoints — single item, bulk selection, full inventory, and theme listing + r.Get("/items/export/pdf", chain.ToHandlerFunc(v1Ctrl.HandleItemsExportAllPDF(), userMW...)) + r.Post("/items/export/pdf", chain.ToHandlerFunc(v1Ctrl.HandleItemsExportPDF(), userMW...)) + r.Get("/items/export/pdf/themes", chain.ToHandlerFunc(v1Ctrl.HandlePDFThemes(), userMW...)) r.Get("/items/fields", chain.ToHandlerFunc(v1Ctrl.HandleGetAllCustomFieldNames(), userMW...)) r.Get("/items/fields/values", chain.ToHandlerFunc(v1Ctrl.HandleGetAllCustomFieldValues(), userMW...)) @@ -154,6 +158,7 @@ func (a *app) mountRoutes(r *chi.Mux, chain *errchain.ErrChain, repos *repo.AllR r.Patch("/items/{id}", chain.ToHandlerFunc(v1Ctrl.HandleItemPatch(), userMW...)) r.Delete("/items/{id}", chain.ToHandlerFunc(v1Ctrl.HandleItemDelete(), userMW...)) r.Post("/items/{id}/duplicate", chain.ToHandlerFunc(v1Ctrl.HandleItemDuplicate(), userMW...)) + r.Get("/items/{id}/export/pdf", chain.ToHandlerFunc(v1Ctrl.HandleItemExportPDF(), userMW...)) // Item attachment endpoints r.Post("/items/{id}/attachments", chain.ToHandlerFunc(v1Ctrl.HandleItemAttachmentCreate(), userMW...)) diff --git a/backend/go.mod b/backend/go.mod index eb50a6c78..197a9735a 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -3,6 +3,7 @@ module github.com/sysadminsmedia/homebox/backend go 1.26.0 require ( + codeberg.org/go-pdf/fpdf v0.11.1 entgo.io/ent v0.14.5 github.com/XSAM/otelsql v0.41.0 github.com/ardanlabs/conf/v3 v3.11.0 diff --git a/backend/go.sum b/backend/go.sum index 9bf78e362..8ec5799dd 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -26,6 +26,8 @@ cloud.google.com/go/storage v1.57.2 h1:sVlym3cHGYhrp6XZKkKb+92I1V42ks2qKKpB0CF5M cloud.google.com/go/storage v1.57.2/go.mod h1:n5ijg4yiRXXpCu0sJTD6k+eMf7GRrJmPyr9YxLXGHOk= cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U= cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s= +codeberg.org/go-pdf/fpdf v0.11.1 h1:U8+coOTDVLxHIXZgGvkfQEi/q0hYHYvEHFuGNX2GzGs= +codeberg.org/go-pdf/fpdf v0.11.1/go.mod h1:Y0DGRAdZ0OmnZPvjbMp/1bYxmIPxm0ws4tfoPOc4LjU= entgo.io/ent v0.14.5 h1:Rj2WOYJtCkWyFo6a+5wB3EfBRP0rnx1fMk6gGA0UUe4= entgo.io/ent v0.14.5/go.mod h1:zTzLmWtPvGpmSwtkaayM2cm5m819NdM7z7tYPq3vN0U= github.com/Azure/azure-amqp-common-go/v3 v3.2.3 h1:uDF62mbd9bypXWi19V1bN5NZEO84JqgmI5G73ibAmrk= @@ -75,8 +77,6 @@ github.com/IBM/sarama v1.47.0 h1:GcQFEd12+KzfPYeLgN69Fh7vLCtYRhVIx0rO4TZO318= github.com/IBM/sarama v1.47.0/go.mod h1:7gLLIU97nznOmA6TX++Qds+DRxH89P2XICY2KAQUzAY= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= -github.com/XSAM/otelsql v0.40.0 h1:8jaiQ6KcoEXF46fBmPEqb+pp29w2xjWfuXjZXTXBjaA= -github.com/XSAM/otelsql v0.40.0/go.mod h1:/7F+1XKt3/sTlYtwKtkHQ5Gzoom+EerXmD1VdnTqfB4= github.com/XSAM/otelsql v0.41.0 h1:uZifjQhZhv5EDYJh+IVk1DiYxQZJBlNSen0MBFnfxB8= github.com/XSAM/otelsql v0.41.0/go.mod h1:NMQT0PiKoFILp9QgjQz+D5mvW+9mT0suR7OejqrtMaM= github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo= @@ -339,8 +339,6 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= -github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk= github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= @@ -363,8 +361,6 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/olahol/melody v1.4.0 h1:Pa5SdeZL/zXPi1tJuMAPDbl4n3gQOThSL6G1p4qZ4SI= github.com/olahol/melody v1.4.0/go.mod h1:GgkTl6Y7yWj/HtfD48Q5vLKPVoZOH+Qqgfa7CvJgJM4= -github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= -github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/onsi/ginkgo/v2 v2.9.2 h1:BA2GMJOtfGAfagzYtrAlufIP0lq6QERkFmHLMLPwFSU= github.com/onsi/ginkgo/v2 v2.9.2/go.mod h1:WHcJJG2dIlcCqVfBAwUCrJxSPFb6v4azBwgxeMeDuts= github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE= @@ -409,10 +405,6 @@ github.com/shirou/gopsutil/v4 v4.26.2 h1:X8i6sicvUFih4BmYIGT1m2wwgw2VG9YgrDTi7cI github.com/shirou/gopsutil/v4 v4.26.2/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= -github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= -github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -476,24 +468,18 @@ go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0 h1:deI9UQMoG go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0/go.mod h1:PFx9NgpNUKXdf7J4Q3agRxMs3Y07QhTCVipKmLsMKnU= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.18.0 h1:icqq3Z34UrEFk2u+HMhTtRsvo7Ues+eiJVjaJt62njs= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.18.0/go.mod h1:W2m8P+d5Wn5kipj4/xmbt9uMqezEKfBjzVJadfABSBE= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0 h1:vl9obrcoWVKp/lwl8tRE33853I8Xru9HFbw/skNeLs8= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0/go.mod h1:GAXRxmLJcVM3u22IjTg74zWBrRCKq8BnOqUVLodpcpw= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0 h1:MdKucPl/HbzckWWEisiNqMPhRrAOQX8r4jTuGr636gk= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0/go.mod h1:RolT8tWtfHcjajEH5wFIZ4Dgh5jpPdFXYV9pTAk/qjc= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0 h1:H7O6RlGOMTizyl3R08Kn5pdM06bnH8oscSj7o11tmLA= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0/go.mod h1:mBFWu/WOVDkWWsR7Tx7h6EpQB8wsv7P0Yrh0Pb7othc= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 h1:THuZiwpQZuHPul65w4WcwEnkX2QIuMT+UFoOrygtoJw= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0/go.mod h1:J2pvYM5NGHofZ2/Ru6zw/TNWnEQp5crgyDeSrYpXkAw= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 h1:lwI4Dc5leUqENgGuQImwLo4WnuXFPetmPpkLi2IrX54= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0/go.mod h1:Kz/oCE7z5wuyhPxsXDuaPteSWqjSBD5YaSdbxZYGbGk= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 h1:zWWrB1U6nqhS/k6zYB74CjRpuiitRtLLi68VcgmOEto= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0/go.mod h1:2qXPNBX1OVRC0IwOnfo1ljoid+RD0QK3443EaqVlsOU= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0 h1:uLXP+3mghfMf7XmV4PkGfFhFKuNWoCvvx5wP/wOXo0o= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0/go.mod h1:v0Tj04armyT59mnURNUJf7RCKcKzq+lgJs6QSjHjaTc= go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.18.0 h1:KJVjPD3rcPb98rIs3HznyJlrfx9ge5oJvxxlGR+P/7s= go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.18.0/go.mod h1:K3kRa2ckmHWQaTWQdPRHc7qGXASuVuoEQXzrvlA98Ws= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.37.0 h1:6VjV6Et+1Hd2iLZEPtdV7vie80Yyqf7oikJLjQ/myi0= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.37.0/go.mod h1:u8hcp8ji5gaM/RfcOo8z9NMnf1pVLfVY7lBY2VOGuUU= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0 h1:lSZHgNHfbmQTPfuTmWVkEu8J8qXaQwuV30pjCcAUvP8= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0/go.mod h1:so9ounLcuoRDu033MW/E0AD4hhUjVqswrMF5FoZlBcw= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0 h1:s/1iRkCKDfhlh1JF26knRneorus8aOwVIDhvYx9WoDw= @@ -512,8 +498,6 @@ go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9 go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc= go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= -go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= -go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -534,17 +518,11 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= -golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc= -golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4= golang.org/x/image v0.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA= golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= -golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -554,8 +532,6 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= -golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= @@ -586,8 +562,6 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= @@ -595,8 +569,6 @@ golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= -golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -609,12 +581,8 @@ google.golang.org/api v0.271.0 h1:cIPN4qcUc61jlh7oXu6pwOQqbJW2GqYh5PS6rB2C/JY= google.golang.org/api v0.271.0/go.mod h1:CGT29bhwkbF+i11qkRUJb2KMKqcJ1hdFceEIRd9u64Q= google.golang.org/genproto v0.0.0-20260217215200-42d3e9bedb6d h1:vsOm753cOAMkt76efriTCDKjpCbK18XGHMJHo0JUKhc= google.golang.org/genproto v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:0oz9d7g9QLSdv9/lgbIjowW1JoxMbxmBVNe8i6tORJI= -google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d h1:EocjzKLywydp5uZ5tJ79iP6Q0UjDnyiHkGRWxuPBP8s= -google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:48U2I+QQUYhsFrg2SY6r+nJzeOtjey7j//WBESw+qyQ= google.golang.org/genproto/googleapis/api v0.0.0-20260311181403-84a4fc48630c h1:OyQPd6I3pN/9gDxz6L13kYGJgqkpdrAohJRBeXyxlgI= google.golang.org/genproto/googleapis/api v0.0.0-20260311181403-84a4fc48630c/go.mod h1:X2gu9Qwng7Nn009s/r3RUxqkzQNqOrAy79bluY7ojIg= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c h1:xgCzyF2LFIO/0X2UAoVRiXKU5Xg6VjToG4i2/ecSswk= google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU= diff --git a/backend/internal/core/services/pdf_export.go b/backend/internal/core/services/pdf_export.go new file mode 100644 index 000000000..15945aae8 --- /dev/null +++ b/backend/internal/core/services/pdf_export.go @@ -0,0 +1,871 @@ +package services + +import ( + "bytes" + "context" + "fmt" + "io" + "path/filepath" + "strings" + "time" + + "codeberg.org/go-pdf/fpdf" + "github.com/google/uuid" + "github.com/rs/zerolog/log" + "github.com/sysadminsmedia/homebox/backend/internal/data/ent/attachment" + "github.com/sysadminsmedia/homebox/backend/internal/data/repo" + + "gocloud.dev/blob" +) + +const ( + // MaxImageBytes is the maximum size for embedded images (10 MB). + // Images larger than this are skipped to prevent excessive memory usage. + MaxImageBytes = 10 * 1024 * 1024 +) + +// PDFTheme defines the color scheme and styling for PDF exports. +// Multiple themes are available to suit different presentation needs. +type PDFTheme struct { + Name string // Display name of the theme + HeaderR int // Header background red component (0-255) + HeaderG int // Header background green component + HeaderB int // Header background blue component + AccentR int // Accent/divider line red component + AccentG int // Accent/divider line green component + AccentB int // Accent/divider line blue component + AltRowR int // Alternating table row red component + AltRowG int // Alternating table row green component + AltRowB int // Alternating table row blue component + HeaderFontSize float64 // Font size for section headers + BodyFontSize float64 // Font size for body text + CoverTitleSize float64 // Font size for cover page title + CoverDetailSize float64 // Font size for cover page details +} + +// Available PDF themes — keyed by name for user selection via query parameter. +var PDFThemes = map[string]PDFTheme{ + // Navy: professional insurance-style look with navy blue headers + "navy": { + Name: "Navy", HeaderR: 26, HeaderG: 54, HeaderB: 93, + AccentR: 41, AccentG: 98, AccentB: 168, + AltRowR: 245, AltRowG: 245, AltRowB: 245, + HeaderFontSize: 14, BodyFontSize: 10, CoverTitleSize: 28, CoverDetailSize: 14, + }, + // Modern: clean dark slate with teal accents + "modern": { + Name: "Modern", HeaderR: 45, HeaderG: 55, HeaderB: 72, + AccentR: 56, AccentG: 178, AccentB: 172, + AltRowR: 248, AltRowG: 250, AltRowB: 252, + HeaderFontSize: 14, BodyFontSize: 10, CoverTitleSize: 28, CoverDetailSize: 14, + }, + // Minimal: light gray theme for a clean, understated appearance + "minimal": { + Name: "Minimal", HeaderR: 75, HeaderG: 85, HeaderB: 99, + AccentR: 148, AccentG: 163, AccentB: 184, + AltRowR: 249, AltRowG: 250, AltRowB: 251, + HeaderFontSize: 13, BodyFontSize: 10, CoverTitleSize: 26, CoverDetailSize: 13, + }, + // Forest: earthy green tones suited for organic/outdoor inventories + "forest": { + Name: "Forest", HeaderR: 34, HeaderG: 87, HeaderB: 59, + AccentR: 56, AccentG: 142, AccentB: 93, + AltRowR: 244, AltRowG: 249, AltRowB: 245, + HeaderFontSize: 14, BodyFontSize: 10, CoverTitleSize: 28, CoverDetailSize: 14, + }, +} + +// PDFExportOptions configures what is included in the generated PDF. +type PDFExportOptions struct { + Theme string // Theme name (navy, modern, minimal, forest) + IncludePhotos bool // Whether to embed item photos + OwnerName string // Name shown on cover page +} + +// PDFExportService handles generating PDF reports from item data. +// It reads item details from the repository and attachment images from blob storage. +type PDFExportService struct { + repo *repo.AllRepos +} + +// NewPDFExportService creates a new PDF export service instance. +func NewPDFExportService(repos *repo.AllRepos) *PDFExportService { + return &PDFExportService{repo: repos} +} + +// getTheme resolves the theme by name, defaulting to "navy" if not found. +func getTheme(name string) PDFTheme { + if t, ok := PDFThemes[strings.ToLower(name)]; ok { + return t + } + return PDFThemes["navy"] +} + +// ExportSingleItem generates a PDF report for a single item. +// Returns the PDF bytes and a suggested filename. +func (svc *PDFExportService) ExportSingleItem( + ctx context.Context, groupID uuid.UUID, itemID uuid.UUID, opts PDFExportOptions, +) ([]byte, string, error) { + // Fetch the full item details including attachments, fields, location, tags + item, err := svc.repo.Items.GetOneByGroup(ctx, groupID, itemID) + if err != nil { + return nil, "", fmt.Errorf("failed to get item: %w", err) + } + + // Fetch maintenance log for this item (all entries, no filter) + maintenance, err := svc.repo.MaintEntry.GetMaintenanceByItemID(ctx, groupID, itemID, repo.MaintenanceFilters{}) + if err != nil { + log.Warn().Err(err).Msg("failed to get maintenance entries for PDF export, continuing without") + maintenance = nil + } + + theme := getTheme(opts.Theme) + pdf := fpdf.New("P", "mm", "A4", "") + pdf.SetAutoPageBreak(true, 20) + + // Generate the single-item cover page + svc.addCoverPage(pdf, theme, opts, []repo.ItemOut{item}) + + // Generate the detailed item page(s) + svc.addItemPages(ctx, pdf, theme, opts, item, maintenance) + + // Write PDF to buffer + var buf bytes.Buffer + if err := pdf.Output(&buf); err != nil { + return nil, "", fmt.Errorf("failed to generate PDF: %w", err) + } + + // Build filename with asset ID when available, otherwise just the date + date := time.Now().Format("2006-01-02") + var filename string + if !item.AssetID.Nil() { + filename = fmt.Sprintf("HomeBox Asset Export - %s - %s.pdf", item.AssetID.String(), date) + } else { + filename = fmt.Sprintf("HomeBox Asset Export - %s.pdf", date) + } + + return buf.Bytes(), filename, nil +} + +// ExportMultipleItems generates a PDF report containing multiple items. +// Includes a summary page with a table of all items followed by per-item detail pages. +func (svc *PDFExportService) ExportMultipleItems( + ctx context.Context, groupID uuid.UUID, itemIDs []uuid.UUID, opts PDFExportOptions, +) ([]byte, string, error) { + // Fetch all requested items individually. + // NOTE: This is an N+1 query pattern. A batch-fetch method (e.g., GetManyByGroup) + // would be more efficient but does not currently exist in the repository layer. + // This is acceptable for typical export sizes but should be optimized if exports + // of hundreds of items become common. + var items []repo.ItemOut + for _, id := range itemIDs { + item, err := svc.repo.Items.GetOneByGroup(ctx, groupID, id) + if err != nil { + log.Warn().Err(err).Str("itemID", id.String()).Msg("skipping item in PDF export") + continue + } + items = append(items, item) + } + + if len(items) == 0 { + return nil, "", fmt.Errorf("no valid items found for export") + } + + theme := getTheme(opts.Theme) + pdf := fpdf.New("P", "mm", "A4", "") + pdf.SetAutoPageBreak(true, 20) + + // Cover page + svc.addCoverPage(pdf, theme, opts, items) + + // Summary page with item table (only for multi-item exports) + svc.addSummaryPage(pdf, theme, items) + + // Per-item detail pages + for _, item := range items { + maintenance, err := svc.repo.MaintEntry.GetMaintenanceByItemID(ctx, groupID, item.ID, repo.MaintenanceFilters{}) + if err != nil { + log.Warn().Err(err).Msg("failed to get maintenance for item, continuing") + maintenance = nil + } + svc.addItemPages(ctx, pdf, theme, opts, item, maintenance) + } + + var buf bytes.Buffer + if err := pdf.Output(&buf); err != nil { + return nil, "", fmt.Errorf("failed to generate PDF: %w", err) + } + + date := time.Now().Format("2006-01-02") + filename := fmt.Sprintf("HomeBox Asset Export - %s.pdf", date) + + return buf.Bytes(), filename, nil +} + +// addCoverPage renders the title/cover page of the PDF report. +// Shows the title, owner name, generation date, and purpose statement. +func (svc *PDFExportService) addCoverPage(pdf *fpdf.Fpdf, theme PDFTheme, opts PDFExportOptions, items []repo.ItemOut) { + pdf.AddPage() + + pageW, pageH := pdf.GetPageSize() + + // Large colored header band across the top of the cover page + pdf.SetFillColor(theme.HeaderR, theme.HeaderG, theme.HeaderB) + pdf.Rect(0, 0, pageW, 80, "F") + + // Title text centered in the header band + pdf.SetTextColor(255, 255, 255) + pdf.SetFont("Helvetica", "B", theme.CoverTitleSize) + pdf.SetY(25) + pdf.CellFormat(pageW, 12, "HomeBox Asset Report", "", 1, "C", false, 0, "") + + // Subtitle with item count + pdf.SetFont("Helvetica", "", theme.CoverDetailSize) + subtitle := "Single Item Report" + if len(items) > 1 { + subtitle = fmt.Sprintf("%d Items", len(items)) + } + pdf.CellFormat(pageW, 10, subtitle, "", 1, "C", false, 0, "") + + // Accent divider line below the header + pdf.SetDrawColor(theme.AccentR, theme.AccentG, theme.AccentB) + pdf.SetLineWidth(1.5) + pdf.Line(40, 85, pageW-40, 85) + + // Owner name and date centered below the divider + pdf.SetTextColor(60, 60, 60) + pdf.SetFont("Helvetica", "", theme.CoverDetailSize) + yPos := 100.0 + + if opts.OwnerName != "" { + pdf.SetY(yPos) + pdf.CellFormat(pageW, 10, fmt.Sprintf("Prepared for: %s", opts.OwnerName), "", 1, "C", false, 0, "") + yPos += 12 + } + + pdf.SetY(yPos) + pdf.CellFormat(pageW, 10, fmt.Sprintf("Generated: %s", time.Now().Format("January 2, 2006")), "", 1, "C", false, 0, "") + yPos += 20 + + // Purpose statement for insurance documentation + pdf.SetY(yPos) + pdf.SetFont("Helvetica", "I", 11) + pdf.SetTextColor(100, 100, 100) + pdf.CellFormat(pageW, 10, "For Insurance & Documentation Purposes", "", 1, "C", false, 0, "") + + // Calculate and show total estimated value across all items + totalValue := 0.0 + insuredCount := 0 + for _, item := range items { + totalValue += item.PurchasePrice + if item.Insured { + insuredCount++ + } + } + + if totalValue > 0 { + pdf.SetY(yPos + 20) + pdf.SetFont("Helvetica", "B", 16) + pdf.SetTextColor(theme.HeaderR, theme.HeaderG, theme.HeaderB) + pdf.CellFormat(pageW, 10, fmt.Sprintf("Total Estimated Value: $%.2f", totalValue), "", 1, "C", false, 0, "") + + pdf.SetFont("Helvetica", "", 12) + pdf.SetTextColor(80, 80, 80) + pdf.CellFormat(pageW, 8, fmt.Sprintf("%d of %d items insured", insuredCount, len(items)), "", 1, "C", false, 0, "") + } + + // Footer branding at the bottom of the cover page + pdf.SetY(pageH - 30) + pdf.SetFont("Helvetica", "", 9) + pdf.SetTextColor(150, 150, 150) + pdf.CellFormat(pageW, 6, "Generated by HomeBox — Home Inventory Management", "", 1, "C", false, 0, "") +} + +// addSummaryPage renders a table-based summary of all items in a multi-item export. +// Columns: Asset ID, Name, Location, Value, Insured status. +func (svc *PDFExportService) addSummaryPage(pdf *fpdf.Fpdf, theme PDFTheme, items []repo.ItemOut) { + pdf.AddPage() + + // Section header + svc.drawSectionHeader(pdf, theme, "Item Summary") + + // Table header row + pdf.SetFont("Helvetica", "B", 9) + pdf.SetFillColor(theme.HeaderR, theme.HeaderG, theme.HeaderB) + pdf.SetTextColor(255, 255, 255) + + // Column widths proportional to page width (with margins) + colWidths := []float64{25, 65, 45, 30, 25} + headers := []string{"Asset ID", "Name", "Location", "Value", "Insured"} + for i, h := range headers { + pdf.CellFormat(colWidths[i], 8, h, "1", 0, "C", true, 0, "") + } + pdf.Ln(-1) + + // Table data rows with alternating background colors + pdf.SetFont("Helvetica", "", 8) + for rowIdx, item := range items { + // Check if we need a new page (leave room for footer) + if pdf.GetY() > 260 { + pdf.AddPage() + // Re-draw table header on new page + pdf.SetFont("Helvetica", "B", 9) + pdf.SetFillColor(theme.HeaderR, theme.HeaderG, theme.HeaderB) + pdf.SetTextColor(255, 255, 255) + for i, h := range headers { + pdf.CellFormat(colWidths[i], 8, h, "1", 0, "C", true, 0, "") + } + pdf.Ln(-1) + pdf.SetFont("Helvetica", "", 8) + } + + // Apply alternating row shading for readability + if rowIdx%2 == 1 { + pdf.SetFillColor(theme.AltRowR, theme.AltRowG, theme.AltRowB) + } else { + pdf.SetFillColor(255, 255, 255) + } + pdf.SetTextColor(50, 50, 50) + + locationName := "" + if item.Location != nil { + locationName = item.Location.Name + } + + insuredStr := "No" + if item.Insured { + insuredStr = "Yes" + } + + pdf.CellFormat(colWidths[0], 7, item.AssetID.String(), "1", 0, "C", true, 0, "") + pdf.CellFormat(colWidths[1], 7, truncateStr(item.Name, 35), "1", 0, "L", true, 0, "") + pdf.CellFormat(colWidths[2], 7, truncateStr(locationName, 22), "1", 0, "L", true, 0, "") + pdf.CellFormat(colWidths[3], 7, fmt.Sprintf("$%.2f", item.PurchasePrice), "1", 0, "R", true, 0, "") + pdf.CellFormat(colWidths[4], 7, insuredStr, "1", 0, "C", true, 0, "") + pdf.Ln(-1) + } + + // Calculate totals for the summary footer row + total := 0.0 + insured := 0 + for _, item := range items { + total += item.PurchasePrice + if item.Insured { + insured++ + } + } + + // Summary totals row at the bottom of the table + pdf.SetFont("Helvetica", "B", 9) + pdf.SetFillColor(theme.HeaderR, theme.HeaderG, theme.HeaderB) + pdf.SetTextColor(255, 255, 255) + pdf.CellFormat(colWidths[0]+colWidths[1]+colWidths[2], 8, fmt.Sprintf("Total: %d items", len(items)), "1", 0, "L", true, 0, "") + pdf.CellFormat(colWidths[3], 8, fmt.Sprintf("$%.2f", total), "1", 0, "R", true, 0, "") + pdf.CellFormat(colWidths[4], 8, fmt.Sprintf("%d", insured), "1", 0, "C", true, 0, "") + pdf.Ln(-1) +} + +// addItemPages renders one or more pages of detailed information for a single item. +// Includes: header bar, primary photo, details, purchase/warranty/sold info, +// custom fields, notes, additional photos, receipts, and maintenance history. +func (svc *PDFExportService) addItemPages( + ctx context.Context, pdf *fpdf.Fpdf, theme PDFTheme, opts PDFExportOptions, + item repo.ItemOut, maintenance []repo.MaintenanceEntryWithDetails, +) { + pdf.AddPage() + pageW, _ := pdf.GetPageSize() + marginL := 10.0 + contentW := pageW - 2*marginL + + // === Item Header Bar — colored banner with name and asset ID === + pdf.SetFillColor(theme.HeaderR, theme.HeaderG, theme.HeaderB) + pdf.Rect(0, 10, pageW, 16, "F") + pdf.SetTextColor(255, 255, 255) + pdf.SetFont("Helvetica", "B", theme.HeaderFontSize) + pdf.SetY(12) + pdf.SetX(marginL) + headerText := item.Name + if !item.AssetID.Nil() { + headerText = fmt.Sprintf("%s | Asset ID: %s", item.Name, item.AssetID.String()) + } + pdf.CellFormat(contentW, 12, headerText, "", 1, "L", false, 0, "") + + pdf.SetY(30) + + // === Primary Photo — embedded to the right of item details === + photoY := pdf.GetY() + photoEmbedded := false + if opts.IncludePhotos { + // Find the primary photo attachment + for _, att := range item.Attachments { + if att.Primary && att.Type == attachment.TypePhoto.String() { + imgBytes, imgType, err := svc.readAttachment(ctx, att) + if err != nil { + log.Warn().Err(err).Msg("failed to read primary photo for PDF") + break + } + // Skip images that exceed the size limit to prevent excessive memory usage + if len(imgBytes) > MaxImageBytes { + log.Warn().Int("bytes", len(imgBytes)).Msg("primary photo exceeds max image size, skipping embed") + break + } + // Register the image and place it on the right side of the page + imgName := fmt.Sprintf("primary_%s", att.ID.String()) + pdf.RegisterImageOptionsReader(imgName, fpdf.ImageOptions{ImageType: imgType}, bytes.NewReader(imgBytes)) + // Place photo on the right side, 60mm wide, proportional height + imgW := 60.0 + pdf.ImageOptions(imgName, pageW-marginL-imgW, photoY, imgW, 0, false, fpdf.ImageOptions{ImageType: imgType}, 0, "") + photoEmbedded = true + break + } + } + } + + // === Item Details Section — left column next to the photo === + detailW := contentW + if photoEmbedded { + detailW = contentW - 65 // Leave room for the photo on the right + } + + pdf.SetY(photoY) + pdf.SetX(marginL) + + // Location + if item.Location != nil { + svc.drawDetailRow(pdf, theme, marginL, detailW, "Location", item.Location.Name) + } + + // Quantity + svc.drawDetailRow(pdf, theme, marginL, detailW, "Quantity", fmt.Sprintf("%d", item.Quantity)) + + // Identification details (serial, model, manufacturer) + if item.SerialNumber != "" { + svc.drawDetailRow(pdf, theme, marginL, detailW, "Serial Number", item.SerialNumber) + } + if item.ModelNumber != "" { + svc.drawDetailRow(pdf, theme, marginL, detailW, "Model Number", item.ModelNumber) + } + if item.Manufacturer != "" { + svc.drawDetailRow(pdf, theme, marginL, detailW, "Manufacturer", item.Manufacturer) + } + + // Insured status + insuredStr := "No" + if item.Insured { + insuredStr = "Yes" + } + svc.drawDetailRow(pdf, theme, marginL, detailW, "Insured", insuredStr) + + // Tags + if len(item.Tags) > 0 { + tagNames := make([]string, len(item.Tags)) + for i, t := range item.Tags { + tagNames[i] = t.Name + } + svc.drawDetailRow(pdf, theme, marginL, detailW, "Tags", strings.Join(tagNames, ", ")) + } + + // Ensure we move below the photo before continuing + if photoEmbedded && pdf.GetY() < photoY+65 { + pdf.SetY(photoY + 65) + } + + // === Description === + if item.Description != "" { + pdf.Ln(4) + svc.drawSectionHeader(pdf, theme, "Description") + pdf.SetFont("Helvetica", "", theme.BodyFontSize) + pdf.SetTextColor(50, 50, 50) + pdf.SetX(marginL) + pdf.MultiCell(contentW, 5, item.Description, "", "L", false) + } + + // === Purchase Information === + if item.PurchaseFrom != "" || item.PurchasePrice > 0 || !item.PurchaseTime.Time().IsZero() { + svc.ensureSpace(pdf, 30) + pdf.Ln(4) + svc.drawSectionHeader(pdf, theme, "Purchase Information") + + if item.PurchaseFrom != "" { + svc.drawDetailRow(pdf, theme, marginL, contentW, "Purchased From", item.PurchaseFrom) + } + if !item.PurchaseTime.Time().IsZero() { + svc.drawDetailRow(pdf, theme, marginL, contentW, "Purchase Date", item.PurchaseTime.Time().Format("January 2, 2006")) + } + if item.PurchasePrice > 0 { + svc.drawDetailRow(pdf, theme, marginL, contentW, "Purchase Price", fmt.Sprintf("$%.2f", item.PurchasePrice)) + } + } + + // === Warranty Information === + if item.LifetimeWarranty || !item.WarrantyExpires.Time().IsZero() || item.WarrantyDetails != "" { + svc.ensureSpace(pdf, 25) + pdf.Ln(4) + svc.drawSectionHeader(pdf, theme, "Warranty") + + if item.LifetimeWarranty { + svc.drawDetailRow(pdf, theme, marginL, contentW, "Warranty", "Lifetime") + } else if !item.WarrantyExpires.Time().IsZero() { + svc.drawDetailRow(pdf, theme, marginL, contentW, "Warranty Expires", item.WarrantyExpires.Time().Format("January 2, 2006")) + } + if item.WarrantyDetails != "" { + pdf.SetFont("Helvetica", "", theme.BodyFontSize) + pdf.SetTextColor(50, 50, 50) + pdf.SetX(marginL) + pdf.MultiCell(contentW, 5, item.WarrantyDetails, "", "L", false) + } + } + + // === Custom Fields === + if len(item.Fields) > 0 { + svc.ensureSpace(pdf, 20) + pdf.Ln(4) + svc.drawSectionHeader(pdf, theme, "Custom Fields") + + for _, field := range item.Fields { + var value string + switch field.Type { + case "text": + value = field.TextValue + case "number": + value = fmt.Sprintf("%d", field.NumberValue) + case "boolean": + if field.BooleanValue { + value = "Yes" + } else { + value = "No" + } + default: + value = field.TextValue + } + if value != "" { + svc.drawDetailRow(pdf, theme, marginL, contentW, field.Name, value) + } + } + } + + // === Notes (supports multi-line text, stripped of Markdown) === + if item.Notes != "" { + svc.ensureSpace(pdf, 20) + pdf.Ln(4) + svc.drawSectionHeader(pdf, theme, "Notes") + pdf.SetFont("Helvetica", "", theme.BodyFontSize) + pdf.SetTextColor(50, 50, 50) + pdf.SetX(marginL) + // Strip basic markdown formatting for plain-text rendering in the PDF + notes := stripMarkdown(item.Notes) + pdf.MultiCell(contentW, 5, notes, "", "L", false) + } + + // === Additional Photos — displayed in a grid layout === + if opts.IncludePhotos { + var additionalPhotos []repo.ItemAttachment + for _, att := range item.Attachments { + // Include all photo attachments except the primary one (already shown above) + if att.Type == attachment.TypePhoto.String() && !att.Primary { + additionalPhotos = append(additionalPhotos, att) + } + } + + if len(additionalPhotos) > 0 { + pdf.AddPage() + svc.drawSectionHeader(pdf, theme, "Additional Photos") + svc.drawPhotoGrid(ctx, pdf, marginL, contentW, additionalPhotos) + } + } + + // === Receipt Images — embedded like photos, but only receipt-type attachments === + if opts.IncludePhotos { + var receipts []repo.ItemAttachment + for _, att := range item.Attachments { + if att.Type == attachment.TypeReceipt.String() { + receipts = append(receipts, att) + } + } + + if len(receipts) > 0 { + pdf.AddPage() + svc.drawSectionHeader(pdf, theme, "Receipts") + svc.drawPhotoGrid(ctx, pdf, marginL, contentW, receipts) + } + } + + // === Maintenance History Table === + if len(maintenance) > 0 { + svc.ensureSpace(pdf, 30) + pdf.Ln(4) + svc.drawSectionHeader(pdf, theme, "Maintenance History") + + // Table header + pdf.SetFont("Helvetica", "B", 8) + pdf.SetFillColor(theme.HeaderR, theme.HeaderG, theme.HeaderB) + pdf.SetTextColor(255, 255, 255) + + maintCols := []float64{40, 50, 30, 30, 25} + maintHeaders := []string{"Task", "Description", "Scheduled", "Completed", "Cost"} + pdf.SetX(marginL) + for i, h := range maintHeaders { + pdf.CellFormat(maintCols[i], 7, h, "1", 0, "C", true, 0, "") + } + pdf.Ln(-1) + + // Maintenance data rows + pdf.SetFont("Helvetica", "", 7) + for rowIdx, entry := range maintenance { + if pdf.GetY() > 260 { + pdf.AddPage() + // Re-draw header on new page + pdf.SetFont("Helvetica", "B", 8) + pdf.SetFillColor(theme.HeaderR, theme.HeaderG, theme.HeaderB) + pdf.SetTextColor(255, 255, 255) + pdf.SetX(marginL) + for i, h := range maintHeaders { + pdf.CellFormat(maintCols[i], 7, h, "1", 0, "C", true, 0, "") + } + pdf.Ln(-1) + pdf.SetFont("Helvetica", "", 7) + } + + if rowIdx%2 == 1 { + pdf.SetFillColor(theme.AltRowR, theme.AltRowG, theme.AltRowB) + } else { + pdf.SetFillColor(255, 255, 255) + } + pdf.SetTextColor(50, 50, 50) + + scheduled := "" + if !entry.ScheduledDate.Time().IsZero() { + scheduled = entry.ScheduledDate.Time().Format("2006-01-02") + } + completed := "" + if !entry.CompletedDate.Time().IsZero() { + completed = entry.CompletedDate.Time().Format("2006-01-02") + } + costStr := "" + if entry.Cost > 0 { + costStr = fmt.Sprintf("$%.2f", entry.Cost) + } + + pdf.SetX(marginL) + pdf.CellFormat(maintCols[0], 6, truncateStr(entry.Name, 22), "1", 0, "L", true, 0, "") + pdf.CellFormat(maintCols[1], 6, truncateStr(entry.Description, 28), "1", 0, "L", true, 0, "") + pdf.CellFormat(maintCols[2], 6, scheduled, "1", 0, "C", true, 0, "") + pdf.CellFormat(maintCols[3], 6, completed, "1", 0, "C", true, 0, "") + pdf.CellFormat(maintCols[4], 6, costStr, "1", 0, "R", true, 0, "") + pdf.Ln(-1) + } + } + + // === Sold Information — appended if the item has been sold === + if item.SoldTo != "" || item.SoldPrice > 0 || !item.SoldTime.Time().IsZero() { + svc.ensureSpace(pdf, 25) + pdf.Ln(4) + svc.drawSectionHeader(pdf, theme, "Sold Information") + + if item.SoldTo != "" { + svc.drawDetailRow(pdf, theme, marginL, contentW, "Sold To", item.SoldTo) + } + if !item.SoldTime.Time().IsZero() { + svc.drawDetailRow(pdf, theme, marginL, contentW, "Sold Date", item.SoldTime.Time().Format("January 2, 2006")) + } + if item.SoldPrice > 0 { + svc.drawDetailRow(pdf, theme, marginL, contentW, "Sold Price", fmt.Sprintf("$%.2f", item.SoldPrice)) + } + if item.SoldNotes != "" { + pdf.SetFont("Helvetica", "", theme.BodyFontSize) + pdf.SetTextColor(50, 50, 50) + pdf.SetX(marginL) + pdf.MultiCell(contentW, 5, item.SoldNotes, "", "L", false) + } + } +} + +// drawSectionHeader renders a themed section title with an accent underline. +func (svc *PDFExportService) drawSectionHeader(pdf *fpdf.Fpdf, theme PDFTheme, title string) { + marginL := 10.0 + pageW, _ := pdf.GetPageSize() + contentW := pageW - 2*marginL + + pdf.SetFont("Helvetica", "B", theme.HeaderFontSize-2) + pdf.SetTextColor(theme.HeaderR, theme.HeaderG, theme.HeaderB) + pdf.SetX(marginL) + pdf.CellFormat(contentW, 8, title, "", 1, "L", false, 0, "") + + // Draw a colored accent line under the section title + pdf.SetDrawColor(theme.AccentR, theme.AccentG, theme.AccentB) + pdf.SetLineWidth(0.5) + y := pdf.GetY() + pdf.Line(marginL, y, marginL+contentW, y) + pdf.Ln(3) +} + +// drawDetailRow renders a single label-value pair as a row. +// The label is bold and the value is normal weight. +func (svc *PDFExportService) drawDetailRow(pdf *fpdf.Fpdf, theme PDFTheme, marginL, width float64, label, value string) { + labelW := 45.0 + valueW := width - labelW + + pdf.SetX(marginL) + pdf.SetFont("Helvetica", "B", theme.BodyFontSize) + pdf.SetTextColor(70, 70, 70) + pdf.CellFormat(labelW, 6, label+":", "", 0, "L", false, 0, "") + + pdf.SetFont("Helvetica", "", theme.BodyFontSize) + pdf.SetTextColor(50, 50, 50) + pdf.CellFormat(valueW, 6, value, "", 1, "L", false, 0, "") +} + +// drawPhotoGrid renders a set of images in a 2-column grid layout. +// Each image is scaled to fit within a cell while maintaining aspect ratio. +func (svc *PDFExportService) drawPhotoGrid( + ctx context.Context, pdf *fpdf.Fpdf, + marginL, contentW float64, attachments []repo.ItemAttachment, +) { + colW := (contentW - 5) / 2 // 5mm gap between columns + imgH := 70.0 // Fixed height for grid cells + + for i, att := range attachments { + // Start a new row every 2 images + col := i % 2 + if col == 0 && i > 0 { + pdf.Ln(imgH + 5) + } + if col == 0 && pdf.GetY()+imgH > 270 { + pdf.AddPage() + } + + imgBytes, imgType, err := svc.readAttachment(ctx, att) + if err != nil { + log.Warn().Err(err).Str("attachment", att.ID.String()).Msg("failed to read attachment for photo grid") + continue + } + // Skip images that exceed the size limit to prevent excessive memory usage + if len(imgBytes) > MaxImageBytes { + log.Warn().Str("attachment", att.ID.String()).Int("bytes", len(imgBytes)).Msg("image exceeds max size, skipping") + continue + } + + imgName := fmt.Sprintf("grid_%s", att.ID.String()) + pdf.RegisterImageOptionsReader(imgName, fpdf.ImageOptions{ImageType: imgType}, bytes.NewReader(imgBytes)) + + x := marginL + float64(col)*(colW+5) + y := pdf.GetY() + + pdf.ImageOptions(imgName, x, y, colW, 0, false, fpdf.ImageOptions{ImageType: imgType}, 0, "") + + // Draw a light border around the image + pdf.SetDrawColor(200, 200, 200) + pdf.SetLineWidth(0.3) + pdf.Rect(x, y, colW, imgH, "D") + + // Caption below the image with the attachment title + if att.Title != "" { + pdf.SetFont("Helvetica", "I", 7) + pdf.SetTextColor(120, 120, 120) + pdf.Text(x+2, y+imgH-2, truncateStr(att.Title, 40)) + } + } + + pdf.Ln(imgH + 5) +} + +// readAttachment reads the binary content of an attachment from blob storage. +// Returns the bytes, detected image type (for fpdf), and any error. +func (svc *PDFExportService) readAttachment(ctx context.Context, att repo.ItemAttachment) ([]byte, string, error) { + // Defensive path traversal check: ensure the attachment path does not + // contain ".." components that could escape the expected storage directory. + cleanPath := filepath.ToSlash(filepath.Clean(att.Path)) + if strings.Contains(cleanPath, "..") { + return nil, "", fmt.Errorf("invalid attachment path (directory traversal detected): %s", att.Path) + } + + // Open the blob storage bucket using the configured connection string + bucket, err := blob.OpenBucket(ctx, svc.repo.Attachments.GetConnString()) + if err != nil { + return nil, "", fmt.Errorf("failed to open bucket: %w", err) + } + defer bucket.Close() + + // Read the full file from storage + reader, err := bucket.NewReader(ctx, svc.repo.Attachments.GetFullPath(att.Path), nil) + if err != nil { + return nil, "", fmt.Errorf("failed to read attachment: %w", err) + } + defer reader.Close() + + data, err := io.ReadAll(reader) + if err != nil { + return nil, "", fmt.Errorf("failed to read attachment data: %w", err) + } + + // Determine image type from MIME type for fpdf registration. + // fpdf natively supports jpg, png, and gif. For unsupported formats + // (webp, heic, avif), we log a warning and skip the image since fpdf + // cannot embed them without a conversion step. + mime := strings.ToLower(att.MimeType) + var imgType string + switch { + case strings.Contains(mime, "jpeg"), strings.Contains(mime, "jpg"): + imgType = "jpg" + case strings.Contains(mime, "png"): + imgType = "png" + case strings.Contains(mime, "gif"): + imgType = "gif" + case strings.Contains(mime, "webp"), + strings.Contains(mime, "heic"), strings.Contains(mime, "heif"), + strings.Contains(mime, "avif"): + log.Warn().Str("mimeType", att.MimeType).Str("attachmentID", att.ID.String()). + Msg("unsupported image format for PDF embed, skipping") + return nil, "", fmt.Errorf("unsupported image format for PDF: %s", att.MimeType) + default: + log.Warn().Str("mimeType", att.MimeType).Str("attachmentID", att.ID.String()). + Msg("unknown MIME type for PDF image embed, defaulting to jpg") + imgType = "jpg" + } + + return data, imgType, nil +} + +// ensureSpace checks if enough vertical space remains on the current page. +// If not, it starts a new page to prevent content from being cut off. +func (svc *PDFExportService) ensureSpace(pdf *fpdf.Fpdf, minSpace float64) { + _, pageH := pdf.GetPageSize() + if pdf.GetY()+minSpace > pageH-20 { + pdf.AddPage() + } +} + +// truncateStr shortens a string to maxLen runes, appending "..." if truncated. +// Uses rune-based slicing to safely handle multi-byte UTF-8 characters. +func truncateStr(s string, maxLen int) string { + runes := []rune(s) + if len(runes) <= maxLen { + return s + } + return string(runes[:maxLen-3]) + "..." +} + +// stripMarkdown removes common Markdown formatting for plain-text rendering in PDFs. +// Targets bold/italic markers, header prefixes, and code backticks while preserving +// underscores that appear in identifiers (e.g., serial_number). +func stripMarkdown(s string) string { + // Remove bold/italic markers (must remove ** before * to avoid partial matches) + s = strings.ReplaceAll(s, "**", "") + s = strings.ReplaceAll(s, "__", "") + + // Remove header markers from line starts (e.g., "## Title" -> "Title") + lines := strings.Split(s, "\n") + for i, line := range lines { + stripped := strings.TrimLeft(line, "#") + if stripped != line { + lines[i] = strings.TrimLeft(stripped, " ") + } + } + s = strings.Join(lines, "\n") + + // Remove code backticks (triple first, then single) + s = strings.ReplaceAll(s, "```", "") + s = strings.ReplaceAll(s, "`", "") + + return s +} diff --git a/frontend/lib/api/classes/items.ts b/frontend/lib/api/classes/items.ts index 01b8368a7..d84be1317 100644 --- a/frontend/lib/api/classes/items.ts +++ b/frontend/lib/api/classes/items.ts @@ -190,4 +190,66 @@ export class ItemsApi extends BaseAPI { return route("/items/export"); } + + // exportPDFURL returns a URL to download a single item's PDF export. + // The URL can be opened in a new tab/window to trigger the download. + exportPDFURL( + id: string, + options: { theme?: string; photos?: boolean; owner?: string } = {} + ): string { + const params: Record = {}; + if (options.theme) params.theme = options.theme; + if (options.photos === false) params.photos = "false"; + if (options.owner) params.owner = options.owner; + return route(`/items/${id}/export/pdf`, params); + } + + // exportAllPDFURL returns a URL to download a PDF of all items. + // Optionally filter by tenant (collection ID) to match the current view. + exportAllPDFURL(options: { theme?: string; photos?: boolean; owner?: string; tenant?: string } = {}): string { + const params: Record = {}; + if (options.theme) params.theme = options.theme; + if (options.photos === false) params.photos = "false"; + if (options.owner) params.owner = options.owner; + if (options.tenant) params.tenant = options.tenant; + return route("/items/export/pdf", params); + } + + // exportBulkPDF triggers a POST request to download a PDF for multiple selected items. + // Returns a Blob that can be used to trigger a file download in the browser. + // Auth is handled by same-origin cookies (same mechanism as window.open for GET exports). + async exportBulkPDF( + itemIds: string[], + options: { theme?: string; photos?: boolean; owner?: string } = {} + ): Promise<{ data: Blob | null; error: boolean }> { + const params: Record = {}; + if (options.theme) params.theme = options.theme; + if (options.photos === false) params.photos = "false"; + if (options.owner) params.owner = options.owner; + + const url = route("/items/export/pdf", params); + + try { + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "same-origin", + body: JSON.stringify({ itemIds }), + }); + + if (!response.ok) { + return { data: null, error: true }; + } + + const blob = await response.blob(); + return { data: blob, error: false }; + } catch { + return { data: null, error: true }; + } + } + + // getPDFThemes fetches the available PDF theme options from the server. + getPDFThemes() { + return this.http.get>({ url: route("/items/export/pdf/themes") }); + } } diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 835e87db6..18fc79070 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -406,6 +406,7 @@ "duplicate": "Duplicate", "edit": "Edit", "email": "Email", + "export_pdf": "Export PDF", "follow_dev": "Follow the Developer", "footer": { "api_link": "''API''", @@ -868,6 +869,9 @@ "import_export_set": { "export": "Export Inventory", "export_button": "Export Inventory", + "export_pdf": "Export PDF Report", + "export_pdf_button": "Export PDF", + "export_pdf_sub": "Generates an insurance-grade PDF report of your full inventory with cover page, item details, photos, and maintenance history.", "export_sub": "Exports the standard CSV format for Homebox. This will export all items in your inventory.", "import": "Import Inventory", "import_button": "Import Inventory", diff --git a/frontend/pages/collection/index/tools.vue b/frontend/pages/collection/index/tools.vue index f10c1298e..ca746af63 100644 --- a/frontend/pages/collection/index/tools.vue +++ b/frontend/pages/collection/index/tools.vue @@ -48,6 +48,12 @@ {{ $t("tools.import_export_set.export_sub") }} + + + + {{ $t("tools.import_export_set.export_pdf_sub") }} + + @@ -148,6 +154,12 @@ window.open(url, "_blank"); }; + // Opens a new tab to download a PDF report of items in the current collection + const getExportPDF = () => { + const url = api.items.exportAllPDFURL({ tenant: prefs.value.collectionId ?? undefined }); + window.open(url, "_blank"); + }; + const ensureAssetIDs = async () => { const { isCanceled } = await confirm.open(t("tools.actions_set.ensure_ids_confirm")); diff --git a/frontend/pages/item/[id]/index.vue b/frontend/pages/item/[id]/index.vue index 4712bcbd0..ff7e4e6bf 100644 --- a/frontend/pages/item/[id]/index.vue +++ b/frontend/pages/item/[id]/index.vue @@ -11,6 +11,7 @@ import MdiPlusBoxMultipleOutline from "~icons/mdi/plus-box-multiple-outline"; import MdiContentSaveEdit from "~icons/mdi/content-save-edit"; import MdiDotsVertical from "~icons/mdi/dots-vertical"; + import MdiFilePdfBox from "~icons/mdi/file-pdf-box"; import { Separator } from "@/components/ui/separator"; import { DropdownMenu, @@ -567,6 +568,12 @@ navigateTo("/home"); } + // Export the current item as a PDF report — opens in a new tab for download + function exportPDF() { + const url = api.items.exportPDFURL(itemId.value); + window.open(url, "_blank"); + } + async function saveAsTemplate() { if (!item.value) { return; @@ -726,6 +733,10 @@ {{ $t("components.template.save_as_template") }} + + + {{ $t("global.export_pdf") }} +