This commit is contained in:
j3d1 2026-08-26 22:18:02 +02:00
parent 4c9e8f942e
commit 23185e1721
13 changed files with 365 additions and 107 deletions

View file

@ -127,7 +127,55 @@
</div>
<div class="card">
<button class="btn" @click="fetchItems">Refresh</button>
<router-link v-if="isOwnDomain" :to="`/inventory/new?group=${group.id}`"
<router-link :to="`/inventory/new?group=${encodeHandleForUrl(group.handle)}`"
class="btn btn-primary">Add
</router-link>
</div>
</div>
<div class="col-12 col-xl-6">
<div class="card">
<div class="card-header">
<h5 class="card-title">Group storage locations</h5>
</div>
<table class="table table-striped">
<thead>
<tr>
<th style="width:40%;">Name</th>
<th style="width:25%">Path</th>
<th class="d-none d-md-table-cell" style="width:25%">Category</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="location in storageLocations" :key="location.id">
<td>
<router-link :to="locationRoute(location)">{{ location.name }}</router-link>
</td>
<td class="d-none d-md-table-cell">
<span class="text-muted">{{ location.path }}</span>
</td>
<td class="d-none d-md-table-cell">
<span class="badge bg-info text-white" v-if="location.category">{{ location.category }}</span>
<span class="text-muted" v-else>-</span>
</td>
<td class="table-action">
<router-link :to="`${locationRoute(location)}/edit`">
<b-icon-pencil-square></b-icon-pencil-square>
</router-link>
<a :href="`${locationRoute(location)}/delete`" @click.prevent="tryDeleteLocation(location)">
<b-icon-trash></b-icon-trash>
</a>
<router-link v-if="locationPrintLinkFor(location)" :to="locationPrintLinkFor(location)">
<b-icon-qr-code></b-icon-qr-code>
</router-link>
</td>
</tr>
</tbody>
</table>
</div>
<div class="card">
<button class="btn" @click="fetchLocations">Refresh</button>
<router-link :to="`/storage-locations/new?group=${encodeHandleForUrl(group.handle)}`"
class="btn btn-primary">Add
</router-link>
</div>
@ -139,7 +187,7 @@
</template>
<script>
import {mapActions, mapGetters, mapState} from "vuex";
import {mapActions, mapGetters} from "vuex";
import * as BIcons from "bootstrap-icons-vue";
import BaseLayout from "@/components/BaseLayout.vue";
import UserNameTag from "@/components/UserNameTag.vue";
@ -167,21 +215,21 @@ export default {
}
},
computed: {
...mapState(['user']),
decodedHandle() {
return decodeHandleFromUrl(this.handle)
},
items() {
return this.group ? this.groupInventoryItems(this.group.id, this.group.domain) : []
return this.group ? this.groupInventoryItems(this.group.handle) : []
},
isOwnDomain() {
return this.group && this.group.domain === this.user.split('@')[1]
storageLocations() {
return this.group ? this.groupStorageLocations(this.group.handle) : []
},
...mapGetters(['groupInventoryItems'])
...mapGetters(['groupInventoryItems', 'groupStorageLocations'])
},
methods: {
encodeHandleForUrl,
...mapActions(['fetchGroup', 'removeGroupMember', 'inviteToGroup', 'fetchGroupInventoryItems',
'deleteInventoryItem']),
'deleteInventoryItem', 'fetchGroupStorageLocations', 'deleteStorageLocation']),
// Loads by handle regardless of which backend actually hosts the group - see
// store.js's fetchGroup.
refresh() {
@ -191,7 +239,7 @@ export default {
},
fetchItems() {
if (this.group) {
this.fetchGroupInventoryItems({groupId: this.group.id, domain: this.group.domain})
this.fetchGroupInventoryItems({groupHandle: this.group.handle})
}
},
itemRoute(item) {
@ -200,11 +248,24 @@ export default {
printLinkFor(item) {
return {path: '/print', query: {kind: 'item', userHandle: this.group.handle, item: item.id}}
},
fetchLocations() {
if (this.group) {
this.fetchGroupStorageLocations({groupHandle: this.group.handle})
}
},
locationRoute(location) {
return `/storage-locations/${encodeHandleForUrl(this.group.handle)}/${location.id}`
},
locationPrintLinkFor(location) {
return {path: '/print', query: {kind: 'storage-location', userHandle: this.group.handle, location: location.id}}
},
tryDeleteLocation(location) {
this.deleteStorageLocation(location).then(() => {
this.fetchLocations()
})
},
tryInvite() {
this.inviteToGroup({
groupId: this.group.id, domain: this.group.domain, groupHandle: this.group.handle,
invitee: this.invitee
}).then((ok) => {
this.inviteToGroup({groupHandle: this.group.handle, invitee: this.invitee}).then((ok) => {
if (ok) {
this.show_invite = false
this.invitee = ""
@ -213,7 +274,7 @@ export default {
})
},
tryRemoveMember(member) {
this.removeGroupMember({groupId: this.group.id, domain: this.group.domain, identityId: member.id})
this.removeGroupMember({groupHandle: this.group.handle, identityId: member.id})
.then(() => {
this.refresh()
}).catch(() => {
@ -226,7 +287,10 @@ export default {
}
},
mounted() {
this.refresh().then(() => this.fetchItems())
this.refresh().then(() => {
this.fetchItems()
this.fetchLocations()
})
}
}
</script>

View file

@ -28,7 +28,7 @@
<label for="owner" class="form-label">Owner</label>
<select class="form-select" id="owner" name="owner" v-model="item.owner_group">
<option :value="null">Myself</option>
<option v-for="group in groups" :value="group.id" :key="group.id">
<option v-for="group in ownerGroups" :value="group.handle" :key="group.handle">
{{ group.handle }}
</option>
</select>
@ -84,7 +84,7 @@ import BaseLayout from "@/components/BaseLayout.vue";
import TagField from "@/components/TagField.vue";
import PropertyField from "@/components/PropertyField.vue";
import CombinedFileField from "@/components/CombinedFileField.vue";
import {ownerOverviewRoute} from "@/router";
import {decodeHandleFromUrl, ownerOverviewRoute} from "@/router";
export default {
name: "InventoryNew",
@ -107,23 +107,34 @@ export default {
properties: [],
files: [],
storage_location: null,
// The group's own "#name@domain" handle once picked, or null for a personal
// item - createInventoryItem resolves the target domain from it directly.
owner_group: null
}
}
},
methods: {
ownerOverviewRoute,
...mapActions(['createInventoryItem', 'fetchInfo', 'fetchStorageLocations', 'fetchGroups'])
...mapActions(['createInventoryItem', 'fetchInfo', 'fetchStorageLocations', 'fetchGroups',
'fetchGroupMemberships'])
},
computed: {
...mapState(["availability_policies", "storage_locations", "groups"]),
...mapState(["availability_policies", "storage_locations", "groups", "groupMemberships"]),
// Groups hosted here plus groups only known via a GroupMembership pointer (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))
}
},
async mounted() {
await this.fetchInfo();
await this.fetchStorageLocations();
await this.fetchGroups();
await this.fetchGroupMemberships();
if (this.$route.query.group) {
this.item.owner_group = parseInt(this.$route.query.group, 10)
this.item.owner_group = decodeHandleFromUrl(this.$route.query.group)
}
}
}

View file

@ -464,10 +464,18 @@ export default {
}
if (f.userHandle.startsWith("#")) {
const owner_group_id = this.groupIdByHandle[f.userHandle];
if (owner_group_id === undefined || !f.itemId) {
if (owner_group_id === undefined) {
return null;
}
return shortenedRoute({kind: "group_item", owner_group_id, item_local_id: f.itemId}).slice(1);
if (f.itemId) {
return shortenedRoute({kind: "group_item", owner_group_id, item_local_id: f.itemId}).slice(1);
}
if (f.locationId) {
return shortenedRoute({
kind: "group_storage_location", owner_group_id, storage_location_id: f.locationId
}).slice(1);
}
return null;
}
const owner_identity_id = this.identityIdByHandle[f.userHandle];
if (owner_identity_id === undefined) {

View file

@ -154,7 +154,7 @@ export default {
};
},
methods: {
...mapActions(["fetchItemByHandle", "fetchStorageLocations", "fetchIdMap"]),
...mapActions(["fetchItemByHandle", "fetchStorageLocationByHandle", "fetchIdMap"]),
resolveDescription(entry) {
const {link, text} = entry;
@ -219,12 +219,19 @@ export default {
}
return handle === undefined ? null : handle;
}
if (decoded.kind === "storage_location") {
if (!this.$store.state.storage_locations.length) {
await this.fetchStorageLocations();
if (decoded.kind === "storage_location" || decoded.kind === "group_storage_location") {
const byId = decoded.kind === "storage_location"
? this.$store.getters.identityHandleById
: this.$store.getters.groupHandleById;
const ownerId = decoded.kind === "storage_location" ? decoded.owner_identity_id : decoded.owner_group_id;
let handle = byId[ownerId];
if (handle === undefined) {
await this.fetchIdMap();
handle = (decoded.kind === "storage_location"
? this.$store.getters.identityHandleById
: this.$store.getters.groupHandleById)[ownerId];
}
const location = this.$store.state.storage_locations.find(l => l.id === decoded.storage_location_id);
return location ? `[#${location.id}] ${location.name}` : null;
return handle === undefined ? null : this.describeLocation(handle, decoded.storage_location_id);
}
return null; // workflow, category, file: no per-item title lookup wired up yet
},
@ -234,6 +241,11 @@ export default {
return item ? `[#${item.id}] ${item.name}` : null;
},
async describeLocation(handle, id) {
const location = await this.fetchStorageLocationByHandle({handle, id});
return location ? `[#${location.id}] ${location.name}` : null;
},
// Handler for CameraScanner's "scan" event - logs every code from the batch it decoded.
onCameraScan(codes) {
codes.forEach(this.logDecode);

View file

@ -73,6 +73,7 @@ const EXAMPLES = [
{kind: 'group_item', owner_group_id: 5, item_local_id: 42},
{kind: 'file', file_id: 123},
{kind: 'storage_location', owner_identity_id: 3, storage_location_id: 1000},
{kind: 'group_storage_location', owner_group_id: 5, storage_location_id: 1000},
{kind: 'category', category_id: 5},
{kind: 'workflow', owner_identity_id: 2, workflow_id: 9},
];

View file

@ -27,7 +27,7 @@
<tbody>
<tr v-for="location in storage_locations" :key="location.id">
<td>
<router-link :to="`/storage-locations/${location.id}`">{{ location.name }}</router-link>
<router-link :to="locationRoute(location)">{{ location.name }}</router-link>
</td>
<td class="d-none d-md-table-cell">
<span class="text-muted">{{ location.path }}</span>
@ -37,10 +37,10 @@
<span class="text-muted" v-else>-</span>
</td>
<td class="table-action">
<router-link :to="`/storage-locations/${location.id}/edit`">
<router-link :to="`${locationRoute(location)}/edit`">
<b-icon-pencil-square></b-icon-pencil-square>
</router-link>
<a :href="`/storage-locations/${location.id}/delete`" @click.prevent="deleteStorageLocation(location)">
<a :href="`${locationRoute(location)}/delete`" @click.prevent="deleteStorageLocation(location)">
<b-icon-trash></b-icon-trash>
</a>
<router-link v-if="shortIdLink(location)" :to="shortIdLink(location)">
@ -60,7 +60,7 @@
<div class="card">
<div class="card-body">
<h5 class="card-title mb-0">
<router-link :to="`/storage-locations/${location.id}`">
<router-link :to="locationRoute(location)">
{{ location.name }}
</router-link>
</h5>
@ -75,7 +75,7 @@
<button class="btn btn-danger btn-sm"
@click="deleteStorageLocation(location.id)">Delete
</button>
<router-link :to="`/storage-locations/${location.id}/edit`"
<router-link :to="`${locationRoute(location)}/edit`"
class="btn btn-primary btn-sm">Edit
</router-link>
<router-link v-if="shortIdLink(location)" :to="shortIdLink(location)"
@ -109,7 +109,7 @@
import {mapActions, mapGetters, mapState} from "vuex";
import * as BIcons from "bootstrap-icons-vue";
import BaseLayout from "@/components/BaseLayout.vue";
import {shortenedRoute} from "@/router";
import {shortenedRoute, encodeHandleForUrl} from "@/router";
export default {
name: "StorageLocation",
@ -123,12 +123,25 @@ export default {
...BIcons
},
computed: {
...mapGetters(["identityIdByHandle"]),
...mapGetters(["identityIdByHandle", "groupIdByHandle"]),
...mapState(["user", "storage_locations"]),
},
methods: {
...mapActions(["fetchStorageLocations", "deleteStorageLocation", "fetchIdMap"]),
// This list is always the caller's own personal locations (fetchStorageLocations has no
// group content), so the owner_group branches below are currently unreachable here - kept
// for parity with Inventory.vue's shortIdLink/printLinkFor in case that ever changes.
locationRoute(location) {
return `/storage-locations/${encodeHandleForUrl(location.owner_group || location.owner)}/${location.id}`
},
shortIdLink(location) {
if (location.owner_group) {
const owner_group_id = this.groupIdByHandle[location.owner_group]
if (owner_group_id === undefined) return null
return shortenedRoute({
kind: 'group_storage_location', owner_group_id, storage_location_id: location.id
})
}
const owner_identity_id = this.identityIdByHandle[location.owner]
if (owner_identity_id === undefined) return null
return shortenedRoute({kind: 'storage_location', owner_identity_id, storage_location_id: location.id})
@ -136,7 +149,8 @@ export default {
// Routes to Print.vue with this location's raw identity, same shape as Inventory.vue's
// printLinkFor. See docs/implementation.md#print-link-shape-for-storage-locations.
printLinkFor(location) {
return {path: '/print', query: {kind: 'storage-location', userHandle: location.owner, location: location.id}}
const userHandle = location.owner_group || location.owner
return {path: '/print', query: {kind: 'storage-location', userHandle, location: location.id}}
},
},
async mounted() {

View file

@ -23,20 +23,25 @@
</div>
<div class="mb-3">
<label for="owner" class="form-label">Owner</label>
<div>{{ location.owner?.username || '-' }}</div>
<div>{{ location.owner || location.owner_group || '-' }}</div>
</div>
</div>
</div>
<div class="card">
<button class="btn btn-primary" @click="$router.push('/storage-locations/' + id + '/edit')">
<div class="card" v-if="canEdit">
<button class="btn btn-primary" @click="$router.push(`/storage-locations/${handle}/${id}/edit`)">
<b-icon-pencil-square></b-icon-pencil-square>
Edit
</button>
<button type="submit" class="btn btn-danger"
@click="deleteStorageLocation(location).then(() => $router.push('/storage-location'))">
@click="deleteStorageLocation(location).then(() => $router.push(ownerOverviewRoute(location)))">
<b-icon-trash></b-icon-trash>
Delete
</button>
<button class="btn btn-secondary"
@click="$router.push({path: '/print', query: {kind: 'storage-location', userHandle: decodedHandle, location: id}})">
<b-icon-printer></b-icon-printer>
Print label
</button>
</div>
</div>
</div>
@ -47,7 +52,8 @@
<script>
import * as BIcons from "bootstrap-icons-vue";
import BaseLayout from "@/components/BaseLayout.vue";
import {mapActions, mapState} from "vuex";
import {mapActions, mapGetters, mapState} from "vuex";
import {decodeHandleFromUrl, ownerOverviewRoute} from "@/router";
export default {
name: "StorageLocationDetail",
@ -56,22 +62,52 @@ export default {
...BIcons
},
props: {
handle: {
type: String,
required: true
},
id: {
type: String,
required: true
}
},
data() {
return {
location: {}
}
},
computed: {
...mapState(["storage_locations"]),
location() {
return this.storage_locations.find(loc => loc.id === parseInt(this.id)) || {}
...mapGetters(["groupIdByHandle"]),
...mapState(["user", "groupMemberships"]),
decodedHandle() {
return decodeHandleFromUrl(this.handle)
},
// Am I a member of this group, hosted here or elsewhere - see InventoryDetail.vue's
// isGroupMember for the same check on the item side.
isGroupMember() {
return this.decodedHandle in this.groupIdByHandle
|| this.groupMemberships.some(m => m.handle === this.decodedHandle)
},
canEdit() {
return this.decodedHandle === this.user || this.isGroupMember
}
},
methods: {
...mapActions(["fetchStorageLocations", "deleteStorageLocation"]),
ownerOverviewRoute,
...mapActions(["fetchStorageLocationByHandle", "deleteStorageLocation", "fetchGroupMemberships"]),
async loadLocation() {
this.location = await this.fetchStorageLocationByHandle({handle: this.decodedHandle, id: this.id}) || {}
}
},
async mounted() {
await this.fetchStorageLocations()
watch: {
handle: 'loadLocation',
id: 'loadLocation'
},
mounted() {
this.loadLocation()
if (this.decodedHandle.startsWith('#')) {
this.fetchGroupMemberships()
}
}
}
</script>

View file

@ -55,6 +55,7 @@
import * as BIcons from "bootstrap-icons-vue";
import {mapActions, mapState} from "vuex";
import BaseLayout from "@/components/BaseLayout.vue";
import {decodeHandleFromUrl, ownerOverviewRoute} from "@/router";
export default {
name: "StorageLocationEdit",
@ -63,32 +64,56 @@ export default {
...BIcons
},
props: {
handle: {
type: String,
required: true
},
id: {
type: String,
required: true
}
},
computed: {
...mapState(["storage_locations", "categories"]),
location() {
return {
data() {
return {
location: {
name: "",
description: "",
category: null,
parent: null,
...this.storage_locations.find(loc => loc.id === parseInt(this.id))
}
},
// The location's own owner's full location list, for the parent dropdown - own
// personal locations, or the owning group's own (see loadLocation).
siblingLocations: []
}
},
computed: {
...mapState(["categories"]),
decodedHandle() {
return decodeHandleFromUrl(this.handle)
},
availableParents() {
// Filter out self and children to prevent circular references
return this.storage_locations.filter(loc =>
return this.siblingLocations.filter(loc =>
loc.id !== parseInt(this.id) &&
!this.isChildOf(loc, parseInt(this.id))
)
}
},
methods: {
...mapActions(["fetchStorageLocations", "updateStorageLocation", "fetchInfo"]),
ownerOverviewRoute,
...mapActions(["fetchStorageLocationByHandle", "updateStorageLocation", "fetchInfo",
"fetchStorageLocations", "fetchGroupStorageLocations"]),
async loadLocation() {
const loaded = await this.fetchStorageLocationByHandle({handle: this.decodedHandle, id: this.id})
if (loaded) {
this.location = {...this.location, ...loaded}
}
if (this.decodedHandle.startsWith('#')) {
this.siblingLocations = await this.fetchGroupStorageLocations({groupHandle: this.decodedHandle})
} else {
this.siblingLocations = await this.fetchStorageLocations()
}
},
submitForm() {
// Convert empty strings to null for ForeignKey fields
const locationData = {
@ -96,21 +121,25 @@ export default {
category: this.location.category === "" ? null : this.location.category,
parent: this.location.parent === "" ? null : this.location.parent
};
this.updateStorageLocation(locationData);
this.updateStorageLocation(locationData).then(updated => this.$router.push(ownerOverviewRoute(updated)));
},
isChildOf(location, parentId) {
// Simple check to prevent circular references
let current = location;
while (current && current.parent) {
if (current.parent === parentId) return true;
current = this.storage_locations.find(loc => loc.id === current.parent);
current = this.siblingLocations.find(loc => loc.id === current.parent);
}
return false;
}
},
watch: {
handle: 'loadLocation',
id: 'loadLocation'
},
async mounted() {
await this.fetchInfo();
await this.fetchStorageLocations();
await this.loadLocation();
}
}
</script>

View file

@ -26,12 +26,21 @@
</option>
</select>
</div>
<div class="mb-3">
<label for="owner" class="form-label">Owner</label>
<select class="form-select" id="owner" name="owner" v-model="location.owner_group">
<option :value="null">Myself</option>
<option v-for="group in ownerGroups" :value="group.handle" :key="group.handle">
{{ group.handle }}
</option>
</select>
</div>
<div class="mb-3">
<label for="parent" class="form-label">Parent Location</label>
<select class="form-select" id="parent" name="parent"
v-model="location.parent">
<option value="">No Parent</option>
<option v-for="parent in storage_locations" :value="parent.id">
<option v-for="parent in availableParents" :value="parent.id">
{{ parent.path }}
</option>
</select>
@ -53,6 +62,7 @@
import * as BIcons from "bootstrap-icons-vue";
import {mapActions, mapState} from "vuex";
import BaseLayout from "@/components/BaseLayout.vue";
import {decodeHandleFromUrl, ownerOverviewRoute} from "@/router";
export default {
name: "StorageLocationNew",
@ -66,12 +76,27 @@ export default {
name: "",
description: "",
category: null,
parent: null
}
parent: null,
// The group's own "#name@domain" handle once picked, or null for a personal
// location - createStorageLocation resolves the target domain from it directly.
owner_group: null
},
// The selected owner's own location list, for the parent dropdown - own personal
// locations, or the chosen group's own (see loadParentOptionsForOwner).
groupLocations: []
}
},
methods: {
...mapActions(['createStorageLocation', 'fetchInfo', 'fetchStorageLocations']),
ownerOverviewRoute,
...mapActions(['createStorageLocation', 'fetchInfo', 'fetchStorageLocations', 'fetchGroups',
'fetchGroupMemberships', 'fetchGroupStorageLocations']),
async loadParentOptionsForOwner() {
if (!this.location.owner_group) {
this.groupLocations = []
return
}
this.groupLocations = await this.fetchGroupStorageLocations({groupHandle: this.location.owner_group})
},
submitForm() {
// Convert empty strings to null for ForeignKey fields
const locationData = {
@ -79,15 +104,37 @@ export default {
category: this.location.category === "" ? null : this.location.category,
parent: this.location.parent === "" ? null : this.location.parent
};
this.createStorageLocation(locationData).then(() => this.$router.push('/storage-location'));
this.createStorageLocation(locationData).then(created => this.$router.push(ownerOverviewRoute(created)));
}
},
computed: {
...mapState(["categories", "storage_locations"]),
...mapState(["categories", "storage_locations", "groups", "groupMemberships"]),
// Groups hosted here plus groups only known via a GroupMembership pointer (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))
},
availableParents() {
return this.location.owner_group ? this.groupLocations : this.storage_locations
}
},
watch: {
'location.owner_group'() {
// Previously-selected parent likely doesn't belong to the newly-chosen owner.
this.location.parent = null
this.loadParentOptionsForOwner()
}
},
async mounted() {
await this.fetchInfo();
await this.fetchStorageLocations();
await this.fetchGroups();
await this.fetchGroupMemberships();
if (this.$route.query.group) {
this.location.owner_group = decodeHandleFromUrl(this.$route.query.group)
}
}
}
</script>