Bug 38010: Add vue app and list component

This patch adds the base vue app as well as the component to list vendors

Amended-by: Jonathan Druart
chmod 755 acqui/vendors.pl

Signed-off-by: Michaela Sieber <michaela.sieber@kit.edu>
Signed-off-by: Jonathan Druart <jonathan.druart@bugs.koha-community.org>
Signed-off-by: Katrin Fischer <katrin.fischer@bsz-bw.de>
This commit is contained in:
Matt Blenkinsop 2024-09-11 09:11:32 +00:00 committed by Katrin Fischer
parent cb0268075d
commit b71b62059a
No known key found for this signature in database
GPG Key ID: 0EF6E2C03357A834
16 changed files with 674 additions and 13 deletions

View File

@ -168,6 +168,7 @@ for my $vendor (@suppliers) {
};
}
$template->param(
loop_suppliers => $loop_suppliers,
supplier => ( $booksellerid || $supplier ),

54
acqui/vendors.pl Executable file
View File

@ -0,0 +1,54 @@
#!/usr/bin/perl
# Copyright 2025 Open Fifth
#
# This file is part of Koha.
#
# Koha is free software; you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# Koha is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Koha; if not, see <http://www.gnu.org/licenses>.
use Modern::Perl;
use C4::Auth qw( get_template_and_user haspermission );
use C4::Output qw( output_html_with_http_headers );
use CGI qw ( -utf8 );
use C4::Context;
use Koha::Acquisition::Currencies;
my $query = CGI->new;
my ( $template, $loggedinuser, $cookie, $userflags ) = get_template_and_user(
{
template_name => 'acqui/vendors.tt',
query => $query,
type => 'intranet',
flagsrequired => { acquisition => '*' },
}
);
my $user_permissions = {};
my $var_data = $template->{VARS};
foreach my $key ( keys %{$var_data} ) {
$user_permissions->{$key} = $var_data->{$key} if ( $key =~ /CAN_user_(.*)/ );
}
my @gst_values = map { option => $_ + 0.0 }, split( '\|', C4::Context->preference("TaxRates") );
$template->param(
user_permissions => $user_permissions,
currencies => Koha::Acquisition::Currencies->search->unblessed,
gst_values => \@gst_values,
edifact => C4::Context->preference('EDIFACT')
);
output_html_with_http_headers $query, $cookie, $template->output;

View File

@ -119,6 +119,9 @@ properties:
description: List of aliases
items:
$ref: "vendor_alias.yaml"
baskets:
type: array
description: List of baskets
subscriptions_count:
type:
- integer
@ -126,4 +129,4 @@ properties:
description: Number of linked subscriptions
additionalProperties: false
required:
- name
- name

View File

@ -35,6 +35,7 @@
type: string
enum:
- aliases
- baskets
- subscriptions+count
collectionFormat: csv
responses:
@ -268,4 +269,4 @@
$ref: "../swagger.yaml#/definitions/error"
x-koha-authorization:
permissions:
acquisition: vendors_manage
acquisition: vendors_manage

View File

@ -24,6 +24,7 @@ RewriteRule ^/cgi-bin/koha/erm/.*$ /cgi-bin/koha/erm/erm.pl [PT]
RewriteCond %{REQUEST_URI} !^/cgi-bin/koha/preservation/.*.pl$
RewriteRule ^/cgi-bin/koha/preservation/.*$ /cgi-bin/koha/preservation/home.pl [PT]
RewriteRule ^/cgi-bin/koha/admin/record_sources(.*)?$ /cgi-bin/koha/admin/record_sources.pl$1 [PT]
RewriteRule ^/cgi-bin/koha/vendors(.*)?$ /cgi-bin/koha/acqui/vendors.pl$1 [PT]
Alias "/api" "/usr/share/koha/api"
<Directory "/usr/share/koha/api">
@ -35,4 +36,4 @@ Alias "/api" "/usr/share/koha/api"
RewriteBase /api/
RewriteCond %{REQUEST_URI} !^/api/v[0-1]+/app.pl
RewriteRule ^(v[0-9]+)/(.*)$ /api/$1/app.pl/api/$1/$2 [L]
</Directory>
</Directory>

View File

@ -33,7 +33,7 @@
[% END %]
[% ELSIF ( CAN_user_acquisition_vendors_manage ) %]
<div class="btn-group"
><a class="btn btn-default" href="/cgi-bin/koha/acqui/supplier.pl?op=enter"><i class="fa fa-plus"></i> New vendor</a></div
><a class="btn btn-default" href="/cgi-bin/koha/vendors"><i class="fa fa-plus"></i> New vendor</a></div
>
[% END %]
</div>

View File

@ -48,13 +48,8 @@
<div class="col">
<div id="acqui_acqui_home_order">
<fieldset>
<legend>Search vendors</legend>
<form name="findsupplier" action="/cgi-bin/koha/acqui/booksellers.pl" method="get">
<p
><label for="supplierpage">Vendor: </label><input type="text" size="25" name="supplier" id="supplierpage" class="focus" />
<input type="submit" class="btn btn-primary" value="Search" />
</p>
</form>
<legend>View vendors</legend>
<a href="/cgi-bin/koha/vendors" class="btn btn-default">Vendor list</a>
</fieldset>
</div>
</div>

View File

@ -0,0 +1,47 @@
[% USE raw %]
[% USE To %]
[% USE Asset %]
[% USE KohaDates %]
[% USE TablesSettings %]
[% PROCESS 'i18n.inc' %]
[% SET footerjs = 1 %]
[% PROCESS 'patron-search.inc' %]
[% INCLUDE 'doc-head-open.inc' %]
<title
>[% FILTER collapse %]
[% t("Vendors") | html %]
&rsaquo; [% t("Koha") | html %]
[% END %]</title
>
[% INCLUDE 'doc-head-close.inc' %]
</head>
<body id="acq_booksellers" class="acq">
[% WRAPPER 'header.inc' %]
[% INCLUDE 'acquisitions-search.inc' %]
[% END %]
<div id="__vendors">
<!-- this is closed in intranet-bottom.inc -->
[% MACRO jsinclude BLOCK %]
[% INCLUDE 'calendar.inc' %]
[% INCLUDE 'datatables.inc' %]
[% INCLUDE 'columns_settings.inc' %]
[% INCLUDE 'js-patron-format.inc' %]
[% INCLUDE 'js-date-format.inc' %]
<script>
const userPermissions = [% To.json(user_permissions) | $raw %];
const currencies = [% To.json(currencies) | $raw %];
const gstValues = [% To.json(gst_values) | $raw %];
const edifact = [% To.json(edifact) | $raw %]
const viewBaskets = [% To.json(viewbaskets) | $raw %]
</script>
[% Asset.js("js/vue/dist/acquisitions.js") | $raw %]
[% INCLUDE 'select2.inc' %]
[% SET columns = ['cardnumber','name','category','branch','action'] %]
[% PROCESS patron_search_modal columns => columns, modal_title => t("Select user") %]
[% PROCESS patron_search_js columns => columns, filter => erm_users, actions => ["select"], preview_on_name_click => 1 %]
[% END %]
[% INCLUDE 'intranet-bottom.inc' %]</div
>

View File

@ -7,15 +7,46 @@ export class AcquisitionAPIClient {
get vendors() {
return {
get: id =>
this.get({
endpoint: "vendors/" + id,
headers: {
"x-koha-embed": "aliases",
},
}),
getAll: (query, params) =>
this.httpClient.getAll({
endpoint: "vendors",
query,
params: { _order_by: "name", ...params },
headers: {
"x-koha-embed": "aliases",
"x-koha-embed": "aliases,baskets",
},
}),
delete: id =>
this.delete({
endpoint: "vendors/" + id,
}),
create: vendor =>
this.post({
endpoint: "vendors",
body: vendor,
}),
update: (vendor, id) =>
this.put({
endpoint: "vendors/" + id,
body: vendor,
}),
count: (query = {}) =>
this.count({
endpoint:
"vendors?" +
new URLSearchParams({
_page: 1,
_per_page: 1,
...(query && { q: JSON.stringify(query) }),
}),
}),
};
}
}

View File

@ -0,0 +1,9 @@
<template>
<h1>Vendors</h1>
</template>
<script>
export default {};
</script>
<style></style>

View File

@ -0,0 +1,156 @@
<template>
<div v-if="initialized">
<div id="sub-header">
<Breadcrumbs />
<Help />
</div>
<div class="main container-fluid">
<div class="row">
<div class="col-md-10 order-md-2 order-sm-1">
<main>
<Dialog />
<router-view />
</main>
</div>
<div class="col-md-2 order-sm-2 order-md-1">
<LeftMenu :title="$__('Vendors')"></LeftMenu>
</div>
</div>
</div>
</div>
<div class="main container-fluid" v-else>
<Dialog />
</div>
</template>
<script>
import { inject } from "vue";
import Breadcrumbs from "../Breadcrumbs.vue";
import Help from "../Help.vue";
import LeftMenu from "../LeftMenu.vue";
import Dialog from "../Dialog.vue";
import "vue-select/dist/vue-select.css";
import { storeToRefs } from "pinia";
import { APIClient } from "../../fetch/api-client";
export default {
setup() {
const vendorStore = inject("vendorStore");
const AVStore = inject("AVStore");
const mainStore = inject("mainStore");
const { loading, loaded, setError } = mainStore;
return {
vendorStore,
AVStore,
setError,
loading,
loaded,
AVStore,
};
},
beforeCreate() {
this.loading();
const fetchConfig = () => {
let promises = [];
const av_client = APIClient.authorised_values;
const authorised_values = {
av_vendor_types: "VENDOR_TYPE",
av_vendor_interface_types: "VENDOR_INTERFACE_TYPE",
};
let av_cat_array = Object.keys(authorised_values).map(
function (av_cat) {
return '"' + authorised_values[av_cat] + '"';
}
);
promises.push(
av_client.values
.getCategoriesWithValues(av_cat_array)
.then(av_categories => {
Object.entries(authorised_values).forEach(
([av_var, av_cat]) => {
const av_match = av_categories.find(
element => element.category_name == av_cat
);
this.AVStore[av_var] =
av_match.authorised_values;
}
);
})
);
return Promise.all(promises);
};
fetchConfig().then(() => {
this.vendorStore.currencies = currencies;
this.vendorStore.gstValues = gstValues.map(gv => {
return {
label: `${(gv.option * 100).toFixed(2)}%`,
value: gv.option,
};
});
this.loaded();
this.initialized = true;
});
},
data() {
return {
initialized: true,
};
},
methods: {},
components: {
Breadcrumbs,
Dialog,
Help,
LeftMenu,
},
};
</script>
<style>
#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"]
):not([type="radio"]),
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.current.disabled {
background-color: inherit;
border-left: 5px solid #e6e6e6;
color: #000;
}
#navmenulist ul li a.disabled {
color: #666;
pointer-events: none;
font-weight: 700;
}
</style>

