toolshed/frontend/src/views/Inventory.vue
2026-09-06 02:27:17 +02:00

360 lines
No EOL
20 KiB
Vue

<template>
<BaseLayout>
<main class="content">
<div class="container-fluid p-0">
<h1 class="h3 mb-3">Inventory</h1>
<div class="row">
<div class="col-12 col-xl-12">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center flex-wrap">
<h5 class="card-title mb-0">{{ selectedOwner }}'s Inventory</h5>
<div class="d-flex align-items-center">
<!--label for="ownerSelect" class="visually-hidden">Viewing inventory for</label-->
<select id="ownerSelect" class="form-select form-select-sm me-2" style="width:auto"
v-model="selectedOwner">
<option :value="user">{{ user }} (you)</option>
<option v-for="group in ownerGroups" :value="group.handle" :key="group.handle">
{{ group.handle }}
</option>
<option v-for="friend in friends" :value="friend.handle" :key="friend.handle">
{{ friend.handle }}
</option>
</select>
<div class="btn-group">
<button class="btn" @click="fetchItemsForOwner">Refresh</button>
<router-link v-if="canEdit" :to="addItemRoute" class="btn btn-primary">Add</router-link>
<button v-if="layout === 'grid'" @click="layout = 'masonry'" class="btn">
<b-icon-columns-gap></b-icon-columns-gap>
</button>
<button v-else-if="layout === 'masonry'" @click="layout = 'table'" class="btn">
<b-icon-list></b-icon-list>
</button>
<button v-else @click="layout = 'grid'" class="btn">
<b-icon-grid></b-icon-grid>
</button>
</div>
</div>
</div>
<table class="table table-striped" v-if="layout === 'table'">
<thead>
<tr>
<th style="width:35%;">Name</th>
<th style="width:20%">Availability Policy</th>
<th style="width:20%">Visibility Policy</th>
<th class="d-none d-md-table-cell" style="width:25%">Amount</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="item in items" :key="item.id">
<td>
<router-link :to="itemRoute(item)">{{ item.name }}</router-link>
</td>
<td class="d-none d-md-table-cell">
<span class="badge bg-secondary text-white">{{ item.availability_policy }}</span>
</td>
<td class="d-none d-md-table-cell">
<span class="badge bg-secondary text-white">{{ item.visibility_policy }}</span>
</td>
<td class="d-none d-md-table-cell">{{ item.owned_quantity }}</td>
<td class="table-action">
<router-link v-if="canEdit" :to="itemEditRoute(item)" title="Edit">
<b-icon-pencil-square></b-icon-pencil-square>
</router-link>
<guarded-button v-if="canEdit" tag="a" title="Delete"
:confirm-message='`Are you sure you want to delete "${item.name}"? This cannot be undone.`'
@confirm="tryDeleteItem(item)">
<b-icon-trash></b-icon-trash>
</guarded-button>
<router-link v-if="shortIdLink(item)" :to="shortIdLink(item)">
<b-icon-link></b-icon-link>
</router-link>
<router-link v-if="printLinkFor(item)" :to="printLinkFor(item)" title="Print Label">
<b-icon-qr-code></b-icon-qr-code>
</router-link>
</td>
</tr>
</tbody>
</table>
<div class="card-body" v-else-if="layout === 'masonry'">
<div class="masonry-grid">
<div class="masonry-item" v-for="item in items" :key="item.id">
<div class="card">
<authenticated-image v-if="only_images(item.files).length > 0"
:src="only_images(item.files)[0].name"
:owner="only_images(item.files)[0].owner"
class="card-img-top img-preview-masonry"/>
<div class="card-body">
<h5 class="card-title mb-0">
<router-link :to="itemRoute(item)">
{{ item.name }}
</router-link></h5>
<div class="card-text text-black-50">
<span class="badge bg-secondary text-white">{{ item.availability_policy }}</span>
<span class="badge bg-secondary text-white">{{ item.visibility_policy }}</span>
<span class="float-right">{{ item.owned_quantity }}</span>
</div>
<div class="btn-group">
<guarded-button v-if="canEdit" class="btn btn-danger btn-sm"
title="Delete"
:confirm-message='`Are you sure you want to delete "${item.name}"? This cannot be undone.`'
@confirm="tryDeleteItem(item)">
<b-icon-trash></b-icon-trash>
</guarded-button>
<router-link v-if="canEdit" :to="itemEditRoute(item)"
class="btn btn-primary btn-sm" title="Edit">
<b-icon-pencil-square></b-icon-pencil-square>
</router-link>
<router-link v-if="shortIdLink(item)" :to="shortIdLink(item)"
class="btn btn-secondary btn-sm">
<b-icon-link></b-icon-link>
</router-link>
<router-link v-if="printLinkFor(item)" :to="printLinkFor(item)"
class="btn btn-secondary btn-sm" title="Print Label">
<b-icon-qr-code></b-icon-qr-code>
</router-link>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="card-body" v-else>
<div class="row">
<div class="col-12 col-sm-6 col-md-4 col-lg-3 col-xl-2 col-xxl-1 d-flex" v-for="item in items"
:key="item.id">
<div class="card w-100 d-flex flex-column">
<authenticated-image v-if="only_images(item.files).length > 0"
:src="only_images(item.files)[0].name"
:owner="only_images(item.files)[0].owner"
class="card-img-top img-preview-grid"/>
<div class="card-body d-flex flex-column flex-grow-1">
<h5 class="card-title mb-0">
<router-link :to="itemRoute(item)">
{{ item.name }}
</router-link></h5>
<div class="card-text text-black-50">
<span class="badge bg-secondary text-white">{{ item.availability_policy }}</span>
<span class="badge bg-secondary text-white">{{ item.visibility_policy }}</span>
<span class="float-right">{{ item.owned_quantity }}</span>
</div>
<div class="btn-group mt-auto">
<guarded-button v-if="canEdit" class="btn btn-danger btn-sm"
title="Delete"
:confirm-message='`Are you sure you want to delete "${item.name}"? This cannot be undone.`'
@confirm="tryDeleteItem(item)">
<b-icon-trash></b-icon-trash>
</guarded-button>
<router-link v-if="canEdit" :to="itemEditRoute(item)"
class="btn btn-primary btn-sm" title="Edit">
<b-icon-pencil-square></b-icon-pencil-square>
</router-link>
<router-link v-if="shortIdLink(item)" :to="shortIdLink(item)"
class="btn btn-secondary btn-sm">
<b-icon-link></b-icon-link>
</router-link>
<router-link v-if="printLinkFor(item)" :to="printLinkFor(item)"
class="btn btn-secondary btn-sm" title="Print Label">
<b-icon-qr-code></b-icon-qr-code>
</router-link>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
</BaseLayout>
</template>
<script>
import {mapActions, mapGetters, mapMutations, mapState} from "vuex";
import * as BIcons from "bootstrap-icons-vue";
import BaseLayout from "@/components/BaseLayout.vue";
import AuthenticatedImage from "../components/AuthenticatedImage.vue";
import GuardedButton from "@/components/GuardedButton.vue";
import {shortenedRoute, encodeHandleForUrl, decodeHandleFromUrl} from "@/router";
export default {
name: "Inventory",
props: {
// Matches /inventory/:owner; absent means "me". See docs/implementation.md#owner-filtered-overview-routes-use-path-segments-not-query-strings.
owner: {
type: String,
default: null
}
},
components: {
AuthenticatedImage,
BaseLayout,
GuardedButton,
...BIcons
},
computed: {
...mapGetters(["inventory_items", "groupInventoryItems", "loaded_items", "identityIdByHandle", "groupIdByHandle", "getPreference"]),
...mapState(["user", "storage_locations", "groups", "groupMemberships", "friends"]),
// Implicit preference: remembers the last-used view without a settings-page entry. See docs/implementation.md#preferences.
layout: {
get() {
return this.getPreference('ui.remembered.inventory_view_mode', 'grid')
},
set(value) {
this.setDevicePreference({key: 'ui.remembered.inventory_view_mode', value})
}
},
// Groups hosted here plus GroupMembership-only ones - see Groups.vue's allGroups for the same merge/dedupe.
ownerGroups() {
const hostedHandles = new Set(this.groups.map(group => group.handle))
const foreign = this.groupMemberships.filter(m => !hostedHandles.has(m.handle))
return [...this.groups, ...foreign].sort((a, b) => a.handle.localeCompare(b.handle))
},
// See docs/implementation.md#owner-filtered-overview-routes-use-path-segments-not-query-strings.
selectedOwner: {
get() {
return this.owner ? decodeHandleFromUrl(this.owner) : this.user
},
set(value) {
const params = value === this.user ? {} : {owner: encodeHandleForUrl(value)}
this.$router.replace({name: 'inventory', params})
}
},
isGroupSelected() {
return !!this.selectedOwner && this.selectedOwner.startsWith('#')
},
// Friend items are browse-only - inventory.py's perform_create/update/destroy reject writes from anyone but the owner/a fellow group member.
canEdit() {
return this.selectedOwner === this.user || this.isGroupSelected
},
items() {
// item_map is keyed by owner handle regardless of group vs friend - see store.js's groupInventoryItems.
return this.selectedOwner === this.user ? this.inventory_items : this.groupInventoryItems(this.selectedOwner)
},
addItemRoute() {
const params = this.selectedOwner === this.user ? {} : {group: encodeHandleForUrl(this.selectedOwner)}
return {name: 'inventory-new', params}
}
},
methods: {
...mapActions(["fetchInventoryItems", "fetchGroupInventoryItems", "fetchFriendInventoryItems",
"deleteInventoryItem", "fetchStorageLocations", "fetchIdMap", "fetchGroups",
"fetchGroupMemberships", "fetchFriends", "setDevicePreference"]),
fetchItemsForOwner() {
if (this.selectedOwner === this.user) return this.fetchInventoryItems()
if (this.isGroupSelected) return this.fetchGroupInventoryItems({groupHandle: this.selectedOwner})
return this.fetchFriendInventoryItems({friendHandle: this.selectedOwner})
},
tryDeleteItem(item) {
this.deleteInventoryItem(item).then(() => {
this.fetchItemsForOwner()
})
},
// Derived from the item's own owner, not the current selection, so the link stays correct if the dropdown changes underneath it.
itemRoute(item) {
return {name: 'inventory-detail', params: {handle: encodeHandleForUrl(item.owner_group || item.owner), id: item.id}}
},
itemEditRoute(item) {
return {name: 'inventory-edit', params: {handle: encodeHandleForUrl(item.owner_group || item.owner), id: item.id}}
},
only_images(files) {
return files.filter(file => file.mime_type.startsWith("image/"));
},
locationPath(item) {
const loc = this.storage_locations.find(loc => loc.id === item.storage_location)
return loc ? loc.path : null
},
shortIdLink(item) {
if (item.owner_group) {
const owner_group_id = this.groupIdByHandle[item.owner_group]
if (owner_group_id === undefined) return null
return shortenedRoute({kind: 'group_item', owner_group_id, item_local_id: item.id})
}
const owner_identity_id = this.identityIdByHandle[item.owner]
if (owner_identity_id === undefined) return null
return shortenedRoute({kind: 'item', owner_identity_id, item_local_id: item.id})
},
printLinkFor(item) {
const userHandle = item.owner || item.owner_group
if (!userHandle) return null
return {name: 'print', query: {kind: 'item', userHandle, id: item.id}}
},
},
watch: {
owner() {
this.fetchItemsForOwner()
}
},
async mounted() {
this.fetchItemsForOwner()
await this.fetchStorageLocations()
await this.fetchIdMap()
await this.fetchGroups()
await this.fetchGroupMemberships()
await this.fetchFriends()
}
}
</script>
<style scoped lang="scss">
@import "../scss/breakpoints";
.img-preview-grid {
height: 160px;
object-fit: cover;
}
.img-preview-masonry {
width: 100%;
height: auto;
object-fit: cover;
}
.table-action {
white-space: nowrap;
}
.masonry-grid {
column-count: 2;
column-gap: 1rem;
}
.masonry-item {
break-inside: avoid;
margin-bottom: 1rem;
}
@include media-breakpoint-up(md) {
.masonry-grid {
column-count: 3;
}
}
@include media-breakpoint-up(lg) {
.masonry-grid {
column-count: 4;
}
}
@include media-breakpoint-up(xl) {
.masonry-grid {
column-count: 6;
}
}
@include media-breakpoint-up(xxl) {
.masonry-grid {
column-count: 12;
}
}
.unguarded.btn-danger,
.unguarded.btn-danger:hover,
.unguarded.btn-danger:focus {
background-color: orange;
border-color: orange;
}
</style>