Bugzilla – Attachment 188083 Details for
Bug 10190
Overdue notice triggers based on item type
Home
|
New
|
Browse
|
Search
|
[?]
|
Reports
|
Help
|
New Account
|
Log In
[x]
|
Forgot Password
Login:
[x]
[patch]
Bug 10190: Complete Vue.js rewrite of Circulation Triggers administration interface
Bug-10190-Complete-Vuejs-rewrite-of-Circulation-Tr.patch (text/plain), 153.22 KB, created by
Martin Renvoize (ashimema)
on 2025-10-17 13:58:32 UTC
(
hide
)
Description:
Bug 10190: Complete Vue.js rewrite of Circulation Triggers administration interface
Filename:
MIME Type:
Creator:
Martin Renvoize (ashimema)
Created:
2025-10-17 13:58:32 UTC
Size:
153.22 KB
patch
obsolete
>From ad5dd208f24138b84a22bd5c7d28c48e06bf6d5a Mon Sep 17 00:00:00 2001 >From: =?UTF-8?q?Chlo=C3=A9=20Zermatten?= <chloe.zermatten@openfifth.co.uk> >Date: Tue, 13 Aug 2024 14:15:44 +0000 >Subject: [PATCH] Bug 10190: Complete Vue.js rewrite of Circulation Triggers > administration interface > >This patch introduces a modern Vue.js-based interface for managing circulation triggers (overdue notice/status triggers), replacing the legacy Tools-based interface with a more intuitive, feature-rich administration page. > >New Features > >Enhanced Visibility & Navigation >- Exhaustive effective rule lists showing all rules that apply to each context >- Toggle between "all exhaustive rules" and "explicitly set rules" views >- Advanced filtering by patron category and/or item type >- Clear display of fallback/default rules for each context > >Rule Management Actions >- Delete triggers (last trigger in a sequence for any given library) >- Reset entire rule sets for a given context (with safeguards) >- Edit existing triggers with improved context awareness >- Add new triggers with intelligent delay constraints > >Improved User Experience >- Multi-step form wizard with clear workflow progression >- Context-aware letter template filtering (only shows applicable templates) >- Placeholder values showing fallback rules when clearing inputs >- Dynamic min/max constraints for delay values based on neighboring triggers >- Real-time form validation and feedback >- Partial page reloads (only reload affected sections, not entire page) >- Specific loading messages for different operations > >Technical Improvements >- Centralized Vuex store for state management >- Reusable CirculationTriggersForm component >- API clients for libraries, circulation rules, item types, and patron categories >- Consistent async/await pattern throughout >- Separation of repositories, utilities, and services >- Improved naming consistency and code maintainability >- "Dumb" TriggersTable component for better separation of concerns > >Test Plan > >Prerequisites >1. Log in to staff interface with appropriate permissions (tools_edit_notice_status_triggers) >2. Ensure you have test data: multiple libraries, patron categories, item types, and notice templates >3. Navigate to Administration > Circulation triggers (new interface) > >Part 1: Basic Navigation & Interface >1. Verify the new "Circulation triggers" link appears in Administration home under the Circulation section >2. Access the new interface and confirm it loads successfully >3. Verify the default view shows either "All libraries" or your logged-in library based on DefaultToLoggedInLibraryOverdueTriggers system preference >4. Confirm the interface displays a list of triggers organized by context (library/patron category/item type combinations) > >Part 2: Viewing and Filtering Rules >1. Toggle between "All exhaustive rules" and "Explicitly set rules" views > - Verify "All exhaustive rules" shows inherited/fallback rules > - Verify "Explicitly set rules" shows only rules you've specifically configured >2. Test the patron category filter: > - Select a specific patron category > - Confirm only rules for that category are displayed >3. Test the item type filter: > - Select a specific item type > - Confirm only rules for that item type are displayed >4. Test combined filters (patron category + item type simultaneously) >5. Clear filters and verify all rules display again > >Part 3: Adding New Triggers >1. Click "Add trigger" button >2. Step 1 - Confirm Context: > - Select Library (required) > - Select Patron category (required) > - Select Item type (required) > - Click "Confirm context" >3. Step 2 - Review Existing Triggers: > - Verify existing triggers for this context are displayed > - Note the trigger numbers and delays >4. Step 3 - Configure New Trigger: > - Enter a delay value (should be constrained by neighboring triggers) > - Try entering an invalid delay (too low or too high) - verify validation > - Set "Restricts checkouts" (Yes/No/Fallback to default) > - Note the placeholder showing the fallback value > - Select a letter template > - Verify only templates for the selected library context appear > - Set "Debarring" options if desired > - Select "Charged notice fees" if applicable >5. Submit the form >6. Verify the new trigger appears in the list without a full page reload >7. Verify success message is displayed > >Part 4: Editing Existing Triggers >1. Locate an existing trigger in the list >2. Click the edit icon/button >3. Verify the form pre-populates with existing values >4. Test delay modification: > - Try changing the delay within valid bounds - should succeed > - Try changing delay beyond neighboring triggers - should show validation error > - Use the increment/decrement buttons to adjust delay >5. Test clearing values: > - Clear a field (e.g., delay or restrict checkouts) > - Verify placeholder shows the fallback/default value > - Submit with cleared value - should save as null and inherit fallback >6. Change the letter template to a different valid template >7. Submit changes >8. Verify the trigger updates in the list without full page reload >9. Verify the changes persist after refreshing the page > >Part 5: Deleting Triggers >1. Identify a trigger that is the last in its sequence >2. Click the delete button/icon >3. Verify a confirmation dialog appears >4. Confirm deletion >5. Verify the trigger is removed from the list without page reload >6. Verify the deletion persists after page refresh >7. Test delete constraints: > - Try to delete a trigger that's not the last in sequence > - Verify appropriate error/warning message > >Part 6: Resetting Rule Sets >1. Locate a context with explicitly set rules (not just inherited) >2. Ensure this is not the only rule set for this trigger sequence >3. Click the "Reset" button for that context >4. Verify confirmation dialog explains what will be reset >5. Confirm the reset >6. Verify the explicit rules are removed and context now shows inherited rules >7. Verify changes persist after page refresh >8. Test reset constraints: > - Try to reset when it would leave no rules for that trigger > - Verify appropriate warning/prevention > >Part 7: Context-Specific Behavior >1. Test library-specific letter filtering: > - Create/edit a trigger for a specific library > - Verify only letter templates with matching branchcode or default templates appear > - Switch to a different library context > - Verify the letter list updates accordingly >2. Test default rules: > - View rules for "All libraries" / "All patron categories" / "All item types" > - Verify these are labeled as "Default rule for [context]" > - Confirm these serve as fallback for more specific rules >3. Test DefaultToLoggedInLibraryOverdueTriggers preference: > - Set preference to "Yes" > - Reload circulation triggers page > - Verify it defaults to your logged-in library > - Set preference to "No" > - Verify it defaults to "All libraries" > >Part 8: Form Workflow & Validation >1. Test required fields: > - Try submitting add/edit form without selecting library - should fail > - Try submitting without patron category - should fail > - Try submitting without item type - should fail >2. Test stepper navigation: > - Start adding a trigger > - Verify you must confirm context before proceeding > - Verify you see existing triggers before editing/adding > - Verify trigger form only appears after context confirmation >3. Test cancellation: > - Start adding/editing a trigger > - Click Cancel > - Verify you return to the list without changes > - Verify no partial data was saved > >Part 9: Loading States & Messages >1. On slow connections or with many rules: > - Verify "Loading context..." appears while fetching context data > - Verify "Loading rule set information..." appears while fetching rules > - Verify section-specific loading messages appear during updates >2. After form submission: > - Verify appropriate success/error messages display > - Verify loading indicators during async operations > >Part 10: Permissions >1. Log in as a user without tools_edit_notice_status_triggers permission >2. Verify the "Circulation triggers" link does not appear in Administration >3. Try accessing /cgi-bin/koha/admin/circulation_triggers directly >4. Verify access is denied > >Part 11: Data Integrity & Persistence >1. Create a complex set of rules with multiple triggers across different contexts >2. Refresh the page and verify all rules persist >3. Restart the Koha services >4. Verify all rules still display correctly >5. Check the database circulation_rules table: > - Verify rules are stored with correct rule_name pattern (overdue_1_delay, etc.) > - Verify null values are properly stored for inheritance > - Verify the overdue_X_has_rules rule kind is present > >Part 12: Edge Cases >1. Test with no existing triggers - verify empty state message >2. Test with maximum trigger sequences (e.g., trigger 1, 2, 3) >3. Test rapid successive edits >4. Test with special characters in letter template names >5. Test browser back/forward buttons during form workflow > >Part 13: Comparison with Legacy Interface >1. Access the old interface if still available (via Tools menu) >2. Create a trigger using the new interface >3. Verify it appears correctly in the old interface (if applicable) >4. Create a trigger using the old interface (if still available) >5. Verify it appears correctly in the new interface > >--- >Notes for QA > >- This is a complete UI rewrite; the underlying API remains unchanged >- The interface is mobile-responsive and should be tested on various screen sizes >- Vue.js components are compiled via Rspack; run yarn build after pulling >- All existing circulation trigger functionality should continue to work >- The URL rewrite rule ensures /admin/circulation_triggers routes correctly > >Sponsored-by: Glasgow Colleges Library Group <https://library.cityofglasgowcollege.ac.uk> >Co-authored-by: Matt Blenkinsop <matt.blenkinsop@openfifth.co.uk> >Co-authored-by: Martin Renvoize <martin.renvoize@openfifth.co.uk> >Signed-off-by: George Harkins <George.Harkins@cityofglasgowcollege.ac.uk> >--- > Koha/CirculationRules.pm | 4 + > admin/circulation_triggers.pl | 43 + > debian/templates/apache-shared-intranet.conf | 1 + > .../prog/en/modules/admin/admin-home.tt | 4 + > .../en/modules/admin/circulation_triggers.tt | 43 + > .../js/fetch/circulation-rules-api-client.js | 36 + > .../prog/js/fetch/item-api-client.js | 11 + > .../prog/js/fetch/library-api-client.js | 25 + > .../prog/js/fetch/patron-api-client.js | 16 +- > .../CirculationTriggersForm.vue | 44 + > .../CirculationTriggersFormAdd.vue | 1008 +++++++++++++++++ > .../CirculationTriggersFormConfirmReset.vue | 487 ++++++++ > ...lationTriggersFormConfirmTriggerDelete.vue | 257 +++++ > .../CirculationTriggersList.vue | 503 ++++++++ > .../Admin/CirculationTriggers/Main.vue | 69 ++ > .../CirculationTriggers/TriggerContext.vue | 50 + > .../CirculationTriggers/TriggersTable.vue | 357 ++++++ > .../prog/js/vue/fetch/api-client.js | 4 + > .../vue/modules/admin/circulation_triggers.ts | 57 + > .../vue/routes/admin/circulation_triggers.js | 91 ++ > .../prog/js/vue/stores/circulation-rules.js | 486 ++++++++ > rspack.config.js | 4 +- > 22 files changed, 3597 insertions(+), 3 deletions(-) > create mode 100644 admin/circulation_triggers.pl > create mode 100644 koha-tmpl/intranet-tmpl/prog/en/modules/admin/circulation_triggers.tt > create mode 100644 koha-tmpl/intranet-tmpl/prog/js/fetch/circulation-rules-api-client.js > create mode 100644 koha-tmpl/intranet-tmpl/prog/js/fetch/library-api-client.js > create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/Admin/CirculationTriggers/CirculationTriggersForm.vue > create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/Admin/CirculationTriggers/CirculationTriggersFormAdd.vue > create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/Admin/CirculationTriggers/CirculationTriggersFormConfirmReset.vue > create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/Admin/CirculationTriggers/CirculationTriggersFormConfirmTriggerDelete.vue > create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/Admin/CirculationTriggers/CirculationTriggersList.vue > create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/Admin/CirculationTriggers/Main.vue > create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/Admin/CirculationTriggers/TriggerContext.vue > create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/Admin/CirculationTriggers/TriggersTable.vue > create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/modules/admin/circulation_triggers.ts > create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/routes/admin/circulation_triggers.js > create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/stores/circulation-rules.js > >diff --git a/Koha/CirculationRules.pm b/Koha/CirculationRules.pm >index 86863a0f019..6fdd1501339 100644 >--- a/Koha/CirculationRules.pm >+++ b/Koha/CirculationRules.pm >@@ -190,6 +190,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 00000000000..6a09f5b86c2 >--- /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 <http://www.gnu.org/licenses>. >+ >+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 ad001458325..86fc33bc983 100644 >--- a/debian/templates/apache-shared-intranet.conf >+++ b/debian/templates/apache-shared-intranet.conf >@@ -27,6 +27,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" > <Directory "/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 bf92a4c1f8c..08a70eb5262 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 @@ > <dt><a href="/cgi-bin/koha/admin/smart-rules.pl">Circulation and fine rules</a></dt> > <dd>Define circulation and fine rules for combinations of libraries, patron categories, and item types</dd> > [% END %] >+ [% IF ( CAN_user_tools_edit_notice_status_triggers ) %] >+ <dt><a href="/cgi-bin/koha/admin/circulation_triggers">Circulation triggers</a></dt> >+ <dd>Define triggers for circulation notices</dd> >+ [% END %] > [% IF ( CAN_user_parameters_manage_patron_attributes ) %] > <dt><a href="/cgi-bin/koha/admin/patron-attr-types.pl">Patron attribute types</a></dt> > <dd>Define extended attributes (identifiers and statistical categories) for patron records</dd> >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 00000000000..d2f11c99cc4 >--- /dev/null >+++ b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/circulation_triggers.tt >@@ -0,0 +1,43 @@ >+[% 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' %] >+<title >+ >[% FILTER collapse %] >+ [% t("Circulation triggers") | html %] >+ › [% t("Administration") | html %] › [% t("Koha") | html %] >+ [% END %]</title >+> >+[% INCLUDE 'doc-head-close.inc' %] >+</head> >+ >+<body id="circulation_triggers" class="circulation_triggers"> >+[% WRAPPER 'header.inc' %] >+ [% INCLUDE 'prefs-admin-search.inc' %] >+[% END %] >+ >+<div id="__app"> >+ <!-- this is closed in intranet-bottom.inc --> >+ >+ [% MACRO jsinclude BLOCK %] >+ [% INCLUDE 'calendar.inc' %] >+ <!-- FIXME: this shouldn't be needed --> >+ [% INCLUDE 'datatables.inc' %] >+ [% INCLUDE 'js-patron-format.inc' %] >+ [% INCLUDE 'js-date-format.inc' %] >+ <script> >+ const letters = [% To.json(letters) | $raw %]; >+ const default_view = "[%- IF Koha.Preference('DefaultToLoggedInLibraryOverdueTriggers') -%][% Branches.GetLoggedInBranchcode %][%- ELSE -%]*[%- END -%]"; >+ const from_branch = "[% Koha.Preference('OverdueNoticeFrom') %]"; >+ </script> >+ [% Asset.js("js/vue/dist/admin/circulation_triggers.js") | $raw %] >+ [% END %] >+ >+ [% INCLUDE 'intranet-bottom.inc' %]</div >+> >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 00000000000..ccb1c2c1623 >--- /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 circRules() { >+ 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/item-api-client.js b/koha-tmpl/intranet-tmpl/prog/js/fetch/item-api-client.js >index 84b9b8c3704..cd9af06923f 100644 >--- a/koha-tmpl/intranet-tmpl/prog/js/fetch/item-api-client.js >+++ b/koha-tmpl/intranet-tmpl/prog/js/fetch/item-api-client.js >@@ -16,6 +16,17 @@ export class ItemAPIClient { > }), > }; > } >+ get itemTypes() { >+ return { >+ getAll: (query, params, headers) => >+ this.httpClient.getAll({ >+ endpoint: "item_types", >+ query, >+ params, >+ headers, >+ }), >+ }; >+ } > } > > export default ItemAPIClient; >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 00000000000..01d34db3343 >--- /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/fetch/patron-api-client.js b/koha-tmpl/intranet-tmpl/prog/js/fetch/patron-api-client.js >index 2ec8b8c2600..77b0a38e356 100644 >--- a/koha-tmpl/intranet-tmpl/prog/js/fetch/patron-api-client.js >+++ b/koha-tmpl/intranet-tmpl/prog/js/fetch/patron-api-client.js >@@ -1,7 +1,7 @@ > export class PatronAPIClient { > constructor(HttpClient) { > this.httpClient = new HttpClient({ >- baseURL: "/api/v1/patrons/", >+ baseURL: "/api/v1/", > }); > } > >@@ -9,7 +9,19 @@ export class PatronAPIClient { > return { > get: id => > this.httpClient.get({ >- endpoint: id, >+ endpoint: "patrons/" + id, >+ }), >+ }; >+ } >+ >+ get patronCategories() { >+ return { >+ getAll: (query, params, headers) => >+ this.httpClient.getAll({ >+ endpoint: "patron_categories", >+ query, >+ params, >+ headers, > }), > }; > } >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 00000000000..25c0ba02986 >--- /dev/null >+++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Admin/CirculationTriggers/CirculationTriggersForm.vue >@@ -0,0 +1,44 @@ >+<template> >+ <form >+ id="circulation-trigger-form" >+ @submit="submitAction($event)" >+ class="modal-content" >+ > >+ <div class="modal-header"> >+ <h1 class="modal-title"> >+ {{ $__(formTitle) }} >+ </h1> >+ <router-link >+ class="btn-close" >+ type="button" >+ :to="{ >+ name: 'CirculationTriggersList', >+ }" >+ ></router-link> >+ </div> >+ <div class="modal-body"> >+ <slot></slot> >+ </div> >+ <div class="modal-footer"> >+ <ButtonSubmit text="Confirm" /> >+ <router-link >+ :to="{ >+ name: 'CirculationTriggersList', >+ }" >+ >{{ $__("Cancel") }} >+ </router-link> >+ </div> >+ </form> >+</template> >+<script> >+import ButtonSubmit from "../../ButtonSubmit.vue"; >+export default { >+ props: ["submitAction", "formTitle"], >+ components: { ButtonSubmit }, >+}; >+</script> >+<style scoped> >+#circulation-trigger-form { >+ max-height: 90vh; >+} >+</style> >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 00000000000..136fd55a0ee >--- /dev/null >+++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Admin/CirculationTriggers/CirculationTriggersFormAdd.vue >@@ -0,0 +1,1008 @@ >+<template> >+ <form >+ class="modal-content" >+ id="circulation-trigger-form-add" >+ @submit="addCircRule($event)" >+ > >+ <div class="modal-header"> >+ <h1 class="modal-title"> >+ {{ $__("Circulation Trigger Configuration") }} >+ </h1> >+ <router-link >+ class="btn-close" >+ type="button" >+ :to="{ >+ name: 'CirculationTriggersList', >+ }" >+ ></router-link> >+ </div> >+ <div class="modal-body"> >+ <div class="page-section bg-info" v-if="ruleSetInitialized"> >+ <h2>{{ $__("Circulation context") }}</h2> >+ <TriggerContext :ruleSetInfo="ruleSetInfo" /> >+ </div> >+ <div v-else-if="editMode !== 'confirmContext'"> >+ <p>{{ $__("Loading rule set information...") }}</p> >+ </div> >+ <fieldset class="rows" v-if="contextInitialized"> >+ <legend>{{ $__("Confirm trigger context") }}</legend> >+ <ol> >+ <li> >+ <label for="library_id" class="required" >+ >{{ $__("Library") }}:</label >+ > >+ <v-select >+ id="library_id" >+ v-model="context.library_id" >+ label="name" >+ :reduce="lib => lib.library_id" >+ :options="libraries" >+ :disabled="editMode !== 'confirmContext'" >+ > >+ <template #search="{ attributes, events }"> >+ <input >+ :required="!context.library_id" >+ class="vs__search" >+ v-bind="attributes" >+ v-on="events" >+ /> >+ </template> >+ </v-select> >+ <span class="required">{{ $__("Required") }}</span> >+ </li> >+ <li> >+ <label for="patron_category_id" class="required" >+ >{{ $__("Patron category") }}:</label >+ > >+ <v-select >+ id="patron_category_id" >+ v-model="context.patron_category_id" >+ label="name" >+ :reduce="cat => cat.patron_category_id" >+ :options="patronCategories" >+ :disabled="editMode !== 'confirmContext'" >+ > >+ <template #search="{ attributes, events }"> >+ <input >+ :required="!context.patron_category_id" >+ class="vs__search" >+ v-bind="attributes" >+ v-on="events" >+ /> >+ </template> >+ </v-select> >+ <span class="required">{{ $__("Required") }}</span> >+ </li> >+ <li> >+ <label for="item_type_id" class="required" >+ >{{ $__("Item type") }}:</label >+ > >+ <v-select >+ id="item_type_id" >+ v-model="context.item_type_id" >+ label="description" >+ :reduce="type => type.item_type_id" >+ :options="itemTypes" >+ :disabled="editMode !== 'confirmContext'" >+ > >+ <template #search="{ attributes, events }"> >+ <input >+ :required="!context.item_type_id" >+ class="vs__search" >+ v-bind="attributes" >+ v-on="events" >+ /> >+ </template> >+ </v-select> >+ <span class="required">{{ $__("Required") }}</span> >+ </li> >+ </ol> >+ <div v-if="editMode === 'confirmContext'"> >+ <router-link >+ :to="{ >+ name: 'CirculationTriggersSelectOrAdd', >+ query: context, >+ }" >+ class="btn btn-default btn-xs" >+ ><i class="fa-solid fa-pencil"></i> >+ {{ $__("Confirm context") }}</router-link >+ > >+ </div> >+ <div >+ class="page-section" >+ v-if="ruleSetInitialized && editMode !== 'confirmContext'" >+ > >+ <TriggersTable >+ :triggerNumber="triggerNumber" >+ :modal="true" >+ :actions="true" >+ :ruleSets="effectiveTriggerFilteredRuleSets" >+ :ruleSetBeingEdited="currentRuleSet" >+ :triggerBeingEdited="triggerBeingEdited" >+ /> >+ </div> >+ </fieldset> >+ <div v-else> >+ <p>{{ $__("Loading context...") }}</p> >+ </div> >+ <fieldset class="rows" v-if="alertMessage"> >+ <div class="alert alert-info">{{ alertMessage }}</div> >+ </fieldset> >+ <fieldset >+ class="rows" >+ v-if=" >+ (ruleSetInitialized && editMode === 'edit') || >+ editMode === 'add' >+ " >+ > >+ <legend v-if="editMode === 'add'"> >+ {{ $__("Add new trigger") }} >+ {{ " " + triggerNumber }} >+ </legend> >+ <legend v-else> >+ {{ $__("Edit trigger") }} {{ " " + triggerNumber }} >+ </legend> >+ <div class="page-section bg-info"> >+ <p> >+ {{ >+ $__( >+ "NOTE: Delay for a given trigger can be pushed forward or backwards only within the bounds of what its two neighbouring triggers allows." >+ ) >+ }} >+ </p> >+ </div> >+ <ol> >+ <li> >+ <label for="overdue_delay">{{ $__("Delay") }}: </label> >+ <div class="numeric-input-wrapper"> >+ <div class="input-with-clear"> >+ <input >+ @input="setAllowSubmission" >+ id="overdue_delay" >+ v-model=" >+ ruleSetToSubmit[ >+ `overdue_${triggerNumber}_delay` >+ ] >+ " >+ type="number" >+ :placeholder=" >+ fallbackRuleSet?.[ >+ `overdue_${triggerNumber}_delay` >+ ] >+ " >+ :min="minDelay" >+ :max="maxDelay" >+ class="numeric-input" >+ /> >+ <button >+ v-if=" >+ ruleSetToSubmit[ >+ `overdue_${triggerNumber}_delay` >+ ] !== null && >+ ruleSetToSubmit[ >+ `overdue_${triggerNumber}_delay` >+ ] !== undefined >+ " >+ type="button" >+ class="clear-btn" >+ @click="handleSetDelayToNull" >+ > >+ <svg >+ xmlns="http://www.w3.org/2000/svg" >+ width="10" >+ height="10" >+ > >+ <path >+ d="M6.895455 5l2.842897-2.842898c.348864-.348863.348864-.914488 0-1.263636L9.106534.261648c-.348864-.348864-.914489-.348864-1.263636 0L5 3.104545 2.157102.261648c-.348863-.348864-.914488-.348864-1.263636 0L.261648.893466c-.348864.348864-.348864.914489 0 1.263636L3.104545 5 .261648 7.842898c-.348864.348863-.348864.914488 0 1.263636l.631818.631818c.348864.348864.914773.348864 1.263636 0L5 6.895455l2.842898 2.842897c.348863.348864.914772.348864 1.263636 0l.631818-.631818c.348864-.348864.348864-.914489 0-1.263636L6.895455 5z" >+ ></path> >+ </svg> >+ </button> >+ <div class="chevron-buttons"> >+ <button >+ type="button" >+ class="increment-btn" >+ @click="incrementDelay" >+ > >+ â´ >+ </button> >+ <button >+ type="button" >+ class="decrement-btn" >+ @click="decrementDelay" >+ > >+ â¾ >+ </button> >+ </div> >+ </div> >+ </div> >+ </li> >+ <li> >+ <label for="restricts" >+ >{{ $__("Restricts checkouts") }}:</label >+ > >+ <div> >+ <input >+ @click="setAllowSubmission" >+ type="radio" >+ id="restricts-yes" >+ v-model=" >+ ruleSetToSubmit[ >+ `overdue_${triggerNumber}_restrict` >+ ] >+ " >+ :value="1" >+ /> >+ {{ $__("Yes") }} >+ <input >+ @click="setAllowSubmission" >+ type="radio" >+ id="restricts-no" >+ v-model=" >+ ruleSetToSubmit[ >+ `overdue_${triggerNumber}_restrict` >+ ] >+ " >+ :value="0" >+ /> >+ {{ $__("No") }} >+ <input >+ @click="setAllowSubmission" >+ type="radio" >+ id="restricts-fallback" >+ v-model=" >+ ruleSetToSubmit[ >+ `overdue_${triggerNumber}_restrict` >+ ] >+ " >+ :value="null" >+ /> >+ {{ $__("Fallback to default") }} >+ <span >+ v-if=" >+ fallbackRuleSet?.[ >+ `overdue_${triggerNumber}_restrict` >+ ] !== null >+ " >+ > >+ ({{ >+ fallbackRuleSet?.[ >+ `overdue_${triggerNumber}_restrict` >+ ] === "1" >+ ? $__("Yes") >+ : $__("No") >+ }}) >+ </span> >+ </div> >+ </li> >+ </ol> >+ </fieldset> >+ <div v-else-if="editMode === 'add' || editMode === 'edit'"> >+ <p>{{ $__("Loading circulation rules...") }}</p> >+ </div> >+ <fieldset >+ class="rows" >+ v-if=" >+ (ruleSetInitialized && editMode === 'edit') || >+ editMode === 'add' >+ " >+ > >+ <legend v-if="ruleSetInfo.triggerCount < triggerNumber"> >+ {{ $__("Notice for trigger") }} >+ {{ " " + triggerNumber }} >+ </legend> >+ <legend v-else> >+ {{ $__("Edit notice for trigger") }} >+ {{ " " + triggerNumber }} >+ </legend> >+ <ol> >+ <li> >+ <label for="letter_code">{{ $__("Letter") }}:</label> >+ <v-select >+ id="letter_code" >+ v-model=" >+ ruleSetToSubmit[ >+ `overdue_${triggerNumber}_notice` >+ ] >+ " >+ label="name" >+ :reduce="type => type.code" >+ :options="filteredLetters" >+ @update:modelValue="setAllowSubmission" >+ > >+ <template #search="{ attributes, events }"> >+ <input >+ class="vs__search" >+ v-bind="attributes" >+ v-on="events" >+ :placeholder=" >+ ruleSetToSubmit[ >+ `overdue_${triggerNumber}_notice` >+ ] === null || >+ ruleSetToSubmit[ >+ `overdue_${triggerNumber}_notice` >+ ] === undefined >+ ? letters.find( >+ letter => >+ letter.code === >+ fallbackRuleSet?.[ >+ `overdue_${triggerNumber}_notice` >+ ] >+ )?.name || >+ fallbackRuleSet?.[ >+ `overdue_${triggerNumber}_notice` >+ ] >+ : '' >+ " >+ /> >+ </template> >+ </v-select> >+ </li> >+ <li >+ v-if=" >+ ruleSetToSubmit[ >+ `overdue_${triggerNumber}_notice` >+ ] !== '' || >+ ((ruleSetToSubmit[ >+ `overdue_${triggerNumber}_notice` >+ ] === null || >+ ruleSetToSubmit[ >+ `overdue_${triggerNumber}_notice` >+ ] === undefined) && >+ fallbackRuleSet?.[ >+ `overdue_${triggerNumber}_notice` >+ ] !== '') >+ " >+ > >+ <label for="mtt">{{ $__("Transport type(s)") }}:</label> >+ <v-select >+ id="mtt" >+ v-model=" >+ ruleSetToSubmit[`overdue_${triggerNumber}_mtt`] >+ " >+ label="name" >+ :reduce="type => type.code" >+ :options="transportTypes" >+ multiple >+ :required="true" >+ @update:modelValue="setAllowSubmission" >+ > >+ <template #search="{ attributes, events }"> >+ <input >+ class="vs__search" >+ v-bind="attributes" >+ v-on="events" >+ :placeholder=" >+ ruleSetToSubmit[ >+ `overdue_${triggerNumber}_mtt` >+ ] === null || >+ ruleSetToSubmit[ >+ `overdue_${triggerNumber}_mtt` >+ ] === undefined || >+ ruleSetToSubmit[ >+ `overdue_${triggerNumber}_mtt` >+ ].length === 0 >+ ? fallbackRuleSet?.[ >+ `overdue_${triggerNumber}_mtt` >+ ] >+ : '' >+ " >+ /> >+ </template> >+ </v-select> >+ </li> >+ </ol> >+ </fieldset> >+ <div v-else-if="editMode === 'add' || editMode === 'edit'"> >+ <p>{{ $__("Loading circulation rules...") }}</p> >+ </div> >+ </div> >+ <div class="modal-footer"> >+ <ButtonSubmit >+ v-if="editMode === 'edit' || editMode === 'add'" >+ :disabled="!allowSubmission" >+ /> >+ <router-link >+ :to="{ >+ name: 'CirculationTriggersList', >+ }" >+ >{{ $__("Cancel") }}</router-link >+ > >+ </div> >+ </form> >+</template> >+ >+<script> >+import TriggersTable from "./TriggersTable.vue"; >+import { inject } from "vue"; >+import { storeToRefs } from "pinia"; >+import ButtonSubmit from "../../ButtonSubmit.vue"; >+import TriggerContext from "./TriggerContext.vue"; >+import { isEqual, cloneDeep } from "lodash"; >+ >+export default { >+ setup() { >+ const circRulesStore = inject("circRulesStore"); >+ const { >+ updateTriggerCount, >+ findEffectiveRule, >+ getSelectedRuleSet, >+ setEffectiveTriggerFilteredRuleSet, >+ updateCircRuleSets, >+ hasConflict, >+ } = circRulesStore; >+ const { >+ letters, >+ libraries, >+ itemTypes, >+ transportTypes, >+ patronCategories, >+ triggerCounts, >+ storeInitialized, >+ } = storeToRefs(circRulesStore); >+ >+ return { >+ letters, >+ itemTypes, >+ libraries, >+ transportTypes, >+ triggerCounts, >+ patronCategories, >+ getSelectedRuleSet, >+ updateTriggerCount, >+ findEffectiveRule, >+ setEffectiveTriggerFilteredRuleSet, >+ updateCircRuleSets, >+ hasConflict, >+ storeInitialized, >+ }; >+ }, >+ data() { >+ return { >+ triggerNumber: 1, >+ context: { >+ library_id: "*", >+ item_type_id: "*", >+ patron_category_id: "*", >+ }, >+ fallbackRuleSet: null, >+ ruleSetInfo: { >+ issuelength: null, >+ decreaseloanholds: null, >+ fine: null, >+ chargeperiod: null, >+ lengthunit: null, >+ triggerCount: null, >+ }, >+ editMode: false, >+ ruleSetToSubmit: null, >+ currentRuleSet: null, >+ triggerBeingEdited: null, >+ minDelay: 0, >+ maxDelay: Infinity, >+ filteredLetters: [], >+ alertMessage: null, >+ allowSubmission: false, >+ effectiveTriggerFilteredRuleSets: [], >+ contextInitialized: false, >+ ruleSetInitialized: false, >+ }; >+ }, >+ beforeMount() { >+ // handle hard refresh mid-stepper workflow by ensuring store is initialized >+ if (!this.storeInitialized) { >+ this.$watch("storeInitialized", newVal => { >+ if (newVal) { >+ this.initializeComponent(); >+ } >+ }); >+ return; >+ } >+ this.initializeComponent(); >+ }, >+ beforeRouteEnter(to, from, next) { >+ if (!from.name) { >+ next(); >+ return; >+ } >+ next(vm => vm.initializeComponent()); >+ }, >+ methods: { >+ initializeComponent() { >+ const { query } = this.$route; >+ this.setEditMode(); >+ this.setContext(query); >+ this.contextInitialized = true; >+ this.setTriggerNumber(query.triggerNumber, this.context.library_id); >+ if ( >+ ["selectOrAdd", "add", "edit"].some(str => >+ this.$route.fullPath.includes(str) >+ ) >+ ) { >+ this.setRuleSets(); >+ } >+ }, >+ async addCircRule(e) { >+ e.preventDefault(); >+ >+ const ruleSetToSubmit = { >+ context: this.context, >+ }; >+ >+ // ensure that no property is set to null as this would delete the rule! >+ if ( >+ this.ruleSetToSubmit[`overdue_${this.triggerNumber}_delay`] !== >+ null && >+ this.ruleSetToSubmit[`overdue_${this.triggerNumber}_delay`] !== >+ this.fallbackRuleSet[`overdue_${this.triggerNumber}_delay`] >+ ) { >+ ruleSetToSubmit[`overdue_${this.triggerNumber}_delay`] = >+ cloneDeep( >+ this.ruleSetToSubmit[ >+ `overdue_${this.triggerNumber}_delay` >+ ] >+ ); >+ } >+ >+ if ( >+ this.ruleSetToSubmit[`overdue_${this.triggerNumber}_notice`] !== >+ null && >+ this.ruleSetToSubmit[`overdue_${this.triggerNumber}_notice`] !== >+ this.fallbackRuleSet[`overdue_${this.triggerNumber}_notice`] >+ ) { >+ ruleSetToSubmit[`overdue_${this.triggerNumber}_notice`] = >+ cloneDeep( >+ this.ruleSetToSubmit[ >+ `overdue_${this.triggerNumber}_notice` >+ ] >+ ); >+ } >+ >+ if ( >+ this.ruleSetToSubmit[ >+ `overdue_${this.triggerNumber}_restrict` >+ ] !== null && >+ this.ruleSetToSubmit[ >+ `overdue_${this.triggerNumber}_restrict` >+ ] !== >+ this.fallbackRuleSet[ >+ `overdue_${this.triggerNumber}_restrict` >+ ] >+ ) { >+ ruleSetToSubmit[`overdue_${this.triggerNumber}_restrict`] = >+ cloneDeep( >+ this.ruleSetToSubmit[ >+ `overdue_${this.triggerNumber}_restrict` >+ ] >+ ); >+ } >+ >+ if ( >+ this.ruleSetToSubmit[`overdue_${this.triggerNumber}_mtt`] !== >+ null && >+ Array.isArray( >+ this.ruleSetToSubmit[`overdue_${this.triggerNumber}_mtt`] >+ ) && >+ !isEqual( >+ this.ruleSetToSubmit[`overdue_${this.triggerNumber}_mtt`], >+ this.fallbackRuleSet[`overdue_${this.triggerNumber}_mtt`] >+ ) >+ ) { >+ ruleSetToSubmit[`overdue_${this.triggerNumber}_mtt`] = >+ cloneDeep( >+ this.ruleSetToSubmit[ >+ `overdue_${this.triggerNumber}_mtt` >+ ].join(",") >+ ); >+ } >+ >+ ruleSetToSubmit[`overdue_${this.triggerNumber}_has_rules`] = true; >+ >+ // in edit mode, check for changes from elsewhere to the rule set being edited >+ if (this.editMode === "edit") { >+ const ruleSetInDb = await this.getSelectedRuleSet(this.context); >+ if ( >+ this.hasConflict( >+ ruleSetInDb, >+ this.currentRuleSet, >+ this.triggerNumber >+ ) >+ ) { >+ this.alertMessage = >+ "Your changes could not be saved as this circulation trigger was updated elsewhere. Please see the updated trigger below."; >+ this.$router.push({ >+ path: "/cgi-bin/koha/admin/circulation_triggers/edit", >+ query: { >+ library_id: this.context.library_id, >+ patron_category_id: this.context.patron_category_id, >+ item_type_id: this.context.item_type_id, >+ triggerNumber: this.triggerNumber, >+ }, >+ }); >+ return; >+ } >+ } >+ >+ await this.updateCircRuleSets(ruleSetToSubmit, this.triggerNumber); >+ await this.$router.push({ >+ name: "CirculationTriggersList", >+ query: { refresh: Date.now() }, >+ }); >+ }, >+ async setRuleSets() { >+ this.updateTriggerCount(); >+ await this.setCurrentRuleSet(); >+ >+ this.ruleSetToSubmit = cloneDeep(this.currentRuleSet); >+ >+ this.setMinDelay(); >+ if (this.ruleSetToSubmit === null) { >+ this.ruleSetToSubmit = { >+ context: this.context, >+ [`overdue_${this.triggerNumber}_delay`]: this.minDelay, >+ [`overdue_${this.triggerNumber}_notice`]: null, >+ [`overdue_${this.triggerNumber}_mtt`]: null, >+ [`overdue_${this.triggerNumber}_restrict`]: null, >+ }; >+ } >+ this.setMaxDelay(); >+ this.setRuleSetInfo(); >+ this.effectiveTriggerFilteredRuleSets = >+ this.setEffectiveTriggerFilteredRuleSet(this.context); >+ this.setFallbackRuleSet(); >+ this.setFilteredLetters(); >+ this.setAllowSubmission(); >+ this.ruleSetInitialized = true; >+ }, >+ setContext(query) { >+ this.context.library_id = query.library_id ?? "*"; >+ this.context.item_type_id = query.item_type_id ?? "*"; >+ this.context.patron_category_id = query.patron_category_id ?? "*"; >+ }, >+ setEditMode() { >+ this.editMode = this.$route.path.substring( >+ this.$route.path.lastIndexOf("/") + 1 >+ ); >+ }, >+ setRuleSetInfo() { >+ this.ruleSetInfo = { >+ issuelength: this.currentRuleSet.issuelength, >+ decreaseloanholds: this.currentRuleSet.decreaseloanholds, >+ fine: this.currentRuleSet.fine, >+ chargeperiod: this.currentRuleSet.chargeperiod, >+ lengthunit: this.currentRuleSet.lengthunit, >+ triggerCount: this.triggerCounts[this.context.library_id], >+ }; >+ }, >+ async setCurrentRuleSet() { >+ this.currentRuleSet = await this.getSelectedRuleSet( >+ this.context, >+ true >+ ); >+ // override the context as fetched from the DBto ensure it matches the selected context >+ // FIXME: consider passing the context to trigger table separately >+ this.currentRuleSet.context = this.context; >+ }, >+ setTriggerNumber(triggerNumber, library_id) { >+ this.triggerNumber = >+ this.editMode === "edit" >+ ? triggerNumber >+ : this.triggerCounts[library_id] + 1; >+ }, >+ setFallbackRuleSet() { >+ this.fallbackRuleSet = { >+ [`overdue_${this.triggerNumber}_delay`]: this.findEffectiveRule( >+ this.context, >+ "delay", >+ i >+ ).value, >+ }; >+ >+ if (this.editMode === "add") { >+ return; >+ } >+ this.fallbackRuleSet = { >+ [`overdue_${this.triggerNumber}_delay`]: this.findEffectiveRule( >+ this.context, >+ "delay", >+ this.triggerNumber >+ ).value, >+ [`overdue_${this.triggerNumber}_notice`]: >+ this.findEffectiveRule( >+ this.context, >+ "notice", >+ this.triggerNumber >+ ).value, >+ [`overdue_${this.triggerNumber}_mtt`]: this.findEffectiveRule( >+ this.context, >+ "mtt", >+ this.triggerNumber >+ ).value, >+ [`overdue_${this.triggerNumber}_restrict`]: >+ this.findEffectiveRule( >+ this.context, >+ "restrict", >+ this.triggerNumber >+ ).value, >+ }; >+ }, >+ setMinDelay() { >+ if ( >+ this.ruleSetToSubmit === null || >+ this.triggerNumber === 0 || >+ this.triggerNumber === 1 >+ ) { >+ this.minDelay = 0; >+ return; >+ } >+ const priorTriggerNumber = this.triggerNumber - 1; >+ this.minDelay = this.currentRuleSet[ >+ `overdue_${priorTriggerNumber}_delay` >+ ] >+ ? parseInt( >+ this.currentRuleSet[`overdue_${priorTriggerNumber}_delay`] >+ ) + 1 >+ : 0; >+ this.ruleSetToSubmit[`overdue_${this.triggerNumber}_delay`] = >+ this.minDelay; >+ }, >+ setMaxDelay() { >+ const nextTriggerNumber = parseInt(this.triggerNumber) + 1; >+ this.maxDelay = this.currentRuleSet?.[ >+ `overdue_${nextTriggerNumber}_delay` >+ ] >+ ? parseInt( >+ this.currentRuleSet[`overdue_${nextTriggerNumber}_delay`] >+ ) - 1 >+ : Infinity; >+ }, >+ setFilteredLetters() { >+ let library = this.context.library_id; >+ const branchcodeMatches = letters.filter( >+ letter => letter.branchcode === library >+ ); >+ const emptyBranchcodeMatches = letters.filter( >+ letter => letter.branchcode === "" >+ ); >+ >+ const uniqueCodes = [ >+ ...new Set( >+ [...branchcodeMatches, ...emptyBranchcodeMatches].map( >+ letter => letter.code >+ ) >+ ), >+ ]; >+ >+ this.filteredLetters = letters.filter( >+ letter => >+ uniqueCodes.includes(letter.code) && >+ (letter.branchcode === library || letter.branchcode === "") >+ ); >+ }, >+ incrementDelay() { >+ // Check for minDelay and maxDelay >+ const min = this.minDelay !== undefined ? this.minDelay : 1; >+ const max = this.maxDelay !== undefined ? this.maxDelay : Infinity; >+ >+ // Set to minDelay if it's null or undefined >+ if ( >+ this.ruleSetToSubmit[`overdue_${this.triggerNumber}_delay`] === >+ undefined || >+ this.ruleSetToSubmit[`overdue_${this.triggerNumber}_delay`] === >+ null >+ ) { >+ this.ruleSetToSubmit[`overdue_${this.triggerNumber}_delay`] = >+ min; >+ } >+ >+ // Increment within the valid range >+ else { >+ this.ruleSetToSubmit[`overdue_${this.triggerNumber}_delay`] = >+ Math.min( >+ this.ruleSetToSubmit[ >+ `overdue_${this.triggerNumber}_delay` >+ ] + 1, >+ max >+ ); >+ } >+ this.setAllowSubmission(); >+ }, >+ decrementDelay() { >+ // Check for minDelay >+ const min = this.minDelay !== undefined ? this.minDelay : 1; >+ // Decrement only if greater than minDelay >+ if ( >+ this.ruleSetToSubmit[`overdue_${this.triggerNumber}_delay`] > >+ min >+ ) { >+ this.ruleSetToSubmit[`overdue_${this.triggerNumber}_delay`]--; >+ } >+ this.setAllowSubmission(); >+ }, >+ setAllowSubmission() { >+ // if notice is set to "", this translates to 'No letter', for which submisison is allowed, and mtt is redundant >+ const noticeAllowSubmissionAndBypassMtt = >+ this.ruleSetToSubmit?.[ >+ `overdue_${this.triggerNumber}_notice` >+ ] === ""; >+ if (noticeAllowSubmissionAndBypassMtt) { >+ this.allowSubmission = true; >+ return; >+ } >+ >+ // check specificity required as 0 is a value that submission must be allowed for >+ const delayAllowsSubmission = >+ this.ruleSetToSubmit?.[`overdue_${this.triggerNumber}_delay`] != >+ null; >+ if (delayAllowsSubmission) { >+ this.allowSubmission = true; >+ return; >+ } >+ >+ // if notice is set, then ensure at least one transport has been set >+ const noticeAllowSubmisison = >+ this.ruleSetToSubmit?.[ >+ `overdue_${this.triggerNumber}_notice` >+ ] != null; >+ const mttHasItems = >+ this.ruleSetToSubmit?.[`overdue_${this.triggerNumber}_mtt`] >+ ?.length; >+ if (noticeAllowSubmisison && mttHasItems) { >+ this.allowSubmission = true; >+ return; >+ } >+ >+ const restrictAllowsSubmission = >+ this.ruleSetToSubmit?.[ >+ `overdue_${this.triggerNumber}_restrict` >+ ] != null; >+ if (restrictAllowsSubmission) { >+ this.allowSubmission = true; >+ return; >+ } >+ this.allowSubmission = false; >+ }, >+ handleSetDelayToNull() { >+ this.ruleSetToSubmit[`overdue_${this.triggerNumber}_delay`] = null; >+ this.setAllowSubmission(); >+ }, >+ }, >+ watch: { >+ $route: { >+ immediate: true, >+ handler: function (newVal, oldVal) { >+ if ( >+ oldVal && >+ oldVal.query.triggerNumber && >+ newVal.query.triggerNumber !== oldVal.query.triggerNumber >+ ) { >+ this.$router.go(0); >+ } >+ }, >+ }, >+ }, >+ components: { TriggersTable, ButtonSubmit, TriggerContext }, >+}; >+</script> >+ >+<style scoped> >+#circulation-trigger-form-add { >+ max-height: 90vh; >+} >+ >+form li { >+ display: flex; >+ align-items: center; >+} >+ >+.numeric-input-wrapper { >+ position: relative; >+ display: inline-block; >+ width: 30%; >+} >+ >+.input-with-clear { >+ position: relative; >+ display: flex; >+ align-items: center; >+ width: 100%; >+} >+ >+.numeric-input { >+ padding-right: 40px; /* Adjust to leave space for clear button */ >+ padding-left: 0.25em; >+ padding-top: 2px; >+ padding-bottom: 2px; >+ width: 100%; >+ border-radius: 4px; >+ border: 1px solid #ccc; >+ font-size: 16px; >+ box-sizing: border-box; >+ transition: border-color 0.2s ease; >+} >+ >+.clear-btn { >+ position: absolute; >+ right: 22px; /* Adjust positioning */ >+ fill: var(--vs-controls-color); >+ background-color: transparent; >+ border: 0; >+ font-size: 1.2em; >+ color: #333; >+ cursor: pointer; >+ z-index: 2; /* Ensure it is above the input */ >+} >+ >+.button:active:hover, >+.clear-btn:active:hover { >+ background-color: #d4d4d4; >+ border-color: #8c8c8c; >+} >+ >+/* Chevron buttons container */ >+.chevron-buttons { >+ display: flex; >+ flex-direction: column; >+ position: absolute; >+ right: 0px; >+ top: 0; >+ bottom: 0; >+ width: 16px; >+ padding: 0px 5px 0px 2px; >+ justify-content: center; >+ z-index: 2; >+} >+ >+/* Chevron button styles */ >+.increment-btn, >+.decrement-btn { >+ background-color: transparent; >+ border: 0px solid #ccc; >+ font-size: 10px; >+ padding: 0px; >+ cursor: pointer; >+ color: rgba(60, 60, 60, 0.5); >+ border-radius: 2px; >+} >+ >+.increment-btn:hover, >+.decrement-btn:hover { >+ background-color: #ddd; >+} >+ >+/* Hide the native increment/decrement buttons */ >+input[type="number"]::-webkit-inner-spin-button, >+input[type="number"]::-webkit-outer-spin-button { >+ -webkit-appearance: none; >+ margin: 0; >+} >+ >+input[type="number"] { >+ -moz-appearance: textfield; /* For Firefox */ >+} >+ >+.numeric-input:focus, >+.numeric-input:hover { >+ border-color: #007bff; /* Match focus color of v-select */ >+ outline: none; >+} >+ >+.dialog.alert >+ fieldset:not(.bg-danger):not(.bg-warning):not(.bg-info):not( >+ .bg-success >+ ):not(.bg-primary):not(.action), >+.dialog.error >+ fieldset:not(.bg-danger):not(.bg-warning):not(.bg-info):not( >+ .bg-success >+ ):not(.bg-primary):not(.action) { >+ margin: 0; >+ background-color: rgba(255, 255, 255, 1); >+} >+ >+.router-link-active { >+ margin-left: 10px; >+} >+.modal-header { >+ display: flex; >+ justify-content: space-between; >+} >+</style> >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 00000000000..0c1e96286ae >--- /dev/null >+++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Admin/CirculationTriggers/CirculationTriggersFormConfirmReset.vue >@@ -0,0 +1,487 @@ >+<template> >+ <CirculationTriggersForm >+ :submitAction="resetCircRules" >+ formTitle="Confirm circulation rule set reset" >+ > >+ <fieldset class="rows"> >+ <div class="page-section bg-info"> >+ <p> >+ {{ >+ $__( >+ "Resetting this rule set for the chosen context will have an impact on all the contexts that used to fall back on this rule set." >+ ) >+ }} >+ </p> >+ <p> >+ {{ >+ $__( >+ "To better understand which contexts will be affected, use 'Display all patron categories and items types.' on the circulations triggers page." >+ ) >+ }} >+ </p> >+ </div> >+ <legend>{{ $__("Trigger context") }}</legend> >+ <ol v-if="initialized"> >+ <li> >+ <p> >+ <strong>{{ $__("Library") }}:</strong> >+ </p> >+ <p id="library_id"> >+ {{ handleContext(library_id, libraries, "library_id") }} >+ </p> >+ </li> >+ <li> >+ <p> >+ <strong>{{ $__("Patron category") }}:</strong> >+ </p> >+ <p id="patron_category_id"> >+ {{ >+ handleContext( >+ patron_category_id, >+ patronCategories, >+ "patron_category_id" >+ ) >+ }} >+ </p> >+ </li> >+ <li> >+ <p> >+ <strong>{{ $__("Item type") }}:</strong> >+ </p> >+ <p id="item_type_id"> >+ {{ >+ handleContext( >+ item_type_id, >+ itemTypes, >+ "item_type_id", >+ "description" >+ ) >+ }} >+ </p> >+ </li> >+ </ol> >+ <div v-else> >+ <p>{{ $__("Loading circulation context...") }}</p> >+ </div> >+ </fieldset> >+ >+ <fieldset class="rows"> >+ <legend> >+ {{ $__("Rules (overrides) which will be deleted") }} >+ </legend> >+ <table> >+ <thead> >+ <th> >+ {{ $__("Delay") }} >+ </th> >+ <th> >+ {{ $__("Notice") }} >+ </th> >+ <th> >+ {{ $__("Email") }} >+ </th> >+ <th> >+ {{ $__("Print") }} >+ </th> >+ <th> >+ {{ $__("SMS") }} >+ </th> >+ <th> >+ {{ $__("Restricts checkouts") }} >+ </th> >+ </thead> >+ <tbody v-if="initialized"> >+ <tr> >+ <!-- Delay --> >+ <td> >+ <span >+ v-if=" >+ effectiveRuleSet[ >+ `overdue_${triggerNumber}_delay` >+ ] >+ " >+ :class="{ >+ fallback: >+ effectiveRuleSet[ >+ `overdue_${triggerNumber}_delay` >+ ].isFallback, >+ }" >+ > >+ {{ >+ effectiveRuleSet[ >+ `overdue_${triggerNumber}_delay` >+ ].value >+ }} >+ </span> >+ </td> >+ >+ <!-- Notice --> >+ <td> >+ <span >+ v-if=" >+ effectiveRuleSet[ >+ `overdue_${triggerNumber}_notice` >+ ] >+ " >+ :class="{ >+ fallback: >+ effectiveRuleSet[ >+ `overdue_${triggerNumber}_notice` >+ ].isFallback, >+ }" >+ > >+ {{ >+ handleNotice( >+ effectiveRuleSet[ >+ `overdue_${triggerNumber}_notice` >+ ].value >+ ) >+ }} >+ </span> >+ </td> >+ <!-- Email --> >+ <td> >+ <span >+ v-if=" >+ effectiveRuleSet[ >+ `overdue_${triggerNumber}_mtt` >+ ]?.length >+ " >+ :class="{ >+ fallback: >+ effectiveRuleSet[ >+ `overdue_${triggerNumber}_mtt` >+ ].isFallback, >+ }" >+ > >+ {{ >+ handleTransport( >+ effectiveRuleSet[ >+ `overdue_${triggerNumber}_mtt` >+ ].value, >+ "email", >+ !effectiveRuleSet[ >+ `overdue_${modal ? i + 1 : triggerNumber}_notice` >+ ].value >+ ) >+ }} >+ </span> >+ </td> >+ <!-- Print --> >+ <td> >+ <span >+ v-if=" >+ effectiveRuleSet[ >+ `overdue_${triggerNumber}_mtt` >+ ]?.length >+ " >+ :class="{ >+ fallback: >+ effectiveRuleSet[ >+ `overdue_${triggerNumber}_mtt` >+ ].isFallback, >+ }" >+ > >+ {{ >+ handleTransport( >+ effectiveRuleSet[ >+ `overdue_${triggerNumber}_mtt` >+ ].value, >+ "print", >+ !effectiveRuleSet[ >+ `overdue_${modal ? i + 1 : triggerNumber}_notice` >+ ].value >+ ) >+ }} >+ </span> >+ </td> >+ <!-- SMS --> >+ <td> >+ <span >+ v-if=" >+ effectiveRuleSet[ >+ `overdue_${triggerNumber}_mtt` >+ ]?.length >+ " >+ :class="{ >+ fallback: >+ effectiveRuleSet[ >+ `overdue_${triggerNumber}_mtt` >+ ].isFallback, >+ }" >+ > >+ {{ >+ handleTransport( >+ effectiveRuleSet[ >+ `overdue_${triggerNumber}_mtt` >+ ].value, >+ !effectiveRuleSet[ >+ `overdue_${modal ? i + 1 : triggerNumber}_notice` >+ ].value >+ ) >+ }} >+ </span> >+ </td> >+ <!-- Restricts Checkouts --> >+ <td> >+ <span >+ v-if=" >+ effectiveRuleSet[ >+ `overdue_${triggerNumber}_restrict` >+ ] >+ " >+ :class="{ >+ fallback: >+ effectiveRuleSet[ >+ `overdue_${triggerNumber}_restrict` >+ ].isFallback, >+ }" >+ > >+ {{ >+ handleRestrictions( >+ effectiveRuleSet[ >+ `overdue_${triggerNumber}_restrict` >+ ].value >+ ) >+ }} >+ </span> >+ </td> >+ </tr> >+ </tbody> >+ <tbody v-else> >+ <tr> >+ {{ >+ $__("Loading circulation rule set...") >+ }} >+ </tr> >+ </tbody> >+ </table> >+ </fieldset> >+ >+ <fieldset class="rows" v-if="alertMessage"> >+ <div class="alert alert-info">{{ alertMessage }}</div> >+ </fieldset> >+ </CirculationTriggersForm> >+</template> >+ >+<script> >+import ButtonSubmit from "../../ButtonSubmit.vue"; >+import CirculationTriggersForm from "./CirculationTriggersForm.vue"; >+import TriggerContext from "./TriggerContext.vue"; >+import { inject } from "vue"; >+import { storeToRefs } from "pinia"; >+ >+export default { >+ setup() { >+ const circRulesStore = inject("circRulesStore"); >+ const { >+ handleContext, >+ handleNotice, >+ handleTransport, >+ getSelectedRuleSet, >+ formatTriggerSpecificRuleSetForDisplay, >+ deleteRuleSet, >+ handleRestrictions, >+ } = circRulesStore; >+ const { libraries, itemTypes, patronCategories } = >+ storeToRefs(circRulesStore); >+ return { >+ libraries, >+ itemTypes, >+ patronCategories, >+ handleContext, >+ handleNotice, >+ handleTransport, >+ getSelectedRuleSet, >+ formatTriggerSpecificRuleSetForDisplay, >+ deleteRuleSet, >+ handleRestrictions, >+ }; >+ }, >+ data() { >+ return { >+ alertMessage: null, >+ initialized: false, >+ library_id: "*", >+ item_type_id: "*", >+ patron_category_id: "*", >+ triggerNumber: null, >+ ruleSet: null, >+ currentRuleSet: null, >+ fallbackRuleSet: null, >+ effectiveRuleSet: null, >+ }; >+ }, >+ beforeRouteEnter(to, from, next) { >+ next(async vm => { >+ vm.setContext(to.query); >+ vm.currentRuleSet = await vm.getSelectedRuleSet({ >+ library_id: vm.library_id, >+ patron_category_id: vm.patron_category_id, >+ item_type_id: vm.item_type_id, >+ }); >+ vm.effectiveRuleSet = vm.formatTriggerSpecificRuleSetForDisplay( >+ vm.currentRuleSet.context, >+ vm.triggerNumber, >+ false >+ ); >+ vm.initialized = true; >+ }); >+ }, >+ methods: { >+ async resetCircRules(e) { >+ e.preventDefault(); >+ try { >+ await this.deleteRuleSet( >+ this.currentRuleSet, >+ this.triggerNumber >+ ); >+ } catch (e) { >+ this.alertMessage = e; >+ // reload the form components that have changed, remain in edit mode >+ this.$router.push({ >+ path: "/cgi-bin/koha/admin/circulation_triggers/reset", >+ query: { >+ ...this.currentRuleSet.context, >+ triggerNumber: triggerNumber, >+ }, >+ }); >+ } >+ await this.$router.push({ >+ name: "CirculationTriggersList", >+ query: { refresh: Date.now() }, >+ }); >+ }, >+ setContext(query) { >+ this.library_id = query.library_id ?? "*"; >+ this.item_type_id = query.item_type_id ?? "*"; >+ this.patron_category_id = query.patron_category_id ?? "*"; >+ this.triggerNumber = query.triggerNumber; >+ }, >+ }, >+ components: { ButtonSubmit, TriggerContext, CirculationTriggersForm }, >+}; >+</script> >+ >+<style scoped> >+#circulation-trigger-form-confirm-reset { >+ max-height: 90vh; >+} >+ >+form li { >+ display: flex; >+ align-items: center; >+} >+ >+.numeric-input-wrapper { >+ position: relative; >+ display: inline-block; >+ width: 30%; >+} >+ >+.input-with-clear { >+ position: relative; >+ display: flex; >+ align-items: center; >+ width: 100%; >+} >+ >+.numeric-input { >+ padding-right: 40px; /* Adjust to leave space for clear button */ >+ padding-left: 0.25em; >+ padding-top: 2px; >+ padding-bottom: 2px; >+ width: 100%; >+ border-radius: 4px; >+ border: 1px solid #ccc; >+ font-size: 16px; >+ box-sizing: border-box; >+ transition: border-color 0.2s ease; >+} >+ >+.clear-btn { >+ position: absolute; >+ right: 22px; /* Adjust positioning */ >+ fill: var(--vs-controls-color); >+ background-color: transparent; >+ border: 0; >+ font-size: 1.2em; >+ color: #333; >+ cursor: pointer; >+ z-index: 2; /* Ensure it is above the input */ >+} >+ >+.button:active:hover, >+.clear-btn:active:hover { >+ background-color: #d4d4d4; >+ border-color: #8c8c8c; >+} >+ >+/* Chevron buttons container */ >+.chevron-buttons { >+ display: flex; >+ flex-direction: column; >+ position: absolute; >+ right: 0px; >+ top: 0; >+ bottom: 0; >+ width: 16px; >+ padding: 0px 5px 0px 2px; >+ justify-content: center; >+ z-index: 2; >+} >+ >+/* Chevron button styles */ >+.increment-btn, >+.decrement-btn { >+ background-color: transparent; >+ border: 0px solid #ccc; >+ font-size: 10px; >+ padding: 0px; >+ cursor: pointer; >+ color: rgba(60, 60, 60, 0.5); >+ border-radius: 2px; >+} >+ >+.increment-btn:hover, >+.decrement-btn:hover { >+ background-color: #ddd; >+} >+ >+/* Hide the native increment/decrement buttons */ >+input[type="number"]::-webkit-inner-spin-button, >+input[type="number"]::-webkit-outer-spin-button { >+ -webkit-appearance: none; >+ margin: 0; >+} >+ >+input[type="number"] { >+ -moz-appearance: textfield; /* For Firefox */ >+} >+ >+.numeric-input:focus, >+.numeric-input:hover { >+ border-color: #007bff; /* Match focus color of v-select */ >+ outline: none; >+} >+ >+.dialog.alert >+ fieldset:not(.bg-danger):not(.bg-warning):not(.bg-info):not( >+ .bg-success >+ ):not(.bg-primary):not(.action), >+.dialog.error >+ fieldset:not(.bg-danger):not(.bg-warning):not(.bg-info):not( >+ .bg-success >+ ):not(.bg-primary):not(.action) { >+ margin: 0; >+ background-color: rgba(255, 255, 255, 1); >+} >+ >+.router-link-active { >+ margin-left: 10px; >+} >+.modal-header { >+ display: flex; >+ justify-content: space-between; >+} >+</style> >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 00000000000..ccc989f754a >--- /dev/null >+++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Admin/CirculationTriggers/CirculationTriggersFormConfirmTriggerDelete.vue >@@ -0,0 +1,257 @@ >+<template> >+ <CirculationTriggersForm >+ :submitAction="deleteTrigger" >+ formTitle="Confirm circulation trigger deletion" >+ > >+ <fieldset class="rows"> >+ <h2 v-if="initialized"> >+ {{ >+ $__( >+ `Delete Trigger ${triggerNumber} for: ${handleContext(library_id, libraries, "library_id")}` >+ ) >+ }} >+ </h2> >+ <div v-else> >+ <p>{{ $__("Loading library...") }}</p> >+ </div> >+ </fieldset> >+ <fieldset class="rows"> >+ <TriggersTable >+ :triggerNumber="triggerNumber" >+ :modal="false" >+ :actions="false" >+ :ruleSets="formattedEffectiveRuleSets" >+ /> >+ </fieldset> >+ <fieldset class="rows"> >+ <div class="page-section bg-info"> >+ <p> >+ {{ >+ $__( >+ "Deleting this trigger will delete every rule set listed above, and therefore completely remove this trigger for the library selected." >+ ) >+ }} >+ </p> >+ </div> >+ <div class="alert alert-info" v-if="alertMessage"> >+ {{ alertMessage }} >+ </div> >+ </fieldset> >+ </CirculationTriggersForm> >+</template> >+<script> >+import TriggersTable from "./TriggersTable.vue"; >+import TriggerContext from "./TriggerContext.vue"; >+import CirculationTriggersForm from "./CirculationTriggersForm.vue"; >+import { inject } from "vue"; >+import { storeToRefs } from "pinia"; >+ >+export default { >+ setup() { >+ const circRulesStore = inject("circRulesStore"); >+ const { handleContext, findEffectiveRule, deleteRuleSet } = >+ circRulesStore; >+ const { libraries, allCurrentLibraryRawRuleSets, ruleSuffixes } = >+ storeToRefs(circRulesStore); >+ return { >+ libraries, >+ handleContext, >+ findEffectiveRule, >+ allCurrentLibraryRawRuleSets, >+ ruleSuffixes, >+ deleteRuleSet, >+ }; >+ }, >+ data() { >+ return { >+ alertMessage: null, >+ initialized: false, >+ library_id: "*", >+ triggerNumber: null, >+ formattedEffectiveRuleSets: [], >+ }; >+ }, >+ beforeRouteEnter(to, from, next) { >+ next(async vm => { >+ vm.setContext(to.query); >+ vm.setFormattedEffectiveRuleSets(); >+ vm.initialized = true; >+ }); >+ }, >+ methods: { >+ async deleteTrigger(e) { >+ e.preventDefault(); >+ for (const ruleSet of this.allCurrentLibraryRawRuleSets) { >+ try { >+ await this.deleteRuleSet(ruleSet, this.triggerNumber); >+ } catch (e) { >+ this.alertMessage = e; >+ this.$router.push({ >+ path: "/cgi-bin/koha/admin/circulation_triggers/reset", >+ query: { >+ ...ruleSet.context, >+ triggerNumber: this.triggerNumber, >+ }, >+ }); >+ } >+ } >+ await this.$router.push({ >+ name: "CirculationTriggersList", >+ query: { refresh: Date.now() }, >+ }); >+ }, >+ setFormattedEffectiveRuleSets() { >+ this.formattedEffectiveRuleSets = []; >+ this.allCurrentLibraryRawRuleSets.forEach(ruleSet => { >+ const effectiveRuleSet = { >+ context: { ...ruleSet.context }, >+ }; >+ >+ if ( >+ ruleSet[`overdue_${this.triggerNumber}_has_rules`] === null >+ ) { >+ return; >+ } >+ this.ruleSuffixes.forEach(ruleSuffix => { >+ effectiveRuleSet[ >+ `overdue_${this.triggerNumber}_${ruleSuffix}` >+ ] = this.findEffectiveRule( >+ ruleSet.context, >+ ruleSuffix, >+ this.triggerNumber >+ ); >+ }); >+ this.formattedEffectiveRuleSets.push(effectiveRuleSet); >+ }); >+ }, >+ setContext(query) { >+ this.library_id = query.library_id ?? "*"; >+ this.triggerNumber = query.triggerNumber; >+ }, >+ }, >+ components: { TriggerContext, TriggersTable, CirculationTriggersForm }, >+}; >+</script> >+ >+<style scoped> >+#circulation-trigger-form-confirm-trigger-delete { >+ max-height: 90vh; >+} >+ >+form li { >+ display: flex; >+ align-items: center; >+} >+ >+.numeric-input-wrapper { >+ position: relative; >+ display: inline-block; >+ width: 30%; >+} >+ >+.input-with-clear { >+ position: relative; >+ display: flex; >+ align-items: center; >+ width: 100%; >+} >+ >+.numeric-input { >+ padding-right: 40px; /* Adjust to leave space for clear button */ >+ padding-left: 0.25em; >+ padding-top: 2px; >+ padding-bottom: 2px; >+ width: 100%; >+ border-radius: 4px; >+ border: 1px solid #ccc; >+ font-size: 16px; >+ box-sizing: border-box; >+ transition: border-color 0.2s ease; >+} >+ >+.clear-btn { >+ position: absolute; >+ right: 22px; /* Adjust positioning */ >+ fill: var(--vs-controls-color); >+ background-color: transparent; >+ border: 0; >+ font-size: 1.2em; >+ color: #333; >+ cursor: pointer; >+ z-index: 2; /* Ensure it is above the input */ >+} >+ >+.button:active:hover, >+.clear-btn:active:hover { >+ background-color: #d4d4d4; >+ border-color: #8c8c8c; >+} >+ >+/* Chevron buttons container */ >+.chevron-buttons { >+ display: flex; >+ flex-direction: column; >+ position: absolute; >+ right: 0px; >+ top: 0; >+ bottom: 0; >+ width: 16px; >+ padding: 0px 5px 0px 2px; >+ justify-content: center; >+ z-index: 2; >+} >+ >+/* Chevron button styles */ >+.increment-btn, >+.decrement-btn { >+ background-color: transparent; >+ border: 0px solid #ccc; >+ font-size: 10px; >+ padding: 0px; >+ cursor: pointer; >+ color: rgba(60, 60, 60, 0.5); >+ border-radius: 2px; >+} >+ >+.increment-btn:hover, >+.decrement-btn:hover { >+ background-color: #ddd; >+} >+ >+/* Hide the native increment/decrement buttons */ >+input[type="number"]::-webkit-inner-spin-button, >+input[type="number"]::-webkit-outer-spin-button { >+ -webkit-appearance: none; >+ margin: 0; >+} >+ >+input[type="number"] { >+ -moz-appearance: textfield; /* For Firefox */ >+} >+ >+.numeric-input:focus, >+.numeric-input:hover { >+ border-color: #007bff; /* Match focus color of v-select */ >+ outline: none; >+} >+ >+.dialog.alert >+ fieldset:not(.bg-danger):not(.bg-warning):not(.bg-info):not( >+ .bg-success >+ ):not(.bg-primary):not(.action), >+.dialog.error >+ fieldset:not(.bg-danger):not(.bg-warning):not(.bg-info):not( >+ .bg-success >+ ):not(.bg-primary):not(.action) { >+ margin: 0; >+ background-color: rgba(255, 255, 255, 1); >+} >+ >+.router-link-active { >+ margin-left: 10px; >+} >+.modal-header { >+ display: flex; >+ justify-content: space-between; >+} >+</style> >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 00000000000..3060373d563 >--- /dev/null >+++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Admin/CirculationTriggers/CirculationTriggersList.vue >@@ -0,0 +1,503 @@ >+<template> >+ <Toolbar> >+ <ToolbarButton >+ :to="{ >+ name: 'CirculationTriggersFormConfirmContext', >+ query: { >+ library_id: currentLibraryId, >+ patron_category_id: currentPatronCategoryId, >+ item_type_id: currentItemTypeId, >+ }, >+ }" >+ icon="plus" >+ :title="$__('Add new trigger')" >+ /> >+ </Toolbar> >+ <div v-if="filtersInitialized"> >+ <h1>{{ $__("Circulation triggers") }}</h1> >+ <div class="page-section bg-info"> >+ <p> >+ {{ >+ $__( >+ "Rules are applied from most specific to less specific, using the first found in this order" >+ ) >+ }}: >+ </p> >+ <ul> >+ <li> >+ {{ >+ $__( >+ "same library, same patron category, same item type" >+ ) >+ }} >+ </li> >+ <li> >+ {{ >+ $__( >+ "same library, same patron category, all item types" >+ ) >+ }} >+ </li> >+ <li> >+ {{ >+ $__( >+ "same library, all patron categories, same item type" >+ ) >+ }} >+ </li> >+ <li> >+ {{ >+ $__( >+ "same library, all patron categories, all item types" >+ ) >+ }} >+ </li> >+ <li> >+ {{ >+ $__( >+ "default (all libraries), same patron category, same item type" >+ ) >+ }} >+ </li> >+ <li> >+ {{ >+ $__( >+ "default (all libraries), same patron category, all item types" >+ ) >+ }} >+ </li> >+ <li> >+ {{ >+ $__( >+ "default (all libraries), all patron categories, same item type" >+ ) >+ }} >+ </li> >+ <li> >+ {{ >+ $__( >+ "default (all libraries), all patron categories, all item types" >+ ) >+ }} >+ </li> >+ </ul> >+ <p> >+ {{ >+ $__( >+ "The system is currently set to match based on the %s" >+ ).format(from_branch) >+ }} >+ </p> >+ </div> >+ <div class="page-section" v-if="filtersInitialized"> >+ <legend> >+ Filter by >+ <span style="color: blue; font-weight: bold">context</span> >+ </legend> >+ <table> >+ <thead> >+ <tr> >+ <th>{{ $__("Library") }}</th> >+ <th>{{ $__("Category") }}</th> >+ <th>{{ $__("Item type") }}</th> >+ </tr> >+ </thead> >+ <tbody> >+ <tr> >+ <td> >+ <v-select >+ id="library_select" >+ v-model="currentLibraryId" >+ label="name" >+ :reduce="lib => lib.library_id" >+ :options="libraries" >+ @update:modelValue=" >+ filterRuleSetsbySearchParam() >+ " >+ :clearable="false" >+ placeholder="Default rules for all libraries" >+ > >+ <template #search="{ attributes, events }"> >+ <input >+ :required="!currentLibraryId" >+ class="vs__search" >+ v-bind="attributes" >+ v-on="events" >+ /> >+ </template> >+ </v-select> >+ </td> >+ <td> >+ <v-select >+ id="patron_category_select" >+ v-model="currentPatronCategoryId" >+ label="name" >+ :reduce="cat => cat.patron_category_id" >+ :options="patronCategories" >+ @update:modelValue=" >+ filterRuleSetsbySearchParam() >+ " >+ placeholder="any" >+ > >+ <template #search="{ attributes, events }"> >+ <input >+ :required="!currentPatronCategoryId" >+ class="vs__search" >+ v-bind="attributes" >+ v-on="events" >+ /> >+ </template> >+ </v-select> >+ </td> >+ <td> >+ <v-select >+ id="item_type_select" >+ v-model="currentItemTypeId" >+ label="description" >+ :reduce="itype => itype.item_type_id" >+ :options="itemTypes" >+ @update:modelValue=" >+ filterRuleSetsbySearchParam() >+ " >+ placeholder="any" >+ > >+ <template #search="{ attributes, events }"> >+ <input >+ :required="!currentItemTypeId" >+ class="vs__search" >+ v-bind="attributes" >+ v-on="events" >+ /> >+ </template> >+ </v-select> >+ </td> >+ </tr> >+ </tbody> >+ </table> >+ <div class="toggle-view-all-applicable-wrapper"> >+ <label for="filter-rules">{{ $__("Display ") }}</label> >+ <v-select >+ id="filter-rules" >+ v-model="displayAllApplicableRules" >+ :reduce="opt => opt.value" >+ :options="[ >+ { >+ value: 0, >+ label: 'explictly set rules.', >+ }, >+ { >+ value: 1, >+ label: 'all applied rules.', >+ }, >+ ]" >+ @update:modelValue="filterRuleSetsbySearchParam()" >+ > >+ </v-select> >+ </div> >+ </div> >+ </div> >+ <div v-if="filtersInitialized && ruleSetInitialized"> >+ <div id="circ_triggers_tabs" class="toptabs numbered"> >+ <ul class="nav nav-tabs" role="tablist"> >+ <li >+ v-for="number in triggerCounts[currentLibraryId]" >+ class="nav-item" >+ role="presentation" >+ :key="`noticeTab_${number}`" >+ > >+ <a >+ href="#" >+ class="nav-link" >+ role="tab" >+ v-bind:class=" >+ tabSelected === `Notice ${number}` ? 'active' : '' >+ " >+ @click="changeTabContent" >+ :data-content="`Notice ${number}`" >+ >{{ $__("Trigger") + " " + number }} >+ </a> >+ </li> >+ </ul> >+ </div> >+ <div class="tab-content"> >+ <template v-for="number in triggerCounts[currentLibraryId]"> >+ <div >+ class="tab-pane" >+ role="tabpanel" >+ v-bind:class=" >+ tabSelected === `Notice ${number}` ? 'show active' : '' >+ " >+ v-if="tabSelected === `Notice ${number}`" >+ :key="`noticeTabContent_${number}`" >+ > >+ <div class="page-section"> >+ <TriggersTable >+ :modal="false" >+ :actions="true" >+ :ruleSets="ruleSets" >+ :triggerNumber="number" >+ > >+ <router-link >+ v-if="isLastTrigger(number)" >+ :to="{ >+ name: 'CirculationTriggersFormConfirmTriggerDelete', >+ query: { >+ triggerNumber: number, >+ }, >+ }" >+ class="btn btn-primary" >+ > >+ <i class="fa-solid fa-xmark"></i> >+ {{ $__("Delete") }} >+ </router-link> >+ <div >+ :class="{ >+ 'page-section bg-info': true, >+ 'inline-block': isLastTrigger(number), >+ }" >+ > >+ {{ >+ $__( >+ "Bolid italic values denote fallback values where an override has not been set for the context." >+ ) >+ }} >+ </div> >+ </TriggersTable> >+ </div> >+ </div> >+ </template> >+ </div> >+ </div> >+ <div v-if="showModal" class="modal" role="dialog"> >+ <div >+ class="modal-dialog modal-dialog-centered modal-lg modal-dialog-scrollable" >+ role="document" >+ > >+ <router-view></router-view> >+ </div> >+ </div> >+</template> >+ >+<script> >+import Toolbar from "../../Toolbar.vue"; >+import ToolbarButton from "../../ToolbarButton.vue"; >+import TriggersTable from "./TriggersTable.vue"; >+import { inject } from "vue"; >+import { storeToRefs } from "pinia"; >+ >+export default { >+ setup() { >+ const circRulesStore = inject("circRulesStore"); >+ circRulesStore.init(); >+ const { >+ updateTriggerCount, >+ setAllRawRuleSets, >+ setAllEffectiveRuleSets, >+ setAllExhaustiveEffectiveRuleSets, >+ isLastTrigger, >+ } = circRulesStore; >+ const { >+ currentLibraryId, >+ currentPatronCategoryId, >+ currentItemTypeId, >+ itemTypes, >+ libraries, >+ patronCategories, >+ triggerCounts, >+ allExhaustiveEffectiveRuleSets, >+ allEffectiveRuleSets, >+ storeInitialized, >+ } = storeToRefs(circRulesStore); >+ >+ return { >+ currentLibraryId, >+ currentPatronCategoryId, >+ currentItemTypeId, >+ itemTypes, >+ libraries, >+ triggerCounts, >+ patronCategories, >+ updateTriggerCount, >+ allExhaustiveEffectiveRuleSets, >+ setAllRawRuleSets, >+ setAllEffectiveRuleSets, >+ setAllExhaustiveEffectiveRuleSets, >+ allEffectiveRuleSets, >+ isLastTrigger, >+ storeInitialized, >+ from_branch, >+ }; >+ }, >+ data() { >+ return { >+ filtersInitialized: false, >+ ruleSetInitialized: false, >+ tabSelected: "Notice 1", >+ showModal: false, >+ displayAllApplicableRules: 1, >+ }; >+ }, >+ beforeRouteEnter(to, from, next) { >+ next(async vm => { >+ vm.filtersInitialized = true; >+ await vm.filterRuleSetsbySearchParam(); >+ }); >+ }, >+ methods: { >+ async loadRuleSets() { >+ this.ruleSetInitialized = false; >+ await this.setAllRawRuleSets(); >+ this.updateTriggerCount(); >+ this.setAllEffectiveRuleSets(); >+ this.setAllExhaustiveEffectiveRuleSets(); >+ this.storeInitialized = true; >+ }, >+ formatSelectedParams() { >+ const selectedParams = {}; >+ selectedParams.effective = true; >+ selectedParams.library_id = this.currentLibraryId ?? "*"; >+ if (this.currentPatronCategoryId) { >+ selectedParams.patron_category_id = >+ this.currentPatronCategoryId; >+ } >+ if (this.currentItemTypeId) { >+ selectedParams.item_type_id = this.currentItemTypeId; >+ } >+ return selectedParams; >+ }, >+ async filterRuleSetsbySearchParam() { >+ await this.loadRuleSets(); >+ >+ const selectedParams = this.formatSelectedParams(); >+ const data = this.displayAllApplicableRules >+ ? this.allExhaustiveEffectiveRuleSets >+ : this.allEffectiveRuleSets; >+ >+ // handle searches for any patron category and item type combinations >+ if ( >+ !selectedParams.patron_category_id && >+ !selectedParams.item_type_id >+ ) { >+ this.ruleSets = data; >+ this.ruleSetInitialized = true; >+ return; >+ } >+ >+ // handle searches where only the item type is specified >+ if (!selectedParams.patron_category_id) { >+ this.ruleSets = data.filter( >+ ruleSet => >+ ruleSet.context.item_type_id === >+ selectedParams.item_type_id && >+ ruleSet.context.library_id === selectedParams.library_id >+ ); >+ this.ruleSetInitialized = true; >+ return; >+ } >+ >+ // handle searches where only the patron category is specified >+ if (!selectedParams.item_type_id) { >+ this.ruleSets = data.filter( >+ ruleSet => >+ ruleSet.context.patron_category_id === >+ selectedParams.patron_category_id && >+ ruleSet.context.library_id === selectedParams.library_id >+ ); >+ this.ruleSetInitialized = true; >+ return; >+ } >+ >+ // handle searches where both patron category and item type are specified and one specific rule is retrieved >+ this.ruleSets = data.filter( >+ ruleSet => >+ ruleSet.context.item_type_id === >+ selectedParams.item_type_id && >+ ruleSet.context.patron_category_id === >+ selectedParams.patron_category_id && >+ ruleSet.context.library_id === selectedParams.library_id >+ ); >+ this.ruleSetInitialized = true; >+ return; >+ }, >+ changeTabContent(e) { >+ this.tabSelected = e.target.getAttribute("data-content"); >+ }, >+ }, >+ watch: { >+ $route: { >+ immediate: true, >+ handler: function (newVal, oldVal) { >+ this.showModal = newVal.meta && newVal.meta.showModal; >+ }, >+ }, >+ "$route.query.refresh": { >+ async handler(newVal) { >+ if (newVal) { >+ await this.filterRuleSetsbySearchParam(); >+ } >+ }, >+ immediate: true, >+ }, >+ }, >+ components: { TriggersTable, Toolbar, ToolbarButton }, >+}; >+</script> >+ >+<style scoped> >+.inline-block { >+ display: inline-block; >+ width: calc(100% - 90.25px); >+ margin: 0 0 0 13px; >+} >+.page-section table { >+ width: 100%; >+ table-layout: fixed; >+} >+.page-section th, >+.page-section td { >+ width: 33%; >+} >+.page-section td { >+ padding: 0.5em; >+ vertical-align: top; >+} >+.v-select { >+ display: block; >+ background-color: white; >+ margin: 10px; >+ height: auto; >+} >+.vs__search, >+.v__selected { >+ display: inline-block; >+ vertical-align: middle; >+} >+.active { >+ cursor: pointer; >+} >+.toptabs { >+ margin-bottom: 0; >+} >+.toggle-view-all-applicable-wrapper { >+ margin: 10px; >+} >+ >+.modal { >+ position: fixed; >+ z-index: 9998; >+ display: table; >+ transition: opacity 0.3s ease; >+ left: 0px; >+ top: 0px; >+ width: 100%; >+ height: 100%; >+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.33); >+ background-color: rgba(0, 0, 0, 0.33); >+} >+.modal-dialog, >+.modal-dialog-centered, >+.modal-lg { >+ max-width: 90%; >+ width: fit-content; >+} >+</style> >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 00000000000..9a05c2d347c >--- /dev/null >+++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Admin/CirculationTriggers/Main.vue >@@ -0,0 +1,69 @@ >+<template> >+ <div> >+ <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"></div> >+ </div> >+ </div> >+ </div> >+</template> >+ >+<script> >+import Breadcrumbs from "../../Breadcrumbs.vue"; >+import Help from "../../Help.vue"; >+import Dialog from "../../Dialog.vue"; >+import "vue-select/dist/vue-select.css"; >+import { inject } from "vue"; >+ >+export default { >+ setup() { >+ const circRulesStore = inject("circRulesStore"); >+ letters.unshift({ >+ name: "No letter", >+ code: "", >+ branchcode: "", >+ value: "", >+ }); >+ circRulesStore.letters = letters; >+ >+ return { >+ letters, >+ }; >+ }, >+ components: { >+ Breadcrumbs, >+ Dialog, >+ Help, >+ }, >+}; >+</script> >+ >+<style> >+form .v-select { >+ display: inline-block; >+ background-color: white; >+ width: 50%; >+} >+ >+.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%; >+} >+</style> >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 00000000000..6be8bc94eda >--- /dev/null >+++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Admin/CirculationTriggers/TriggerContext.vue >@@ -0,0 +1,50 @@ >+<template> >+ <div class="page-section"> >+ <table> >+ <thead> >+ <th> >+ {{ $__("Loan period") }} >+ </th> >+ <th> >+ {{ $__("Unit") }} >+ </th> >+ <th> >+ {{ $__("Decreased loan period for high holds") }} >+ </th> >+ <th> >+ {{ $__("Fine amount") }} >+ </th> >+ <th> >+ {{ $__("Fine charging interval") }} >+ </th> >+ </thead> >+ <tbody> >+ <tr> >+ <td> >+ {{ ruleSetInfo.issuelength }} >+ </td> >+ <td> >+ {{ ruleSetInfo.lengthunit }} >+ </td> >+ <td> >+ {{ ruleSetInfo.decreaseloanholds }} >+ </td> >+ <td> >+ {{ ruleSetInfo.fine }} >+ </td> >+ <td> >+ {{ ruleSetInfo.chargeperiod }} >+ </td> >+ </tr> >+ </tbody> >+ </table> >+ </div> >+</template> >+ >+<script> >+export default { >+ props: ["ruleSetInfo"], >+}; >+</script> >+ >+<style></style> >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 00000000000..b79d5da7d51 >--- /dev/null >+++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Admin/CirculationTriggers/TriggersTable.vue >@@ -0,0 +1,357 @@ >+<template> >+ <slot></slot> >+ <table> >+ <thead> >+ <th v-if="!modal" class="trigger_context"> >+ {{ $__("Library") }} >+ </th> >+ <th v-if="!modal" class="trigger_context"> >+ {{ $__("Patron category") }} >+ </th> >+ <th v-if="!modal" class="trigger_context border_right"> >+ {{ $__("Item type") }} >+ </th> >+ <th v-if="modal"> >+ {{ $__("Trigger") }} >+ </th> >+ <th> >+ {{ $__("Delay") }} >+ </th> >+ <th> >+ {{ $__("Notice") }} >+ </th> >+ <th> >+ {{ $__("Email") }} >+ </th> >+ <th> >+ {{ $__("Print") }} >+ </th> >+ <th> >+ {{ $__("SMS") }} >+ </th> >+ <th> >+ {{ $__("Restricts checkouts") }} >+ </th> >+ <th v-if="actions"> >+ {{ $__("Actions") }} >+ </th> >+ </thead> >+ <tbody> >+ <template >+ v-for="(ruleSet, i) in ruleSets" >+ :key="'ruleSet' + i" >+ :class="{ >+ selected_rule_set: >+ modal && i + 1 === parseInt(activeTriggerBeingEdited), >+ }" >+ > >+ <tr >+ v-if=" >+ ruleSet[ >+ `overdue_${modal ? i + 1 : triggerNumber}_has_rules` >+ ] >+ " >+ > >+ <td v-if="!modal" class="trigger_context"> >+ {{ >+ handleContext( >+ ruleSet.context.library_id, >+ libraries, >+ "library_id" >+ ) >+ }} >+ </td> >+ <td v-if="!modal" class="trigger_context"> >+ {{ >+ handleContext( >+ ruleSet.context.patron_category_id, >+ patronCategories, >+ "patron_category_id" >+ ) >+ }} >+ </td> >+ <td v-if="!modal" class="trigger_context border_right"> >+ {{ >+ handleContext( >+ ruleSet.context.item_type_id, >+ itemTypes, >+ "item_type_id", >+ "description" >+ ) >+ }} >+ </td> >+ <td v-if="modal"> >+ {{ i + 1 }} >+ </td> >+ <!-- Delay --> >+ <td> >+ <span >+ :class="{ >+ fallback: >+ ruleSet[ >+ `overdue_${modal ? i + 1 : triggerNumber}_delay` >+ ].isFallback, >+ }" >+ > >+ {{ >+ ruleSet[ >+ `overdue_${modal ? i + 1 : triggerNumber}_delay` >+ ].value >+ }} >+ </span> >+ </td> >+ <!-- Notice --> >+ <td> >+ <span >+ :class="{ >+ fallback: >+ ruleSet[ >+ `overdue_${modal ? i + 1 : triggerNumber}_notice` >+ ].isFallback, >+ }" >+ > >+ {{ >+ handleNotice( >+ ruleSet[ >+ `overdue_${modal ? i + 1 : triggerNumber}_notice` >+ ].value >+ ) >+ }} >+ </span> >+ </td> >+ <!-- Email --> >+ <td> >+ <span >+ :class="{ >+ fallback: >+ ruleSet[ >+ `overdue_${modal ? i + 1 : triggerNumber}_mtt` >+ ].isFallback, >+ }" >+ > >+ {{ >+ handleTransport( >+ ruleSet[ >+ `overdue_${modal ? i + 1 : triggerNumber}_mtt` >+ ].value, >+ "email", >+ !ruleSet[ >+ `overdue_${modal ? i + 1 : triggerNumber}_notice` >+ ].value >+ ) >+ }} >+ </span> >+ </td> >+ <!-- Print --> >+ <td> >+ <span >+ :class="{ >+ fallback: >+ ruleSet[ >+ `overdue_${modal ? i + 1 : triggerNumber}_mtt` >+ ].isFallback, >+ }" >+ > >+ {{ >+ handleTransport( >+ ruleSet[ >+ `overdue_${modal ? i + 1 : triggerNumber}_mtt` >+ ].value, >+ "print", >+ !ruleSet[ >+ `overdue_${modal ? i + 1 : triggerNumber}_notice` >+ ].value >+ ) >+ }} >+ </span> >+ </td> >+ <!-- SMS --> >+ <td> >+ <span >+ :class="{ >+ fallback: >+ ruleSet[ >+ `overdue_${modal ? i + 1 : triggerNumber}_mtt` >+ ].isFallback, >+ }" >+ > >+ {{ >+ handleTransport( >+ ruleSet[ >+ `overdue_${modal ? i + 1 : triggerNumber}_mtt` >+ ].value, >+ "sms", >+ !ruleSet[ >+ `overdue_${modal ? i + 1 : triggerNumber}_notice` >+ ].value >+ ) >+ }} >+ </span> >+ </td> >+ <!-- Restricts Checkouts --> >+ <td> >+ <span >+ :class="{ >+ fallback: >+ ruleSet[ >+ `overdue_${modal ? i + 1 : triggerNumber}_restrict` >+ ].isFallback, >+ }" >+ > >+ {{ >+ handleRestrictions( >+ ruleSet[ >+ `overdue_${modal ? i + 1 : triggerNumber}_restrict` >+ ].value >+ ) >+ }} >+ </span> >+ </td> >+ <td class="actions" v-if="actions"> >+ <router-link >+ :to="{ >+ name: 'CirculationTriggersFormEdit', >+ query: { >+ library_id: ruleSet.context.library_id, >+ item_type_id: ruleSet.context.item_type_id, >+ patron_category_id: >+ ruleSet.context.patron_category_id, >+ triggerNumber: modal >+ ? i + 1 >+ : triggerNumber, >+ }, >+ }" >+ class="btn btn-default btn-xs" >+ ><i class="fa-solid fa-pencil"></i> >+ {{ $__("Edit") }}</router-link >+ > >+ <router-link >+ v-if=" >+ ruleSet[ >+ `overdue_${modal ? i + 1 : triggerNumber}_has_rules` >+ ].value && >+ !isOnlyRuleSetForTrigger(triggerNumber) >+ " >+ :to="{ >+ name: 'CirculationTriggersFormConfirmReset', >+ query: { >+ library_id: ruleSet.context.library_id, >+ item_type_id: ruleSet.context.item_type_id, >+ patron_category_id: >+ ruleSet.context.patron_category_id, >+ triggerNumber: triggerNumber, >+ }, >+ }" >+ class="btn btn-default btn-xs" >+ ><i class="fa-solid fa-eraser"></i> >+ {{ $__("Reset") }}</router-link >+ > >+ </td> >+ </tr> >+ </template> >+ <tr v-if="modal"> >+ <td colspan="7"></td> >+ <td class="actions"> >+ <router-link >+ :to="{ >+ name: 'CirculationTriggersFormAdd', >+ query: { >+ library_id: >+ activeRuleSetBeingEdited.context.library_id, >+ item_type_id: >+ activeRuleSetBeingEdited.context >+ .item_type_id, >+ patron_category_id: >+ activeRuleSetBeingEdited.context >+ .patron_category_id, >+ triggerNumber: >+ triggerCounts[ >+ activeRuleSetBeingEdited.context >+ .library_id >+ ] + 1, >+ }, >+ }" >+ class="btn btn-default btn-xs" >+ ><i class="fa-solid fa-pencil"></i> >+ {{ $__("Add") }}</router-link >+ > >+ </td> >+ </tr> >+ </tbody> >+ </table> >+</template> >+ >+<script> >+import { inject } from "vue"; >+import { storeToRefs } from "pinia"; >+ >+export default { >+ props: [ >+ "triggerNumber", >+ "modal", >+ "actions", >+ "ruleSetBeingEdited", >+ "triggerBeingEdited", >+ "ruleSets", >+ ], >+ computed: { >+ activeRuleSetBeingEdited() { >+ return this.modal ? this.ruleSetBeingEdited : null; >+ }, >+ activeTriggerBeingEdited() { >+ return this.modal ? this.triggerBeingEdited : null; >+ }, >+ }, >+ setup() { >+ const circRulesStore = inject("circRulesStore"); >+ const { triggerCounts, patronCategories, itemTypes, libraries } = >+ storeToRefs(circRulesStore); >+ const { >+ handleContext, >+ handleNotice, >+ handleTransport, >+ isOnlyRuleSetForTrigger, >+ handleRestrictions, >+ } = circRulesStore; >+ >+ return { >+ handleContext, >+ handleNotice, >+ handleTransport, >+ triggerCounts, >+ patronCategories, >+ itemTypes, >+ libraries, >+ isOnlyRuleSetForTrigger, >+ handleRestrictions, >+ }; >+ }, >+}; >+</script> >+ >+<style scoped> >+.selected_rule_set > td { >+ background-color: yellow !important; >+} >+ >+.fallback { >+ font-style: italic; >+ font-weight: bold; >+} >+ >+.actions a { >+ margin-right: 5px; >+} >+ >+td.trigger_context { >+ color: black; >+} >+ >+th.trigger_context { >+ color: blue; >+} >+ >+.border_right { >+ border-right: solid 4px black; >+} >+</style> >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 c12f54beebf..59863485f5f 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 >@@ -2,6 +2,7 @@ import HttpClient from "./http-client"; > > import ERMAPIClient from "@fetch/erm-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"; >@@ -9,10 +10,12 @@ import ItemAPIClient from "@fetch/item-api-client"; > import RecordSourcesAPIClient from "@fetch/record-sources-api-client"; > import SysprefAPIClient from "@fetch/system-preferences-api-client"; > import PreservationAPIClient from "@fetch/preservation-api-client"; >+import CircRuleAPIClient from "@fetch/circulation-rules-api-client"; > > export const APIClient = { > erm: new ERMAPIClient(HttpClient), > patron: new PatronAPIClient(HttpClient), >+ library: new LibraryAPIClient(HttpClient), > acquisition: new AcquisitionAPIClient(HttpClient), > additional_fields: new AdditionalFieldsAPIClient(HttpClient), > authorised_values: new AVAPIClient(HttpClient), >@@ -20,4 +23,5 @@ export const APIClient = { > sysprefs: new SysprefAPIClient(HttpClient), > preservation: new PreservationAPIClient(HttpClient), > record_sources: new RecordSourcesAPIClient(HttpClient), >+ circRule: new CircRuleAPIClient(HttpClient), > }; >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 00000000000..f5e6a50c50b >--- /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 00000000000..e3d5a9cc587 >--- /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 00000000000..53d21f1f562 >--- /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_<rule_name>: {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_<rule_name>: <value>}} >+ // raw: includes only the exact sets as they are found in the db >+ // Format: [{overdue_X_<rule_name>: <value>}}] >+ 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.circRules.getAll( >+ {}, >+ { library_id: "*", effective: false } >+ ); >+ this.allCurrentLibraryRawRuleSets = >+ await client.circRules.getAll( >+ {}, >+ { library_id: this.currentLibraryId, effective: false } >+ ); >+ } catch (e) { >+ throw e; >+ } >+ }, >+ async getItemTypes() { >+ const client = APIClient.item; >+ let itemTypes = []; >+ try { >+ itemTypes = await client.itemTypes.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.patronCategories.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.circRules.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.circRules.update(circRuleSet); >+ } catch (e) { >+ //TODO: handle e >+ } >+ }, >+ }, >+}); >diff --git a/rspack.config.js b/rspack.config.js >index e05273fb7b5..a57414f5c6c 100644 >--- a/rspack.config.js >+++ b/rspack.config.js >@@ -27,7 +27,9 @@ module.exports = [ > acquisitions: > "./koha-tmpl/intranet-tmpl/prog/js/vue/modules/acquisitions.ts", > islands: "./koha-tmpl/intranet-tmpl/prog/js/vue/modules/islands.ts", >- }, >+ "admin/circulation_triggers": >+ "./koha-tmpl/intranet-tmpl/prog/js/vue/modules/admin/circulation_triggers.ts", >+ }, > output: { > filename: "[name].js", > path: path.resolve( >-- >2.51.0
You cannot view the attachment while viewing its details because your browser does not support IFRAMEs.
View the attachment on a separate page
.
View Attachment As Diff
View Attachment As Raw
Actions:
View
|
Diff
|
Splinter Review
Attachments on
bug 10190
:
170357
|
170358
|
170359
|
170360
|
170361
|
170362
|
170363
|
170364
|
170365
|
170366
|
170367
|
170368
|
170369
|
170370
|
170371
|
170372
|
170373
|
170374
|
170375
|
170376
|
170380
|
170449
|
171727
|
171728
|
171729
|
171730
|
171731
|
171732
|
171733
|
171734
|
171735
|
171736
|
171737
|
171738
|
171739
|
171740
|
171741
|
171742
|
171743
|
171744
|
171745
|
171746
|
171747
|
171748
|
171749
|
171750
|
171751
|
171752
|
171753
|
171754
|
171755
|
172588
|
172589
|
172590
|
172591
|
172592
|
172593
|
172594
|
172595
|
172596
|
172597
|
172598
|
172599
|
172600
|
172601
|
172602
|
172603
|
172604
|
172605
|
172606
|
172607
|
172608
|
172609
|
172612
|
172614
|
172617
|
172619
|
172621
|
172622
|
172623
|
172624
|
172625
|
172626
|
172630
|
172631
|
172632
|
172633
|
172634
|
172635
|
172636
|
172637
|
172638
|
172639
|
172640
|
172641
|
172642
|
172643
|
172644
|
172645
|
172646
|
172647
|
172648
|
172649
|
172650
|
172651
|
172652
|
172653
|
172654
|
172655
|
172656
|
172657
|
172658
|
172659
|
172660
|
172661
|
172662
|
172663
|
178643
|
178644
|
178645
|
178646
|
178647
|
178648
|
178649
|
178650
|
178651
|
178652
|
178653
|
178654
|
178655
|
178656
|
178657
|
178658
|
178659
|
178660
|
178661
|
178662
|
178663
|
178664
|
178665
|
178666
|
178667
|
178668
|
178669
|
178670
|
178671
|
178672
|
178673
|
178674
|
178675
|
188075
|
188076
|
188077
|
188078
|
188079
|
188080
|
188081
|
188082
| 188083 |
188084
|
188085
|
188086
|
188087
|
188088