Merge branch 'main' into mk/kill-32-bit
1
.github/FUNDING.yml
vendored
@@ -1 +1,2 @@
|
||||
open_collective: homebox
|
||||
github: [tankerkiller125,katosdev,tonyaellie]
|
||||
|
||||
11
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -58,6 +58,17 @@ body:
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: database
|
||||
attributes:
|
||||
label: Database Type
|
||||
description: What database backend are you using?
|
||||
multiple: false
|
||||
options:
|
||||
- SQLite
|
||||
- PostgreSQL
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: arch
|
||||
attributes:
|
||||
|
||||
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 86 KiB |
@@ -1,8 +1,8 @@
|
||||
# Screenshots
|
||||
|
||||
These screenshots are taken from our public [Demo](https://demo.homebox.software) instance.
|
||||
Note that whilst we will make every effort to ensure that these are maintained and updated, they may be outdated or missing functionality and we would always advise reviewing our demo instances:
|
||||
|
||||
- [Demo](https://demo.homebox.software)
|
||||
- [Nightly](https://nightly.homebox.software)
|
||||
# Screenshots
|
||||
|
||||
These screenshots are taken from our public [Demo](https://demo.homebox.software) instance.
|
||||
Note that whilst we will make every effort to ensure that these are maintained and updated, they may be outdated or missing functionality and we would always advise reviewing our demo instances:
|
||||
|
||||
- [Demo](https://demo.homebox.software)
|
||||
- [Nightly](https://nightly.homebox.software)
|
||||
- [VNext](https://vnext.homebox.software/)
|
||||
117
.github/scripts/update_currencies.py
vendored
@@ -1,16 +1,33 @@
|
||||
#!/usr/bin/env python3
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
from requests.adapters import HTTPAdapter, Retry
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
API_URL = 'https://restcountries.com/v3.1/all?fields=name,common,currencies'
|
||||
# Default to a pinned commit for supply-chain security
|
||||
DEFAULT_ISO_4217_URL = 'https://raw.githubusercontent.com/datasets/currency-codes/052b3088938ba32028a14e75040c286c5e142145/data/codes-all.csv'
|
||||
ISO_4217_URL = os.environ.get('ISO_4217_URL', DEFAULT_ISO_4217_URL)
|
||||
SAVE_PATH = Path('backend/internal/core/currencies/currencies.json')
|
||||
TIMEOUT = 10 # seconds
|
||||
|
||||
# Known currency decimal overrides
|
||||
CURRENCY_DECIMAL_OVERRIDES = {
|
||||
"BTC": 8, # Bitcoin uses 8 decimal places
|
||||
"JPY": 0, # Japanese Yen has no decimal places
|
||||
"BHD": 3, # Bahraini Dinar uses 3 decimal places
|
||||
}
|
||||
DEFAULT_DECIMALS = 2
|
||||
MIN_DECIMALS = 0
|
||||
MAX_DECIMALS = 6
|
||||
|
||||
|
||||
def setup_logging():
|
||||
logging.basicConfig(
|
||||
@@ -19,7 +36,93 @@ def setup_logging():
|
||||
)
|
||||
|
||||
|
||||
def get_currency_decimals(code, iso_data):
|
||||
"""
|
||||
Get the decimal places for a currency code.
|
||||
Checks overrides first, then ISO data, then uses default.
|
||||
Clamps result to safe range [MIN_DECIMALS, MAX_DECIMALS].
|
||||
"""
|
||||
# Normalize the input code
|
||||
normalized_code = (code or "").strip().upper()
|
||||
|
||||
# First check overrides
|
||||
if normalized_code in CURRENCY_DECIMAL_OVERRIDES:
|
||||
decimals = CURRENCY_DECIMAL_OVERRIDES[normalized_code]
|
||||
# Then check ISO data
|
||||
elif normalized_code in iso_data:
|
||||
decimals = iso_data[normalized_code]
|
||||
# Finally use default
|
||||
else:
|
||||
decimals = DEFAULT_DECIMALS
|
||||
|
||||
# Ensure it's an integer and clamp to safe range
|
||||
try:
|
||||
decimals = int(decimals)
|
||||
except (ValueError, TypeError):
|
||||
decimals = DEFAULT_DECIMALS
|
||||
|
||||
return max(MIN_DECIMALS, min(MAX_DECIMALS, decimals))
|
||||
|
||||
|
||||
def fetch_iso_4217_data():
|
||||
"""
|
||||
Fetch ISO 4217 currency data to get minor units (decimal places).
|
||||
Returns a dict mapping currency code to minor units.
|
||||
"""
|
||||
# Log the resolved URL for transparency
|
||||
logging.info("Fetching ISO 4217 data from: %s", ISO_4217_URL)
|
||||
if not ISO_4217_URL.lower().startswith("https://"):
|
||||
logging.error("Refusing non-HTTPS ISO_4217_URL: %s", ISO_4217_URL)
|
||||
return {}
|
||||
|
||||
session = requests.Session()
|
||||
retries = Retry(
|
||||
total=3,
|
||||
backoff_factor=1,
|
||||
status_forcelist=[429, 500, 502, 503, 504],
|
||||
allowed_methods=frozenset(['GET'])
|
||||
)
|
||||
session.mount('https://', HTTPAdapter(max_retries=retries))
|
||||
|
||||
try:
|
||||
# Add Accept header for CSV content
|
||||
headers = {'Accept': 'text/csv'}
|
||||
resp = session.get(ISO_4217_URL, timeout=TIMEOUT, headers=headers)
|
||||
resp.raise_for_status()
|
||||
except requests.exceptions.RequestException as e:
|
||||
logging.error("Failed to fetch ISO 4217 data: %s", e)
|
||||
return {}
|
||||
|
||||
# Parse CSV data
|
||||
iso_data = {}
|
||||
try:
|
||||
# Decode with utf-8-sig to strip BOM if present
|
||||
csv_content = resp.content.decode('utf-8-sig')
|
||||
csv_reader = csv.DictReader(io.StringIO(csv_content))
|
||||
|
||||
for row in csv_reader:
|
||||
code = row.get('AlphabeticCode', '').strip()
|
||||
minor_unit = row.get('MinorUnit', '').strip()
|
||||
|
||||
if code and minor_unit != 'N.A.':
|
||||
try:
|
||||
# Convert minor unit to int (decimal places)
|
||||
iso_data[code] = int(minor_unit) if minor_unit.isdigit() else 2
|
||||
except (ValueError, TypeError):
|
||||
iso_data[code] = 2 # Default to 2 if parsing fails
|
||||
|
||||
logging.info("Successfully loaded decimal data for %d currencies from ISO 4217", len(iso_data))
|
||||
return iso_data
|
||||
|
||||
except Exception as e:
|
||||
logging.error("Failed to parse ISO 4217 CSV data: %s", e)
|
||||
return {}
|
||||
|
||||
|
||||
def fetch_currencies():
|
||||
# First, fetch ISO 4217 data for decimal places
|
||||
iso_data = fetch_iso_4217_data()
|
||||
|
||||
session = requests.Session()
|
||||
retries = Retry(
|
||||
total=3,
|
||||
@@ -46,11 +149,15 @@ def fetch_currencies():
|
||||
for country in countries:
|
||||
country_name = country.get('name', {}).get('common') or "Unknown"
|
||||
for code, info in country.get('currencies', {}).items():
|
||||
# Get decimal places using the helper function
|
||||
decimals = get_currency_decimals(code, iso_data)
|
||||
|
||||
results.append({
|
||||
'code': code,
|
||||
'local': country_name,
|
||||
'symbol': info.get('symbol', ''),
|
||||
'name': info.get('name', '')
|
||||
'code': code,
|
||||
'local': country_name,
|
||||
'symbol': info.get('symbol', ''),
|
||||
'name': info.get('name', ''),
|
||||
'decimals': decimals
|
||||
})
|
||||
|
||||
# sort by country name for consistency
|
||||
|
||||
64
.github/workflows/binaries-publish.yaml
vendored
@@ -9,6 +9,8 @@ jobs:
|
||||
goreleaser:
|
||||
name: goreleaser
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
hashes: ${{ steps.binary.outputs.hashes }}
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
@@ -42,18 +44,30 @@ jobs:
|
||||
go install github.com/sigstore/cosign/cmd/cosign@latest
|
||||
|
||||
- name: Run GoReleaser
|
||||
id: releaser
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: goreleaser/goreleaser-action@v5
|
||||
with:
|
||||
workdir: "backend"
|
||||
distribution: goreleaser
|
||||
version: "~> v2"
|
||||
args: release --clean
|
||||
args: release --rm-dist
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
COSIGN_PWD: ${{ secrets.COSIGN_PWD }}
|
||||
COSIGN_YES: "true"
|
||||
|
||||
- name: Generate binary hashes
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
id: binary
|
||||
env:
|
||||
ARTIFACTS: "${{ steps.releaser.outputs.artifacts }}"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
checksum_file=$(echo "$ARTIFACTS" | jq -r '.[] | select (.type=="Checksum") | .path')
|
||||
echo "hashes=$(cat $checksum_file | base64 -w0)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Run GoReleaser No Release
|
||||
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||
uses: goreleaser/goreleaser-action@v5
|
||||
@@ -65,4 +79,50 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
COSIGN_PWD: ${{ secrets.COSIGN_PWD }}
|
||||
COSIGN_YES: "true"
|
||||
COSIGN_YES: "true"
|
||||
|
||||
binary-provenance:
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
needs: [ goreleaser ]
|
||||
permissions:
|
||||
actions: read # To read the workflow path.
|
||||
id-token: write # To sign the provenance.
|
||||
contents: write # To add assets to a release.
|
||||
uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v1.9.0
|
||||
with:
|
||||
base64-subjects: "${{ needs.goreleaser.outputs.hashes }}"
|
||||
upload-assets: true # upload to a new release
|
||||
|
||||
verification-with-slsa-verifier:
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
needs: [goreleaser, binary-provenance]
|
||||
runs-on: ubuntu-latest
|
||||
permissions: read-all
|
||||
steps:
|
||||
- name: Install the verifier
|
||||
uses: slsa-framework/slsa-verifier/actions/installer@v2.4.0
|
||||
|
||||
- name: Download assets
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PROVENANCE: "${{ needs.binary-provenance.outputs.provenance-name }}"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh -R "$GITHUB_REPOSITORY" release download "$GITHUB_REF_NAME" -p "*.tar.gz"
|
||||
gh -R "$GITHUB_REPOSITORY" release download "$GITHUB_REF_NAME" -p "*.zip"
|
||||
gh -R "$GITHUB_REPOSITORY" release download "$GITHUB_REF_NAME" -p "$PROVENANCE"
|
||||
- name: Verify assets
|
||||
env:
|
||||
CHECKSUMS: ${{ needs.goreleaser.outputs.hashes }}
|
||||
PROVENANCE: "${{ needs.binary-provenance.outputs.provenance-name }}"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
checksums=$(echo "$CHECKSUMS" | base64 -d)
|
||||
while read -r line; do
|
||||
fn=$(echo $line | cut -d ' ' -f2)
|
||||
echo "Verifying $fn"
|
||||
slsa-verifier verify-artifact --provenance-path "$PROVENANCE" \
|
||||
--source-uri "github.com/$GITHUB_REPOSITORY" \
|
||||
--source-tag "$GITHUB_REF_NAME" \
|
||||
"$fn"
|
||||
done <<<"$checksums"
|
||||
|
||||
21
.github/workflows/update-currencies.yml
vendored
@@ -5,18 +5,22 @@ on:
|
||||
branches: [ main ]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
update-currencies:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.8'
|
||||
cache: 'pip'
|
||||
@@ -25,15 +29,14 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install requests
|
||||
pip install -r .github/workflows/update-currencies/requirements.txt
|
||||
|
||||
- name: Run currency update script
|
||||
run: python .github/scripts/update_currencies.py
|
||||
|
||||
- name: Check for file changes
|
||||
id: changes
|
||||
- name: Check for currencies.json changes
|
||||
run: |
|
||||
if git diff --quiet; then
|
||||
if git diff --quiet -- backend/internal/core/currencies/currencies.json; then
|
||||
echo "changed=false" >> $GITHUB_ENV
|
||||
else
|
||||
echo "changed=true" >> $GITHUB_ENV
|
||||
@@ -41,14 +44,16 @@ jobs:
|
||||
|
||||
- name: Create Pull Request
|
||||
if: env.changed == 'true'
|
||||
uses: peter-evans/create-pull-request@v7.0.8
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
branch: update-currencies
|
||||
base: main
|
||||
title: "Update currencies.json"
|
||||
commit-message: "chore: update currencies.json"
|
||||
path: backend/internal/core/currencies/currencies.json
|
||||
path: .
|
||||
add-paths: |
|
||||
backend/internal/core/currencies/currencies.json
|
||||
|
||||
- name: No updates needed
|
||||
if: env.changed == 'false'
|
||||
|
||||
56
README.md
@@ -2,36 +2,59 @@
|
||||
<img src="/docs/public/lilbox.svg" height="200"/>
|
||||
</div>
|
||||
|
||||
<h1 align="center" style="margin-top: -10px"> HomeBox </h1>
|
||||
<p align="center" style="width: 100;">
|
||||
<h1 align="center" style="margin-top: -10px;"> HomeBox </h1>
|
||||
<p align="center" style="width: 100%;">
|
||||
<a href="https://homebox.software/en/">Docs</a>
|
||||
|
|
||||
<a href="https://demo.homebox.software">Demo</a>
|
||||
|
|
||||
<a href="https://discord.gg/aY4DCkpNA9">Discord</a>
|
||||
</p>
|
||||
<p align="center" style="width: 100%;">
|
||||
<img src="https://img.shields.io/github/check-runs/sysadminsmedia/homebox/main" alt="Github Checks"/>
|
||||
<img src="https://img.shields.io/github/license/sysadminsmedia/homebox"/>
|
||||
<img src="https://img.shields.io/github/v/release/sysadminsmedia/homebox?sort=semver&display_name=release"/>
|
||||
<img src="https://img.shields.io/weblate/progress/homebox?server=https%3A%2F%2Ftranslate.sysadminsmedia.com"/>
|
||||
</p>
|
||||
<p align="center" style="width: 100%;">
|
||||
<img src="https://img.shields.io/reddit/subreddit-subscribers/homebox"/>
|
||||
<img src="https://img.shields.io/mastodon/follow/110749314839831923?domain=infosec.exchange"/>
|
||||
<img src="https://img.shields.io/lemmy/homebox%40lemmy.world?label=lemmy"/>
|
||||
</p>
|
||||
|
||||
## What is HomeBox
|
||||
|
||||
HomeBox is the inventory and organization system built for the Home User! With a focus on simplicity and ease of use, Homebox is the perfect solution for your home inventory, organization, and management needs. While developing this project, I've tried to keep the following principles in mind:
|
||||
HomeBox is the inventory and organization system built for the Home User! With a focus on simplicity and ease of use, Homebox is the perfect solution for your home inventory, organization, and management needs. While developing this project, We've tried to keep the following principles in mind:
|
||||
|
||||
- _Simple_ - Homebox is designed to be simple and easy to use. No complicated setup or configuration required. Use either a single docker container, or deploy yourself by compiling the binary for your platform of choice.
|
||||
- _Blazingly Fast_ - Homebox is written in Go, which makes it extremely fast and requires minimal resources to deploy. In general, idle memory usage is less than 50MB for the whole container.
|
||||
- _Portable_ - Homebox is designed to be portable and run on anywhere. We use SQLite and an embedded Web UI to make it easy to deploy, use, and backup.
|
||||
- 🧘 _Simple but Expandable_ - Homebox is designed to be simple and easy to use. No complicated setup or configuration required. But expandable to whatever level of infrastructure you want to put into it.
|
||||
- 🚀 _Blazingly Fast_ - Homebox is written in Go, which makes it extremely fast and requires minimal resources to deploy. In general, idle memory usage is less than 50MB for the whole container.
|
||||
- 📦 _Portable_ - Homebox is designed to be portable and run on anywhere. We use SQLite and an embedded Web UI to make it easy to deploy, use, and backup.
|
||||
|
||||
### Key Features
|
||||
- 📇 Rich Organization - Organize your items into categories, locations, and tags. You can also create custom fields to store additional information about your items.
|
||||
- 🔍 Powerful Search - Quickly find items in your inventory using the powerful search feature.
|
||||
- 📸 Image Upload - Upload images of your items to make it easy to identify them.
|
||||
- 📄 Document and Warranty Tracking - Keep track of important documents and warranties for your items.
|
||||
- 💰 Purchase & Maintenance Tracking - Track purchase dates, prices, and maintenance schedules for your items.
|
||||
- 📱 Responsive Design - Homebox is designed to work on any device, including desktops, tablets, and smartphones.
|
||||
|
||||
## Screenshots
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
# Screenshots
|
||||
Check out screenshots of the project [here](https://github.com/sysadminsmedia/homebox/tree/main/screenshots).
|
||||
You can also try the demo instances of Homebox:
|
||||
- [Demo](https://demo.homebox.software)
|
||||
- [Nightly](https://nightly.homebox.software)
|
||||
- [VNext](https://vnext.homebox.software/)
|
||||
|
||||
## Quick Start
|
||||
|
||||
[Configuration & Docker Compose](https://homebox.software/en/quick-start.html)
|
||||
|
||||
```bash
|
||||
# If using the rootless image, ensure data
|
||||
# If using the rootless or hardened image, ensure data
|
||||
# folder has correct permissions
|
||||
mkdir -p /path/to/data/folder
|
||||
chown 65532:65532 -R /path/to/data/folder
|
||||
@@ -43,6 +66,7 @@ docker run -d \
|
||||
--volume /path/to/data/folder/:/data \
|
||||
ghcr.io/sysadminsmedia/homebox:latest
|
||||
# ghcr.io/sysadminsmedia/homebox:latest-rootless
|
||||
# ghcr.io/sysadminsmedia/homebox:latest-hardened
|
||||
```
|
||||
|
||||
<!-- CONTRIBUTING -->
|
||||
@@ -51,14 +75,20 @@ docker run -d \
|
||||
|
||||
Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**.
|
||||
|
||||
If you are not a coder, you can still contribute financially. Financial contributions help me prioritize working on this project over others and helps me know that there is a real demand for project development.
|
||||
To get started with code based contributions, please see our [contributing guide](https://homebox.software/en/contribute/get-started.html).
|
||||
|
||||
If you are not a coder and can't help translate, you can still contribute financially. Financial contributions help us maintain the project and keep demos running.
|
||||
|
||||
## Help us Translate
|
||||
We want to make sure that Homebox is available in as many languages as possible. If you are interested in helping us translate Homebox, please help us via our [Weblate instance](https://translate.sysadminsmedia.com/projects/homebox/).
|
||||
|
||||
[](http://translate.sysadminsmedia.com/engage/homebox/)
|
||||
[](https://translate.sysadminsmedia.com/engage/homebox/)
|
||||
|
||||
## Credits
|
||||
|
||||
- Original project by [@hay-kot](https://github.com/hay-kot)
|
||||
- Logo by [@lakotelman](https://github.com/lakotelman)
|
||||
|
||||
### Contributors
|
||||
<a href="https://github.com/sysadminsmedia/homebox/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=sysadminsmedia/homebox" />
|
||||
</a>
|
||||
|
||||
@@ -23,10 +23,13 @@ tasks:
|
||||
INTERNAL: "../../../internal"
|
||||
PKGS: "../../../pkgs"
|
||||
cmds:
|
||||
- swag fmt --dir={{ .API }}
|
||||
- swag init --dir={{ .API }},{{ .INTERNAL }}/core/services,{{ .INTERNAL }}/data/repo --parseDependency
|
||||
- cp -r ./docs/swagger.json ../../../../docs/en/api/openapi-2.0.json
|
||||
- cp -r ./docs/swagger.yaml ../../../../docs/en/api/openapi-2.0.yaml
|
||||
- npx -y -p swagger2openapi swagger2openapi --outfile ./docs/openapi-3.json ./docs/swagger.json
|
||||
- npx -y -p swagger2openapi swagger2openapi --yaml --outfile ./docs/openapi-3.yaml ./docs/swagger.json
|
||||
- cp -r ./docs/swagger.json ../../../../docs/en/api/swagger-2.0.json
|
||||
- cp -r ./docs/swagger.yaml ../../../../docs/en/api/swagger-2.0.yaml
|
||||
- cp -r ./docs/openapi-3.json ../../../../docs/en/api/openapi-3.0.json
|
||||
- cp -r ./docs/openapi-3.yaml ../../../../docs/en/api/openapi-3.0.yaml
|
||||
sources:
|
||||
- "./backend/app/api/**/*"
|
||||
- "./backend/internal/data/**"
|
||||
|
||||
@@ -19,6 +19,13 @@ builds:
|
||||
- amd64
|
||||
- arm64
|
||||
- riscv64
|
||||
flags:
|
||||
- -trimpath
|
||||
ldflags:
|
||||
- -s -w
|
||||
- -X main.version={{.Version}}
|
||||
- -X main.commit={{.Commit}}
|
||||
- -X main.date={{.Date}}
|
||||
tags:
|
||||
- >-
|
||||
{{- if eq .Arch "riscv64" }}nodynamic
|
||||
|
||||
@@ -2240,6 +2240,9 @@ const docTemplate = `{
|
||||
"code": {
|
||||
"type": "string"
|
||||
},
|
||||
"decimals": {
|
||||
"type": "integer"
|
||||
},
|
||||
"local": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -3480,6 +3483,19 @@ const docTemplate = `{
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"labelIds": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"x-nullable": true,
|
||||
"x-omitempty": true
|
||||
},
|
||||
"locationId": {
|
||||
"type": "string",
|
||||
"x-nullable": true,
|
||||
"x-omitempty": true
|
||||
},
|
||||
"quantity": {
|
||||
"type": "integer",
|
||||
"x-nullable": true,
|
||||
|
||||
4563
backend/app/api/static/docs/openapi-3.json
Normal file
2898
backend/app/api/static/docs/openapi-3.yaml
Normal file
@@ -2238,6 +2238,9 @@
|
||||
"code": {
|
||||
"type": "string"
|
||||
},
|
||||
"decimals": {
|
||||
"type": "integer"
|
||||
},
|
||||
"local": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -3478,6 +3481,19 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"labelIds": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"x-nullable": true,
|
||||
"x-omitempty": true
|
||||
},
|
||||
"locationId": {
|
||||
"type": "string",
|
||||
"x-nullable": true,
|
||||
"x-omitempty": true
|
||||
},
|
||||
"quantity": {
|
||||
"type": "integer",
|
||||
"x-nullable": true,
|
||||
|
||||
@@ -34,6 +34,8 @@ definitions:
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
decimals:
|
||||
type: integer
|
||||
local:
|
||||
type: string
|
||||
name:
|
||||
@@ -873,6 +875,16 @@ definitions:
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
labelIds:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
x-nullable: true
|
||||
x-omitempty: true
|
||||
locationId:
|
||||
type: string
|
||||
x-nullable: true
|
||||
x-omitempty: true
|
||||
quantity:
|
||||
type: integer
|
||||
x-nullable: true
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -15,6 +16,24 @@ import (
|
||||
//go:embed currencies.json
|
||||
var defaults []byte
|
||||
|
||||
const (
|
||||
MinDecimals = 0
|
||||
MaxDecimals = 18
|
||||
)
|
||||
|
||||
// clampDecimals ensures the decimals value is within a safe range [0, 18]
|
||||
func clampDecimals(decimals int, code string) int {
|
||||
original := decimals
|
||||
if decimals < MinDecimals {
|
||||
decimals = MinDecimals
|
||||
log.Printf("WARNING: Currency %s had negative decimals (%d), normalized to %d", code, original, decimals)
|
||||
} else if decimals > MaxDecimals {
|
||||
decimals = MaxDecimals
|
||||
log.Printf("WARNING: Currency %s had excessive decimals (%d), normalized to %d", code, original, decimals)
|
||||
}
|
||||
return decimals
|
||||
}
|
||||
|
||||
type CollectorFunc func() ([]Currency, error)
|
||||
|
||||
func CollectJSON(reader io.Reader) CollectorFunc {
|
||||
@@ -25,6 +44,11 @@ func CollectJSON(reader io.Reader) CollectorFunc {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Clamp decimals during collection to ensure early normalization
|
||||
for i := range currencies {
|
||||
currencies[i].Decimals = clampDecimals(currencies[i].Decimals, currencies[i].Code)
|
||||
}
|
||||
|
||||
return currencies, nil
|
||||
}
|
||||
}
|
||||
@@ -48,10 +72,11 @@ func CollectionCurrencies(collectors ...CollectorFunc) ([]Currency, error) {
|
||||
}
|
||||
|
||||
type Currency struct {
|
||||
Name string `json:"name"`
|
||||
Code string `json:"code"`
|
||||
Local string `json:"local"`
|
||||
Symbol string `json:"symbol"`
|
||||
Name string `json:"name"`
|
||||
Code string `json:"code"`
|
||||
Local string `json:"local"`
|
||||
Symbol string `json:"symbol"`
|
||||
Decimals int `json:"decimals"`
|
||||
}
|
||||
|
||||
type CurrencyRegistry struct {
|
||||
@@ -62,7 +87,10 @@ type CurrencyRegistry struct {
|
||||
func NewCurrencyService(currencies []Currency) *CurrencyRegistry {
|
||||
registry := make(map[string]Currency, len(currencies))
|
||||
for i := range currencies {
|
||||
registry[currencies[i].Code] = currencies[i]
|
||||
// Clamp decimals to safe range before adding to registry
|
||||
currency := currencies[i]
|
||||
currency.Decimals = clampDecimals(currency.Decimals, currency.Code)
|
||||
registry[currency.Code] = currency
|
||||
}
|
||||
|
||||
return &CurrencyRegistry{
|
||||
|
||||
@@ -120,9 +120,11 @@ type (
|
||||
}
|
||||
|
||||
ItemPatch struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Quantity *int `json:"quantity,omitempty" extensions:"x-nullable,x-omitempty"`
|
||||
ImportRef *string `json:"-,omitempty" extensions:"x-nullable,x-omitempty"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
Quantity *int `json:"quantity,omitempty" extensions:"x-nullable,x-omitempty"`
|
||||
ImportRef *string `json:"-,omitempty" extensions:"x-nullable,x-omitempty"`
|
||||
LocationID uuid.UUID `json:"locationId" extensions:"x-nullable,x-omitempty"`
|
||||
LabelIDs []uuid.UUID `json:"labelIds" extensions:"x-nullable,x-omitempty"`
|
||||
}
|
||||
|
||||
ItemSummary struct {
|
||||
@@ -814,7 +816,20 @@ func (e *ItemsRepository) GetAllZeroImportRef(ctx context.Context, gid uuid.UUID
|
||||
}
|
||||
|
||||
func (e *ItemsRepository) Patch(ctx context.Context, gid, id uuid.UUID, data ItemPatch) error {
|
||||
q := e.db.Item.Update().
|
||||
tx, err := e.db.Tx(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
if err := tx.Rollback(); err != nil {
|
||||
log.Warn().Err(err).Msg("failed to rollback transaction during item patch")
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
q := tx.Item.Update().
|
||||
Where(
|
||||
item.ID(id),
|
||||
item.HasGroupWith(group.ID(gid)),
|
||||
@@ -828,8 +843,81 @@ func (e *ItemsRepository) Patch(ctx context.Context, gid, id uuid.UUID, data Ite
|
||||
q.SetQuantity(*data.Quantity)
|
||||
}
|
||||
|
||||
if data.LocationID != uuid.Nil {
|
||||
q.SetLocationID(data.LocationID)
|
||||
}
|
||||
|
||||
err = q.Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if data.LabelIDs != nil {
|
||||
currentLabels, err := tx.Item.Query().Where(item.ID(id), item.HasGroupWith(group.ID(gid))).QueryLabel().All(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
set := newIDSet(currentLabels)
|
||||
|
||||
addLabels := []uuid.UUID{}
|
||||
for _, l := range data.LabelIDs {
|
||||
if set.Contains(l) {
|
||||
set.Remove(l)
|
||||
} else {
|
||||
addLabels = append(addLabels, l)
|
||||
}
|
||||
}
|
||||
|
||||
if len(addLabels) > 0 {
|
||||
if err := tx.Item.Update().
|
||||
Where(item.ID(id), item.HasGroupWith(group.ID(gid))).
|
||||
AddLabelIDs(addLabels...).
|
||||
Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if set.Len() > 0 {
|
||||
if err := tx.Item.Update().
|
||||
Where(item.ID(id), item.HasGroupWith(group.ID(gid))).
|
||||
RemoveLabelIDs(set.Slice()...).
|
||||
Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if data.LocationID != uuid.Nil {
|
||||
itemEnt, err := tx.Item.Query().Where(item.ID(id), item.HasGroupWith(group.ID(gid))).Only(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if itemEnt.SyncChildItemsLocations {
|
||||
children, err := tx.Item.Query().Where(item.ID(id), item.HasGroupWith(group.ID(gid))).QueryChildren().All(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, child := range children {
|
||||
childLocation, err := child.QueryLocation().First(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if data.LocationID != childLocation.ID {
|
||||
err = child.Update().SetLocationID(data.LocationID).Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
committed = true
|
||||
|
||||
e.publishMutationEvent(gid)
|
||||
return q.Exec(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *ItemsRepository) GetAllCustomFieldValues(ctx context.Context, gid uuid.UUID, name string) ([]string, error) {
|
||||
|
||||
@@ -50,7 +50,7 @@ document.head.appendChild(elementStyle);
|
||||
<client-only>
|
||||
<elements-api
|
||||
:key="componentKey"
|
||||
apiDescriptionUrl="https://raw.githubusercontent.com/sysadminsmedia/homebox/refs/heads/main/docs/en/api/openapi-2.0.json"
|
||||
apiDescriptionUrl="https://raw.githubusercontent.com/sysadminsmedia/homebox/refs/heads/main/docs/en/api/openapi-3.0.json"
|
||||
router="hash"
|
||||
layout="responsive"
|
||||
hideSchemas="true"
|
||||
|
||||
4563
docs/en/api/openapi-3.0.json
Normal file
2898
docs/en/api/openapi-3.0.yaml
Normal file
@@ -2238,6 +2238,9 @@
|
||||
"code": {
|
||||
"type": "string"
|
||||
},
|
||||
"decimals": {
|
||||
"type": "integer"
|
||||
},
|
||||
"local": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -3478,6 +3481,19 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"labelIds": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"x-nullable": true,
|
||||
"x-omitempty": true
|
||||
},
|
||||
"locationId": {
|
||||
"type": "string",
|
||||
"x-nullable": true,
|
||||
"x-omitempty": true
|
||||
},
|
||||
"quantity": {
|
||||
"type": "integer",
|
||||
"x-nullable": true,
|
||||
@@ -34,6 +34,8 @@ definitions:
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
decimals:
|
||||
type: integer
|
||||
local:
|
||||
type: string
|
||||
name:
|
||||
@@ -873,6 +875,16 @@ definitions:
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
labelIds:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
x-nullable: true
|
||||
x-omitempty: true
|
||||
locationId:
|
||||
type: string
|
||||
x-nullable: true
|
||||
x-omitempty: true
|
||||
quantity:
|
||||
type: integer
|
||||
x-nullable: true
|
||||
@@ -14,7 +14,7 @@ aside: false
|
||||
| HBOX_WEB_HOST | | host to run the web server on, if you're using docker do not change this. see below for examples |
|
||||
| HBOX_OPTIONS_ALLOW_REGISTRATION | true | allow users to register themselves |
|
||||
| HBOX_OPTIONS_AUTO_INCREMENT_ASSET_ID | true | auto-increments the asset_id field for new items |
|
||||
| HBOX_OPTIONS_CURRENCY_CONFIG | | json configuration file containing additional currencie |
|
||||
| HBOX_OPTIONS_CURRENCY_CONFIG | | json configuration file containing additional currencies |
|
||||
| HBOX_OPTIONS_ALLOW_ANALYTICS | false | Allows the homebox team to view extremely basic information about the system that your running on. This helps make decisions regarding builds and other general decisions. |
|
||||
| HBOX_WEB_MAX_UPLOAD_SIZE | 10 | maximum file upload size supported in MB |
|
||||
| HBOX_WEB_READ_TIMEOUT | 10s | Read timeout of HTTP sever |
|
||||
|
||||
@@ -102,3 +102,11 @@ The copy to clipboard functionality requires a secure context (HTTPS or localhos
|
||||
To enable this feature:
|
||||
- Use HTTPS by setting up a reverse proxy (like Nginx or Caddy)
|
||||
- OR access Homebox through localhost
|
||||
|
||||
## Open Multiple Items in New Tabs
|
||||
|
||||
By default browsers prevent opening multiple tabs with one click, to allow for the `View Items` button to work you therefore need to enable a setting usually called `Allow pop-ups and redirects` or similar for the domain you're using Homebox on.
|
||||
|
||||
- Chrome: [Block or allow pop-ups in Chrome](https://support.google.com/chrome/answer/95472?hl=en-GB&co=GENIE.Platform%3DDesktop#zippy=%2Callow-pop-ups-and-redirects-from-a-site)
|
||||
- Firefox: [Pop-up blocker settings, exceptions and troubleshooting](https://support.mozilla.org/en-US/kb/pop-blocker-settings-exceptions-troubleshooting)
|
||||
- Safari: [Block pop-up ads and windows in Safari](https://support.apple.com/en-gb/102524)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<CardTitle class="flex items-center">
|
||||
<slot />
|
||||
</CardTitle>
|
||||
<slot name="subtitle" />
|
||||
<CardDescription v-if="$slots.description">
|
||||
<slot name="description" />
|
||||
</CardDescription>
|
||||
|
||||
@@ -115,7 +115,6 @@
|
||||
import MdiAlertCircleOutline from "~icons/mdi/alert-circle-outline";
|
||||
import MdiBarcode from "~icons/mdi/barcode";
|
||||
import MdiLoading from "~icons/mdi/loading";
|
||||
import type { TableData } from "~/components/Item/View/Table.types";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
@@ -225,7 +224,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
function extractValue(data: TableData, value: string) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function extractValue(data: Record<string, any>, value: string) {
|
||||
const parts = value.split(".");
|
||||
let current = data;
|
||||
for (const part of parts) {
|
||||
|
||||
@@ -1,8 +1,30 @@
|
||||
<template>
|
||||
<Card class="overflow-hidden">
|
||||
<Card class="relative overflow-hidden">
|
||||
<div v-if="tableRow" class="absolute left-1 top-1 z-10">
|
||||
<Checkbox
|
||||
class="size-5 bg-accent hover:bg-background-accent"
|
||||
:model-value="tableRow.getIsSelected()"
|
||||
:aria-label="$t('components.item.view.selectable.select_card')"
|
||||
@update:model-value="tableRow.toggleSelected()"
|
||||
/>
|
||||
</div>
|
||||
<NuxtLink :to="`/item/${item.id}`">
|
||||
<div class="relative h-[200px]">
|
||||
<img v-if="imageUrl" class="h-[200px] w-full object-cover shadow-md" loading="lazy" :src="imageUrl" alt="" />
|
||||
<img
|
||||
v-if="imageUrl && objectContain"
|
||||
class="absolute h-[200px] w-full object-cover blur-md"
|
||||
loading="lazy"
|
||||
:src="imageUrl"
|
||||
alt=""
|
||||
/>
|
||||
<img
|
||||
v-if="imageUrl"
|
||||
class="absolute h-[200px] w-full shadow-md"
|
||||
:class="objectContain ? 'object-contain' : 'object-cover'"
|
||||
loading="lazy"
|
||||
:src="imageUrl"
|
||||
:alt="item.name"
|
||||
/>
|
||||
<div class="absolute inset-x-1 bottom-1">
|
||||
<Badge class="text-wrap bg-secondary text-secondary-foreground hover:bg-secondary/70 hover:underline">
|
||||
<NuxtLink v-if="item.location" :to="`/location/${item.location.id}`">
|
||||
@@ -64,8 +86,11 @@
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import Markdown from "@/components/global/Markdown.vue";
|
||||
import LabelChip from "@/components/Label/Chip.vue";
|
||||
import type { Row } from "@tanstack/vue-table";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
|
||||
const api = useUserApi();
|
||||
const preferences = useViewPreferences();
|
||||
|
||||
const imageUrl = computed(() => {
|
||||
if (!props.item.imageId) {
|
||||
@@ -92,8 +117,15 @@
|
||||
required: false,
|
||||
default: () => [],
|
||||
},
|
||||
tableRow: {
|
||||
type: Object as () => Row<ItemSummary>,
|
||||
required: false,
|
||||
default: () => null,
|
||||
},
|
||||
});
|
||||
|
||||
const objectContain = computed(() => imageUrl.value !== "/no-image.jpg" && !preferences.value.legacyImageFit);
|
||||
|
||||
const locationString = computed(
|
||||
() => props.locationFlatTree.find(l => l.id === props.item.location?.id)?.treeString || props.item.location?.name
|
||||
);
|
||||
|
||||
169
frontend/components/Item/View/ItemChangeDetails.vue
Normal file
@@ -0,0 +1,169 @@
|
||||
<script setup lang="ts">
|
||||
import { Dialog, DialogContent, DialogFooter, DialogTitle, DialogHeader } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useDialog } from "@/components/ui/dialog-provider";
|
||||
import { DialogID } from "~/components/ui/dialog-provider/utils";
|
||||
import type { ItemPatch, ItemSummary, LabelOut, LocationSummary } from "~/lib/api/types/data-contracts";
|
||||
import LocationSelector from "~/components/Location/Selector.vue";
|
||||
import MdiLoading from "~icons/mdi/loading";
|
||||
import { toast } from "~/components/ui/sonner";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import LabelSelector from "~/components/Label/Selector.vue";
|
||||
|
||||
const { closeDialog, registerOpenDialogCallback } = useDialog();
|
||||
|
||||
const api = useUserApi();
|
||||
const { t } = useI18n();
|
||||
const labelStore = useLabelStore();
|
||||
|
||||
const allLabels = computed(() => labelStore.labels);
|
||||
|
||||
const items = ref<ItemSummary[]>([]);
|
||||
const saving = ref(false);
|
||||
|
||||
const enabled = reactive({
|
||||
changeLocation: false,
|
||||
addLabels: false,
|
||||
removeLabels: false,
|
||||
});
|
||||
|
||||
const newLocation = ref<LocationSummary | null>(null);
|
||||
const addLabels = ref<string[]>([]);
|
||||
const removeLabels = ref<string[]>([]);
|
||||
|
||||
const availableToAddLabels = ref<LabelOut[]>([]);
|
||||
const availableToRemoveLabels = ref<LabelOut[]>([]);
|
||||
|
||||
const intersectLabelIds = (items: ItemSummary[]): string[] => {
|
||||
if (items.length === 0) return [];
|
||||
const counts = new Map<string, number>();
|
||||
for (const it of items) {
|
||||
const seen = new Set<string>();
|
||||
for (const l of it.labels || []) seen.add(l.id);
|
||||
for (const id of seen) counts.set(id, (counts.get(id) || 0) + 1);
|
||||
}
|
||||
return [...counts.entries()].filter(([_, c]) => c === items.length).map(([id]) => id);
|
||||
};
|
||||
|
||||
const unionLabelIds = (items: ItemSummary[]): string[] => {
|
||||
const s = new Set<string>();
|
||||
for (const it of items) for (const l of it.labels || []) s.add(l.id);
|
||||
return Array.from(s);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
const cleanup = registerOpenDialogCallback(DialogID.ItemChangeDetails, params => {
|
||||
items.value = params.items;
|
||||
enabled.changeLocation = params.changeLocation ?? false;
|
||||
enabled.addLabels = params.addLabels ?? false;
|
||||
enabled.removeLabels = params.removeLabels ?? false;
|
||||
|
||||
if (params.changeLocation && params.items.length > 0) {
|
||||
// if all locations are the same then set the current location to said location
|
||||
if (
|
||||
params.items[0]!.location &&
|
||||
params.items.every(item => item.location?.id === params.items[0]!.location?.id)
|
||||
) {
|
||||
newLocation.value = params.items[0]!.location;
|
||||
}
|
||||
}
|
||||
|
||||
if (params.addLabels && params.items.length > 0) {
|
||||
const intersection = intersectLabelIds(params.items);
|
||||
availableToAddLabels.value = allLabels.value.filter(l => !intersection.includes(l.id));
|
||||
}
|
||||
|
||||
if (params.removeLabels && params.items.length > 0) {
|
||||
const union = unionLabelIds(params.items);
|
||||
availableToRemoveLabels.value = allLabels.value.filter(l => union.includes(l.id));
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(cleanup);
|
||||
});
|
||||
|
||||
const save = async () => {
|
||||
const location = newLocation.value;
|
||||
const labelsToAdd = addLabels.value;
|
||||
const labelsToRemove = removeLabels.value;
|
||||
if (!items.value.length || (enabled.changeLocation && !location)) {
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
|
||||
await Promise.allSettled(
|
||||
items.value.map(async item => {
|
||||
const patch: ItemPatch = {
|
||||
id: item.id,
|
||||
};
|
||||
|
||||
if (enabled.changeLocation) {
|
||||
patch.locationId = location!.id;
|
||||
}
|
||||
|
||||
let currentLabels = item.labels.map(l => l.id);
|
||||
|
||||
if (enabled.addLabels) {
|
||||
currentLabels = currentLabels.concat(labelsToAdd);
|
||||
}
|
||||
|
||||
if (enabled.removeLabels) {
|
||||
currentLabels = currentLabels.filter(l => !labelsToRemove.includes(l));
|
||||
}
|
||||
|
||||
if (enabled.addLabels || enabled.removeLabels) {
|
||||
patch.labelIds = Array.from(new Set(currentLabels));
|
||||
}
|
||||
|
||||
const { error, data } = await api.items.patch(item.id, patch);
|
||||
|
||||
if (error) {
|
||||
console.error("failed to update item", item.id, data);
|
||||
toast.error(t("components.item.view.change_details.failed_to_update_item"));
|
||||
return;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
closeDialog(DialogID.ItemChangeDetails, true);
|
||||
enabled.changeLocation = false;
|
||||
enabled.addLabels = false;
|
||||
enabled.removeLabels = false;
|
||||
items.value = [];
|
||||
addLabels.value = [];
|
||||
removeLabels.value = [];
|
||||
availableToAddLabels.value = [];
|
||||
availableToRemoveLabels.value = [];
|
||||
saving.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :dialog-id="DialogID.ItemChangeDetails">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ $t("components.item.view.change_details.title") }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<LocationSelector v-if="enabled.changeLocation" v-model="newLocation" />
|
||||
<LabelSelector
|
||||
v-if="enabled.addLabels"
|
||||
v-model="addLabels"
|
||||
:labels="availableToAddLabels"
|
||||
:name="$t('components.item.view.change_details.add_labels')"
|
||||
/>
|
||||
<LabelSelector
|
||||
v-if="enabled.removeLabels"
|
||||
v-model="removeLabels"
|
||||
:labels="availableToRemoveLabels"
|
||||
:name="$t('components.item.view.change_details.remove_labels')"
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button type="submit" :disabled="saving || (enabled.changeLocation && !newLocation)" @click="save">
|
||||
<span v-if="!saving">{{ $t("global.save") }}</span>
|
||||
<MdiLoading v-else class="animate-spin" />
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -6,17 +6,32 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button, ButtonGroup } from "@/components/ui/button";
|
||||
import BaseSectionHeader from "@/components/Base/SectionHeader.vue";
|
||||
import ItemCard from "@/components/Item/Card.vue";
|
||||
import ItemViewTable from "@/components/Item/View/Table.vue";
|
||||
import DataTable from "./table/data-table.vue";
|
||||
import { makeColumns } from "./table/columns";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { Pagination } from "./pagination";
|
||||
import MaintenanceEditModal from "@/components/Maintenance/EditModal.vue";
|
||||
import ItemChangeDetails from "./ItemChangeDetails.vue";
|
||||
|
||||
type Props = {
|
||||
const props = defineProps<{
|
||||
view?: ViewType;
|
||||
items: ItemSummary[];
|
||||
};
|
||||
locationFlatTree?: FlatTreeItem[];
|
||||
pagination?: Pagination;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "refresh"): void;
|
||||
}>();
|
||||
|
||||
const preferences = useViewPreferences();
|
||||
const { t } = useI18n();
|
||||
const columns = computed(() =>
|
||||
makeColumns(t, () => {
|
||||
emit("refresh");
|
||||
})
|
||||
);
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const viewSet = computed(() => {
|
||||
return !!props.view;
|
||||
});
|
||||
@@ -28,17 +43,29 @@
|
||||
function setViewPreference(view: ViewType) {
|
||||
preferences.value.itemDisplayView = view;
|
||||
}
|
||||
|
||||
const externalPagination = computed(() => !!props.pagination);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<BaseSectionHeader class="mb-2 mt-4 flex items-center justify-between">
|
||||
<MaintenanceEditModal />
|
||||
<ItemChangeDetails />
|
||||
|
||||
<BaseSectionHeader class="flex items-center justify-between" :class="{ 'mb-2 mt-4': !externalPagination }">
|
||||
<div class="flex gap-2 text-nowrap">
|
||||
{{ $t("components.item.view.selectable.items") }}
|
||||
<Badge>
|
||||
<Badge v-if="!externalPagination">
|
||||
{{ items.length }}
|
||||
</Badge>
|
||||
</div>
|
||||
<template #subtitle>
|
||||
<div
|
||||
id="selectable-subtitle"
|
||||
class="flex grow items-center px-2"
|
||||
:class="{ hidden: !preferences.quickActions.enabled }"
|
||||
/>
|
||||
</template>
|
||||
<template #description>
|
||||
<div v-if="!viewSet">
|
||||
<ButtonGroup>
|
||||
@@ -59,15 +86,26 @@
|
||||
</template>
|
||||
</BaseSectionHeader>
|
||||
|
||||
<template v-if="itemView === 'table'">
|
||||
<ItemViewTable :items="items" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3">
|
||||
<ItemCard v-for="item in items" :key="item.id" :item="item" />
|
||||
<div class="hidden first:block">{{ $t("components.item.view.selectable.no_items") }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<p v-if="externalPagination && pagination!.totalSize > 0" class="mb-4 flex items-center text-base font-medium">
|
||||
{{ $t("items.results", { total: pagination!.totalSize }) }}
|
||||
<span class="ml-auto text-base">
|
||||
{{
|
||||
$t("items.pages", {
|
||||
page: pagination!.page,
|
||||
totalPages: Math.ceil(pagination!.totalSize / pagination!.pageSize),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<DataTable
|
||||
:view="itemView"
|
||||
:columns="preferences.quickActions.enabled ? columns : columns.filter(c => c.enableHiding !== false)"
|
||||
:data="items"
|
||||
:location-flat-tree="locationFlatTree"
|
||||
:external-pagination="pagination"
|
||||
@refresh="$emit('refresh')"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import type { ItemSummary } from "~~/lib/api/types/data-contracts";
|
||||
|
||||
export type TableHeaderType = {
|
||||
text: string;
|
||||
value: keyof ItemSummary;
|
||||
sortable?: boolean;
|
||||
align?: "left" | "center" | "right";
|
||||
enabled: boolean;
|
||||
type?: "price" | "boolean" | "name" | "location" | "date";
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type TableData = Record<string, any>;
|
||||
@@ -1,333 +1,19 @@
|
||||
<template>
|
||||
<Dialog :dialog-id="DialogID.ItemTableSettings">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ $t("components.item.view.table.table_settings") }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div>{{ $t("components.item.view.table.headers") }}</div>
|
||||
<div class="flex flex-col">
|
||||
<div v-for="(h, i) in headers" :key="h.value" class="flex flex-row items-center gap-1">
|
||||
<Button size="icon" class="size-6" variant="ghost" :disabled="i === 0" @click="moveHeader(i, i - 1)">
|
||||
<MdiArrowUp />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
class="size-6"
|
||||
variant="ghost"
|
||||
:disabled="i === headers.length - 1"
|
||||
@click="moveHeader(i, i + 1)"
|
||||
>
|
||||
<MdiArrowDown />
|
||||
</Button>
|
||||
<Checkbox :id="h.value" :model-value="h.enabled" @update:model-value="toggleHeader(h.value)" />
|
||||
<label class="text-sm" :for="h.value"> {{ $t(h.text) }} </label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<Label> {{ $t("components.item.view.table.rows_per_page") }} </Label>
|
||||
<Select :model-value="pagination.rowsPerPage" @update:model-value="pagination.rowsPerPage = Number($event)">
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="10">10</SelectItem>
|
||||
<SelectItem :value="25">25</SelectItem>
|
||||
<SelectItem :value="50">50</SelectItem>
|
||||
<SelectItem :value="100">100</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button @click="closeDialog(DialogID.ItemTableSettings)"> {{ $t("global.save") }} </Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<BaseCard>
|
||||
<Table class="w-full">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead
|
||||
v-for="h in headers.filter(h => h.enabled)"
|
||||
:key="h.value"
|
||||
class="text-no-transform cursor-pointer bg-secondary text-sm text-secondary-foreground hover:bg-secondary/90"
|
||||
@click="sortBy(h.value)"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-1"
|
||||
:class="{
|
||||
'justify-center': h.align === 'center',
|
||||
'justify-start': h.align === 'right',
|
||||
'justify-end': h.align === 'left',
|
||||
}"
|
||||
>
|
||||
<template v-if="typeof h === 'string'">{{ h }}</template>
|
||||
<template v-else>{{ $t(h.text) }}</template>
|
||||
<div
|
||||
:data-swap="pagination.descending"
|
||||
:class="{ 'opacity-0': sortByProperty !== h.value }"
|
||||
class="transition-transform duration-300 data-[swap=true]:rotate-180"
|
||||
>
|
||||
<MdiArrowUp class="size-5" />
|
||||
</div>
|
||||
</div>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-for="(d, i) in data" :key="d.id" class="relative cursor-pointer">
|
||||
<TableCell
|
||||
v-for="h in headers.filter(h => h.enabled)"
|
||||
:key="`${h.value}-${i}`"
|
||||
:class="{
|
||||
'text-center': h.align === 'center',
|
||||
'text-right': h.align === 'right',
|
||||
'text-left': h.align === 'left',
|
||||
}"
|
||||
>
|
||||
<template v-if="h.type === 'name'">
|
||||
{{ d.name }}
|
||||
</template>
|
||||
<template v-else-if="h.type === 'price'">
|
||||
<Currency :amount="d.purchasePrice" />
|
||||
</template>
|
||||
<template v-else-if="h.type === 'boolean'">
|
||||
<MdiCheck v-if="d.insured" class="inline size-5 text-green-500" />
|
||||
<MdiClose v-else class="inline size-5 text-destructive" />
|
||||
</template>
|
||||
<template v-else-if="h.type === 'location'">
|
||||
<NuxtLink v-if="d.location" class="hover:underline" :to="`/location/${d.location.id}`">
|
||||
{{ d.location.name }}
|
||||
</NuxtLink>
|
||||
</template>
|
||||
<template v-else-if="h.type === 'date'">
|
||||
<DateTime :date="d[h.value]" datetime-type="date" />
|
||||
</template>
|
||||
<slot v-else :name="cell(h)" v-bind="{ item: d }">
|
||||
{{ extractValue(d, h.value) }}
|
||||
</slot>
|
||||
</TableCell>
|
||||
<TableCell class="absolute inset-0">
|
||||
<NuxtLink :to="`/item/${d.id}`" class="absolute inset-0">
|
||||
<span class="sr-only">{{ $t("components.item.view.table.view_item") }}</span>
|
||||
</NuxtLink>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div
|
||||
class="flex items-center justify-between gap-2 border-t p-3"
|
||||
:class="{
|
||||
hidden: disableControls,
|
||||
}"
|
||||
>
|
||||
<Button class="size-10 p-0" variant="outline" @click="openDialog(DialogID.ItemTableSettings)">
|
||||
<MdiTableCog />
|
||||
</Button>
|
||||
<Pagination
|
||||
v-slot="{ page }"
|
||||
:items-per-page="pagination.rowsPerPage"
|
||||
:total="props.items.length"
|
||||
:sibling-count="2"
|
||||
@update:page="pagination.page = $event"
|
||||
>
|
||||
<PaginationList v-slot="{ items: pageItems }" class="flex items-center gap-1">
|
||||
<PaginationFirst />
|
||||
<template v-for="(item, index) in pageItems">
|
||||
<PaginationListItem v-if="item.type === 'page'" :key="index" :value="item.value" as-child>
|
||||
<Button class="size-10 p-0" :variant="item.value === page ? 'default' : 'outline'">
|
||||
{{ item.value }}
|
||||
</Button>
|
||||
</PaginationListItem>
|
||||
<PaginationEllipsis v-else :key="item.type" :index="index" />
|
||||
</template>
|
||||
<PaginationLast />
|
||||
</PaginationList>
|
||||
</Pagination>
|
||||
<Button class="invisible hidden size-10 p-0 md:block">
|
||||
<!-- properly centre the pagination buttons -->
|
||||
</Button>
|
||||
</div>
|
||||
</BaseCard>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TableData, TableHeaderType } from "./Table.types";
|
||||
import type { ItemSummary } from "~~/lib/api/types/data-contracts";
|
||||
import MdiArrowDown from "~icons/mdi/arrow-down";
|
||||
import MdiArrowUp from "~icons/mdi/arrow-up";
|
||||
import MdiCheck from "~icons/mdi/check";
|
||||
import MdiClose from "~icons/mdi/close";
|
||||
import MdiTableCog from "~icons/mdi/table-cog";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import {
|
||||
Pagination,
|
||||
PaginationEllipsis,
|
||||
PaginationFirst,
|
||||
PaginationLast,
|
||||
PaginationList,
|
||||
PaginationListItem,
|
||||
} from "@/components/ui/pagination";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { useDialog } from "@/components/ui/dialog-provider";
|
||||
import { DialogID } from "~/components/ui/dialog-provider/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import BaseCard from "@/components/Base/Card.vue";
|
||||
import Currency from "~/components/global/Currency.vue";
|
||||
import DateTime from "~/components/global/DateTime.vue";
|
||||
import { computed } from "vue";
|
||||
import type { ItemSummary } from "~/lib/api/types/data-contracts";
|
||||
import DataTable from "./table/data-table.vue";
|
||||
import { makeColumns } from "./table/columns";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const { openDialog, closeDialog } = useDialog();
|
||||
|
||||
type Props = {
|
||||
defineProps<{
|
||||
items: ItemSummary[];
|
||||
disableControls?: boolean;
|
||||
};
|
||||
const props = defineProps<Props>();
|
||||
}>();
|
||||
|
||||
const sortByProperty = ref<keyof ItemSummary | "">("");
|
||||
const { t } = useI18n();
|
||||
|
||||
const preferences = useViewPreferences();
|
||||
|
||||
const defaultHeaders = [
|
||||
{ text: "items.asset_id", value: "assetId", enabled: false },
|
||||
{
|
||||
text: "items.name",
|
||||
value: "name",
|
||||
enabled: true,
|
||||
type: "name",
|
||||
},
|
||||
{ text: "items.quantity", value: "quantity", align: "center", enabled: true },
|
||||
{ text: "items.insured", value: "insured", align: "center", enabled: true, type: "boolean" },
|
||||
{ text: "items.purchase_price", value: "purchasePrice", align: "center", enabled: true, type: "price" },
|
||||
{ text: "items.location", value: "location", align: "center", enabled: false, type: "location" },
|
||||
{ text: "items.archived", value: "archived", align: "center", enabled: false, type: "boolean" },
|
||||
{ text: "items.created_at", value: "createdAt", align: "center", enabled: false, type: "date" },
|
||||
{ text: "items.updated_at", value: "updatedAt", align: "center", enabled: false, type: "date" },
|
||||
] satisfies TableHeaderType[];
|
||||
|
||||
const headers = ref<TableHeaderType[]>(
|
||||
(preferences.value.tableHeaders ?? [])
|
||||
.concat(defaultHeaders.filter(h => !preferences.value.tableHeaders?.find(h2 => h2.value === h.value)))
|
||||
// this is a hack to make sure that any changes to the defaultHeaders are reflected in the preferences
|
||||
.map(h => ({
|
||||
...(defaultHeaders.find(h2 => h2.value === h.value) as TableHeaderType),
|
||||
enabled: h.enabled,
|
||||
}))
|
||||
);
|
||||
|
||||
const toggleHeader = (value: string) => {
|
||||
const header = headers.value.find(h => h.value === value);
|
||||
if (header) {
|
||||
header.enabled = !header.enabled; // Toggle the 'enabled' state
|
||||
}
|
||||
|
||||
preferences.value.tableHeaders = headers.value;
|
||||
};
|
||||
const moveHeader = (from: number, to: number) => {
|
||||
const header = headers.value[from];
|
||||
if (!header) {
|
||||
return;
|
||||
}
|
||||
headers.value.splice(from, 1);
|
||||
headers.value.splice(to, 0, header);
|
||||
|
||||
preferences.value.tableHeaders = headers.value;
|
||||
};
|
||||
|
||||
const pagination = reactive({
|
||||
descending: false,
|
||||
page: 1,
|
||||
rowsPerPage: preferences.value.itemsPerTablePage,
|
||||
rowsNumber: 0,
|
||||
});
|
||||
|
||||
watch(
|
||||
() => pagination.rowsPerPage,
|
||||
newRowsPerPage => {
|
||||
preferences.value.itemsPerTablePage = newRowsPerPage;
|
||||
}
|
||||
);
|
||||
|
||||
function sortBy(property: keyof ItemSummary) {
|
||||
if (sortByProperty.value === property) {
|
||||
pagination.descending = !pagination.descending;
|
||||
} else {
|
||||
pagination.descending = false;
|
||||
}
|
||||
sortByProperty.value = property;
|
||||
}
|
||||
|
||||
function extractSortable(item: ItemSummary, property: keyof ItemSummary): string | number | boolean {
|
||||
const value = item[property];
|
||||
if (typeof value === "string") {
|
||||
// Try to parse number
|
||||
const parsed = Number(value);
|
||||
if (!isNaN(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return value.toLowerCase();
|
||||
}
|
||||
|
||||
if (typeof value !== "number" && typeof value !== "boolean") {
|
||||
return "";
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function itemSort(a: ItemSummary, b: ItemSummary) {
|
||||
if (!sortByProperty.value) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const aVal = extractSortable(a, sortByProperty.value);
|
||||
const bVal = extractSortable(b, sortByProperty.value);
|
||||
|
||||
if (typeof aVal === "string" && typeof bVal === "string") {
|
||||
return aVal.localeCompare(bVal, undefined, { numeric: true, sensitivity: "base" });
|
||||
}
|
||||
|
||||
if (aVal < bVal) {
|
||||
return -1;
|
||||
}
|
||||
if (aVal > bVal) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
const data = computed<TableData[]>(() => {
|
||||
// sort by property
|
||||
let data = [...props.items].sort(itemSort);
|
||||
|
||||
// sort descending
|
||||
if (pagination.descending) {
|
||||
data.reverse();
|
||||
}
|
||||
|
||||
// paginate
|
||||
const start = (pagination.page - 1) * pagination.rowsPerPage;
|
||||
const end = start + pagination.rowsPerPage;
|
||||
data = data.slice(start, end);
|
||||
return data;
|
||||
});
|
||||
|
||||
function extractValue(data: TableData, value: string) {
|
||||
const parts = value.split(".");
|
||||
let current = data;
|
||||
for (const part of parts) {
|
||||
current = current[part];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function cell(h: TableHeaderType) {
|
||||
return `cell-${h.value.replace(".", "_")}`;
|
||||
}
|
||||
const columns = computed(() => makeColumns(t).filter(c => c.enableHiding !== false));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DataTable view="table" :data="items" :columns="columns" disable-controls />
|
||||
</template>
|
||||
|
||||
8
frontend/components/Item/View/pagination.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { ShallowUnwrapRef } from "vue";
|
||||
|
||||
export type Pagination = ShallowUnwrapRef<{
|
||||
page: WritableComputedRef<number, number>;
|
||||
pageSize: ComputedRef<number>;
|
||||
totalSize: Ref<number, number>;
|
||||
setPage: (newPage: number) => void;
|
||||
}>;
|
||||
66
frontend/components/Item/View/table/card-view.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import ItemCard from "@/components/Item/Card.vue";
|
||||
import type { ItemSummary } from "~/lib/api/types/data-contracts";
|
||||
import type { Table as TableType } from "@tanstack/vue-table";
|
||||
import MdiSelectSearch from "~icons/mdi/select-search";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import DropdownAction from "./data-table-dropdown.vue";
|
||||
|
||||
const preferences = useViewPreferences();
|
||||
|
||||
const props = defineProps<{
|
||||
table: TableType<ItemSummary>;
|
||||
locationFlatTree?: FlatTreeItem[];
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
(e: "refresh"): void;
|
||||
}>();
|
||||
|
||||
const selectedCount = computed(() => props.table.getSelectedRowModel().rows.length);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="#selectable-subtitle" defer>
|
||||
<Checkbox
|
||||
class="size-6 p-0"
|
||||
:model-value="
|
||||
table.getIsAllPageRowsSelected() ? true : table.getSelectedRowModel().rows.length > 0 ? 'indeterminate' : false
|
||||
"
|
||||
:aria-label="$t('components.item.view.selectable.select_all')"
|
||||
@update:model-value="table.toggleAllPageRowsSelected(!!$event)"
|
||||
/>
|
||||
|
||||
<div class="grow" />
|
||||
|
||||
<div :class="['relative inline-flex items-center', selectedCount === 0 ? 'pointer-events-none opacity-50' : '']">
|
||||
<DropdownAction
|
||||
:multi="{ items: table.getSelectedRowModel().rows, columns: table.getAllColumns() }"
|
||||
view="card"
|
||||
:table="table"
|
||||
@refresh="$emit('refresh')"
|
||||
/>
|
||||
|
||||
<span v-if="selectedCount > 0" class="absolute -right-1 -top-1 flex size-4">
|
||||
<span
|
||||
class="pointer-events-none relative flex size-4 items-center justify-center whitespace-nowrap rounded-full bg-primary p-1 text-xs text-primary-foreground"
|
||||
>
|
||||
{{ String(selectedCount) }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</Teleport>
|
||||
<div v-if="table.getRowModel().rows?.length === 0" class="flex flex-col items-center gap-2">
|
||||
<MdiSelectSearch class="size-10" />
|
||||
<p>{{ $t("items.no_results") }}</p>
|
||||
</div>
|
||||
<div v-else class="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
<ItemCard
|
||||
v-for="item in table.getRowModel().rows"
|
||||
:key="item.original.id"
|
||||
:item="item.original"
|
||||
:table-row="preferences.quickActions.enabled ? item : undefined"
|
||||
:location-flat-tree="locationFlatTree"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
270
frontend/components/Item/View/table/columns.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import type { Column, ColumnDef } from "@tanstack/vue-table";
|
||||
import { h } from "vue";
|
||||
import DropdownAction from "./data-table-dropdown.vue";
|
||||
import { ArrowDown, ArrowUpDown, Check, X } from "lucide-vue-next";
|
||||
import Button from "~/components/ui/button/Button.vue";
|
||||
import Checkbox from "~/components/Form/Checkbox.vue";
|
||||
import type { ItemSummary } from "~/lib/api/types/data-contracts";
|
||||
import Currency from "~/components/global/Currency.vue";
|
||||
import DateTime from "~/components/global/DateTime.vue";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
/**
|
||||
* Create columns with i18n support.
|
||||
* Pass `t` from useI18n() when creating the columns in your component.
|
||||
*/
|
||||
export function makeColumns(t: (key: string) => string, refresh?: () => void): ColumnDef<ItemSummary>[] {
|
||||
const sortable = (column: Column<ItemSummary, unknown>, key: string) => {
|
||||
const sortState = column.getIsSorted(); // 'asc' | 'desc' | false
|
||||
if (!sortState) {
|
||||
// show the neutral up/down icon when not sorted
|
||||
return [t(key), h(ArrowUpDown, { class: "ml-2 h-4 w-4 opacity-40" })];
|
||||
}
|
||||
// show a single arrow that points up for asc (rotate-180) and down for desc
|
||||
return [
|
||||
t(key),
|
||||
h(ArrowDown, {
|
||||
class: cn(["ml-2 h-4 w-4 transition-transform opacity-100", sortState === "asc" ? "rotate-180" : ""]),
|
||||
}),
|
||||
];
|
||||
};
|
||||
|
||||
return [
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) =>
|
||||
h(Checkbox, {
|
||||
modelValue: table.getIsAllPageRowsSelected()
|
||||
? true
|
||||
: table.getSelectedRowModel().rows.length > 0
|
||||
? ("indeterminate" as unknown as boolean) // :)
|
||||
: false,
|
||||
"onUpdate:modelValue": (value: boolean) => table.toggleAllPageRowsSelected(!!value),
|
||||
ariaLabel: t("components.item.view.selectable.select_all"),
|
||||
}),
|
||||
cell: ({ row }) =>
|
||||
h(Checkbox, {
|
||||
modelValue: row.getIsSelected(),
|
||||
"onUpdate:modelValue": (value: boolean) => row.toggleSelected(!!value),
|
||||
ariaLabel: t("components.item.view.selectable.select_row"),
|
||||
}),
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
id: "assetId",
|
||||
accessorKey: "assetId",
|
||||
header: ({ column }) =>
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
variant: "ghost",
|
||||
onClick: () => column.toggleSorting(column.getIsSorted() === "asc"),
|
||||
},
|
||||
() => sortable(column, "items.asset_id")
|
||||
),
|
||||
cell: ({ row }) => h("div", { class: "text-sm" }, String(row.getValue("assetId") ?? "")),
|
||||
},
|
||||
{
|
||||
id: "name",
|
||||
accessorKey: "name",
|
||||
header: ({ column }) =>
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
variant: "ghost",
|
||||
onClick: () => column.toggleSorting(column.getIsSorted() === "asc"),
|
||||
},
|
||||
() => sortable(column, "items.name")
|
||||
),
|
||||
cell: ({ row }) => h("span", { class: "text-sm font-medium" }, row.getValue("name")),
|
||||
},
|
||||
{
|
||||
id: "quantity",
|
||||
accessorKey: "quantity",
|
||||
header: ({ column }) =>
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
variant: "ghost",
|
||||
onClick: () => column.toggleSorting(column.getIsSorted() === "asc"),
|
||||
},
|
||||
() => sortable(column, "items.quantity")
|
||||
),
|
||||
cell: ({ row }) => h("div", { class: "text-center" }, String(row.getValue("quantity") ?? "")),
|
||||
},
|
||||
{
|
||||
id: "insured",
|
||||
accessorKey: "insured",
|
||||
header: ({ column }) =>
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
variant: "ghost",
|
||||
onClick: () => column.toggleSorting(column.getIsSorted() === "asc"),
|
||||
},
|
||||
() => sortable(column, "items.insured")
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const val = row.getValue("insured");
|
||||
return h(
|
||||
"div",
|
||||
{ class: "block mx-auto w-min" },
|
||||
val ? h(Check, { class: "h-4 w-4 text-green-500" }) : h(X, { class: "h-4 w-4 text-destructive" })
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "purchasePrice",
|
||||
accessorKey: "purchasePrice",
|
||||
header: ({ column }) =>
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
variant: "ghost",
|
||||
onClick: () => column.toggleSorting(column.getIsSorted() === "asc"),
|
||||
},
|
||||
() => sortable(column, "items.purchase_price")
|
||||
),
|
||||
cell: ({ row }) =>
|
||||
h("div", { class: "text-center" }, h(Currency, { amount: Number(row.getValue("purchasePrice")) })),
|
||||
},
|
||||
{
|
||||
id: "location",
|
||||
accessorKey: "location",
|
||||
header: ({ column }) =>
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
variant: "ghost",
|
||||
onClick: () => column.toggleSorting(column.getIsSorted() === "asc"),
|
||||
},
|
||||
() => sortable(column, "items.location")
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const loc = (row.original as ItemSummary).location as { id: string; name: string } | null;
|
||||
if (loc) {
|
||||
return h("NuxtLink", { to: `/location/${loc.id}`, class: "hover:underline text-sm" }, () => loc.name);
|
||||
}
|
||||
return h("div", { class: "text-sm text-muted-foreground" }, "");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "archived",
|
||||
accessorKey: "archived",
|
||||
header: ({ column }) =>
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
variant: "ghost",
|
||||
onClick: () => column.toggleSorting(column.getIsSorted() === "asc"),
|
||||
},
|
||||
() => sortable(column, "items.archived")
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const val = row.getValue("archived");
|
||||
return h(
|
||||
"div",
|
||||
{ class: "block mx-auto w-min" },
|
||||
val ? h(Check, { class: "h-4 w-4 text-green-500" }) : h(X, { class: "h-4 w-4 text-destructive" })
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "createdAt",
|
||||
accessorKey: "createdAt",
|
||||
header: ({ column }) =>
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
variant: "ghost",
|
||||
onClick: () => column.toggleSorting(column.getIsSorted() === "asc"),
|
||||
},
|
||||
() => sortable(column, "items.created_at")
|
||||
),
|
||||
cell: ({ row }) =>
|
||||
h(
|
||||
"div",
|
||||
{ class: "text-center text-sm" },
|
||||
h(DateTime, { date: row.getValue("createdAt") as Date, datetimeType: "date" })
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "updatedAt",
|
||||
accessorKey: "updatedAt",
|
||||
header: ({ column }) =>
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
variant: "ghost",
|
||||
onClick: () => column.toggleSorting(column.getIsSorted() === "asc"),
|
||||
},
|
||||
() => sortable(column, "items.updated_at")
|
||||
),
|
||||
cell: ({ row }) =>
|
||||
h(
|
||||
"div",
|
||||
{ class: "text-center text-sm" },
|
||||
h(DateTime, { date: row.getValue("updatedAt") as Date, datetimeType: "date" })
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
enableHiding: false,
|
||||
header: ({ table }) => {
|
||||
const selectedCount = table.getSelectedRowModel().rows.length;
|
||||
return h(
|
||||
"div",
|
||||
{
|
||||
class: [
|
||||
"relative inline-flex items-center",
|
||||
selectedCount === 0 ? "opacity-50 pointer-events-none" : "",
|
||||
].join(" "),
|
||||
},
|
||||
[
|
||||
h(DropdownAction, {
|
||||
multi: {
|
||||
items: table.getSelectedRowModel().rows,
|
||||
columns: table.getAllColumns(),
|
||||
},
|
||||
onExpand: () => {
|
||||
table.getSelectedRowModel().rows.forEach(row => row.toggleExpanded());
|
||||
},
|
||||
view: "table",
|
||||
onRefresh: () => refresh?.(),
|
||||
table,
|
||||
}),
|
||||
selectedCount > 0 &&
|
||||
h(
|
||||
"span",
|
||||
{
|
||||
class: "-right-1 -top-1 absolute flex size-4",
|
||||
},
|
||||
h(
|
||||
"span",
|
||||
{
|
||||
class:
|
||||
"relative flex size-4 items-center justify-center rounded-full bg-primary p-1 text-primary-foreground text-xs pointer-events-none whitespace-nowrap",
|
||||
},
|
||||
String(selectedCount)
|
||||
)
|
||||
),
|
||||
]
|
||||
);
|
||||
},
|
||||
cell: ({ row, table }) => {
|
||||
const item = row.original;
|
||||
return h(
|
||||
"div",
|
||||
{ class: "relative" },
|
||||
h(DropdownAction, {
|
||||
item,
|
||||
onExpand: row.toggleExpanded,
|
||||
view: "table",
|
||||
onRefresh: () => refresh?.(),
|
||||
table,
|
||||
})
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
93
frontend/components/Item/View/table/data-table-controls.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<script setup lang="ts">
|
||||
import type { Table as TableType } from "@tanstack/vue-table";
|
||||
import type { ItemSummary } from "~/lib/api/types/data-contracts";
|
||||
|
||||
import MdiTableCog from "~icons/mdi/table-cog";
|
||||
|
||||
import Button from "~/components/ui/button/Button.vue";
|
||||
import {
|
||||
Pagination,
|
||||
PaginationEllipsis,
|
||||
PaginationFirst,
|
||||
PaginationLast,
|
||||
PaginationList,
|
||||
PaginationListItem,
|
||||
} from "@/components/ui/pagination";
|
||||
import { DialogID, useDialog } from "~/components/ui/dialog-provider/utils";
|
||||
import type { Pagination as PaginationType } from "../pagination";
|
||||
|
||||
const { openDialog } = useDialog();
|
||||
|
||||
const props = defineProps<{
|
||||
table: TableType<ItemSummary>;
|
||||
dataLength: number;
|
||||
externalPagination?: PaginationType;
|
||||
}>();
|
||||
|
||||
const setPage = (page: number) => {
|
||||
if (props.externalPagination) {
|
||||
if (page !== props.externalPagination.page) {
|
||||
// clear selection and expanded
|
||||
props.table.resetRowSelection();
|
||||
props.table.resetExpanded();
|
||||
}
|
||||
props.externalPagination.setPage(page);
|
||||
} else {
|
||||
props.table.setPageIndex(page - 1);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between md:gap-0">
|
||||
<div class="order-2 flex items-center gap-2 md:order-1">
|
||||
<Button class="size-10 p-0" variant="outline" @click="openDialog(DialogID.ItemTableSettings)">
|
||||
<MdiTableCog />
|
||||
</Button>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
{{
|
||||
$t("components.item.view.table.selected_rows", {
|
||||
selected: table.getFilteredSelectedRowModel().rows.length,
|
||||
total: table.getFilteredRowModel().rows.length,
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="order-1 flex w-full justify-center md:order-2 md:w-auto">
|
||||
<Pagination
|
||||
v-slot="{ page }"
|
||||
:items-per-page="externalPagination ? externalPagination.pageSize : table.getState().pagination.pageSize"
|
||||
:total="externalPagination ? externalPagination.totalSize : dataLength"
|
||||
:sibling-count="2"
|
||||
:page="externalPagination ? externalPagination.page : table.getState().pagination.pageIndex + 1"
|
||||
@update:page="val => setPage(val)"
|
||||
>
|
||||
<PaginationList v-slot="{ items: pageItems }" class="flex items-center gap-1">
|
||||
<PaginationFirst @click="() => setPage(1)" />
|
||||
<template v-for="(item, index) in pageItems">
|
||||
<PaginationListItem v-if="item.type === 'page'" :key="index" :value="item.value" as-child>
|
||||
<Button
|
||||
class="size-10 p-0"
|
||||
:variant="item.value === page ? 'default' : 'outline'"
|
||||
@click="() => setPage(item.value)"
|
||||
>
|
||||
{{ item.value }}
|
||||
</Button>
|
||||
</PaginationListItem>
|
||||
<PaginationEllipsis v-else :key="item.type" :index="index" />
|
||||
</template>
|
||||
<PaginationLast
|
||||
@click="
|
||||
() =>
|
||||
setPage(
|
||||
externalPagination
|
||||
? Math.ceil(externalPagination.totalSize / externalPagination.pageSize)
|
||||
: table.getPageCount()
|
||||
)
|
||||
"
|
||||
/>
|
||||
</PaginationList>
|
||||
</Pagination>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
273
frontend/components/Item/View/table/data-table-dropdown.vue
Normal file
@@ -0,0 +1,273 @@
|
||||
<script setup lang="ts">
|
||||
import { MoreHorizontal } from "lucide-vue-next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import type { ItemSummary } from "~/lib/api/types/data-contracts";
|
||||
import type { Column, Row, Table } from "@tanstack/vue-table";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { toast } from "~/components/ui/sonner";
|
||||
import { useDialog } from "@/components/ui/dialog-provider";
|
||||
import { DialogID } from "~/components/ui/dialog-provider/utils";
|
||||
|
||||
const { t } = useI18n();
|
||||
const api = useUserApi();
|
||||
const confirm = useConfirm();
|
||||
const preferences = useViewPreferences();
|
||||
const { openDialog } = useDialog();
|
||||
|
||||
const props = defineProps<{
|
||||
item?: ItemSummary;
|
||||
multi?: {
|
||||
items: Row<ItemSummary>[];
|
||||
columns: Column<ItemSummary>[];
|
||||
};
|
||||
view: "table" | "card";
|
||||
table: Table<ItemSummary>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "expand"): void;
|
||||
(e: "refresh"): void;
|
||||
}>();
|
||||
|
||||
const resetSelection = () => {
|
||||
props.table.resetRowSelection();
|
||||
props.table.resetExpanded();
|
||||
emit("refresh");
|
||||
};
|
||||
|
||||
const openMultiTab = async (items: string[]) => {
|
||||
if (!preferences.value.shownMultiTabWarning) {
|
||||
// TODO: add warning with link to docs and just improve this
|
||||
const { isCanceled } = await confirm.open({
|
||||
message: t("components.item.view.table.dropdown.open_multi_tab_warning"),
|
||||
href: "https://homebox.software/en/user-guide/tips-tricks#open-multiple-items-in-new-tabs",
|
||||
});
|
||||
if (isCanceled) {
|
||||
return;
|
||||
}
|
||||
preferences.value.shownMultiTabWarning = true;
|
||||
}
|
||||
|
||||
items.forEach(item => window.open(`/item/${item}`, "_blank"));
|
||||
};
|
||||
|
||||
const escapeCsvField = (value: unknown): string => {
|
||||
let str = String(value ?? "");
|
||||
// Mitigate formula injection
|
||||
if (/^[=+\-@]/.test(str)) {
|
||||
str = "'" + str;
|
||||
}
|
||||
// Escape double quotes
|
||||
str = str.replace(/"/g, '""');
|
||||
// Wrap in double quotes
|
||||
return `"${str}"`;
|
||||
};
|
||||
|
||||
const downloadCsv = (items: Row<ItemSummary>[], columns: Column<ItemSummary>[]) => {
|
||||
// get enabled columns
|
||||
const enabledColumns = columns.filter(c => c.id !== undefined && c.getIsVisible() && c.getCanHide()).map(c => c.id);
|
||||
|
||||
// create CSV header (escaped)
|
||||
const header = enabledColumns.map(escapeCsvField).join(",");
|
||||
|
||||
// map each item to a row matching enabled columns order, escaping each field
|
||||
const rows = items.map(item =>
|
||||
enabledColumns.map(col => escapeCsvField(item.original[col as keyof ItemSummary])).join(",")
|
||||
);
|
||||
|
||||
const csv = [header, ...rows].join("\n");
|
||||
const blob = new Blob([csv], { type: "text/csv" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "items.csv";
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const downloadJson = (items: Row<ItemSummary>[], columns: Column<ItemSummary>[]) => {
|
||||
// get enabled columns
|
||||
const enabledColumns = columns.filter(c => c.id !== undefined && c.getIsVisible() && c.getCanHide()).map(c => c.id);
|
||||
|
||||
// map each item to an object with only enabled columns
|
||||
const data = items.map(item => {
|
||||
const obj: Record<string, unknown> = {};
|
||||
enabledColumns.forEach(col => {
|
||||
obj[col] = item.original[col as keyof ItemSummary] ?? null;
|
||||
});
|
||||
return obj;
|
||||
});
|
||||
|
||||
const exportObj = {
|
||||
headers: enabledColumns,
|
||||
data,
|
||||
};
|
||||
|
||||
const json = JSON.stringify(exportObj, null, 2);
|
||||
const blob = new Blob([json], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "items.json";
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const deleteItems = async (ids: string[]) => {
|
||||
const { isCanceled } = await confirm.open(t("components.item.view.table.dropdown.delete_confirmation"));
|
||||
|
||||
if (isCanceled) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.allSettled(
|
||||
ids.map(id =>
|
||||
api.items.delete(id).catch(err => {
|
||||
toast.error(t("components.item.view.table.dropdown.error_deleting"));
|
||||
console.error(err);
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
resetSelection();
|
||||
};
|
||||
|
||||
const duplicateItems = async (ids: string[]) => {
|
||||
await Promise.allSettled(
|
||||
ids.map(id =>
|
||||
api.items
|
||||
.duplicate(id, {
|
||||
copyMaintenance: preferences.value.duplicateSettings.copyMaintenance,
|
||||
copyAttachments: preferences.value.duplicateSettings.copyAttachments,
|
||||
copyCustomFields: preferences.value.duplicateSettings.copyCustomFields,
|
||||
copyPrefix: preferences.value.duplicateSettings.copyPrefixOverride ?? t("items.duplicate.prefix"),
|
||||
})
|
||||
.catch(err => {
|
||||
toast.error(t("components.item.view.table.dropdown.error_duplicating"));
|
||||
console.error(err);
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
resetSelection();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button
|
||||
:variant="view === 'table' ? 'ghost' : 'outline'"
|
||||
class="size-8 p-0 hover:bg-primary hover:text-primary-foreground"
|
||||
>
|
||||
<span class="sr-only">{{ t("components.item.view.table.dropdown.open_menu") }}</span>
|
||||
<MoreHorizontal class="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>{{ t("components.item.view.table.dropdown.actions") }}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem v-if="item" as-child>
|
||||
<NuxtLink :to="`/item/${item.id}`" class="hover:underline">
|
||||
{{ t("components.item.view.table.dropdown.view_item") }}
|
||||
</NuxtLink>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem v-if="multi" @click="openMultiTab(multi.items.map(row => row.original.id))">
|
||||
{{ t("components.item.view.table.dropdown.view_items") }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem v-if="view === 'table'" @click="$emit('expand')">
|
||||
{{ t("components.item.view.table.dropdown.toggle_expand") }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<!-- change location -->
|
||||
<DropdownMenuItem
|
||||
@click="
|
||||
openDialog(DialogID.ItemChangeDetails, {
|
||||
params: { items: multi ? multi.items.map(row => row.original) : [item!], changeLocation: true },
|
||||
onClose: result => {
|
||||
if (result) {
|
||||
toast.success(t('components.item.view.table.dropdown.change_location_success'));
|
||||
resetSelection();
|
||||
}
|
||||
},
|
||||
})
|
||||
"
|
||||
>
|
||||
{{ t("components.item.view.table.dropdown.change_location") }}
|
||||
</DropdownMenuItem>
|
||||
<!-- change labels -->
|
||||
<DropdownMenuItem
|
||||
@click="
|
||||
openDialog(DialogID.ItemChangeDetails, {
|
||||
params: {
|
||||
items: multi ? multi.items.map(row => row.original) : [item!],
|
||||
addLabels: true,
|
||||
removeLabels: true,
|
||||
},
|
||||
onClose: result => {
|
||||
if (result) {
|
||||
toast.success(t('components.item.view.table.dropdown.change_labels_success'));
|
||||
resetSelection();
|
||||
}
|
||||
},
|
||||
})
|
||||
"
|
||||
>
|
||||
{{ t("components.item.view.table.dropdown.change_labels") }}
|
||||
</DropdownMenuItem>
|
||||
<!-- maintenance -->
|
||||
<DropdownMenuItem
|
||||
@click="
|
||||
openDialog(DialogID.EditMaintenance, {
|
||||
params: { type: 'create', itemId: multi ? multi.items.map(row => row.original.id) : item!.id },
|
||||
onClose: result => {
|
||||
if (result) {
|
||||
toast.success(t('components.item.view.table.dropdown.create_maintenance_success'));
|
||||
}
|
||||
},
|
||||
})
|
||||
"
|
||||
>
|
||||
{{
|
||||
multi
|
||||
? t("components.item.view.table.dropdown.create_maintenance_selected")
|
||||
: t("components.item.view.table.dropdown.create_maintenance_item")
|
||||
}}
|
||||
</DropdownMenuItem>
|
||||
<!-- duplicate -->
|
||||
<DropdownMenuItem @click="duplicateItems(multi ? multi.items.map(row => row.original.id) : [item!.id])">
|
||||
{{
|
||||
multi
|
||||
? t("components.item.view.table.dropdown.duplicate_selected")
|
||||
: t("components.item.view.table.dropdown.duplicate_item")
|
||||
}}
|
||||
</DropdownMenuItem>
|
||||
<!-- delete -->
|
||||
<DropdownMenuItem @click="deleteItems(multi ? multi.items.map(row => row.original.id) : [item!.id])">
|
||||
{{
|
||||
multi
|
||||
? t("components.item.view.table.dropdown.delete_selected")
|
||||
: t("components.item.view.table.dropdown.delete_item")
|
||||
}}
|
||||
</DropdownMenuItem>
|
||||
<!-- download -->
|
||||
<DropdownMenuSeparator v-if="multi && view === 'table'" />
|
||||
<DropdownMenuItem v-if="multi && view === 'table'" @click="downloadCsv(multi.items, multi.columns)">
|
||||
{{ t("components.item.view.table.dropdown.download_csv") }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem v-if="multi && view === 'table'" @click="downloadJson(multi.items, multi.columns)">
|
||||
{{ t("components.item.view.table.dropdown.download_json") }}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import type { ItemSummary } from "~/lib/api/types/data-contracts";
|
||||
import LabelChip from "@/components/Label/Chip.vue";
|
||||
import Badge from "~/components/ui/badge/Badge.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
item: ItemSummary;
|
||||
}>();
|
||||
|
||||
const api = useUserApi();
|
||||
|
||||
const imageUrl = computed(() => {
|
||||
if (!props.item.imageId) {
|
||||
return "/no-image.jpg";
|
||||
}
|
||||
if (props.item.thumbnailId) {
|
||||
return api.authURL(`/items/${props.item.id}/attachments/${props.item.thumbnailId}`);
|
||||
} else {
|
||||
return api.authURL(`/items/${props.item.id}/attachments/${props.item.imageId}`);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="shrink-0">
|
||||
<img :src="imageUrl" class="size-32 rounded-lg bg-muted object-cover" />
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-2">
|
||||
<h2 class="truncate text-xl font-bold">{{ item.name }}</h2>
|
||||
<Badge class="w-min text-nowrap bg-secondary text-secondary-foreground hover:bg-secondary/70 hover:underline">
|
||||
<NuxtLink v-if="item.location" :to="`/location/${item.location.id}`">
|
||||
{{ item.location.name }}
|
||||
</NuxtLink>
|
||||
</Badge>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<LabelChip v-for="label in item.labels" :key="label.id" :label="label" size="sm" />
|
||||
</div>
|
||||
<p class="whitespace-pre-line break-words text-sm text-muted-foreground">
|
||||
{{ item.description || $t("components.item.no_description") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
252
frontend/components/Item/View/table/data-table.vue
Normal file
@@ -0,0 +1,252 @@
|
||||
<script setup lang="ts" generic="TData, TValue">
|
||||
import BaseCard from "@/components/Base/Card.vue";
|
||||
import type { ColumnDef, SortingState, VisibilityState, ExpandedState } from "@tanstack/vue-table";
|
||||
import {
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
getExpandedRowModel,
|
||||
useVueTable,
|
||||
} from "@tanstack/vue-table";
|
||||
|
||||
import { camelToSnakeCase, valueUpdater } from "@/lib/utils";
|
||||
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import Button from "~/components/ui/button/Button.vue";
|
||||
import { DialogID } from "~/components/ui/dialog-provider/utils";
|
||||
import MdiArrowDown from "~icons/mdi/arrow-down";
|
||||
import MdiArrowUp from "~icons/mdi/arrow-up";
|
||||
import Checkbox from "~/components/ui/checkbox/Checkbox.vue";
|
||||
import Label from "~/components/ui/label/Label.vue";
|
||||
import type { ItemSummary } from "~/lib/api/types/data-contracts";
|
||||
|
||||
import TableView from "./table-view.vue";
|
||||
import CardView from "./card-view.vue";
|
||||
import DataTableControls from "./data-table-controls.vue";
|
||||
import type { Pagination } from "../pagination";
|
||||
import Switch from "~/components/ui/switch/Switch.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
columns: ColumnDef<ItemSummary, TValue>[];
|
||||
data: ItemSummary[];
|
||||
disableControls?: boolean;
|
||||
view: "table" | "card";
|
||||
locationFlatTree?: FlatTreeItem[];
|
||||
externalPagination?: Pagination;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
(e: "refresh"): void;
|
||||
}>();
|
||||
|
||||
const preferences = useViewPreferences();
|
||||
const defaultPageSize = preferences.value.itemsPerTablePage;
|
||||
const tableHeadersData = preferences.value.tableHeaders;
|
||||
const defaultVisible = ["name", "quantity", "insured", "purchasePrice"];
|
||||
|
||||
const tableHeaders = computed(
|
||||
() =>
|
||||
tableHeadersData ??
|
||||
props.columns
|
||||
.filter(c => c.enableHiding !== false)
|
||||
.map(c => ({
|
||||
value: c.id!,
|
||||
enabled: defaultVisible.includes(c.id ?? ""),
|
||||
}))
|
||||
);
|
||||
|
||||
const sorting = ref<SortingState>([]);
|
||||
const columnOrder = ref<string[]>([
|
||||
"select",
|
||||
...(tableHeaders.value ? tableHeaders.value.map(h => h.value) : []),
|
||||
"actions",
|
||||
]);
|
||||
const columnVisibility = ref<VisibilityState>(
|
||||
tableHeaders.value?.reduce((acc, h) => ({ ...acc, [h.value]: h.enabled }), {})
|
||||
);
|
||||
const rowSelection = ref({});
|
||||
const expanded = ref<ExpandedState>({});
|
||||
const pagination = ref({
|
||||
pageIndex: 0,
|
||||
pageSize: defaultPageSize || 12,
|
||||
});
|
||||
|
||||
watch(
|
||||
() => pagination.value.pageSize,
|
||||
newSize => {
|
||||
preferences.value.itemsPerTablePage = newSize;
|
||||
}
|
||||
);
|
||||
|
||||
const table = useVueTable<ItemSummary>({
|
||||
manualPagination: !!props.externalPagination,
|
||||
|
||||
get data() {
|
||||
return props.data;
|
||||
},
|
||||
get columns() {
|
||||
return props.columns;
|
||||
},
|
||||
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getExpandedRowModel: getExpandedRowModel(),
|
||||
|
||||
onSortingChange: updaterOrValue => valueUpdater(updaterOrValue, sorting),
|
||||
onColumnVisibilityChange: updaterOrValue => valueUpdater(updaterOrValue, columnVisibility),
|
||||
onRowSelectionChange: updaterOrValue => valueUpdater(updaterOrValue, rowSelection),
|
||||
onExpandedChange: updaterOrValue => valueUpdater(updaterOrValue, expanded),
|
||||
onColumnOrderChange: updaterOrValue => valueUpdater(updaterOrValue, columnOrder),
|
||||
onPaginationChange: updaterOrValue => valueUpdater(updaterOrValue, pagination),
|
||||
|
||||
state: {
|
||||
get sorting() {
|
||||
return sorting.value;
|
||||
},
|
||||
get columnVisibility() {
|
||||
return columnVisibility.value;
|
||||
},
|
||||
get rowSelection() {
|
||||
return rowSelection.value;
|
||||
},
|
||||
get expanded() {
|
||||
return expanded.value;
|
||||
},
|
||||
get columnOrder() {
|
||||
return columnOrder.value;
|
||||
},
|
||||
get pagination() {
|
||||
return pagination.value;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const persistHeaders = () => {
|
||||
const headers = table
|
||||
.getAllColumns()
|
||||
.filter(column => column.getCanHide())
|
||||
.map(h => ({
|
||||
value: h.id as keyof ItemSummary,
|
||||
enabled: h.getIsVisible(),
|
||||
}));
|
||||
|
||||
preferences.value.tableHeaders = headers;
|
||||
};
|
||||
|
||||
const moveHeader = (from: number, to: number) => {
|
||||
// Only allow moving between the first and last index (excluding 'select' and 'actions')
|
||||
const start = 1; // index of 'select'
|
||||
const end = columnOrder.value.length - 2; // index before 'actions'
|
||||
|
||||
if (from < start || from > end || to < start || to > end || from === to) return;
|
||||
|
||||
const order = [...columnOrder.value];
|
||||
const [moved] = order.splice(from, 1);
|
||||
order.splice(to, 0, moved!);
|
||||
columnOrder.value = order;
|
||||
|
||||
persistHeaders();
|
||||
};
|
||||
|
||||
const toggleHeader = (id: string) => {
|
||||
const header = table
|
||||
.getAllColumns()
|
||||
.filter(column => column.getCanHide())
|
||||
.find(h => h.id === id);
|
||||
if (header) {
|
||||
header.toggleVisibility();
|
||||
}
|
||||
|
||||
persistHeaders();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Dialog :dialog-id="DialogID.ItemTableSettings">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ $t("components.item.view.table.table_settings") }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div v-if="props.view === 'table'" class="flex flex-col gap-2">
|
||||
<div>{{ $t("components.item.view.table.headers") }}</div>
|
||||
<div class="flex flex-col">
|
||||
<div
|
||||
v-for="(colId, i) in columnOrder.slice(1, columnOrder.length - 1)"
|
||||
:key="colId"
|
||||
class="flex flex-row items-center gap-1"
|
||||
>
|
||||
<Button size="icon" class="size-6" variant="ghost" :disabled="i === 0" @click="moveHeader(i + 1, i)">
|
||||
<MdiArrowUp />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
class="size-6"
|
||||
variant="ghost"
|
||||
:disabled="i === columnOrder.length - 3"
|
||||
@click="moveHeader(i + 1, i + 2)"
|
||||
>
|
||||
<MdiArrowDown />
|
||||
</Button>
|
||||
<Checkbox
|
||||
:id="colId"
|
||||
:model-value="table.getColumn(colId)?.getIsVisible()"
|
||||
@update:model-value="toggleHeader(colId)"
|
||||
/>
|
||||
<label class="text-sm" :for="colId"> {{ $t(`items.${camelToSnakeCase(colId)}`) }} </label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<Label> {{ $t("components.item.view.table.rows_per_page") }} </Label>
|
||||
<Select :model-value="pagination.pageSize" @update:model-value="val => table.setPageSize(Number(val))">
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="12">12</SelectItem>
|
||||
<SelectItem :value="24">24</SelectItem>
|
||||
<SelectItem :value="48">48</SelectItem>
|
||||
<SelectItem :value="96">96</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<Label class="text-sm"> {{ $t("components.item.view.table.quick_actions") }} </Label>
|
||||
<Switch v-model="preferences.quickActions.enabled" />
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<BaseCard v-if="props.view === 'table'">
|
||||
<div>
|
||||
<TableView :table="table" :columns="columns" />
|
||||
</div>
|
||||
<div v-if="!props.disableControls" class="border-t p-3">
|
||||
<DataTableControls
|
||||
:table="table"
|
||||
:pagination="pagination"
|
||||
:data-length="data.length"
|
||||
:external-pagination="externalPagination"
|
||||
/>
|
||||
</div>
|
||||
</BaseCard>
|
||||
<div v-else>
|
||||
<CardView :table="table" :location-flat-tree="locationFlatTree" @refresh="$emit('refresh')" />
|
||||
<div v-if="!props.disableControls" class="pt-2">
|
||||
<DataTableControls
|
||||
:table="table"
|
||||
:pagination="pagination"
|
||||
:data-length="data.length"
|
||||
:external-pagination="externalPagination"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
73
frontend/components/Item/View/table/table-view.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts" generic="TValue">
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import DataTableExpandedRow from "./data-table-expanded-row.vue";
|
||||
import { FlexRender, type Column, type ColumnDef, type Table as TableType } from "@tanstack/vue-table";
|
||||
import type { ItemSummary } from "~/lib/api/types/data-contracts";
|
||||
|
||||
defineProps<{
|
||||
table: TableType<ItemSummary>;
|
||||
columns: ColumnDef<ItemSummary, TValue>[];
|
||||
}>();
|
||||
|
||||
const ariaSort = (column: Column<ItemSummary, unknown>) => {
|
||||
const s = column.getIsSorted();
|
||||
if (s === "asc") return "ascending";
|
||||
if (s === "desc") return "descending";
|
||||
return "none";
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Table class="w-full">
|
||||
<TableHeader>
|
||||
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
|
||||
<TableHead
|
||||
v-for="header in headerGroup.headers"
|
||||
:key="header.id"
|
||||
:class="[
|
||||
'text-no-transform cursor-pointer bg-secondary text-sm text-secondary-foreground hover:bg-secondary/90',
|
||||
header.column.id === 'select' || header.column.id === 'actions' ? 'w-10 px-3 text-center' : '',
|
||||
]"
|
||||
:aria-sort="ariaSort(header.column)"
|
||||
>
|
||||
<FlexRender
|
||||
v-if="!header.isPlaceholder"
|
||||
:render="header.column.columnDef.header"
|
||||
:props="header.getContext()"
|
||||
/>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<template v-if="table.getRowModel().rows?.length">
|
||||
<template v-for="row in table.getRowModel().rows" :key="row.id">
|
||||
<TableRow :data-state="row.getIsSelected() ? 'selected' : undefined">
|
||||
<TableCell
|
||||
v-for="cell in row.getVisibleCells()"
|
||||
:key="cell.id"
|
||||
:href="
|
||||
cell.column.id !== 'select' && cell.column.id !== 'actions' ? `/item/${row.original.id}` : undefined
|
||||
"
|
||||
:class="cell.column.id === 'select' || cell.column.id === 'actions' ? 'w-10 px-3' : ''"
|
||||
:compact="cell.column.id === 'select' || cell.column.id === 'actions'"
|
||||
>
|
||||
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow v-if="row.getIsExpanded()">
|
||||
<TableCell :colspan="row.getAllCells().length">
|
||||
<DataTableExpandedRow :item="row.original" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
<TableRow>
|
||||
<TableCell :colspan="columns.length" class="h-24 text-center">
|
||||
<p>{{ $t("items.no_results") }}</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</template>
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-1">
|
||||
<Label :for="id" class="px-1">
|
||||
{{ $t("global.labels") }}
|
||||
{{ props.name ?? $t("global.labels") }}
|
||||
</Label>
|
||||
|
||||
<TagsInput
|
||||
@@ -108,6 +108,11 @@
|
||||
type: Array as () => LabelOut[],
|
||||
required: true,
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const modelValue = useVModel(props, "modelValue", emit);
|
||||
|
||||
@@ -29,7 +29,6 @@
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { DialogID } from "@/components/ui/dialog-provider/utils";
|
||||
import { toast } from "@/components/ui/sonner";
|
||||
import type { MaintenanceEntry, MaintenanceEntryWithDetails } from "~~/lib/api/types/data-contracts";
|
||||
import MdiPost from "~icons/mdi/post";
|
||||
import DatePicker from "~~/components/Form/DatePicker.vue";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
@@ -38,13 +37,11 @@
|
||||
import FormTextArea from "~/components/Form/TextArea.vue";
|
||||
import Button from "@/components/ui/button/Button.vue";
|
||||
|
||||
const { openDialog, closeDialog } = useDialog();
|
||||
const { closeDialog, registerOpenDialogCallback } = useDialog();
|
||||
|
||||
const { t } = useI18n();
|
||||
const api = useUserApi();
|
||||
|
||||
const emit = defineEmits(["changed"]);
|
||||
|
||||
const entry = reactive({
|
||||
id: null as string | null,
|
||||
name: "",
|
||||
@@ -52,7 +49,7 @@
|
||||
scheduledDate: null as Date | null,
|
||||
description: "",
|
||||
cost: "",
|
||||
itemId: null as string | null,
|
||||
itemIds: null as string[] | null,
|
||||
});
|
||||
|
||||
async function dispatchFormSubmit() {
|
||||
@@ -65,24 +62,28 @@
|
||||
}
|
||||
|
||||
async function createEntry() {
|
||||
if (!entry.itemId) {
|
||||
return;
|
||||
}
|
||||
const { error } = await api.items.maintenance.create(entry.itemId, {
|
||||
name: entry.name,
|
||||
completedDate: entry.completedDate ?? "",
|
||||
scheduledDate: entry.scheduledDate ?? "",
|
||||
description: entry.description,
|
||||
cost: parseFloat(entry.cost) ? entry.cost : "0",
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(t("maintenance.toast.failed_to_create"));
|
||||
if (!entry.itemIds) {
|
||||
return;
|
||||
}
|
||||
|
||||
closeDialog(DialogID.EditMaintenance);
|
||||
emit("changed");
|
||||
await Promise.allSettled(
|
||||
entry.itemIds.map(async itemId => {
|
||||
const { error } = await api.items.maintenance.create(itemId, {
|
||||
name: entry.name,
|
||||
completedDate: entry.completedDate ?? "",
|
||||
scheduledDate: entry.scheduledDate ?? "",
|
||||
description: entry.description,
|
||||
cost: parseFloat(entry.cost) ? entry.cost : "0",
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(t("maintenance.toast.failed_to_create"));
|
||||
return;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
closeDialog(DialogID.EditMaintenance, true);
|
||||
}
|
||||
|
||||
async function editEntry() {
|
||||
@@ -103,73 +104,42 @@
|
||||
return;
|
||||
}
|
||||
|
||||
closeDialog(DialogID.EditMaintenance);
|
||||
emit("changed");
|
||||
closeDialog(DialogID.EditMaintenance, true);
|
||||
}
|
||||
|
||||
const openCreateModal = (itemId: string) => {
|
||||
entry.id = null;
|
||||
entry.name = "";
|
||||
entry.completedDate = null;
|
||||
entry.scheduledDate = null;
|
||||
entry.description = "";
|
||||
entry.cost = "";
|
||||
entry.itemId = itemId;
|
||||
openDialog(DialogID.EditMaintenance);
|
||||
};
|
||||
|
||||
const openUpdateModal = (maintenanceEntry: MaintenanceEntry | MaintenanceEntryWithDetails) => {
|
||||
entry.id = maintenanceEntry.id;
|
||||
entry.name = maintenanceEntry.name;
|
||||
entry.completedDate = new Date(maintenanceEntry.completedDate);
|
||||
entry.scheduledDate = new Date(maintenanceEntry.scheduledDate);
|
||||
entry.description = maintenanceEntry.description;
|
||||
entry.cost = maintenanceEntry.cost;
|
||||
entry.itemId = null;
|
||||
openDialog(DialogID.EditMaintenance);
|
||||
};
|
||||
|
||||
const confirm = useConfirm();
|
||||
|
||||
async function deleteEntry(id: string) {
|
||||
const result = await confirm.open(t("maintenance.modal.delete_confirmation"));
|
||||
if (result.isCanceled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { error } = await api.maintenance.delete(id);
|
||||
|
||||
if (error) {
|
||||
toast.error(t("maintenance.toast.failed_to_delete"));
|
||||
return;
|
||||
}
|
||||
emit("changed");
|
||||
}
|
||||
|
||||
async function complete(maintenanceEntry: MaintenanceEntry) {
|
||||
const { error } = await api.maintenance.update(maintenanceEntry.id, {
|
||||
name: maintenanceEntry.name,
|
||||
completedDate: new Date(Date.now()),
|
||||
scheduledDate: maintenanceEntry.scheduledDate ?? "null",
|
||||
description: maintenanceEntry.description,
|
||||
cost: maintenanceEntry.cost,
|
||||
onMounted(() => {
|
||||
const cleanup = registerOpenDialogCallback(DialogID.EditMaintenance, params => {
|
||||
switch (params.type) {
|
||||
case "create":
|
||||
entry.id = null;
|
||||
entry.name = "";
|
||||
entry.completedDate = null;
|
||||
entry.scheduledDate = null;
|
||||
entry.description = "";
|
||||
entry.cost = "";
|
||||
entry.itemIds = typeof params.itemId === "string" ? [params.itemId] : params.itemId;
|
||||
break;
|
||||
case "update":
|
||||
entry.id = params.maintenanceEntry.id;
|
||||
entry.name = params.maintenanceEntry.name;
|
||||
entry.completedDate = new Date(params.maintenanceEntry.completedDate);
|
||||
entry.scheduledDate = new Date(params.maintenanceEntry.scheduledDate);
|
||||
entry.description = params.maintenanceEntry.description;
|
||||
entry.cost = params.maintenanceEntry.cost;
|
||||
entry.itemIds = null;
|
||||
break;
|
||||
case "duplicate":
|
||||
entry.id = null;
|
||||
entry.name = params.maintenanceEntry.name;
|
||||
entry.completedDate = null;
|
||||
entry.scheduledDate = null;
|
||||
entry.description = params.maintenanceEntry.description;
|
||||
entry.cost = params.maintenanceEntry.cost;
|
||||
entry.itemIds = [params.itemId];
|
||||
break;
|
||||
}
|
||||
});
|
||||
if (error) {
|
||||
toast.error(t("maintenance.toast.failed_to_update"));
|
||||
}
|
||||
emit("changed");
|
||||
}
|
||||
|
||||
function duplicate(maintenanceEntry: MaintenanceEntry | MaintenanceEntryWithDetails, itemId: string) {
|
||||
entry.id = null;
|
||||
entry.name = maintenanceEntry.name;
|
||||
entry.completedDate = null;
|
||||
entry.scheduledDate = null;
|
||||
entry.description = maintenanceEntry.description;
|
||||
entry.cost = maintenanceEntry.cost;
|
||||
entry.itemId = itemId;
|
||||
openDialog(DialogID.EditMaintenance);
|
||||
}
|
||||
|
||||
defineExpose({ openCreateModal, openUpdateModal, deleteEntry, complete, duplicate });
|
||||
onUnmounted(cleanup);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { MaintenanceEntryWithDetails } from "~~/lib/api/types/data-contracts";
|
||||
import type { MaintenanceEntry, MaintenanceEntryWithDetails } from "~~/lib/api/types/data-contracts";
|
||||
import { MaintenanceFilterStatus } from "~~/lib/api/types/data-contracts";
|
||||
import type { StatsFormat } from "~~/components/global/StatCard/types";
|
||||
import MdiCheck from "~icons/mdi/check";
|
||||
@@ -20,12 +20,16 @@
|
||||
import DateTime from "~/components/global/DateTime.vue";
|
||||
import Currency from "~/components/global/Currency.vue";
|
||||
import Markdown from "~/components/global/Markdown.vue";
|
||||
import { toast } from "@/components/ui/sonner";
|
||||
import { useDialog } from "@/components/ui/dialog-provider";
|
||||
import { DialogID } from "../ui/dialog-provider/utils";
|
||||
|
||||
const maintenanceFilterStatus = ref(MaintenanceFilterStatus.MaintenanceFilterStatusScheduled);
|
||||
const maintenanceEditModal = ref<InstanceType<typeof MaintenanceEditModal>>();
|
||||
|
||||
const api = useUserApi();
|
||||
const { t } = useI18n();
|
||||
const confirm = useConfirm();
|
||||
const { openDialog } = useDialog();
|
||||
|
||||
const props = defineProps({
|
||||
currentItemId: {
|
||||
@@ -81,6 +85,35 @@
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
async function deleteEntry(id: string) {
|
||||
const result = await confirm.open(t("maintenance.modal.delete_confirmation"));
|
||||
if (result.isCanceled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { error } = await api.maintenance.delete(id);
|
||||
|
||||
if (error) {
|
||||
toast.error(t("maintenance.toast.failed_to_delete"));
|
||||
return;
|
||||
}
|
||||
refreshList();
|
||||
}
|
||||
|
||||
async function completeEntry(maintenanceEntry: MaintenanceEntry) {
|
||||
const { error } = await api.maintenance.update(maintenanceEntry.id, {
|
||||
name: maintenanceEntry.name,
|
||||
completedDate: new Date(Date.now()),
|
||||
scheduledDate: maintenanceEntry.scheduledDate ?? "null",
|
||||
description: maintenanceEntry.description,
|
||||
cost: maintenanceEntry.cost,
|
||||
});
|
||||
if (error) {
|
||||
toast.error(t("maintenance.toast.failed_to_update"));
|
||||
}
|
||||
refreshList();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -122,7 +155,16 @@
|
||||
v-if="props.currentItemId"
|
||||
class="ml-auto"
|
||||
size="sm"
|
||||
@click="maintenanceEditModal?.openCreateModal(props.currentItemId)"
|
||||
@click="
|
||||
openDialog(DialogID.EditMaintenance, {
|
||||
params: { type: 'create', itemId: props.currentItemId },
|
||||
onClose: result => {
|
||||
if (result) {
|
||||
refreshList();
|
||||
}
|
||||
},
|
||||
})
|
||||
"
|
||||
>
|
||||
<MdiPlus />
|
||||
{{ $t("maintenance.list.new") }}
|
||||
@@ -171,24 +213,44 @@
|
||||
<Markdown :source="e.description" />
|
||||
</div>
|
||||
<ButtonGroup class="flex flex-wrap justify-end p-4">
|
||||
<Button size="sm" @click="maintenanceEditModal?.openUpdateModal(e)">
|
||||
<Button
|
||||
size="sm"
|
||||
@click="
|
||||
openDialog(DialogID.EditMaintenance, {
|
||||
params: { type: 'update', maintenanceEntry: e },
|
||||
onClose: result => {
|
||||
if (result) {
|
||||
refreshList();
|
||||
}
|
||||
},
|
||||
})
|
||||
"
|
||||
>
|
||||
<MdiEdit />
|
||||
{{ $t("maintenance.list.edit") }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!validDate(e.completedDate)"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@click="maintenanceEditModal?.complete(e)"
|
||||
>
|
||||
<Button v-if="!validDate(e.completedDate)" size="sm" variant="outline" @click="completeEntry(e)">
|
||||
<MdiCheck />
|
||||
{{ $t("maintenance.list.complete") }}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" @click="maintenanceEditModal?.duplicate(e, e.itemID)">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@click="
|
||||
openDialog(DialogID.EditMaintenance, {
|
||||
params: { type: 'duplicate', maintenanceEntry: e, itemId: props.currentItemId! },
|
||||
onClose: result => {
|
||||
if (result) {
|
||||
refreshList();
|
||||
}
|
||||
},
|
||||
})
|
||||
"
|
||||
>
|
||||
<MdiContentDuplicate />
|
||||
{{ $t("maintenance.list.duplicate") }}
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" @click="maintenanceEditModal?.deleteEntry(e.id)">
|
||||
<Button size="sm" variant="destructive" @click="deleteEntry(e.id)">
|
||||
<MdiDelete />
|
||||
{{ $t("maintenance.list.delete") }}
|
||||
</Button>
|
||||
@@ -198,7 +260,16 @@
|
||||
<button
|
||||
type="button"
|
||||
class="relative block w-full rounded-lg border-2 border-dashed p-12 text-center"
|
||||
@click="maintenanceEditModal?.openCreateModal(props.currentItemId)"
|
||||
@click="
|
||||
openDialog(DialogID.EditMaintenance, {
|
||||
params: { type: 'create', itemId: props.currentItemId },
|
||||
onClose: result => {
|
||||
if (result) {
|
||||
refreshList();
|
||||
}
|
||||
},
|
||||
})
|
||||
"
|
||||
>
|
||||
<MdiWrenchClock class="inline size-16" />
|
||||
<span class="mt-2 block text-sm font-medium text-gray-900"> {{ $t("maintenance.list.create_first") }} </span>
|
||||
|
||||
@@ -3,7 +3,14 @@
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{{ $t("global.confirm") }}</AlertDialogTitle>
|
||||
<AlertDialogDescription> {{ text || $t("global.delete_confirm") }} </AlertDialogDescription>
|
||||
<AlertDialogDescription>
|
||||
{{ text || $t("global.delete_confirm") }}
|
||||
</AlertDialogDescription>
|
||||
<div v-if="href && href !== ''">
|
||||
<a :href="href" target="_blank" rel="noopener noreferrer" class="break-all text-sm text-primary underline">
|
||||
{{ href }}
|
||||
</a>
|
||||
</div>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel @click="cancel(false)">
|
||||
@@ -30,7 +37,7 @@
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
const { text, isRevealed, confirm, cancel } = useConfirm();
|
||||
const { text, href, isRevealed, confirm, cancel } = useConfirm();
|
||||
const { addAlert, removeAlert } = useDialog();
|
||||
|
||||
watch(
|
||||
|
||||
@@ -147,6 +147,4 @@
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
</style>
|
||||
|
||||
@@ -1,32 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import type { CheckboxRootEmits, CheckboxRootProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Check } from 'lucide-vue-next'
|
||||
import { CheckboxIndicator, CheckboxRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
import { computed, type HTMLAttributes } from 'vue'
|
||||
import type { CheckboxRootEmits, CheckboxRootProps } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Check, Minus } from "lucide-vue-next";
|
||||
import { CheckboxIndicator, CheckboxRoot, useForwardPropsEmits } from "reka-ui";
|
||||
import { computed, type HTMLAttributes } from "vue";
|
||||
|
||||
const props = defineProps<CheckboxRootProps & { class?: HTMLAttributes['class'] }>()
|
||||
const emits = defineEmits<CheckboxRootEmits>()
|
||||
const props = defineProps<CheckboxRootProps & { class?: HTMLAttributes["class"] }>();
|
||||
const emits = defineEmits<CheckboxRootEmits>();
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props;
|
||||
|
||||
return delegated
|
||||
})
|
||||
return delegated;
|
||||
});
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CheckboxRoot
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn('peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
|
||||
props.class)"
|
||||
cn(
|
||||
'peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground',
|
||||
props.class
|
||||
)
|
||||
"
|
||||
>
|
||||
<CheckboxIndicator class="flex h-full w-full items-center justify-center text-current">
|
||||
<CheckboxIndicator class="flex size-full items-center justify-center text-current">
|
||||
<slot>
|
||||
<Check class="h-4 w-4" />
|
||||
<Check v-if="typeof props.modelValue === 'boolean'" class="size-4" />
|
||||
<Minus v-else class="size-4" />
|
||||
</slot>
|
||||
</CheckboxIndicator>
|
||||
</CheckboxRoot>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
/* eslint-disable @typescript-eslint/unified-signatures */
|
||||
import { computed, type ComputedRef } from "vue";
|
||||
import { createContext } from "reka-ui";
|
||||
import { useMagicKeys, useActiveElement } from "@vueuse/core";
|
||||
import type { BarcodeProduct } from "~~/lib/api/types/data-contracts";
|
||||
import type { BarcodeProduct, ItemSummary, MaintenanceEntry, MaintenanceEntryWithDetails } from "~~/lib/api/types/data-contracts";
|
||||
|
||||
export enum DialogID {
|
||||
AttachmentEdit = "attachment-edit",
|
||||
@@ -24,6 +23,7 @@ export enum DialogID {
|
||||
PageQRCode = "page-qr-code",
|
||||
UpdateLabel = "update-label",
|
||||
UpdateLocation = "update-location",
|
||||
ItemChangeDetails = "item-table-updater",
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,6 +50,16 @@ export type DialogParamsMap = {
|
||||
};
|
||||
[DialogID.CreateItem]?: { product?: BarcodeProduct };
|
||||
[DialogID.ProductImport]?: { barcode?: string };
|
||||
[DialogID.EditMaintenance]:
|
||||
| { type: "create"; itemId: string | string[] }
|
||||
| { type: "update"; maintenanceEntry: MaintenanceEntry | MaintenanceEntryWithDetails }
|
||||
| { type: "duplicate"; maintenanceEntry: MaintenanceEntry | MaintenanceEntryWithDetails; itemId: string };
|
||||
[DialogID.ItemChangeDetails]: {
|
||||
items: ItemSummary[];
|
||||
changeLocation?: boolean;
|
||||
addLabels?: boolean;
|
||||
removeLabels?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -57,6 +67,8 @@ export type DialogParamsMap = {
|
||||
*/
|
||||
export type DialogResultMap = {
|
||||
[DialogID.ItemImage]?: { action: "delete"; id: string };
|
||||
[DialogID.EditMaintenance]?: boolean;
|
||||
[DialogID.ItemChangeDetails]?: boolean;
|
||||
};
|
||||
|
||||
/** Helpers to split IDs by requirement */
|
||||
|
||||
@@ -1,21 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"];
|
||||
href?: string;
|
||||
compact?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<td
|
||||
:class="
|
||||
cn(
|
||||
'p-4 align-middle [&:has([role=checkbox])]:pr-0',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
<td :class="cn('p-0 align-middle relative', props.class)">
|
||||
<NuxtLink
|
||||
v-if="props.href"
|
||||
:to="props.href"
|
||||
class="block size-full"
|
||||
:class="props.compact ? 'p-0' : 'p-4'"
|
||||
style="min-width: 100%; min-height: 100%; display: flex; align-items: stretch"
|
||||
>
|
||||
<span class="flex min-h-0 min-w-0 flex-1 items-center"> <slot /> </span>
|
||||
</NuxtLink>
|
||||
<template v-else>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex size-full min-h-0 min-w-0 items-center [&:has([role=checkbox])]:pr-0',
|
||||
props.compact ? 'justify-center p-0' : 'p-4'
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
</td>
|
||||
</template>
|
||||
|
||||
@@ -4,12 +4,14 @@ import type { Ref } from "vue";
|
||||
|
||||
type Store = UseConfirmDialogReturn<any, boolean, boolean> & {
|
||||
text: Ref<string>;
|
||||
href: Ref<string>;
|
||||
setup: boolean;
|
||||
open: (text: string) => Promise<UseConfirmDialogRevealResult<boolean, boolean>>;
|
||||
open: (text: string | { message: string; href?: string }) => Promise<UseConfirmDialogRevealResult<boolean, boolean>>;
|
||||
};
|
||||
|
||||
const store: Partial<Store> = {
|
||||
text: ref("Are you sure you want to delete this item? "),
|
||||
href: ref(""),
|
||||
setup: false,
|
||||
};
|
||||
|
||||
@@ -31,15 +33,26 @@ export function useConfirm(): Store {
|
||||
store.cancel = cancel;
|
||||
}
|
||||
|
||||
async function openDialog(msg: string): Promise<UseConfirmDialogRevealResult<boolean, boolean>> {
|
||||
async function openDialog(
|
||||
msg: string | { message: string; href?: string }
|
||||
): Promise<UseConfirmDialogRevealResult<boolean, boolean>> {
|
||||
if (!store.reveal) {
|
||||
throw new Error("reveal is not defined");
|
||||
}
|
||||
if (!store.text) {
|
||||
throw new Error("text is not defined");
|
||||
}
|
||||
if (store.href === undefined) {
|
||||
throw new Error("href is not defined");
|
||||
}
|
||||
|
||||
store.text.value = msg;
|
||||
store.href.value = "";
|
||||
if (typeof msg === "string") {
|
||||
store.text.value = msg;
|
||||
} else {
|
||||
store.text.value = msg.message;
|
||||
store.href.value = msg.href ?? "";
|
||||
}
|
||||
return await store.reveal();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { format, formatDistance } from "date-fns";
|
||||
/* eslint import/namespace: ['error', { allowComputed: true }] */
|
||||
import * as Locales from "date-fns/locale";
|
||||
import { fmtCurrency, fmtCurrencyAsync } from "./utils";
|
||||
|
||||
const cache = {
|
||||
currency: "",
|
||||
@@ -21,6 +22,16 @@ export async function useFormatCurrency() {
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-load currency decimals for better formatting (optional, non-blocking)
|
||||
if (cache.currency && cache.currency.trim() !== "") {
|
||||
try {
|
||||
await fmtCurrencyAsync(0, cache.currency, getLocaleCode());
|
||||
} catch (error) {
|
||||
// Silently swallow preload errors - formatter will still work, just without pre-cached decimals
|
||||
console.debug("Currency preload failed (non-fatal):", error);
|
||||
}
|
||||
}
|
||||
|
||||
return (value: number | string) => fmtCurrency(value, cache.currency, getLocaleCode());
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { Ref } from "vue";
|
||||
import type { TableHeaderType } from "~/components/Item/View/Table.types";
|
||||
import type { ItemSummary } from "~/lib/api/types/data-contracts";
|
||||
import type { DaisyTheme } from "~~/lib/data/themes";
|
||||
|
||||
export type ViewType = "table" | "card" | "tree";
|
||||
export type ViewType = "table" | "card";
|
||||
|
||||
export type DuplicateSettings = {
|
||||
copyMaintenance: boolean;
|
||||
@@ -18,11 +18,19 @@ export type LocationViewPreferences = {
|
||||
itemDisplayView: ViewType;
|
||||
theme: DaisyTheme;
|
||||
itemsPerTablePage: number;
|
||||
tableHeaders?: TableHeaderType[];
|
||||
tableHeaders?: {
|
||||
value: keyof ItemSummary;
|
||||
enabled: boolean;
|
||||
}[];
|
||||
displayLegacyHeader: boolean;
|
||||
legacyImageFit: boolean;
|
||||
language?: string;
|
||||
overrideFormatLocale?: string;
|
||||
duplicateSettings: DuplicateSettings;
|
||||
shownMultiTabWarning: boolean;
|
||||
quickActions: {
|
||||
enabled: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -40,6 +48,7 @@ export function useViewPreferences(): Ref<LocationViewPreferences> {
|
||||
theme: "homebox",
|
||||
itemsPerTablePage: 10,
|
||||
displayLegacyHeader: false,
|
||||
legacyImageFit: false,
|
||||
language: null,
|
||||
overrideFormatLocale: null,
|
||||
duplicateSettings: {
|
||||
@@ -48,6 +57,10 @@ export function useViewPreferences(): Ref<LocationViewPreferences> {
|
||||
copyCustomFields: true,
|
||||
copyPrefixOverride: null,
|
||||
},
|
||||
shownMultiTabWarning: false,
|
||||
quickActions: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
{ mergeDefaults: true }
|
||||
);
|
||||
|
||||
@@ -25,19 +25,126 @@ export function validDate(dt: Date | string | null | undefined): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Currency cache to store decimal places information
|
||||
export const currencyDecimalsCache: Record<string, number> = {};
|
||||
|
||||
// Promise to track in-flight loading to coalesce concurrent calls
|
||||
let currencyLoadingPromise: Promise<void> | null = null;
|
||||
|
||||
// Safe range for server-provided decimals
|
||||
const SAFE_MIN_DECIMALS = 0;
|
||||
const SAFE_MAX_DECIMALS = 4;
|
||||
|
||||
// Helper function to clamp decimal places to safe range
|
||||
function clampDecimals(currency: string, decimals: number): number {
|
||||
const truncated = Math.trunc(decimals);
|
||||
return Math.max(SAFE_MIN_DECIMALS, Math.min(SAFE_MAX_DECIMALS, truncated));
|
||||
}
|
||||
|
||||
// Type guard to validate currency response shape with strict validation
|
||||
function isValidCurrencyItem(item: any): item is { code: string; decimals: number } {
|
||||
if (
|
||||
typeof item !== "object" ||
|
||||
item === null ||
|
||||
typeof item.code !== "string" ||
|
||||
item.code.trim() === "" ||
|
||||
typeof item.decimals !== "number" ||
|
||||
!Number.isFinite(item.decimals)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Truncate decimals to integer and check range
|
||||
const truncatedDecimals = Math.trunc(item.decimals);
|
||||
return truncatedDecimals >= SAFE_MIN_DECIMALS && truncatedDecimals <= SAFE_MAX_DECIMALS;
|
||||
}
|
||||
|
||||
// Function to load currency decimals from API
|
||||
function loadCurrencyDecimals(): Promise<void> {
|
||||
// Check environment variable to see if remote decimals are disabled
|
||||
if (process.env.USE_REMOTE_DECIMALS === "false") {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// Return early if already loaded
|
||||
if (Object.keys(currencyDecimalsCache).length > 0) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// Coalesce concurrent calls - return existing promise if loading
|
||||
if (currencyLoadingPromise) {
|
||||
return currencyLoadingPromise;
|
||||
}
|
||||
|
||||
// Create new loading promise
|
||||
currencyLoadingPromise = (async () => {
|
||||
try {
|
||||
const api = useUserApi();
|
||||
const { data, error } = await api.group.currencies();
|
||||
|
||||
if (!error && data) {
|
||||
// Validate that data is an array
|
||||
if (!Array.isArray(data)) {
|
||||
// Log generic message without server details
|
||||
console.warn("Currency API returned invalid data format");
|
||||
return;
|
||||
}
|
||||
|
||||
// Process and validate each currency item
|
||||
for (const currency of data) {
|
||||
// Strict validation: only process items that pass all checks
|
||||
if (!isValidCurrencyItem(currency)) {
|
||||
// Skip invalid items without caching - no clamping for out-of-range values
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only cache strictly validated items with truncated and clamped decimals
|
||||
const code = currency.code.trim().toUpperCase();
|
||||
const truncatedDecimals = Math.trunc(currency.decimals);
|
||||
const clampedDecimals = Math.max(SAFE_MIN_DECIMALS, Math.min(SAFE_MAX_DECIMALS, truncatedDecimals));
|
||||
currencyDecimalsCache[code] = clampedDecimals;
|
||||
}
|
||||
} else if (error) {
|
||||
// Generic error logging without exposing server error details
|
||||
console.warn("Currency API request failed, using default formatting");
|
||||
}
|
||||
} catch (e) {
|
||||
// Generic error without sensitive details - no raw error logging
|
||||
console.warn("Currency data loading failed, using default formatting");
|
||||
} finally {
|
||||
// Clear loading promise when done (success or failure)
|
||||
currencyLoadingPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return currencyLoadingPromise;
|
||||
}
|
||||
|
||||
export function fmtCurrency(value: number | string, currency = "USD", locale = "en-Us"): string {
|
||||
if (typeof value === "string") {
|
||||
value = parseFloat(value);
|
||||
}
|
||||
|
||||
// Normalize and validate currency code
|
||||
const normalizedCurrency = String(currency).toUpperCase();
|
||||
const safeCurrency = /^[A-Z]{3}$/.test(normalizedCurrency) ? normalizedCurrency : "USD";
|
||||
// Derive fraction digits using the same clamp helper
|
||||
const fractionDigits = clampDecimals(safeCurrency, currencyDecimalsCache[safeCurrency] ?? 2);
|
||||
|
||||
const formatter = new Intl.NumberFormat(locale, {
|
||||
style: "currency",
|
||||
currency,
|
||||
minimumFractionDigits: 2,
|
||||
currency: safeCurrency,
|
||||
minimumFractionDigits: fractionDigits,
|
||||
maximumFractionDigits: fractionDigits,
|
||||
});
|
||||
return formatter.format(value);
|
||||
}
|
||||
|
||||
export async function fmtCurrencyAsync(value: number | string, currency = "USD", locale = "en-Us"): Promise<string> {
|
||||
await loadCurrencyDecimals();
|
||||
return fmtCurrency(value, currency, locale);
|
||||
}
|
||||
|
||||
export type MaybeUrlResult = {
|
||||
isUrl: boolean;
|
||||
url: string;
|
||||
|
||||
@@ -53,6 +53,7 @@ export default withNuxt([
|
||||
},
|
||||
],
|
||||
"@typescript-eslint/no-invalid-void-type": "off",
|
||||
"@typescript-eslint/unified-signatures": "off",
|
||||
|
||||
"prettier/prettier": [
|
||||
"warn",
|
||||
|
||||
@@ -105,7 +105,7 @@
|
||||
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
<SidebarInset class="min-h-dvh bg-background-accent">
|
||||
<SidebarInset class="min-h-dvh max-w-full overflow-hidden bg-background-accent">
|
||||
<div class="relative flex h-full flex-col justify-center">
|
||||
<div v-if="preferences.displayLegacyHeader">
|
||||
<AppHeaderDecor class="-mt-10 hidden lg:block" />
|
||||
|
||||
@@ -54,6 +54,7 @@ export enum AttachmentType {
|
||||
|
||||
export interface CurrenciesCurrency {
|
||||
code: string;
|
||||
decimals: number;
|
||||
local: string;
|
||||
name: string;
|
||||
symbol: string;
|
||||
@@ -464,6 +465,13 @@ export interface BarcodeProduct {
|
||||
search_engine_name: string;
|
||||
}
|
||||
|
||||
export interface DuplicateOptions {
|
||||
copyAttachments: boolean;
|
||||
copyCustomFields: boolean;
|
||||
copyMaintenance: boolean;
|
||||
copyPrefix: string;
|
||||
}
|
||||
|
||||
export interface Group {
|
||||
createdAt: Date | string;
|
||||
currency: string;
|
||||
@@ -570,6 +578,8 @@ export interface ItemOut {
|
||||
|
||||
export interface ItemPatch {
|
||||
id: string;
|
||||
labelIds?: string[] | null;
|
||||
locationId?: string | null;
|
||||
quantity?: number | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Updater } from "@tanstack/vue-table";
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
@@ -5,6 +6,11 @@ export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function valueUpdater<T extends Updater<any>>(updaterOrValue: T, ref: Ref) {
|
||||
ref.value = typeof updaterOrValue === "function" ? updaterOrValue(ref.value) : updaterOrValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns either '#000' or '#fff' depending on which has better contrast with the given background color.
|
||||
* Accepts hex (#RRGGBB or #RGB) or rgb(a) strings.
|
||||
@@ -36,3 +42,5 @@ export function getContrastTextColor(bgColor: string): string {
|
||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||
return luminance > 0.5 ? "#000" : "#fff";
|
||||
}
|
||||
|
||||
export const camelToSnakeCase = (str: string) => str.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`);
|
||||
|
||||
@@ -138,18 +138,53 @@
|
||||
"searching": "Searching…"
|
||||
},
|
||||
"view": {
|
||||
"change_details": {
|
||||
"title": "Change Item Details",
|
||||
"failed_to_update_item": "Failed to update item",
|
||||
"add_labels": "Add Labels",
|
||||
"remove_labels": "Remove Labels"
|
||||
},
|
||||
"selectable": {
|
||||
"card": "Card",
|
||||
"items": "Items",
|
||||
"no_items": "No Items to Display",
|
||||
"table": "Table"
|
||||
"table": "Table",
|
||||
"select_all": "Select All",
|
||||
"select_row": "Select Row",
|
||||
"select_card": "Select Card"
|
||||
},
|
||||
"table": {
|
||||
"headers": "Headers",
|
||||
"page": "Page",
|
||||
"rows_per_page": "Rows per page",
|
||||
"quick_actions": "Enable Quick Actions & Selection",
|
||||
"table_settings": "Table Settings",
|
||||
"view_item": "View Item"
|
||||
"view_item": "View Item",
|
||||
"selected_rows": "{selected} of {total} row(s) selected.",
|
||||
"dropdown": {
|
||||
"open_menu": "Open menu",
|
||||
"actions": "Actions",
|
||||
"view_item": "View item",
|
||||
"view_items": "View items",
|
||||
"toggle_expand": "Toggle Expand",
|
||||
"download_csv": "Download Table as CSV",
|
||||
"download_json": "Download Table as JSON",
|
||||
"delete_selected": "Delete Selected Items",
|
||||
"delete_item": "Delete Item",
|
||||
"error_deleting": "Error Deleting Item",
|
||||
"delete_confirmation": "Are you sure you want to delete the selected item(s)? This action cannot be undone.",
|
||||
"duplicate_selected": "Duplicate Selected Items",
|
||||
"duplicate_item": "Duplicate Item",
|
||||
"error_duplicating": "Error Duplicating Item",
|
||||
"open_multi_tab_warning": "For security reasons browsers do not allow multiple tabs to be opened at once by default, to change this please follow the documentation:",
|
||||
"create_maintenance_selected": "Create Maintenance Entry for Selected Items",
|
||||
"create_maintenance_item": "Create Maintenance Entry for Item",
|
||||
"create_maintenance_success": "Maintenance Entry(s) Created",
|
||||
"change_location": "Change Location",
|
||||
"change_location_success": "Location Changed",
|
||||
"change_labels": "Change Labels",
|
||||
"change_labels_success": "Labels Changed"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -520,6 +555,7 @@
|
||||
"delete_account_sub": "Delete your account and all its associated data. This can not be undone.",
|
||||
"delete_notifier_confirm": "Are you sure you want to delete this notifier?",
|
||||
"display_legacy_header": "{ currentValue, select, true {Disable Legacy Header} false {Enable Legacy Header} other {Not Hit}}",
|
||||
"legacy_image_fit": "{ currentValue, select, true {Disable Legacy Fit: Fit Image with Bars} false {Enable Legacy Fit: Fill Image with Crop} other {Not Hit}}",
|
||||
"enabled": "Enabled",
|
||||
"example": "Example",
|
||||
"gen_invite": "Generate Invite Link",
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
"@tailwindcss/aspect-ratio": "^0.4.2",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tanstack/vue-table": "^8.21.3",
|
||||
"@vuepic/vue-datepicker": "^11.0.2",
|
||||
"@vueuse/core": "^13.8.0",
|
||||
"@vueuse/nuxt": "^13.8.0",
|
||||
|
||||
@@ -8,10 +8,10 @@
|
||||
import BaseCard from "@/components/Base/Card.vue";
|
||||
import Subtitle from "~/components/global/Subtitle.vue";
|
||||
import StatCard from "~/components/global/StatCard/StatCard.vue";
|
||||
import ItemViewTable from "~/components/Item/View/Table.vue";
|
||||
import ItemCard from "~/components/Item/Card.vue";
|
||||
import LocationCard from "~/components/Location/Card.vue";
|
||||
import LabelChip from "~/components/Label/Chip.vue";
|
||||
import Table from "~/components/Item/View/Table.vue";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
|
||||
<p v-if="itemTable.items.length === 0" class="ml-2 text-sm">{{ $t("items.no_results") }}</p>
|
||||
<BaseCard v-else-if="breakpoints.lg">
|
||||
<ItemViewTable :items="itemTable.items" disable-controls />
|
||||
<Table :items="itemTable.items" />
|
||||
</BaseCard>
|
||||
<div v-else class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<ItemCard v-for="item in itemTable.items" :key="item.id" :item="item" />
|
||||
|
||||
@@ -478,22 +478,28 @@
|
||||
return resp.data;
|
||||
});
|
||||
|
||||
const items = computedAsync(async () => {
|
||||
if (!item.value) {
|
||||
return [];
|
||||
const { data: items, refresh: refreshItemList } = useAsyncData(
|
||||
() => itemId.value + "_item_list",
|
||||
async () => {
|
||||
if (!itemId.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const resp = await api.items.getAll({
|
||||
parentIds: [itemId.value],
|
||||
});
|
||||
|
||||
if (resp.error) {
|
||||
toast.error(t("items.toast.failed_load_items"));
|
||||
return [];
|
||||
}
|
||||
|
||||
return resp.data.items;
|
||||
},
|
||||
{
|
||||
watch: [itemId],
|
||||
}
|
||||
|
||||
const resp = await api.items.getAll({
|
||||
parentIds: [item.value.id],
|
||||
});
|
||||
|
||||
if (resp.error) {
|
||||
toast.error(t("items.toast.failed_load_items"));
|
||||
return [];
|
||||
}
|
||||
|
||||
return resp.data.items;
|
||||
});
|
||||
);
|
||||
|
||||
async function duplicateItem(settings?: DuplicateSettings) {
|
||||
if (!item.value) {
|
||||
@@ -787,7 +793,7 @@
|
||||
</section>
|
||||
|
||||
<section v-if="items && items.length > 0" class="mt-6">
|
||||
<ItemViewSelectable :items="items" />
|
||||
<ItemViewSelectable :items="items" @refresh="refreshItemList" />
|
||||
</section>
|
||||
</BaseContainer>
|
||||
</template>
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
import { useLabelStore } from "~~/stores/labels";
|
||||
import { useLocationStore } from "~~/stores/locations";
|
||||
import MdiLoading from "~icons/mdi/loading";
|
||||
import MdiSelectSearch from "~icons/mdi/select-search";
|
||||
import MdiMagnify from "~icons/mdi/magnify";
|
||||
import MdiDelete from "~icons/mdi/delete";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -15,18 +14,9 @@
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import {
|
||||
Pagination,
|
||||
PaginationEllipsis,
|
||||
PaginationFirst,
|
||||
PaginationLast,
|
||||
PaginationList,
|
||||
PaginationListItem,
|
||||
} from "@/components/ui/pagination";
|
||||
import BaseContainer from "@/components/Base/Container.vue";
|
||||
import BaseSectionHeader from "@/components/Base/SectionHeader.vue";
|
||||
import SearchFilter from "~/components/Search/Filter.vue";
|
||||
import ItemCard from "~/components/Item/Card.vue";
|
||||
import ItemViewSelectable from "~/components/Item/View/Selectable.vue";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -56,7 +46,6 @@
|
||||
},
|
||||
});
|
||||
|
||||
const pageSize = useRouteQuery("pageSize", 24);
|
||||
const query = useRouteQuery("q", "");
|
||||
const advanced = useRouteQuery("advanced", false);
|
||||
const includeArchived = useRouteQuery("archived", false);
|
||||
@@ -66,7 +55,8 @@
|
||||
const onlyWithPhoto = useRouteQuery("onlyWithPhoto", false);
|
||||
const orderBy = useRouteQuery("orderBy", "name");
|
||||
|
||||
const totalPages = computed(() => Math.ceil(total.value / pageSize.value));
|
||||
const preferences = useViewPreferences();
|
||||
const pageSize = computed(() => preferences.value.itemsPerTablePage);
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -243,8 +233,7 @@
|
||||
advanced: route.query.advanced,
|
||||
q: query.value,
|
||||
page: page.value,
|
||||
pageSize: pageSize.value,
|
||||
includeArchived: includeArchived.value ? "true" : "false",
|
||||
archived: includeArchived.value ? "true" : "false",
|
||||
negateLabels: negateLabels.value ? "true" : "false",
|
||||
onlyWithoutPhoto: onlyWithoutPhoto.value ? "true" : "false",
|
||||
onlyWithPhoto: onlyWithPhoto.value ? "true" : "false",
|
||||
@@ -330,7 +319,6 @@
|
||||
onlyWithoutPhoto: onlyWithoutPhoto.value ? "true" : "false",
|
||||
onlyWithPhoto: onlyWithPhoto.value ? "true" : "false",
|
||||
orderBy: orderBy.value,
|
||||
pageSize: pageSize.value,
|
||||
page: page.value,
|
||||
q: query.value,
|
||||
|
||||
@@ -361,7 +349,6 @@
|
||||
query: {
|
||||
archived: "false",
|
||||
fieldSelector: "false",
|
||||
pageSize: pageSize.value,
|
||||
page: 1,
|
||||
orderBy: "name",
|
||||
q: "",
|
||||
@@ -373,6 +360,15 @@
|
||||
|
||||
await search();
|
||||
}
|
||||
|
||||
const pagination = proxyRefs({
|
||||
page,
|
||||
pageSize,
|
||||
totalSize: total,
|
||||
setPage: (newPage: number) => {
|
||||
page.value = newPage;
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -503,41 +499,12 @@
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<BaseSectionHeader ref="itemsTitle"> {{ $t("global.items") }} </BaseSectionHeader>
|
||||
<p v-if="items.length > 0" class="flex items-center text-base font-medium">
|
||||
{{ $t("items.results", { total: total }) }}
|
||||
<span class="ml-auto text-base"> {{ $t("items.pages", { page: page, totalPages: totalPages }) }} </span>
|
||||
</p>
|
||||
|
||||
<div v-if="items.length === 0" class="flex flex-col items-center gap-2">
|
||||
<MdiSelectSearch class="size-10" />
|
||||
<p>{{ $t("items.no_results") }}</p>
|
||||
</div>
|
||||
<div v-else ref="cardgrid" class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
<ItemCard v-for="item in items" :key="item.id" :item="item" :location-flat-tree="locationFlatTree" />
|
||||
</div>
|
||||
<Pagination
|
||||
v-slot="{ page: currentPage }"
|
||||
:items-per-page="pageSize"
|
||||
:total="total"
|
||||
:sibling-count="2"
|
||||
:default-page="page"
|
||||
class="flex justify-center p-2"
|
||||
@update:page="page = $event"
|
||||
>
|
||||
<PaginationList v-slot="{ items: pageItems }" class="flex items-center gap-1">
|
||||
<PaginationFirst />
|
||||
<template v-for="(item, index) in pageItems">
|
||||
<PaginationListItem v-if="item.type === 'page'" :key="index" :value="item.value" as-child>
|
||||
<Button class="size-10 p-0" :variant="item.value === currentPage ? 'default' : 'outline'">
|
||||
{{ item.value }}
|
||||
</Button>
|
||||
</PaginationListItem>
|
||||
<PaginationEllipsis v-else :key="item.type" :index="index" />
|
||||
</template>
|
||||
<PaginationLast />
|
||||
</PaginationList>
|
||||
</Pagination>
|
||||
<ItemViewSelectable
|
||||
:items="items"
|
||||
:location-flat-tree="locationFlatTree"
|
||||
:pagination="pagination"
|
||||
@refresh="async () => search()"
|
||||
/>
|
||||
</section>
|
||||
</BaseContainer>
|
||||
</template>
|
||||
|
||||
@@ -94,28 +94,34 @@
|
||||
updating.value = false;
|
||||
}
|
||||
|
||||
const items = computedAsync(async () => {
|
||||
if (!label.value) {
|
||||
return {
|
||||
items: [],
|
||||
totalPrice: null,
|
||||
};
|
||||
const { data: items, refresh: refreshItemList } = useAsyncData(
|
||||
() => labelId.value + "_item_list",
|
||||
async () => {
|
||||
if (!labelId.value) {
|
||||
return {
|
||||
items: [],
|
||||
totalPrice: null,
|
||||
};
|
||||
}
|
||||
|
||||
const resp = await api.items.getAll({
|
||||
labels: [labelId.value],
|
||||
});
|
||||
|
||||
if (resp.error) {
|
||||
toast.error(t("items.toast.failed_load_items"));
|
||||
return {
|
||||
items: [],
|
||||
totalPrice: null,
|
||||
};
|
||||
}
|
||||
|
||||
return resp.data;
|
||||
},
|
||||
{
|
||||
watch: [labelId],
|
||||
}
|
||||
|
||||
const resp = await api.items.getAll({
|
||||
labels: [label.value.id],
|
||||
});
|
||||
|
||||
if (resp.error) {
|
||||
toast.error(t("items.toast.failed_load_items"));
|
||||
return {
|
||||
items: [],
|
||||
totalPrice: null,
|
||||
};
|
||||
}
|
||||
|
||||
return resp.data;
|
||||
});
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -200,7 +206,7 @@
|
||||
<Markdown v-if="label && label.description" class="mt-3 text-base" :source="label.description" />
|
||||
</Card>
|
||||
<section v-if="label && items">
|
||||
<ItemViewSelectable :items="items.items" />
|
||||
<ItemViewSelectable :items="items.items" @refresh="refreshItemList" />
|
||||
</section>
|
||||
</BaseContainer>
|
||||
</template>
|
||||
|
||||
@@ -120,22 +120,28 @@
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const parent = ref<LocationSummary | any>({});
|
||||
|
||||
const items = computedAsync(async () => {
|
||||
if (!location.value) {
|
||||
return [];
|
||||
const { data: items, refresh: refreshItemList } = useAsyncData(
|
||||
() => locationId.value + "_item_list",
|
||||
async () => {
|
||||
if (!locationId.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const resp = await api.items.getAll({
|
||||
locations: [locationId.value],
|
||||
});
|
||||
|
||||
if (resp.error) {
|
||||
toast.error(t("items.toast.failed_load_items"));
|
||||
return [];
|
||||
}
|
||||
|
||||
return resp.data.items;
|
||||
},
|
||||
{
|
||||
watch: [locationId],
|
||||
}
|
||||
|
||||
const resp = await api.items.getAll({
|
||||
locations: [location.value.id],
|
||||
});
|
||||
|
||||
if (resp.error) {
|
||||
toast.error(t("items.toast.failed_load_items"));
|
||||
return [];
|
||||
}
|
||||
|
||||
return resp.data.items;
|
||||
});
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -228,7 +234,7 @@
|
||||
<Markdown v-if="location && location.description" class="mt-3 text-base" :source="location.description" />
|
||||
</Card>
|
||||
<section v-if="location && items">
|
||||
<ItemViewSelectable :items="items" />
|
||||
<ItemViewSelectable :items="items" @refresh="refreshItemList" />
|
||||
</section>
|
||||
|
||||
<section v-if="location && location.children.length > 0" class="mt-6">
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import MdiPencil from "~icons/mdi/pencil";
|
||||
import MdiAccountMultiple from "~icons/mdi/account-multiple";
|
||||
import { getLocaleCode } from "~/composables/use-formatters";
|
||||
import { fmtCurrencyAsync } from "~/composables/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { useDialog } from "@/components/ui/dialog-provider";
|
||||
@@ -60,6 +61,9 @@
|
||||
function setDisplayHeader() {
|
||||
preferences.value.displayLegacyHeader = !preferences.value.displayLegacyHeader;
|
||||
}
|
||||
function setLegacyImageFit() {
|
||||
preferences.value.legacyImageFit = !preferences.value.legacyImageFit;
|
||||
}
|
||||
|
||||
// Currency Selection
|
||||
const currency = ref<CurrenciesCurrency>({
|
||||
@@ -67,6 +71,7 @@
|
||||
name: "United States Dollar",
|
||||
local: "en-US",
|
||||
symbol: "$",
|
||||
decimals: 2,
|
||||
});
|
||||
watch(currency, () => {
|
||||
if (group.value) {
|
||||
@@ -74,9 +79,18 @@
|
||||
}
|
||||
});
|
||||
|
||||
const currencyExample = computed(() => {
|
||||
return fmtCurrency(1000, currency.value?.code ?? "USD", getLocaleCode());
|
||||
});
|
||||
const currencyExample = ref("$1,000.00");
|
||||
|
||||
// Update currency example when currency changes
|
||||
watch(
|
||||
currency,
|
||||
async () => {
|
||||
if (currency.value) {
|
||||
currencyExample.value = await fmtCurrencyAsync(1000, currency.value.code, getLocaleCode());
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const { data: group } = useAsyncData(async () => {
|
||||
const { data } = await api.group.get();
|
||||
@@ -519,10 +533,13 @@
|
||||
</template>
|
||||
|
||||
<div class="px-4 pb-4">
|
||||
<div class="mb-3">
|
||||
<div class="mb-3 flex gap-2">
|
||||
<Button variant="secondary" size="sm" @click="setDisplayHeader">
|
||||
{{ $t("profile.display_legacy_header", { currentValue: preferences.displayLegacyHeader }) }}
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" @click="setLegacyImageFit">
|
||||
{{ $t("profile.legacy_image_fit", { currentValue: preferences.legacyImageFit }) }}
|
||||
</Button>
|
||||
</div>
|
||||
<ThemePicker />
|
||||
</div>
|
||||
|
||||
20
frontend/pnpm-lock.yaml
generated
@@ -29,6 +29,9 @@ importers:
|
||||
'@tailwindcss/typography':
|
||||
specifier: ^0.5.16
|
||||
version: 0.5.16(tailwindcss@3.4.17)
|
||||
'@tanstack/vue-table':
|
||||
specifier: ^8.21.3
|
||||
version: 8.21.3(vue@3.5.20(typescript@5.9.2))
|
||||
'@vuepic/vue-datepicker':
|
||||
specifier: ^11.0.2
|
||||
version: 11.0.2(vue@3.5.20(typescript@5.9.2))
|
||||
@@ -2187,9 +2190,19 @@ packages:
|
||||
peerDependencies:
|
||||
tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1'
|
||||
|
||||
'@tanstack/table-core@8.21.3':
|
||||
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
'@tanstack/virtual-core@3.13.12':
|
||||
resolution: {integrity: sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==}
|
||||
|
||||
'@tanstack/vue-table@8.21.3':
|
||||
resolution: {integrity: sha512-rusRyd77c5tDPloPskctMyPLFEQUeBzxdQ+2Eow4F7gDPlPOB1UnnhzfpdvqZ8ZyX2rRNGmqNnQWm87OI2OQPw==}
|
||||
engines: {node: '>=12'}
|
||||
peerDependencies:
|
||||
vue: '>=3.2'
|
||||
|
||||
'@tanstack/vue-virtual@3.13.12':
|
||||
resolution: {integrity: sha512-vhF7kEU9EXWXh+HdAwKJ2m3xaOnTTmgcdXcF2pim8g4GvI7eRrk2YRuV5nUlZnd/NbCIX4/Ja2OZu5EjJL06Ww==}
|
||||
peerDependencies:
|
||||
@@ -8897,8 +8910,15 @@ snapshots:
|
||||
postcss-selector-parser: 6.0.10
|
||||
tailwindcss: 3.4.17
|
||||
|
||||
'@tanstack/table-core@8.21.3': {}
|
||||
|
||||
'@tanstack/virtual-core@3.13.12': {}
|
||||
|
||||
'@tanstack/vue-table@8.21.3(vue@3.5.20(typescript@5.9.2))':
|
||||
dependencies:
|
||||
'@tanstack/table-core': 8.21.3
|
||||
vue: 3.5.20(typescript@5.9.2)
|
||||
|
||||
'@tanstack/vue-virtual@3.13.12(vue@3.5.20(typescript@5.9.2))':
|
||||
dependencies:
|
||||
'@tanstack/virtual-core': 3.13.12
|
||||
|
||||