Bug 30708: Vue app

This commit contains the main commit message

The description of the original need is described in documents attached by the sponsor on the bug report specifically "[En] Preservation module - Main principles".
The idea is to develop a whole new module to track the status of the documents that are sent for processings/treatments in order to preserve them (eg. covering).

This is a first step, more are certainly coming later.

The author and sponsors have worked for several months before providing this MVP version. The different discussion and needs can be found at https://tree.taiga.io/project/joubu-koha-preservation-module/kanban
Some ideas of the next steps are also listed.

The first iterations have been done using the classic .pl/.tt Koha style but we finally switched to a new Vue module, for more fun.

These patches made the following main changes:
New files
* Koha objects under Koha/Preservation
* REST API controllers under Koha/REST/V1/Preservation
* preservation/home.pl and preservation/home.tt
* Vue components under js/vue/components/Preservation
* tests under t/db_dependent/Koha/Preservation and t/db_dependent/api/v1/preservation_*
* Cypress tests under t/cypress/integration/Preservation
DB:
* 3 new sysprefs PreservationModule, PreservationNotForLoanWaitingListIn, PreservationNotForLoanDefaultTrainIn
* 1 new permission "preservation" (will be split into subpermissions later)
* 5 new tables:
- preservation_processings
- preservation_trains
- preservation_processing_attributes
- preservation_trains_items
- preservation_processing_attributes_items

Terminology and workflow:
*Processings* are the different treatments an item can receive during its stay in the preservation module
A *processing* is defined by a list of *attributes*. To make the module as easy to use for the librarians in charge of the preservation area a list of processings will be defined when the module will be set up. An *attribute* is a name and a value. That's it. However it also has a type, to define what the value is coming from: *free text*, *authorised value* or *database column*.
For instance if you are defining a processing that will handling the book cover you could have 3 *attributes*:
- first named "Barcode" that will be automatically filled with "items.barcode" (type *database column*"
- second attribute named "color" linked with a new PRES_COLOR authorised value category you would have previously defined with "red", "blue", "green", etc. (type *authorised value*)
- third attribute named "notes", because librarians like notes (type *free text*)
Important: Even if the attribute is linked with a DB column or AV category, the value will be automatically pre filled but will stay editable (could be a config option to restrict the edition, later, if needed).

The *status* of an item will change during the preservation process. First it will arrive in the preservation area and be on a *waiting list*. It is not processed already but is not available anymore for the patrons of the library. That's why we are going to use the "not for loan" (items.notforloan) value for this. This *waiting list* is a fictional concept, it simply lists all the items in the library with a specific *status*.
A *train* is... how they call that at the BULAC, a train (same in French!). And we quite like the word so we kept it. It is what it is: a list of items/waggons, one after each other. We could have picked "cart", "list", but the concepts were already used in different places. We are not strongly attached to the term and it can be modified (but it's spread all over the code already and will be tedious to modify!) if you have a very good suggestion :)
So, a *train* is where items are going after they have been sent to the waiting list. It's a stack of items that will be sent to a provider. When you create a new train you will be asked for the "Status for item added to this train", that will be the "not for loan" value to set to the items added to this train, and a "Default processing" that will be the processing used. But keep in mind that a train can have items that have different processings (specific case, will see later).
When all items have been added to a *train*, you can *close* it. You cannot add items anymore to it! Then you can *send* it, and finally *receive* it. They are just statuses to keep track of the dates, and filter trains by status.
However when a train is received you can *copy* an item to another (opened) train. It means that you have the item on hand but something went wrong, you are not happy with the work done by the supplier and want to send it back, so you create a new train (that can have different items, and it is the case where you will have items in a train that don't all have the same processing!).

Test plan:
A. Prerequisites
0. Just `reset_all` and jump to B, or:
1. Apache configuration
You will need to edit /etc/koha/apache-shared-intranet-git.conf and add the following lines after the RewriteRule for erm (line.24?)
RewriteCond %{REQUEST_URI} !^/cgi-bin/koha/preservation/.*.pl$
RewriteRule ^/cgi-bin/koha/preservation/.*$ /cgi-bin/koha/preservation/home.pl [PT]
The RewriteCond is only useful if you are testing the "print slips" bugs as well, but it cannot hurt to have it!
2. `yarn js:build` to regenerate the Vue app for the preservation module
3. `updatedatabase`
4. `restart_all`
B. Settings
0.
Create 2 different values for NOTLOAN, eg. 'In preservation' and 'In preservation external'
Create different authorised values for a new category, eg. PRES_COLORS: RED, BLUE, GREEN. Feel free to create more categories.
1. You can turn on the "PreservationModule" syspref and go to the Koha homepage to see a new "Preservation" link
2. You landed on the empty home page of the preservation, no worry! We need to fill this page with useful information! (see #2 on the kanban)
3. Go to settings
4. Set "Status for item added to waiting list" to "In preservation"
and "Default status for item added to train": "In preservation external"
Create a new processing and define some attributes. Ideally at least one of each type.
5. Go to "Waiting list" and add some items
6. Go to "Trains" and create several trains (at least 2). Notice that the "Status for item added to this train" value is set to the value defined in the settings, but can be modified. Notice that this status can be set when a train is created but it won't be possible to edit later.
7. Add items to a train. You can only add items that are already in the waiting list. Add values for the attributes. Notice that the attributes linked with a database column are automatically pre filled. Notice that attributes linked with an authorised value are displayed with a dropdown list but that a different value can be set (remember, this is a feature!). Notice that attributes can be multivalued.
8. Add other items to the waiting list, notice the "Add last X items to a train" link at the top of the waiting list table, click it
9. You can now add several items to a train, directly (for instance if you don't really need to pass through the waiting list). Values can be set for the batch, but attributes linked with a database column are not editable (they will be prefilled automatically)
10. Once you have a train with several items, look at the "show train" view and notice the item list. If all of them are using the same processing then a table is displayed, one column per attribute. However if at least one item of the train has a different processing then the items are not listed in a table.
11. Edit items and confirm that the values are correctly saved.
12. Close, send and receive a train
13. Once a train is closed you can no longer add items to it
14. Once a train is received notice that you can "copy" an item to another (opened) train

QA notes:
The patch is huge! New enhancements and improvements have been moved to separate bug reports but this cannot be split. We need a ground base to build on top.
The size is mainly coming from Vue components, Koha::Objects, REST API controllers and specs, and tests. Nothing hard ;)

More to come:
- See the kanban!
- Print slips (bug 33547 and bug 34030)
- Put something on the landing page!
- Link with the acquisition module (suppliers, funds, etc.)

Sponsored-by: BULAC - http://www.bulac.fr/

Signed-off-by: BULAC - http://www.bulac.fr/
Signed-off-by: Heather Hernandez <heather_hernandez@nps.gov>
Signed-off-by: Laurence Rault <laurence.rault@biblibre.com>

Signed-off-by: Marcel de Rooy <m.de.rooy@rijksmuseum.nl>
Signed-off-by: Tomas Cohen Arazi <tomascohen@theke.io>
This commit is contained in:
Jonathan Druart 2023-04-07 14:50:11 +02:00 committed by Tomas Cohen Arazi
parent 8972181484
commit a639ec291f
Signed by: tomascohen
GPG key ID: 0A272EA1B2F3C15F
21 changed files with 3085 additions and 0 deletions

View file

@ -105,6 +105,12 @@ export default {
this.$__("Delete") +
"</a>"
)
} else if (action == "remove") {
content.push(
'<a class="remove btn btn-default btn-xs" role="button"><i class="fa fa-remove"></i> ' +
this.$__("Remove") +
"</a>"
)
}
})
return content.join(" ")

View file

@ -0,0 +1,11 @@
<template></template>
<script>
export default {
data() {
return {}
},
methods: {},
components: {},
}
</script>

View file

@ -0,0 +1,194 @@
<template>
<div v-if="initialized && PreservationModule == 1">
<Breadcrumb />
<div class="main container-fluid">
<div class="row">
<div class="col-sm-10 col-sm-push-2">
<main>
<Dialog />
<router-view />
</main>
</div>
<div class="col-sm-2 col-sm-pull-10">
<aside>
<div id="navmenu">
<div id="navmenulist">
<h5>{{ $__("Preservation") }}</h5>
<ul>
<li>
<router-link
to="/cgi-bin/koha/preservation/home.pl"
>
<i class="fa fa-home"></i>
{{ $__("Home") }}</router-link
>
</li>
<li>
<router-link
to="/cgi-bin/koha/preservation/trains"
>
<i class="fa fa-train"></i>
{{ $__("Trains") }}</router-link
>
</li>
<li>
<router-link
to="/cgi-bin/koha/preservation/waiting-list"
>
<i class="fa fa-recycle"></i>
{{ $__("Waiting list") }}
</router-link>
</li>
<li>
<router-link
to="/cgi-bin/koha/preservation/settings"
>
<i class="fa fa-cog"></i>
{{ $__("Settings") }}
</router-link>
</li>
</ul>
</div>
</div>
</aside>
</div>
</div>
</div>
</div>
<div class="main container-fluid" v-else>
<Dialog />
</div>
</template>
<script>
import { inject } from "vue"
import Breadcrumb from "../Breadcrumb.vue"
import Dialog from "../Dialog.vue"
import { APIClient } from "../../fetch/api-client.js"
import "vue-select/dist/vue-select.css"
export default {
setup() {
const AVStore = inject("AVStore")
const mainStore = inject("mainStore")
const { loading, loaded, setError } = mainStore
const PreservationStore = inject("PreservationStore")
return {
AVStore,
loading,
loaded,
setError,
PreservationStore,
}
},
data() {
return {
initialized: false,
PreservationModule: null,
}
},
beforeCreate() {
this.loading()
const fetch_config = () => {
const sysprefs_client = APIClient.sysprefs
const av_client = APIClient.authorised_values
let promises = [
sysprefs_client.sysprefs
.get("PreservationNotForLoanWaitingListIn")
.then(
value => {
this.PreservationStore.settings.not_for_loan_waiting_list_in =
value.value
},
error => {}
),
sysprefs_client.sysprefs
.get("PreservationNotForLoanDefaultTrainIn")
.then(
value => {
this.PreservationStore.settings.not_for_loan_default_train_in =
value.value
},
error => {}
),
av_client.values.get("NOT_LOAN").then(
values => {
this.AVStore.av_notforloan = values
},
error => {}
),
]
return Promise.all(promises)
}
const sysprefs_client = APIClient.sysprefs
sysprefs_client.sysprefs
.get("PreservationModule")
.then(value => {
this.PreservationModule = value.value
if (this.PreservationModule != 1) {
return this.setError(
this.$__(
'The preservation module is disabled, turn on <a href="/cgi-bin/koha/admin/preferences.pl?tab=&op=search&searchfield=PreservationModule">PreservationModule</a> to use it'
),
false
)
}
return fetch_config()
})
.then(() => {
this.loaded()
this.initialized = true
})
},
components: {
Breadcrumb,
Dialog,
},
}
</script>
<style>
#navmenulist a.router-link-active {
font-weight: 700;
}
#menu ul ul,
#navmenulist ul ul {
padding-left: 2em;
font-size: 100%;
}
form .v-select {
display: inline-block;
background-color: white;
width: 30%;
}
.v-select,
input:not([type="submit"]):not([type="search"]):not([type="button"]):not([type="checkbox"]),
textarea {
border-color: rgba(60, 60, 60, 0.26);
border-width: 1px;
border-radius: 4px;
min-width: 30%;
}
.flatpickr-input {
width: 30%;
}
#navmenulist ul li a.disabled {
color: #666;
pointer-events: none;
font-weight: 700;
}
#navmenulist ul li a.disabled.router-link-active {
color: #000;
}
</style>

