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

Signed-off-by: Michaela <michaela.sieber@kit.edu>
Signed-off-by: Nick Clemens <nick@bywatersolutions.com>
Signed-off-by: Pedro Amorim <pedro.amorim@openfifth.co.uk>
This commit is contained in:
Chloé Zermatten 2024-08-13 14:15:44 +00:00 committed by Pedro Amorim
parent d199a559c0
commit d2c575a93d
No known key found for this signature in database
GPG Key ID: 06B75BA7F3894D55
20 changed files with 3572 additions and 0 deletions

View File

@ -202,6 +202,10 @@ our $RULE_KINDS = {
scope => [ 'branchcode', 'categorycode', 'itemtype' ],
can_be_blank => 0,
},
overdue_X_has_rules => {
scope => [ 'branchcode', 'categorycode', 'itemtype' ],
can_be_blank => 0,
},
renewalperiod => {
scope => [ 'branchcode', 'categorycode', 'itemtype' ],
},

View File

@ -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;

View File

@ -32,6 +32,7 @@ RewriteRule ^/cgi-bin/koha/admin/record_sources(.*)?$ /cgi-bin/koha/admin/record
RewriteCond %{QUERY_STRING} booksellerid=(.*)
RewriteRule ^/cgi-bin/koha/acqui/supplier.pl$ /cgi-bin/koha/acquisition/vendors/%1? [R]
RewriteRule ^/cgi-bin/koha/acquisition/vendors(.*)?$ /cgi-bin/koha/acqui/vendors.pl$1 [PT]
RewriteRule ^/cgi-bin/koha/admin/circulation_triggers(.*)?$ /cgi-bin/koha/admin/circulation_triggers.pl$1 [PT]
Alias "/api" "/usr/share/koha/api"
<Directory "/usr/share/koha/api">

View File

@ -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>

View File

@ -0,0 +1,42 @@
[% USE raw %]
[% USE To %]
[% USE Asset %]
[% USE Branches %]
[% USE KohaDates %]
[% USE TablesSettings %]
[% USE AuthorisedValues %]
[% SET footerjs = 1 %]
[% PROCESS 'i18n.inc' %]
[% INCLUDE 'doc-head-open.inc' %]
<title
>[% FILTER collapse %]
[% t("Circulation triggers") | html %]
&rsaquo; [% t("Administration") | html %] &rsaquo; [% 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-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
>

View File

@ -0,0 +1,36 @@
export class CircRuleAPIClient {
constructor(HttpClient) {
this.httpClient = new HttpClient({
baseURL: "/api/v1/circulation_rules",
});
}
get circ_rules() {
return {
getAll: (query, params, headers) =>
this.httpClient.getAll({
endpoint: "",
query,
params,
headers,
}),
update: rule =>
this.httpClient.put({
endpoint: "",
body: rule,
}),
count: (query = {}) =>
this.httpClient.count({
endpoint:
"?" +
new URLSearchParams({
_page: 1,
_per_page: 1,
...(query && { q: JSON.stringify(query) }),
}),
}),
};
}
}
export default CircRuleAPIClient;

View File

@ -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;

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -7,6 +7,7 @@ import ILLAPIClient from "@fetch/ill-api-client";
import BiblioAPIClient from "@fetch/biblio-api-client";
import CheckoutAPIClient from "@fetch/checkout-api-client";
import PatronAPIClient from "@fetch/patron-api-client";
import LibraryAPIClient from "@fetch/library-api-client";
import AcquisitionAPIClient from "@fetch/acquisition-api-client";
import AdditionalFieldsAPIClient from "@fetch/additional-fields-api-client";
import AVAPIClient from "@fetch/authorised-values-api-client";
@ -16,6 +17,7 @@ import RecordSourcesAPIClient from "@fetch/record-sources-api-client";
import SysprefAPIClient from "@fetch/system-preferences-api-client";
import SIP2APIClient from "@fetch/sip2-api-client";
import PreservationAPIClient from "@fetch/preservation-api-client";
import CircRuleAPIClient from "@fetch/circulation-rules-api-client";
export const APIClient = {
booking: new BookingAPIClient(HttpClient),
@ -25,6 +27,7 @@ export const APIClient = {
biblio: new BiblioAPIClient(HttpClient),
checkout: new CheckoutAPIClient(HttpClient),
patron: new PatronAPIClient(HttpClient),
library: new LibraryAPIClient(HttpClient),
acquisition: new AcquisitionAPIClient(HttpClient),
additional_fields: new AdditionalFieldsAPIClient(HttpClient),
authorised_values: new AVAPIClient(HttpClient),
@ -34,6 +37,7 @@ export const APIClient = {
sip2: new SIP2APIClient(HttpClient),
preservation: new PreservationAPIClient(HttpClient),
record_sources: new RecordSourcesAPIClient(HttpClient),
circRule: new CircRuleAPIClient(HttpClient),
};
export default APIClient;

View File

@ -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
});

View File

@ -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,
},
},
],
},
],
},
],
};