View File

@ -0,0 +1,250 @@
<template>
<div v-if="!initialized">{{ $__("Loading") }}</div>
<div v-else id="vendors_list">
<Toolbar>
<ToolbarButton
:to="{ name: 'VendorList' }"
icon="plus"
:title="$__('New vendor')"
/>
</Toolbar>
<div v-if="vendor_count > 0" class="page-section">
<KohaTable
ref="table"
v-bind="tableOptions"
@show="doShow"
@edit="doEdit"
@delete="doDelete"
@select="doSelect"
></KohaTable>
</div>
<div v-else class="alert alert-info">
{{ $__("There are no vendors defined") }}
</div>
</div>
</template>
<script>
import flatPickr from "vue-flatpickr-component";
import Toolbar from "../Toolbar.vue";
import ToolbarButton from "../ToolbarButton.vue";
import { inject, ref, reactive } from "vue";
import { APIClient } from "../../fetch/api-client.js";
import { storeToRefs } from "pinia";
import { build_url } from "../../composables/datatables";
import KohaTable from "../KohaTable.vue";
export default {
setup() {
const vendorStore = inject("vendorStore");
const { vendors } = storeToRefs(vendorStore);
const { setConfirmationDialog, setMessage } = inject("mainStore");
const { get_lib_from_av, map_av_dt_filter } = inject("AVStore");
const table = ref();
// const filters = reactive({
// by_expired: false,
// max_expiration_date: "",
// by_mine: false,
// })
return {
vendors,
get_lib_from_av,
map_av_dt_filter,
table,
setConfirmationDialog,
setMessage,
escape_str,
// filters,
};
},
data() {
return {
fp_config: flatpickr_defaults,
vendor_count: 0,
initialized: false,
tableOptions: {
columns: this.getTableColumns(),
options: { embed: "aliases,baskets,subscriptions" },
url: () => this.table_url(),
add_filters: true,
filters_options: {
1: [
{ _id: 0, _str: this.$__("Inactive") },
{ _id: 1, _str: this.$__("Active") },
],
...(this.map_av_dt_filter("av_vendor_types").length && {
2: () => this.map_av_dt_filter("av_vendor_types"),
}),
},
actions: {
0: ["show"],
"-1": ["edit", "delete"],
},
},
before_route_entered: false,
building_table: false,
};
},
beforeRouteEnter(to, from, next) {
next(vm => {
vm.getVendorCount().then(() => (vm.initialized = true));
});
},
methods: {
async getVendorCount() {
const client = APIClient.acquisition;
await client.vendors.count().then(
count => {
this.vendor_count = count;
},
error => {}
);
},
doShow: function ({ id }, dt, event) {
event.preventDefault();
this.$router.push({
name: "VendorShow",
params: { vendor_id: id },
});
},
doEdit: function ({ id }, dt, event) {
this.$router.push({
name: "Home",
});
},
doDelete: function (vendor, dt, event) {
this.setConfirmationDialog(
{
title: this.$__(
"Are you sure you want to remove this vendor?"
),
message: vendor.name,
accept_label: this.$__("Yes, delete"),
cancel_label: this.$__("No, do not delete"),
},
() => {
const client = APIClient.erm;
client.vendors.delete(vendor.id).then(
success => {
this.setMessage(
this.$__("Vendor %s deleted").format(
vendor.name
),
true
);
dt.draw();
},
error => {}
);
}
);
},
doSelect: function (vendor, dt, event) {
this.$emit("select-vendor", vendor.id);
this.$emit("close");
},
table_url: function () {
let url = "/api/v1/acquisitions/vendors";
return url;
},
getTableColumns: function () {
const escape_str = this.escape_str;
const get_lib_from_av = this.get_lib_from_av;
return [
{
title: __("Name"),
data: "me.name:me.id",
searchable: true,
orderable: true,
render: function (data, type, row, meta) {
return (
'<a href="/cgi-bin/koha/vendors/' +
row.id +
'" class="show">' +
escape_str(`${row.name} (#${row.id})`) +
"</a>"
);
},
},
{
title: __("Status"),
data: "active",
searchable: true,
orderable: true,
render: function (data, type, row, meta) {
return escape_str(
row.active ? __("Active") : __("Inactive")
);
},
},
{
title: __("Type"),
data: "type",
searchable: true,
orderable: true,
render(data, type, row, meta) {
return get_lib_from_av("av_vendor_types", row.type);
},
},
{
title: __("Currency"),
data: "invoice_currency",
searchable: true,
orderable: true,
},
{
title: __("Baskets"),
data: "baskets",
searchable: false,
orderable: true,
render: function (data, type, row, meta) {
return row.baskets.length
? '<a href="/cgi-bin/koha/vendors/' +
row.id +
'" class="show">' +
escape_str(
`${row.baskets.length} basket(s)`
) +
"</a>"
: escape_str(__("No baskets"));
},
},
{
title: __("Subscriptions"),
data: "subscriptions",
searchable: false,
orderable: true,
render: function (data, type, row, meta) {
return row.subscriptions.length
? '<a href="/cgi-bin/koha/serials/serials-search.pl?bookseller_filter=' +
row.name +
"&searched=1" +
'" class="show">' +
escape_str(
`${row.subscriptions.length} subscription(s)`
) +
"</a>"
: escape_str(__("No subscriptions"));
},
},
];
},
},
components: { flatPickr, Toolbar, ToolbarButton, KohaTable },
name: "VendorList",
emits: ["select-vendor", "close"],
};
</script>
<style scoped>
.filters > label[for="by_mine_filter"],
.filters > input[type="checkbox"],
.filters > input[type="button"] {
margin-left: 1rem;
}
</style>