View file

@ -0,0 +1,130 @@
<template>
<div v-if="!initialized">{{ $__("Loading") }}</div>
<div v-else id="settings">
<h2>
{{ $__("Edit preservation settings") }}
</h2>
<div>
<form @submit="onSubmit($event)">
<fieldset class="rows">
<legend>{{ $__("General settings") }}</legend>
<ol>
<li>
<label
class="required"
for="not_for_loan_waiting_list_in"
>{{
$__(
"Status for item added to waiting list"
)
}}:</label
>
<v-select
id="not_for_loan_waiting_list_in"
v-model="settings.not_for_loan_waiting_list_in"
label="description"
:reduce="av => av.value"
:options="av_notforloan"
>
<template #search="{ attributes, events }">
<input
:required="
!settings.not_for_loan_waiting_list_in
"
class="vs__search"
v-bind="attributes"
v-on="events"
/>
</template>
</v-select>
</li>
<li>
<label for="not_for_loan_default_train_in"
>{{
$__(
"Default status for item added to train"
)
}}:</label
>
<v-select
id="not_for_loan_default_train_in"
v-model="settings.not_for_loan_default_train_in"
label="description"
:reduce="av => av.value"
:options="av_notforloan"
/>
</li>
</ol>
</fieldset>
<fieldset class="action">
<input type="submit" value="Submit" />
<router-link
to="/cgi-bin/koha/preservation/home.pl"
role="button"
class="cancel"
>{{ $__("Cancel") }}</router-link
>
</fieldset>
</form>
<SettingsProcessings />
</div>
</div>
</template>
<script>
import { inject } from "vue"
import { APIClient } from "../../fetch/api-client.js"
import { storeToRefs } from "pinia"
import SettingsProcessings from "./SettingsProcessings.vue"
export default {
setup() {
const AVStore = inject("AVStore")
const { av_notforloan } = storeToRefs(AVStore)
const { setMessage, setWarning } = inject("mainStore")
const PreservationStore = inject("PreservationStore")
const { settings } = storeToRefs(PreservationStore)
return { av_notforloan, setMessage, setWarning, settings }
},
data() {
return {
initialized: true,
}
},
methods: {
checkForm(train) {
let errors = []
errors.forEach(function (e) {
setWarning(e)
})
return !errors.length
},
onSubmit(e) {
e.preventDefault()
const client = APIClient.sysprefs
client.sysprefs
.update(
"PreservationNotForLoanWaitingListIn",
this.settings.not_for_loan_waiting_list_in
)
.then(
client.sysprefs.update(
"PreservationNotForLoanDefaultTrainIn",
this.settings.not_for_loan_default_train_in || 0
)
)
.then(
success => {
this.setMessage(this.$__("Settings updated"), true)
},
error => {}
)
},
},
components: { SettingsProcessings },
name: "Settings",
}
</script>

View file

@ -0,0 +1,110 @@
<template>
<fieldset>
<legend>{{ $__("Processings") }}</legend>
<ol>
<li
:id="`processing_${counter}`"
class="rows"
v-for="(processing, counter) in processings"
v-bind:key="counter"
>
<router-link
:to="`/cgi-bin/koha/preservation/settings/processings/${processing.processing_id}`"
>
{{ processing.name }}
</router-link>
<span class="action_links">
<a @click="deleteProcessing(processing)"
><i class="fa fa-trash"></i>
{{ $__("Remove this processing") }}</a
>
<router-link
:to="`/cgi-bin/koha/preservation/settings/processings/edit/${processing.processing_id}`"
><i class="fa fa-pencil"></i>
{{ $__("Edit this processing") }}</router-link
>
</span>
</li>
</ol>
<router-link
to="/cgi-bin/koha/preservation/settings/processings/add"
role="button"
class="btn btn-default"
><font-awesome-icon icon="plus" />
{{ $__("Add new processing") }}</router-link
>
</fieldset>
</template>
<script>
import { inject } from "vue"
import { APIClient } from "../../fetch/api-client.js"
export default {
setup() {
const { setConfirmationDialog, setMessage } = inject("mainStore")
return { setConfirmationDialog, setMessage }
},
data() {
return {
processings: [],
}
},
beforeCreate() {
// FIXME Do we want that or a props passed from parent?
const client = APIClient.preservation
client.processings.getAll().then(
processings => {
this.processings = processings
},
error => {}
)
},
methods: {
deleteProcessing(processing) {
this.setConfirmationDialog(
{
title: this.$__(
"Are you sure you want to remove this processing?"
),
message: processing.name,
accept_label: this.$__("Yes, delete"),
cancel_label: this.$__("No, do not delete"),
},
() => {
const client = APIClient.preservation
client.processings.delete(processing.processing_id).then(
success => {
this.setMessage(
this.$__("Processing %s deleted").format(
processing.name
),
true
)
client.processings.getAll().then(
processings => {
this.processings = processings
},
error => {}
)
},
error => {}
)
}
)
},
},
props: {},
name: "SettingsProcessings",
}
</script>
<style scoped>
.action_links a {
padding-left: 0.2em;
font-size: 11px;
cursor: pointer;
}
</style>

View file

