add basic view for item history

This commit is contained in:
j3d1 2024-11-27 19:39:58 +01:00
parent ec6e872163
commit 385620717c
18 changed files with 630 additions and 314 deletions

186
web/src/views/Item.vue Normal file
View file

@ -0,0 +1,186 @@
<template>
<AsyncLoader :loaded="!!item.id">
<div class="container-fluid px-xl-5 mt-3">
<div class="row">
<div class="col-lg-3 col-xl-2">
<div class="card bg-dark text-light mb-2" id="filters">
<div class="card bg-dark">
<InputPhoto
v-if="!!editingItem"
:model="editingItem"
field="file"
:on-capture="storeImage"
imgClass="d-block card-img"
/>
<div class="card-body">
<h6 class="card-subtitle text-secondary">id: {{ item.id }} box: {{ item.box }}</h6>
<h6 class="card-title">{{ item.description }}</h6>
</div>
<div class="card-footer">
<InputString
v-if="!!editingItem"
label="description"
:model="editingItem"
field="description"
:validation-fn="str => str && str.length > 0"
/>
</div>
</div>
</div>
</div>
<div class="col-xl-8">
<div class="card bg-dark text-light mb-2" id="filters">
<div class="card-header">
<h3>Item #{{ item.id }} - {{ item.description }}</h3>
</div>
<Timeline :timeline="item.timeline">
<template v-slot:timeline_action1>
<span class="timeline-item-icon | faded-icon">
<font-awesome-icon icon="comment"/>
</span>
<div class="new-comment card bg-dark">
<div class="">
<textarea placeholder="add comment..." v-model="newComment"
class="form-control">
</textarea>
<AsyncButton class="btn btn-primary float-right" :task="addCommentAndClear">
<font-awesome-icon icon="comment"/>
Save Comment
</AsyncButton>
</div>
</div>
</template>
</Timeline>
<div class="card-footer d-flex justify-content-between">
<div class="btn-group">
<button class="btn btn-outline-success"
@click.stop="confirm('return Item?') && markItemReturnedAndClose(item)"
title="returned">
<font-awesome-icon icon="check"/>&nbsp;mark&nbsp;returned
</button>
<button class="btn btn-outline-danger"
@click.stop="confirm('delete Item?') && deleteItemAndClose(item)"
title="delete">
<font-awesome-icon icon="trash"/>&nbsp;delete
</button>
</div>
<InputCombo
v-if="!!editingItem"
label="box"
:model="editingItem"
nameKey="box"
uniqueKey="cid"
:options="boxes"
style="width: auto;"
/>
<button type="button" class="btn btn-success" @click="saveEditingItem()">Save Changes
</button>
</div>
</div>
</div>
<div class="col-lg-3 col-xl-2" v-if="item.related_issues && item.related_issues.length">
<div class="card bg-dark text-light mb-2" id="filters">
<div class="card-body">
<h5 class="card-title text-info">Related</h5>
</div>
<div class="card bg-dark" v-for="issue in item.related_issues" v-bind:key="item.id">
<div class="card-body">
<router-link :to="{name: 'ticket', params: {id: issue.id}}">
<h6 class="card-title">Ticket #{{ issue.id }} - {{ issue.name }}</h6>
</router-link>
<h6 class="card-subtitle text-secondary">state: {{ issue.state }}</h6>
</div>
</div>
</div>
</div>
</div>
</div>
</AsyncLoader>
</template>
<script>
import {mapActions, mapGetters, mapMutations, mapState} from 'vuex';
import router from "@/router";
import Timeline from "@/components/Timeline.vue";
import ClipboardButton from "@/components/inputs/ClipboardButton.vue";
import AsyncLoader from "@/components/AsyncLoader.vue";
import InputCombo from "@/components/inputs/InputCombo.vue";
import InputString from "@/components/inputs/InputString.vue";
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
import InputPhoto from "@/components/inputs/InputPhoto.vue";
import Modal from "@/components/Modal.vue";
import EditItem from "@/components/EditItem.vue";
import AsyncButton from "@/components/inputs/AsyncButton.vue";
export default {
name: 'Item',
components: {
AsyncButton,
EditItem,
Modal, InputPhoto, AuthenticatedImage, InputString, InputCombo, AsyncLoader, ClipboardButton, Timeline
},
data() {
return {
newComment: "",
editingItem: null,
}
},
computed: {
...mapState(['state_options', 'users']),
...mapGetters(['availableShippingVoucherTypes', 'getAllItems', 'route', 'getBoxes']),
item() {
const id = parseInt(this.route.params.id)
const ret = this.getAllItems.find(item => item.id === id);
return ret ? ret : {};
},
boxes() {
return this.getBoxes.map(obj => ({cid: obj.id, box: obj.name}));
}
},
methods: {
...mapActions(['deleteItem', 'markItemReturned', 'updateTicketPartial', 'postItemComment']),
...mapActions(['loadTickets', 'fetchTicketStates', 'loadUsers', 'scheduleAfterInit', 'updateItem']),
...mapActions(['claimShippingVoucher', 'fetchShippingVouchers', 'loadEventItems', 'loadBoxes']),
...mapMutations(['openLightboxModalWith']),
async addCommentAndClear() {
await this.postItemComment({
id: this.item.id,
message: this.newComment
})
this.newComment = "";
},
async saveEditingItem() { // Saves the edited copy of the item.
await this.updateItem(this.editingItem);
this.editingItem = {...this.item}
},
storeImage(image) {
this.editingItem.dataImage = image;
},
confirm(message) {
return window.confirm(message);
},
async markItemReturnedAndClose(item) {
await this.markItemReturned(item);
router.back();
},
async deleteItemAndClose(item) {
await this.deleteItem(item);
router.back();
}
},
mounted() {
this.scheduleAfterInit(() => [Promise.all([this.loadEventItems(), this.loadBoxes()]).then(() => {
this.selected_state = this.item.state;
this.selected_assignee = this.item.assigned_to
this.editingItem = {...this.item}
})]);
}
};
</script>
<style scoped>
</style>