View File

@ -0,0 +1,62 @@
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,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import vSelect from "vue-select";
library.add(faPlus, faMinus, faPencil, faTrash, faSpinner);
import App from "../components/Vendors/Main.vue";
import { routes as routesDef } from "../routes/vendors";
import { useMainStore } from "../stores/main";
import { useVendorStore } from "../stores/vendors";
import { useAVStore } from "../stores/authorised-values";
import { useNavigationStore } from "../stores/navigation";
import i18n from "../i18n";
const pinia = createPinia();
const mainStore = useMainStore(pinia);
const AVStore = useAVStore(pinia);
const navigationStore = useNavigationStore(pinia);
const routes = navigationStore.setRoutes(routesDef);
const router = createRouter({
history: createWebHistory(),
linkActiveClass: "current",
routes,
});
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;
app.provide("vendorStore", useVendorStore(pinia));
app.provide("mainStore", mainStore);
app.provide("AVStore", AVStore);
app.provide("navigationStore", navigationStore);
app.mount("#__vendors");
const { removeMessages } = mainStore;
router.beforeEach((to, from) => {
navigationStore.$patch({ current: to.matched, params: to.params || {} });
removeMessages(); // This will actually flag the messages as displayed already
});

View File

@ -0,0 +1,43 @@
import { markRaw } from "vue";
import Home from "../components/Vendors/Home.vue";
import VendorList from "../components/Vendors/VendorList.vue";
import VendorShow from "../components/Vendors/VendorShow.vue";
import { $__ } from "../i18n";
export const routes = [
{
path: "/cgi-bin/koha/acqui/booksellers.pl",
is_default: true,
is_base: true,
title: $__("Vendors"),
children: [
{
path: "",
name: "Home",
component: markRaw(Home),
is_navigation_item: false,
},
{
path: "/cgi-bin/koha/vendors",
title: $__("Vendor list"),
icon: "fa fa-check-circle",
is_end_node: true,
children: [
{
path: "",
name: "VendorList",
component: markRaw(VendorList),
},
{
path: ":vendor_id",
name: "VendorShow",
component: markRaw(VendorShow),
title: $__("Show vendor"),
},
],
},
],
},
];

View File

@ -4,7 +4,13 @@ import { reactive, toRefs } from "vue";
export const useVendorStore = defineStore("vendors", () => {
const store = reactive({
vendors: [],
config: { settings: {} },
currencies: [],
gstValues: [],
config: {
settings: {
edifact: false,
},
},
});
return {

View File

@ -19,6 +19,8 @@ module.exports = [
"./koha-tmpl/intranet-tmpl/prog/js/vue/modules/preservation.ts",
"admin/record_sources":
"./koha-tmpl/intranet-tmpl/prog/js/vue/modules/admin/record_sources.ts",
vendors: "./koha-tmpl/intranet-tmpl/prog/js/vue/modules/vendors.ts",
islands: "./koha-tmpl/intranet-tmpl/prog/js/vue/modules/islands.ts",
},
output: {
filename: "[name].js",