@ -0,0 +1,286 @@
<template>
<div v-if="!initialized">{{ $__("Loading") }}</div>
<div v-else id="processings_add">
<h2 v-if="processing.processing_id">
{{ $__("Edit processing #%s").format(processing.processing_id) }}
</h2>
<h2 v-else>{{ $__("New processing") }}</h2>
<div>
<form @submit="onSubmit($event)">
<fieldset class="rows">
<ol>
<li>
<label class="required" for="processing_name"
>{{ $__("Processing name") }}:</label
>
<input
id="processing_name"
v-model="processing.name"
:placeholder="$__('Processing name')"
required
/>
<span class="required">{{ $__("Required") }}</span>
</li>
</ol>
</fieldset>
<fieldset class="rows">
<legend>{{ $__("Attributes") }}</legend>
<fieldset
:id="`attribute_${counter}`"
class="rows"
v-for="(attribute, counter) in processing.attributes"
v-bind:key="counter"
>
<legend>
{{ $__("Attribute %s").format(counter + 1) }}
<a
href="#"
@click.prevent="deleteAttribute(counter)"
><i class="fa fa-trash"></i>
{{ $__("Remove this attribute") }}</a
>
</legend>
<ol>
<li>
<label
:for="`attribute_name_${counter}`"
class="required"
>{{ $__("Name") }}:
</label>
<input
:id="`attribute_name_${counter}`"
type="text"
:name="`attribute_name_${counter}`"
v-model="attribute.name"
required
/>
<span class="required">{{
$__("Required")
}}</span>
</li>
<li>
<label
:for="`attribute_type_${counter}`"
class="required"
>{{ $__("Type") }}:
</label>
<v-select
:id="`attribute_type_${counter}`"
v-model="attribute.type"
:options="attribute_types"
:reduce="o => o.code"
@option:selected="
attribute.option_source = null
"
>
<template #search="{ attributes, events }">
<input
:required="!attribute.type"
class="vs__search"
v-bind="attributes"
v-on="events"
/>
</template>
</v-select>
<span class="required">{{
$__("Required")
}}</span>
</li>
<li v-if="attribute.type == 'authorised_value'">
<label
:for="`attribute_option_${counter}`"
class="required"
>{{ $__("Options") }}:
</label>
<v-select
:id="`attribute_option_${counter}`"
v-model="attribute.option_source"
:options="authorised_value_categories"
>
<template #search="{ attributes, events }">
<input
:required="!attribute.option_source"
class="vs__search"
v-bind="attributes"
v-on="events"
/>
</template>
</v-select>
<span class="required">{{
$__("Required")
}}</span>
</li>
<li v-if="attribute.type == 'db_column'">
<label
:for="`attribute_option_${counter}`"
class="required"
>{{ $__("Options") }}:
</label>
<v-select
:id="`attribute_option_${counter}`"
v-model="attribute.option_source"
:options="db_column_options"
:reduce="o => o.code"
>
<template #search="{ attributes, events }">
<input
:required="!attribute.option_source"
class="vs__search"
v-bind="attributes"
v-on="events"
/>
</template>
</v-select>
<span class="required">{{
$__("Required")
}}</span>
</li>
</ol>
</fieldset>
<a class="btn btn-default" @click="addAttribute"
><font-awesome-icon icon="plus" />
{{ $__("Add new attribute") }}</a
>
</fieldset>
<fieldset class="action">
<input type="submit" value="Submit" />
<router-link
to="/cgi-bin/koha/preservation/settings"
role="button"
class="cancel"
>{{ $__("Cancel") }}</router-link
>
</fieldset>
</form>
</div>
</div>
</template>
<script>
import { inject } from "vue"
import { APIClient } from "../../fetch/api-client.js"
import { storeToRefs } from "pinia"
export default {
setup() {
const AVStore = inject("AVStore")
const {} = storeToRefs(AVStore)
const { setMessage, setWarning } = inject("mainStore")
const db_column_options = Object.keys(db_columns).map(function (c) {
return { label: "%s (%s)".format(db_columns[c], c), code: c }
})
return {
setMessage,
setWarning,
authorised_value_categories,
db_column_options,
}
},
data() {
return {
processing: {
processing_id: null,
name: "",
attributes: [],
},
attribute_types: [
{
label: this.$__("Authorized value"),
code: "authorised_value",
},
{
label: this.$__("Free text"),
code: "free_text",
},
{
label: this.$__("Database column"),
code: "db_column",
},
],
initialized: false,
}
},
beforeRouteEnter(to, from, next) {
next(vm => {
if (to.params.processing_id) {
vm.processing = vm.getProcessing(to.params.processing_id)
} else {
vm.initialized = true
}
})
},
methods: {
async getProcessing(processing_id) {
const client = APIClient.preservation
await client.processings.get(processing_id).then(
processing => {
this.processing = processing
this.initialized = true
},
error => {}
)
},
checkForm(processing) {
let errors = []
let attributes = processing.attributes
errors.forEach(function (e) {
setWarning(e)
})
return !errors.length
},
onSubmit(e) {
e.preventDefault()
let processing = JSON.parse(JSON.stringify(this.processing)) // copy
let processing_id = processing.processing_id
delete processing.processing_id
if (!this.checkForm(processing)) {
return false
}
processing.attributes = processing.attributes.map(
({ processing_id, processing_attribute_id, ...keepAttrs }) =>
keepAttrs
)
const client = APIClient.preservation
if (processing_id) {
client.processings.update(processing, processing_id).then(
success => {
this.setMessage(this.$__("Processing updated"))
this.$router.push("/cgi-bin/koha/preservation/settings")
},
error => {}
)
} else {
client.processings.create(processing).then(
success => {
this.setMessage(this.$__("Processing created"))
this.$router.push("/cgi-bin/koha/preservation/settings")
},
error => {}
)
}
},
addAttribute() {
this.processing.attributes.push({
name: "",
type: null,
option_source: null,
})
},
deleteAttribute(counter) {
this.processing.attributes.splice(counter, 1)
},
},
components: {},
name: "SettingsProcessingsFormAdd",
}
</script>

View file

@ -0,0 +1,151 @@
<template>
<div v-if="!initialized">{{ $__("Loading") }}</div>
<div v-else id="processing_show">
<h2>
{{ $__("Processing #%s").format(processing.processing_id) }}
<span class="action_links">
<router-link
:to="`/cgi-bin/koha/preservation/settings/processings/edit/${processing.processing_id}`"
:title="$__('Edit')"
><i class="fa fa-pencil"></i
></router-link>
<a @click="doDelete()"><i class="fa fa-trash"></i></a>
</span>
</h2>
<div>
<fieldset class="rows">
<ol>
<li>
<label>{{ $__("Processing name") }}:</label>
<span>
{{ processing.name }}
</span>
</li>
</ol>
</fieldset>
<fieldset class="rows">
<legend>{{ $__("Attributes") }}</legend>
<ol v-if="processing.attributes.length">
<li
v-for="(attribute, counter) in processing.attributes"
v-bind:key="counter"
>
<label>{{ attribute.name }}</label>
<span v-if="attribute.type == 'authorised_value'">{{
$__("Authorized value")
}}</span>
<span v-else-if="attribute.type == 'free_text'">{{
$__("Free text")
}}</span>
<span v-else-if="attribute.type == 'db_column'">{{
$__("Database column")
}}</span>
<span v-else
>{{ $__("Unknown") }} - {{ attribute.type }}</span
>
</li>
</ol>
<span v-else>
{{
$__(
"There are no attributes defined for this processing."
)
}}
</span>
</fieldset>
<fieldset class="action">
<router-link
to="/cgi-bin/koha/preservation/settings"
role="button"
class="cancel"
>{{ $__("Close") }}</router-link
>
</fieldset>
</div>
</div>
</template>
<script>
import { inject } from "vue"
import { APIClient } from "../../fetch/api-client.js"
export default {
setup() {
const { setConfirmationDialog, setMessage } = inject("mainStore")
return {
setConfirmationDialog,
setMessage,
}
},
data() {
return {
processing: {
processing_id: null,
name: "",
attributes: [],
},
initialized: false,
}
},
beforeRouteEnter(to, from, next) {
next(vm => {
vm.getProcessing(to.params.processing_id)
})
},
beforeRouteUpdate(to, from) {
this.processing = this.getProcessing(to.params.processing_id)
},
methods: {
async getProcessing(processing_id) {
const client = APIClient.preservation
await client.processings.get(processing_id).then(
processing => {
this.processing = processing
this.initialized = true
},
error => {}
)
},
doDelete: function () {
this.setConfirmationDialog(
{
title: this.$__(
"Are you sure you want to remove this processing?"
),
message: this.processing.name,
accept_label: this.$__("Yes, delete"),
cancel_label: this.$__("No, do not delete"),
},
() => {
const client = APIClient.preservation
client.processings
.delete(this.processing.processing_id)
.then(
success => {
this.setMessage(
this.$__("Processing %s deleted").format(
this.processing.name
),
true
)
this.$router.push(
"/cgi-bin/koha/preservation/settings"
)
},
error => {}
)
}
)
},
},
name: "ProcessingsShow",
}
</script>
<style scoped>
.action_links a {
padding-left: 0.2em;
font-size: 11px;
cursor: pointer;
}
</style>

View file

@ -0,0 +1,196 @@
<template>
<div v-if="!initialized">{{ $__("Loading") }}</div>
<div v-else id="trains_add">
<h2 v-if="train.train_id">
{{ $__("Edit train #%s").format(train.train_id) }}
</h2>
<h2 v-else>{{ $__("New train") }}</h2>
<div>
<form @submit="onSubmit($event)">
<fieldset class="rows">
<ol>
<li>
<label class="required" for="train_name"
>{{ $__("name") }}:</label
>
<input
id="train_name"
v-model="train.name"
:placeholder="$__('Name')"
required
/>
<span class="required">{{ $__("Required") }}</span>
</li>
<li>
<label class="required" for="train_description"
>{{ $__("Description") }}:
</label>
<textarea
id="train_description"
v-model="train.description"
:placeholder="$__('Description')"
required
rows="10"
cols="50"
/>
<span class="required">{{ $__("Required") }}</span>
</li>
<li>
<label for="not_for_loan_waiting_list_in"
>{{
$__("Status for item added to this train")
}}:</label
>
<v-select
id="not_for_loan"
v-model="train.not_for_loan"
label="description"
:reduce="av => av.value"
:options="av_notforloan"
/>
</li>
<li>
<label
class="required"
for="train_default_processing"
>{{ $__("Default processing") }}:
</label>
<v-select
id="train_default_processing"
label="name"
v-model="train.default_processing_id"
:reduce="p => p.processing_id"
:options="processings"
:required="!train.default_processing_id"
>
<template #search="{ attributes, events }">
<input
:required="!train.default_processing_id"
class="vs__search"
v-bind="attributes"
v-on="events"
/>
</template>
</v-select>
</li>
</ol>
</fieldset>
<fieldset class="action">
<input type="submit" value="Submit" />
<router-link
to="/cgi-bin/koha/preservation/trains"
role="button"
class="cancel"
>{{ $__("Cancel") }}</router-link
>
</fieldset>
</form>
</div>
</div>
</template>
<script>
import { inject } from "vue"
import { storeToRefs } from "pinia"
import { APIClient } from "../../fetch/api-client.js"
export default {
setup() {
const AVStore = inject("AVStore")
const { av_notforloan } = storeToRefs(AVStore)
const { setMessage, setWarning } = inject("mainStore")
const PreservationStore = inject("PreservationStore")
const { settings } = storeToRefs(PreservationStore)
return { av_notforloan, setMessage, setWarning, settings }
},
data() {
return {
train: {
train_id: null,
name: "",
description: "",
not_for_loan: this.settings.not_for_loan_default_train_in,
default_processing_id: null,
created_on: null,
closed_on: null,
sent_on: null,
received_on: null,
},
processings: [],
initialized: false,
}
},
beforeRouteEnter(to, from, next) {
next(vm => {
if (to.params.train_id) {
vm.train = vm.getTrain(to.params.train_id)
} else {
vm.initialized = true
}
})
},
beforeCreate() {
const client = APIClient.preservation
client.processings.getAll().then(
processings => {
this.processings = processings
},
error => {}
)
},
methods: {
async getTrain(train_id) {
const client = APIClient.preservation
client.trains.get(train_id).then(train => {
this.train = train
this.initialized = true
})
},
checkForm(train) {
let errors = []
errors.forEach(function (e) {
setWarning(e)
})
return !errors.length
},
onSubmit(e) {
e.preventDefault()
let train = JSON.parse(JSON.stringify(this.train)) // copy
let train_id = train.train_id
if (!this.checkForm(train)) {
return false
}
delete train.train_id
delete train.default_processing
delete train.items
const client = APIClient.preservation
if (train_id) {
client.trains.update(train, train_id).then(
success => {
this.setMessage(this.$__("Train updated"))
this.$router.push("/cgi-bin/koha/preservation/trains")
},
error => {}
)
} else {
client.trains.create(train).then(
success => {
this.setMessage(this.$__("Train created"))
this.$router.push("/cgi-bin/koha/preservation/trains")
},
error => {}
)
}
},
},
components: {},
name: "TrainsFormAdd",
}
</script>