View file

@ -1,26 +1,13 @@
<template>
<AsyncLoader :loaded="isItemsLoaded">
<div class="container-fluid px-xl-5 mt-3">
<Modal title="Edit Item" v-if="editingItem" @close="closeEditingModal()">
<template #body>
<EditItem
:item="editingItem"
badge="uid"
/>
</template>
<template #buttons>
<button type="button" class="btn btn-secondary" @click="closeEditingModal()">Cancel</button>
<button type="button" class="btn btn-success" @click="saveEditingItem()">Save Changes</button>
</template>
</Modal>
<Lightbox v-if="lightboxHash" :hash="lightboxHash" @close="closeLightboxModal()"/>
<div class="row" v-if="layout === 'table'">
<div class="col-xl-8 offset-xl-2">
<Table
:columns="['uid', 'description', 'box']"
:columns="['id', 'description', 'box']"
:items="getEventItems"
:keyName="'uid'"
@itemActivated="openLightboxModalWith($event)"
:keyName="'id'"
@itemActivated="showItemDetail"
>
<template #actions="{ item }">
<div class="btn-group">
@ -43,11 +30,11 @@
</div>
<Cards
v-if="layout === 'cards'"
:columns="['uid', 'description', 'box']"
:columns="['id', 'description', 'box']"
:items="getEventItems"
:keyName="'uid'"
:keyName="'id'"
v-slot="{ item }"
@itemActivated="openLightboxModalWith($event)"
@itemActivated="item => openLightboxModalWith(item.file)"
>
<AuthenticatedImage v-if="item.file" cached
:src="`/media/2/256/${item.file}/`"
@ -55,14 +42,14 @@
/>
<div class="card-body">
<h6 class="card-title">{{ item.description }}</h6>
<h6 class="card-subtitle text-secondary">uid: {{ item.uid }} box: {{ item.box }}</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="openEditingModalWith(item)"
<button class="btn btn-outline-secondary" @click.stop="showItemDetail(item)"
title="edit">
<font-awesome-icon icon="edit"/>
</button>
@ -84,10 +71,10 @@ import Table from '@/components/Table';
import Cards from '@/components/Cards';
import Modal from '@/components/Modal';
import EditItem from '@/components/EditItem';
import {mapActions, mapGetters, mapState} from 'vuex';
import Lightbox from '../components/Lightbox';
import {mapActions, mapGetters, mapMutations} from 'vuex';
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
import AsyncLoader from "@/components/AsyncLoader.vue";
import router from "@/router";
export default {
name: 'Items',
@ -95,28 +82,15 @@ export default {
lightboxHash: null,
editingItem: null,
}),
components: {AsyncLoader, AuthenticatedImage, Lightbox, Table, Cards, Modal, EditItem},
components: {AsyncLoader, AuthenticatedImage, Table, Cards, Modal, EditItem},
computed: {
...mapState([]),
...mapGetters(['getEventItems', 'isItemsLoaded', 'layout']),
},
methods: {
...mapActions(['deleteItem', 'markItemReturned', 'loadEventItems', 'updateItem', 'scheduleAfterInit']),
openLightboxModalWith(item) {
this.lightboxHash = item.file;
},
closeLightboxModal() { // Closes the editing modal and discards the edited copy of the item.
this.lightboxHash = null;
},
openEditingModalWith(item) { // Opens the editing modal with a copy of the selected item.
this.editingItem = item;
},
closeEditingModal() {
this.editingItem = null;
},
saveEditingItem() { // Saves the edited copy of the item.
this.updateItem(this.editingItem);
this.closeEditingModal();
...mapMutations(['openLightboxModalWith']),
showItemDetail(item) {
router.push({name: 'item', params: {id: item.id}});
},
confirm(message) {
return window.confirm(message);

View file

@ -1,5 +1,5 @@
<template>
<AsyncLoader :loaded="ticket.id">
<AsyncLoader :loaded="!!ticket.id">
<div class="container-fluid px-xl-5 mt-3">
<div class="row">
<div class="col-xl-8 offset-xl-2">
@ -7,7 +7,42 @@
<div class="card-header">
<h3>Ticket #{{ ticket.id }} - {{ ticket.name }}</h3>
</div>
<Timeline :timeline="ticket.timeline" @sendMail="handleMail" @addComment="handleComment"/>
<Timeline :timeline="ticket.timeline">
<template v-slot:timeline_action1>
<span class="timeline-item-icon | faded-icon">
<font-awesome-icon icon="comment"/>
</span>
<div class="new-comment card bg-dark">
<div>
<textarea placeholder="add comment..." v-model="newComment"
class="form-control">
</textarea>
<AsyncButton class="btn btn-primary float-right" :task="addCommentAndClear">
<font-awesome-icon icon="comment"/>
Save Comment
</AsyncButton>
</div>
</div>
</template>
<template v-slot:timeline_action2>
<span class="timeline-item-icon | faded-icon">
<font-awesome-icon icon="envelope"/>
</span>
<div class="new-mail card bg-dark">
<div class="card-header">
{{ newestMailSubject }}
</div>
<div>
<textarea placeholder="reply mail..." v-model="newMail" class="form-control">
</textarea>
<AsyncButton class="btn btn-primary float-right" :task="sendMailAndClear">
<font-awesome-icon icon="envelope"/>
Send Mail
</AsyncButton>
</div>
</div>
</template>
</Timeline>
<div class="card-footer d-flex justify-content-between">
<button class="btn btn-secondary mr-2" @click="$router.go(-1)">Back</button>
<!--button class="btn btn-danger" @click="deleteItem({type: 'tickets', id: ticket.id})">
@ -16,11 +51,14 @@
</button-->
<div class="btn-group">
<select class="form-control" v-model="selected_assignee">
<option v-for="user in users" :value="user.username">{{ user.username }}</option>
<option v-for="user in users" :value="user.username">{{
user.username
}}
</option>
</select>
<button class="form-control btn btn-success"
@click="assignTicket(ticket)"
:disabled="!selected_assignee || (selected_assignee == ticket.assigned_to)">
:disabled="!selected_assignee || (selected_assignee === ticket.assigned_to)">
Assign&nbsp;Ticket
</button>
</div>
@ -33,7 +71,7 @@
</select>
<button class="form-control btn btn-success"
@click="changeTicketStatus(ticket)"
:disabled="(selected_state == ticket.state)">
:disabled="(selected_state === ticket.state)">
Change&nbsp;Status
</button>
</div>
@ -58,25 +96,53 @@
</div>
</div>
</div>
<div class="col-lg-3 col-xl-2 d-lg-none d-xl-block"
v-if="ticket.related_items && ticket.related_items.length">
<div class="card bg-dark text-light mb-2" id="filters">
<div class="card-body">
<h5 class="card-title text-info">Related</h5>
<div class="card bg-dark" v-for="item in ticket.related_items" v-bind:key="item.id">
<AuthenticatedImage v-if="item.file" cached
:src="`/media/2/256/${item.file}/`"
class="d-block card-img"
@click="openLightboxModalWith(item.file)"
/>
<div class="card-body">
<!--h6 class="card-title text-info"><span class="badge badge-primary">{{ item.relation_status }}</span></--h6-->
<h6 class="card-subtitle text-secondary">id: {{ item.id }} box: {{
item.box
}}</h6>
<router-link :to="{name: 'item', params: {id: item.id}}">
<h6 class="card-title">{{ item.description }}</h6>
</router-link>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</AsyncLoader>
</template>
<script>
import {mapActions, mapGetters, mapState} from 'vuex';
import {mapActions, mapGetters, mapMutations, mapState} from 'vuex';
import Timeline from "@/components/Timeline.vue";
import ClipboardButton from "@/components/inputs/ClipboardButton.vue";
import AsyncLoader from "@/components/AsyncLoader.vue";
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
import AsyncButton from "@/components/inputs/AsyncButton.vue";
export default {
name: 'Ticket',
components: {AsyncLoader, ClipboardButton, Timeline},
components: {AsyncButton, AuthenticatedImage, AsyncLoader, ClipboardButton, Timeline},
data() {
return {
selected_state: null,
selected_assignee: null,
shipping_voucher_type: null,
newMail: "",
newComment: ""
}
},
computed: {
@ -90,46 +156,52 @@ export default {
shippingEmail() {
const domain = document.location.hostname;
return `ticket+${this.ticket.uuid}@${domain}`;
}
},
newestMailSubject() {
const mail = this.ticket.timeline ? this.ticket.timeline.filter(item => item.type === 'mail').pop() : null;
return mail ? mail.subject : "";
},
},
methods: {
...mapActions(['deleteItem', 'markItemReturned', 'sendMail', 'updateTicketPartial', 'postComment']),
...mapActions(['loadTickets', 'fetchTicketStates', 'loadUsers', 'scheduleAfterInit']),
...mapActions(['claimShippingVoucher', 'fetchShippingVouchers']),
handleMail(mail) {
this.sendMail({
id: this.ticket.id,
message: mail
})
},
handleComment(comment) {
this.postComment({
id: this.ticket.id,
message: comment
})
},
changeTicketStatus(ticket) {
ticket.state = this.selected_state;
...mapMutations(['openLightboxModalWith']),
changeTicketStatus() {
this.ticket.state = this.selected_state;
this.updateTicketPartial({
id: ticket.id,
id: this.ticket.id,
state: this.selected_state,
})
},
assignTicket(ticket) {
ticket.assigned_to = this.selected_assignee;
assignTicket() {
this.ticket.assigned_to = this.selected_assignee;
this.updateTicketPartial({
id: ticket.id,
id: this.ticket.id,
assigned_to: this.selected_assignee
})
},
sendMailAndClear: async function () {
await this.sendMail({
id: this.ticket.id,
message: this.newMail,
})
this.newMail = "";
},
addCommentAndClear: async function () {
await this.postComment({
id: this.ticket.id,
message: this.newComment
})
this.newComment = "";
}
},
mounted() {
this.scheduleAfterInit(() => [Promise.all([this.fetchTicketStates(), this.loadTickets(), this.loadUsers(), this.fetchShippingVouchers()]).then(() => {
if (this.ticket.state == "pending_new") {
if (this.ticket.state === "pending_new") {
this.selected_state = "pending_open";
this.changeTicketStatus(this.ticket)
this.changeTicketStatus()
}
;
this.selected_state = this.ticket.state;
this.selected_assignee = this.ticket.assigned_to
})]);

View file

@ -12,11 +12,11 @@
>
<template v-slot:actions="{item}">
<div class="btn-group">
<a class="btn btn-primary" :href="'/'+ getEventSlug + '/ticket/' + item.id" title="view"
@click.prevent="gotoDetail(item)">
<router-link :to="{name: 'ticket', params: {id: item.id}}" class="btn btn-primary"
title="view">
<font-awesome-icon icon="eye"/>
View
</a>
</router-link>
</div>
</template>
</Table>
@ -39,11 +39,11 @@
<td v-if="getEventSlug==='all'">{{ item.event }}</td>
<td>
<div class="btn-group">
<a class="btn btn-primary" :href="'/'+ getEventSlug + '/ticket/' + item.id" title="view"
@click.prevent="gotoDetail(item)">
<router-link :to="{name: 'ticket', params: {id: item.id}}" class="btn btn-primary"
title="view">
<font-awesome-icon icon="eye"/>
View
</a>
</router-link>
</div>
</td>
</tr>
@ -55,24 +55,22 @@
<script>
import Cards from '@/components/Cards';
import Modal from '@/components/Modal';
import EditItem from '@/components/EditItem';
import {mapActions, mapGetters, mapState} from 'vuex';
import Lightbox from '../components/Lightbox';
import Table from '@/components/Table';
import CollapsableCards from "@/components/CollapsableCards.vue";
import AsyncLoader from "@/components/AsyncLoader.vue";
import router from "@/router";
export default {
name: 'Tickets',
components: {AsyncLoader, Lightbox, Table, Cards, Modal, EditItem, CollapsableCards},
components: {AsyncLoader, Table, Cards, CollapsableCards},
computed: {
...mapGetters(['getEventTickets', 'isTicketsLoaded', 'stateInfo', 'getEventSlug', 'layout']),
},
methods: {
...mapActions(['loadTickets', 'fetchTicketStates', 'scheduleAfterInit']),
gotoDetail(ticket) {
this.$router.push({name: 'ticket', params: {id: ticket.id}});
router.push({name: 'ticket', params: {id: ticket.id}});
},
formatTicket(ticket) {
return {