Compare commits

..

23 Commits

Author SHA1 Message Date
Matthew Kilgore
364e6cd91f Fix arm builds not getting runner 2025-11-16 16:03:04 -05:00
Matthew Kilgore
82f018b996 Use native Github runners 2025-11-16 15:49:12 -05:00
Matt
c70005a4bc Merge branch 'main' into mk/kill-32-bit 2025-09-24 21:29:42 -04:00
Matt
825e72bceb Add funding information for contributors 2025-09-24 20:03:58 -04:00
Matt
8547fb9bb3 Add database type selection to bug report template
Added a dropdown for selecting the database type in the bug report template.
2025-09-24 11:28:11 -04:00
Tonya
f66624774e Change Item Card to use object contain by default for images (#1020)
* feat: add legacy image fit preference and adjustable image display in card component

* feat: add blurred bg image when object contain

* fix: add alt text for image and improve objectContain
2025-09-24 16:09:15 +01:00
Guy Taggar
33ec0c4aff Fix typo (#1019)
* Fix typo

* Change to plural
2025-09-24 09:48:44 -04:00
Tonya
6cd9e2779f Use Tanstack table for Selectable Table, quick actions (#998)
* feat: implement example of data table

* feat: load item data into table

* chore: begin switching dialogs

* feat: implement old dialog for controlling headers and page size

* feat: get table into relatively usable state

* feat: enhance dropdown actions for multi-selection and CSV download

* feat: enhance table cell and dropdown button styles for better usability

* feat: json download for table

* feat: add expanded row component for item details in data table

* chore: add translation support

* feat: restore table on home page

* fix: oops need ids

* feat: move card view to use tanstack to allow for pagination

* feat: switch the items search to use ItemViewSelectable

* fix: update pagination handling and improve button click logic

* feat: improve selectable table

* feat: add indeterminate to checkbox

* feat: overhaul maintenance dialog to use new system and add maintenance options to table

* feat: add label ids and location id to item patch api

* feat: change location and labels in table view

* feat: add quick actions preference and enable toggle in table settings

* fix: lint

* fix: remove sized 1 pages

* fix: attempt to fix type error

* fix: various issues

* fix: remove

* fix: refactor item fetching logic to use useAsyncData for improved reactivity and improve use confirm

* fix: sort backend issues

* fix: enhance CSV export functionality by escaping fields to prevent formula injection

* fix: put aria sort on th not button

* chore: update api types
2025-09-24 02:37:38 +01:00
Matthew Kilgore
a5d63ac4e1 In theory SLSA provenience for binary builds 2025-09-23 21:05:22 -04:00
Matt
ba45203ea3 beautify the readme a bit (#1014)
* beautify the readme a bit

* Revert CSS updates, Github filters them out

* Enhance README with Lemmy badge and description update
2025-09-23 13:20:50 -04:00
Matt
609b7a606b Generate OpenAPI 3 schemas from the swagger 2.0 generation (#1017)
* Generate OpenAPI 3 schemas from the swagger 2.0 generation

* Update API description URL in index.md
2025-09-23 12:14:37 -04:00
Katos
b56505452f Merge pull request #1006 from sysadminsmedia/update-currencies
Update currencies.json
2025-09-14 15:54:48 +01:00
katosdev
118bce4441 chore: update currencies.json 2025-09-14 14:52:41 +00:00
Katos
b535cdeb96 Refactor update-currencies workflow with enhancements
Update currencies workflow to fix errors and introduce improvements
2025-09-14 15:52:17 +01:00
Katos
3b0e986f01 Refactor update-currencies workflow file 2025-09-13 18:57:10 +01:00
Choong Jun Jin
8f8dbf4a3a Fea: add decimal support to currency system with ISO 4217 data integration (#976)
* feat: add decimal support to currency system with ISO 4217 data integration

* Harden currency formatting: add decimal bounds, input validation, and robust error handling

* Fixed issues raised by coderrabitai

* Fixed linting issue
2025-09-13 11:51:54 -04:00
Matt
85e280105d Remove 32bit builds 2025-09-09 15:50:28 -04:00
Weblate
3183b38114 Translated using Weblate (French)
Currently translated at 100.0% (518 of 518 strings)

Translated using Weblate (French)

Currently translated at 100.0% (518 of 518 strings)

Translated using Weblate (French)

Currently translated at 100.0% (518 of 518 strings)

Translated using Weblate (French)

Currently translated at 100.0% (518 of 518 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (518 of 518 strings)

Translated using Weblate (Romanian)

Currently translated at 60.1% (311 of 517 strings)

Translated using Weblate (Romanian)

Currently translated at 60.1% (311 of 517 strings)

Translated using Weblate (Romanian)

Currently translated at 60.1% (311 of 517 strings)

Translated using Weblate (Romanian)

Currently translated at 60.1% (311 of 517 strings)

Translated using Weblate (Polish)

Currently translated at 97.8% (506 of 517 strings)

Translated using Weblate (Italian)

Currently translated at 82.5% (427 of 517 strings)

Translated using Weblate (Italian)

Currently translated at 82.5% (427 of 517 strings)

Translated using Weblate (German)

Currently translated at 99.8% (516 of 517 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (517 of 517 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (517 of 517 strings)

Translated using Weblate (German)

Currently translated at 98.4% (509 of 517 strings)

Translated using Weblate (Slovak)

Currently translated at 100.0% (517 of 517 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (517 of 517 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (517 of 517 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (506 of 506 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (506 of 506 strings)

Co-authored-by: Adam Havránek <adamhavra@seznam.cz>
Co-authored-by: Erwin van Londen <translate.sysadminsm.treachery437@passmail.net>
Co-authored-by: Hannes Salen <hannes.salen@gmail.com>
Co-authored-by: Jose Riha <jose1711@gmail.com>
Co-authored-by: MyMemory <noreply-mt-mymemory@weblate.org>
Co-authored-by: Philipp Walter <philipp.walter@scodex.de>
Co-authored-by: Saverio Salatino <saverio.salatino@gmail.com>
Co-authored-by: Supertriton <tristan.marie@laposte.net>
Co-authored-by: The Frog <frog@blackbox.net>
Co-authored-by: Weblate <noreply-mt-weblate@weblate.org>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: Weblate Translation Memory <noreply-mt-weblate-translation-memory@weblate.org>
Co-authored-by: vizu <bogdan.vizureanu@gmail.com>
Translate-URL: https://translate.sysadminsmedia.com/projects/homebox/frontend/cs/
Translate-URL: https://translate.sysadminsmedia.com/projects/homebox/frontend/de/
Translate-URL: https://translate.sysadminsmedia.com/projects/homebox/frontend/fr/
Translate-URL: https://translate.sysadminsmedia.com/projects/homebox/frontend/it/
Translate-URL: https://translate.sysadminsmedia.com/projects/homebox/frontend/nl/
Translate-URL: https://translate.sysadminsmedia.com/projects/homebox/frontend/pl/
Translate-URL: https://translate.sysadminsmedia.com/projects/homebox/frontend/ro/
Translate-URL: https://translate.sysadminsmedia.com/projects/homebox/frontend/sk/
Translation: Homebox/Frontend
2025-09-08 09:42:46 +00:00
confiks
0408b1c03b Use CatmullRom instead of ApproxBiLinear for thumbnail generation (#964) 2025-09-05 11:19:46 -04:00
Copilot
a2e108eac4 Make attachment storage paths relative in database with cross-platform support (#967) 2025-09-05 11:12:51 -04:00
James Droste
227b81c6af Set default postgres sql_mode to require (#986)
fixes #985. libpq does not support the current default (prefer). This
sets the default sql_mode to match libpq's default which is require
2025-09-05 08:39:11 -04:00
Choong Jun Jin
3ef25d6463 Fix: add focus-triggered preloading to ItemSelector (#980)
* fix: add focus-triggered preloading to ItemSelector with proper error handling and complete localization

* Removed machine translated files

---------

Co-authored-by: Tonya <tonya@tokia.dev>
2025-09-04 16:29:34 +01:00
Tonya
d4e28e6f3b Upgrade frontend deps, including nuxt (#982)
* feat: begin upgrading deps, still very buggy

* feat: progress

* feat: sort all type issues

* fix: sort type issues

* fix: import sonner styles

* fix: nuxt is the enemy

* fix: try sorting issue with workflows

* fix: update vitest config for dynamic import of path and defineConfig

* fix: add missing import

* fix: add time out to try and fix issues

* fix: add ui:ci:preview task for frontend build in CI mode

* fix: i was silly

* feat: add go:ci:with-frontend task for CI mode and remove ui:ci:preview from e2e workflow

* fix: update baseURL in Playwright config for local testing to use port 7745

* fix: update E2E_BASE_URL and remove wait for timeout in login test for smoother execution
2025-09-04 09:00:25 +01:00
98 changed files with 18232 additions and 1013 deletions

1
.github/FUNDING.yml vendored
View File

@@ -1 +1,2 @@
open_collective: homebox
github: [tankerkiller125,katosdev,tonyaellie]

View File

@@ -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:

View File

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 53 KiB

View File

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 81 KiB

View File

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 87 KiB

View File

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 83 KiB

View File

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 95 KiB

View File

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

View File

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 108 KiB

View File

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 85 KiB

View File

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 86 KiB

View File

@@ -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/)

View File

@@ -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

View File

@@ -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"

View File

@@ -33,7 +33,7 @@ env:
jobs:
build:
runs-on: ubuntu-latest
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
@@ -43,10 +43,11 @@ jobs:
strategy:
fail-fast: false
matrix:
platform:
- linux/amd64
- linux/arm64
- linux/arm/v7
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
steps:
- name: Enable Debug Logs

View File

@@ -37,7 +37,7 @@ env:
jobs:
build:
runs-on: ubuntu-latest
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
@@ -47,10 +47,11 @@ jobs:
strategy:
fail-fast: false
matrix:
platform:
- linux/amd64
- linux/arm64
- linux/arm/v7
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
steps:
- name: Enable Debug Logs

View File

@@ -37,7 +37,7 @@ permissions:
jobs:
build:
runs-on: ubuntu-latest
runs-on: ${{ matrix.runner }}
permissions:
contents: read # Allows access to repository contents (read-only)
packages: write # Allows pushing to GHCR
@@ -47,10 +47,11 @@ jobs:
strategy:
fail-fast: false
matrix:
platform:
- linux/amd64
- linux/arm64
- linux/arm/v7
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
steps:
- name: Checkout repository

View File

@@ -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'

View File

@@ -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
![Login Screen](.github/screenshots/1.png)
![Dashboard](.github/screenshots/2.png)
![Item View](.github/screenshots/3.png)
![Create Item](.github/screenshots/9.png)
![Search](.github/screenshots/8.png)
# 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/).
[![Translation status](http://translate.sysadminsmedia.com/widget/homebox/multi-auto.svg)](http://translate.sysadminsmedia.com/engage/homebox/)
[![Translation status](https://translate.sysadminsmedia.com/widget/homebox/multi-auto.svg)](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>

View File

@@ -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/**"

View File

@@ -17,24 +17,18 @@ builds:
- freebsd
goarch:
- amd64
- "386"
- arm
- arm64
- riscv64
ignore:
- goos: windows
goarch: arm
- goos: windows
goarch: "386"
- goos: freebsd
goarch: arm
- goos: freebsd
goarch: "386"
flags:
- -trimpath
ldflags:
- -s -w
- -X main.version={{.Version}}
- -X main.commit={{.Commit}}
- -X main.date={{.Date}}
tags:
- >-
{{- if eq .Arch "riscv64" }}nodynamic
{{- else if eq .Arch "arm" }}nodynamic
{{- else if eq .Arch "386" }}nodynamic
{{- else if eq .Os "freebsd" }}nodynamic
{{ end }}
@@ -56,7 +50,6 @@ archives:
{{ .ProjectName }}_
{{- title .Os }}_
{{- if eq .Arch "amd64" }}x86_64
{{- else if eq .Arch "386" }}i386
{{- else }}{{ .Arch }}{{ end }}
{{- if .Arm }}v{{ .Arm }}{{ end }}
# use zip for windows archives

View File

@@ -186,7 +186,7 @@ func (ctrl *V1Controller) handleItemAttachmentsHandler(w http.ResponseWriter, r
log.Err(err).Msg("failed to open bucket")
return validate.NewRequestError(err, http.StatusInternalServerError)
}
file, err := bucket.NewReader(ctx, doc.Path, nil)
file, err := bucket.NewReader(ctx, ctrl.repo.Attachments.GetFullPath(doc.Path), nil)
if err != nil {
log.Err(err).Msg("failed to open file")
return validate.NewRequestError(err, http.StatusInternalServerError)

View File

@@ -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,

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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,

View File

@@ -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

View File

@@ -337,7 +337,6 @@ github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/olahol/melody v1.3.0 h1:n7UlKiQnxVrgxKoM0d7usZiN+Z0y2lVENtYLgKtXS6s=
github.com/olahol/melody v1.3.0/go.mod h1:GgkTl6Y7yWj/HtfD48Q5vLKPVoZOH+Qqgfa7CvJgJM4=
github.com/onsi/ginkgo/v2 v2.9.2 h1:BA2GMJOtfGAfagzYtrAlufIP0lq6QERkFmHLMLPwFSU=

View File

@@ -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{

File diff suppressed because it is too large Load Diff

View File

@@ -52,8 +52,8 @@ func TestItemService_AddAttachment(t *testing.T) {
// Check that the file exists
storedPath := afterAttachment.Attachments[0].Path
// {root}/{group}/{item}/{attachment}
assert.Equal(t, path.Join("/", tGroup.ID.String(), "documents"), path.Dir(storedPath))
// path should now be relative: {group}/{documents}
assert.Equal(t, path.Join(tGroup.ID.String(), "documents"), path.Dir(storedPath))
// Check that the file contents are correct
bts, err := os.ReadFile(path.Join(os.TempDir(), storedPath))

View File

@@ -0,0 +1,25 @@
-- +goose Up
-- Make attachment paths relative by removing the prefix path
-- This migration converts absolute paths to relative paths by finding the UUID/documents pattern
-- Update Unix-style paths that contain "/documents/" by extracting the part starting from the UUID
-- The approach: find the "/documents/" substring, go back 37 characters (UUID + slash),
-- and extract from there to get "uuid/documents/hash"
UPDATE attachments
SET path = SUBSTRING(path FROM POSITION('/documents/' IN path) - 36)
WHERE path LIKE '%/documents/%'
AND POSITION('/documents/' IN path) > 36;
-- Update Windows-style paths that contain "\documents\" by extracting the part starting from the UUID
-- Convert backslashes to forward slashes in the process for consistency
UPDATE attachments
SET path = REPLACE(SUBSTRING(path FROM POSITION('\documents\' IN path) - 36), '\', '/')
WHERE path LIKE '%\documents\%'
AND POSITION('\documents\' IN path) > 36;
-- For paths that already look like relative paths (start with UUID), leave them unchanged
-- This handles cases where the migration might be run multiple times
-- +goose Down
-- Note: This down migration cannot be safely implemented because we don't know
-- what the original prefix paths were. This is a one-way migration.

View File

@@ -0,0 +1,25 @@
-- +goose Up
-- Make attachment paths relative by removing the prefix path
-- This migration converts absolute paths to relative paths by finding the UUID/documents pattern
-- Update Unix-style paths that contain "/documents/" by extracting the part starting from the UUID
-- The approach: find the "/documents/" substring, go back 37 characters (UUID + slash),
-- and extract from there to get "uuid/documents/hash"
UPDATE attachments
SET path = SUBSTR(path, INSTR(path, '/documents/') - 36)
WHERE path LIKE '%/documents/%'
AND INSTR(path, '/documents/') > 36;
-- Update Windows-style paths that contain "\documents\" by extracting the part starting from the UUID
-- Convert backslashes to forward slashes in the process for consistency
UPDATE attachments
SET path = REPLACE(SUBSTR(path, INSTR(path, '\documents\') - 36), '\', '/')
WHERE path LIKE '%\documents\%'
AND INSTR(path, '\documents\') > 36;
-- For paths that already look like relative paths (start with UUID), leave them unchanged
-- This handles cases where the migration might be run multiple times
-- +goose Down
-- Note: This down migration cannot be safely implemented because we don't know
-- what the original prefix paths were. This is a one-way migration.

View File

@@ -98,7 +98,15 @@ func ToItemAttachment(attachment *ent.Attachment) ItemAttachment {
}
func (r *AttachmentRepo) path(gid uuid.UUID, hash string) string {
return filepath.Join(r.storage.PrefixPath, gid.String(), "documents", hash)
return filepath.Join(gid.String(), "documents", hash)
}
func (r *AttachmentRepo) fullPath(relativePath string) string {
return filepath.Join(r.storage.PrefixPath, relativePath)
}
func (r *AttachmentRepo) GetFullPath(relativePath string) string {
return r.fullPath(relativePath)
}
func (r *AttachmentRepo) GetConnString() string {
@@ -385,7 +393,7 @@ func (r *AttachmentRepo) Delete(ctx context.Context, gid uuid.UUID, itemId uuid.
log.Err(err).Msg("failed to open bucket for thumbnail deletion")
return err
}
err = thumbBucket.Delete(ctx, thumb.Path)
err = thumbBucket.Delete(ctx, r.fullPath(thumb.Path))
if err != nil {
return err
}
@@ -407,7 +415,7 @@ func (r *AttachmentRepo) Delete(ctx context.Context, gid uuid.UUID, itemId uuid.
log.Err(err).Msg("failed to close bucket")
}
}(bucket)
err = bucket.Delete(ctx, doc.Path)
err = bucket.Delete(ctx, r.fullPath(doc.Path))
if err != nil {
return err
}
@@ -475,7 +483,7 @@ func (r *AttachmentRepo) CreateThumbnail(ctx context.Context, groupId, attachmen
}
}(bucket)
origFile, err := bucket.Open(path)
origFile, err := bucket.Open(r.fullPath(path))
if err != nil {
err := tx.Rollback()
if err != nil {
@@ -785,14 +793,15 @@ func (r *AttachmentRepo) UploadFile(ctx context.Context, itemGroup *ent.Group, d
ContentType: contentType,
ContentMD5: md5hash.Sum(nil),
}
path := r.path(itemGroup.ID, fmt.Sprintf("%x", hashOut))
err = bucket.WriteAll(ctx, path, contentBytes, options)
relativePath := r.path(itemGroup.ID, fmt.Sprintf("%x", hashOut))
fullPath := r.fullPath(relativePath)
err = bucket.WriteAll(ctx, fullPath, contentBytes, options)
if err != nil {
log.Err(err).Msg("failed to write file to bucket")
return "", err
}
return path, nil
return relativePath, nil
}
func isImageFile(mimetype string) bool {
@@ -842,7 +851,7 @@ func (r *AttachmentRepo) processThumbnailFromImage(ctx context.Context, groupId
}
newWidth, newHeight := calculateThumbnailDimensions(bounds.Dx(), bounds.Dy(), r.thumbnail.Width, r.thumbnail.Height)
dst := image.NewRGBA(image.Rect(0, 0, newWidth, newHeight))
draw.ApproxBiLinear.Scale(dst, dst.Rect, img, img.Bounds(), draw.Over, nil)
draw.CatmullRom.Scale(dst, dst.Rect, img, img.Bounds(), draw.Over, nil)
buf := new(bytes.Buffer)
err := webp.Encode(buf, dst, webp.Options{Quality: 80, Lossless: false})

View File

@@ -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) {

View File

@@ -17,7 +17,7 @@ type Database struct {
Host string `yaml:"host"`
Port string `yaml:"port"`
Database string `yaml:"database"`
SslMode string `yaml:"ssl_mode" conf:"default:prefer"`
SslMode string `yaml:"ssl_mode" conf:"default:require"`
SslRootCert string `yaml:"ssl_rootcert"`
SslCert string `yaml:"ssl_cert"`
SslKey string `yaml:"ssl_key"`

View File

@@ -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

File diff suppressed because it is too large Load Diff

2898
docs/en/api/openapi-3.0.yaml Normal file

File diff suppressed because it is too large Load Diff

View 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,

View File

@@ -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

View File

@@ -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 |

View File

@@ -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)

View File

@@ -3,6 +3,7 @@
<CardTitle class="flex items-center">
<slot />
</CardTitle>
<slot name="subtitle" />
<CardDescription v-if="$slots.description">
<slot name="description" />
</CardDescription>

View File

@@ -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) {

View File

@@ -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
);

View File

@@ -39,6 +39,8 @@
:items="results"
item-text="name"
no-results-text="Type to search..."
:is-loading="isLoading"
:trigger-search="triggerSearch"
/>
<FormTextField
ref="nameInput"
@@ -219,7 +221,7 @@
const router = useRouter();
const parent = ref();
const { query, results } = useItemSearch(api, { immediate: false });
const { query, results, isLoading, triggerSearch } = useItemSearch(api, { immediate: false });
const subItemCreateParam = useRouteQuery("subItemCreate", "n");
const subItemCreate = ref();

View File

@@ -18,7 +18,13 @@
<Command :ignore-filter="true">
<CommandInput v-model="search" :placeholder="localizedSearchPlaceholder" :display-value="_ => ''" />
<CommandEmpty>
{{ localizedNoResultsText }}
<div v-if="isLoading" class="flex items-center justify-center p-4">
<div class="size-4 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
<span class="ml-2">{{ t("components.item.selector.searching") }}</span>
</div>
<div v-else>
{{ localizedNoResultsText }}
</div>
</CommandEmpty>
<CommandList>
<CommandGroup>
@@ -65,6 +71,8 @@
noResultsText?: string;
placeholder?: string;
excludeItems?: ItemsObject[];
isLoading?: boolean;
triggerSearch?: () => Promise<boolean>;
}
const emit = defineEmits(["update:modelValue", "update:search"]);
@@ -79,12 +87,15 @@
noResultsText: undefined,
placeholder: undefined,
excludeItems: undefined,
isLoading: false,
triggerSearch: undefined,
});
const id = useId();
const open = ref(false);
const search = ref(props.search);
const value = useVModel(props, "modelValue", emit);
const hasInitialSearch = ref(false);
const localizedSearchPlaceholder = computed(
() => props.searchPlaceholder ?? t("components.item.selector.search_placeholder")
@@ -92,6 +103,32 @@
const localizedNoResultsText = computed(() => props.noResultsText ?? t("components.item.selector.no_results"));
const localizedPlaceholder = computed(() => props.placeholder ?? t("components.item.selector.placeholder"));
// Trigger search when popover opens for the first time if no results exist
async function handlePopoverOpen() {
if (hasInitialSearch.value || props.items.length !== 0 || !props.triggerSearch) return;
try {
const success = await props.triggerSearch();
if (success) {
// Only mark as attempted after successful completion
hasInitialSearch.value = true;
}
// If not successful, leave hasInitialSearch false to allow retries
} catch (err) {
console.error("triggerSearch failed:", err);
// Leave hasInitialSearch false to allow retries on subsequent opens
}
}
watch(
() => open.value,
isOpen => {
if (isOpen) {
handlePopoverOpen();
}
}
);
watch(
() => props.search,
val => {
@@ -107,7 +144,7 @@
);
function isStrings(arr: string[] | ItemsObject[]): arr is string[] {
return typeof arr[0] === "string";
return arr.length > 0 && typeof arr[0] === "string";
}
function displayValue(item: string | ItemsObject | null | undefined): string {

View 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>

View File

@@ -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>

View File

@@ -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>;

View File

@@ -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>

View 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;
}>;

View 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>

View 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,
})
);
},
},
];
}

View 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>

View 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>

View File

@@ -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>

View 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>

View 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>

View File

@@ -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);

View File

@@ -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>

View File

@@ -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>

View File

@@ -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(

View File

@@ -147,6 +147,4 @@
overflow-wrap: break-word;
}
}
</style>

View File

@@ -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>

View File

@@ -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 */

View File

@@ -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>

View File

@@ -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();
}

View File

@@ -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());
}

View File

@@ -11,26 +11,74 @@ export function useItemSearch(client: UserClient, opts?: SearchOptions) {
const labels = ref<LabelSummary[]>([]);
const results = ref<ItemSummary[]>([]);
const includeArchived = ref(false);
const isLoading = ref(false);
const pendingQuery = ref<string | null>(null);
watchDebounced(query, search, { debounce: 250, maxWait: 1000 });
async function search() {
const locIds = locations.value.map(l => l.id);
const labelIds = labels.value.map(l => l.id);
const { data, error } = await client.items.getAll({
q: query.value,
locations: locIds,
labels: labelIds,
includeArchived: includeArchived.value,
});
if (error) {
return;
async function search(): Promise<boolean> {
if (isLoading.value) {
// Store the latest query to run after current search completes
pendingQuery.value = query.value;
return false;
}
const searchQuery = query.value;
isLoading.value = true;
try {
const locIds = locations.value.map(l => l.id);
const labelIds = labels.value.map(l => l.id);
const { data, error } = await client.items.getAll({
q: searchQuery,
locations: locIds,
labels: labelIds,
includeArchived: includeArchived.value,
});
if (error || !data) {
console.error("useItemSearch.search error:", error);
return false;
}
results.value = data.items ?? [];
return true;
} finally {
isLoading.value = false;
// If user changed query while we were searching, run again with the latest query
if (pendingQuery.value !== null && pendingQuery.value !== searchQuery) {
const nextQuery = pendingQuery.value;
pendingQuery.value = null;
// Use nextTick to avoid potential recursion issues
await nextTick();
if (query.value === nextQuery) {
await search();
}
} else {
pendingQuery.value = null;
}
}
}
async function triggerSearch(): Promise<boolean> {
try {
return await search();
} catch (err) {
console.error("triggerSearch error:", err);
return false;
}
results.value = data.items;
}
if (opts?.immediate) {
search();
search()
.then(success => {
if (!success) {
console.error("Initial search failed");
}
})
.catch(err => {
console.error("Initial search error:", err);
});
}
return {
@@ -38,5 +86,7 @@ export function useItemSearch(client: UserClient, opts?: SearchOptions) {
results,
locations,
labels,
isLoading,
triggerSearch,
};
}

View File

@@ -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 }
);

View File

@@ -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;

View File

@@ -53,6 +53,7 @@ export default withNuxt([
},
],
"@typescript-eslint/no-invalid-void-type": "off",
"@typescript-eslint/unified-signatures": "off",
"prettier/prettier": [
"warn",

View File

@@ -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" />

View File

@@ -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;
}

View File

@@ -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()}`);

View File

@@ -291,6 +291,18 @@
"description": "Popis",
"details": "Detaily",
"drag_and_drop": "Přetáhněte sem soubory nebo klikněte pro výběr",
"duplicate": {
"copy_attachments": "Kopírovat přílohy",
"copy_custom_fields": "Kopírovat vlastní pole",
"copy_maintenance": "Kopírovat údržbu",
"custom_prefix": "Kopírovat předponu",
"enable_custom_prefix": "Povolit vlastní předponu",
"override_instructions": "Podržením klávesy Shift při kliknutí na tlačítko duplikovat přepíšete tato nastavení.",
"prefix": "Kopie ",
"prefix_instructions": "Tato předpona bude přidána na začátek názvu duplikované položky. Na konec předpony vložte mezeru, aby mezi předponou a názvem položky byla mezera.",
"temporary_title": "Dočasné nastavení",
"title": "Duplikovat nastavení"
},
"edit": {
"edit_attachment_dialog": {
"attachment_title": "Název přílohy",
@@ -299,7 +311,8 @@
"primary_photo_sub": "Tato možnost je k dispozici pouze pro fotky. Primární může být pouze jedna fotka. Pokud vyberete tuto možnost, aktuální primární fotka, pokud existuje, bude odškrtnuta.",
"select_type": "Zvolte typ",
"title": "Úprava přílohy"
}
},
"view_image": "Zobrazit obrázek"
},
"edit_details": "Upravit detaily",
"field_selector": "Výběr pole",
@@ -397,6 +410,7 @@
"update_label": "Aktualizovat štítek"
},
"languages": {
"bs-BA": "Bosňština (Bosna a Hercegovina)",
"ca": "Katalánština",
"cs-CZ": "Čeština",
"de": "Němčina",
@@ -423,6 +437,7 @@
"th-TH": "Thajština",
"tr": "Turečtina",
"uk-UA": "Ukrajinština",
"vi-VN": "Vietnamština",
"zh-CN": "Čínština (Zjednodušená)",
"zh-HK": "Čínština (Hong Kong)",
"zh-MO": "Čínština (Macau)",

View File

@@ -291,15 +291,28 @@
"description": "Beschreibung",
"details": "Details",
"drag_and_drop": "Ziehen Sie Dateien hierher und legen Sie sie dort ab oder klicken Sie, um Dateien auszuwählen",
"duplicate": {
"copy_attachments": "Anhänge kopieren",
"copy_custom_fields": "Benutzerdefinierte Felder kopieren",
"copy_maintenance": "Kopiere Wartung",
"custom_prefix": "Präfix kopieren",
"enable_custom_prefix": "Benutzerdefinierten Präfix aktivieren",
"override_instructions": "Die Umschalt-Taste während des Drückens des Duplizieren-Knopfes halten, um die Einstellungen außer Kraft zu setzen.",
"prefix": "Kopie von ",
"prefix_instructions": "Dieser Präfix wird vor den Beginn des Namens des duplizierten Artikels gestellt. Verwenden Sie ein Leerzeichen am Ende des Präfix, um einen Platz zwischen Präfix und Artikelnamen zu lassen.",
"temporary_title": "Temporäre Einstellungen",
"title": "Einstellungen duplizieren"
},
"edit": {
"edit_attachment_dialog": {
"attachment_title": "Anhang Überschrift",
"attachment_title": "Anhangberschrift",
"attachment_type": "Anhangstyp",
"primary_photo": "Primäres Bild",
"primary_photo_sub": "Diese Option ist nur für Bilder verfügbar. Nur ein Bild kann das primäre Bild sein. Wenn Sie diese Option auswählen, wird das aktuelle primär Bild, wenn eins ausgewählt ist, abgewählt.",
"select_type": "Wählen Sie einen Typ aus",
"title": "Anhang bearbeiten"
}
},
"view_image": "Bild anzeigen"
},
"edit_details": "Details bearbeiten",
"field_selector": "Feldauswahl",

View File

@@ -134,21 +134,57 @@
"selector": {
"no_results": "No Results Found",
"placeholder": "Select…",
"search_placeholder": "Type to search…"
"search_placeholder": "Type to search…",
"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"
}
}
}
},
@@ -519,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",

View File

@@ -25,7 +25,9 @@
}
},
"color_selector": {
"clear": "Effacer Couleur",
"color": "Couleur",
"no_color": "Aucune couleur",
"no_color_selected": "Aucune couleur sélectionnée",
"randomize": "Couleur aléatoire"
},
@@ -98,6 +100,8 @@
"item_photo": "Photo de l'article 📷",
"item_quantity": "Quantité d'articles",
"parent_item": "Article parent",
"product_tooltip_input_barcode": "Remplir avec un code-barre entré manuellement",
"product_tooltip_scan_barcode": "Remplir avec un barre-code d'une 📷",
"rotate_photo": "Faire pivoter la photo",
"set_as_primary_photo": "Définir comme photo { isPrimary, select, true {non-} false {} other {}}principale",
"title": "Créer un article",
@@ -118,10 +122,20 @@
"upload_photos": "Transférer des photos",
"uploaded": "Photo téléversée"
},
"product_import": {
"barcode": "Code-barre produit",
"db_source": "Base de données source",
"error_exception": "Une erreur s'est produite lors de la récupération du code-barre ",
"error_invalid_barcode": "Le code-barre fourni est invalide",
"error_not_found": "Aucun produit trouvé pour le barre-code fourni.",
"search_item": "Rechercher un produit",
"title": "Importer un produit"
},
"selector": {
"no_results": "Aucun résultat trouvé",
"placeholder": "Sélectionner…",
"search_placeholder": "Tapez pour rechercher…"
"search_placeholder": "Tapez pour rechercher…",
"searching": "Recherche en cours…"
},
"view": {
"selectable": {
@@ -141,6 +155,7 @@
},
"label": {
"create_modal": {
"label_color": "Couleur de l'étiquette",
"label_description": "Description de l'étiquette",
"label_name": "Nom de l'étiquette",
"title": "Créer une étiquette",
@@ -181,6 +196,9 @@
"shortcut_hint": "Utilisez les touches numériques pour sélectionner rapidement une action."
}
},
"errors": {
"api_failure": "L'appel vers l'API du back-end a échoué "
},
"global": {
"add": "Ajouter",
"archived": "Archivé",
@@ -274,6 +292,18 @@
"description": "Description",
"details": "Détails",
"drag_and_drop": "Glissez-déposez les fichiers ici ou cliquez pour choisir des fichiers",
"duplicate": {
"copy_attachments": "Copier les fichiers joints",
"copy_custom_fields": "Copier les champs personnaliisés",
"copy_maintenance": "Copie de la maintenance",
"custom_prefix": "Copier le préfixe",
"enable_custom_prefix": "Activer le préfixe personnalisé",
"override_instructions": "Maintenez la touche Maj enfoncée lorsque vous cliquez sur le bouton Dupliquer pour remplacer ces paramètres.",
"prefix": "Copie de ",
"prefix_instructions": "Ce préfixe sera ajouté au début du nom de l'élément dupliqué. Insérez un espace à la fin afin de séparer le préfixe du nom de l'élément.",
"temporary_title": "Paramètres temporaires",
"title": "Paramètres de duplication"
},
"edit": {
"edit_attachment_dialog": {
"attachment_title": "Titre de la pièce jointe",
@@ -282,7 +312,8 @@
"primary_photo_sub": "Cette option n'est disponible que pour les photos. Seule une photo peut être principale. Si vous sélectionnez cette option, la photo principale, s'il y en a une, sera désélectionnée.",
"select_type": "Sélectionner un type",
"title": "Modification de pièce jointe"
}
},
"view_image": "Voir l'image"
},
"edit_details": "Éditez les détails",
"field_selector": "Sélecteur de champ",
@@ -380,6 +411,7 @@
"update_label": "Mettre à jour l'étiquette"
},
"languages": {
"bs-BA": "Bosnie (Bosnie-Herzégovine)",
"ca": "Catalan",
"cs-CZ": "Tchèque",
"de": "Allemand",
@@ -557,6 +589,8 @@
}
},
"scanner": {
"barcode_detected_message": "Le code-barre du produit a été détecté",
"barcode_fetch_data": "Récupérer les données du produit",
"error": "Une erreur s'est produite lors du scan",
"invalid_url": "URL de code-barres non valide",
"no_sources": "Aucune source vidéo disponible",

View File

@@ -100,6 +100,7 @@
"item_photo": "Foto dell'articolo 📷",
"item_quantity": "Quantità Articoli",
"parent_item": "Articolo principale",
"product_tooltip_input_barcode": "Compilazione automatica con un codice a barre fornito manualmente",
"product_tooltip_scan_barcode": "Riempimento automatico con un codice a barre da 📷",
"rotate_photo": "Ruota foto",
"set_as_primary_photo": "Imposta come { isPrimary, select, true {non} false {} other {}} foto principale",
@@ -125,6 +126,7 @@
"barcode": "Codice a barre del prodotto",
"error_exception": "Si è verificato un errore durante il recupero del codice a barre dell'articolo: ",
"error_invalid_barcode": "Il codice a barre fornito non è valido",
"error_not_found": "Nessun prodotto trovato con il codice a barre fornito.",
"search_item": "Cerca prodotto",
"title": "Importa prodotto"
},
@@ -151,6 +153,7 @@
},
"label": {
"create_modal": {
"label_color": "Colore Etichetta",
"label_description": "Descrizione Etichetta",
"label_name": "Nome Etichetta",
"title": "Crea Etichetta",
@@ -191,6 +194,9 @@
"shortcut_hint": "Utilizzare i tasti numerici per selezionare rapidamente un'azione."
}
},
"errors": {
"api_failure": "Chiamata API backend non riuscita: "
},
"global": {
"add": "Aggiungi",
"archived": "Archiviato",
@@ -284,6 +290,14 @@
"description": "Descrizione",
"details": "Dettagli",
"drag_and_drop": "Trascina e rilascia i file qui o fai clic per selezionare i file",
"duplicate": {
"copy_attachments": "Copia allegati",
"copy_custom_fields": "Copia Campi Personalizzati",
"custom_prefix": "Copia Prefisso",
"enable_custom_prefix": "Abilita prefisso personalizzato",
"prefix": "Copia di ",
"temporary_title": "Impostazioni temporanee"
},
"edit": {
"edit_attachment_dialog": {
"attachment_title": "Titolo allegato",

View File

@@ -134,7 +134,8 @@
"selector": {
"no_results": "一致するものがありません",
"placeholder": "選択してください…",
"search_placeholder": "入力してください"
"search_placeholder": "入力してください",
"searching": "検索中…"
},
"view": {
"selectable": {

View File

@@ -134,7 +134,8 @@
"selector": {
"no_results": "Geen resultaten gevonden",
"placeholder": "Selecteer…",
"search_placeholder": "Type om te zoeken…"
"search_placeholder": "Type om te zoeken…",
"searching": "Zoeken…"
},
"view": {
"selectable": {
@@ -291,6 +292,18 @@
"description": "Beschrijving",
"details": "Details",
"drag_and_drop": "Sleep bestanden hierheen en zet ze neer of klik om bestanden te selecteren",
"duplicate": {
"copy_attachments": "Bijlagen kopiëren",
"copy_custom_fields": "Aangepaste velden kopiëren",
"copy_maintenance": "Onderhoud kopiëren",
"custom_prefix": "Voorvoegsel kopiëren",
"enable_custom_prefix": "Aangepast voorvoegsel inschakelen",
"override_instructions": "Houd Shift ingedrukt wanneer u op de knop dupliceren klikt om deze instellingen te overschrijven.",
"prefix": "Kopie van ",
"prefix_instructions": "Dit voorvoegsel wordt toegevoegd aan het begin van de naam van het gedupliceerde item. Voeg een spatie toe aan het einde van het voorvoegsel om een spatie toe te voegen tussen het voorvoegsel en de itemnaam.",
"temporary_title": "Tijdelijke instellingen",
"title": "Dupliceer instellingen"
},
"edit": {
"edit_attachment_dialog": {
"attachment_title": "Bijlage titel",
@@ -299,7 +312,8 @@
"primary_photo_sub": "Deze optie is alleen beschikbaar voor foto's. Er kan maar één primaire foto zijn. Als je deze optie selecteerd zal de huidige primaire foto worden gedeselecteerd.",
"select_type": "Selecteer een type",
"title": "Bijlage bewerken"
}
},
"view_image": "Afbeelding bekijken"
},
"edit_details": "Details bewerken",
"field_selector": "Veld selectie",

View File

@@ -291,6 +291,9 @@
"description": "Opis",
"details": "Szczegóły",
"drag_and_drop": "Przeciągnij i upuść pliki tutaj lub kliknij, aby wybrać pliki",
"duplicate": {
"prefix": "Kopia z "
},
"edit": {
"edit_attachment_dialog": {
"attachment_title": "Tytuł załącznika",

View File

@@ -1,10 +1,16 @@
{
"components": {
"app": {
"create_modal": {
"shift": "Shift"
},
"import_dialog": {
"change_warning": "Comportamentul pentru importuri cu import_refs existente s-a schimbat. Dacă un import_ref este prezent în fișierul CSV,\nobiectul v-a fi actualizat cu valorile din fișierul CSV.",
"description": "Importă un fișier CSV care conține toate obiectele, etichetele și locațiile tale. \nCitește documentația pentru mai multe informații privind format-ul necesar.",
"title": "Importă fișier CSV"
"title": "Importă fișier CSV",
"toast": {
"import_success": "Import cu succes!"
}
},
"outdated": {
"current_version": "Versiune Curentă",
@@ -14,12 +20,16 @@
"new_version_available_link": "Dă click aici pentru a vedea notele de lansare"
}
},
"color_selector": {
"color": "Culoare",
"no_color": "Fara culoare"
},
"global": {
"copy_text": {
"documentation": "documentație",
"failed_to_copy": "Nu s-a copiat textul în clipboard",
"https_required": "pentru că HTTPS este necesar",
"learn_more": "Învață mai multe în noastră"
"learn_more": "Învață mai multe în"
},
"date_time": {
"ago": "{0} în urmă",
@@ -45,18 +55,47 @@
"years": "ani",
"yesterday": "ieri"
},
"label_maker": {
"browser_print": "Imprimare din browser",
"confirm_description": "Sigur doriți să imprimați această etichetă?",
"download": "Descărcare etichetă",
"print": "Imprimare etichetă",
"server_print": "Imprimare pe server",
"titles": "Etichete",
"toast": {
"print_failed": "Imprimarea etichetei a eșuat",
"print_success": "Etichetă imprimată"
}
},
"page_qr_code": {
"page_url": "URL Pagină"
"page_url": "URL Pagină",
"qr_tooltip": "Afișați codul QR"
},
"password_score": {
"password_strength": "Complexitate Parolă"
}
},
"item": {
"attachments_list": {
"download": "Descărcare",
"open_new_tab": "Deschideți într-o filă nouă"
},
"create_modal": {
"delete_photo": "Ştergere fotografie",
"item_description": "Descriere articol",
"item_name": "Denumire articol",
"title": "Crează articol"
"item_photo": "Fotografie de articol 📷",
"item_quantity": "Cantitate Articol",
"parent_item": "Articol Părinte",
"product_tooltip_input_barcode": "Completare automată cu un cod de bare furnizat manual",
"product_tooltip_scan_barcode": "Completare automată cu un cod de bare de la 📷",
"rotate_photo": "Rotește fotografia",
"set_as_primary_photo": "Setați ca { isPrimary, select, true {non-} false {} other {}} fotografie principală",
"title": "Crează articol",
"toast": {
"create_failed": "Articolul nu a putut fi creat",
"create_success": "Articol creat"
}
},
"view": {
"selectable": {
@@ -88,7 +127,7 @@
"parent_location": "Locație Părinte"
},
"tree": {
"no_locations": "Nu există locații disponibile. Adaugă o locație nouă folosind butonul\n`<`span class=\"link-primary\"`>`Crează`<`/span`>` din bara de navigație."
"no_locations": "Nu există locații disponibile. Adaugă o locație nouă folosind butonul\n<span class=\"link-primary\">'Crează'</span> din bara de navigație."
}
}
},
@@ -105,7 +144,12 @@
"edit": "Redactare",
"email": "Adresă de email",
"follow_dev": "Urmărește developer-ul",
"footer": {
"api_link": "'<a href=\"https://homebox.software/en/api/\" target=\"_blank\">'API'</a>'",
"version_link": "'<'a href=\"https://github.com/sysadminsmedia/homebox/releases/tag/{ version }\" target=\"_blank\"'>' Versiune: { version } Build: { build } '</a>'"
},
"github": "Proiect GitHub",
"insured": "Asigurat",
"items": "Articole",
"join_discord": "Vino pe Discord",
"labels": "Etichete",
@@ -158,6 +202,9 @@
"description": "Descriere",
"details": "Detalii",
"drag_and_drop": "Trageți și plasați fișierele aici sau faceți clic pentru a selecta fișierele",
"edit": {
"view_image": "Vizualizează Imagine"
},
"edit_details": "Editare detalii",
"field_selector": "Selector Câmp",
"field_value": "Valoare Câmp",
@@ -343,7 +390,7 @@
"export_sub": "Exportă formatul CSV standard pentru Homebox. Această acțiune ca exporta toate articolele din inventar.",
"import": "Importă Inventar",
"import_button": "Importă Inventar",
"import_sub": "Importă formatul standard CSV pentru Homebox. Fără un '<code> ' HB.import_ref' </code> 'coloană, aceasta va'<b>'nu' < / b> ' suprascrie orice elemente existente în inventarul dvs., adăugați doar elemente noi. Rânduri cu' < code> ' HB.import_ref' < / code> ' coloana sunt îmbinate în elementele existente cu același import_ref, dacă există unul."
"import_sub": "Importă formatul standard CSV pentru Homebox. Fără un '<code>' HB.import_ref '</code>' coloană, aceasta va'<b>'nu'</b>' suprascrie orice elemente existente în inventarul dvs., adăugați doar elemente noi. Rânduri cu'<code>'HB.import_ref'</code>' coloana sunt îmbinate în elementele existente cu același import_ref, dacă există unul."
},
"import_export_sub": "Importați și exportați inventarul într-un fișier CSV. Acest lucru este util atunci când se migrează inventarul către o instanță nouă de Homebox.",
"reports": "Rapoarte",

View File

@@ -100,6 +100,8 @@
"item_photo": "Fotografia predmetu 📷",
"item_quantity": "Počet predmetov",
"parent_item": "Nadradená položka",
"product_tooltip_input_barcode": "Automaticky vyplniť ručne zadaným čiarovým kódom",
"product_tooltip_scan_barcode": "Automaticky vyplniť čiarovým kódom z 📷",
"rotate_photo": "Otočiť fotografiu",
"set_as_primary_photo": "Nastaviť ako { isPrimary, select, true {non-} false {} other {}}primárnu fotografiu",
"title": "Vytvoriť položku",
@@ -120,6 +122,15 @@
"upload_photos": "Nahrať fotografie",
"uploaded": "Nahraná fotografia"
},
"product_import": {
"barcode": "Čiarový kód produktu",
"db_source": "Zdroj DB",
"error_exception": "Pri načítaní čiarového kódu došlo k výnimke: ",
"error_invalid_barcode": "Zadali ste neplatný čiarový kód",
"error_not_found": "Nenašiel sa žiaden produkt s uvedeným čiarovým kódom.",
"search_item": "Vyhľadať produkt",
"title": "Importovať produkt"
},
"selector": {
"no_results": "Nenašli sa žiadne výsledky",
"placeholder": "Vybrať…",
@@ -184,6 +195,9 @@
"shortcut_hint": "Pomocou číselných tlačidiel rýchlo vyberte akciu."
}
},
"errors": {
"api_failure": "Volanie backendového API skončilo s chybou: "
},
"global": {
"add": "Pridať",
"archived": "Archivované",
@@ -277,6 +291,18 @@
"description": "Popis",
"details": "Podrobnosti",
"drag_and_drop": "Presuňte súbory sem alebo kliknutím vyberte súbory",
"duplicate": {
"copy_attachments": "Kopírovať prílohy",
"copy_custom_fields": "Kopírovať vlastné polia",
"copy_maintenance": "Kopírovať údržbu",
"custom_prefix": "Kopírovať predponu",
"enable_custom_prefix": "Povoliť vlastnú predponu",
"override_instructions": "Ak pri kliknutí na tlačidlo duplikovať stlačíte kláves Shift, toto nastavenie prepíšete.",
"prefix": "Kópia ",
"prefix_instructions": "Táto predpona bude pridaná na začiatok názvu duplikovanej položky. Na koniec predpony vložte medzeru, aby medzi predponou a názvom položky bola medzera.",
"temporary_title": "Dočasné nastavenia",
"title": "Duplikovať nastavenia"
},
"edit": {
"edit_attachment_dialog": {
"attachment_title": "Názov prílohy",
@@ -285,7 +311,8 @@
"primary_photo_sub": "Táto možnosť je dostupná len pre fotografie. Primárna môže byť iba jedna fotografia. Ak vyberiete túto voľbu, aktuálna primárna fotografia (ak taká existuje) bude zrušená.",
"select_type": "Vyberte typ",
"title": "Upraviť prílohu"
}
},
"view_image": "Zobraziť obrázok"
},
"edit_details": "Upraviť podrobnosti",
"field_selector": "Výber poľa",
@@ -383,6 +410,7 @@
"update_label": "Aktualizovať štítok"
},
"languages": {
"bs-BA": "Bosniačtina (Bosna a Hercegovina)",
"ca": "Katalánsky",
"cs-CZ": "Čeština",
"de": "Nemecky",
@@ -410,6 +438,7 @@
"th-TH": "Thaičina",
"tr": "Turečtina",
"uk-UA": "Ukrajinsky",
"vi-VN": "Vietnamčina",
"zh-CN": "Čínština (zjednodušená)",
"zh-HK": "Čínsky (Hong Kong) Name",
"zh-MO": "Čínština (Macao)",
@@ -559,6 +588,8 @@
}
},
"scanner": {
"barcode_detected_message": "bol zistený čiarový kód produktu",
"barcode_fetch_data": "Načítať údaje produktu",
"error": "Počas skenovania sa vyskytla chyba",
"invalid_url": "Neplatná adresa URL čiarového kódu",
"no_sources": "Nie sú k dispozícii žiadne zdroje videa",

View File

@@ -134,7 +134,8 @@
"selector": {
"no_results": "返回为空",
"placeholder": "选择…",
"search_placeholder": "输入以搜索…"
"search_placeholder": "输入以搜索…",
"searching": "搜索中…"
},
"view": {
"selectable": {

View File

@@ -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",

View File

@@ -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" />

View File

@@ -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>

View File

@@ -424,7 +424,7 @@
} as unknown as ItemField);
}
const { query, results } = useItemSearch(api, { immediate: false });
const { query, results, isLoading, triggerSearch } = useItemSearch(api, { immediate: false });
const parent = ref();
async function keyboardSave(e: KeyboardEvent) {
@@ -591,6 +591,8 @@
:label="$t('items.parent_item')"
no-results-text="Type to search..."
:exclude-items="[item]"
:is-loading="isLoading"
:trigger-search="triggerSearch"
@update:model-value="maybeSyncWithParentLocation()"
/>
<div class="flex flex-col gap-2">

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>

View File

@@ -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