View file

@ -0,0 +1,327 @@
<template>
<div v-if="!initialized">{{ $__("Loading") }}</div>
<div v-else id="trains_add_item">
<h2 v-if="train_item && train_item.train_item_id">
{{ $__("Edit item #%s").format(train_item.item_id) }}
</h2>
<h2 v-else>{{ $__("Add new item to %s").format(train.name) }}</h2>
<div v-if="train_item">
<form @submit="onSubmit($event)">
<fieldset class="rows">
<ol>
<li>
<label for="itemnumber"
>{{ $__("Itemnumber") }}:</label
>
<span>{{ train_item.item_id }}</span>
</li>
<li>
<label for="processing"
>{{ $__("Processing") }}:
</label>
<v-select
id="processing"
label="name"
v-model="train_item.processing_id"
@option:selected="refreshAttributes(1)"
:reduce="p => p.processing_id"
:options="processings"
:clearable="false"
/>
</li>
<li
class="attribute"
v-for="(attribute, counter) in attributes"
v-bind:key="counter"
>
<label :for="`attribute_${counter}`"
>{{ attribute.name }}:
</label>
<span v-if="attribute.type == 'authorised_value'">
<v-select
:id="`attribute_${counter}`"
v-model="attribute.value"
label="description"
:reduce="av => av.value"
:options="
av_options[attribute.option_source]
"
/>
</span>
<span v-else-if="attribute.type == 'free_text'">
<input
:id="`attribute_${counter}`"
v-model="attribute.value"
/>
</span>
<span v-else-if="attribute.type == 'db_column'">
<input
:id="`attribute_${counter}`"
v-model="attribute.value"
/>
</span>
</li>
</ol>
</fieldset>
<fieldset class="action">
<input type="submit" value="Submit" />
<router-link
to="/cgi-bin/koha/preservation/trains"
role="button"
class="cancel"
>{{ $__("Cancel") }}</router-link
>
</fieldset>
</form>
</div>
<div v-else>
<form @submit="getItemFromWaitingList($event)">
<fieldset class="rows">
<ol>
<li>
<label class="required" for="barcode"
>{{ $__("barcode") }}:</label
>
<input
id="barcode"
v-model="barcode"
:placeholder="$__('barcode')"
required
/>
<span class="required">{{ $__("Required") }}</span>
</li>
</ol>
</fieldset>
<fieldset class="action">
<input type="submit" value="Submit" />
<router-link
:to="`/cgi-bin/koha/preservation/trains/${train.train_id}`"
role="button"
class="cancel"
>{{ $__("Cancel") }}</router-link
>
</fieldset>
</form>
</div>
</div>
</template>
<script>
import { inject } from "vue"
import { APIClient } from "../../fetch/api-client"
export default {
setup() {
const { setMessage, setWarning, loading, loaded } = inject("mainStore")
return {
setMessage,
setWarning,
loading,
loaded,
api_mappings,
}
},
data() {
return {
train: {
train_id: null,
name: "",
description: "",
},
item: { item_id: null },
train_item: null,
barcode: "",
processings: [],
processing: null,
initialized: false,
av_options: {},
default_values: {},
attributes: [],
}
},
beforeCreate() {
const client = APIClient.preservation
client.processings
.getAll()
.then(processings => (this.processings = processings))
},
beforeRouteEnter(to, from, next) {
next(vm => {
if (to.params.train_item_id) {
vm.train = vm
.getTrain(to.params.train_id)
.then(() =>
vm
.getTrainItem(
to.params.train_id,
to.params.train_item_id
)
.then(() =>
vm
.refreshAttributes()
.then(() => (vm.initialized = true))
)
)
} else {
vm.train = vm
.getTrain(to.params.train_id)
.then(() => (vm.initialized = true))
}
})
},
methods: {
async getTrain(train_id) {
const client = APIClient.preservation
await client.trains.get(train_id).then(
train => {
this.train = train
},
error => {}
)
},
async getTrainItem(train_id, train_item_id) {
const client = APIClient.preservation
await client.train_items.get(train_id, train_item_id).then(
train_item => {
this.train_item = train_item
this.item = train_item.catalogue_item
},
error => {}
)
},
async getItemFromWaitingList(e) {
e.preventDefault()
const client = APIClient.preservation
client.waiting_list_items.get_from_barcode(this.barcode).then(
item => {
if (!item) {
this.setWarning(
this.$__(
"Cannot find item with this barcode. It must be in the waiting list."
)
)
return
}
this.item = item
this.train_item = {
item_id: item.item_id,
processing_id: this.train.default_processing_id,
}
this.refreshAttributes(1)
},
error => {}
)
},
columnApiMapping(db_column) {
let table_col = db_column.split(".")
let table = table_col[0]
let col = table_col[1]
let api_attribute = this.api_mappings[table][col] || col
return table == "biblio" || table == "biblioitems"
? this.item.biblio[api_attribute]
: this.item[api_attribute]
},
updateDefaultValues() {
this.processing.attributes
.filter(attribute => attribute.type == "db_column")
.forEach(attribute => {
this.default_values[attribute.processing_attribute_id] =
this.columnApiMapping(attribute.option_source)
})
},
async refreshAttributes(apply_default_value) {
this.loading()
const client = APIClient.preservation
await client.processings.get(this.train_item.processing_id).then(
processing => (this.processing = processing),
error => {}
)
this.updateDefaultValues()
this.attributes = this.processing.attributes.map(attribute => {
let value = ""
if (!apply_default_value) {
let existing_attribute = this.train_item.attributes.find(
a =>
a.processing_attribute_id ==
attribute.processing_attribute_id
)
if (existing_attribute) {
value = existing_attribute.value
}
} else if (attribute.type == "db_column") {
value =
this.default_values[attribute.processing_attribute_id]
}
return {
processing_attribute_id: attribute.processing_attribute_id,
name: attribute.name,
type: attribute.type,
option_source: attribute.option_source,
value,
}
})
const client_av = APIClient.authorised_values
let av_cat_array = this.processing.attributes
.filter(attribute => attribute.type == "authorised_value")
.map(attribute => attribute.option_source)
client_av.values
.getCategoriesWithValues([
...new Set(av_cat_array.map(av_cat => '"' + av_cat + '"')),
]) // unique
.then(av_categories => {
av_cat_array.forEach(av_cat => {
let av_match = av_categories.find(
element => element.category_name == av_cat
)
this.av_options[av_cat] = av_match.authorised_values
})
})
.then(() => this.loaded())
},
onSubmit(e) {
e.preventDefault()
let train_item_id = this.train_item.train_item_id
let train_item = {
item_id: this.train_item.item_id,
processing_id: this.train_item.processing_id,
attributes: this.attributes.map(a => ({
processing_attribute_id: a.processing_attribute_id,
value: a.value,
})),
}
const client = APIClient.preservation
if (train_item_id) {
client.train_items
.update(train_item, this.train.train_id, train_item_id)
.then(
success => {
this.setMessage(this.$__("Item updated"))
this.$router.push(
"/cgi-bin/koha/preservation/trains/" +
this.train.train_id
)
},
error => {}
)
} else {
client.train_items.create(train_item, this.train.train_id).then(
success => {
this.setMessage(this.$__("Item added to train"))
this.$router.push(
"/cgi-bin/koha/preservation/trains/" +
this.train.train_id
)
},
error => {}
)
}
},
},
components: {},
name: "TrainsFormAdd",
}
</script>

View file

