add UI for backend search
All checks were successful
/ test (push) Successful in 2m42s

This commit is contained in:
j3d1 2025-01-09 14:48:05 +01:00
parent ea3aaddb24
commit efb9f2cd20
6 changed files with 273 additions and 14 deletions

View file

@ -115,10 +115,10 @@ export default {
this.$router.push(link); this.$router.push(link);
}, },
isItemView() { isItemView() {
return this.getActiveView === 'items' || this.getActiveView === 'item'; return this.getActiveView === 'items' || this.getActiveView === 'item' || this.getActiveView === 'item_search';
}, },
isTicketView() { isTicketView() {
return this.getActiveView === 'tickets' || this.getActiveView === 'ticket'; return this.getActiveView === 'tickets' || this.getActiveView === 'ticket' || this.getActiveView === 'ticket_search';
}, },
setLayout(layout) { setLayout(layout) {
if (this.route.query.layout === layout) if (this.route.query.layout === layout)

View file

@ -12,6 +12,7 @@
<script> <script>
import {mapActions, mapGetters} from "vuex"; import {mapActions, mapGetters} from "vuex";
import router from "@/router";
export default { export default {
name: 'SearchBox', name: 'SearchBox',
@ -21,21 +22,34 @@ export default {
} }
}, },
computed: { computed: {
...mapGetters(['getActiveView']) ...mapGetters(['getActiveView', 'route']),
},
watch: {
route() {
this.search_query = this.route.params.search || '';
}
}, },
methods: { methods: {
...mapActions(['searchEventItems', 'searchEventTickets']), ...mapActions(['searchEventItems', 'searchEventTickets']),
isItemView() { isItemView() {
return this.getActiveView === 'items' || this.getActiveView === 'item'; return this.getActiveView === 'items' || this.getActiveView === 'item' || this.getActiveView === 'item_search';
}, },
isTicketView() { isTicketView() {
return this.getActiveView === 'tickets' || this.getActiveView === 'ticket'; return this.getActiveView === 'tickets' || this.getActiveView === 'ticket' || this.getActiveView === 'ticket_search';
}, },
dispatchSearch() { dispatchSearch() {
if (this.isItemView()) { if (this.isItemView()) {
this.searchEventItems(this.search_query); router.push({
name: "item_search",
query: this.route.query,
params: {...this.route.params, search: this.search_query}
});
} else if (this.isTicketView()) { } else if (this.isTicketView()) {
this.searchEventTickets(this.search_query); router.push({
name: "ticket_search",
query: this.route.query,
params: {...this.route.params, search: this.search_query}
});
} }
} }
} }

View file

@ -2,6 +2,7 @@ import {createRouter, createWebHistory} from 'vue-router'
import store from '@/store'; import store from '@/store';
import Item from "@/views/Item.vue"; import Item from "@/views/Item.vue";
import ItemSearch from "@/views/ItemSearch.vue";
import Items from '@/views/Items'; import Items from '@/views/Items';
import Boxes from '@/views/Boxes'; import Boxes from '@/views/Boxes';
import Files from '@/views/Files'; import Files from '@/views/Files';
@ -10,6 +11,7 @@ import Login from '@/views/Login.vue';
import Register from '@/views/Register.vue'; import Register from '@/views/Register.vue';
import Dashboard from "@/views/admin/Dashboard.vue"; import Dashboard from "@/views/admin/Dashboard.vue";
import Tickets from "@/views/Tickets.vue"; import Tickets from "@/views/Tickets.vue";
import TicketSearch from "@/views/TicketSearch.vue";
import Ticket from "@/views/Ticket.vue"; import Ticket from "@/views/Ticket.vue";
import Admin from "@/views/admin/Admin.vue"; import Admin from "@/views/admin/Admin.vue";
import Empty from "@/views/Empty.vue"; import Empty from "@/views/Empty.vue";
@ -27,6 +29,10 @@ const routes = [
path: '/:event/items/', name: 'items', component: Items, meta: path: '/:event/items/', name: 'items', component: Items, meta:
{requiresAuth: true, requiresPermission: 'view_item'} {requiresAuth: true, requiresPermission: 'view_item'}
}, },
{
path: '/:event/items/:search', name: 'item_search', component: ItemSearch, meta:
{requiresAuth: true, requiresPermission: 'view_item'}
},
{ {
path: '/:event/item/:id/', name: 'item', component: Item, meta: path: '/:event/item/:id/', name: 'item', component: Item, meta:
{requiresAuth: true, requiresPermission: 'view_item'} {requiresAuth: true, requiresPermission: 'view_item'}
@ -43,6 +49,10 @@ const routes = [
path: '/:event/tickets/', name: 'tickets', component: Tickets, meta: path: '/:event/tickets/', name: 'tickets', component: Tickets, meta:
{requiresAuth: true, requiresPermission: 'view_issuethread'} {requiresAuth: true, requiresPermission: 'view_issuethread'}
}, },
{
path: '/:event/tickets/:search', name: 'ticket_search', component: TicketSearch, meta:
{requiresAuth: true, requiresPermission: 'view_issuethread'}
},
{ {
path: '/:event/ticket/:id/', name: 'ticket', component: Ticket, meta: path: '/:event/ticket/:id/', name: 'ticket', component: Ticket, meta:
{requiresAuth: true, requiresPermission: 'view_issuethread'} {requiresAuth: true, requiresPermission: 'view_issuethread'}

View file

@ -9,6 +9,7 @@ import persistentStatePlugin from "@/persistent-state-plugin";
const store = createStore({ const store = createStore({
state: { state: {
//shared
keyIncrement: 0, keyIncrement: 0,
events: [], events: [],
items: [], items: [],
@ -22,9 +23,12 @@ const store = createStore({
loadedItems: {}, loadedItems: {},
loadedTickets: {}, loadedTickets: {},
loadedItemSearchResults: {},
loadedTicketSearchResults: {},
//local
lastEvent: 'all', lastEvent: 'all',
lastUsed: {}, lastUsed: {},
searchQuery: '',
remember: false, remember: false,
user: { user: {
username: null, username: null,
@ -66,12 +70,17 @@ const store = createStore({
route: state => router.currentRoute.value, route: state => router.currentRoute.value,
session: state => http_session(state.user.token), session: state => http_session(state.user.token),
getEventSlug: state => router.currentRoute.value.params.event ? router.currentRoute.value.params.event : state.lastEvent, getEventSlug: state => router.currentRoute.value.params.event ? router.currentRoute.value.params.event : state.lastEvent,
searchQuery: state => router.currentRoute.value.params.search,
getAllItems: state => Object.values(state.loadedItems).flat(), getAllItems: state => Object.values(state.loadedItems).flat(),
getAllTickets: state => Object.values(state.loadedTickets).flat(), getAllTickets: state => Object.values(state.loadedTickets).flat(),
getEventItems: (state, getters) => getters.getEventSlug === 'all' ? getters.getAllItems : getters.getAllItems.filter(t => t.event === getters.getEventSlug || (t.event == null && getters.getEventSlug === 'none')), getEventItems: (state, getters) => getters.getEventSlug === 'all' ? getters.getAllItems : getters.getAllItems.filter(t => t.event === getters.getEventSlug || (t.event == null && getters.getEventSlug === 'none')),
getEventTickets: (state, getters) => getters.getEventSlug === 'all' ? getters.getAllTickets : getters.getAllTickets.filter(t => t.event === getters.getEventSlug || (t.event == null && getters.getEventSlug === 'none')), getEventTickets: (state, getters) => getters.getEventSlug === 'all' ? getters.getAllTickets : getters.getAllTickets.filter(t => t.event === getters.getEventSlug || (t.event == null && getters.getEventSlug === 'none')),
isItemsLoaded: (state, getters) => (getters.getEventSlug === 'all' || getters.getEventSlug === 'none') ? !!state.loadedItems : Object.keys(state.loadedItems).includes(getters.getEventSlug), isItemsLoaded: (state, getters) => (getters.getEventSlug === 'all' || getters.getEventSlug === 'none') ? !!state.loadedItems : Object.keys(state.loadedItems).includes(getters.getEventSlug),
isTicketsLoaded: (state, getters) => (getters.getEventSlug === 'all' || getters.getEventSlug === 'none') ? !!state.loadedTickets : Object.keys(state.loadedTickets).includes(getters.getEventSlug), isTicketsLoaded: (state, getters) => (getters.getEventSlug === 'all' || getters.getEventSlug === 'none') ? !!state.loadedTickets : Object.keys(state.loadedTickets).includes(getters.getEventSlug),
getItemsSearchResults: (state, getters) => state.loadedItemSearchResults[getters.getEventSlug + '/' + base64.encode(utf8.encode(getters.searchQuery))] || [],
getTicketsSearchResults: (state, getters) => state.loadedTicketSearchResults[getters.getEventSlug + '/' + base64.encode(utf8.encode(getters.searchQuery))] || [],
isItemsSearchLoaded: (state, getters) => Object.keys(state.loadedItemSearchResults).includes(getters.getEventSlug + '/' + base64.encode(utf8.encode(getters.searchQuery))),
isTicketsSearchLoaded: (state, getters) => Object.keys(state.loadedTicketSearchResults).includes(getters.getEventSlug + '/' + base64.encode(utf8.encode(getters.searchQuery))),
getActiveView: state => router.currentRoute.value.name || 'items', getActiveView: state => router.currentRoute.value.name || 'items',
getFilters: state => router.currentRoute.value.query, getFilters: state => router.currentRoute.value.query,
getBoxes: state => state.loadedBoxes, getBoxes: state => state.loadedBoxes,
@ -106,9 +115,9 @@ const store = createStore({
layout: (state, getters) => { layout: (state, getters) => {
if (router.currentRoute.value.query.layout) if (router.currentRoute.value.query.layout)
return router.currentRoute.value.query.layout; return router.currentRoute.value.query.layout;
if (getters.getActiveView === 'items') if (getters.getActiveView === 'items' || getters.getActiveView === 'item_search')
return 'cards'; return 'cards';
if (getters.getActiveView === 'tickets') if (getters.getActiveView === 'tickets' || getters.getActiveView === 'ticket_search')
return 'tasks'; return 'tasks';
}, },
isLoggedIn(state) { isLoggedIn(state) {
@ -147,6 +156,10 @@ const store = createStore({
state.loadedItems[slug] = items; state.loadedItems[slug] = items;
state.loadedItems = {...state.loadedItems}; state.loadedItems = {...state.loadedItems};
}, },
setItemSearchResults(state, {slug, query, items}) {
state.loadedItemSearchResults[slug + '/' + query] = items;
state.loadedItemSearchResults = {...state.loadedItemSearchResults};
},
replaceItems(state, items) { replaceItems(state, items) {
const groups = Object.groupBy(items, i => i.event ? i.event : 'none') const groups = Object.groupBy(items, i => i.event ? i.event : 'none')
for (const [key, value] of Object.entries(groups)) state.loadedItems[key] = value; for (const [key, value] of Object.entries(groups)) state.loadedItems[key] = value;
@ -167,6 +180,10 @@ const store = createStore({
state.loadedTickets[slug] = tickets; state.loadedTickets[slug] = tickets;
state.loadedTickets = {...state.loadedTickets}; state.loadedTickets = {...state.loadedTickets};
}, },
setTicketSearchResults(state, {slug, query, items}) {
state.loadedTicketSearchResults[slug + '/' + query] = items;
state.loadedTicketSearchResults = {...state.loadedTicketSearchResults};
},
replaceTickets(state, tickets) { replaceTickets(state, tickets) {
const groups = Object.groupBy(tickets, t => t.event ? t.event : 'none') const groups = Object.groupBy(tickets, t => t.event ? t.event : 'none')
for (const [key, value] of Object.entries(groups)) state.loadedTickets[key] = value; for (const [key, value] of Object.entries(groups)) state.loadedTickets[key] = value;
@ -376,11 +393,12 @@ const store = createStore({
async searchEventItems({commit, getters, state}, query) { async searchEventItems({commit, getters, state}, query) {
const encoded_query = base64.encode(utf8.encode(query)); const encoded_query = base64.encode(utf8.encode(query));
const slug = getters.getEventSlug; const slug = getters.getEventSlug;
if (Object.keys(state.loadedItemSearchResults).includes(slug + '/' + encoded_query)) return;
const { const {
data, success data, success
} = await getters.session.get(`/2/${slug}/items/${encoded_query}/`); } = await getters.session.get(`/2/${slug}/items/${encoded_query}/`);
if (data && success) { if (data && success) {
commit('setItems', {slug, items: data}); commit('setItemSearchResults', {slug, query: encoded_query, items: data});
} }
}, },
async loadBoxes({commit, state, getters}) { async loadBoxes({commit, state, getters}) {
@ -428,11 +446,12 @@ const store = createStore({
}, },
async searchEventTickets({commit, getters, state}, query) { async searchEventTickets({commit, getters, state}, query) {
const encoded_query = base64.encode(utf8.encode(query)); const encoded_query = base64.encode(utf8.encode(query));
const slug = getters.getEventSlug;
if (Object.keys(state.loadedTicketSearchResults).includes(slug + '/' + encoded_query)) return;
const { const {
data, success data, success
} = await getters.session.get(`/2/${getters.getEventSlug}/tickets/${encoded_query}/`); } = await getters.session.get(`/2/${slug}/tickets/${encoded_query}/`);
if (data && success) commit('replaceTickets', data); if (data && success) commit('setTicketSearchResults', {slug, query: encoded_query, items: data});
}, },
async sendMail({commit, dispatch, state, getters}, {id, message}) { async sendMail({commit, dispatch, state, getters}, {id, message}) {
const {data, success} = await getters.session.post(`/2/tickets/${id}/reply/`, {message}, const {data, success} = await getters.session.post(`/2/tickets/${id}/reply/`, {message},

View file

@ -0,0 +1,114 @@
<template>
<AsyncLoader :loaded="isItemsSearchLoaded">
<div class="container-fluid px-xl-5 mt-3">
<div class="row" v-if="layout === 'table'">
<div class="col-xl-8 offset-xl-2">
<Table
:columns="['id', 'description', 'box']"
:items="getItemsSearchResults"
:keyName="'id'"
@itemActivated="showItemDetail"
>
<template #actions="{ item }">
<div class="btn-group">
<button class="btn btn-success"
@click.stop="confirm('return Item?') && markItemReturned(item)"
title="returned">
<font-awesome-icon icon="check"/>
</button>
<button class="btn btn-secondary" @click.stop="openEditingModalWith(item)" title="edit">
<font-awesome-icon icon="edit"/>
</button>
<button class="btn btn-danger" @click.stop="confirm('delete Item?') && deleteItem(item)"
title="delete">
<font-awesome-icon icon="trash"/>
</button>
</div>
</template>
</Table>
</div>
</div>
<Cards
v-if="layout === 'cards'"
:columns="['id', 'description', 'box']"
:items="getItemsSearchResults"
:keyName="'id'"
v-slot="{ item }"
@itemActivated="item => openLightboxModalWith(item.file)"
>
<AuthenticatedImage v-if="item.file" cached
:src="`/media/2/256/${item.file}/`"
class="card-img-top img-fluid"
/>
<div class="card-body">
<h6 class="card-title">{{ item.description }}</h6>
<h6 class="card-subtitle text-secondary">id: {{ item.id }} box: {{ item.box }}</h6>
<div class="row mx-auto mt-2">
<div class="btn-group">
<button class="btn btn-outline-success"
@click.stop="confirm('return Item?') && markItemReturned(item)" title="returned">
<font-awesome-icon icon="check"/>
</button>
<button class="btn btn-outline-secondary" @click.stop="showItemDetail(item)"
title="edit">
<font-awesome-icon icon="edit"/>
</button>
<button class="btn btn-outline-danger"
@click.stop="confirm('delete Item?') && deleteItem(item)"
title="delete">
<font-awesome-icon icon="trash"/>
</button>
</div>
</div>
</div>
</Cards>
</div>
</AsyncLoader>
</template>
<script>
import {mapActions, mapGetters, mapMutations, mapState} from 'vuex';
import Table from '@/components/Table';
import Cards from '@/components/Cards';
import Modal from '@/components/Modal';
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
import AsyncLoader from "@/components/AsyncLoader.vue";
import router from "@/router";
export default {
name: 'Items',
data: () => ({
lightboxHash: null,
editingItem: null,
}),
components: {AsyncLoader, AuthenticatedImage, Table, Cards, Modal},
computed: {
...mapGetters(['getItemsSearchResults', 'isItemsSearchLoaded', 'layout', 'getEventSlug', 'searchQuery']),
},
methods: {
...mapActions(['deleteItem', 'markItemReturned', 'searchEventItems', 'updateItem', 'scheduleAfterInit']),
...mapMutations(['openLightboxModalWith']),
showItemDetail(item) {
router.push({name: 'item', params: {id: item.id}});
},
confirm(message) {
return window.confirm(message);
}
},
watch: {
searchQuery() {
this.scheduleAfterInit(() => [this.searchEventItems(this.searchQuery)]);
},
getEventSlug() {
this.scheduleAfterInit(() => [this.searchEventItems(this.searchQuery)]);
}
},
mounted() {
this.scheduleAfterInit(() => [this.searchEventItems(this.searchQuery)]);
}
};
</script>
<style scoped>
</style>

View file

@ -0,0 +1,102 @@
<template>
<AsyncLoader :loaded="isTicketsSearchLoaded">
<div class="container-fluid px-xl-5 mt-3">
<div class="row" v-if="layout === 'table'">
<div class="col-xl-8 offset-xl-2">
<Table
:columns="['id', 'name', 'state', 'last_activity', 'assigned_to',
...(getEventSlug==='all'?['event']:[])]"
:items="getTicketsSearchResults.map(formatTicket)"
:keyName="'id'"
>
<template v-slot:actions="{item}">
<div class="btn-group">
<router-link :to="{name: 'ticket', params: {id: item.id}}" class="btn btn-primary"
title="view">
<font-awesome-icon icon="eye"/>
View
</router-link>
</div>
</template>
</Table>
</div>
</div>
<CollapsableCards v-if="layout === 'tasks'" :items="getTicketsSearchResults"
:columns="['id', 'name', 'last_activity', 'assigned_to',
...(getEventSlug==='all'?['event']:[])]"
:keyName="'state'" :sections="['pending_new', 'pending_open','pending_shipping',
'pending_physical_confirmation','pending_return','pending_postponed'].map(stateInfo)">
<template #section_header="{index, section, count}">
{{ section.text }} <span class="badge badge-light ml-1">{{ count }}</span>
</template>
<template #section_body="{item}">
<tr>
<td>{{ item.id }}</td>
<td>{{ item.name }}</td>
<td>{{ item.last_activity }}</td>
<td>{{ item.assigned_to }}</td>
<td v-if="getEventSlug==='all'">{{ item.event }}</td>
<td>
<div class="btn-group">
<router-link :to="{name: 'ticket', params: {id: item.id}}" class="btn btn-primary"
title="view">
<font-awesome-icon icon="eye"/>
View
</router-link>
</div>
</td>
</tr>
</template>
</CollapsableCards>
</div>
</AsyncLoader>
</template>
<script>
import Cards from '@/components/Cards';
import {mapActions, mapGetters, mapState} from 'vuex';
import Table from '@/components/Table';
import CollapsableCards from "@/components/CollapsableCards.vue";
import AsyncLoader from "@/components/AsyncLoader.vue";
import router from "@/router";
export default {
name: 'TicketSearch',
components: {AsyncLoader, Table, Cards, CollapsableCards},
computed: {
...mapGetters(['getTicketsSearchResults', 'isTicketsSearchLoaded', 'stateInfo', 'getEventSlug', 'layout', 'searchQuery']),
},
methods: {
...mapActions(['searchEventTickets', 'fetchTicketStates', 'scheduleAfterInit']),
gotoDetail(ticket) {
router.push({name: 'ticket', params: {id: ticket.id}});
},
formatTicket(ticket) {
return {
id: ticket.id,
name: ticket.name,
state: this.stateInfo(ticket.state).text,
stateColor: this.stateInfo(ticket.state).color,
last_activity: ticket.last_activity,
assigned_to: ticket.assigned_to,
event: ticket.event
};
}
},
watch: {
searchQuery() {
this.scheduleAfterInit(() => [this.searchEventTickets(this.searchQuery)]);
},
getEventSlug() {
this.scheduleAfterInit(() => [this.searchEventTickets(this.searchQuery)]);
}
},
mounted() {
this.scheduleAfterInit(() => [this.fetchTicketStates(), this.searchEventTickets(this.searchQuery)]);
}
};
</script>
<style scoped>
</style>