Compare commits
52 Commits
mk/db-sett
...
copilot/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c92488e90a | ||
|
|
47f87cbe30 | ||
|
|
cc2acbdaac | ||
|
|
28c3e102a2 | ||
|
|
116e39531b | ||
|
|
8a90b9c133 | ||
|
|
ef52009f57 | ||
|
|
76154263e0 | ||
|
|
108194e7fd | ||
|
|
bf845ae0f7 | ||
|
|
9be6a8c888 | ||
|
|
38a987676e | ||
|
|
1f746efe27 | ||
|
|
d57bf8834b | ||
|
|
cb2c58c3f4 | ||
|
|
7b3cf0453e | ||
|
|
825e72bceb | ||
|
|
8547fb9bb3 | ||
|
|
f66624774e | ||
|
|
33ec0c4aff | ||
|
|
6cd9e2779f | ||
|
|
a5d63ac4e1 | ||
|
|
ba45203ea3 | ||
|
|
609b7a606b | ||
|
|
b56505452f | ||
|
|
118bce4441 | ||
|
|
b535cdeb96 | ||
|
|
3b0e986f01 | ||
|
|
8f8dbf4a3a | ||
|
|
3183b38114 | ||
|
|
0408b1c03b | ||
|
|
a2e108eac4 | ||
|
|
227b81c6af | ||
|
|
3ef25d6463 | ||
|
|
d4e28e6f3b | ||
|
|
790352da34 | ||
|
|
52a6a31098 | ||
|
|
1d02285b0d | ||
|
|
19563d8b38 | ||
|
|
282977e82c | ||
|
|
769d5c5b95 | ||
|
|
b8f7ce7eb2 | ||
|
|
62ed3fabc2 | ||
|
|
304fc7f11f | ||
|
|
1b7a7a1999 | ||
|
|
a63f08ad87 | ||
|
|
9cb1a3f83c | ||
|
|
f86d38412b | ||
|
|
cbbe056d01 | ||
|
|
5f6b1a0805 | ||
|
|
27e9eb2277 | ||
|
|
6fcd10d796 |
1
.github/FUNDING.yml
vendored
@@ -1 +1,2 @@
|
||||
open_collective: homebox
|
||||
github: [tankerkiller125,katosdev,tonyaellie]
|
||||
|
||||
11
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -58,6 +58,17 @@ body:
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: database
|
||||
attributes:
|
||||
label: Database Type
|
||||
description: What database backend are you using?
|
||||
multiple: false
|
||||
options:
|
||||
- SQLite
|
||||
- PostgreSQL
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: arch
|
||||
attributes:
|
||||
|
||||
BIN
.github/screenshots/1.png
vendored
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
.github/screenshots/10.png
vendored
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
.github/screenshots/2.png
vendored
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
.github/screenshots/3.png
vendored
Normal file
|
After Width: | Height: | Size: 87 KiB |
BIN
.github/screenshots/4.png
vendored
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
.github/screenshots/5.png
vendored
Normal file
|
After Width: | Height: | Size: 95 KiB |
BIN
.github/screenshots/6.png
vendored
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
.github/screenshots/7.png
vendored
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
.github/screenshots/8.png
vendored
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
.github/screenshots/9.png
vendored
Normal file
|
After Width: | Height: | Size: 86 KiB |
8
.github/screenshots/readme.md
vendored
Normal file
@@ -0,0 +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)
|
||||
- [VNext](https://vnext.homebox.software/)
|
||||
117
.github/scripts/update_currencies.py
vendored
@@ -1,16 +1,33 @@
|
||||
#!/usr/bin/env python3
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
from requests.adapters import HTTPAdapter, Retry
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
API_URL = 'https://restcountries.com/v3.1/all?fields=name,common,currencies'
|
||||
# Default to a pinned commit for supply-chain security
|
||||
DEFAULT_ISO_4217_URL = 'https://raw.githubusercontent.com/datasets/currency-codes/052b3088938ba32028a14e75040c286c5e142145/data/codes-all.csv'
|
||||
ISO_4217_URL = os.environ.get('ISO_4217_URL', DEFAULT_ISO_4217_URL)
|
||||
SAVE_PATH = Path('backend/internal/core/currencies/currencies.json')
|
||||
TIMEOUT = 10 # seconds
|
||||
|
||||
# Known currency decimal overrides
|
||||
CURRENCY_DECIMAL_OVERRIDES = {
|
||||
"BTC": 8, # Bitcoin uses 8 decimal places
|
||||
"JPY": 0, # Japanese Yen has no decimal places
|
||||
"BHD": 3, # Bahraini Dinar uses 3 decimal places
|
||||
}
|
||||
DEFAULT_DECIMALS = 2
|
||||
MIN_DECIMALS = 0
|
||||
MAX_DECIMALS = 6
|
||||
|
||||
|
||||
def setup_logging():
|
||||
logging.basicConfig(
|
||||
@@ -19,7 +36,93 @@ def setup_logging():
|
||||
)
|
||||
|
||||
|
||||
def get_currency_decimals(code, iso_data):
|
||||
"""
|
||||
Get the decimal places for a currency code.
|
||||
Checks overrides first, then ISO data, then uses default.
|
||||
Clamps result to safe range [MIN_DECIMALS, MAX_DECIMALS].
|
||||
"""
|
||||
# Normalize the input code
|
||||
normalized_code = (code or "").strip().upper()
|
||||
|
||||
# First check overrides
|
||||
if normalized_code in CURRENCY_DECIMAL_OVERRIDES:
|
||||
decimals = CURRENCY_DECIMAL_OVERRIDES[normalized_code]
|
||||
# Then check ISO data
|
||||
elif normalized_code in iso_data:
|
||||
decimals = iso_data[normalized_code]
|
||||
# Finally use default
|
||||
else:
|
||||
decimals = DEFAULT_DECIMALS
|
||||
|
||||
# Ensure it's an integer and clamp to safe range
|
||||
try:
|
||||
decimals = int(decimals)
|
||||
except (ValueError, TypeError):
|
||||
decimals = DEFAULT_DECIMALS
|
||||
|
||||
return max(MIN_DECIMALS, min(MAX_DECIMALS, decimals))
|
||||
|
||||
|
||||
def fetch_iso_4217_data():
|
||||
"""
|
||||
Fetch ISO 4217 currency data to get minor units (decimal places).
|
||||
Returns a dict mapping currency code to minor units.
|
||||
"""
|
||||
# Log the resolved URL for transparency
|
||||
logging.info("Fetching ISO 4217 data from: %s", ISO_4217_URL)
|
||||
if not ISO_4217_URL.lower().startswith("https://"):
|
||||
logging.error("Refusing non-HTTPS ISO_4217_URL: %s", ISO_4217_URL)
|
||||
return {}
|
||||
|
||||
session = requests.Session()
|
||||
retries = Retry(
|
||||
total=3,
|
||||
backoff_factor=1,
|
||||
status_forcelist=[429, 500, 502, 503, 504],
|
||||
allowed_methods=frozenset(['GET'])
|
||||
)
|
||||
session.mount('https://', HTTPAdapter(max_retries=retries))
|
||||
|
||||
try:
|
||||
# Add Accept header for CSV content
|
||||
headers = {'Accept': 'text/csv'}
|
||||
resp = session.get(ISO_4217_URL, timeout=TIMEOUT, headers=headers)
|
||||
resp.raise_for_status()
|
||||
except requests.exceptions.RequestException as e:
|
||||
logging.error("Failed to fetch ISO 4217 data: %s", e)
|
||||
return {}
|
||||
|
||||
# Parse CSV data
|
||||
iso_data = {}
|
||||
try:
|
||||
# Decode with utf-8-sig to strip BOM if present
|
||||
csv_content = resp.content.decode('utf-8-sig')
|
||||
csv_reader = csv.DictReader(io.StringIO(csv_content))
|
||||
|
||||
for row in csv_reader:
|
||||
code = row.get('AlphabeticCode', '').strip()
|
||||
minor_unit = row.get('MinorUnit', '').strip()
|
||||
|
||||
if code and minor_unit != 'N.A.':
|
||||
try:
|
||||
# Convert minor unit to int (decimal places)
|
||||
iso_data[code] = int(minor_unit) if minor_unit.isdigit() else 2
|
||||
except (ValueError, TypeError):
|
||||
iso_data[code] = 2 # Default to 2 if parsing fails
|
||||
|
||||
logging.info("Successfully loaded decimal data for %d currencies from ISO 4217", len(iso_data))
|
||||
return iso_data
|
||||
|
||||
except Exception as e:
|
||||
logging.error("Failed to parse ISO 4217 CSV data: %s", e)
|
||||
return {}
|
||||
|
||||
|
||||
def fetch_currencies():
|
||||
# First, fetch ISO 4217 data for decimal places
|
||||
iso_data = fetch_iso_4217_data()
|
||||
|
||||
session = requests.Session()
|
||||
retries = Retry(
|
||||
total=3,
|
||||
@@ -46,11 +149,15 @@ def fetch_currencies():
|
||||
for country in countries:
|
||||
country_name = country.get('name', {}).get('common') or "Unknown"
|
||||
for code, info in country.get('currencies', {}).items():
|
||||
# Get decimal places using the helper function
|
||||
decimals = get_currency_decimals(code, iso_data)
|
||||
|
||||
results.append({
|
||||
'code': code,
|
||||
'local': country_name,
|
||||
'symbol': info.get('symbol', ''),
|
||||
'name': info.get('name', '')
|
||||
'code': code,
|
||||
'local': country_name,
|
||||
'symbol': info.get('symbol', ''),
|
||||
'name': info.get('name', ''),
|
||||
'decimals': decimals
|
||||
})
|
||||
|
||||
# sort by country name for consistency
|
||||
|
||||
83
.github/workflows/binaries-publish.yaml
vendored
@@ -1,6 +1,7 @@
|
||||
name: Publish Release Binaries
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags: [ 'v*.*.*' ]
|
||||
|
||||
@@ -8,6 +9,12 @@ jobs:
|
||||
goreleaser:
|
||||
name: goreleaser
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
hashes: ${{ steps.binary.outputs.hashes }}
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -37,11 +44,85 @@ 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
|
||||
with:
|
||||
workdir: "backend"
|
||||
distribution: goreleaser
|
||||
version: "~> v2"
|
||||
args: release --clean --snapshot --skip=publish
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
COSIGN_PWD: ${{ secrets.COSIGN_PWD }}
|
||||
COSIGN_YES: "true"
|
||||
|
||||
binary-provenance:
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
needs: [ goreleaser ]
|
||||
permissions:
|
||||
actions: read # To read the workflow path.
|
||||
id-token: write # To sign the provenance.
|
||||
contents: write # To add assets to a release.
|
||||
uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v1.9.0
|
||||
with:
|
||||
base64-subjects: "${{ needs.goreleaser.outputs.hashes }}"
|
||||
upload-assets: true # upload to a new release
|
||||
|
||||
verification-with-slsa-verifier:
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
needs: [goreleaser, binary-provenance]
|
||||
runs-on: ubuntu-latest
|
||||
permissions: read-all
|
||||
steps:
|
||||
- name: Install the verifier
|
||||
uses: slsa-framework/slsa-verifier/actions/installer@v2.4.0
|
||||
|
||||
- name: Download assets
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PROVENANCE: "${{ needs.binary-provenance.outputs.provenance-name }}"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh -R "$GITHUB_REPOSITORY" release download "$GITHUB_REF_NAME" -p "*.tar.gz"
|
||||
gh -R "$GITHUB_REPOSITORY" release download "$GITHUB_REF_NAME" -p "*.zip"
|
||||
gh -R "$GITHUB_REPOSITORY" release download "$GITHUB_REF_NAME" -p "$PROVENANCE"
|
||||
- name: Verify assets
|
||||
env:
|
||||
CHECKSUMS: ${{ needs.goreleaser.outputs.hashes }}
|
||||
PROVENANCE: "${{ needs.binary-provenance.outputs.provenance-name }}"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
checksums=$(echo "$CHECKSUMS" | base64 -d)
|
||||
while read -r line; do
|
||||
fn=$(echo $line | cut -d ' ' -f2)
|
||||
echo "Verifying $fn"
|
||||
slsa-verifier verify-artifact --provenance-path "$PROVENANCE" \
|
||||
--source-uri "github.com/$GITHUB_REPOSITORY" \
|
||||
--source-tag "$GITHUB_REF_NAME" \
|
||||
"$fn"
|
||||
done <<<"$checksums"
|
||||
|
||||
21
.github/workflows/update-currencies.yml
vendored
@@ -5,18 +5,22 @@ on:
|
||||
branches: [ main ]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
update-currencies:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.8'
|
||||
cache: 'pip'
|
||||
@@ -25,15 +29,14 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install requests
|
||||
pip install -r .github/workflows/update-currencies/requirements.txt
|
||||
|
||||
- name: Run currency update script
|
||||
run: python .github/scripts/update_currencies.py
|
||||
|
||||
- name: Check for file changes
|
||||
id: changes
|
||||
- name: Check for currencies.json changes
|
||||
run: |
|
||||
if git diff --quiet; then
|
||||
if git diff --quiet -- backend/internal/core/currencies/currencies.json; then
|
||||
echo "changed=false" >> $GITHUB_ENV
|
||||
else
|
||||
echo "changed=true" >> $GITHUB_ENV
|
||||
@@ -41,14 +44,16 @@ jobs:
|
||||
|
||||
- name: Create Pull Request
|
||||
if: env.changed == 'true'
|
||||
uses: peter-evans/create-pull-request@v7.0.8
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
branch: update-currencies
|
||||
base: main
|
||||
title: "Update currencies.json"
|
||||
commit-message: "chore: update currencies.json"
|
||||
path: backend/internal/core/currencies/currencies.json
|
||||
path: .
|
||||
add-paths: |
|
||||
backend/internal/core/currencies/currencies.json
|
||||
|
||||
- name: No updates needed
|
||||
if: env.changed == 'false'
|
||||
|
||||
2
.gitignore
vendored
@@ -60,7 +60,7 @@ backend/app/api/static/public/*
|
||||
backend/api
|
||||
|
||||
docs/.vitepress/cache/
|
||||
/.data/
|
||||
.data/
|
||||
|
||||
# Playwright
|
||||
frontend/test-results/
|
||||
|
||||
4
.vscode/settings.json
vendored
@@ -4,7 +4,7 @@
|
||||
},
|
||||
"explorer.fileNesting.enabled": true,
|
||||
"explorer.fileNesting.patterns": {
|
||||
"package.json": "package-lock.json, yarn.lock, .eslintrc.js, tsconfig.json, .prettierrc, .editorconfig, pnpm-lock.yaml, postcss.config.js, tailwind.config.js",
|
||||
"package.json": "package-lock.json, yarn.lock, eslint.config.mjs, tsconfig.json, .prettierrc, .editorconfig, pnpm-lock.yaml, postcss.config.js, tailwind.config.js",
|
||||
"docker-compose.yml": "Dockerfile, .dockerignore, docker-compose.dev.yml, docker-compose.yml",
|
||||
"README.md": "LICENSE, SECURITY.md"
|
||||
},
|
||||
@@ -22,6 +22,8 @@
|
||||
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
|
||||
},
|
||||
"eslint.format.enable": true,
|
||||
"eslint.validate": ["javascript", "typescript", "vue"],
|
||||
"eslint.useFlatConfig": true,
|
||||
"css.validate": false,
|
||||
"tailwindCSS.includeLanguages": {
|
||||
"vue": "html",
|
||||
|
||||
56
README.md
@@ -2,36 +2,59 @@
|
||||
<img src="/docs/public/lilbox.svg" height="200"/>
|
||||
</div>
|
||||
|
||||
<h1 align="center" style="margin-top: -10px"> HomeBox </h1>
|
||||
<p align="center" style="width: 100;">
|
||||
<h1 align="center" style="margin-top: -10px;"> HomeBox </h1>
|
||||
<p align="center" style="width: 100%;">
|
||||
<a href="https://homebox.software/en/">Docs</a>
|
||||
|
|
||||
<a href="https://demo.homebox.software">Demo</a>
|
||||
|
|
||||
<a href="https://discord.gg/aY4DCkpNA9">Discord</a>
|
||||
</p>
|
||||
<p align="center" style="width: 100%;">
|
||||
<img src="https://img.shields.io/github/check-runs/sysadminsmedia/homebox/main" alt="Github Checks"/>
|
||||
<img src="https://img.shields.io/github/license/sysadminsmedia/homebox"/>
|
||||
<img src="https://img.shields.io/github/v/release/sysadminsmedia/homebox?sort=semver&display_name=release"/>
|
||||
<img src="https://img.shields.io/weblate/progress/homebox?server=https%3A%2F%2Ftranslate.sysadminsmedia.com"/>
|
||||
</p>
|
||||
<p align="center" style="width: 100%;">
|
||||
<img src="https://img.shields.io/reddit/subreddit-subscribers/homebox"/>
|
||||
<img src="https://img.shields.io/mastodon/follow/110749314839831923?domain=infosec.exchange"/>
|
||||
<img src="https://img.shields.io/lemmy/homebox%40lemmy.world?label=lemmy"/>
|
||||
</p>
|
||||
|
||||
## What is HomeBox
|
||||
|
||||
HomeBox is the inventory and organization system built for the Home User! With a focus on simplicity and ease of use, Homebox is the perfect solution for your home inventory, organization, and management needs. While developing this project, I've tried to keep the following principles in mind:
|
||||
HomeBox is the inventory and organization system built for the Home User! With a focus on simplicity and ease of use, Homebox is the perfect solution for your home inventory, organization, and management needs. While developing this project, We've tried to keep the following principles in mind:
|
||||
|
||||
- _Simple_ - Homebox is designed to be simple and easy to use. No complicated setup or configuration required. Use either a single docker container, or deploy yourself by compiling the binary for your platform of choice.
|
||||
- _Blazingly Fast_ - Homebox is written in Go, which makes it extremely fast and requires minimal resources to deploy. In general, idle memory usage is less than 50MB for the whole container.
|
||||
- _Portable_ - Homebox is designed to be portable and run on anywhere. We use SQLite and an embedded Web UI to make it easy to deploy, use, and backup.
|
||||
- 🧘 _Simple but Expandable_ - Homebox is designed to be simple and easy to use. No complicated setup or configuration required. But expandable to whatever level of infrastructure you want to put into it.
|
||||
- 🚀 _Blazingly Fast_ - Homebox is written in Go, which makes it extremely fast and requires minimal resources to deploy. In general, idle memory usage is less than 50MB for the whole container.
|
||||
- 📦 _Portable_ - Homebox is designed to be portable and run on anywhere. We use SQLite and an embedded Web UI to make it easy to deploy, use, and backup.
|
||||
|
||||
### Key Features
|
||||
- 📇 Rich Organization - Organize your items into categories, locations, and tags. You can also create custom fields to store additional information about your items.
|
||||
- 🔍 Powerful Search - Quickly find items in your inventory using the powerful search feature.
|
||||
- 📸 Image Upload - Upload images of your items to make it easy to identify them.
|
||||
- 📄 Document and Warranty Tracking - Keep track of important documents and warranties for your items.
|
||||
- 💰 Purchase & Maintenance Tracking - Track purchase dates, prices, and maintenance schedules for your items.
|
||||
- 📱 Responsive Design - Homebox is designed to work on any device, including desktops, tablets, and smartphones.
|
||||
|
||||
## Screenshots
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
# Screenshots
|
||||
Check out screenshots of the project [here](https://imgur.com/a/5gLWt2j).
|
||||
You can also try the demo instances of Homebox:
|
||||
- [Demo](https://demo.homebox.software)
|
||||
- [Nightly](https://nightly.homebox.software)
|
||||
- [VNext](https://vnext.homebox.software/)
|
||||
|
||||
## Quick Start
|
||||
|
||||
[Configuration & Docker Compose](https://homebox.software/en/quick-start.html)
|
||||
|
||||
```bash
|
||||
# If using the rootless image, ensure data
|
||||
# If using the rootless or hardened image, ensure data
|
||||
# folder has correct permissions
|
||||
mkdir -p /path/to/data/folder
|
||||
chown 65532:65532 -R /path/to/data/folder
|
||||
@@ -43,6 +66,7 @@ docker run -d \
|
||||
--volume /path/to/data/folder/:/data \
|
||||
ghcr.io/sysadminsmedia/homebox:latest
|
||||
# ghcr.io/sysadminsmedia/homebox:latest-rootless
|
||||
# ghcr.io/sysadminsmedia/homebox:latest-hardened
|
||||
```
|
||||
|
||||
<!-- CONTRIBUTING -->
|
||||
@@ -51,14 +75,20 @@ docker run -d \
|
||||
|
||||
Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**.
|
||||
|
||||
If you are not a coder, you can still contribute financially. Financial contributions help me prioritize working on this project over others and helps me know that there is a real demand for project development.
|
||||
To get started with code based contributions, please see our [contributing guide](https://homebox.software/en/contribute/get-started.html).
|
||||
|
||||
If you are not a coder and can't help translate, you can still contribute financially. Financial contributions help us maintain the project and keep demos running.
|
||||
|
||||
## Help us Translate
|
||||
We want to make sure that Homebox is available in as many languages as possible. If you are interested in helping us translate Homebox, please help us via our [Weblate instance](https://translate.sysadminsmedia.com/projects/homebox/).
|
||||
|
||||
[](http://translate.sysadminsmedia.com/engage/homebox/)
|
||||
[](https://translate.sysadminsmedia.com/engage/homebox/)
|
||||
|
||||
## Credits
|
||||
|
||||
- Original project by [@hay-kot](https://github.com/hay-kot)
|
||||
- Logo by [@lakotelman](https://github.com/lakotelman)
|
||||
|
||||
### Contributors
|
||||
<a href="https://github.com/sysadminsmedia/homebox/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=sysadminsmedia/homebox" />
|
||||
</a>
|
||||
|
||||
24
Taskfile.yml
@@ -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/**"
|
||||
@@ -93,6 +96,16 @@ tasks:
|
||||
- go run ./app/api/ {{ .CLI_ARGS }} &
|
||||
silent: true
|
||||
|
||||
go:ci:with-frontend:
|
||||
desc: Run backend with frontend in CI mode
|
||||
dir: frontend
|
||||
cmds:
|
||||
- pnpm install
|
||||
- pnpm run build
|
||||
- cp -r ./.output/public ../backend/app/api/static/
|
||||
- task: go:ci
|
||||
silent: true
|
||||
|
||||
go:test:
|
||||
desc: Runs all go tests using gotestsum - supports passing gotestsum args
|
||||
dir: backend
|
||||
@@ -201,12 +214,11 @@ tasks:
|
||||
desc: Runs end-to-end test on a live server
|
||||
dir: frontend
|
||||
cmds:
|
||||
- task: go:ci
|
||||
- task: ui:ci
|
||||
- task: go:ci:with-frontend
|
||||
- pnpm exec playwright install-deps
|
||||
- pnpm exec playwright install
|
||||
- sleep 30
|
||||
- TEST_SHUTDOWN_API_SERVER=true pnpm exec playwright test -c ./test/playwright.config.ts {{ .CLI_ARGS }}
|
||||
- TEST_SHUTDOWN_API_SERVER=true E2E_BASE_URL=http://localhost:7745 pnpm exec playwright test -c ./test/playwright.config.ts {{ .CLI_ARGS }}
|
||||
|
||||
pr:
|
||||
desc: Runs all tasks required for a PR
|
||||
|
||||
@@ -21,6 +21,13 @@ builds:
|
||||
- arm
|
||||
- arm64
|
||||
- riscv64
|
||||
flags:
|
||||
- -trimpath
|
||||
ldflags:
|
||||
- -s -w
|
||||
- -X main.version={{.Version}}
|
||||
- -X main.commit={{.Commit}}
|
||||
- -X main.date={{.Date}}
|
||||
ignore:
|
||||
- goos: windows
|
||||
goarch: arm
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -138,6 +138,13 @@ func run(cfg *config.Config) error {
|
||||
)
|
||||
}
|
||||
|
||||
if strings.ToLower(cfg.Database.Driver) == "sqlite3" {
|
||||
db := c.Sql()
|
||||
db.SetMaxOpenConns(1)
|
||||
db.SetMaxIdleConns(1)
|
||||
log.Info().Msg("SQLite connection pool configured: max_open=1, max_idle=1")
|
||||
}
|
||||
|
||||
migrationsFs, err := migrations.Migrations(strings.ToLower(cfg.Database.Driver))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get migrations for %s: %w", strings.ToLower(cfg.Database.Driver), err)
|
||||
|
||||
@@ -2240,6 +2240,9 @@ const docTemplate = `{
|
||||
"code": {
|
||||
"type": "string"
|
||||
},
|
||||
"decimals": {
|
||||
"type": "integer"
|
||||
},
|
||||
"local": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -3480,6 +3483,19 @@ const docTemplate = `{
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"labelIds": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"x-nullable": true,
|
||||
"x-omitempty": true
|
||||
},
|
||||
"locationId": {
|
||||
"type": "string",
|
||||
"x-nullable": true,
|
||||
"x-omitempty": true
|
||||
},
|
||||
"quantity": {
|
||||
"type": "integer",
|
||||
"x-nullable": true,
|
||||
|
||||
4563
backend/app/api/static/docs/openapi-3.json
Normal file
2898
backend/app/api/static/docs/openapi-3.yaml
Normal file
@@ -2238,6 +2238,9 @@
|
||||
"code": {
|
||||
"type": "string"
|
||||
},
|
||||
"decimals": {
|
||||
"type": "integer"
|
||||
},
|
||||
"local": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -3478,6 +3481,19 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"labelIds": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"x-nullable": true,
|
||||
"x-omitempty": true
|
||||
},
|
||||
"locationId": {
|
||||
"type": "string",
|
||||
"x-nullable": true,
|
||||
"x-omitempty": true
|
||||
},
|
||||
"quantity": {
|
||||
"type": "integer",
|
||||
"x-nullable": true,
|
||||
|
||||
@@ -34,6 +34,8 @@ definitions:
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
decimals:
|
||||
type: integer
|
||||
local:
|
||||
type: string
|
||||
name:
|
||||
@@ -873,6 +875,16 @@ definitions:
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
labelIds:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
x-nullable: true
|
||||
x-omitempty: true
|
||||
locationId:
|
||||
type: string
|
||||
x-nullable: true
|
||||
x-omitempty: true
|
||||
quantity:
|
||||
type: integer
|
||||
x-nullable: true
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -50,6 +50,7 @@ func (svc *ItemService) AttachmentAdd(ctx Context, itemID uuid.UUID, filename st
|
||||
_, err = svc.repo.Attachments.Create(ctx, itemID, repo.ItemCreateAttachment{Title: filename, Content: file}, attachmentType, primary)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("failed to create attachment")
|
||||
return repo.ItemOut{}, err
|
||||
}
|
||||
|
||||
return svc.repo.Items.GetOneByGroup(ctx, ctx.GID, itemID)
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/sysadminsmedia/homebox/backend/internal/data/repo"
|
||||
"github.com/sysadminsmedia/homebox/backend/internal/sys/config"
|
||||
)
|
||||
|
||||
func TestItemService_AddAttachment(t *testing.T) {
|
||||
@@ -52,11 +53,61 @@ 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))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, contents, string(bts))
|
||||
}
|
||||
|
||||
func TestItemService_AddAttachment_InvalidStorage(t *testing.T) {
|
||||
// Create a service with an invalid storage path to simulate the issue
|
||||
svc := &ItemService{
|
||||
repo: tRepos,
|
||||
filepath: "/nonexistent/path/that/should/not/exist",
|
||||
}
|
||||
|
||||
// Create a temporary repo with invalid storage config
|
||||
invalidRepos := repo.New(tClient, tbus, config.Storage{
|
||||
PrefixPath: "/",
|
||||
ConnString: "file:///nonexistent/directory/that/does/not/exist",
|
||||
}, "mem://{{ .Topic }}", config.Thumbnail{
|
||||
Enabled: false,
|
||||
Width: 0,
|
||||
Height: 0,
|
||||
})
|
||||
|
||||
svc.repo = invalidRepos
|
||||
|
||||
loc, err := invalidRepos.Locations.Create(context.Background(), tGroup.ID, repo.LocationCreate{
|
||||
Description: "test",
|
||||
Name: "test-invalid",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, loc)
|
||||
|
||||
itmC := repo.ItemCreate{
|
||||
Name: fk.Str(10),
|
||||
Description: fk.Str(10),
|
||||
LocationID: loc.ID,
|
||||
}
|
||||
|
||||
itm, err := invalidRepos.Items.Create(context.Background(), tGroup.ID, itmC)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, itm)
|
||||
t.Cleanup(func() {
|
||||
err := invalidRepos.Items.Delete(context.Background(), itm.ID)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
contents := fk.Str(1000)
|
||||
reader := strings.NewReader(contents)
|
||||
|
||||
// Attempt to add attachment with invalid storage - should return an error
|
||||
_, err = svc.AttachmentAdd(tCtx, itm.ID, "testfile.txt", "attachment", false, reader)
|
||||
|
||||
// This should return an error now (after the fix)
|
||||
assert.Error(t, err, "AttachmentAdd should return an error when storage is invalid")
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 {
|
||||
@@ -210,9 +218,8 @@ func (r *AttachmentRepo) Create(ctx context.Context, itemID uuid.UUID, doc ItemC
|
||||
// Upload the file to the storage bucket
|
||||
path, err := r.UploadFile(ctx, itemGroup, doc)
|
||||
if err != nil {
|
||||
err := tx.Rollback()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if rollbackErr := tx.Rollback(); rollbackErr != nil {
|
||||
return nil, rollbackErr
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
@@ -385,7 +392,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 +414,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 +482,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 +792,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 +850,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})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -3,18 +3,18 @@ package repo
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/sysadminsmedia/homebox/backend/pkgs/textutils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/sysadminsmedia/homebox/backend/pkgs/textutils"
|
||||
)
|
||||
|
||||
func TestItemsRepository_AccentInsensitiveSearch(t *testing.T) {
|
||||
// Test cases for accent-insensitive search
|
||||
testCases := []struct {
|
||||
name string
|
||||
itemName string
|
||||
searchQuery string
|
||||
shouldMatch bool
|
||||
description string
|
||||
name string
|
||||
itemName string
|
||||
searchQuery string
|
||||
shouldMatch bool
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "Spanish accented item, search without accents",
|
||||
@@ -155,25 +155,25 @@ func TestItemsRepository_AccentInsensitiveSearch(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Test the normalization logic used in the repository
|
||||
normalizedSearch := textutils.NormalizeSearchQuery(tc.searchQuery)
|
||||
|
||||
|
||||
// This simulates what happens in the repository
|
||||
// The original search would find exact matches (case-insensitive)
|
||||
// The normalized search would find accent-insensitive matches
|
||||
|
||||
|
||||
// Test that our normalization works as expected
|
||||
if tc.shouldMatch {
|
||||
// If it should match, then either the original query should match
|
||||
// or the normalized query should match when applied to the stored data
|
||||
assert.NotEqual(t, "", normalizedSearch, "Normalized search should not be empty")
|
||||
|
||||
|
||||
// The key insight is that we're searching with both the original and normalized queries
|
||||
// So "electrónica" will be found when searching for "electronica" because:
|
||||
// 1. Original search: "electronica" doesn't match "electrónica"
|
||||
// 2. Normalized search: "electronica" matches the normalized version
|
||||
t.Logf("✓ %s: Item '%s' should be found with search '%s' (normalized: '%s')",
|
||||
t.Logf("✓ %s: Item '%s' should be found with search '%s' (normalized: '%s')",
|
||||
tc.description, tc.itemName, tc.searchQuery, normalizedSearch)
|
||||
} else {
|
||||
t.Logf("✗ %s: Item '%s' should NOT be found with search '%s' (normalized: '%s')",
|
||||
t.Logf("✗ %s: Item '%s' should NOT be found with search '%s' (normalized: '%s')",
|
||||
tc.description, tc.itemName, tc.searchQuery, normalizedSearch)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -71,6 +71,8 @@ type LabelMakerConf struct {
|
||||
DynamicLength bool `yaml:"bool" conf:"default:true"`
|
||||
LabelServiceUrl *string `yaml:"label_service_url"`
|
||||
LabelServiceTimeout *time.Duration `yaml:"label_service_timeout"`
|
||||
RegularFontPath *string `yaml:"regular_font_path"`
|
||||
BoldFontPath *string `yaml:"bold_font_path"`
|
||||
}
|
||||
|
||||
type BarcodeAPIConf struct {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -27,6 +27,13 @@ import (
|
||||
"golang.org/x/image/font/gofont/gomedium"
|
||||
)
|
||||
|
||||
type FontType int
|
||||
|
||||
const (
|
||||
FontTypeRegular FontType = iota
|
||||
FontTypeBold
|
||||
)
|
||||
|
||||
type GenerateParameters struct {
|
||||
Width int
|
||||
Height int
|
||||
@@ -140,6 +147,48 @@ func wrapText(text string, face font.Face, maxWidth int, maxHeight int, lineHeig
|
||||
return wrappedLines, ""
|
||||
}
|
||||
|
||||
func loadFont(cfg *config.Config, fontType FontType) (*truetype.Font, error) {
|
||||
var fontPath *string
|
||||
var fallbackData []byte
|
||||
|
||||
switch fontType {
|
||||
case FontTypeRegular:
|
||||
if cfg != nil && cfg.LabelMaker.RegularFontPath != nil {
|
||||
fontPath = cfg.LabelMaker.RegularFontPath
|
||||
}
|
||||
fallbackData = gomedium.TTF
|
||||
case FontTypeBold:
|
||||
if cfg != nil && cfg.LabelMaker.BoldFontPath != nil {
|
||||
fontPath = cfg.LabelMaker.BoldFontPath
|
||||
}
|
||||
fallbackData = gobold.TTF
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown font type: %d", fontType)
|
||||
}
|
||||
|
||||
if fontPath != nil && *fontPath != "" {
|
||||
data, err := os.ReadFile(*fontPath)
|
||||
if err != nil {
|
||||
log.Printf("Failed to load font from %s: %v, using fallback font", *fontPath, err)
|
||||
} else {
|
||||
font, err := truetype.Parse(data)
|
||||
if err != nil {
|
||||
log.Printf("Failed to parse font from %s: %v, using fallback font", *fontPath, err)
|
||||
} else {
|
||||
log.Printf("Successfully loaded font from %s", *fontPath)
|
||||
return font, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
font, err := truetype.Parse(fallbackData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return font, nil
|
||||
}
|
||||
|
||||
func GenerateLabel(w io.Writer, params *GenerateParameters, cfg *config.Config) error {
|
||||
if err := params.Validate(); err != nil {
|
||||
return err
|
||||
@@ -165,12 +214,12 @@ func GenerateLabel(w io.Writer, params *GenerateParameters, cfg *config.Config)
|
||||
qr.DisableBorder = true
|
||||
qrImage := qr.Image(params.QrSize)
|
||||
|
||||
regularFont, err := truetype.Parse(gomedium.TTF)
|
||||
regularFont, err := loadFont(cfg, FontTypeRegular)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
boldFont, err := truetype.Parse(gobold.TTF)
|
||||
boldFont, err := loadFont(cfg, FontTypeBold)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
187
backend/pkgs/labelmaker/labelmaker_test.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package labelmaker
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/sysadminsmedia/homebox/backend/internal/sys/config"
|
||||
"golang.org/x/image/font/gofont/gobold"
|
||||
"golang.org/x/image/font/gofont/gomedium"
|
||||
)
|
||||
|
||||
func TestLoadFont_WithNilConfig(t *testing.T) {
|
||||
font, err := loadFont(nil, FontTypeRegular)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, font)
|
||||
|
||||
font, err = loadFont(nil, FontTypeBold)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, font)
|
||||
}
|
||||
|
||||
func TestLoadFont_WithEmptyConfig(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
|
||||
font, err := loadFont(cfg, FontTypeRegular)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, font)
|
||||
|
||||
font, err = loadFont(cfg, FontTypeBold)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, font)
|
||||
}
|
||||
|
||||
func TestLoadFont_WithCustomFontPath(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
fontPath := filepath.Join(tempDir, "test-font.ttf")
|
||||
|
||||
err := os.WriteFile(fontPath, gomedium.TTF, 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg := &config.Config{
|
||||
LabelMaker: config.LabelMakerConf{
|
||||
RegularFontPath: &fontPath,
|
||||
},
|
||||
}
|
||||
|
||||
font, err := loadFont(cfg, FontTypeRegular)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, font)
|
||||
}
|
||||
|
||||
func TestLoadFont_WithNonExistentFontPath(t *testing.T) {
|
||||
nonExistentPath := "/non/existent/path/font.ttf"
|
||||
cfg := &config.Config{
|
||||
LabelMaker: config.LabelMakerConf{
|
||||
RegularFontPath: &nonExistentPath,
|
||||
},
|
||||
}
|
||||
|
||||
font, err := loadFont(cfg, FontTypeRegular)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, font)
|
||||
}
|
||||
|
||||
func TestLoadFont_UnknownFontType(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
|
||||
_, err := loadFont(cfg, FontType(999))
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "unknown font type")
|
||||
}
|
||||
|
||||
func TestLoadFont_BoldFontWithCustomPath(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
fontPath := filepath.Join(tempDir, "test-bold-font.ttf")
|
||||
|
||||
err := os.WriteFile(fontPath, gobold.TTF, 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg := &config.Config{
|
||||
LabelMaker: config.LabelMakerConf{
|
||||
BoldFontPath: &fontPath,
|
||||
},
|
||||
}
|
||||
|
||||
font, err := loadFont(cfg, FontTypeBold)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, font)
|
||||
}
|
||||
|
||||
func TestLoadFont_EmptyStringPath(t *testing.T) {
|
||||
emptyPath := ""
|
||||
cfg := &config.Config{
|
||||
LabelMaker: config.LabelMakerConf{
|
||||
RegularFontPath: &emptyPath,
|
||||
},
|
||||
}
|
||||
|
||||
font, err := loadFont(cfg, FontTypeRegular)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, font)
|
||||
}
|
||||
|
||||
func TestLoadFont_CJKRendering(t *testing.T) {
|
||||
cjkFontPath := filepath.Join("testdata", "NotoSansKR-VF.ttf")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
text string
|
||||
fontPath string
|
||||
shouldHaveGlyph bool
|
||||
}{
|
||||
{
|
||||
name: "Korean with default font",
|
||||
text: "한글",
|
||||
fontPath: "",
|
||||
shouldHaveGlyph: false,
|
||||
},
|
||||
{
|
||||
name: "Chinese with default font",
|
||||
text: "中文",
|
||||
fontPath: "",
|
||||
shouldHaveGlyph: false,
|
||||
},
|
||||
{
|
||||
name: "Japanese with default font",
|
||||
text: "ひらがなカタカナ",
|
||||
fontPath: "",
|
||||
shouldHaveGlyph: false,
|
||||
},
|
||||
{
|
||||
name: "Korean with Noto Sans CJK",
|
||||
text: "한글",
|
||||
fontPath: cjkFontPath,
|
||||
shouldHaveGlyph: true,
|
||||
},
|
||||
{
|
||||
name: "Chinese with Noto Sans CJK",
|
||||
text: "中文",
|
||||
fontPath: cjkFontPath,
|
||||
shouldHaveGlyph: true,
|
||||
},
|
||||
{
|
||||
name: "Japanese with Noto Sans CJK",
|
||||
text: "ひらがなカタカナ",
|
||||
fontPath: cjkFontPath,
|
||||
shouldHaveGlyph: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var cfg *config.Config
|
||||
if tt.fontPath != "" {
|
||||
if _, err := os.Stat(tt.fontPath); os.IsNotExist(err) {
|
||||
t.Skipf("Font file not found: %s", tt.fontPath)
|
||||
}
|
||||
cfg = &config.Config{
|
||||
LabelMaker: config.LabelMakerConf{
|
||||
RegularFontPath: &tt.fontPath,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
font, err := loadFont(cfg, FontTypeRegular)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, font)
|
||||
|
||||
hasAllGlyphs := true
|
||||
for _, r := range tt.text {
|
||||
if font.Index(r) == 0 {
|
||||
hasAllGlyphs = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if tt.shouldHaveGlyph {
|
||||
assert.True(t, hasAllGlyphs, "Font should render %s characters", tt.name)
|
||||
} else {
|
||||
assert.False(t, hasAllGlyphs, "Default font should not render %s characters", tt.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
92
backend/pkgs/labelmaker/testdata/NotoSans-LICENSE
vendored
Normal file
@@ -0,0 +1,92 @@
|
||||
This Font Software is licensed under the SIL Open Font License,
|
||||
Version 1.1.
|
||||
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font
|
||||
creation efforts of academic and linguistic communities, and to
|
||||
provide a free and open framework in which fonts may be shared and
|
||||
improved in partnership with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply to
|
||||
any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software
|
||||
components as distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to,
|
||||
deleting, or substituting -- in part or in whole -- any of the
|
||||
components of the Original Version, by changing formats or by porting
|
||||
the Font Software to a new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed,
|
||||
modify, redistribute, and sell modified and unmodified copies of the
|
||||
Font Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components, in
|
||||
Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the
|
||||
corresponding Copyright Holder. This restriction only applies to the
|
||||
primary font name as presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created using
|
||||
the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
BIN
backend/pkgs/labelmaker/testdata/NotoSansKR-VF.ttf
vendored
Normal file
15
backend/pkgs/labelmaker/testdata/README.md
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
# Test Data
|
||||
|
||||
This directory contains font files used only for testing purposes.
|
||||
|
||||
## Fonts
|
||||
|
||||
- **NotoSansKR-VF.ttf**: Noto Sans CJK Korean Variable Font
|
||||
- Used for testing CJK (Chinese, Japanese, Korean) character rendering
|
||||
- License: See `NotoSans-LICENSE` file
|
||||
|
||||
## Notes
|
||||
|
||||
- These fonts are **only used during testing** and are **not included in production builds**
|
||||
- Go's build system automatically excludes `testdata` directories from production builds
|
||||
- The fonts support rendering of Korean, Chinese, and Japanese characters
|
||||
@@ -50,7 +50,7 @@ document.head.appendChild(elementStyle);
|
||||
<client-only>
|
||||
<elements-api
|
||||
:key="componentKey"
|
||||
apiDescriptionUrl="https://raw.githubusercontent.com/sysadminsmedia/homebox/refs/heads/main/docs/en/api/openapi-2.0.json"
|
||||
apiDescriptionUrl="https://raw.githubusercontent.com/sysadminsmedia/homebox/refs/heads/main/docs/en/api/openapi-3.0.json"
|
||||
router="hash"
|
||||
layout="responsive"
|
||||
hideSchemas="true"
|
||||
|
||||
4563
docs/en/api/openapi-3.0.json
Normal file
2898
docs/en/api/openapi-3.0.yaml
Normal file
@@ -2238,6 +2238,9 @@
|
||||
"code": {
|
||||
"type": "string"
|
||||
},
|
||||
"decimals": {
|
||||
"type": "integer"
|
||||
},
|
||||
"local": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -3478,6 +3481,19 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"labelIds": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"x-nullable": true,
|
||||
"x-omitempty": true
|
||||
},
|
||||
"locationId": {
|
||||
"type": "string",
|
||||
"x-nullable": true,
|
||||
"x-omitempty": true
|
||||
},
|
||||
"quantity": {
|
||||
"type": "integer",
|
||||
"x-nullable": true,
|
||||
@@ -34,6 +34,8 @@ definitions:
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
decimals:
|
||||
type: integer
|
||||
local:
|
||||
type: string
|
||||
name:
|
||||
@@ -873,6 +875,16 @@ definitions:
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
labelIds:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
x-nullable: true
|
||||
x-omitempty: true
|
||||
locationId:
|
||||
type: string
|
||||
x-nullable: true
|
||||
x-omitempty: true
|
||||
quantity:
|
||||
type: integer
|
||||
x-nullable: true
|
||||
@@ -14,13 +14,13 @@ 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 | 10 | maximum file upload size supported in MB |
|
||||
| HBOX_WEB_MAX_UPLOAD_SIZE | 10 | maximum file upload size supported in MB |
|
||||
| HBOX_WEB_READ_TIMEOUT | 10s | Read timeout of HTTP sever |
|
||||
| HBOX_WEB_WRITE_TIMEOUT | 10s | Write timeout of HTTP server |
|
||||
| HBOX_WEB_IDLE_TIMEOUT | 30s | Idle timeout of HTTP server |
|
||||
| HBOX_STORAGE_CONN_STRING | file://./ | path to the data directory, do not change this if you're using docker |
|
||||
| HBOX_STORAGE_CONN_STRING | file:///./ | path to the data directory, do not change this if you're using docker |
|
||||
| HBOX_STORAGE_PREFIX_PATH | .data | prefix path for the storage, if not set the storage will be used as is |
|
||||
| HBOX_LOG_LEVEL | `info` | log level to use, can be one of `trace`, `debug`, `info`, `warn`, `error`, `critical` |
|
||||
| HBOX_LOG_FORMAT | `text` | log format to use, can be one of: `text`, `json` |
|
||||
@@ -29,20 +29,18 @@ aside: false
|
||||
| HBOX_MAILER_USERNAME | | email user to use |
|
||||
| HBOX_MAILER_PASSWORD | | email password to use |
|
||||
| HBOX_MAILER_FROM | | email from address to use |
|
||||
| HBOX_SWAGGER_HOST | 7745 | swagger host to use, if not set swagger will be disabled |
|
||||
| HBOX_SWAGGER_SCHEMA | `http` | swagger schema to use, can be one of: `http`, `https` |
|
||||
| HBOX_DATABASE_DRIVER | sqlite3 | sets the correct database type (`sqlite3` or `postgres`) |
|
||||
| HBOX_DATABASE_SQLITE_PATH | ./.data/homebox.db?_pragma=busy_timeout=999&_pragma=journal_mode=WAL&_fk=1 | sets the directory path for Sqlite |
|
||||
| HBOX_DATABASE_SQLITE_PATH | ./.data/homebox.db?_pragma=busy_timeout=999&_pragma=journal_mode=WAL&_fk=1&_time_format=sqlite | sets the directory path for Sqlite |
|
||||
| HBOX_DATABASE_HOST | | sets the hostname for a postgres database |
|
||||
| HBOX_DATABASE_PORT | | sets the port for a postgres database |
|
||||
| HBOX_DATABASE_USERNAME | | sets the username for a postgres connection (optional if using cert auth) |
|
||||
| HBOX_DATABASE_PASSWORD | | sets the password for a postgres connection (optional if using cert auth) |
|
||||
| HBOX_DATABASE_DATABASE | | sets the database for a postgres connection |
|
||||
| HBOX_DATABASE_SSL_MODE | | sets the sslmode for a postgres connection |
|
||||
| HBOX_DATABASE_SSL_MODE | prefer | sets the sslmode for a postgres connection |
|
||||
| HBOX_DATABASE_SSL_CERT | | sets the sslcert for a postgres connection (should be a path) |
|
||||
| HBOX_DATABASE_SSL_KEY | | sets the sslkey for a postgres connection (should be a path) |
|
||||
| HBOX_DATABASE_SSL_ROOTCERT | | sets the sslrootcert for a postgres connection (should be a path) |
|
||||
| HBOX_OPTIONS_CHECK_GITHUB_RELEASE | true | check for new github releases |
|
||||
| HBOX_DATABASE_SSL_ROOT_CERT | | sets the sslrootcert for a postgres connection (should be a path) |
|
||||
| HBOX_OPTIONS_GITHUB_RELEASE_CHECK | true | check for new github releases |
|
||||
| HBOX_LABEL_MAKER_WIDTH | 526 | width for generated labels in pixels |
|
||||
| HBOX_LABEL_MAKER_HEIGHT | 200 | height for generated labels in pixels |
|
||||
| HBOX_LABEL_MAKER_PADDING | 32 | space between elements on label |
|
||||
@@ -50,6 +48,8 @@ aside: false
|
||||
| HBOX_LABEL_MAKER_PRINT_COMMAND | | the command to use for printing labels. if empty, label printing is disabled. <span v-pre>`{{.FileName}}`</span> in the command will be replaced with the png filename of the label |
|
||||
| HBOX_LABEL_MAKER_DYNAMIC_LENGTH | true | allow label generation with open length. `HBOX_LABEL_MAKER_HEIGHT` is still used for layout and minimal height. If not used, long text may be cut off, but all labels have the same size. |
|
||||
| HBOX_LABEL_MAKER_ADDITIONAL_INFORMATION | | Additional information added to the label like name or phone number |
|
||||
| HBOX_LABEL_MAKER_REGULAR_FONT_PATH | | path to regular font file for label generation (e.g., `/fonts/NotoSansKR-Regular.ttf`). If not set, uses embedded font. Supports TTF format. |
|
||||
| HBOX_LABEL_MAKER_BOLD_FONT_PATH | | path to bold font file for label generation (e.g., `/fonts/NotoSansKR-Bold.ttf`). If not set, uses embedded font. Supports TTF format. |
|
||||
| HBOX_THUMBNAIL_ENABLED | true | enable thumbnail generation for images, supports PNG, JPEG, AVIF, WEBP, GIF file types |
|
||||
| HBOX_THUMBNAIL_WIDTH | 500 | width for generated thumbnails in pixels |
|
||||
| HBOX_THUMBNAIL_HEIGHT | 500 | height for generated thumbnails in pixels |
|
||||
@@ -160,8 +160,8 @@ OPTIONS
|
||||
--mode/$HBOX_MODE <string> (default: development)
|
||||
--web-port/$HBOX_WEB_PORT <string> (default: 7745)
|
||||
--web-host/$HBOX_WEB_HOST <string>
|
||||
--web-max-file-upload/$HBOX_WEB_MAX_FILE_UPLOAD <int> (default: 10)
|
||||
--storage-conn-string/$HBOX_STORAGE_CONN_STRING <string> (default: file://./)
|
||||
--web-max-upload-size/$HBOX_WEB_MAX_UPLOAD_SIZE <int> (default: 10)
|
||||
--storage-conn-string/$HBOX_STORAGE_CONN_STRING <string> (default: file:///./)
|
||||
--storage-prefix-path/$HBOX_STORAGE_PREFIX_PATH <string> (default: .data)
|
||||
--log-level/$HBOX_LOG_LEVEL <string> (default: info)
|
||||
--log-format/$HBOX_LOG_FORMAT <string> (default: text)
|
||||
@@ -170,32 +170,32 @@ OPTIONS
|
||||
--mailer-username/$HBOX_MAILER_USERNAME <string>
|
||||
--mailer-password/$HBOX_MAILER_PASSWORD <string>
|
||||
--mailer-from/$HBOX_MAILER_FROM <string>
|
||||
--swagger-host/$HBOX_SWAGGER_HOST <string> (default: localhost:7745)
|
||||
--swagger-scheme/$HBOX_SWAGGER_SCHEME <string> (default: http)
|
||||
--demo/$HBOX_DEMO <bool>
|
||||
--debug-enabled/$HBOX_DEBUG_ENABLED <bool> (default: false)
|
||||
--debug-port/$HBOX_DEBUG_PORT <string> (default: 4000)
|
||||
--database-driver/$HBOX_DATABASE_DRIVER <string> (default: sqlite3)
|
||||
--database-sqlite-path/$HBOX_DATABASE_SQLITE_PATH <string> (default: ./.data/homebox.db?_pragma=busy_timeout=999&_pragma=journal_mode=WAL&_fk=1)
|
||||
--database-sqlite-path/$HBOX_DATABASE_SQLITE_PATH <string> (default: ./.data/homebox.db?_pragma=busy_timeout=999&_pragma=journal_mode=WAL&_fk=1&_time_format=sqlite)
|
||||
--database-host/$HBOX_DATABASE_HOST <string>
|
||||
--database-port/$HBOX_DATABASE_PORT <string>
|
||||
--database-username/$HBOX_DATABASE_USERNAME <string>
|
||||
--database-password/$HBOX_DATABASE_PASSWORD <string>
|
||||
--database-database/$HBOX_DATABASE_DATABASE <string>
|
||||
--database-ssl-mode/$HBOX_DATABASE_SSL_MODE <string>
|
||||
--database-ssl-mode/$HBOX_DATABASE_SSL_MODE <string> (default: prefer)
|
||||
--options-allow-registration/$HBOX_OPTIONS_ALLOW_REGISTRATION <bool> (default: true)
|
||||
--options-auto-increment-asset-id/$HBOX_OPTIONS_AUTO_INCREMENT_ASSET_ID <bool> (default: true)
|
||||
--options-currency-config/$HBOX_OPTIONS_CURRENCY_CONFIG <string>
|
||||
--options-check-github-release/$HBOX_OPTIONS_CHECK_GITHUB_RELEASE <bool> (default: true)
|
||||
--options-github-release-check/$HBOX_OPTIONS_GITHUB_RELEASE_CHECK <bool> (default: true)
|
||||
--options-allow-analytics/$HBOX_OPTIONS_ALLOW_ANALYTICS <bool> (default: false)
|
||||
--label-maker-width/$HBOX_LABEL_MAKER_WIDTH <int> (default: 526)
|
||||
--label-maker-height/$HBOX_LABEL_MAKER_HEIGHT <int> (default: 200)
|
||||
--label-maker-padding/$HBOX_LABEL_MAKER_PADDING <int> (default: 32)
|
||||
--label-maker-margin/$HBOX_LABEL_MAKER_MARGIN <int> (default: 32)
|
||||
--label-maker-margin/$HBOX_LABEL_MAKER_MARGIN <int> (default: 32)
|
||||
--label-maker-font-size/$HBOX_LABEL_MAKER_FONT_SIZE <float> (default: 32.0)
|
||||
--label-maker-print-command/$HBOX_LABEL_MAKER_PRINT_COMMAND <string>
|
||||
--label-maker-additional-information/$HBOX_LABEL_MAKER_DYNAMIC_LENGTH <string> (default: true)
|
||||
--label-maker-dynamic-length/$HBOX_LABEL_MAKER_DYNAMIC_LENGTH <bool> (default: true)
|
||||
--label-maker-additional-information/$HBOX_LABEL_MAKER_ADDITIONAL_INFORMATION <string>
|
||||
--label-maker-regular-font-path/$HBOX_LABEL_MAKER_REGULAR_FONT_PATH <string>
|
||||
--label-maker-bold-font-path/$HBOX_LABEL_MAKER_BOLD_FONT_PATH <string>
|
||||
--thumbnail-enabled/$HBOX_THUMBNAIL_ENABLED <bool> (default: true)
|
||||
--thumbnail-width/$HBOX_THUMBNAIL_WIDTH <int> (default: 500)
|
||||
--thumbnail-height/$HBOX_THUMBNAIL_HEIGHT <int> (default: 500)
|
||||
|
||||
@@ -1,19 +1,7 @@
|
||||
# Bounty Program
|
||||
|
||||
## About
|
||||
As part of our commitment to open source, and building an active community around Homebox (and hopefully active pool of developers), we are enabling bounties on issues.
|
||||
::: danger Bounties Paused
|
||||
Do to issues with the bounty provider we were using, the bounty program is currently paused. We are working on a solution and will update this page when bounties are available again.
|
||||
|
||||
After digging through several platforms, we ended up settling on [boss.dev](https://www.boss.dev/) as it has some of the lowest fees we could possibly find for any of these platforms other than spinning one up ourselves (which we currently aren't in a position to do).
|
||||
|
||||
While it's not the perfect solution, we think it's about the best one we could find at the moment to lower the rates as much as possible to make sure everyone get's the highest payouts possible. (Some we found were as high as a combined 16%!!!)
|
||||
|
||||
We hope that by enabling bounties on issues, people who have the means and want certain features implemented quicker can now sponsor issues, and in turn everyone contributing code can potentially earn some money for their hard work.
|
||||
|
||||
## Contributor
|
||||
As a contributor wanting to accept money from bounties all you need to do is simply register for an account via GitHub, and attach a bank account (or debit card in the USA).
|
||||
|
||||
## Sponsor
|
||||
Sign in with a GitHub account, and then attach a credit card to your account.
|
||||
|
||||
## Commands to use boss.dev
|
||||
There is documentation on their website regarding commands that you can put in comments to use the bounty system. [boss.dev Documentation](https://www.boss.dev/doc)
|
||||
For more information, please check our [Blog post](https://sysadminsjournal.com/navigating-the-future-of-homeboxs-bounties/).
|
||||
:::
|
||||
|
||||
80
docs/en/custom-font-setup.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# External Font Support for Label Maker
|
||||
|
||||
Label maker supports external font files.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Docker/Podman Setup
|
||||
|
||||
1. **Download external fonts** (e.g., Noto Sans KR):
|
||||
- Download from [Google Fonts](https://fonts.google.com/noto/specimen/Noto+Sans+KR)
|
||||
- Or use the Variable Font from [GitHub](https://github.com/notofonts/noto-cjk)
|
||||
|
||||
2. **Create a fonts directory**:
|
||||
```bash
|
||||
mkdir -p ./fonts
|
||||
# Place your font files in this directory
|
||||
# e.g., NotoSansKR-VF.ttf
|
||||
```
|
||||
|
||||
3. **Mount the fonts directory and set environment variables**:
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
homebox:
|
||||
image: homebox:latest
|
||||
volumes:
|
||||
- ./data:/data
|
||||
- ./fonts:/fonts:ro # Mount fonts directory as read-only
|
||||
environment:
|
||||
- HBOX_LABEL_MAKER_REGULAR_FONT_PATH=/fonts/NotoSansKR-VF.ttf
|
||||
- HBOX_LABEL_MAKER_BOLD_FONT_PATH=/fonts/NotoSansKR-VF.ttf
|
||||
ports:
|
||||
- 3100:7745
|
||||
```
|
||||
|
||||
Or with podman:
|
||||
```bash
|
||||
podman run -d \
|
||||
--name homebox \
|
||||
-p 3100:7745 \
|
||||
-v ./data:/data \
|
||||
-v ./fonts:/fonts:ro \
|
||||
-e HBOX_LABEL_MAKER_REGULAR_FONT_PATH=/fonts/NotoSansKR-VF.ttf \
|
||||
-e HBOX_LABEL_MAKER_BOLD_FONT_PATH=/fonts/NotoSansKR-VF.ttf \
|
||||
homebox:latest
|
||||
```
|
||||
|
||||
4. **Restart the container** and test label generation with Chinese, Japanese, Korean text!
|
||||
|
||||
## Supported Fonts
|
||||
|
||||
- **Format**: TTF (TrueType Font)
|
||||
- **Recommended Fonts**:
|
||||
- Noto Sans KR (Korean)
|
||||
- Noto Sans CJK (Chinese, Japanese, Korean)
|
||||
- Noto Sans SC (Simplified Chinese)
|
||||
- Noto Sans JP (Japanese)
|
||||
|
||||
## Fallback Behavior
|
||||
|
||||
1. **External font specified** → Tries to load from `HBOX_LABEL_MAKER_*_FONT_PATH`
|
||||
2. **External font fails or not specified** → Falls back to bundled Go fonts (Latin-only, **does not support CJK characters**)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Labels still show squares (□□□)
|
||||
- Check if the font file exists at the specified path
|
||||
- Verify the font file format (must be TTF, not OTF)
|
||||
- Check container logs: `podman logs homebox | grep -i font`
|
||||
|
||||
### Font file not found
|
||||
- Ensure the volume is correctly mounted
|
||||
- Check file permissions (font files should be readable)
|
||||
- Use absolute paths in environment variables
|
||||
|
||||
## Why External Fonts?
|
||||
|
||||
- **Smaller base image**: No need to embed large font files (~10MB per font)
|
||||
- **Flexibility**: Easily switch fonts without rebuilding the image
|
||||
- **Multi-language support**: Add support for any language by mounting appropriate fonts
|
||||
@@ -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)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/*
|
||||
X-Frame-Options: DENY
|
||||
X-Content-Type-Options: nosniff
|
||||
Content-Security-Policy: default-src 'self'; script-src 'report-sample' 'unsafe-inline' 'self' https://a.sysadmins.zone/js/embed.host.js https://static.cloudflareinsights.com/beacon.min.js/vcd15cbe7772f49c399c6a5babf22c1241717689176015 https://unpkg.com/@stoplight/elements/web-components.min.js; style-src 'report-sample' 'unsafe-inline' 'self' https://unpkg.com; object-src 'none'; base-uri 'self'; connect-src 'self' https://raw.githubusercontent.com; font-src 'self'; frame-src 'self' https://a.sysadmins.zone; img-src 'self' data: http://translate.sysadminsmedia.com; manifest-src 'self'; media-src 'self'; worker-src 'none';
|
||||
Content-Security-Policy: default-src 'self'; script-src 'report-sample' 'unsafe-inline' 'self' https://a.sysadmins.zone/js/embed.host.js https://static.cloudflareinsights.com/beacon.min.js/vcd15cbe7772f49c399c6a5babf22c1241717689176015 https://unpkg.com/@stoplight/elements/web-components.min.js; style-src 'report-sample' 'unsafe-inline' 'self' https://unpkg.com; object-src 'none'; base-uri 'self'; connect-src 'self' https://raw.githubusercontent.com https://demo.homebox.software; font-src 'self'; frame-src 'self' https://a.sysadmins.zone; img-src 'self' data: http://translate.sysadminsmedia.com; manifest-src 'self'; media-src 'self'; worker-src 'none';
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
module.exports = {
|
||||
env: {
|
||||
browser: true,
|
||||
es2021: true,
|
||||
node: true,
|
||||
},
|
||||
extends: [
|
||||
"eslint:recommended",
|
||||
"plugin:vue/essential",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"@nuxtjs/eslint-config-typescript",
|
||||
"plugin:vue/vue3-recommended",
|
||||
"plugin:prettier/recommended",
|
||||
"plugin:tailwindcss/recommended",
|
||||
],
|
||||
parserOptions: {
|
||||
ecmaVersion: "latest",
|
||||
parser: "@typescript-eslint/parser",
|
||||
sourceType: "module",
|
||||
},
|
||||
plugins: ["vue", "@typescript-eslint"],
|
||||
rules: {
|
||||
"no-console": 0,
|
||||
"no-unused-vars": "off",
|
||||
"vue/multi-word-component-names": "off",
|
||||
"vue/no-setup-props-destructure": 0,
|
||||
"vue/no-multiple-template-root": 0,
|
||||
"vue/no-v-model-argument": 0,
|
||||
"vue/no-v-html": 0,
|
||||
"@typescript-eslint/consistent-type-imports": "error",
|
||||
"@typescript-eslint/ban-ts-comment": 0,
|
||||
"tailwindcss/no-custom-classname": "warn",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
ignoreRestSiblings: true,
|
||||
destructuredArrayIgnorePattern: "_",
|
||||
caughtErrors: "none",
|
||||
},
|
||||
],
|
||||
"prettier/prettier": [
|
||||
"warn",
|
||||
{
|
||||
arrowParens: "avoid",
|
||||
semi: true,
|
||||
tabWidth: 2,
|
||||
useTabs: false,
|
||||
vueIndentScriptAndStyle: true,
|
||||
singleQuote: false,
|
||||
trailingComma: "es5",
|
||||
printWidth: 120,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
<NuxtLayout>
|
||||
<Html :lang="locale" :data-theme="theme || 'homebox'" />
|
||||
<Link rel="icon" type="image/svg" href="/favicon.svg"></Link>
|
||||
<Link rel="icon" type="image/svg" href="/favicon.svg" />
|
||||
<Link rel="apple-touch-icon" href="/apple-touch-icon.png" size="180x180" />
|
||||
<Link rel="mask-icon" href="/mask-icon.svg" color="#5b7f67" />
|
||||
<Meta name="theme-color" content="#5b7f67" />
|
||||
|
||||
@@ -1005,12 +1005,12 @@
|
||||
|
||||
.markdown :where(ul) {
|
||||
list-style: disc;
|
||||
margin-left: 2rem;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
.markdown :where(ol) {
|
||||
list-style: decimal;
|
||||
margin-left: 2rem;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
/* Heading Styles */
|
||||
.markdown :where(h1) {
|
||||
@@ -1046,4 +1046,32 @@
|
||||
:root {
|
||||
--header-height: 4rem;
|
||||
--header-height-mobile: 7rem;
|
||||
}
|
||||
|
||||
/* Non-scoped styles for regular text */
|
||||
.break-all {
|
||||
word-break: break-all;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Handle very long words */
|
||||
pre,
|
||||
code,
|
||||
a,
|
||||
p,
|
||||
span,
|
||||
div,
|
||||
td,
|
||||
th,
|
||||
li,
|
||||
blockquote,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
overflow-wrap: anywhere;
|
||||
word-break: normal;
|
||||
hyphens: auto;
|
||||
}
|
||||
@@ -47,7 +47,8 @@
|
||||
import { useMediaQuery } from "@vueuse/core";
|
||||
import type { DialogID } from "@/components/ui/dialog-provider/utils";
|
||||
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from "@/components/ui/drawer";
|
||||
import { Dialog, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Dialog, DialogFooter, DialogHeader, DialogScrollContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
|
||||
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
type Props = {
|
||||
modelValue: boolean;
|
||||
modelValue?: boolean;
|
||||
};
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -66,13 +66,13 @@
|
||||
|
||||
const api = useUserApi();
|
||||
|
||||
const importCsv = ref<File | null>(null);
|
||||
const importCsv = ref<File | undefined>(undefined);
|
||||
const importLoading = ref(false);
|
||||
const importRef = ref<HTMLInputElement>();
|
||||
whenever(
|
||||
() => !dialog.value,
|
||||
() => {
|
||||
importCsv.value = null;
|
||||
importCsv.value = undefined;
|
||||
}
|
||||
);
|
||||
|
||||
@@ -102,7 +102,7 @@
|
||||
// Reset
|
||||
dialog.value = false;
|
||||
importLoading.value = false;
|
||||
importCsv.value = null;
|
||||
importCsv.value = undefined;
|
||||
|
||||
if (importRef.value) {
|
||||
importRef.value.value = "";
|
||||
|
||||
@@ -29,12 +29,12 @@
|
||||
import { lt } from "semver";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogAction,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { useDialog } from "@/components/ui/dialog-provider";
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { DialogID } from "@/components/ui/dialog-provider/utils";
|
||||
import { DialogID, type NoParamDialogIDs, type OptionalDialogIDs } from "@/components/ui/dialog-provider/utils";
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from "~/components/ui/command";
|
||||
import { Shortcut } from "~/components/ui/shortcut";
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
export type QuickMenuAction =
|
||||
| { text: string; href: string; type: "navigate" }
|
||||
| { text: string; dialogId: DialogID; shortcut: string; type: "create" };
|
||||
| { text: string; dialogId: NoParamDialogIDs | OptionalDialogIDs; shortcut: string; type: "create" };
|
||||
|
||||
const props = defineProps({
|
||||
actions: {
|
||||
@@ -40,7 +40,7 @@
|
||||
const item = props.actions.filter(item => 'shortcut' in item).find(item => item.shortcut === e.key);
|
||||
if (item) {
|
||||
e.preventDefault();
|
||||
openDialog(item.dialogId);
|
||||
openDialog(item.dialogId as NoParamDialogIDs);
|
||||
}
|
||||
// if esc is pressed, close the dialog
|
||||
if (e.key === 'Escape') {
|
||||
@@ -61,7 +61,7 @@
|
||||
@select="
|
||||
e => {
|
||||
e.preventDefault();
|
||||
openDialog(create.dialogId);
|
||||
openDialog(create.dialogId as NoParamDialogIDs);
|
||||
}
|
||||
"
|
||||
>
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
<!-- eslint-disable-next-line tailwindcss/no-custom-classname -->
|
||||
<video ref="video" class="aspect-video w-full rounded-lg bg-muted shadow" poster="data:image/gif,AAAA"></video>
|
||||
<video ref="video" class="aspect-video w-full rounded-lg bg-muted shadow" poster="data:image/gif,AAAA" />
|
||||
<div class="mt-4">
|
||||
<Select v-model="selectedSource">
|
||||
<SelectTrigger class="w-full">
|
||||
@@ -52,13 +52,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from "vue";
|
||||
import { BrowserMultiFormatReader, NotFoundException, BarcodeFormat } from "@zxing/library";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { BarcodeFormat, BrowserMultiFormatReader, NotFoundException } from "@zxing/library";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { DialogID } from "@/components/ui/dialog-provider/utils";
|
||||
import { Dialog, DialogHeader, DialogTitle, DialogScrollContent } from "@/components/ui/dialog";
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogHeader, DialogScrollContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Button, ButtonGroup } from "@/components/ui/button";
|
||||
import MdiBarcode from "~icons/mdi/barcode";
|
||||
import MdiAlertCircleOutline from "~icons/mdi/alert-circle-outline";
|
||||
import { useDialog } from "@/components/ui/dialog-provider";
|
||||
@@ -93,7 +93,7 @@
|
||||
};
|
||||
|
||||
const handleButtonClick = () => {
|
||||
openDialog(DialogID.ProductImport, { barcode: detectedBarcode.value });
|
||||
openDialog(DialogID.ProductImport, { params: { barcode: detectedBarcode.value } });
|
||||
};
|
||||
|
||||
const startScanner = async () => {
|
||||
@@ -113,12 +113,12 @@
|
||||
|
||||
if (devices.length > 0) {
|
||||
for (let i = 0; i < devices.length; i++) {
|
||||
if (devices[i].label.toLowerCase().includes("back")) {
|
||||
selectedSource.value = devices[i].deviceId;
|
||||
if (devices[i]!.label.toLowerCase().includes("back")) {
|
||||
selectedSource.value = devices[i]!.deviceId;
|
||||
}
|
||||
}
|
||||
if (!selectedSource.value) {
|
||||
selectedSource.value = devices[0].deviceId;
|
||||
selectedSource.value = devices[0]!.deviceId;
|
||||
}
|
||||
} else {
|
||||
errorMessage.value = t("scanner.no_sources");
|
||||
|
||||
45
frontend/components/App/ThemePicker.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import { themes } from "~~/lib/data/themes";
|
||||
import { useTheme } from "~/composables/use-theme";
|
||||
|
||||
const { setTheme } = useTheme();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="homebox grid grid-cols-1 gap-4 font-sans sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
||||
<div
|
||||
v-for="theme in themes"
|
||||
:key="theme.value"
|
||||
:class="'theme-' + theme.value"
|
||||
class="overflow-hidden rounded-lg border outline-2 outline-offset-2"
|
||||
:data-theme="theme.value"
|
||||
:data-set-theme="theme.value"
|
||||
data-act-class="outline"
|
||||
@click="setTheme(theme.value)"
|
||||
>
|
||||
<div :data-theme="theme.value" class="w-full cursor-pointer bg-background-accent text-foreground">
|
||||
<div class="grid grid-cols-5 grid-rows-3">
|
||||
<div class="col-start-1 row-start-1 bg-background" />
|
||||
<div class="col-start-1 row-start-2 bg-sidebar" />
|
||||
<div class="col-start-1 row-start-3 bg-background-accent" />
|
||||
<div class="col-span-4 col-start-2 row-span-3 row-start-1 flex flex-col gap-1 bg-background p-2">
|
||||
<div class="font-bold">{{ theme.label }}</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<div class="flex size-5 items-center justify-center rounded bg-primary lg:size-6">
|
||||
<div class="text-sm font-bold text-primary-foreground">A</div>
|
||||
</div>
|
||||
<div class="flex size-5 items-center justify-center rounded bg-secondary lg:size-6">
|
||||
<div class="text-sm font-bold text-secondary-foreground">A</div>
|
||||
</div>
|
||||
<div class="flex size-5 items-center justify-center rounded bg-accent lg:size-6">
|
||||
<div class="text-sm font-bold text-accent-foreground">A</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -3,7 +3,7 @@
|
||||
<CardHeader v-if="$slots.title" class="px-4 py-5 sm:px-6">
|
||||
<component :is="collapsable ? 'button' : 'div'" v-on="collapsable ? { click: toggle } : {}">
|
||||
<h3 class="flex items-center text-lg font-medium leading-6">
|
||||
<slot name="title"></slot>
|
||||
<slot name="title" />
|
||||
<template v-if="collapsable">
|
||||
<span class="ml-2 transition-transform" :class="{ 'rotate-180': collapsed }">
|
||||
<MdiChevronDown class="size-6" />
|
||||
@@ -13,10 +13,10 @@
|
||||
</component>
|
||||
<div>
|
||||
<p v-if="$slots.subtitle" class="mt-1 max-w-2xl text-sm text-gray-500">
|
||||
<slot name="subtitle"></slot>
|
||||
<slot name="subtitle" />
|
||||
</p>
|
||||
<template v-if="$slots['title-actions']">
|
||||
<slot name="title-actions"></slot>
|
||||
<slot name="title-actions" />
|
||||
</template>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<CardTitle class="flex items-center">
|
||||
<slot />
|
||||
</CardTitle>
|
||||
<slot name="subtitle" />
|
||||
<CardDescription v-if="$slots.description">
|
||||
<slot name="description" />
|
||||
</CardDescription>
|
||||
|
||||
@@ -2,24 +2,24 @@
|
||||
<div class="flex flex-col gap-10 py-6 md:flex-row">
|
||||
<div class="flex-1">
|
||||
<h4 class="mb-1 text-lg font-semibold">
|
||||
<slot name="title"></slot>
|
||||
<slot name="title" />
|
||||
</h4>
|
||||
<p class="text-sm">
|
||||
<slot></slot>
|
||||
<slot />
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<template v-if="to">
|
||||
<NuxtLink :to="to" :class="buttonVariants({ size: 'lg' })" class="min-w-52 grow">
|
||||
<slot name="button">
|
||||
<slot name="title"></slot>
|
||||
<slot name="title" />
|
||||
</slot>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Button class="min-w-52 grow" size="lg" @click="$emit('action')">
|
||||
<slot name="button">
|
||||
<slot name="title"></slot>
|
||||
<slot name="title" />
|
||||
</slot>
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
required: true,
|
||||
required: false,
|
||||
default: "",
|
||||
},
|
||||
label: {
|
||||
@@ -81,7 +81,7 @@
|
||||
:aria-label="`${t('components.color_selector.color')}: ${modelValue || t('components.color_selector.no_color_selected')}`"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="$refs.colorInput.click()"
|
||||
@click="($refs.colorInput as HTMLInputElement).click()"
|
||||
/>
|
||||
<span v-if="showHex" class="font-mono text-xs text-muted-foreground">{{
|
||||
modelValue || t("components.color_selector.no_color")
|
||||
@@ -122,7 +122,7 @@
|
||||
:aria-label="`${t('components.color_selector.color')}: ${modelValue || t('components.color_selector.no_color_selected')}`"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="$refs.colorInput.click()"
|
||||
@click="($refs.colorInput as HTMLInputElement).click()"
|
||||
/>
|
||||
<span v-if="showHex" class="font-mono text-xs text-muted-foreground">{{
|
||||
modelValue || t("components.color_selector.no_color")
|
||||
|
||||
@@ -10,11 +10,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// @ts-ignore
|
||||
import VueDatePicker from "@vuepic/vue-datepicker";
|
||||
import "@vuepic/vue-datepicker/dist/main.css";
|
||||
import * as datelib from "~/lib/datelib/datelib";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { darkThemes } from "~/lib/data/themes";
|
||||
|
||||
const emit = defineEmits(["update:modelValue", "update:text"]);
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
},
|
||||
});
|
||||
|
||||
const isDark = useIsDark();
|
||||
const isDark = useIsThemeInList(darkThemes);
|
||||
|
||||
const formatDate = (date: Date | string | number) => fmtDate(date, "human", "date");
|
||||
|
||||
|
||||
83
frontend/components/Form/MarkdownEditor.vue
Normal file
@@ -0,0 +1,83 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue";
|
||||
import Markdown from "@/components/global/Markdown.vue";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue?: string | null;
|
||||
label?: string | null;
|
||||
maxLength?: number;
|
||||
minLength?: number;
|
||||
}>(),
|
||||
{
|
||||
modelValue: null,
|
||||
label: null,
|
||||
maxLength: -1,
|
||||
minLength: -1,
|
||||
}
|
||||
);
|
||||
|
||||
const emit = defineEmits(["update:modelValue"]);
|
||||
|
||||
const local = ref(props.modelValue ?? "");
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
v => {
|
||||
if (v !== local.value) local.value = v ?? "";
|
||||
}
|
||||
);
|
||||
|
||||
watch(local, v => emit("update:modelValue", v === "" ? null : v));
|
||||
|
||||
const showPreview = ref(false);
|
||||
|
||||
const id = useId();
|
||||
|
||||
const isLengthInvalid = computed(() => {
|
||||
if (typeof local.value !== "string") return false;
|
||||
const len = local.value.length;
|
||||
const max = props.maxLength ?? -1;
|
||||
const min = props.minLength ?? -1;
|
||||
return (max !== -1 && len > max) || (min !== -1 && len < min);
|
||||
});
|
||||
|
||||
const lengthIndicator = computed(() => {
|
||||
if (typeof local.value !== "string") return "";
|
||||
const max = props.maxLength ?? -1;
|
||||
if (max !== -1) {
|
||||
return `${local.value.length}/${max}`;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<div class="mb-2 grid grid-cols-1 items-center gap-2 md:grid-cols-4">
|
||||
<div class="min-w-0">
|
||||
<Label :for="id" class="flex min-w-0 items-center gap-2 px-1">
|
||||
<span class="truncate" :title="props.label ?? ''">{{ props.label }}</span>
|
||||
<span class="grow" />
|
||||
<span class="ml-2 text-sm" :class="{ 'text-destructive': isLengthInvalid }">{{ lengthIndicator }}</span>
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div class="col-span-1 flex items-center justify-start gap-2 md:col-span-3 md:justify-end">
|
||||
<label class="text-xs text-slate-500">{{ $t("global.preview") }}</label>
|
||||
<Checkbox v-model="showPreview" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full flex-col gap-4">
|
||||
<Textarea :id="id" v-model="local" autosize class="resize-none" />
|
||||
|
||||
<div v-if="showPreview">
|
||||
<Markdown :source="local" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<FormTextField v-model="value" :placeholder="localizedPlaceholder" :label="localizedLabel" :type="inputType">
|
||||
</FormTextField>
|
||||
<FormTextField v-model="value" :placeholder="localizedPlaceholder" :label="localizedLabel" :type="inputType" />
|
||||
<TooltipProvider :delay-duration="0">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
@@ -22,7 +21,8 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "vue-i18n";
|
||||
import MdiEye from "~icons/mdi/eye";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import FormTextField from "@/components/Form/TextField.vue";
|
||||
|
||||
const { t } = useI18n();
|
||||
type Props = {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div v-if="!inline" class="flex w-full flex-col gap-1.5">
|
||||
<Label :for="id" class="flex w-full px-1">
|
||||
<span>{{ label }}</span>
|
||||
<span class="grow"></span>
|
||||
<span class="grow" />
|
||||
<span :class="{ 'text-destructive': isLengthInvalid }">
|
||||
{{ lengthIndicator }}
|
||||
</span>
|
||||
@@ -18,7 +18,7 @@
|
||||
<div v-else class="sm:grid sm:grid-cols-4 sm:items-start sm:gap-4">
|
||||
<Label :for="id" class="flex w-full px-1 py-2">
|
||||
<span>{{ label }}</span>
|
||||
<span class="grow"></span>
|
||||
<span class="grow" />
|
||||
<span :class="{ 'text-destructive': isLengthInvalid }">
|
||||
{{ lengthIndicator }}
|
||||
</span>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div v-if="!inline" class="flex w-full flex-col gap-1.5">
|
||||
<Label :for="id" class="flex w-full px-1">
|
||||
<span> {{ label }} </span>
|
||||
<span class="grow"></span>
|
||||
<span class="grow" />
|
||||
<span
|
||||
:class="{
|
||||
'text-destructive':
|
||||
@@ -26,7 +26,7 @@
|
||||
<div v-else class="sm:grid sm:grid-cols-4 sm:items-start sm:gap-4">
|
||||
<Label class="flex w-full px-1 py-2" :for="id">
|
||||
<span> {{ label }} </span>
|
||||
<span class="grow"></span>
|
||||
<span class="grow" />
|
||||
<span
|
||||
:class="{
|
||||
'text-destructive':
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
import MdiDownload from "~icons/mdi/download";
|
||||
import MdiOpenInNew from "~icons/mdi/open-in-new";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
const props = defineProps({
|
||||
attachments: {
|
||||
|
||||
@@ -115,7 +115,11 @@
|
||||
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";
|
||||
import BaseCard from "@/components/Base/Card.vue";
|
||||
import FormTextField from "@/components/Form/TextField.vue";
|
||||
|
||||
const { openDialog, registerOpenDialogCallback } = useDialog();
|
||||
const { t } = useI18n();
|
||||
@@ -149,7 +153,7 @@
|
||||
const headers = defaultHeaders;
|
||||
|
||||
onMounted(() => {
|
||||
registerOpenDialogCallback(DialogID.ProductImport, params => {
|
||||
const cleanup = registerOpenDialogCallback(DialogID.ProductImport, params => {
|
||||
selectedRow.value = -1;
|
||||
searching.value = false;
|
||||
errorMessage.value = null;
|
||||
@@ -168,6 +172,8 @@
|
||||
products.value = null;
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(cleanup);
|
||||
});
|
||||
|
||||
const api = useUserApi();
|
||||
@@ -180,7 +186,9 @@
|
||||
selectedRow.value < products.value.length
|
||||
) {
|
||||
const p = products.value![selectedRow.value];
|
||||
openDialog(DialogID.CreateItem, { product: p });
|
||||
openDialog(DialogID.CreateItem, {
|
||||
params: { product: p },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,7 +224,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
function extractValue(data: TableData, value: string) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function extractValue(data: Record<string, any>, value: string) {
|
||||
const parts = value.split(".");
|
||||
let current = data;
|
||||
for (const part of parts) {
|
||||
|
||||
@@ -1,8 +1,30 @@
|
||||
<template>
|
||||
<Card class="overflow-hidden">
|
||||
<Card class="relative overflow-hidden">
|
||||
<div v-if="tableRow" class="absolute left-1 top-1 z-10">
|
||||
<Checkbox
|
||||
class="size-5 bg-accent hover:bg-background-accent"
|
||||
:model-value="tableRow.getIsSelected()"
|
||||
:aria-label="$t('components.item.view.selectable.select_card')"
|
||||
@update:model-value="tableRow.toggleSelected()"
|
||||
/>
|
||||
</div>
|
||||
<NuxtLink :to="`/item/${item.id}`">
|
||||
<div class="relative h-[200px]">
|
||||
<img v-if="imageUrl" class="h-[200px] w-full object-cover shadow-md" loading="lazy" :src="imageUrl" alt="" />
|
||||
<img
|
||||
v-if="imageUrl && objectContain"
|
||||
class="absolute h-[200px] w-full object-cover blur-md"
|
||||
loading="lazy"
|
||||
:src="imageUrl"
|
||||
alt=""
|
||||
/>
|
||||
<img
|
||||
v-if="imageUrl"
|
||||
class="absolute h-[200px] w-full shadow-md"
|
||||
:class="objectContain ? 'object-contain' : 'object-cover'"
|
||||
loading="lazy"
|
||||
:src="imageUrl"
|
||||
:alt="item.name"
|
||||
/>
|
||||
<div class="absolute inset-x-1 bottom-1">
|
||||
<Badge class="text-wrap bg-secondary text-secondary-foreground hover:bg-secondary/70 hover:underline">
|
||||
<NuxtLink v-if="item.location" :to="`/location/${item.location.id}`">
|
||||
@@ -32,7 +54,7 @@
|
||||
{{ $t("global.archived") }}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<div class="grow"></div>
|
||||
<div class="grow" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Badge>
|
||||
@@ -62,8 +84,13 @@
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
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) {
|
||||
@@ -90,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
|
||||
);
|
||||
|
||||
@@ -39,6 +39,8 @@
|
||||
:items="results"
|
||||
item-text="name"
|
||||
no-results-text="Type to search..."
|
||||
:is-loading="isLoading"
|
||||
:trigger-search="triggerSearch"
|
||||
/>
|
||||
<FormTextField
|
||||
ref="nameInput"
|
||||
@@ -190,6 +192,10 @@
|
||||
import { useDialog, useDialogHotkey } from "~/components/ui/dialog-provider";
|
||||
import LabelSelector from "~/components/Label/Selector.vue";
|
||||
import ItemSelector from "~/components/Item/Selector.vue";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "~/components/ui/tooltip";
|
||||
import LocationSelector from "~/components/Location/Selector.vue";
|
||||
import FormTextField from "~/components/Form/TextField.vue";
|
||||
import FormTextArea from "~/components/Form/TextArea.vue";
|
||||
|
||||
interface PhotoPreview {
|
||||
photoName: string;
|
||||
@@ -215,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();
|
||||
|
||||
@@ -276,8 +282,8 @@
|
||||
function setPrimary(index: number) {
|
||||
const primary = form.photos.findIndex(p => p.primary);
|
||||
|
||||
if (primary !== -1) form.photos[primary].primary = false;
|
||||
if (primary !== index) form.photos[index].primary = true;
|
||||
if (primary !== -1) form.photos[primary]!.primary = false;
|
||||
if (primary !== index) form.photos[index]!.primary = true;
|
||||
}
|
||||
|
||||
function previewImage(event: Event) {
|
||||
@@ -300,13 +306,13 @@
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
registerOpenDialogCallback(DialogID.CreateItem, async params => {
|
||||
const cleanup = registerOpenDialogCallback(DialogID.CreateItem, async params => {
|
||||
// needed since URL will be cleared in the next step => ParentId Selection should stay though
|
||||
subItemCreate.value = subItemCreateParam.value === "y";
|
||||
let parentItemLocationId = null;
|
||||
|
||||
if (subItemCreate.value && itemId.value) {
|
||||
const itemIdRead = typeof itemId.value === "string" ? (itemId.value as string) : itemId.value[0];
|
||||
const itemIdRead = typeof itemId.value === "string" ? (itemId.value as string) : itemId.value[0]!;
|
||||
const { data, error } = await api.items.get(itemIdRead);
|
||||
if (error || !data) {
|
||||
toast.error(t("components.item.create_modal.toast.failed_load_parent"));
|
||||
@@ -359,6 +365,8 @@
|
||||
form.labels = labels.value.filter(l => l.id === labelId.value).map(l => l.id);
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(cleanup);
|
||||
});
|
||||
|
||||
async function create(close = true) {
|
||||
@@ -374,7 +382,7 @@
|
||||
|
||||
loading.value = true;
|
||||
|
||||
if (shift.value) close = false;
|
||||
if (shift?.value) close = false;
|
||||
|
||||
const out: ItemCreate = {
|
||||
parentId: form.parentId,
|
||||
@@ -438,7 +446,7 @@
|
||||
function dataURLtoFile(dataURL: string, fileName: string) {
|
||||
try {
|
||||
const arr = dataURL.split(",");
|
||||
const mimeMatch = arr[0].match(/:(.*?);/);
|
||||
const mimeMatch = arr[0]!.match(/:(.*?);/);
|
||||
if (!mimeMatch || !mimeMatch[1]) {
|
||||
throw new Error("Invalid data URL format");
|
||||
}
|
||||
@@ -449,7 +457,7 @@
|
||||
throw new Error("Invalid mime type, expected image");
|
||||
}
|
||||
|
||||
const bstr = atob(arr[arr.length - 1]);
|
||||
const bstr = atob(arr[arr.length - 1]!);
|
||||
let n = bstr.length;
|
||||
const u8arr = new Uint8Array(n);
|
||||
while (n--) {
|
||||
@@ -498,8 +506,8 @@
|
||||
|
||||
// Encode image to data-uri with base64
|
||||
try {
|
||||
form.photos[index].fileBase64 = offScreenCanvas.toDataURL(imageType, 100);
|
||||
form.photos[index].file = dataURLtoFile(form.photos[index].fileBase64, form.photos[index].photoName);
|
||||
form.photos[index]!.fileBase64 = offScreenCanvas.toDataURL(imageType, 100);
|
||||
form.photos[index]!.file = dataURLtoFile(form.photos[index]!.fileBase64, form.photos[index]!.photoName);
|
||||
} catch (error) {
|
||||
toast.error(t("components.item.create_modal.toast.rotate_process_failed"));
|
||||
console.error(error);
|
||||
|
||||
99
frontend/components/Item/ImageDialog.vue
Normal file
@@ -0,0 +1,99 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { useDialog } from "@/components/ui/dialog-provider";
|
||||
import { DialogID } from "~/components/ui/dialog-provider/utils";
|
||||
import { useConfirm } from "@/composables/use-confirm";
|
||||
import { toast } from "@/components/ui/sonner";
|
||||
import MdiClose from "~icons/mdi/close";
|
||||
import MdiDownload from "~icons/mdi/download";
|
||||
import MdiDelete from "~icons/mdi/delete";
|
||||
|
||||
const { t } = useI18n();
|
||||
const confirm = useConfirm();
|
||||
|
||||
const { closeDialog, registerOpenDialogCallback } = useDialog();
|
||||
|
||||
const api = useUserApi();
|
||||
|
||||
const image = reactive<{
|
||||
attachmentId: string;
|
||||
itemId: string;
|
||||
originalSrc: string;
|
||||
originalType?: string;
|
||||
thumbnailSrc?: string;
|
||||
}>({
|
||||
attachmentId: "",
|
||||
itemId: "",
|
||||
originalSrc: "",
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
const cleanup = registerOpenDialogCallback(DialogID.ItemImage, params => {
|
||||
image.attachmentId = params.attachmentId;
|
||||
image.itemId = params.itemId;
|
||||
if (params.type === "preloaded") {
|
||||
image.originalSrc = params.originalSrc;
|
||||
image.originalType = params.originalType;
|
||||
image.thumbnailSrc = params.thumbnailSrc;
|
||||
} else if (params.type === "attachment") {
|
||||
image.originalSrc = api.authURL(`/items/${params.itemId}/attachments/${params.attachmentId}`);
|
||||
image.originalType = params.mimeType;
|
||||
image.thumbnailSrc = params.thumbnailId
|
||||
? api.authURL(`/items/${params.itemId}/attachments/${params.thumbnailId}`)
|
||||
: image.originalSrc;
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(cleanup);
|
||||
});
|
||||
|
||||
async function deleteAttachment() {
|
||||
const confirmed = await confirm.open(t("items.delete_attachment_confirm"));
|
||||
|
||||
if (confirmed.isCanceled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { error } = await api.items.attachments.delete(image.itemId, image.attachmentId);
|
||||
|
||||
if (error) {
|
||||
toast.error(t("items.toast.failed_delete_attachment"));
|
||||
return;
|
||||
}
|
||||
|
||||
closeDialog(DialogID.ItemImage, {
|
||||
action: "delete",
|
||||
id: image.attachmentId,
|
||||
});
|
||||
toast.success(t("items.toast.attachment_deleted"));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :dialog-id="DialogID.ItemImage">
|
||||
<DialogContent class="w-auto border-transparent bg-transparent p-0" disable-close>
|
||||
<picture>
|
||||
<source :srcset="image.originalSrc" :type="image.originalType" />
|
||||
<img :src="image.thumbnailSrc" alt="attachment image" />
|
||||
</picture>
|
||||
<Button variant="destructive" size="icon" class="absolute right-[84px] top-1" @click="deleteAttachment">
|
||||
<MdiDelete />
|
||||
</Button>
|
||||
<a :class="buttonVariants({ size: 'icon' })" :href="image.originalSrc" download class="absolute right-11 top-1">
|
||||
<MdiDownload />
|
||||
</a>
|
||||
<Button
|
||||
size="icon"
|
||||
class="absolute right-1 top-1"
|
||||
@click="
|
||||
closeDialog(DialogID.ItemImage);
|
||||
image.originalSrc = '';
|
||||
"
|
||||
>
|
||||
<MdiClose />
|
||||
</Button>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -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>
|
||||
@@ -37,7 +43,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from "vue";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { Check, ChevronsUpDown } from "lucide-vue-next";
|
||||
import fuzzysort from "fuzzysort";
|
||||
import { useVModel } from "@vueuse/core";
|
||||
@@ -47,7 +53,6 @@
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { useId } from "#imports";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -56,9 +61,9 @@
|
||||
};
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
modelValue: string | ItemsObject | null | undefined;
|
||||
items: ItemsObject[] | string[];
|
||||
label?: string;
|
||||
modelValue?: string | ItemsObject | null | undefined;
|
||||
items?: ItemsObject[] | string[];
|
||||
itemText?: string;
|
||||
itemValue?: string;
|
||||
search?: string;
|
||||
@@ -66,6 +71,8 @@
|
||||
noResultsText?: string;
|
||||
placeholder?: string;
|
||||
excludeItems?: ItemsObject[];
|
||||
isLoading?: boolean;
|
||||
triggerSearch?: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
const emit = defineEmits(["update:modelValue", "update:search"]);
|
||||
@@ -80,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")
|
||||
@@ -93,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 => {
|
||||
@@ -108,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 {
|
||||
|
||||
169
frontend/components/Item/View/ItemChangeDetails.vue
Normal file
@@ -0,0 +1,169 @@
|
||||
<script setup lang="ts">
|
||||
import { Dialog, DialogContent, DialogFooter, DialogTitle, DialogHeader } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useDialog } from "@/components/ui/dialog-provider";
|
||||
import { DialogID } from "~/components/ui/dialog-provider/utils";
|
||||
import type { ItemPatch, ItemSummary, LabelOut, LocationSummary } from "~/lib/api/types/data-contracts";
|
||||
import LocationSelector from "~/components/Location/Selector.vue";
|
||||
import MdiLoading from "~icons/mdi/loading";
|
||||
import { toast } from "~/components/ui/sonner";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import LabelSelector from "~/components/Label/Selector.vue";
|
||||
|
||||
const { closeDialog, registerOpenDialogCallback } = useDialog();
|
||||
|
||||
const api = useUserApi();
|
||||
const { t } = useI18n();
|
||||
const labelStore = useLabelStore();
|
||||
|
||||
const allLabels = computed(() => labelStore.labels);
|
||||
|
||||
const items = ref<ItemSummary[]>([]);
|
||||
const saving = ref(false);
|
||||
|
||||
const enabled = reactive({
|
||||
changeLocation: false,
|
||||
addLabels: false,
|
||||
removeLabels: false,
|
||||
});
|
||||
|
||||
const newLocation = ref<LocationSummary | null>(null);
|
||||
const addLabels = ref<string[]>([]);
|
||||
const removeLabels = ref<string[]>([]);
|
||||
|
||||
const availableToAddLabels = ref<LabelOut[]>([]);
|
||||
const availableToRemoveLabels = ref<LabelOut[]>([]);
|
||||
|
||||
const intersectLabelIds = (items: ItemSummary[]): string[] => {
|
||||
if (items.length === 0) return [];
|
||||
const counts = new Map<string, number>();
|
||||
for (const it of items) {
|
||||
const seen = new Set<string>();
|
||||
for (const l of it.labels || []) seen.add(l.id);
|
||||
for (const id of seen) counts.set(id, (counts.get(id) || 0) + 1);
|
||||
}
|
||||
return [...counts.entries()].filter(([_, c]) => c === items.length).map(([id]) => id);
|
||||
};
|
||||
|
||||
const unionLabelIds = (items: ItemSummary[]): string[] => {
|
||||
const s = new Set<string>();
|
||||
for (const it of items) for (const l of it.labels || []) s.add(l.id);
|
||||
return Array.from(s);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
const cleanup = registerOpenDialogCallback(DialogID.ItemChangeDetails, params => {
|
||||
items.value = params.items;
|
||||
enabled.changeLocation = params.changeLocation ?? false;
|
||||
enabled.addLabels = params.addLabels ?? false;
|
||||
enabled.removeLabels = params.removeLabels ?? false;
|
||||
|
||||
if (params.changeLocation && params.items.length > 0) {
|
||||
// if all locations are the same then set the current location to said location
|
||||
if (
|
||||
params.items[0]!.location &&
|
||||
params.items.every(item => item.location?.id === params.items[0]!.location?.id)
|
||||
) {
|
||||
newLocation.value = params.items[0]!.location;
|
||||
}
|
||||
}
|
||||
|
||||
if (params.addLabels && params.items.length > 0) {
|
||||
const intersection = intersectLabelIds(params.items);
|
||||
availableToAddLabels.value = allLabels.value.filter(l => !intersection.includes(l.id));
|
||||
}
|
||||
|
||||
if (params.removeLabels && params.items.length > 0) {
|
||||
const union = unionLabelIds(params.items);
|
||||
availableToRemoveLabels.value = allLabels.value.filter(l => union.includes(l.id));
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(cleanup);
|
||||
});
|
||||
|
||||
const save = async () => {
|
||||
const location = newLocation.value;
|
||||
const labelsToAdd = addLabels.value;
|
||||
const labelsToRemove = removeLabels.value;
|
||||
if (!items.value.length || (enabled.changeLocation && !location)) {
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
|
||||
await Promise.allSettled(
|
||||
items.value.map(async item => {
|
||||
const patch: ItemPatch = {
|
||||
id: item.id,
|
||||
};
|
||||
|
||||
if (enabled.changeLocation) {
|
||||
patch.locationId = location!.id;
|
||||
}
|
||||
|
||||
let currentLabels = item.labels.map(l => l.id);
|
||||
|
||||
if (enabled.addLabels) {
|
||||
currentLabels = currentLabels.concat(labelsToAdd);
|
||||
}
|
||||
|
||||
if (enabled.removeLabels) {
|
||||
currentLabels = currentLabels.filter(l => !labelsToRemove.includes(l));
|
||||
}
|
||||
|
||||
if (enabled.addLabels || enabled.removeLabels) {
|
||||
patch.labelIds = Array.from(new Set(currentLabels));
|
||||
}
|
||||
|
||||
const { error, data } = await api.items.patch(item.id, patch);
|
||||
|
||||
if (error) {
|
||||
console.error("failed to update item", item.id, data);
|
||||
toast.error(t("components.item.view.change_details.failed_to_update_item"));
|
||||
return;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
closeDialog(DialogID.ItemChangeDetails, true);
|
||||
enabled.changeLocation = false;
|
||||
enabled.addLabels = false;
|
||||
enabled.removeLabels = false;
|
||||
items.value = [];
|
||||
addLabels.value = [];
|
||||
removeLabels.value = [];
|
||||
availableToAddLabels.value = [];
|
||||
availableToRemoveLabels.value = [];
|
||||
saving.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :dialog-id="DialogID.ItemChangeDetails">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ $t("components.item.view.change_details.title") }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<LocationSelector v-if="enabled.changeLocation" v-model="newLocation" />
|
||||
<LabelSelector
|
||||
v-if="enabled.addLabels"
|
||||
v-model="addLabels"
|
||||
:labels="availableToAddLabels"
|
||||
:name="$t('components.item.view.change_details.add_labels')"
|
||||
/>
|
||||
<LabelSelector
|
||||
v-if="enabled.removeLabels"
|
||||
v-model="removeLabels"
|
||||
:labels="availableToRemoveLabels"
|
||||
:name="$t('components.item.view.change_details.remove_labels')"
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button type="submit" :disabled="saving || (enabled.changeLocation && !newLocation)" @click="save">
|
||||
<span v-if="!saving">{{ $t("global.save") }}</span>
|
||||
<MdiLoading v-else class="animate-spin" />
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -5,15 +5,33 @@
|
||||
import MdiTable from "~icons/mdi/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button, ButtonGroup } from "@/components/ui/button";
|
||||
import BaseSectionHeader from "@/components/Base/SectionHeader.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;
|
||||
});
|
||||
@@ -25,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">
|
||||
<div class="flex gap-2">
|
||||
<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>
|
||||
@@ -56,15 +86,26 @@
|
||||
</template>
|
||||
</BaseSectionHeader>
|
||||
|
||||
<template v-if="itemView === 'table'">
|
||||
<ItemViewTable :items="items" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3">
|
||||
<ItemCard v-for="item in items" :key="item.id" :item="item" />
|
||||
<div class="hidden first:block">{{ $t("components.item.view.selectable.no_items") }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<p v-if="externalPagination && pagination!.totalSize > 0" class="mb-4 flex items-center text-base font-medium">
|
||||
{{ $t("items.results", { total: pagination!.totalSize }) }}
|
||||
<span class="ml-auto text-base">
|
||||
{{
|
||||
$t("items.pages", {
|
||||
page: pagination!.page,
|
||||
totalPages: Math.ceil(pagination!.totalSize / pagination!.pageSize),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<DataTable
|
||||
:view="itemView"
|
||||
:columns="preferences.quickActions.enabled ? columns : columns.filter(c => c.enableHiding !== false)"
|
||||
:data="items"
|
||||
:location-flat-tree="locationFlatTree"
|
||||
:external-pagination="pagination"
|
||||
@refresh="$emit('refresh')"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,12 +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";
|
||||
};
|
||||
|
||||
export type TableData = Record<string, any>;
|
||||
@@ -1,325 +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="{ 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, TableHeader, TableCell, TableHead, 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 { 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];
|
||||
headers.value.splice(from, 1);
|
||||
headers.value.splice(to, 0, header);
|
||||
|
||||
preferences.value.tableHeaders = headers.value;
|
||||
};
|
||||
|
||||
const pagination = reactive({
|
||||
descending: false,
|
||||
page: 1,
|
||||
rowsPerPage: preferences.value.itemsPerTablePage,
|
||||
rowsNumber: 0,
|
||||
});
|
||||
|
||||
watch(
|
||||
() => pagination.rowsPerPage,
|
||||
newRowsPerPage => {
|
||||
preferences.value.itemsPerTablePage = newRowsPerPage;
|
||||
}
|
||||
);
|
||||
|
||||
function sortBy(property: keyof ItemSummary) {
|
||||
if (sortByProperty.value === property) {
|
||||
pagination.descending = !pagination.descending;
|
||||
} else {
|
||||
pagination.descending = false;
|
||||
}
|
||||
sortByProperty.value = property;
|
||||
}
|
||||
|
||||
function extractSortable(item: ItemSummary, property: keyof ItemSummary): string | number | boolean {
|
||||
const value = item[property];
|
||||
if (typeof value === "string") {
|
||||
// Try to parse number
|
||||
const parsed = Number(value);
|
||||
if (!isNaN(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return value.toLowerCase();
|
||||
}
|
||||
|
||||
if (typeof value !== "number" && typeof value !== "boolean") {
|
||||
return "";
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function itemSort(a: ItemSummary, b: ItemSummary) {
|
||||
if (!sortByProperty.value) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const aVal = extractSortable(a, sortByProperty.value);
|
||||
const bVal = extractSortable(b, sortByProperty.value);
|
||||
|
||||
if (typeof aVal === "string" && typeof bVal === "string") {
|
||||
return aVal.localeCompare(bVal, undefined, { numeric: true, sensitivity: "base" });
|
||||
}
|
||||
|
||||
if (aVal < bVal) {
|
||||
return -1;
|
||||
}
|
||||
if (aVal > bVal) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
const data = computed<TableData[]>(() => {
|
||||
// sort by property
|
||||
let data = [...props.items].sort(itemSort);
|
||||
|
||||
// sort descending
|
||||
if (pagination.descending) {
|
||||
data.reverse();
|
||||
}
|
||||
|
||||
// paginate
|
||||
const start = (pagination.page - 1) * pagination.rowsPerPage;
|
||||
const end = start + pagination.rowsPerPage;
|
||||
data = data.slice(start, end);
|
||||
return data;
|
||||
});
|
||||
|
||||
function extractValue(data: TableData, value: string) {
|
||||
const parts = value.split(".");
|
||||
let current = data;
|
||||
for (const part of parts) {
|
||||
current = current[part];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function cell(h: TableHeaderType) {
|
||||
return `cell-${h.value.replace(".", "_")}`;
|
||||
}
|
||||
const columns = computed(() => makeColumns(t).filter(c => c.enableHiding !== false));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DataTable view="table" :data="items" :columns="columns" disable-controls />
|
||||
</template>
|
||||
|
||||
8
frontend/components/Item/View/pagination.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { ShallowUnwrapRef } from "vue";
|
||||
|
||||
export type Pagination = ShallowUnwrapRef<{
|
||||
page: WritableComputedRef<number, number>;
|
||||
pageSize: ComputedRef<number>;
|
||||
totalSize: Ref<number, number>;
|
||||
setPage: (newPage: number) => void;
|
||||
}>;
|
||||
66
frontend/components/Item/View/table/card-view.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import ItemCard from "@/components/Item/Card.vue";
|
||||
import type { ItemSummary } from "~/lib/api/types/data-contracts";
|
||||
import type { Table as TableType } from "@tanstack/vue-table";
|
||||
import MdiSelectSearch from "~icons/mdi/select-search";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import DropdownAction from "./data-table-dropdown.vue";
|
||||
|
||||
const preferences = useViewPreferences();
|
||||
|
||||
const props = defineProps<{
|
||||
table: TableType<ItemSummary>;
|
||||
locationFlatTree?: FlatTreeItem[];
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
(e: "refresh"): void;
|
||||
}>();
|
||||
|
||||
const selectedCount = computed(() => props.table.getSelectedRowModel().rows.length);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="#selectable-subtitle" defer>
|
||||
<Checkbox
|
||||
class="size-6 p-0"
|
||||
:model-value="
|
||||
table.getIsAllPageRowsSelected() ? true : table.getSelectedRowModel().rows.length > 0 ? 'indeterminate' : false
|
||||
"
|
||||
:aria-label="$t('components.item.view.selectable.select_all')"
|
||||
@update:model-value="table.toggleAllPageRowsSelected(!!$event)"
|
||||
/>
|
||||
|
||||
<div class="grow" />
|
||||
|
||||
<div :class="['relative inline-flex items-center', selectedCount === 0 ? 'pointer-events-none opacity-50' : '']">
|
||||
<DropdownAction
|
||||
:multi="{ items: table.getSelectedRowModel().rows, columns: table.getAllColumns() }"
|
||||
view="card"
|
||||
:table="table"
|
||||
@refresh="$emit('refresh')"
|
||||
/>
|
||||
|
||||
<span v-if="selectedCount > 0" class="absolute -right-1 -top-1 flex size-4">
|
||||
<span
|
||||
class="pointer-events-none relative flex size-4 items-center justify-center whitespace-nowrap rounded-full bg-primary p-1 text-xs text-primary-foreground"
|
||||
>
|
||||
{{ String(selectedCount) }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</Teleport>
|
||||
<div v-if="table.getRowModel().rows?.length === 0" class="flex flex-col items-center gap-2">
|
||||
<MdiSelectSearch class="size-10" />
|
||||
<p>{{ $t("items.no_results") }}</p>
|
||||
</div>
|
||||
<div v-else class="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
<ItemCard
|
||||
v-for="item in table.getRowModel().rows"
|
||||
:key="item.original.id"
|
||||
:item="item.original"
|
||||
:table-row="preferences.quickActions.enabled ? item : undefined"
|
||||
:location-flat-tree="locationFlatTree"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
270
frontend/components/Item/View/table/columns.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import type { Column, ColumnDef } from "@tanstack/vue-table";
|
||||
import { h } from "vue";
|
||||
import DropdownAction from "./data-table-dropdown.vue";
|
||||
import { ArrowDown, ArrowUpDown, Check, X } from "lucide-vue-next";
|
||||
import Button from "~/components/ui/button/Button.vue";
|
||||
import Checkbox from "~/components/Form/Checkbox.vue";
|
||||
import type { ItemSummary } from "~/lib/api/types/data-contracts";
|
||||
import Currency from "~/components/global/Currency.vue";
|
||||
import DateTime from "~/components/global/DateTime.vue";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
/**
|
||||
* Create columns with i18n support.
|
||||
* Pass `t` from useI18n() when creating the columns in your component.
|
||||
*/
|
||||
export function makeColumns(t: (key: string) => string, refresh?: () => void): ColumnDef<ItemSummary>[] {
|
||||
const sortable = (column: Column<ItemSummary, unknown>, key: string) => {
|
||||
const sortState = column.getIsSorted(); // 'asc' | 'desc' | false
|
||||
if (!sortState) {
|
||||
// show the neutral up/down icon when not sorted
|
||||
return [t(key), h(ArrowUpDown, { class: "ml-2 h-4 w-4 opacity-40" })];
|
||||
}
|
||||
// show a single arrow that points up for asc (rotate-180) and down for desc
|
||||
return [
|
||||
t(key),
|
||||
h(ArrowDown, {
|
||||
class: cn(["ml-2 h-4 w-4 transition-transform opacity-100", sortState === "asc" ? "rotate-180" : ""]),
|
||||
}),
|
||||
];
|
||||
};
|
||||
|
||||
return [
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) =>
|
||||
h(Checkbox, {
|
||||
modelValue: table.getIsAllPageRowsSelected()
|
||||
? true
|
||||
: table.getSelectedRowModel().rows.length > 0
|
||||
? ("indeterminate" as unknown as boolean) // :)
|
||||
: false,
|
||||
"onUpdate:modelValue": (value: boolean) => table.toggleAllPageRowsSelected(!!value),
|
||||
ariaLabel: t("components.item.view.selectable.select_all"),
|
||||
}),
|
||||
cell: ({ row }) =>
|
||||
h(Checkbox, {
|
||||
modelValue: row.getIsSelected(),
|
||||
"onUpdate:modelValue": (value: boolean) => row.toggleSelected(!!value),
|
||||
ariaLabel: t("components.item.view.selectable.select_row"),
|
||||
}),
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
id: "assetId",
|
||||
accessorKey: "assetId",
|
||||
header: ({ column }) =>
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
variant: "ghost",
|
||||
onClick: () => column.toggleSorting(column.getIsSorted() === "asc"),
|
||||
},
|
||||
() => sortable(column, "items.asset_id")
|
||||
),
|
||||
cell: ({ row }) => h("div", { class: "text-sm" }, String(row.getValue("assetId") ?? "")),
|
||||
},
|
||||
{
|
||||
id: "name",
|
||||
accessorKey: "name",
|
||||
header: ({ column }) =>
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
variant: "ghost",
|
||||
onClick: () => column.toggleSorting(column.getIsSorted() === "asc"),
|
||||
},
|
||||
() => sortable(column, "items.name")
|
||||
),
|
||||
cell: ({ row }) => h("span", { class: "text-sm font-medium" }, row.getValue("name")),
|
||||
},
|
||||
{
|
||||
id: "quantity",
|
||||
accessorKey: "quantity",
|
||||
header: ({ column }) =>
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
variant: "ghost",
|
||||
onClick: () => column.toggleSorting(column.getIsSorted() === "asc"),
|
||||
},
|
||||
() => sortable(column, "items.quantity")
|
||||
),
|
||||
cell: ({ row }) => h("div", { class: "text-center" }, String(row.getValue("quantity") ?? "")),
|
||||
},
|
||||
{
|
||||
id: "insured",
|
||||
accessorKey: "insured",
|
||||
header: ({ column }) =>
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
variant: "ghost",
|
||||
onClick: () => column.toggleSorting(column.getIsSorted() === "asc"),
|
||||
},
|
||||
() => sortable(column, "items.insured")
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const val = row.getValue("insured");
|
||||
return h(
|
||||
"div",
|
||||
{ class: "block mx-auto w-min" },
|
||||
val ? h(Check, { class: "h-4 w-4 text-green-500" }) : h(X, { class: "h-4 w-4 text-destructive" })
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "purchasePrice",
|
||||
accessorKey: "purchasePrice",
|
||||
header: ({ column }) =>
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
variant: "ghost",
|
||||
onClick: () => column.toggleSorting(column.getIsSorted() === "asc"),
|
||||
},
|
||||
() => sortable(column, "items.purchase_price")
|
||||
),
|
||||
cell: ({ row }) =>
|
||||
h("div", { class: "text-center" }, h(Currency, { amount: Number(row.getValue("purchasePrice")) })),
|
||||
},
|
||||
{
|
||||
id: "location",
|
||||
accessorKey: "location",
|
||||
header: ({ column }) =>
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
variant: "ghost",
|
||||
onClick: () => column.toggleSorting(column.getIsSorted() === "asc"),
|
||||
},
|
||||
() => sortable(column, "items.location")
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const loc = (row.original as ItemSummary).location as { id: string; name: string } | null;
|
||||
if (loc) {
|
||||
return h("NuxtLink", { to: `/location/${loc.id}`, class: "hover:underline text-sm" }, () => loc.name);
|
||||
}
|
||||
return h("div", { class: "text-sm text-muted-foreground" }, "");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "archived",
|
||||
accessorKey: "archived",
|
||||
header: ({ column }) =>
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
variant: "ghost",
|
||||
onClick: () => column.toggleSorting(column.getIsSorted() === "asc"),
|
||||
},
|
||||
() => sortable(column, "items.archived")
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const val = row.getValue("archived");
|
||||
return h(
|
||||
"div",
|
||||
{ class: "block mx-auto w-min" },
|
||||
val ? h(Check, { class: "h-4 w-4 text-green-500" }) : h(X, { class: "h-4 w-4 text-destructive" })
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "createdAt",
|
||||
accessorKey: "createdAt",
|
||||
header: ({ column }) =>
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
variant: "ghost",
|
||||
onClick: () => column.toggleSorting(column.getIsSorted() === "asc"),
|
||||
},
|
||||
() => sortable(column, "items.created_at")
|
||||
),
|
||||
cell: ({ row }) =>
|
||||
h(
|
||||
"div",
|
||||
{ class: "text-center text-sm" },
|
||||
h(DateTime, { date: row.getValue("createdAt") as Date, datetimeType: "date" })
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "updatedAt",
|
||||
accessorKey: "updatedAt",
|
||||
header: ({ column }) =>
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
variant: "ghost",
|
||||
onClick: () => column.toggleSorting(column.getIsSorted() === "asc"),
|
||||
},
|
||||
() => sortable(column, "items.updated_at")
|
||||
),
|
||||
cell: ({ row }) =>
|
||||
h(
|
||||
"div",
|
||||
{ class: "text-center text-sm" },
|
||||
h(DateTime, { date: row.getValue("updatedAt") as Date, datetimeType: "date" })
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
enableHiding: false,
|
||||
header: ({ table }) => {
|
||||
const selectedCount = table.getSelectedRowModel().rows.length;
|
||||
return h(
|
||||
"div",
|
||||
{
|
||||
class: [
|
||||
"relative inline-flex items-center",
|
||||
selectedCount === 0 ? "opacity-50 pointer-events-none" : "",
|
||||
].join(" "),
|
||||
},
|
||||
[
|
||||
h(DropdownAction, {
|
||||
multi: {
|
||||
items: table.getSelectedRowModel().rows,
|
||||
columns: table.getAllColumns(),
|
||||
},
|
||||
onExpand: () => {
|
||||
table.getSelectedRowModel().rows.forEach(row => row.toggleExpanded());
|
||||
},
|
||||
view: "table",
|
||||
onRefresh: () => refresh?.(),
|
||||
table,
|
||||
}),
|
||||
selectedCount > 0 &&
|
||||
h(
|
||||
"span",
|
||||
{
|
||||
class: "-right-1 -top-1 absolute flex size-4",
|
||||
},
|
||||
h(
|
||||
"span",
|
||||
{
|
||||
class:
|
||||
"relative flex size-4 items-center justify-center rounded-full bg-primary p-1 text-primary-foreground text-xs pointer-events-none whitespace-nowrap",
|
||||
},
|
||||
String(selectedCount)
|
||||
)
|
||||
),
|
||||
]
|
||||
);
|
||||
},
|
||||
cell: ({ row, table }) => {
|
||||
const item = row.original;
|
||||
return h(
|
||||
"div",
|
||||
{ class: "relative" },
|
||||
h(DropdownAction, {
|
||||
item,
|
||||
onExpand: row.toggleExpanded,
|
||||
view: "table",
|
||||
onRefresh: () => refresh?.(),
|
||||
table,
|
||||
})
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
93
frontend/components/Item/View/table/data-table-controls.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<script setup lang="ts">
|
||||
import type { Table as TableType } from "@tanstack/vue-table";
|
||||
import type { ItemSummary } from "~/lib/api/types/data-contracts";
|
||||
|
||||
import MdiTableCog from "~icons/mdi/table-cog";
|
||||
|
||||
import Button from "~/components/ui/button/Button.vue";
|
||||
import {
|
||||
Pagination,
|
||||
PaginationEllipsis,
|
||||
PaginationFirst,
|
||||
PaginationLast,
|
||||
PaginationList,
|
||||
PaginationListItem,
|
||||
} from "@/components/ui/pagination";
|
||||
import { DialogID, useDialog } from "~/components/ui/dialog-provider/utils";
|
||||
import type { Pagination as PaginationType } from "../pagination";
|
||||
|
||||
const { openDialog } = useDialog();
|
||||
|
||||
const props = defineProps<{
|
||||
table: TableType<ItemSummary>;
|
||||
dataLength: number;
|
||||
externalPagination?: PaginationType;
|
||||
}>();
|
||||
|
||||
const setPage = (page: number) => {
|
||||
if (props.externalPagination) {
|
||||
if (page !== props.externalPagination.page) {
|
||||
// clear selection and expanded
|
||||
props.table.resetRowSelection();
|
||||
props.table.resetExpanded();
|
||||
}
|
||||
props.externalPagination.setPage(page);
|
||||
} else {
|
||||
props.table.setPageIndex(page - 1);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between md:gap-0">
|
||||
<div class="order-2 flex items-center gap-2 md:order-1">
|
||||
<Button class="size-10 p-0" variant="outline" @click="openDialog(DialogID.ItemTableSettings)">
|
||||
<MdiTableCog />
|
||||
</Button>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
{{
|
||||
$t("components.item.view.table.selected_rows", {
|
||||
selected: table.getFilteredSelectedRowModel().rows.length,
|
||||
total: table.getFilteredRowModel().rows.length,
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="order-1 flex w-full justify-center md:order-2 md:w-auto">
|
||||
<Pagination
|
||||
v-slot="{ page }"
|
||||
:items-per-page="externalPagination ? externalPagination.pageSize : table.getState().pagination.pageSize"
|
||||
:total="externalPagination ? externalPagination.totalSize : dataLength"
|
||||
:sibling-count="2"
|
||||
:page="externalPagination ? externalPagination.page : table.getState().pagination.pageIndex + 1"
|
||||
@update:page="val => setPage(val)"
|
||||
>
|
||||
<PaginationList v-slot="{ items: pageItems }" class="flex items-center gap-1">
|
||||
<PaginationFirst @click="() => setPage(1)" />
|
||||
<template v-for="(item, index) in pageItems">
|
||||
<PaginationListItem v-if="item.type === 'page'" :key="index" :value="item.value" as-child>
|
||||
<Button
|
||||
class="size-10 p-0"
|
||||
:variant="item.value === page ? 'default' : 'outline'"
|
||||
@click="() => setPage(item.value)"
|
||||
>
|
||||
{{ item.value }}
|
||||
</Button>
|
||||
</PaginationListItem>
|
||||
<PaginationEllipsis v-else :key="item.type" :index="index" />
|
||||
</template>
|
||||
<PaginationLast
|
||||
@click="
|
||||
() =>
|
||||
setPage(
|
||||
externalPagination
|
||||
? Math.ceil(externalPagination.totalSize / externalPagination.pageSize)
|
||||
: table.getPageCount()
|
||||
)
|
||||
"
|
||||
/>
|
||||
</PaginationList>
|
||||
</Pagination>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
273
frontend/components/Item/View/table/data-table-dropdown.vue
Normal file
@@ -0,0 +1,273 @@
|
||||
<script setup lang="ts">
|
||||
import { MoreHorizontal } from "lucide-vue-next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import type { ItemSummary } from "~/lib/api/types/data-contracts";
|
||||
import type { Column, Row, Table } from "@tanstack/vue-table";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { toast } from "~/components/ui/sonner";
|
||||
import { useDialog } from "@/components/ui/dialog-provider";
|
||||
import { DialogID } from "~/components/ui/dialog-provider/utils";
|
||||
|
||||
const { t } = useI18n();
|
||||
const api = useUserApi();
|
||||
const confirm = useConfirm();
|
||||
const preferences = useViewPreferences();
|
||||
const { openDialog } = useDialog();
|
||||
|
||||
const props = defineProps<{
|
||||
item?: ItemSummary;
|
||||
multi?: {
|
||||
items: Row<ItemSummary>[];
|
||||
columns: Column<ItemSummary>[];
|
||||
};
|
||||
view: "table" | "card";
|
||||
table: Table<ItemSummary>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "expand"): void;
|
||||
(e: "refresh"): void;
|
||||
}>();
|
||||
|
||||
const resetSelection = () => {
|
||||
props.table.resetRowSelection();
|
||||
props.table.resetExpanded();
|
||||
emit("refresh");
|
||||
};
|
||||
|
||||
const openMultiTab = async (items: string[]) => {
|
||||
if (!preferences.value.shownMultiTabWarning) {
|
||||
// TODO: add warning with link to docs and just improve this
|
||||
const { isCanceled } = await confirm.open({
|
||||
message: t("components.item.view.table.dropdown.open_multi_tab_warning"),
|
||||
href: "https://homebox.software/en/user-guide/tips-tricks#open-multiple-items-in-new-tabs",
|
||||
});
|
||||
if (isCanceled) {
|
||||
return;
|
||||
}
|
||||
preferences.value.shownMultiTabWarning = true;
|
||||
}
|
||||
|
||||
items.forEach(item => window.open(`/item/${item}`, "_blank"));
|
||||
};
|
||||
|
||||
const escapeCsvField = (value: unknown): string => {
|
||||
let str = String(value ?? "");
|
||||
// Mitigate formula injection
|
||||
if (/^[=+\-@]/.test(str)) {
|
||||
str = "'" + str;
|
||||
}
|
||||
// Escape double quotes
|
||||
str = str.replace(/"/g, '""');
|
||||
// Wrap in double quotes
|
||||
return `"${str}"`;
|
||||
};
|
||||
|
||||
const downloadCsv = (items: Row<ItemSummary>[], columns: Column<ItemSummary>[]) => {
|
||||
// get enabled columns
|
||||
const enabledColumns = columns.filter(c => c.id !== undefined && c.getIsVisible() && c.getCanHide()).map(c => c.id);
|
||||
|
||||
// create CSV header (escaped)
|
||||
const header = enabledColumns.map(escapeCsvField).join(",");
|
||||
|
||||
// map each item to a row matching enabled columns order, escaping each field
|
||||
const rows = items.map(item =>
|
||||
enabledColumns.map(col => escapeCsvField(item.original[col as keyof ItemSummary])).join(",")
|
||||
);
|
||||
|
||||
const csv = [header, ...rows].join("\n");
|
||||
const blob = new Blob([csv], { type: "text/csv" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "items.csv";
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const downloadJson = (items: Row<ItemSummary>[], columns: Column<ItemSummary>[]) => {
|
||||
// get enabled columns
|
||||
const enabledColumns = columns.filter(c => c.id !== undefined && c.getIsVisible() && c.getCanHide()).map(c => c.id);
|
||||
|
||||
// map each item to an object with only enabled columns
|
||||
const data = items.map(item => {
|
||||
const obj: Record<string, unknown> = {};
|
||||
enabledColumns.forEach(col => {
|
||||
obj[col] = item.original[col as keyof ItemSummary] ?? null;
|
||||
});
|
||||
return obj;
|
||||
});
|
||||
|
||||
const exportObj = {
|
||||
headers: enabledColumns,
|
||||
data,
|
||||
};
|
||||
|
||||
const json = JSON.stringify(exportObj, null, 2);
|
||||
const blob = new Blob([json], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "items.json";
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const deleteItems = async (ids: string[]) => {
|
||||
const { isCanceled } = await confirm.open(t("components.item.view.table.dropdown.delete_confirmation"));
|
||||
|
||||
if (isCanceled) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.allSettled(
|
||||
ids.map(id =>
|
||||
api.items.delete(id).catch(err => {
|
||||
toast.error(t("components.item.view.table.dropdown.error_deleting"));
|
||||
console.error(err);
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
resetSelection();
|
||||
};
|
||||
|
||||
const duplicateItems = async (ids: string[]) => {
|
||||
await Promise.allSettled(
|
||||
ids.map(id =>
|
||||
api.items
|
||||
.duplicate(id, {
|
||||
copyMaintenance: preferences.value.duplicateSettings.copyMaintenance,
|
||||
copyAttachments: preferences.value.duplicateSettings.copyAttachments,
|
||||
copyCustomFields: preferences.value.duplicateSettings.copyCustomFields,
|
||||
copyPrefix: preferences.value.duplicateSettings.copyPrefixOverride ?? t("items.duplicate.prefix"),
|
||||
})
|
||||
.catch(err => {
|
||||
toast.error(t("components.item.view.table.dropdown.error_duplicating"));
|
||||
console.error(err);
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
resetSelection();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button
|
||||
:variant="view === 'table' ? 'ghost' : 'outline'"
|
||||
class="size-8 p-0 hover:bg-primary hover:text-primary-foreground"
|
||||
>
|
||||
<span class="sr-only">{{ t("components.item.view.table.dropdown.open_menu") }}</span>
|
||||
<MoreHorizontal class="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>{{ t("components.item.view.table.dropdown.actions") }}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem v-if="item" as-child>
|
||||
<NuxtLink :to="`/item/${item.id}`" class="hover:underline">
|
||||
{{ t("components.item.view.table.dropdown.view_item") }}
|
||||
</NuxtLink>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem v-if="multi" @click="openMultiTab(multi.items.map(row => row.original.id))">
|
||||
{{ t("components.item.view.table.dropdown.view_items") }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem v-if="view === 'table'" @click="$emit('expand')">
|
||||
{{ t("components.item.view.table.dropdown.toggle_expand") }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<!-- change location -->
|
||||
<DropdownMenuItem
|
||||
@click="
|
||||
openDialog(DialogID.ItemChangeDetails, {
|
||||
params: { items: multi ? multi.items.map(row => row.original) : [item!], changeLocation: true },
|
||||
onClose: result => {
|
||||
if (result) {
|
||||
toast.success(t('components.item.view.table.dropdown.change_location_success'));
|
||||
resetSelection();
|
||||
}
|
||||
},
|
||||
})
|
||||
"
|
||||
>
|
||||
{{ t("components.item.view.table.dropdown.change_location") }}
|
||||
</DropdownMenuItem>
|
||||
<!-- change labels -->
|
||||
<DropdownMenuItem
|
||||
@click="
|
||||
openDialog(DialogID.ItemChangeDetails, {
|
||||
params: {
|
||||
items: multi ? multi.items.map(row => row.original) : [item!],
|
||||
addLabels: true,
|
||||
removeLabels: true,
|
||||
},
|
||||
onClose: result => {
|
||||
if (result) {
|
||||
toast.success(t('components.item.view.table.dropdown.change_labels_success'));
|
||||
resetSelection();
|
||||
}
|
||||
},
|
||||
})
|
||||
"
|
||||
>
|
||||
{{ t("components.item.view.table.dropdown.change_labels") }}
|
||||
</DropdownMenuItem>
|
||||
<!-- maintenance -->
|
||||
<DropdownMenuItem
|
||||
@click="
|
||||
openDialog(DialogID.EditMaintenance, {
|
||||
params: { type: 'create', itemId: multi ? multi.items.map(row => row.original.id) : item!.id },
|
||||
onClose: result => {
|
||||
if (result) {
|
||||
toast.success(t('components.item.view.table.dropdown.create_maintenance_success'));
|
||||
}
|
||||
},
|
||||
})
|
||||
"
|
||||
>
|
||||
{{
|
||||
multi
|
||||
? t("components.item.view.table.dropdown.create_maintenance_selected")
|
||||
: t("components.item.view.table.dropdown.create_maintenance_item")
|
||||
}}
|
||||
</DropdownMenuItem>
|
||||
<!-- duplicate -->
|
||||
<DropdownMenuItem @click="duplicateItems(multi ? multi.items.map(row => row.original.id) : [item!.id])">
|
||||
{{
|
||||
multi
|
||||
? t("components.item.view.table.dropdown.duplicate_selected")
|
||||
: t("components.item.view.table.dropdown.duplicate_item")
|
||||
}}
|
||||
</DropdownMenuItem>
|
||||
<!-- delete -->
|
||||
<DropdownMenuItem @click="deleteItems(multi ? multi.items.map(row => row.original.id) : [item!.id])">
|
||||
{{
|
||||
multi
|
||||
? t("components.item.view.table.dropdown.delete_selected")
|
||||
: t("components.item.view.table.dropdown.delete_item")
|
||||
}}
|
||||
</DropdownMenuItem>
|
||||
<!-- download -->
|
||||
<DropdownMenuSeparator v-if="multi && view === 'table'" />
|
||||
<DropdownMenuItem v-if="multi && view === 'table'" @click="downloadCsv(multi.items, multi.columns)">
|
||||
{{ t("components.item.view.table.dropdown.download_csv") }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem v-if="multi && view === 'table'" @click="downloadJson(multi.items, multi.columns)">
|
||||
{{ t("components.item.view.table.dropdown.download_json") }}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import type { ItemSummary } from "~/lib/api/types/data-contracts";
|
||||
import LabelChip from "@/components/Label/Chip.vue";
|
||||
import Badge from "~/components/ui/badge/Badge.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
item: ItemSummary;
|
||||
}>();
|
||||
|
||||
const api = useUserApi();
|
||||
|
||||
const imageUrl = computed(() => {
|
||||
if (!props.item.imageId) {
|
||||
return "/no-image.jpg";
|
||||
}
|
||||
if (props.item.thumbnailId) {
|
||||
return api.authURL(`/items/${props.item.id}/attachments/${props.item.thumbnailId}`);
|
||||
} else {
|
||||
return api.authURL(`/items/${props.item.id}/attachments/${props.item.imageId}`);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="shrink-0">
|
||||
<img :src="imageUrl" class="size-32 rounded-lg bg-muted object-cover" />
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-2">
|
||||
<h2 class="truncate text-xl font-bold">{{ item.name }}</h2>
|
||||
<Badge class="w-min text-nowrap bg-secondary text-secondary-foreground hover:bg-secondary/70 hover:underline">
|
||||
<NuxtLink v-if="item.location" :to="`/location/${item.location.id}`">
|
||||
{{ item.location.name }}
|
||||
</NuxtLink>
|
||||
</Badge>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<LabelChip v-for="label in item.labels" :key="label.id" :label="label" size="sm" />
|
||||
</div>
|
||||
<p class="whitespace-pre-line break-words text-sm text-muted-foreground">
|
||||
{{ item.description || $t("components.item.no_description") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
252
frontend/components/Item/View/table/data-table.vue
Normal file
@@ -0,0 +1,252 @@
|
||||
<script setup lang="ts" generic="TData, TValue">
|
||||
import BaseCard from "@/components/Base/Card.vue";
|
||||
import type { ColumnDef, SortingState, VisibilityState, ExpandedState } from "@tanstack/vue-table";
|
||||
import {
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
getExpandedRowModel,
|
||||
useVueTable,
|
||||
} from "@tanstack/vue-table";
|
||||
|
||||
import { camelToSnakeCase, valueUpdater } from "@/lib/utils";
|
||||
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import Button from "~/components/ui/button/Button.vue";
|
||||
import { DialogID } from "~/components/ui/dialog-provider/utils";
|
||||
import MdiArrowDown from "~icons/mdi/arrow-down";
|
||||
import MdiArrowUp from "~icons/mdi/arrow-up";
|
||||
import Checkbox from "~/components/ui/checkbox/Checkbox.vue";
|
||||
import Label from "~/components/ui/label/Label.vue";
|
||||
import type { ItemSummary } from "~/lib/api/types/data-contracts";
|
||||
|
||||
import TableView from "./table-view.vue";
|
||||
import CardView from "./card-view.vue";
|
||||
import DataTableControls from "./data-table-controls.vue";
|
||||
import type { Pagination } from "../pagination";
|
||||
import Switch from "~/components/ui/switch/Switch.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
columns: ColumnDef<ItemSummary, TValue>[];
|
||||
data: ItemSummary[];
|
||||
disableControls?: boolean;
|
||||
view: "table" | "card";
|
||||
locationFlatTree?: FlatTreeItem[];
|
||||
externalPagination?: Pagination;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
(e: "refresh"): void;
|
||||
}>();
|
||||
|
||||
const preferences = useViewPreferences();
|
||||
const defaultPageSize = preferences.value.itemsPerTablePage;
|
||||
const tableHeadersData = preferences.value.tableHeaders;
|
||||
const defaultVisible = ["name", "quantity", "insured", "purchasePrice"];
|
||||
|
||||
const tableHeaders = computed(
|
||||
() =>
|
||||
tableHeadersData ??
|
||||
props.columns
|
||||
.filter(c => c.enableHiding !== false)
|
||||
.map(c => ({
|
||||
value: c.id!,
|
||||
enabled: defaultVisible.includes(c.id ?? ""),
|
||||
}))
|
||||
);
|
||||
|
||||
const sorting = ref<SortingState>([]);
|
||||
const columnOrder = ref<string[]>([
|
||||
"select",
|
||||
...(tableHeaders.value ? tableHeaders.value.map(h => h.value) : []),
|
||||
"actions",
|
||||
]);
|
||||
const columnVisibility = ref<VisibilityState>(
|
||||
tableHeaders.value?.reduce((acc, h) => ({ ...acc, [h.value]: h.enabled }), {})
|
||||
);
|
||||
const rowSelection = ref({});
|
||||
const expanded = ref<ExpandedState>({});
|
||||
const pagination = ref({
|
||||
pageIndex: 0,
|
||||
pageSize: defaultPageSize || 12,
|
||||
});
|
||||
|
||||
watch(
|
||||
() => pagination.value.pageSize,
|
||||
newSize => {
|
||||
preferences.value.itemsPerTablePage = newSize;
|
||||
}
|
||||
);
|
||||
|
||||
const table = useVueTable<ItemSummary>({
|
||||
manualPagination: !!props.externalPagination,
|
||||
|
||||
get data() {
|
||||
return props.data;
|
||||
},
|
||||
get columns() {
|
||||
return props.columns;
|
||||
},
|
||||
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getExpandedRowModel: getExpandedRowModel(),
|
||||
|
||||
onSortingChange: updaterOrValue => valueUpdater(updaterOrValue, sorting),
|
||||
onColumnVisibilityChange: updaterOrValue => valueUpdater(updaterOrValue, columnVisibility),
|
||||
onRowSelectionChange: updaterOrValue => valueUpdater(updaterOrValue, rowSelection),
|
||||
onExpandedChange: updaterOrValue => valueUpdater(updaterOrValue, expanded),
|
||||
onColumnOrderChange: updaterOrValue => valueUpdater(updaterOrValue, columnOrder),
|
||||
onPaginationChange: updaterOrValue => valueUpdater(updaterOrValue, pagination),
|
||||
|
||||
state: {
|
||||
get sorting() {
|
||||
return sorting.value;
|
||||
},
|
||||
get columnVisibility() {
|
||||
return columnVisibility.value;
|
||||
},
|
||||
get rowSelection() {
|
||||
return rowSelection.value;
|
||||
},
|
||||
get expanded() {
|
||||
return expanded.value;
|
||||
},
|
||||
get columnOrder() {
|
||||
return columnOrder.value;
|
||||
},
|
||||
get pagination() {
|
||||
return pagination.value;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const persistHeaders = () => {
|
||||
const headers = table
|
||||
.getAllColumns()
|
||||
.filter(column => column.getCanHide())
|
||||
.map(h => ({
|
||||
value: h.id as keyof ItemSummary,
|
||||
enabled: h.getIsVisible(),
|
||||
}));
|
||||
|
||||
preferences.value.tableHeaders = headers;
|
||||
};
|
||||
|
||||
const moveHeader = (from: number, to: number) => {
|
||||
// Only allow moving between the first and last index (excluding 'select' and 'actions')
|
||||
const start = 1; // index of 'select'
|
||||
const end = columnOrder.value.length - 2; // index before 'actions'
|
||||
|
||||
if (from < start || from > end || to < start || to > end || from === to) return;
|
||||
|
||||
const order = [...columnOrder.value];
|
||||
const [moved] = order.splice(from, 1);
|
||||
order.splice(to, 0, moved!);
|
||||
columnOrder.value = order;
|
||||
|
||||
persistHeaders();
|
||||
};
|
||||
|
||||
const toggleHeader = (id: string) => {
|
||||
const header = table
|
||||
.getAllColumns()
|
||||
.filter(column => column.getCanHide())
|
||||
.find(h => h.id === id);
|
||||
if (header) {
|
||||
header.toggleVisibility();
|
||||
}
|
||||
|
||||
persistHeaders();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Dialog :dialog-id="DialogID.ItemTableSettings">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ $t("components.item.view.table.table_settings") }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div v-if="props.view === 'table'" class="flex flex-col gap-2">
|
||||
<div>{{ $t("components.item.view.table.headers") }}</div>
|
||||
<div class="flex flex-col">
|
||||
<div
|
||||
v-for="(colId, i) in columnOrder.slice(1, columnOrder.length - 1)"
|
||||
:key="colId"
|
||||
class="flex flex-row items-center gap-1"
|
||||
>
|
||||
<Button size="icon" class="size-6" variant="ghost" :disabled="i === 0" @click="moveHeader(i + 1, i)">
|
||||
<MdiArrowUp />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
class="size-6"
|
||||
variant="ghost"
|
||||
:disabled="i === columnOrder.length - 3"
|
||||
@click="moveHeader(i + 1, i + 2)"
|
||||
>
|
||||
<MdiArrowDown />
|
||||
</Button>
|
||||
<Checkbox
|
||||
:id="colId"
|
||||
:model-value="table.getColumn(colId)?.getIsVisible()"
|
||||
@update:model-value="toggleHeader(colId)"
|
||||
/>
|
||||
<label class="text-sm" :for="colId"> {{ $t(`items.${camelToSnakeCase(colId)}`) }} </label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<Label> {{ $t("components.item.view.table.rows_per_page") }} </Label>
|
||||
<Select :model-value="pagination.pageSize" @update:model-value="val => table.setPageSize(Number(val))">
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="12">12</SelectItem>
|
||||
<SelectItem :value="24">24</SelectItem>
|
||||
<SelectItem :value="48">48</SelectItem>
|
||||
<SelectItem :value="96">96</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<Label class="text-sm"> {{ $t("components.item.view.table.quick_actions") }} </Label>
|
||||
<Switch v-model="preferences.quickActions.enabled" />
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<BaseCard v-if="props.view === 'table'">
|
||||
<div>
|
||||
<TableView :table="table" :columns="columns" />
|
||||
</div>
|
||||
<div v-if="!props.disableControls" class="border-t p-3">
|
||||
<DataTableControls
|
||||
:table="table"
|
||||
:pagination="pagination"
|
||||
:data-length="data.length"
|
||||
:external-pagination="externalPagination"
|
||||
/>
|
||||
</div>
|
||||
</BaseCard>
|
||||
<div v-else>
|
||||
<CardView :table="table" :location-flat-tree="locationFlatTree" @refresh="$emit('refresh')" />
|
||||
<div v-if="!props.disableControls" class="pt-2">
|
||||
<DataTableControls
|
||||
:table="table"
|
||||
:pagination="pagination"
|
||||
:data-length="data.length"
|
||||
:external-pagination="externalPagination"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
73
frontend/components/Item/View/table/table-view.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts" generic="TValue">
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import DataTableExpandedRow from "./data-table-expanded-row.vue";
|
||||
import { FlexRender, type Column, type ColumnDef, type Table as TableType } from "@tanstack/vue-table";
|
||||
import type { ItemSummary } from "~/lib/api/types/data-contracts";
|
||||
|
||||
defineProps<{
|
||||
table: TableType<ItemSummary>;
|
||||
columns: ColumnDef<ItemSummary, TValue>[];
|
||||
}>();
|
||||
|
||||
const ariaSort = (column: Column<ItemSummary, unknown>) => {
|
||||
const s = column.getIsSorted();
|
||||
if (s === "asc") return "ascending";
|
||||
if (s === "desc") return "descending";
|
||||
return "none";
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Table class="w-full">
|
||||
<TableHeader>
|
||||
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
|
||||
<TableHead
|
||||
v-for="header in headerGroup.headers"
|
||||
:key="header.id"
|
||||
:class="[
|
||||
'text-no-transform cursor-pointer bg-secondary text-sm text-secondary-foreground hover:bg-secondary/90',
|
||||
header.column.id === 'select' || header.column.id === 'actions' ? 'w-10 px-3 text-center' : '',
|
||||
]"
|
||||
:aria-sort="ariaSort(header.column)"
|
||||
>
|
||||
<FlexRender
|
||||
v-if="!header.isPlaceholder"
|
||||
:render="header.column.columnDef.header"
|
||||
:props="header.getContext()"
|
||||
/>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<template v-if="table.getRowModel().rows?.length">
|
||||
<template v-for="row in table.getRowModel().rows" :key="row.id">
|
||||
<TableRow :data-state="row.getIsSelected() ? 'selected' : undefined">
|
||||
<TableCell
|
||||
v-for="cell in row.getVisibleCells()"
|
||||
:key="cell.id"
|
||||
:href="
|
||||
cell.column.id !== 'select' && cell.column.id !== 'actions' ? `/item/${row.original.id}` : undefined
|
||||
"
|
||||
:class="cell.column.id === 'select' || cell.column.id === 'actions' ? 'w-10 px-3' : ''"
|
||||
:compact="cell.column.id === 'select' || cell.column.id === 'actions'"
|
||||
>
|
||||
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow v-if="row.getIsExpanded()">
|
||||
<TableCell :colspan="row.getAllCells().length">
|
||||
<DataTableExpandedRow :item="row.original" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
<TableRow>
|
||||
<TableCell :colspan="columns.length" class="h-24 text-center">
|
||||
<p>{{ $t("items.no_results") }}</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</template>
|
||||
@@ -34,6 +34,9 @@
|
||||
import BaseModal from "@/components/App/CreateModal.vue";
|
||||
import { useDialog, useDialogHotkey } from "~/components/ui/dialog-provider";
|
||||
import ColorSelector from "@/components/Form/ColorSelector.vue";
|
||||
import FormTextField from "~/components/Form/TextField.vue";
|
||||
import FormTextArea from "~/components/Form/TextArea.vue";
|
||||
import { Button, ButtonGroup } from "~/components/ui/button";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -72,7 +75,7 @@
|
||||
|
||||
loading.value = true;
|
||||
|
||||
if (shift.value) close = false;
|
||||
if (shift?.value) close = false;
|
||||
|
||||
const { error, data } = await api.labels.create(form);
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -90,6 +90,7 @@
|
||||
TagsInputItemText,
|
||||
} from "@/components/ui/tags-input";
|
||||
import type { LabelOut } from "~/lib/api/types/data-contracts";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -107,6 +108,11 @@
|
||||
type: Array as () => LabelOut[],
|
||||
required: true,
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const modelValue = useVModel(props, "modelValue", emit);
|
||||
|
||||
@@ -51,8 +51,6 @@
|
||||
});
|
||||
|
||||
const count = computed(() => {
|
||||
if (hasCount.value) {
|
||||
return (props.location as LocationOutCount).itemCount;
|
||||
}
|
||||
return hasCount.value ? (props.location as LocationOutCount).itemCount : undefined;
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -37,6 +37,9 @@
|
||||
import BaseModal from "@/components/App/CreateModal.vue";
|
||||
import type { LocationSummary } from "~~/lib/api/types/data-contracts";
|
||||
import { useDialog, useDialogHotkey } from "~/components/ui/dialog-provider";
|
||||
import LocationSelector from "~/components/Location/Selector.vue";
|
||||
import FormTextField from "~/components/Form/TextField.vue";
|
||||
import FormTextArea from "~/components/Form/TextArea.vue";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -94,7 +97,7 @@
|
||||
}
|
||||
loading.value = true;
|
||||
|
||||
if (shift.value) close = false;
|
||||
if (shift?.value) close = false;
|
||||
|
||||
const { data, error } = await api.locations.create({
|
||||
name: form.name,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import MdiChevronRight from "~icons/mdi/chevron-right";
|
||||
import MdiMapMarker from "~icons/mdi/map-marker";
|
||||
import MdiPackageVariant from "~icons/mdi/package-variant";
|
||||
import LocationTreeNode from "./Node.vue";
|
||||
|
||||
type Props = {
|
||||
treeId: string;
|
||||
@@ -51,7 +52,7 @@
|
||||
'hover:bg-accent hover:text-accent-foreground': hasChildren,
|
||||
}"
|
||||
>
|
||||
<div v-if="!hasChildren" class="size-6"></div>
|
||||
<div v-if="!hasChildren" class="size-6" />
|
||||
<div v-else class="group/node relative size-6" :data-swap="openRef">
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center transition-transform duration-300 group-data-[swap=true]/node:rotate-90"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { TreeItem } from "~~/lib/api/types/data-contracts";
|
||||
import LocationTreeNode from "./Node.vue";
|
||||
|
||||
type Props = {
|
||||
locs: TreeItem[];
|
||||
|
||||
@@ -29,19 +29,19 @@
|
||||
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";
|
||||
import { useDialog } from "@/components/ui/dialog-provider";
|
||||
import FormTextField from "~/components/Form/TextField.vue";
|
||||
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: "",
|
||||
@@ -49,7 +49,7 @@
|
||||
scheduledDate: null as Date | null,
|
||||
description: "",
|
||||
cost: "",
|
||||
itemId: null as string | null,
|
||||
itemIds: null as string[] | null,
|
||||
});
|
||||
|
||||
async function dispatchFormSubmit() {
|
||||
@@ -62,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() {
|
||||
@@ -100,73 +104,42 @@
|
||||
return;
|
||||
}
|
||||
|
||||
closeDialog(DialogID.EditMaintenance);
|
||||
emit("changed");
|
||||
closeDialog(DialogID.EditMaintenance, true);
|
||||
}
|
||||
|
||||
const openCreateModal = (itemId: string) => {
|
||||
entry.id = null;
|
||||
entry.name = "";
|
||||
entry.completedDate = null;
|
||||
entry.scheduledDate = null;
|
||||
entry.description = "";
|
||||
entry.cost = "";
|
||||
entry.itemId = itemId;
|
||||
openDialog(DialogID.EditMaintenance);
|
||||
};
|
||||
|
||||
const openUpdateModal = (maintenanceEntry: MaintenanceEntry | MaintenanceEntryWithDetails) => {
|
||||
entry.id = maintenanceEntry.id;
|
||||
entry.name = maintenanceEntry.name;
|
||||
entry.completedDate = new Date(maintenanceEntry.completedDate);
|
||||
entry.scheduledDate = new Date(maintenanceEntry.scheduledDate);
|
||||
entry.description = maintenanceEntry.description;
|
||||
entry.cost = maintenanceEntry.cost;
|
||||
entry.itemId = null;
|
||||
openDialog(DialogID.EditMaintenance);
|
||||
};
|
||||
|
||||
const confirm = useConfirm();
|
||||
|
||||
async function deleteEntry(id: string) {
|
||||
const result = await confirm.open(t("maintenance.modal.delete_confirmation"));
|
||||
if (result.isCanceled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { error } = await api.maintenance.delete(id);
|
||||
|
||||
if (error) {
|
||||
toast.error(t("maintenance.toast.failed_to_delete"));
|
||||
return;
|
||||
}
|
||||
emit("changed");
|
||||
}
|
||||
|
||||
async function complete(maintenanceEntry: MaintenanceEntry) {
|
||||
const { error } = await api.maintenance.update(maintenanceEntry.id, {
|
||||
name: maintenanceEntry.name,
|
||||
completedDate: new Date(Date.now()),
|
||||
scheduledDate: maintenanceEntry.scheduledDate ?? "null",
|
||||
description: maintenanceEntry.description,
|
||||
cost: maintenanceEntry.cost,
|
||||
onMounted(() => {
|
||||
const cleanup = registerOpenDialogCallback(DialogID.EditMaintenance, params => {
|
||||
switch (params.type) {
|
||||
case "create":
|
||||
entry.id = null;
|
||||
entry.name = "";
|
||||
entry.completedDate = null;
|
||||
entry.scheduledDate = null;
|
||||
entry.description = "";
|
||||
entry.cost = "";
|
||||
entry.itemIds = typeof params.itemId === "string" ? [params.itemId] : params.itemId;
|
||||
break;
|
||||
case "update":
|
||||
entry.id = params.maintenanceEntry.id;
|
||||
entry.name = params.maintenanceEntry.name;
|
||||
entry.completedDate = new Date(params.maintenanceEntry.completedDate);
|
||||
entry.scheduledDate = new Date(params.maintenanceEntry.scheduledDate);
|
||||
entry.description = params.maintenanceEntry.description;
|
||||
entry.cost = params.maintenanceEntry.cost;
|
||||
entry.itemIds = null;
|
||||
break;
|
||||
case "duplicate":
|
||||
entry.id = null;
|
||||
entry.name = params.maintenanceEntry.name;
|
||||
entry.completedDate = null;
|
||||
entry.scheduledDate = null;
|
||||
entry.description = params.maintenanceEntry.description;
|
||||
entry.cost = params.maintenanceEntry.cost;
|
||||
entry.itemIds = [params.itemId];
|
||||
break;
|
||||
}
|
||||
});
|
||||
if (error) {
|
||||
toast.error(t("maintenance.toast.failed_to_update"));
|
||||
}
|
||||
emit("changed");
|
||||
}
|
||||
|
||||
function duplicate(maintenanceEntry: MaintenanceEntry | MaintenanceEntryWithDetails, itemId: string) {
|
||||
entry.id = null;
|
||||
entry.name = maintenanceEntry.name;
|
||||
entry.completedDate = null;
|
||||
entry.scheduledDate = null;
|
||||
entry.description = maintenanceEntry.description;
|
||||
entry.cost = maintenanceEntry.cost;
|
||||
entry.itemId = itemId;
|
||||
openDialog(DialogID.EditMaintenance);
|
||||
}
|
||||
|
||||
defineExpose({ openCreateModal, openUpdateModal, deleteEntry, complete, duplicate });
|
||||
onUnmounted(cleanup);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { MaintenanceEntryWithDetails } from "~~/lib/api/types/data-contracts";
|
||||
import type { MaintenanceEntry, MaintenanceEntryWithDetails } from "~~/lib/api/types/data-contracts";
|
||||
import { MaintenanceFilterStatus } from "~~/lib/api/types/data-contracts";
|
||||
import type { StatsFormat } from "~~/components/global/StatCard/types";
|
||||
import MdiCheck from "~icons/mdi/check";
|
||||
@@ -11,15 +11,25 @@
|
||||
import MdiWrenchClock from "~icons/mdi/wrench-clock";
|
||||
import MdiContentDuplicate from "~icons/mdi/content-duplicate";
|
||||
import MaintenanceEditModal from "~~/components/Maintenance/EditModal.vue";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ButtonGroup, Button } from "@/components/ui/button";
|
||||
import { Button, ButtonGroup } from "@/components/ui/button";
|
||||
import StatCard from "~/components/global/StatCard/StatCard.vue";
|
||||
import BaseCard from "@/components/Base/Card.vue";
|
||||
import BaseSectionHeader from "@/components/Base/SectionHeader.vue";
|
||||
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: {
|
||||
@@ -75,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>
|
||||
@@ -116,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") }}
|
||||
@@ -125,7 +173,7 @@
|
||||
</section>
|
||||
<section>
|
||||
<!-- begin -->
|
||||
<MaintenanceEditModal ref="maintenanceEditModal" @changed="refreshList"></MaintenanceEditModal>
|
||||
<MaintenanceEditModal ref="maintenanceEditModal" @changed="refreshList" />
|
||||
<div class="container space-y-6">
|
||||
<BaseCard v-for="e in maintenanceDataList" :key="e.id">
|
||||
<BaseSectionHeader class="border-b p-6">
|
||||
@@ -165,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>
|
||||
@@ -192,7 +260,16 @@
|
||||
<button
|
||||
type="button"
|
||||
class="relative block w-full rounded-lg border-2 border-dashed p-12 text-center"
|
||||
@click="maintenanceEditModal?.openCreateModal(props.currentItemId)"
|
||||
@click="
|
||||
openDialog(DialogID.EditMaintenance, {
|
||||
params: { type: 'create', itemId: props.currentItemId },
|
||||
onClose: result => {
|
||||
if (result) {
|
||||
refreshList();
|
||||
}
|
||||
},
|
||||
})
|
||||
"
|
||||
>
|
||||
<MdiWrenchClock class="inline size-16" />
|
||||
<span class="mt-2 block text-sm font-medium text-gray-900"> {{ $t("maintenance.list.create_first") }} </span>
|
||||
|
||||
@@ -3,7 +3,14 @@
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{{ $t("global.confirm") }}</AlertDialogTitle>
|
||||
<AlertDialogDescription> {{ text || $t("global.delete_confirm") }} </AlertDialogDescription>
|
||||
<AlertDialogDescription>
|
||||
{{ text || $t("global.delete_confirm") }}
|
||||
</AlertDialogDescription>
|
||||
<div v-if="href && href !== ''">
|
||||
<a :href="href" target="_blank" rel="noopener noreferrer" class="break-all text-sm text-primary underline">
|
||||
{{ href }}
|
||||
</a>
|
||||
</div>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel @click="cancel(false)">
|
||||
@@ -19,8 +26,18 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useDialog } from "./ui/dialog-provider";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
const { text, isRevealed, confirm, cancel } = useConfirm();
|
||||
const { text, href, isRevealed, confirm, cancel } = useConfirm();
|
||||
const { addAlert, removeAlert } = useDialog();
|
||||
|
||||
watch(
|
||||
|
||||