@ -0,0 +1,270 @@
<template>
<div v-if="!initialized">{{ $__("Loading") }}</div>
<div v-else id="trains_list">
<Toolbar />
<fieldset v-if="count_trains > 0" class="filters">
<label>{{ $__("Filter by") }}:</label>
<input
type="radio"
id="all_status_filter"
v-model="filters.status"
value=""
/><label for="all_status_filter">{{ $__("All") }}</label>
<input
type="radio"
id="closed_status_filter"
v-model="filters.status"
value="closed"
/><label for="closed_status_filter">{{ $__("Closed") }}</label>
<input
type="radio"
id="sent_status_filter"
v-model="filters.status"
value="sent"
/><label for="sent_status_filter">{{ $__("Sent") }}</label>
<input
type="radio"
id="received_status_filter"
v-model="filters.status"
value="received"
/><label for="received_status_filter">{{ $__("Received") }}</label>
<input
@click="filter_table"
id="filter_table"
type="button"
:value="$__('Filter')"
/>
</fieldset>
<div v-if="count_trains > 0" class="page-section">
<KohaTable
ref="table"
v-bind="tableOptions"
@show="doShow"
@edit="doEdit"
@delete="doDelete"
@addItems="doAddItems"
></KohaTable>
</div>
<div v-else class="dialog message">
{{ $__("There are no trains defined") }}
</div>
</div>
</template>
<script>
import flatPickr from "vue-flatpickr-component"
import Toolbar from "./TrainsToolbar.vue"
import { inject, ref, reactive } from "vue"
import { APIClient } from "../../fetch/api-client"
import { build_url } from "../../composables/datatables"
import KohaTable from "../KohaTable.vue"
export default {
setup() {
const AVStore = inject("AVStore")
const { get_lib_from_av, map_av_dt_filter } = AVStore
const { setConfirmationDialog, setMessage } = inject("mainStore")
const table = ref()
const filters = reactive({ status: "" })
return {
get_lib_from_av,
map_av_dt_filter,
setConfirmationDialog,
setMessage,
table,
filters,
}
},
data: function () {
this.filters.status = this.$route.query.status || ""
return {
fp_config: flatpickr_defaults,
count_trains: 0,
initialized: false,
tableOptions: {
columns: this.getTableColumns(),
url: this.table_url,
add_filters: true,
actions: {
0: ["show"],
"-1": [
"edit",
"delete",
{
addItems: {
text: this.$__("Add items"),
icon: "fa fa-plus",
},
},
],
},
},
}
},
beforeRouteEnter(to, from, next) {
next(vm => {
vm.getCountTrains()
})
},
computed: {},
methods: {
async getCountTrains() {
const client = APIClient.preservation
client.trains.count().then(
count => {
this.count_trains = count
this.initialized = true
},
error => {}
)
},
doShow: function (train, dt, event) {
event.preventDefault()
this.$router.push(
"/cgi-bin/koha/preservation/trains/" + train.train_id
)
},
doEdit: function (train, dt, event) {
this.$router.push(
"/cgi-bin/koha/preservation/trains/edit/" + train.train_id
)
},
doDelete: function (train, dt, event) {
this.setConfirmationDialog(
{
title: this.$__(
"Are you sure you want to remove this train?"
),
message: train.name,
accept_label: this.$__("Yes, delete"),
cancel_label: this.$__("No, do not delete"),
},
() => {
const client = APIClient.preservation
client.trains.delete(train.train_id).then(
success => {
this.setMessage(
this.$__("Train %s deleted").format(train.name),
true
)
dt.draw()
},
error => {}
)
}
)
},
doAddItems: function (train, dt, event) {
this.$router.push(
"/cgi-bin/koha/preservation/trains/" +
train.train_id +
"/items/add"
)
},
table_url() {
let url = "/api/v1/preservation/trains"
let q
if (this.filters.status == "closed") {
q = {
"me.closed_on": { "!=": null },
"me.sent_on": null,
"me.received_on": null,
}
} else if (this.filters.status == "sent") {
q = {
"me.closed_on": { "!=": null },
"me.sent_on": { "!=": null },
"me.received_on": null,
}
} else if (this.filters.status == "received") {
q = {
"me.closed_on": { "!=": null },
"me.sent_on": { "!=": null },
"me.received_on": { "!=": null },
}
}
if (q) {
url += "?" + new URLSearchParams({ q: JSON.stringify(q) })
}
return url
},
filter_table: async function () {
let new_route = build_url(
"/cgi-bin/koha/preservation/trains",
this.filters
)
this.$router.push(new_route)
if (this.$refs.table) {
this.$refs.table.redraw(this.table_url())
}
},
getTableColumns: function () {
let escape_str = this.escape_str
return [
{
title: __("Name"),
data: "me.train_id:me.name",
searchable: true,
orderable: true,
render: function (data, type, row, meta) {
return `<a href="/cgi-bin/koha/preservation/trains/${row.train_id}" class="show">${row.name} (#${row.train_id})</a>`
},
},
{
title: __("Created on"),
data: "created_on",
searchable: true,
orderable: true,
render: function (data, type, row, meta) {
return $date(row.created_on)
},
},
{
title: __("Closed on"),
data: "closed_on",
searchable: true,
orderable: true,
render: function (data, type, row, meta) {
return $date(row.closed_on)
},
},
{
title: __("Sent on"),
data: "sent_on",
searchable: true,
orderable: true,
render: function (data, type, row, meta) {
return $date(row.sent_on)
},
},
{
title: __("Received on"),
data: "received_on",
searchable: true,
orderable: true,
render: function (data, type, row, meta) {
return $date(row.received_on)
},
},
]
},
},
components: { flatPickr, Toolbar, KohaTable },
name: "trainsList",
emits: ["select-train", "close"],
}
</script>
<style scoped>
#train_list {
display: table;
}
.filters > input[type="radio"] {
min-width: 0 !important;
}
.filters > input[type="button"] {
margin-left: 1rem;
}
</style>

View file

