forked from mirrors/homebox
feat: new homepage statistic API's (#167)
* add date format and orDefault helpers * introduce new statistics calculations queries * rework statistics endpoints * code generation * fix styles on photo card * label and location aggregation endpoints * code-gen * cleanup parser and defaults * remove debug point * setup E2E Testing * linters * formatting * fmt plus name support on time series data * code gen
This commit is contained in:
parent
de419dc37d
commit
d6da63187b
19 changed files with 925 additions and 149 deletions
15
.vscode/settings.json
vendored
15
.vscode/settings.json
vendored
|
@ -1,7 +1,4 @@
|
|||
{
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": true
|
||||
},
|
||||
"yaml.schemas": {
|
||||
"https://squidfunk.github.io/mkdocs-material/schema.json": "mkdocs.yml"
|
||||
},
|
||||
|
@ -13,5 +10,15 @@
|
|||
},
|
||||
"cSpell.words": [
|
||||
"debughandlers"
|
||||
]
|
||||
],
|
||||
// use ESLint to format code on save
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "dbaeumer.vscode-eslint",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": true
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
|
||||
},
|
||||
|
||||
}
|
||||
|
|
|
@ -24,26 +24,6 @@ type (
|
|||
}
|
||||
)
|
||||
|
||||
// HandleGroupGet godoc
|
||||
// @Summary Get the current user's group
|
||||
// @Tags Group
|
||||
// @Produce json
|
||||
// @Success 200 {object} repo.GroupStatistics
|
||||
// @Router /v1/groups/statistics [Get]
|
||||
// @Security Bearer
|
||||
func (ctrl *V1Controller) HandleGroupStatistics() server.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) error {
|
||||
ctx := services.NewContext(r.Context())
|
||||
|
||||
stats, err := ctrl.repo.Groups.GroupStatistics(ctx, ctx.GID)
|
||||
if err != nil {
|
||||
return validate.NewRequestError(err, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
return server.Respond(w, http.StatusOK, stats)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleGroupGet godoc
|
||||
// @Summary Get the current user's group
|
||||
// @Tags Group
|
||||
|
|
109
backend/app/api/handlers/v1/v1_ctrl_statistics.go
Normal file
109
backend/app/api/handlers/v1/v1_ctrl_statistics.go
Normal file
|
@ -0,0 +1,109 @@
|
|||
package v1
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/hay-kot/homebox/backend/internal/core/services"
|
||||
"github.com/hay-kot/homebox/backend/internal/sys/validate"
|
||||
"github.com/hay-kot/homebox/backend/pkgs/server"
|
||||
)
|
||||
|
||||
// HandleGroupGet godoc
|
||||
// @Summary Get the current user's group statistics
|
||||
// @Tags Statistics
|
||||
// @Produce json
|
||||
// @Success 200 {object} []repo.TotalsByOrganizer
|
||||
// @Router /v1/groups/statistics/locations [GET]
|
||||
// @Security Bearer
|
||||
func (ctrl *V1Controller) HandleGroupStatisticsLocations() server.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) error {
|
||||
ctx := services.NewContext(r.Context())
|
||||
|
||||
stats, err := ctrl.repo.Groups.StatsLocationsByPurchasePrice(ctx, ctx.GID)
|
||||
if err != nil {
|
||||
return validate.NewRequestError(err, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
return server.Respond(w, http.StatusOK, stats)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleGroupGet godoc
|
||||
// @Summary Get the current user's group statistics
|
||||
// @Tags Statistics
|
||||
// @Produce json
|
||||
// @Success 200 {object} []repo.TotalsByOrganizer
|
||||
// @Router /v1/groups/statistics/labels [GET]
|
||||
// @Security Bearer
|
||||
func (ctrl *V1Controller) HandleGroupStatisticsLabels() server.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) error {
|
||||
ctx := services.NewContext(r.Context())
|
||||
|
||||
stats, err := ctrl.repo.Groups.StatsLabelsByPurchasePrice(ctx, ctx.GID)
|
||||
if err != nil {
|
||||
return validate.NewRequestError(err, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
return server.Respond(w, http.StatusOK, stats)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleGroupGet godoc
|
||||
// @Summary Get the current user's group statistics
|
||||
// @Tags Statistics
|
||||
// @Produce json
|
||||
// @Success 200 {object} repo.GroupStatistics
|
||||
// @Router /v1/groups/statistics [GET]
|
||||
// @Security Bearer
|
||||
func (ctrl *V1Controller) HandleGroupStatistics() server.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) error {
|
||||
ctx := services.NewContext(r.Context())
|
||||
|
||||
stats, err := ctrl.repo.Groups.StatsGroup(ctx, ctx.GID)
|
||||
if err != nil {
|
||||
return validate.NewRequestError(err, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
return server.Respond(w, http.StatusOK, stats)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleGroupGet godoc
|
||||
// @Summary Queries the changes overtime of the purchase price over time
|
||||
// @Tags Statistics
|
||||
// @Produce json
|
||||
// @Success 200 {object} repo.ValueOverTime
|
||||
// @Param start query string false "start date"
|
||||
// @Param end query string false "end date"
|
||||
// @Router /v1/groups/statistics/purchase-price [GET]
|
||||
// @Security Bearer
|
||||
func (ctrl *V1Controller) HandleGroupStatisticsPriceOverTime() server.HandlerFunc {
|
||||
parseDate := func(datestr string, defaultDate time.Time) (time.Time, error) {
|
||||
if datestr == "" {
|
||||
return defaultDate, nil
|
||||
}
|
||||
return time.Parse("2006-01-02", datestr)
|
||||
}
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request) error {
|
||||
ctx := services.NewContext(r.Context())
|
||||
|
||||
startDate, err := parseDate(r.URL.Query().Get("start"), time.Now().AddDate(0, -1, 0))
|
||||
if err != nil {
|
||||
return validate.NewRequestError(err, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
endDate, err := parseDate(r.URL.Query().Get("end"), time.Now())
|
||||
if err != nil {
|
||||
return validate.NewRequestError(err, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
stats, err := ctrl.repo.Groups.StatsPurchasePrice(ctx, ctx.GID, startDate, endDate)
|
||||
if err != nil {
|
||||
return validate.NewRequestError(err, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
return server.Respond(w, http.StatusOK, stats)
|
||||
}
|
||||
}
|
|
@ -79,6 +79,9 @@ func (a *app) mountRoutes(repos *repo.AllRepos) {
|
|||
|
||||
a.server.Post(v1Base("/groups/invitations"), v1Ctrl.HandleGroupInvitationsCreate(), userMW...)
|
||||
a.server.Get(v1Base("/groups/statistics"), v1Ctrl.HandleGroupStatistics(), userMW...)
|
||||
a.server.Get(v1Base("/groups/statistics/purchase-price"), v1Ctrl.HandleGroupStatisticsPriceOverTime(), userMW...)
|
||||
a.server.Get(v1Base("/groups/statistics/locations"), v1Ctrl.HandleGroupStatisticsLocations(), userMW...)
|
||||
a.server.Get(v1Base("/groups/statistics/labels"), v1Ctrl.HandleGroupStatisticsLabels(), userMW...)
|
||||
|
||||
// TODO: I don't like /groups being the URL for users
|
||||
a.server.Get(v1Base("/groups"), v1Ctrl.HandleGroupGet(), userMW...)
|
||||
|
|
|
@ -148,9 +148,9 @@ const docTemplate = `{
|
|||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Group"
|
||||
"Statistics"
|
||||
],
|
||||
"summary": "Get the current user's group",
|
||||
"summary": "Get the current user's group statistics",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
|
@ -161,6 +161,98 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
},
|
||||
"/v1/groups/statistics/labels": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Statistics"
|
||||
],
|
||||
"summary": "Get the current user's group statistics",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/repo.TotalsByOrganizer"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/groups/statistics/locations": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Statistics"
|
||||
],
|
||||
"summary": "Get the current user's group statistics",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/repo.TotalsByOrganizer"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/groups/statistics/purchase-price": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Statistics"
|
||||
],
|
||||
"summary": "Queries the changes overtime of the purchase price over time",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "start date",
|
||||
"name": "start",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "end date",
|
||||
"name": "end",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/repo.ValueOverTime"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/items": {
|
||||
"get": {
|
||||
"security": [
|
||||
|
@ -449,43 +541,6 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
},
|
||||
"/v1/items/{id}/attachments/download": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/octet-stream"
|
||||
],
|
||||
"tags": [
|
||||
"Items Attachments"
|
||||
],
|
||||
"summary": "retrieves an attachment for an item",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Item ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Attachment token",
|
||||
"name": "token",
|
||||
"in": "query",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/items/{id}/attachments/{attachment_id}": {
|
||||
"get": {
|
||||
"security": [
|
||||
|
@ -1237,6 +1292,9 @@ const docTemplate = `{
|
|||
"repo.GroupStatistics": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"totalItemPrice": {
|
||||
"type": "number"
|
||||
},
|
||||
"totalItems": {
|
||||
"type": "integer"
|
||||
},
|
||||
|
@ -1248,6 +1306,9 @@ const docTemplate = `{
|
|||
},
|
||||
"totalUsers": {
|
||||
"type": "integer"
|
||||
},
|
||||
"totalWithWarranty": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@ -1784,6 +1845,20 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
},
|
||||
"repo.TotalsByOrganizer": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"total": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
"repo.UserOut": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
@ -1821,6 +1896,43 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
},
|
||||
"repo.ValueOverTime": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"end": {
|
||||
"type": "string"
|
||||
},
|
||||
"entries": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/repo.ValueOverTimeEntry"
|
||||
}
|
||||
},
|
||||
"start": {
|
||||
"type": "string"
|
||||
},
|
||||
"valueAtEnd": {
|
||||
"type": "number"
|
||||
},
|
||||
"valueAtStart": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
"repo.ValueOverTimeEntry": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"date": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
"server.ErrorResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
|
@ -140,9 +140,9 @@
|
|||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Group"
|
||||
"Statistics"
|
||||
],
|
||||
"summary": "Get the current user's group",
|
||||
"summary": "Get the current user's group statistics",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
|
@ -153,6 +153,98 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"/v1/groups/statistics/labels": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Statistics"
|
||||
],
|
||||
"summary": "Get the current user's group statistics",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/repo.TotalsByOrganizer"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/groups/statistics/locations": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Statistics"
|
||||
],
|
||||
"summary": "Get the current user's group statistics",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/repo.TotalsByOrganizer"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/groups/statistics/purchase-price": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Statistics"
|
||||
],
|
||||
"summary": "Queries the changes overtime of the purchase price over time",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "start date",
|
||||
"name": "start",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "end date",
|
||||
"name": "end",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/repo.ValueOverTime"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/items": {
|
||||
"get": {
|
||||
"security": [
|
||||
|
@ -441,43 +533,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"/v1/items/{id}/attachments/download": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/octet-stream"
|
||||
],
|
||||
"tags": [
|
||||
"Items Attachments"
|
||||
],
|
||||
"summary": "retrieves an attachment for an item",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Item ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Attachment token",
|
||||
"name": "token",
|
||||
"in": "query",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/items/{id}/attachments/{attachment_id}": {
|
||||
"get": {
|
||||
"security": [
|
||||
|
@ -1229,6 +1284,9 @@
|
|||
"repo.GroupStatistics": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"totalItemPrice": {
|
||||
"type": "number"
|
||||
},
|
||||
"totalItems": {
|
||||
"type": "integer"
|
||||
},
|
||||
|
@ -1240,6 +1298,9 @@
|
|||
},
|
||||
"totalUsers": {
|
||||
"type": "integer"
|
||||
},
|
||||
"totalWithWarranty": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@ -1776,6 +1837,20 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"repo.TotalsByOrganizer": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"total": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
"repo.UserOut": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
@ -1813,6 +1888,43 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"repo.ValueOverTime": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"end": {
|
||||
"type": "string"
|
||||
},
|
||||
"entries": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/repo.ValueOverTimeEntry"
|
||||
}
|
||||
},
|
||||
"start": {
|
||||
"type": "string"
|
||||
},
|
||||
"valueAtEnd": {
|
||||
"type": "number"
|
||||
},
|
||||
"valueAtStart": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
"repo.ValueOverTimeEntry": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"date": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
"server.ErrorResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
|
@ -24,6 +24,8 @@ definitions:
|
|||
type: object
|
||||
repo.GroupStatistics:
|
||||
properties:
|
||||
totalItemPrice:
|
||||
type: number
|
||||
totalItems:
|
||||
type: integer
|
||||
totalLabels:
|
||||
|
@ -32,6 +34,8 @@ definitions:
|
|||
type: integer
|
||||
totalUsers:
|
||||
type: integer
|
||||
totalWithWarranty:
|
||||
type: integer
|
||||
type: object
|
||||
repo.GroupUpdate:
|
||||
properties:
|
||||
|
@ -392,6 +396,15 @@ definitions:
|
|||
total:
|
||||
type: integer
|
||||
type: object
|
||||
repo.TotalsByOrganizer:
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
total:
|
||||
type: number
|
||||
type: object
|
||||
repo.UserOut:
|
||||
properties:
|
||||
email:
|
||||
|
@ -416,6 +429,30 @@ definitions:
|
|||
name:
|
||||
type: string
|
||||
type: object
|
||||
repo.ValueOverTime:
|
||||
properties:
|
||||
end:
|
||||
type: string
|
||||
entries:
|
||||
items:
|
||||
$ref: '#/definitions/repo.ValueOverTimeEntry'
|
||||
type: array
|
||||
start:
|
||||
type: string
|
||||
valueAtEnd:
|
||||
type: number
|
||||
valueAtStart:
|
||||
type: number
|
||||
type: object
|
||||
repo.ValueOverTimeEntry:
|
||||
properties:
|
||||
date:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
value:
|
||||
type: number
|
||||
type: object
|
||||
server.ErrorResponse:
|
||||
properties:
|
||||
error:
|
||||
|
@ -608,9 +645,64 @@ paths:
|
|||
$ref: '#/definitions/repo.GroupStatistics'
|
||||
security:
|
||||
- Bearer: []
|
||||
summary: Get the current user's group
|
||||
summary: Get the current user's group statistics
|
||||
tags:
|
||||
- Group
|
||||
- Statistics
|
||||
/v1/groups/statistics/labels:
|
||||
get:
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/repo.TotalsByOrganizer'
|
||||
type: array
|
||||
security:
|
||||
- Bearer: []
|
||||
summary: Get the current user's group statistics
|
||||
tags:
|
||||
- Statistics
|
||||
/v1/groups/statistics/locations:
|
||||
get:
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/repo.TotalsByOrganizer'
|
||||
type: array
|
||||
security:
|
||||
- Bearer: []
|
||||
summary: Get the current user's group statistics
|
||||
tags:
|
||||
- Statistics
|
||||
/v1/groups/statistics/purchase-price:
|
||||
get:
|
||||
parameters:
|
||||
- description: start date
|
||||
in: query
|
||||
name: start
|
||||
type: string
|
||||
- description: end date
|
||||
in: query
|
||||
name: end
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/repo.ValueOverTime'
|
||||
security:
|
||||
- Bearer: []
|
||||
summary: Queries the changes overtime of the purchase price over time
|
||||
tags:
|
||||
- Statistics
|
||||
/v1/items:
|
||||
get:
|
||||
parameters:
|
||||
|
@ -846,29 +938,6 @@ paths:
|
|||
summary: retrieves an attachment for an item
|
||||
tags:
|
||||
- Items Attachments
|
||||
/v1/items/{id}/attachments/download:
|
||||
get:
|
||||
parameters:
|
||||
- description: Item ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
- description: Attachment token
|
||||
in: query
|
||||
name: token
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/octet-stream
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
security:
|
||||
- Bearer: []
|
||||
summary: retrieves an attachment for an item
|
||||
tags:
|
||||
- Items Attachments
|
||||
/v1/items/import:
|
||||
post:
|
||||
parameters:
|
||||
|
|
|
@ -14,7 +14,6 @@ var Files embed.FS
|
|||
// should be called when the migrations are no longer needed.
|
||||
func Write(temp string) error {
|
||||
err := os.MkdirAll(temp, 0755)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
18
backend/internal/data/repo/query_helpers.go
Normal file
18
backend/internal/data/repo/query_helpers.go
Normal file
|
@ -0,0 +1,18 @@
|
|||
package repo
|
||||
|
||||
import "time"
|
||||
|
||||
func sqliteDateFormat(t time.Time) string {
|
||||
return t.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
// orDefault returns the value of the pointer if it is not nil, otherwise it returns the default value
|
||||
//
|
||||
// This is used for nullable or potentially nullable fields (or aggregates) in the database when running
|
||||
// queries. If the field is null, the pointer will be nil, so we return the default value instead.
|
||||
func orDefault[T any](v *T, def T) T {
|
||||
if v == nil {
|
||||
return def
|
||||
}
|
||||
return *v
|
||||
}
|
|
@ -5,10 +5,14 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"entgo.io/ent/dialect/sql"
|
||||
"github.com/google/uuid"
|
||||
"github.com/hay-kot/homebox/backend/internal/data/ent"
|
||||
"github.com/hay-kot/homebox/backend/internal/data/ent/group"
|
||||
"github.com/hay-kot/homebox/backend/internal/data/ent/groupinvitationtoken"
|
||||
"github.com/hay-kot/homebox/backend/internal/data/ent/item"
|
||||
"github.com/hay-kot/homebox/backend/internal/data/ent/label"
|
||||
"github.com/hay-kot/homebox/backend/internal/data/ent/location"
|
||||
)
|
||||
|
||||
type GroupRepository struct {
|
||||
|
@ -41,11 +45,34 @@ type (
|
|||
Uses int `json:"uses"`
|
||||
Group Group `json:"group"`
|
||||
}
|
||||
|
||||
GroupStatistics struct {
|
||||
TotalUsers int `json:"totalUsers"`
|
||||
TotalItems int `json:"totalItems"`
|
||||
TotalLocations int `json:"totalLocations"`
|
||||
TotalLabels int `json:"totalLabels"`
|
||||
TotalItemPrice float64 `json:"totalItemPrice"`
|
||||
TotalWithWarranty int `json:"totalWithWarranty"`
|
||||
}
|
||||
|
||||
ValueOverTimeEntry struct {
|
||||
Date time.Time `json:"date"`
|
||||
Value float64 `json:"value"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
ValueOverTime struct {
|
||||
PriceAtStart float64 `json:"valueAtStart"`
|
||||
PriceAtEnd float64 `json:"valueAtEnd"`
|
||||
Start time.Time `json:"start"`
|
||||
End time.Time `json:"end"`
|
||||
Entries []ValueOverTimeEntry `json:"entries"`
|
||||
}
|
||||
|
||||
TotalsByOrganizer struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Total float64 `json:"total"`
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -76,18 +103,144 @@ func mapToGroupInvitation(g *ent.GroupInvitationToken) GroupInvitation {
|
|||
}
|
||||
}
|
||||
|
||||
func (r *GroupRepository) GroupStatistics(ctx context.Context, GID uuid.UUID) (GroupStatistics, error) {
|
||||
func (r *GroupRepository) StatsLocationsByPurchasePrice(ctx context.Context, GID uuid.UUID) ([]TotalsByOrganizer, error) {
|
||||
var v []TotalsByOrganizer
|
||||
|
||||
err := r.db.Location.Query().
|
||||
Where(
|
||||
location.HasGroupWith(group.ID(GID)),
|
||||
).
|
||||
GroupBy(location.FieldID, location.FieldName).
|
||||
Aggregate(func(sq *sql.Selector) string {
|
||||
t := sql.Table(item.Table)
|
||||
sq.Join(t).On(sq.C(location.FieldID), t.C(item.LocationColumn))
|
||||
|
||||
return sql.As(sql.Sum(t.C(item.FieldPurchasePrice)), "total")
|
||||
}).
|
||||
Scan(ctx, &v)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return v, err
|
||||
}
|
||||
|
||||
func (r *GroupRepository) StatsLabelsByPurchasePrice(ctx context.Context, GID uuid.UUID) ([]TotalsByOrganizer, error) {
|
||||
var v []TotalsByOrganizer
|
||||
|
||||
err := r.db.Label.Query().
|
||||
Where(
|
||||
label.HasGroupWith(group.ID(GID)),
|
||||
).
|
||||
GroupBy(label.FieldID, label.FieldName).
|
||||
Aggregate(func(sq *sql.Selector) string {
|
||||
itemTable := sql.Table(item.Table)
|
||||
|
||||
jt := sql.Table(label.ItemsTable)
|
||||
|
||||
sq.Join(jt).On(sq.C(label.FieldID), jt.C(label.ItemsPrimaryKey[0]))
|
||||
sq.Join(itemTable).On(jt.C(label.ItemsPrimaryKey[1]), itemTable.C(item.FieldID))
|
||||
|
||||
return sql.As(sql.Sum(itemTable.C(item.FieldPurchasePrice)), "total")
|
||||
}).
|
||||
Scan(ctx, &v)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return v, err
|
||||
}
|
||||
|
||||
func (r *GroupRepository) StatsPurchasePrice(ctx context.Context, GID uuid.UUID, start, end time.Time) (*ValueOverTime, error) {
|
||||
// Get the Totals for the Start and End of the Given Time Period
|
||||
q := `
|
||||
SELECT
|
||||
(SELECT Sum(purchase_price)
|
||||
FROM items
|
||||
WHERE group_items = ?
|
||||
AND items.archived = false
|
||||
AND items.created_at < ?) AS price_at_start,
|
||||
(SELECT Sum(purchase_price)
|
||||
FROM items
|
||||
WHERE group_items = ?
|
||||
AND items.archived = false
|
||||
AND items.created_at < ?) AS price_at_end
|
||||
`
|
||||
stats := ValueOverTime{
|
||||
Start: start,
|
||||
End: end,
|
||||
}
|
||||
|
||||
var maybeStart *float64
|
||||
var maybeEnd *float64
|
||||
|
||||
row := r.db.Sql().QueryRowContext(ctx, q, GID, sqliteDateFormat(start), GID, sqliteDateFormat(end))
|
||||
err := row.Scan(&maybeStart, &maybeEnd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stats.PriceAtStart = orDefault(maybeStart, 0)
|
||||
stats.PriceAtEnd = orDefault(maybeEnd, 0)
|
||||
|
||||
var v []struct {
|
||||
Name string `json:"name"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
PurchasePrice float64 `json:"purchase_price"`
|
||||
}
|
||||
|
||||
// Get Created Date and Price of all items between start and end
|
||||
err = r.db.Item.Query().
|
||||
Where(
|
||||
item.HasGroupWith(group.ID(GID)),
|
||||
item.CreatedAtGTE(start),
|
||||
item.CreatedAtLTE(end),
|
||||
item.Archived(false),
|
||||
).
|
||||
Select(
|
||||
item.FieldName,
|
||||
item.FieldCreatedAt,
|
||||
item.FieldPurchasePrice,
|
||||
).
|
||||
Scan(ctx, &v)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stats.Entries = make([]ValueOverTimeEntry, len(v))
|
||||
|
||||
for i, vv := range v {
|
||||
stats.Entries[i] = ValueOverTimeEntry{
|
||||
Date: vv.CreatedAt,
|
||||
Value: vv.PurchasePrice,
|
||||
}
|
||||
}
|
||||
|
||||
return &stats, nil
|
||||
}
|
||||
|
||||
func (r *GroupRepository) StatsGroup(ctx context.Context, GID uuid.UUID) (GroupStatistics, error) {
|
||||
q := `
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM users WHERE group_users = ?) AS total_users,
|
||||
(SELECT COUNT(*) FROM items WHERE group_items = ? AND items.archived = false) AS total_items,
|
||||
(SELECT COUNT(*) FROM locations WHERE group_locations = ?) AS total_locations,
|
||||
(SELECT COUNT(*) FROM labels WHERE group_labels = ?) AS total_labels
|
||||
(SELECT COUNT(*) FROM labels WHERE group_labels = ?) AS total_labels,
|
||||
(SELECT SUM(purchase_price) FROM items WHERE group_items = ? AND items.archived = false) AS total_item_price,
|
||||
(SELECT COUNT(*)
|
||||
FROM items
|
||||
WHERE group_items = ?
|
||||
AND items.archived = false
|
||||
AND (items.lifetime_warranty = true OR items.warranty_expires > date())
|
||||
) AS total_with_warranty
|
||||
`
|
||||
var stats GroupStatistics
|
||||
row := r.db.Sql().QueryRowContext(ctx, q, GID, GID, GID, GID)
|
||||
row := r.db.Sql().QueryRowContext(ctx, q, GID, GID, GID, GID, GID, GID)
|
||||
|
||||
err := row.Scan(&stats.TotalUsers, &stats.TotalItems, &stats.TotalLocations, &stats.TotalLabels)
|
||||
err := row.Scan(&stats.TotalUsers, &stats.TotalItems, &stats.TotalLocations, &stats.TotalLabels, &stats.TotalItemPrice, &stats.TotalWithWarranty)
|
||||
if err != nil {
|
||||
return GroupStatistics{}, err
|
||||
}
|
||||
|
|
|
@ -36,7 +36,7 @@ func Test_Group_GroupStatistics(t *testing.T) {
|
|||
useItems(t, 20)
|
||||
useLabels(t, 20)
|
||||
|
||||
stats, err := tRepos.Groups.GroupStatistics(context.Background(), tGroup.ID)
|
||||
stats, err := tRepos.Groups.StatsGroup(context.Background(), tGroup.ID)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 20, stats.TotalItems)
|
||||
|
|
142
frontend/lib/api/__test__/user/stats.test.ts
Normal file
142
frontend/lib/api/__test__/user/stats.test.ts
Normal file
|
@ -0,0 +1,142 @@
|
|||
import { faker } from "@faker-js/faker";
|
||||
import { beforeAll, describe, expect, test } from "vitest";
|
||||
import { UserClient } from "../../user";
|
||||
import { factories } from "../factories";
|
||||
|
||||
type ImportObj = {
|
||||
ImportRef: string;
|
||||
Location: string;
|
||||
Labels: string;
|
||||
Quantity: string;
|
||||
Name: string;
|
||||
Description: string;
|
||||
Insured: boolean;
|
||||
SerialNumber: string;
|
||||
ModelNumber: string;
|
||||
Manufacturer: string;
|
||||
Notes: string;
|
||||
PurchaseFrom: string;
|
||||
PurchasedPrice: number;
|
||||
PurchasedTime: string;
|
||||
LifetimeWarranty: boolean;
|
||||
WarrantyExpires: string;
|
||||
WarrantyDetails: string;
|
||||
SoldTo: string;
|
||||
SoldPrice: number;
|
||||
SoldTime: string;
|
||||
SoldNotes: string;
|
||||
};
|
||||
|
||||
function toCsv(data: ImportObj[]): string {
|
||||
const headers = Object.keys(data[0]).join("\t");
|
||||
const rows = data.map(row => {
|
||||
return Object.values(row).join("\t");
|
||||
});
|
||||
return [headers, ...rows].join("\n");
|
||||
}
|
||||
|
||||
function importFileGenerator(entries: number): ImportObj[] {
|
||||
const imports: ImportObj[] = [];
|
||||
|
||||
const pick = (arr: string[]) => arr[Math.floor(Math.random() * arr.length)];
|
||||
|
||||
const labels = faker.random.words(5).split(" ").join(";");
|
||||
const locations = faker.random.words(3).split(" ");
|
||||
|
||||
const half = Math.floor(entries / 2);
|
||||
|
||||
for (let i = 0; i < entries; i++) {
|
||||
imports.push({
|
||||
ImportRef: faker.database.mongodbObjectId(),
|
||||
Location: pick(locations),
|
||||
Labels: labels,
|
||||
Quantity: faker.random.numeric(1),
|
||||
Name: faker.random.words(3),
|
||||
Description: "",
|
||||
Insured: faker.datatype.boolean(),
|
||||
SerialNumber: faker.random.alphaNumeric(5),
|
||||
ModelNumber: faker.random.alphaNumeric(5),
|
||||
Manufacturer: faker.random.alphaNumeric(5),
|
||||
Notes: "",
|
||||
PurchaseFrom: faker.name.fullName(),
|
||||
PurchasedPrice: faker.datatype.number(100),
|
||||
PurchasedTime: faker.date.past().toDateString(),
|
||||
LifetimeWarranty: half > i,
|
||||
WarrantyExpires: faker.date.future().toDateString(),
|
||||
WarrantyDetails: "",
|
||||
SoldTo: faker.name.fullName(),
|
||||
SoldPrice: faker.datatype.number(100),
|
||||
SoldTime: faker.date.past().toDateString(),
|
||||
SoldNotes: "",
|
||||
});
|
||||
}
|
||||
|
||||
return imports;
|
||||
}
|
||||
|
||||
describe("group related statistics tests", () => {
|
||||
const TOTAL_ITEMS = 30;
|
||||
|
||||
let api: UserClient | undefined;
|
||||
const imports = importFileGenerator(TOTAL_ITEMS);
|
||||
|
||||
beforeAll(async () => {
|
||||
// -- Setup --
|
||||
const { client } = await factories.client.singleUse();
|
||||
api = client;
|
||||
|
||||
const csv = toCsv(imports);
|
||||
|
||||
const setupResp = await client.items.import(new Blob([csv], { type: "text/csv" }));
|
||||
|
||||
expect(setupResp.status).toBe(204);
|
||||
});
|
||||
|
||||
// Write to file system for debugging
|
||||
// fs.writeFileSync("test.csv", csv);
|
||||
test("Validate Group Statistics", async () => {
|
||||
const { status, data } = await api.stats.group();
|
||||
expect(status).toBe(200);
|
||||
|
||||
expect(data.totalItems).toEqual(TOTAL_ITEMS);
|
||||
expect(data.totalLabels).toEqual(11); // default + new
|
||||
expect(data.totalLocations).toEqual(11); // default + new
|
||||
expect(data.totalUsers).toEqual(1);
|
||||
expect(data.totalWithWarranty).toEqual(Math.floor(TOTAL_ITEMS / 2));
|
||||
});
|
||||
|
||||
const labelData: Record<string, number> = {};
|
||||
const locationData: Record<string, number> = {};
|
||||
|
||||
for (const item of imports) {
|
||||
for (const label of item.Labels.split(";")) {
|
||||
labelData[label] = (labelData[label] || 0) + item.PurchasedPrice;
|
||||
}
|
||||
|
||||
locationData[item.Location] = (locationData[item.Location] || 0) + item.PurchasedPrice;
|
||||
}
|
||||
|
||||
test("Validate Labels Statistics", async () => {
|
||||
const { status, data } = await api.stats.labels();
|
||||
expect(status).toBe(200);
|
||||
|
||||
for (const label of data) {
|
||||
expect(label.total).toEqual(labelData[label.name]);
|
||||
}
|
||||
});
|
||||
|
||||
test("Validate Locations Statistics", async () => {
|
||||
const { status, data } = await api.stats.locations();
|
||||
expect(status).toBe(200);
|
||||
|
||||
for (const location of data) {
|
||||
expect(location.total).toEqual(locationData[location.name]);
|
||||
}
|
||||
});
|
||||
|
||||
test("Validate Purchase Over Time", async () => {
|
||||
const { status, data } = await api.stats.totalPriceOverTime();
|
||||
expect(status).toBe(200);
|
||||
expect(data.entries.length).toEqual(TOTAL_ITEMS);
|
||||
});
|
||||
});
|
|
@ -1,5 +1,5 @@
|
|||
import { BaseAPI, route } from "../base";
|
||||
import { Group, GroupInvitation, GroupInvitationCreate, GroupStatistics, GroupUpdate } from "../types/data-contracts";
|
||||
import { Group, GroupInvitation, GroupInvitationCreate, GroupUpdate } from "../types/data-contracts";
|
||||
|
||||
export class GroupApi extends BaseAPI {
|
||||
createInvitation(data: GroupInvitationCreate) {
|
||||
|
@ -21,10 +21,4 @@ export class GroupApi extends BaseAPI {
|
|||
url: route("/groups"),
|
||||
});
|
||||
}
|
||||
|
||||
statistics() {
|
||||
return this.http.get<GroupStatistics>({
|
||||
url: route("/groups/statistics"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -50,7 +50,7 @@ export class ItemsApi extends BaseAPI {
|
|||
return payload;
|
||||
}
|
||||
|
||||
import(file: File) {
|
||||
import(file: File | Blob) {
|
||||
const formData = new FormData();
|
||||
formData.append("csv", file);
|
||||
|
||||
|
|
42
frontend/lib/api/classes/stats.ts
Normal file
42
frontend/lib/api/classes/stats.ts
Normal file
|
@ -0,0 +1,42 @@
|
|||
import { BaseAPI, route } from "../base";
|
||||
import { GroupStatistics, TotalsByOrganizer, ValueOverTime } from "../types/data-contracts";
|
||||
|
||||
function YYYY_DD_MM(date?: Date): string {
|
||||
if (!date) {
|
||||
return "";
|
||||
}
|
||||
// with leading zeros
|
||||
const year = date.getFullYear();
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, "0");
|
||||
const day = date.getDate().toString().padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
export class StatsAPI extends BaseAPI {
|
||||
totalPriceOverTime(start?: Date, end?: Date) {
|
||||
return this.http.get<ValueOverTime>({
|
||||
url: route("/groups/statistics/purchase-price", { start: YYYY_DD_MM(start), end: YYYY_DD_MM(end) }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns ths general statistics for the group. This mostly just
|
||||
* includes the totals for various group properties.
|
||||
*/
|
||||
group() {
|
||||
return this.http.get<GroupStatistics>({
|
||||
url: route("/groups/statistics"),
|
||||
});
|
||||
}
|
||||
|
||||
labels() {
|
||||
return this.http.get<TotalsByOrganizer[]>({
|
||||
url: route("/groups/statistics/labels"),
|
||||
});
|
||||
}
|
||||
|
||||
locations() {
|
||||
return this.http.get<TotalsByOrganizer[]>({
|
||||
url: route("/groups/statistics/locations"),
|
||||
});
|
||||
}
|
||||
}
|
|
@ -25,10 +25,12 @@ export interface Group {
|
|||
}
|
||||
|
||||
export interface GroupStatistics {
|
||||
totalItemPrice: number;
|
||||
totalItems: number;
|
||||
totalLabels: number;
|
||||
totalLocations: number;
|
||||
totalUsers: number;
|
||||
totalWithWarranty: number;
|
||||
}
|
||||
|
||||
export interface GroupUpdate {
|
||||
|
@ -246,6 +248,12 @@ export interface PaginationResultRepoItemSummary {
|
|||
total: number;
|
||||
}
|
||||
|
||||
export interface TotalsByOrganizer {
|
||||
id: string;
|
||||
name: string;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface UserOut {
|
||||
email: string;
|
||||
groupId: string;
|
||||
|
@ -261,6 +269,20 @@ export interface UserUpdate {
|
|||
name: string;
|
||||
}
|
||||
|
||||
export interface ValueOverTime {
|
||||
end: string;
|
||||
entries: ValueOverTimeEntry[];
|
||||
start: string;
|
||||
valueAtEnd: number;
|
||||
valueAtStart: number;
|
||||
}
|
||||
|
||||
export interface ValueOverTimeEntry {
|
||||
date: string;
|
||||
name: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface ServerErrorResponse {
|
||||
error: string;
|
||||
fields: Record<string, string>;
|
||||
|
|
|
@ -5,6 +5,7 @@ import { LocationsApi } from "./classes/locations";
|
|||
import { GroupApi } from "./classes/group";
|
||||
import { UserApi } from "./classes/users";
|
||||
import { ActionsAPI } from "./classes/actions";
|
||||
import { StatsAPI } from "./classes/stats";
|
||||
import { Requests } from "~~/lib/requests";
|
||||
|
||||
export class UserClient extends BaseAPI {
|
||||
|
@ -14,6 +15,7 @@ export class UserClient extends BaseAPI {
|
|||
group: GroupApi;
|
||||
user: UserApi;
|
||||
actions: ActionsAPI;
|
||||
stats: StatsAPI;
|
||||
|
||||
constructor(requests: Requests, attachmentToken: string) {
|
||||
super(requests, attachmentToken);
|
||||
|
@ -24,6 +26,7 @@ export class UserClient extends BaseAPI {
|
|||
this.group = new GroupApi(requests);
|
||||
this.user = new UserApi(requests);
|
||||
this.actions = new ActionsAPI(requests);
|
||||
this.stats = new StatsAPI(requests);
|
||||
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
|
|
@ -22,7 +22,7 @@
|
|||
const labels = computed(() => labelsStore.labels);
|
||||
|
||||
const { data: statistics } = useAsyncData(async () => {
|
||||
const { data } = await api.group.statistics();
|
||||
const { data } = await api.stats.group();
|
||||
return data;
|
||||
});
|
||||
|
||||
|
|
|
@ -396,9 +396,11 @@
|
|||
<DetailsSection :details="itemDetails" />
|
||||
</BaseCard>
|
||||
|
||||
<BaseCard>
|
||||
<BaseCard v-if="photos && photos.length > 0">
|
||||
<template #title> Photos </template>
|
||||
<div class="container p-4 flex flex-wrap gap-2 mx-auto max-h-[500px] overflow-scroll">
|
||||
<div
|
||||
class="container border-t border-gray-300 p-4 flex flex-wrap gap-2 mx-auto max-h-[500px] overflow-y-scroll scroll-bg"
|
||||
>
|
||||
<button v-for="(img, i) in photos" :key="i" @click="openDialog(img)">
|
||||
<img class="rounded max-h-[200px]" :src="img.src" />
|
||||
</button>
|
||||
|
@ -465,9 +467,18 @@
|
|||
</BaseContainer>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
<style lang="css" scoped>
|
||||
/* Style dialog background */
|
||||
dialog::backdrop {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.scroll-bg::-webkit-scrollbar {
|
||||
width: 0.5rem;
|
||||
}
|
||||
|
||||
.scroll-bg::-webkit-scrollbar-thumb {
|
||||
border-radius: 0.25rem;
|
||||
@apply bg-base-300;
|
||||
}
|
||||
</style>
|
||||
|
|
Loading…
Reference in a new issue