View File

@ -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.circ_rules.getAll(
{},
{ library_id: "*", effective: false }
);
this.allCurrentLibraryRawRuleSets =
await client.circ_rules.getAll(
{},
{ library_id: this.currentLibraryId, effective: false }
);
} catch (e) {
throw e;
}
},
async getItemTypes() {
const client = APIClient.item;
let itemTypes = [];
try {
itemTypes = await client.item_types.getAll();
} catch (e) {
// TODO: handle e
}
itemTypes.unshift({
item_type_id: "*",
description: $__("Default rule for all item types"),
});
this.itemTypes = itemTypes;
},
async getLibraries() {
const client = APIClient.library;
let libraries = [];
try {
libraries = await client.libraries.getAll();
} catch (e) {
// TODO: handle e
}
libraries.unshift({
library_id: "*",
name: $__("Default rule for all libraries"),
});
this.libraries = libraries;
},
async getPatronCategories() {
const client = APIClient.patron;
let patronCategories = [];
try {
patronCategories = await client.categories.getAll();
} catch (e) {
// TODO: handle e
}
patronCategories.unshift({
patron_category_id: "*",
name: $__("Default rule for all categories"),
});
this.patronCategories = patronCategories;
},
async getSelectedRuleSet(context, effective = false) {
if (context.library_id === null) {
context.library_id = "*";
}
const client = APIClient.circRule;
let result;
try {
result = await client.circ_rules.getAll(
{},
{
library_id: context.library_id,
patron_category_id: context.patron_category_id,
item_type_id: context.item_type_id,
effective,
}
);
} catch (e) {
throw e;
}
return result[0] ?? null;
},
async updateCircRuleSets(existingRuleSet, triggerNumber) {
const circRuleSet = { context: existingRuleSet.context };
circRuleSet[`overdue_${triggerNumber}_delay`] =
existingRuleSet[`overdue_${triggerNumber}_delay`];
circRuleSet[`overdue_${triggerNumber}_notice`] =
existingRuleSet[`overdue_${triggerNumber}_notice`];
circRuleSet[`overdue_${triggerNumber}_restrict`] =
existingRuleSet[`overdue_${triggerNumber}_restrict`];
circRuleSet[`overdue_${triggerNumber}_mtt`] =
existingRuleSet[`overdue_${triggerNumber}_mtt`];
circRuleSet[`overdue_${triggerNumber}_has_rules`] =
existingRuleSet[`overdue_${triggerNumber}_has_rules`];
try {
const client = APIClient.circRule;
await client.circ_rules.update(circRuleSet);
} catch (e) {
//TODO: handle e
}
},
},
});

View File

@ -131,6 +131,10 @@ module.exports = [
"./koha-tmpl/intranet-tmpl/prog/js/vue/csp-nonce.js",
"./koha-tmpl/intranet-tmpl/prog/js/vue/modules/islands-intranet.ts",
],
"admin/circulation_triggers": [
"./koha-tmpl/intranet-tmpl/prog/js/vue/csp-nonce.js",
"./koha-tmpl/intranet-tmpl/prog/js/vue/modules/admin/circulation_triggers.ts",
],
sip2: [
"./koha-tmpl/intranet-tmpl/prog/js/vue/csp-nonce.js",
"./koha-tmpl/intranet-tmpl/prog/js/vue/modules/sip2.ts",