@ -0,0 +1,548 @@
<template>
<transition name="modal">
<div v-if="show_modal" class="modal">
<h2>{{ $__("Copy item to the following train") }}</h2>
<form @submit="copyItem($event)">
<div class="page-section">
<fieldset class="rows">
<ol>
<li>
<label class="required" for="train_list"
>{{ $__("Select a train") }}:</label
>
<v-select
v-model="train_id_selected_for_copy"
label="name"
:options="train_list"
:reduce="t => t.train_id"
>
<template #search="{ attributes, events }">
<input
:required="
!train_id_selected_for_copy
"
class="vs__search"
v-bind="attributes"
v-on="events"
/>
</template>
</v-select>
<span class="required">{{
$__("Required")
}}</span>
</li>
</ol>
</fieldset>
<fieldset class="action">
<input type="submit" value="Copy" />
<input
type="button"
@click="show_modal = false"
:value="$__('Close')"
/>
</fieldset>
</div>
</form>
</div>
</transition>
<div v-if="!initialized">{{ $__("Loading") }}</div>
<div v-else id="trains_show">
<div id="toolbar" class="btn-toolbar">
<router-link
:to="`/cgi-bin/koha/preservation/trains/${train.train_id}/items/add`"
class="btn btn-default"
><font-awesome-icon icon="plus" />
{{ $__("Add items") }}</router-link
>
<router-link
:to="`/cgi-bin/koha/preservation/trains/edit/${train.train_id}`"
class="btn btn-default"
><font-awesome-icon icon="pencil" />
{{ $__("Edit") }}</router-link
>
<a @click="deleteTrain(train)" class="btn btn-default"
><font-awesome-icon icon="trash" /> {{ $__("Delete") }}</a
>
<a
v-if="!train.closed_on"
class="btn btn-default"
@click="closeTrain"
><font-awesome-icon icon="remove" /> {{ $__("Close") }}</a
>
<a
v-else-if="!train.sent_on"
class="btn btn-default"
@click="sendTrain"
><font-awesome-icon icon="paper-plane" /> {{ $__("Send") }}</a
>
<a
v-else-if="!train.received_on"
class="btn btn-default"
@click="receiveTrain"
><font-awesome-icon icon="inbox" /> {{ $__("Receive") }}</a
>
</div>
<h2>
{{ $__("Train #%s").format(train.train_id) }}
</h2>
<div>
<fieldset class="rows">
<ol>
<li>
<label>{{ $__("Name") }}:</label>
<span>
{{ train.name }}
</span>
</li>
<li>
<label>{{ $__("Description") }}:</label>
<span>
{{ train.description }}
</span>
</li>
<li v-if="train.closed_on">
<label>{{ $__("Closed on") }}:</label>
<span>
{{ format_date(train.closed_on) }}
</span>
</li>
<li v-if="train.sent_on">
<label>{{ $__("Sent on") }}:</label>
<span>
{{ format_date(train.sent_on) }}
</span>
</li>
<li v-if="train.received_on">
<label>{{ $__("Received on") }}:</label>
<span>
{{ format_date(train.received_on) }}
</span>
</li>
<li>
<label
>{{
$__("Status for item added to this train")
}}:</label
>
<span>{{
get_lib_from_av("av_notforloan", train.not_for_loan)
}}</span>
</li>
<li>
<label>{{ $__("Default processing") }}:</label>
<span>
{{ train.default_processing.name }}
</span>
</li>
</ol>
</fieldset>
<fieldset v-if="train.items.length" class="rows">
<legend>{{ $__("Items") }}</legend>
<table v-if="item_table.display" :id="table_id"></table>
<ol v-else>
<li
:id="`item_${counter}`"
class="rows"
v-for="(item, counter) in train.items"
v-bind:key="counter"
>
<!-- FIXME Counter here may change, we should pass an order by clause when retrieving the items -->
<label
>{{ counter + 1 }}
<span class="action_links">
<router-link
:to="`/cgi-bin/koha/preservation/trains/${train.train_id}/items/edit/${item.train_item_id}`"
:title="$__('Edit')"
><i class="fa fa-pencil"></i></router-link
></span>
</label>
<div class="attributes_values">
<span
:id="`attribute_${counter_attribute}`"
class="attribute_value"
v-for="(
attribute, counter_attribute
) in item.attributes"
v-bind:key="counter_attribute"
>
<!-- FIXME We need to display the description of the AV here -->
{{ attribute.processing_attribute.name }}={{
attribute.value
}}
</span>
</div>
</li>
</ol>
</fieldset>
<fieldset class="action">
<router-link
to="/cgi-bin/koha/preservation/trains"
role="button"
class="cancel"
>{{ $__("Close") }}</router-link
>
</fieldset>
</div>
</div>
</template>
<script>
import { inject, createVNode, render } from "vue"
import { APIClient } from "../../fetch/api-client"
import { useDataTable } from "../../composables/datatables"
export default {
setup() {
const format_date = $date
const AVStore = inject("AVStore")
const { get_lib_from_av } = AVStore
const { setConfirmationDialog, setMessage } = inject("mainStore")
const table_id = "item_list"
useDataTable(table_id)
return {
format_date,
get_lib_from_av,
table_id,
setConfirmationDialog,
setMessage,
}
},
data() {
return {
train: {
train_id: null,
name: "",
description: "",
},
initialized: false,
show_modal: false,
item_table: {
display: false,
data: [],
columns: [],
},
train_list: [],
train_id_selected_for_copy: null,
train_item_id_to_copy: null,
}
},
beforeRouteEnter(to, from, next) {
next(vm => {
vm.getTrain(to.params.train_id).then(() => vm.build_datatable())
vm.getTrainList()
})
},
methods: {
async getTrain(train_id) {
const client = APIClient.preservation
await client.trains.get(train_id).then(
train => {
this.train = train
let display = this.train.items.every(
item =>
item.processing_id ==
this.train.default_processing_id
)
if (display) {
this.item_table.data = []
this.train.items.forEach(item => {
let item_row = {}
this.train.default_processing.attributes.forEach(
attribute => {
if (item.attributes.length <= 0) return ""
item_row[
attribute.processing_attribute_id
] = item.attributes.find(
a =>
a.processing_attribute_id ==
attribute.processing_attribute_id
).value
}
)
item_row.item = item
this.item_table.data.push(item_row)
})
this.item_table.columns = []
this.item_table.columns.push({
name: "id",
title: this.$__("ID"),
render: (data, type, row) => {
return 42
},
})
train.default_processing.attributes.forEach(a =>
this.item_table.columns.push({
name: a.name,
title: a.name,
data: a.processing_attribute_id,
})
)
this.item_table.columns.push({
name: "actions",
className: "actions noExport",
title: this.$__("Actions"),
searchable: false,
orderable: false,
render: (data, type, row) => {
return ""
},
})
}
this.initialized = true
this.item_table.display = display
},
error => {}
)
},
getTrainList: function () {
const client = APIClient.preservation
let q = { "me.closed_on": null }
client.trains.getAll(q).then(
trains => (this.train_list = trains),
error => {}
)
},
deleteTrain: function (train) {
this.setConfirmationDialog(
{
title: this.$__(
"Are you sure you want to remove this train?"
),
message: train.name,
accept_label: this.$__("Yes, delete"),
cancel_label: this.$__("No, do not delete"),
},
() => {
const client = APIClient.preservation
client.trains.delete(train.train_id).then(
success => {
this.setMessage(
this.$__("Train %s deleted").format(train.name),
true
)
},
error => {}
)
}
)
},
async updateTrainDate(attribute) {
let train = JSON.parse(JSON.stringify(this.train))
let train_id = train.train_id
delete train.train_id
delete train.items
delete train.default_processing
train[attribute] = new Date()
const client = APIClient.preservation
if (train_id) {
client.trains
.update(train, train_id)
.then(() => this.getTrain(this.train.train_id))
} else {
client.trains
.create(train)
.then(() => this.getTrain(this.train.train_id))
}
},
closeTrain() {
this.updateTrainDate("closed_on")
},
sendTrain() {
this.updateTrainDate("sent_on")
},
receiveTrain() {
this.updateTrainDate("received_on")
},
editItem(train_item_id) {
this.$router.push(
`/cgi-bin/koha/preservation/trains/${this.train.train_id}/items/edit/${train_item_id}`
)
},
removeItem(train_item_id) {
this.setConfirmationDialog(
{
title: this.$__(
"Are you sure you want to remove this item?"
),
accept_label: this.$__("Yes, remove"),
cancel_label: this.$__("No, do not remove"),
},
() => {
const client = APIClient.preservation
client.train_items
.delete(this.train.train_id, train_item_id)
.then(
success => {
this.setMessage(this.$__("Item removed"), true)
this.getTrain(this.train.train_id).then(() => {
$("#" + this.table_id)
.DataTable()
.destroy()
this.build_datatable()
})
},
error => {}
)
}
)
},
selectTrainForCopy(train_item_id) {
this.show_modal = true
this.train_item_id_to_copy = train_item_id
},
copyItem(event) {
event.preventDefault()
const client = APIClient.preservation
let new_train_item = {}
client.train_items
.get(this.train.train_id, this.train_item_id_to_copy)
.then(train_item => {
new_train_item = {
attributes: train_item.attributes.map(attr => {
return {
processing_attribute_id:
attr.processing_attribute_id,
value: attr.value,
}
}),
item_id: train_item.item_id,
processing_id: train_item.processing_id,
}
})
.then(() =>
client.train_items
.create(new_train_item, this.train_id_selected_for_copy)
.then(
success => {
this.setMessage(
this.$__("Item copied successfully.")
)
this.show_modal = false
},
error => {}
)
)
},
build_datatable: function () {
let table_id = this.table_id
let item_table = this.item_table
let removeItem = this.removeItem
let editItem = this.editItem
let selectTrainForCopy = this.selectTrainForCopy
let train = this.train
let table = KohaTable(table_id, {
data: item_table.data,
ordering: false,
autoWidth: false,
columns: item_table.columns,
drawCallback: function (settings) {
var api = new $.fn.dataTable.Api(settings)
$.each($(this).find("td.actions"), function (index, e) {
let tr = $(this).parent()
let train_item_id = api.row(tr).data()
.item.train_item_id
let editButton = createVNode(
"a",
{
class: "btn btn-default btn-xs",
role: "button",
onClick: () => {
editItem(train_item_id)
},
},
[
createVNode("i", {
class: "fa fa-pencil",
"aria-hidden": "true",
}),
__("Edit"),
]
)
let removeButton = createVNode(
"a",
{
class: "btn btn-default btn-xs",
role: "button",
onClick: () => {
removeItem(train_item_id)
},
},
[
createVNode("i", {
class: "fa fa-trash",
"aria-hidden": "true",
}),
__("Remove"),
]
)
let buttons = [editButton, "", removeButton]
if (train.received_on !== null) {
buttons.push("")
buttons.push(
createVNode(
"a",
{
class: "btn btn-default btn-xs",
role: "button",
onClick: () => {
selectTrainForCopy(train_item_id)
},
},
[
createVNode("i", {
class: "fa fa-copy",
"aria-hidden": "true",
}),
__("Copy"),
]
)
)
}
let n = createVNode("span", {}, buttons)
render(n, e)
})
},
})
},
},
name: "TrainsShow",
}
</script>
<style scoped>
.action_links a {
padding-left: 0.2em;
font-size: 11px;
}
.attributes_values {
float: left;
}
.attribute_value {
display: block;
}
.modal {
position: fixed;
z-index: 9998;
top: 0;
left: 0;
width: 35%;
height: 30%;
background-color: rgba(0, 0, 0, 0.5);
display: table;
transition: opacity 0.3s ease;
margin: auto;
padding: 20px 30px;
background-color: #fff;
border-radius: 2px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.33);
transition: all 0.3s ease;
}
</style>

View file

@ -0,0 +1,16 @@
<template>
<div id="toolbar" class="btn-toolbar">
<router-link
to="/cgi-bin/koha/preservation/trains/add"
class="btn btn-default"
><font-awesome-icon icon="plus" />
{{ $__("New train") }}</router-link
>
</div>
</template>
<script>
export default {
name: "TrainsToolbar",
}
</script>

View file

