diff --git a/Koha/CirculationRules.pm b/Koha/CirculationRules.pm index ccff3eb340..17c371aba8 100644 --- a/Koha/CirculationRules.pm +++ b/Koha/CirculationRules.pm @@ -202,6 +202,10 @@ our $RULE_KINDS = { scope => [ 'branchcode', 'categorycode', 'itemtype' ], can_be_blank => 0, }, + overdue_X_has_rules => { + scope => [ 'branchcode', 'categorycode', 'itemtype' ], + can_be_blank => 0, + }, renewalperiod => { scope => [ 'branchcode', 'categorycode', 'itemtype' ], }, diff --git a/admin/circulation_triggers.pl b/admin/circulation_triggers.pl new file mode 100644 index 0000000000..6a09f5b86c --- /dev/null +++ b/admin/circulation_triggers.pl @@ -0,0 +1,43 @@ +#!/usr/bin/perl + +# Copyright 2024 PTFS Europe Ltd +# +# 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 . + +use Modern::Perl; + +use CGI qw ( -utf8 ); +use C4::Auth qw( get_template_and_user ); +use C4::Output qw( output_html_with_http_headers ); + +my $query = CGI->new; + +my ( $template, $loggedinuser, $cookie ) = get_template_and_user( + { + template_name => "admin/circulation_triggers.tt", + query => $query, + type => "intranet", + } +); + +my $letters = Koha::Notice::Templates->search( + { module => "circulation", lang => 'default' }, + { columns => [ 'module', 'code', 'branchcode', 'name' ] } +)->unblessed; + +$template->param( letters => $letters ); + +output_html_with_http_headers $query, $cookie, $template->output; diff --git a/debian/templates/apache-shared-intranet.conf b/debian/templates/apache-shared-intranet.conf index 30c1bddbc6..ad30ad05c6 100644 --- a/debian/templates/apache-shared-intranet.conf +++ b/debian/templates/apache-shared-intranet.conf @@ -32,6 +32,7 @@ RewriteRule ^/cgi-bin/koha/admin/record_sources(.*)?$ /cgi-bin/koha/admin/record RewriteCond %{QUERY_STRING} booksellerid=(.*) RewriteRule ^/cgi-bin/koha/acqui/supplier.pl$ /cgi-bin/koha/acquisition/vendors/%1? [R] RewriteRule ^/cgi-bin/koha/acquisition/vendors(.*)?$ /cgi-bin/koha/acqui/vendors.pl$1 [PT] +RewriteRule ^/cgi-bin/koha/admin/circulation_triggers(.*)?$ /cgi-bin/koha/admin/circulation_triggers.pl$1 [PT] Alias "/api" "/usr/share/koha/api" diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/admin-home.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/admin-home.tt index 3d281c185d..11666cae17 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/admin-home.tt +++ b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/admin-home.tt @@ -96,6 +96,10 @@
Circulation and fine rules
Define circulation and fine rules for combinations of libraries, patron categories, and item types
[% END %] + [% IF ( CAN_user_tools_edit_notice_status_triggers ) %] +
Circulation triggers
+
Define triggers for circulation notices
+ [% END %] [% IF ( CAN_user_parameters_manage_patron_attributes ) %]
Patron attribute types
Define extended attributes (identifiers and statistical categories) for patron records
diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/circulation_triggers.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/circulation_triggers.tt new file mode 100644 index 0000000000..3d534c3211 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/circulation_triggers.tt @@ -0,0 +1,42 @@ +[% USE raw %] +[% USE To %] +[% USE Asset %] +[% USE Branches %] +[% USE KohaDates %] +[% USE TablesSettings %] +[% USE AuthorisedValues %] +[% SET footerjs = 1 %] +[% PROCESS 'i18n.inc' %] +[% INCLUDE 'doc-head-open.inc' %] +[% FILTER collapse %] + [% t("Circulation triggers") | html %] + › [% t("Administration") | html %] › [% t("Koha") | html %] + [% END %] +[% INCLUDE 'doc-head-close.inc' %] + + + +[% WRAPPER 'header.inc' %] + [% INCLUDE 'prefs-admin-search.inc' %] +[% END %] + +
+ + + [% MACRO jsinclude BLOCK %] + [% INCLUDE 'calendar.inc' %] + + [% INCLUDE 'datatables.inc' %] + [% INCLUDE 'js-date-format.inc' %] + + [% Asset.js("js/vue/dist/admin/circulation_triggers.js") | $raw %] + [% END %] + + [% INCLUDE 'intranet-bottom.inc' %]
diff --git a/koha-tmpl/intranet-tmpl/prog/js/fetch/circulation-rules-api-client.js b/koha-tmpl/intranet-tmpl/prog/js/fetch/circulation-rules-api-client.js new file mode 100644 index 0000000000..ecf033db6f --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/fetch/circulation-rules-api-client.js @@ -0,0 +1,36 @@ +export class CircRuleAPIClient { + constructor(HttpClient) { + this.httpClient = new HttpClient({ + baseURL: "/api/v1/circulation_rules", + }); + } + + get circ_rules() { + return { + getAll: (query, params, headers) => + this.httpClient.getAll({ + endpoint: "", + query, + params, + headers, + }), + update: rule => + this.httpClient.put({ + endpoint: "", + body: rule, + }), + count: (query = {}) => + this.httpClient.count({ + endpoint: + "?" + + new URLSearchParams({ + _page: 1, + _per_page: 1, + ...(query && { q: JSON.stringify(query) }), + }), + }), + }; + } +} + +export default CircRuleAPIClient; diff --git a/koha-tmpl/intranet-tmpl/prog/js/fetch/library-api-client.js b/koha-tmpl/intranet-tmpl/prog/js/fetch/library-api-client.js new file mode 100644 index 0000000000..01d34db334 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/fetch/library-api-client.js @@ -0,0 +1,25 @@ +export class LibraryAPIClient { + constructor(HttpClient) { + this.httpClient = new HttpClient({ + baseURL: "/api/v1/libraries", + }); + } + + get libraries() { + return { + getAll: (query, params, headers) => + this.httpClient.getAll({ + endpoint: "", + query, + params, + headers, + }), + get: id => + this.httpClient.get({ + endpoint: `/${id}`, + }), + }; + } +} + +export default LibraryAPIClient; diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Admin/CirculationTriggers/CirculationTriggersForm.vue b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Admin/CirculationTriggers/CirculationTriggersForm.vue new file mode 100644 index 0000000000..25c0ba0298 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Admin/CirculationTriggers/CirculationTriggersForm.vue @@ -0,0 +1,44 @@ + + + diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Admin/CirculationTriggers/CirculationTriggersFormAdd.vue b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Admin/CirculationTriggers/CirculationTriggersFormAdd.vue new file mode 100644 index 0000000000..136fd55a0e --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Admin/CirculationTriggers/CirculationTriggersFormAdd.vue @@ -0,0 +1,1008 @@ + + + + + diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Admin/CirculationTriggers/CirculationTriggersFormConfirmReset.vue b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Admin/CirculationTriggers/CirculationTriggersFormConfirmReset.vue new file mode 100644 index 0000000000..0c1e96286a --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Admin/CirculationTriggers/CirculationTriggersFormConfirmReset.vue @@ -0,0 +1,487 @@ + + + + + diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Admin/CirculationTriggers/CirculationTriggersFormConfirmTriggerDelete.vue b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Admin/CirculationTriggers/CirculationTriggersFormConfirmTriggerDelete.vue new file mode 100644 index 0000000000..ccc989f754 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Admin/CirculationTriggers/CirculationTriggersFormConfirmTriggerDelete.vue @@ -0,0 +1,257 @@ + + + + diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Admin/CirculationTriggers/CirculationTriggersList.vue b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Admin/CirculationTriggers/CirculationTriggersList.vue new file mode 100644 index 0000000000..3060373d56 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Admin/CirculationTriggers/CirculationTriggersList.vue @@ -0,0 +1,503 @@ + + + + + diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Admin/CirculationTriggers/Main.vue b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Admin/CirculationTriggers/Main.vue new file mode 100644 index 0000000000..9a05c2d347 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Admin/CirculationTriggers/Main.vue @@ -0,0 +1,69 @@ + + + + + diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Admin/CirculationTriggers/TriggerContext.vue b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Admin/CirculationTriggers/TriggerContext.vue new file mode 100644 index 0000000000..6be8bc94ed --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Admin/CirculationTriggers/TriggerContext.vue @@ -0,0 +1,50 @@ + + + + + diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Admin/CirculationTriggers/TriggersTable.vue b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Admin/CirculationTriggers/TriggersTable.vue new file mode 100644 index 0000000000..b79d5da7d5 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Admin/CirculationTriggers/TriggersTable.vue @@ -0,0 +1,357 @@ + + + + + diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/fetch/api-client.js b/koha-tmpl/intranet-tmpl/prog/js/vue/fetch/api-client.js index ea92f43d3a..6c504ca3f5 100644 --- a/koha-tmpl/intranet-tmpl/prog/js/vue/fetch/api-client.js +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/fetch/api-client.js @@ -7,6 +7,7 @@ import ILLAPIClient from "@fetch/ill-api-client"; import BiblioAPIClient from "@fetch/biblio-api-client"; import CheckoutAPIClient from "@fetch/checkout-api-client"; import PatronAPIClient from "@fetch/patron-api-client"; +import LibraryAPIClient from "@fetch/library-api-client"; import AcquisitionAPIClient from "@fetch/acquisition-api-client"; import AdditionalFieldsAPIClient from "@fetch/additional-fields-api-client"; import AVAPIClient from "@fetch/authorised-values-api-client"; @@ -16,6 +17,7 @@ import RecordSourcesAPIClient from "@fetch/record-sources-api-client"; import SysprefAPIClient from "@fetch/system-preferences-api-client"; import SIP2APIClient from "@fetch/sip2-api-client"; import PreservationAPIClient from "@fetch/preservation-api-client"; +import CircRuleAPIClient from "@fetch/circulation-rules-api-client"; export const APIClient = { booking: new BookingAPIClient(HttpClient), @@ -25,6 +27,7 @@ export const APIClient = { biblio: new BiblioAPIClient(HttpClient), checkout: new CheckoutAPIClient(HttpClient), patron: new PatronAPIClient(HttpClient), + library: new LibraryAPIClient(HttpClient), acquisition: new AcquisitionAPIClient(HttpClient), additional_fields: new AdditionalFieldsAPIClient(HttpClient), authorised_values: new AVAPIClient(HttpClient), @@ -34,6 +37,7 @@ export const APIClient = { sip2: new SIP2APIClient(HttpClient), preservation: new PreservationAPIClient(HttpClient), record_sources: new RecordSourcesAPIClient(HttpClient), + circRule: new CircRuleAPIClient(HttpClient), }; export default APIClient; diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/modules/admin/circulation_triggers.ts b/koha-tmpl/intranet-tmpl/prog/js/vue/modules/admin/circulation_triggers.ts new file mode 100644 index 0000000000..f5e6a50c50 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/modules/admin/circulation_triggers.ts @@ -0,0 +1,57 @@ +import { createApp } from "vue"; +import { createPinia } from "pinia"; +import { createWebHistory, createRouter } from "vue-router"; + +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"; +import { useNavigationStore } from "../../stores/navigation"; +import { useMainStore } from "../../stores/main"; +import { useCircRulesStore } from "../../stores/circulation-rules"; +import routesDef from "../../routes/admin/circulation_triggers"; + +library.add(faPlus, faMinus, faPencil, faTrash, faSpinner); + +const pinia = createPinia(); +const navigationStore = useNavigationStore(pinia); +const mainStore = useMainStore(pinia); +const circRulesStore = useCircRulesStore(pinia); +const { removeMessages } = mainStore; +const { setRoutes } = navigationStore; +const routes = setRoutes(routesDef); + +const router = createRouter({ + history: createWebHistory(), + linkExactActiveClass: "current", + routes, +}); + +import App from "../../components/Admin/CirculationTriggers/Main.vue"; +import i18n from "../../i18n"; + +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("mainStore", mainStore); +app.provide("navigationStore", navigationStore); +app.provide("circRulesStore", circRulesStore); +app.mount("#__app"); + +router.beforeEach(to => { + navigationStore.$patch({ current: to.matched, params: to.params || {} }); + removeMessages(); // This will actually flag the messages as displayed already +}); diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/routes/admin/circulation_triggers.js b/koha-tmpl/intranet-tmpl/prog/js/vue/routes/admin/circulation_triggers.js new file mode 100644 index 0000000000..e3d5a9cc58 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/routes/admin/circulation_triggers.js @@ -0,0 +1,91 @@ +import { markRaw } from "vue"; +import { $__ } from "../../i18n"; + +import CirculationTriggersList from "../../components/Admin/CirculationTriggers/CirculationTriggersList.vue"; +import CirculationTriggersFormAdd from "../../components/Admin/CirculationTriggers/CirculationTriggersFormAdd.vue"; +import CirculationTriggersFormConfirmReset from "../../components/Admin/CirculationTriggers/CirculationTriggersFormConfirmReset.vue"; +import CirculationTriggersFormConfirmTriggerDelete from "../../components/Admin/CirculationTriggers/CirculationTriggersFormConfirmTriggerDelete.vue"; + +export default { + title: $__("Administration"), + path: "", + href: "/cgi-bin/koha/admin/admin-home.pl", + is_base: true, + is_default: true, + children: [ + { + title: $__("Circulation triggers"), + path: "/cgi-bin/koha/admin/circulation_triggers", + children: [ + { + path: "", + name: "CirculationTriggersList", + component: markRaw(CirculationTriggersList), + title: $__("Home"), + children: [ + { + path: "confirmContext", + name: "CirculationTriggersFormConfirmContext", + component: markRaw(CirculationTriggersFormAdd), + title: $__("Confirm trigger context"), + meta: { + showModal: true, + }, + }, + { + path: "selectOrAdd", + name: "CirculationTriggersSelectOrAdd", + component: markRaw(CirculationTriggersFormAdd), + title: $__( + "Select a trigger to edit or select add" + ), + meta: { + showModal: true, + }, + }, + { + path: "add", + name: "CirculationTriggersFormAdd", + component: markRaw(CirculationTriggersFormAdd), + title: $__("Add new rule set"), + meta: { + showModal: true, + }, + }, + { + path: "edit", + name: "CirculationTriggersFormEdit", + component: markRaw(CirculationTriggersFormAdd), + title: $__("Edit rule set"), + meta: { + showModal: true, + }, + }, + { + path: "reset", + name: "CirculationTriggersFormConfirmReset", + component: markRaw( + CirculationTriggersFormConfirmReset + ), + title: $__("Confirm rule set reset"), + meta: { + showModal: true, + }, + }, + { + path: "delete", + name: "CirculationTriggersFormConfirmTriggerDelete", + component: markRaw( + CirculationTriggersFormConfirmTriggerDelete + ), + title: $__("Confirm trigger deletion"), + meta: { + showModal: true, + }, + }, + ], + }, + ], + }, + ], +}; diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/stores/circulation-rules.js b/koha-tmpl/intranet-tmpl/prog/js/vue/stores/circulation-rules.js new file mode 100644 index 0000000000..351033c088 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/stores/circulation-rules.js @@ -0,0 +1,486 @@ +import { defineStore } from "pinia"; +import { $__ } from "../i18n"; +import { APIClient } from "../fetch/api-client.js"; +import { isEqual } from "lodash"; + +export const useCircRulesStore = defineStore("circRules", { + // NOTES ON RULE SETS TYPES + // exhaustive: includes 'pure fallback' rules sets for contexts that no rules match. + // Format: [{overdue_X_: {value: mixed: isFallback: bool}}] + // effective: includes sets only for contexts for which one or more rule exists in the db. These sets will include fallbacks. + // Format: [{overdue_X_: }} + // raw: includes only the exact sets as they are found in the db + // Format: [{overdue_X_: }}] + state: () => ({ + // context + currentLibraryId: "*", + currentPatronCategoryId: null, + currentItemTypeId: null, + triggerCounts: { "*": 0 }, + // references + itemTypes: [], + libraries: [], + patronCategories: [], + letters: [], + ruleSuffixes: ["delay", "notice", "mtt", "restrict", "has_rules"], + transportTypes: [ + { code: "email", name: "Email" }, + { code: "sms", name: "SMS" }, + { code: "print", name: "Print" }, + ], + regex: /overdue_(\d+)_has_rules/, + // rule sets + allDefaultLibraryRawRuleSets: [], // source of truth for default library + allCurrentLibraryRawRuleSets: [], // source of truth for current library + allEffectiveRuleSets: [], // main data set for display explicitly set rules for current library + allExhaustiveEffectiveRuleSets: [], // main data set for display all applied rules for current library + currentAndDefaultRawRuleSets: [], // data set to identify effective rules from (combines allDefaultLibraryRawRuleSets and allCurrentLibraryRawRuleSets) + storeInitialized: false, + }), + actions: { + // controllers + async init() { + await this.getItemTypes(); + await this.getLibraries(); + await this.getPatronCategories(); + }, + // utilities + formatTriggerSpecificRuleSetForDisplay( + context, + triggerNumber, + includeFallbacks = true + ) { + const triggerSpecificRuleSet = { + context, + }; + this.ruleSuffixes.forEach(ruleSuffix => { + triggerSpecificRuleSet[ + `overdue_${triggerNumber}_${ruleSuffix}` + ] = this.findEffectiveRule( + context, + ruleSuffix, + triggerNumber, + includeFallbacks + ); + }); + triggerSpecificRuleSet.context = context; + return triggerSpecificRuleSet; + }, + handleContext(value, data, type, displayProperty = "name") { + const item = data.find(item => item[type] === value); + return item[displayProperty]; + }, + handleNotice(notice) { + const letter = this.letters.find(letter => letter.code === notice); + return letter ? letter.name : notice; + }, + handleRestrictions(value) { + if (!value) { + return; + } + return value === "1" ? $__("Yes") : $__("No"); + }, + handleTransport(value, type, noLetter) { + if (!value || noLetter) { + return ""; + } + return value.includes(type) ? $__("Yes") : $__("No"); + }, + hasConflict(oldRuleSet, newRuleSet, triggerNumber) { + if (!oldRuleSet) { + return false; + } + return ( + oldRuleSet.context.library_id !== + newRuleSet.context.library_id || + oldRuleSet.context.item_type_id !== + newRuleSet.context.item_type_id || + oldRuleSet.context.patron_category_id !== + newRuleSet.context.patron_category_id || + oldRuleSet[`overdue_${triggerNumber}_delay`] !== + newRuleSet[`overdue_${triggerNumber}_delay`] || + oldRuleSet[`overdue_${triggerNumber}_notice`] !== + newRuleSet[`overdue_${triggerNumber}_notice`] || + oldRuleSet[`overdue_${triggerNumber}_restrict`] !== + newRuleSet[`overdue_${triggerNumber}_restrict`] || + !isEqual( + oldRuleSet[`overdue_${triggerNumber}_mtt`], + newRuleSet[`overdue_${triggerNumber}_mtt`] + ) + ); + }, + isOnlyRuleSetForTrigger(triggerNumber) { + return ( + this.allCurrentLibraryRawRuleSets.filter( + ruleSet => ruleSet[`overdue_${triggerNumber}_has_rules`] + ).length === 1 + ); + }, + isLastTrigger(triggerNumber) { + return ( + parseInt(triggerNumber) === + this.triggerCounts[this.currentLibraryId] + ); + }, + // services + findEffectiveRule( + context, + ruleSuffix, + triggerNumber, + includeFallbacks = true + ) { + if ( + !this.currentAndDefaultRawRuleSets || + !Array.isArray(this.currentAndDefaultRawRuleSets) || + this.currentAndDefaultRawRuleSets.length === 0 + ) { + return { value: null, isFallback: true }; + } + // Check if the current ruleSet's value for the ruleSuffix is undefined + const existingRule = this.currentAndDefaultRawRuleSets.find( + ruleSet => + ruleSet[`overdue_${triggerNumber}_${ruleSuffix}`] !== + undefined && + ruleSet[`overdue_${triggerNumber}_${ruleSuffix}`] !== + null && + ruleSet?.context.library_id === context.library_id && + ruleSet?.context.patron_category_id === + context.patron_category_id && + ruleSet?.context.item_type_id === context.item_type_id + ); + + // if handling 'has_rules', stop here + if (ruleSuffix === "has_rules") { + return { + value: existingRule?.[`overdue_${triggerNumber}_has_rules`], + isFallback: + !existingRule?.[`overdue_${triggerNumber}_has_rules`], + }; + } + + // If the current ruleSet's value is not null, use it directly + if (existingRule !== undefined) { + return { + value: existingRule[ + `overdue_${triggerNumber}_${ruleSuffix}` + ], + isFallback: + !existingRule[`overdue_${triggerNumber}_has_rules`], + }; + } + + // If set to return a raw set, return + if (!includeFallbacks) { + return; + } + + // Filter ruleSets to only those with non-null values for the specified ruleSuffix + // and that are no excluded from the selected context + const relevantRules = this.currentAndDefaultRawRuleSets.filter( + ruleSet => + ruleSet[`overdue_${triggerNumber}_${ruleSuffix}`] !== + undefined && + ruleSet[`overdue_${triggerNumber}_${ruleSuffix}`] !== + null && + (ruleSet.context.library_id === context.library_id || + ruleSet.context.library_id === "*") && + (ruleSet.context.patron_category_id === + context.patron_category_id || + ruleSet.context.patron_category_id === "*") && + (ruleSet.context.item_type_id === context.item_type_id || + ruleSet.context.item_type_id === "*") + ); + + // Function to calculate specificity score + const getSpecificityScore = ruleSetContext => { + let score = 0; + if ( + ruleSetContext.library_id !== "*" && + ruleSetContext.library_id === context.library_id + ) + score += 4; + if ( + ruleSetContext.patron_category_id !== "*" && + ruleSetContext.patron_category_id === + context.patron_category_id + ) + score += 2; + if ( + ruleSetContext.item_type_id !== "*" && + ruleSetContext.item_type_id === context.item_type_id + ) + score += 1; + return score; + }; + + // Sort the ruleSets based on specificity score, descending + const sortedRules = relevantRules.sort((a, b) => { + return ( + getSpecificityScore(b.context) - + getSpecificityScore(a.context) + ); + }); + // If no ruleSet found, return null + if (sortedRules.length === 0) { + return { value: null, isFallback: true }; + } + // Get the value from the most specific ruleSet + const bestRule = sortedRules[0]; + return { + value: bestRule[`overdue_${triggerNumber}_${ruleSuffix}`], + isFallback: true, + }; + }, + setAllExhaustiveEffectiveRuleSets() { + // clear array + this.allExhaustiveEffectiveRuleSets = []; + // generate complete rule set list + this.patronCategories.forEach(category => { + this.itemTypes.forEach(itemType => { + const effectiveRuleSet = { + context: { + library_id: this.currentLibraryId, + patron_category_id: category.patron_category_id, + item_type_id: itemType.item_type_id, + }, + }; + for ( + let i = 1; + i <= this.triggerCounts[this.currentLibraryId]; + i++ + ) { + this.ruleSuffixes.forEach(ruleSuffix => { + effectiveRuleSet[`overdue_${i}_${ruleSuffix}`] = + this.findEffectiveRule( + effectiveRuleSet.context, + ruleSuffix, + i + ); + }); + } + this.allExhaustiveEffectiveRuleSets.push(effectiveRuleSet); + }); + }); + }, + setAllEffectiveRuleSets() { + // clear array + this.allEffectiveRuleSets = []; + // generate complete rule set list + this.allCurrentLibraryRawRuleSets.forEach(ruleSet => { + const effectiveRuleSet = { + context: { ...ruleSet.context }, + }; + for ( + let i = 1; + i <= this.triggerCounts[this.currentLibraryId]; + i++ + ) { + if (ruleSet[`overdue_${i}_has_rules`] === null) { + continue; + } + this.ruleSuffixes.forEach(ruleSuffix => { + effectiveRuleSet[`overdue_${i}_${ruleSuffix}`] = + this.findEffectiveRule( + ruleSet.context, + ruleSuffix, + i + ); + }); + } + this.allEffectiveRuleSets.push(effectiveRuleSet); + }); + }, + setEffectiveTriggerFilteredRuleSet(context) { + const effectiveTriggerFilteredRuleSets = []; + for ( + let i = 1; + i <= this.triggerCounts[this.currentLibraryId]; + i++ + ) { + const triggerSpecificRuleSet = + this.formatTriggerSpecificRuleSetForDisplay(context, i); + if (!triggerSpecificRuleSet) { + continue; + } + effectiveTriggerFilteredRuleSets.push(triggerSpecificRuleSet); + } + return effectiveTriggerFilteredRuleSets; + }, + updateTriggerCount() { + // Library-specific triggerCounts can fall into the following use cases: + // - No rule sets specific to them exist. Therefore, their triggerCount is the same as default's. + // - Rule sets exists that override default triggers. Therefore, their triggerCount is the same as default's. + // - Rule sets exists for triggers for which there is no default. + // => such triggers are follow up addition to the existing default sequence. + // => the triggerCount for this library will be higher than default's, and equal to the highest trigger number for this library for which has_rules is not null. + + const ruleNames = Object.keys(this.allDefaultLibraryRawRuleSets[0]); + // Set the triggerCount for the default library rule set + if (this.currentLibraryId === "*") { + this.triggerCounts["*"] = ruleNames.filter( + ruleSuffix => + this.regex.test(ruleSuffix) && + this.allDefaultLibraryRawRuleSets.some( + ruleSet => ruleSet[ruleSuffix] !== null + ) + ).length; + return; + } + + // Set a library-specific trigger count: no rule set exists -> simply use the default trigger count + if (this.allCurrentLibraryRawRuleSets.length === 0) { + this.triggerCounts[this.currentLibraryId] = + this.triggerCounts["*"]; + return; + } + + // Set a library-specific trigger count: at least one rule set exists -> start from the first trigger for which there is no default rule set + let i = this.triggerCounts["*"] + 1; + while ( + ruleNames.includes(`overdue_${i}_has_rules`) && + this.allCurrentLibraryRawRuleSets.some( + ruleSet => ruleSet[`overdue_${i}_has_rules`] !== null + ) + ) { + i++; + } + this.triggerCounts[this.currentLibraryId] = i - 1; + }, + async setAllRawRuleSets() { + const client = APIClient.circRule; + try { + await this.getAllRawRuleSets(); + } catch (e) { + //TODO: handle e + } + this.currentAndDefaultRawRuleSets = [ + ...this.allCurrentLibraryRawRuleSets, + ...this.allDefaultLibraryRawRuleSets, + ]; + }, + // repositories + async deleteRuleSet(ruleSet, triggerNumber) { + const ruleSetInDb = await this.getSelectedRuleSet(ruleSet.context); + + if (this.hasConflict(ruleSet, ruleSetInDb, triggerNumber)) { + throw "The rule set for the selected trigger context could not be reset as it was updated elsewhere. Please see the updated trigger above."; + } + + const rulesForDeletion = { context: ruleSet.context }; + + if (ruleSet[`overdue_${triggerNumber}_delay`] !== null) { + rulesForDeletion[`overdue_${triggerNumber}_delay`] = null; + } + if (ruleSet[`overdue_${triggerNumber}_notice`] !== null) { + rulesForDeletion[`overdue_${triggerNumber}_notice`] = null; + } + if (ruleSet[`overdue_${triggerNumber}_restrict`] !== null) { + rulesForDeletion[`overdue_${triggerNumber}_restrict`] = null; + } + if (ruleSet[`overdue_${triggerNumber}_mtt`] !== null) { + rulesForDeletion[`overdue_${triggerNumber}_mtt`] = null; + } + rulesForDeletion[`overdue_${triggerNumber}_has_rules`] = null; + this.updateCircRuleSets(rulesForDeletion, triggerNumber); + }, + async getAllRawRuleSets() { + const client = APIClient.circRule; + try { + this.allDefaultLibraryRawRuleSets = + await client.circ_rules.getAll( + {}, + { library_id: "*", effective: false } + ); + this.allCurrentLibraryRawRuleSets = + await client.circ_rules.getAll( + {}, + { library_id: this.currentLibraryId, effective: false } + ); + } catch (e) { + throw e; + } + }, + async getItemTypes() { + const client = APIClient.item; + let itemTypes = []; + try { + itemTypes = await client.item_types.getAll(); + } catch (e) { + // TODO: handle e + } + itemTypes.unshift({ + item_type_id: "*", + description: $__("Default rule for all item types"), + }); + this.itemTypes = itemTypes; + }, + async getLibraries() { + const client = APIClient.library; + let libraries = []; + try { + libraries = await client.libraries.getAll(); + } catch (e) { + // TODO: handle e + } + libraries.unshift({ + library_id: "*", + name: $__("Default rule for all libraries"), + }); + this.libraries = libraries; + }, + async getPatronCategories() { + const client = APIClient.patron; + let patronCategories = []; + try { + patronCategories = await client.categories.getAll(); + } catch (e) { + // TODO: handle e + } + patronCategories.unshift({ + patron_category_id: "*", + name: $__("Default rule for all categories"), + }); + this.patronCategories = patronCategories; + }, + async getSelectedRuleSet(context, effective = false) { + if (context.library_id === null) { + context.library_id = "*"; + } + const client = APIClient.circRule; + let result; + try { + result = await client.circ_rules.getAll( + {}, + { + library_id: context.library_id, + patron_category_id: context.patron_category_id, + item_type_id: context.item_type_id, + effective, + } + ); + } catch (e) { + throw e; + } + return result[0] ?? null; + }, + async updateCircRuleSets(existingRuleSet, triggerNumber) { + const circRuleSet = { context: existingRuleSet.context }; + circRuleSet[`overdue_${triggerNumber}_delay`] = + existingRuleSet[`overdue_${triggerNumber}_delay`]; + circRuleSet[`overdue_${triggerNumber}_notice`] = + existingRuleSet[`overdue_${triggerNumber}_notice`]; + circRuleSet[`overdue_${triggerNumber}_restrict`] = + existingRuleSet[`overdue_${triggerNumber}_restrict`]; + circRuleSet[`overdue_${triggerNumber}_mtt`] = + existingRuleSet[`overdue_${triggerNumber}_mtt`]; + circRuleSet[`overdue_${triggerNumber}_has_rules`] = + existingRuleSet[`overdue_${triggerNumber}_has_rules`]; + try { + const client = APIClient.circRule; + await client.circ_rules.update(circRuleSet); + } catch (e) { + //TODO: handle e + } + }, + }, +}); diff --git a/rspack.config.js b/rspack.config.js index a765658b19..c8dd12f912 100644 --- a/rspack.config.js +++ b/rspack.config.js @@ -131,6 +131,10 @@ module.exports = [ "./koha-tmpl/intranet-tmpl/prog/js/vue/csp-nonce.js", "./koha-tmpl/intranet-tmpl/prog/js/vue/modules/islands-intranet.ts", ], + "admin/circulation_triggers": [ + "./koha-tmpl/intranet-tmpl/prog/js/vue/csp-nonce.js", + "./koha-tmpl/intranet-tmpl/prog/js/vue/modules/admin/circulation_triggers.ts", + ], sip2: [ "./koha-tmpl/intranet-tmpl/prog/js/vue/csp-nonce.js", "./koha-tmpl/intranet-tmpl/prog/js/vue/modules/sip2.ts",