@ -0,0 +1,344 @@
<template>
<transition name="modal_add_to_waiting_list">
<div v-if="show_modal_add_to_waiting_list" class="modal">
<h2>{{ $__("Add items to waiting list") }}</h2>
<form @submit="addItemsToWaitingList($event)">
<div class="page-section">
<fieldset class="rows">
<ol>
<li>
<label class="required" for="barcode_list"
>{{ $__("Barcode list") }}:</label
>
<textarea
id="barcode_list"
v-model="barcode_list"
:placeholder="$__('Barcodes')"
rows="10"
cols="50"
required
/>
</li>
</ol>
</fieldset>
<fieldset class="action">
<input type="submit" value="Submit" />
<input
type="button"
@click="show_modal_add_to_waiting_list = false"
:value="$__('Close')"
/>
</fieldset>
</div>
</form>
</div>
</transition>
<transition name="modal_add_to_train">
<div v-if="show_modal_add_to_train" class="modal">
<h2>{{ $__("Add items to a train") }}</h2>
<form @submit="addItemsToTrain($event)">
<div class="page-section">
<fieldset class="rows">
<ol>
<li>
<label class="required" for="train_list"
>{{ $__("Select a train") }}:</label
>
<v-select
id="train_id"
v-model="train_id_selected_for_add"
label="name"
:options="train_list"
:reduce="t => t.train_id"
>
<template #search="{ attributes, events }">
<input
:required="
!train_id_selected_for_add
"
class="vs__search"
v-bind="attributes"
v-on="events"
/>
</template>
</v-select>
<span class="required">{{
$__("Required")
}}</span>
</li>
</ol>
</fieldset>
<fieldset class="action">
<input type="submit" value="Submit" />
<input
type="button"
@click="show_modal_add_to_train = false"
:value="$__('Close')"
/>
</fieldset>
</div>
</form>
</div>
</transition>
<div v-if="!initialized">{{ $__("Loading") }}</div>
<div v-else-if="!settings.not_for_loan_waiting_list_in" id="waiting-list">
{{ $__("You need to configure this module first.") }}
</div>
<div v-else id="waiting-list">
<div id="toolbar" class="btn-toolbar">
<a
class="btn btn-default"
@click="show_modal_add_to_waiting_list = true"
><font-awesome-icon icon="plus" />
{{ $__("Add to waiting list") }}</a
>
<a
v-if="last_items.length > 0"
class="btn btn-default"
@click="show_modal_add_to_train = true"
><font-awesome-icon icon="plus" />
{{
$__("Add last %s items to a train").format(
last_items.length
)
}}</a
>
</div>
<div v-if="count_waiting_list_items > 0" class="page-section">
<KohaTable
ref="table"
v-bind="tableOptions"
@remove="doRemoveItem"
></KohaTable>
</div>
<div v-else class="dialog message">
{{ $__("There are no items in the waiting list") }}
</div>
</div>
</template>
<script>
import flatPickr from "vue-flatpickr-component"
import { inject, ref } from "vue"
import { storeToRefs } from "pinia"
import { APIClient } from "../../fetch/api-client"
import KohaTable from "../KohaTable.vue"
export default {
setup() {
const table = ref()
const PreservationStore = inject("PreservationStore")
const { settings } = storeToRefs(PreservationStore)
const { setMessage, setConfirmationDialog, loading, loaded } =
inject("mainStore")
return {
table,
settings,
setMessage,
setConfirmationDialog,
loading,
loaded,
}
},
data: function () {
return {
fp_config: flatpickr_defaults,
count_waiting_list_items: 0,
barcode_list: "",
initialized: false,
show_modal_add_to_waiting_list: false,
show_modal_add_to_train: false,
tableOptions: {
columns: this.getTableColumns(),
url: "/api/v1/preservation/waiting-list/items",
options: { embed: "biblio" },
add_filters: true,
actions: {
0: ["show"],
"-1": ["remove"],
},
},
last_items: [],
train_list: [],
train_id_selected_for_add: null,
}
},
beforeRouteEnter(to, from, next) {
next(vm => {
vm.getCountWaitingListItems()
vm.getTrainList()
})
},
methods: {
async getCountWaitingListItems() {
const client = APIClient.preservation
client.waiting_list_items.count().then(count => {
this.count_waiting_list_items = count
this.initialized = true
})
},
getTrainList: function () {
const client = APIClient.preservation
client.trains.getAll().then(
trains => (this.train_list = trains),
error => {}
)
},
addItemsToTrain: function (e) {
e.preventDefault()
this.loading()
let item_ids = Object.values(this.last_items)
const client = APIClient.preservation
let promises = []
this.last_items.forEach(i =>
promises.push(
client.train_items.create(
{ item_id: i.item_id },
this.train_id_selected_for_add
)
)
)
Promise.all(promises)
.then(() => {
this.setMessage(
this.$__(
"The items have been added to train %s."
).format(this.train_id_selected_for_add),
true
)
this.$refs.table.redraw(
"/api/v1/preservation/waiting-list/items"
)
this.show_modal_add_to_train = false
this.last_items = []
})
.then(() => this.loaded())
},
addItemsToWaitingList: function (e) {
e.preventDefault()
this.show_modal_add_to_waiting_list = false
let items = []
this.barcode_list
.split("\n")
.forEach(barcode => items.push({ barcode }))
const client = APIClient.preservation
client.waiting_list_items.createAll(items).then(
result => {
if (result.length) {
this.setMessage(
this.$__("%s new items added.").format(
result.length
),
true
)
this.last_items = result
if (this.$refs.table) {
this.$refs.table.redraw(
"/api/v1/preservation/waiting-list/items"
)
} else {
this.getCountWaitingListItems()
}
} else {
this.setMessage(this.$__("No items added"))
}
},
error => {}
)
this.barcode_list = ""
},
doShow: function (biblio, dt, event) {
event.preventDefault()
location.href =
"/cgi-bin/koha/catalogue/detail.pl?biblionumber=" +
biblio.biblio_id
},
doRemoveItem: function (item, dt, event) {
this.setConfirmationDialog(
{
title: this.$__(
"Are you sure you want to remove this item from the waiting list?"
),
message: item.barcode,
accept_label: this.$__("Yes, remove"),
cancel_label: this.$__("No, do not remove"),
},
() => {
const client = APIClient.preservation
client.waiting_list_items.delete(item.item_id).then(
success => {
this.setMessage(
this.$__("Item removed from the waiting list"),
true
)
dt.draw()
},
error => {}
)
}
)
},
getTableColumns: function () {
let escape_str = this.escape_str
return [
{
data: "biblio.title",
title: __("Title"),
searchable: true,
orderable: true,
render: function (data, type, row, meta) {
return `<a href="/cgi-bin/koha/catalogue/detail.pl?biblionumber=${row.biblio.biblio_id}">${row.biblio.title}</a>`
},
},
{
data: "biblio.author",
title: __("Author"),
searchable: true,
orderable: true,
},
{
data: "callnumber",
title: __("Callnumber"),
searchable: true,
orderable: true,
},
{
data: "external_id",
title: __("Barcode"),
searchable: true,
orderable: true,
},
]
},
},
components: { flatPickr, KohaTable },
name: "WaitingList",
}
</script>
<style scoped>
#waiting_list {
display: table;
}
.modal {
position: fixed;
z-index: 9990;
top: 0;
left: 0;
width: 35%;
height: 30%;
background-color: rgba(0, 0, 0, 0.5);
display: table;
transition: opacity 0.3s ease;
margin: auto;
padding: 20px 30px;
background-color: #fff;
border-radius: 2px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.33);
transition: all 0.3s ease;
}
</style>

View file

@ -4,6 +4,7 @@ import AcquisitionAPIClient from "./acquisition-api-client";
import AVAPIClient from "./authorised-values-api-client";
import ItemAPIClient from "./item-api-client";
import SysprefAPIClient from "./system-preferences-api-client";
import PreservationAPIClient from "./preservation-api-client";
export const APIClient = {
erm: new ERMAPIClient(),
@ -12,4 +13,5 @@ export const APIClient = {
authorised_values: new AVAPIClient(),
item: new ItemAPIClient(),
sysprefs: new SysprefAPIClient(),
preservation: new PreservationAPIClient(),
};

View file

@ -9,6 +9,10 @@ export class AVAPIClient extends HttpClient {
get values() {
return {
get: category =>
this.get({
endpoint: `/${category}/authorised_values`,
}),
getCategoriesWithValues: cat_array =>
this.get({
endpoint:

View file

@ -0,0 +1,184 @@
import HttpClient from "./http-client";
export class PreservationAPIClient extends HttpClient {
constructor() {
super({
baseURL: "/api/v1/preservation/",
});
}
get trains() {
return {
get: (id) =>
this.get({
endpoint: "trains/" + id,
headers: {
"x-koha-embed":
"default_processing,default_processing.attributes,items,items.attributes,items.attributes.processing_attribute",
},
}),
getAll: (query = {}) =>
this.get({
endpoint: "trains?" +
new URLSearchParams({
_per_page: -1,
...(query && { q: JSON.stringify(query) }),
}),
}),
delete: (id) =>
this.delete({
endpoint: "trains/" + id,
}),
create: (train) =>
this.post({
endpoint: "trains",
body: train,
}),
update: (train, id) =>
this.put({
endpoint: "trains/" + id,
body: train,
}),
count: (query = {}) =>
this.count({
endpoint:
"trains?" +
new URLSearchParams({
_page: 1,
_per_page: 1,
...(query && { q: JSON.stringify(query) }),
}),
}),
};
}
get processings() {
return {
get: (id) =>
this.get({
endpoint: "processings/" + id,
headers: {
"x-koha-embed": "attributes",
},
}),
getAll: (query) =>
this.get({
endpoint: "processings?" +
new URLSearchParams({
_per_page: -1,
...(query && { q: JSON.stringify(query) }),
}),
}),
delete: (id) =>
this.delete({
endpoint: "processings/" + id,
}),
create: (processing) =>
this.post({
endpoint: "processings",
body: processing,
}),
update: (processing, id) =>
this.put({
endpoint: "processings/" + id,
body: processing,
}),
count: (query = {}) =>
this.count({
endpoint:
"processings?" +
new URLSearchParams({
_page: 1,
_per_page: 1,
...(query && { q: JSON.stringify(query) }),
}),
}),
};
}
get train_items() {
return {
get: (train_id, id) =>
this.get({
endpoint: "trains/" + train_id + "/items/" + id,
headers: {
"x-koha-embed":
"attributes,catalogue_item,catalogue_item.biblio",
},
}),
delete: (train_id, id) =>
this.delete({
endpoint: "trains/" + train_id + "/items/" + id,
}),
create: (train_item, train_id) =>
this.post({
endpoint: "trains/" + train_id + "/items",
body: train_item,
}),
update: (train_item, train_id, id) =>
this.put({
endpoint: "trains/" + train_id + "/items/" + id,
body: train_item,
}),
count: (train_id, query = {}) =>
this.count({
endpoint:
"trains/" +
train_id +
"/items?" +
new URLSearchParams({
_page: 1,
_per_page: 1,
...(query && { q: JSON.stringify(query) }),
}),
}),
};
}
get waiting_list_items() {
return {
get_from_barcode: (barcode) => {
const q = {
"me.barcode": barcode,
};
const params = {
_page: 1,
_per_page: 1,
q: JSON.stringify(q),
};
return this.get({
endpoint:
"waiting-list/items?" + new URLSearchParams(params),
headers: {
"x-koha-embed": "biblio",
},
}).then((response) => {
return response.length ? response[0] : undefined;
});
},
delete: (id) =>
this.delete({
endpoint: "waiting-list/items/" + id,
}),
createAll: (items) =>
this.post({
endpoint: "waiting-list/items",
body: items,
}),
count: (query = {}) =>
this.count({
endpoint:
"waiting-list/items?" +
new URLSearchParams({
_page: 1,
_per_page: 1,
...(query && { q: JSON.stringify(query) }),
}),
}),
};
}
}
export default PreservationAPIClient;

View file

@ -0,0 +1,74 @@
import { createApp } from "vue";
import { createWebHistory, createRouter } from "vue-router";
import { createPinia } from "pinia";
import { library } from "@fortawesome/fontawesome-svg-core";
import {
faPlus,
faMinus,
faPencil,
faTrash,
faSpinner,
faClose,
faPaperPlane,
faInbox,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import vSelect from "vue-select";
library.add(
faPlus,
faMinus,
faPencil,
faTrash,
faSpinner,
faClose,
faPaperPlane,
faInbox
);
import App from "../components/Preservation/Main.vue";
import { routes } from "../routes/preservation";
const router = createRouter({
history: createWebHistory(),
linkActiveClass: "current",
routes,
});
import { useMainStore } from "../stores/main";
import { useAVStore } from "../stores/authorised-values";
import { usePreservationStore } from "../stores/preservation";
const pinia = createPinia();
const i18n = {
install: (app, options) => {
app.config.globalProperties.$__ = key => {
return window["__"](key);
};
},
};
const app = createApp(App);
const rootComponent = app
.use(i18n)
.use(pinia)
.use(router)
.component("font-awesome-icon", FontAwesomeIcon)
.component("v-select", vSelect);
app.config.unwrapInjectedRef = true;
const mainStore = useMainStore(pinia);
app.provide("mainStore", mainStore);
app.provide("AVStore", useAVStore(pinia));
app.provide("PreservationStore", usePreservationStore(pinia));
app.mount("#preservation");
const { removeMessages } = mainStore;
router.beforeEach((to, from) => {
removeMessages(); // This will actually flag the messages as displayed already
});

View file

@ -0,0 +1,220 @@
import Home from "../components/Preservation/Home.vue";
import TrainsList from "../components/Preservation/TrainsList.vue";
import TrainsShow from "../components/Preservation/TrainsShow.vue";
import TrainsFormAdd from "../components/Preservation/TrainsFormAdd.vue";
import TrainsFormAddItem from "../components/Preservation/TrainsFormAddItem.vue";
import WaitingList from "../components/Preservation/WaitingList.vue";
import Settings from "../components/Preservation/Settings.vue";
import SettingsProcessingsShow from "../components/Preservation/SettingsProcessingsShow.vue";
import SettingsProcessingsFormAdd from "../components/Preservation/SettingsProcessingsFormAdd.vue";
const breadcrumbs = {
home: {
text: "Home", // $t("Home")
path: "/cgi-bin/koha/mainpage.pl",
},
preservation_home: {
text: "Preservation", //$t("Preservation")
path: "/cgi-bin/koha/preservation/home.pl",
},
trains: {
text: "Trains", // $t("Trains")
path: "/cgi-bin/koha/preservation/trains",
},
waiting_list: {
text: "Waiting list", // $t("Waiting list")
path: "/cgi-bin/koha/preservation/waiting-list",
},
settings: {
home: {
text: "Settings", // $t("Settings")
path: "/cgi-bin/koha/preservation/settings",
},
processings: {
home: {
text: "Processings", //$t("Processings")
},
},
},
};
const breadcrumb_paths = {
trains: [
breadcrumbs.home,
breadcrumbs.preservation_home,
breadcrumbs.trains,
],
settings: [
breadcrumbs.home,
breadcrumbs.preservation_home,
breadcrumbs.settings.home,
],
settings_processings: [
breadcrumbs.home,
breadcrumbs.preservation_home,
breadcrumbs.settings.home,
],
};
function build_breadcrumb(parent_breadcrumb, current) {
let breadcrumb = parent_breadcrumb.flat(Infinity);
if (current) {
breadcrumb.push({
text: current,
});
}
return breadcrumb;
}
export const routes = [
{
path: "/cgi-bin/koha/mainpage.pl",
beforeEnter(to, from, next) {
window.location.href = "/cgi-bin/koha/mainpage.pl";
},
},
{
path: "/cgi-bin/koha/preservation/home.pl",
component: Home,
meta: {
breadcrumb: () => [breadcrumbs.home, breadcrumbs.preservation_home],
},
},
{
path: "/cgi-bin/koha/preservation/trains",
children: [
{
path: "",
component: TrainsList,
meta: {
breadcrumb: () => breadcrumb_paths.trains,
},
},
{
path: ":train_id",
children: [
{
path: "",
component: TrainsShow,
meta: {
breadcrumb: () =>
build_breadcrumb(
breadcrumb_paths.trains,
"Show train" // $t("Show train")
),
},
},
{
path: "items",
children: [
{
path: "add",
component: TrainsFormAddItem,
meta: {
breadcrumb: () =>
build_breadcrumb(
breadcrumb_paths.trains,
"Add item to train" // $t("Add item to train")
),
},
},
{
path: "edit/:train_item_id",
component: TrainsFormAddItem,
meta: {
breadcrumb: () =>
build_breadcrumb(
breadcrumb_paths.trains,
"Edit item in train" // $t("Edit item in train")
),
},
},
],
},
],
},
{
path: "add",
component: TrainsFormAdd,
meta: {
breadcrumb: () =>
build_breadcrumb(
breadcrumb_paths.trains,
"Add train" // $t("Add train")
),
},
},
{
path: "edit/:train_id",
component: TrainsFormAdd,
meta: {
breadcrumb: () =>
build_breadcrumb(
breadcrumb_paths.trains,
"Edit train" // $t("Edit train")
),
},
},
],
},
{
path: "/cgi-bin/koha/preservation/waiting-list",
component: WaitingList,
meta: {
breadcrumb: () => [
breadcrumbs.home,
breadcrumbs.preservation_home,
breadcrumbs.waiting_list,
],
},
},
{
path: "/cgi-bin/koha/preservation/settings",
children: [
{
path: "",
component: Settings,
meta: {
breadcrumb: () => breadcrumb_paths.settings,
},
},
{
path: "processings",
children: [
{
path: ":processing_id",
component: SettingsProcessingsShow,
meta: {
breadcrumb: () =>
build_breadcrumb(
breadcrumb_paths.settings_processings,
"Show processing" // $t("Show processing")
),
},
},
{
path: "add",
component: SettingsProcessingsFormAdd,
meta: {
breadcrumb: () =>
build_breadcrumb(
breadcrumb_paths.settings_processings,
"Add processing" // $t("Add processing")
),
},
},
{
path: "edit/:processing_id",
component: SettingsProcessingsFormAdd,
meta: {
breadcrumb: () =>
build_breadcrumb(
breadcrumb_paths.settings_processings,
"Edit processing" // $t("Edit processing")
),
},
},
],
},
],
},
];

View file

@ -36,6 +36,7 @@ export const useAVStore = defineStore("authorised_values", {
av_package_types: [],
av_package_content_types: [],
av_title_publication_types: [],
av_notforloan: [],
}),
actions: {
get_lib_from_av(arr_name, av) {

View file

@ -0,0 +1,10 @@
import { defineStore } from "pinia";
export const usePreservationStore = defineStore("preservation", {
state: () => ({
settings: {
not_for_loan_waiting_list_in: null,
not_for_loan_default_train_in: 0,
},
}),
});

View file

@ -6,6 +6,7 @@ const webpack = require('webpack');
module.exports = {
entry: {
erm: "./koha-tmpl/intranet-tmpl/prog/js/vue/modules/erm.ts",
preservation: "./koha-tmpl/intranet-tmpl/prog/js/vue/modules/preservation.ts",
},
output: {
filename: "[name].js",