View | Details | Raw Unified | Return to bug 15522
Collapse All | Expand All

(-)a/Koha/Template/Plugin/To.pm (-1 / +1 lines)
Lines 28-34 our $DYNAMIC = 1; Link Here
28
sub json {
28
sub json {
29
    my ( $self, $value ) = @_;
29
    my ( $self, $value ) = @_;
30
30
31
    my $json = JSON->new->allow_nonref(1);
31
    my $json = JSON->new->allow_nonref(1)->convert_blessed(1);
32
    $json = $json->encode($value);
32
    $json = $json->encode($value);
33
    $json =~ s/^"|"$//g; # Remove quotes around the strings
33
    $json =~ s/^"|"$//g; # Remove quotes around the strings
34
    $json =~ s/\\r/\\\\r/g; # Convert newlines to escaped newline characters
34
    $json =~ s/\\r/\\\\r/g; # Convert newlines to escaped newline characters
(-)a/admin/policy.pl (+47 lines)
Line 0 Link Here
1
#!/usr/bin/perl
2
3
# This file is part of Koha.
4
#
5
# Copyright (C) 2017 Jesse Weaver
6
#
7
# Koha is free software; you can redistribute it and/or modify it
8
# under the terms of the GNU General Public License as published by
9
# the Free Software Foundation; either version 3 of the License, or
10
# (at your option) any later version.
11
#
12
# Koha is distributed in the hope that it will be useful, but
13
# WITHOUT ANY WARRANTY; without even the implied warranty of
14
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15
# GNU General Public License for more details.
16
#
17
# You should have received a copy of the GNU General Public License
18
# along with Koha; if not, see <http://www.gnu.org/licenses>.
19
20
21
use Modern::Perl;
22
23
use C4::Auth;
24
use C4::Context;
25
use C4::Output;
26
27
use Koha::Patrons;
28
29
use CGI qw( -utf8 );
30
31
my $query = new CGI;
32
33
my ( $template, $borrowernumber, $cookie ) = get_template_and_user(
34
    {   template_name   => "admin/policy.tt",
35
        authnotrequired => 0,
36
        flagsrequired   => { parameters => 'manage_circ_rules' },
37
        query           => $query,
38
        type            => "intranet",
39
        debug           => 1,
40
    }
41
);
42
43
my $uid = Koha::Patrons->find( $borrowernumber )->userid;
44
my $restricted_to_own_library = $uid && haspermission( $uid, { parameters => 'manage_circ_rules_restricted' }, { no_inherit => 1 } );
45
$template->param( restricted_to_library => $restricted_to_own_library ? C4::Context::mybranch() : "" );
46
47
output_html_with_http_headers $query, $cookie, $template->output;
(-)a/koha-tmpl/intranet-tmpl/prog/css/admin/policy.css (+160 lines)
Line 0 Link Here
1
#toolbar {
2
    text-align: center;
3
    vertical-align: middle;
4
}
5
6
#toolbar-left, #toolbar-center, #toolbar-right {
7
    display: inline-block;
8
}
9
10
#toolbar-left {
11
    float: left;
12
}
13
14
#toolbar-left > button {
15
    margin-right: 6px;
16
}
17
18
#toolbar-center {
19
    line-height: 1.42857143;
20
    padding: 3px;
21
}
22
23
#toolbar-right {
24
    float: right;
25
}
26
27
#toolbar label input {
28
    margin-left: 10px;
29
    margin-right: 5px;
30
    vertical-align: middle;
31
}
32
33
#toolbar > button {
34
    margin-right: 15px;
35
    vertical-align: middle;
36
}
37
38
.branch-rules {
39
    border: 1px solid #333;
40
    margin-top: 20px;
41
    margin-bottom: 25px;
42
    padding: 10px;
43
}
44
45
.branch-rules h2 {
46
    margin-bottom: 20px;
47
}
48
49
.branch-rules .table {
50
    border-right-width: 0;
51
    width: auto;
52
}
53
54
.branch-rules .all-hidden {
55
    color: #888;
56
    font-style: italic;
57
}
58
59
.branch-rules .table th {
60
    border-right: 1px solid #BCBCBC;
61
    max-width: 10em;
62
    min-width: 10em;
63
    vertical-align: middle;
64
}
65
66
.branch-rules .table th.add-rule {
67
    max-width: 16em;
68
}
69
70
.branch-rules .table th[scope=row] {
71
    min-width: 16em;
72
}
73
74
.branch-rules .table td {
75
    border-right: 1px solid #BCBCBC;
76
    padding: 0;
77
}
78
79
/* This very odd-looking rule causes dropdowns and textboxes to correctly shrink */
80
.branch-rules input, .branch-rules select {
81
    min-width: 0px;
82
    max-width: 100%;
83
    text-overflow: ellipsis;
84
}
85
86
/* Setting these manually isn't the best practice, but for some godforsaken reason the input changes
87
 * size after an update without these rules. */
88
.branch-rules input {
89
    border-color: #BBB;
90
    border-radius: 4px;
91
    border-style: solid;
92
    border-width: 1px !important;
93
    max-width: 6em;
94
    min-width: 6em;
95
    padding: 6px;
96
}
97
98
.branch-rules input:focus {
99
    border-color: #825300;
100
    border-width: 2px !important;
101
    padding: 5px;
102
}
103
104
.branch-rules input:valid:focus {
105
    border-color: #538200;
106
}
107
108
select {
109
    height: 2em;
110
}
111
112
.inherited-rule {
113
    font-style: italic;
114
}
115
116
.policy-rule {
117
    display: flex;
118
    line-height: 30px;
119
    justify-content: space-between;
120
    padding: 8px;
121
}
122
123
.policy-rule.changed-rule {
124
    background-color: #FFFFCC;
125
}
126
127
.policy-rule .rule-tools {
128
    align-items: center;
129
    display: flex;
130
    opacity: .4;
131
    transition: opacity .15s;
132
}
133
134
.policy-rule:hover .rule-tools {
135
    opacity: 1;
136
}
137
138
.rule-tools a {
139
    margin-left: 8px;
140
}
141
142
.category-rules {
143
    border: 1px solid #666;
144
    margin-bottom: 15px;
145
    margin-top: 15px;
146
    padding: 10px;
147
}
148
149
.table-scroll-wrapper {
150
    width: 100%;
151
    overflow-x: auto;
152
}
153
154
.default-rules, .category-default-rules {
155
    border-style: dashed;
156
}
157
158
.default-rules h2, .category-default-rules h4, th.default {
159
    font-style: italic;
160
}
(-)a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/policy.tt (+71 lines)
Line 0 Link Here
1
[% USE raw %]
2
[% USE Asset %]
3
[% USE Branches %]
4
[% USE Categories %]
5
[% USE ItemTypes %]
6
[% USE Koha %]
7
[% USE To %]
8
[% SET footerjs = 1 %]
9
10
[% INCLUDE 'doc-head-open.inc' %]
11
<title>Koha &rsaquo; Administration &rsaquo; Policy</title>
12
[% INCLUDE 'doc-head-close.inc' %]
13
<link rel="stylesheet" href="[% interface %]/[% theme %]/css/admin/policy.css" />
14
</head>
15
<body id="admin_policy" class="admin">
16
[% INCLUDE 'header.inc' %]
17
[% INCLUDE 'prefs-admin-search.inc' %]
18
19
<div id="breadcrumbs"><a href="/cgi-bin/koha/mainpage.pl">Home</a> &rsaquo; <a href="/cgi-bin/koha/admin/admin-home.pl">Administration</a> &rsaquo; Circulation, fines and holds rules</div>
20
21
<div class="main container-fluid">
22
    <div class="row">
23
        <div class="col-sm-10 col-sm-push-2">
24
            <main>
25
                <div id="react-base">
26
                    <h1>Loading, please wait...</h1>
27
                </div>
28
            </main>
29
        </div>
30
        <div class="col-sm-2 col-sm-pull-10">
31
            <aside>
32
                [% INCLUDE 'admin-menu.inc' %]
33
            </aside>
34
        </div> <!-- /.col-sm-2.col-sm-pull-10 -->
35
     </div> <!-- /.row -->
36
37
[% MACRO jsinclude BLOCK %]
38
    [% Asset.js("js/admin-menu.js") | $raw %]
39
    [% Asset.js("lib/hc-sticky.js") | $raw %]
40
    [% Asset.js("lib/jquery/plugins/humanmsg.js") | $raw %]
41
    [% Asset.js("js/built/vendor.js") | $raw %]
42
    [% Asset.js("js/built/admin/policy/entry.js") | $raw %]
43
    [% INCLUDE 'calendar.inc' %]
44
45
    <script>
46
    var restricted_to_library = "[% restricted_to_library %]";
47
48
    Koha.Preferences = {
49
        "dateformat": "[% Koha.Preference( 'dateformat' ) %]",
50
    };
51
52
    Koha.BRANCHES = {
53
        [% FOREACH branch IN Branches.all( unfiltered => 1 ) %]
54
            "[% branch.branchcode %]": [% To.json( branch ) %],
55
        [% END %]
56
    };
57
    // TODO: Account for branch category limits
58
    Koha.PATRON_CATEGORIES = {
59
        [% FOREACH category IN Categories.all() %]
60
            "[% category.categorycode %]": [% To.json( category ) %],
61
        [% END %]
62
    };
63
    Koha.ITEM_TYPES = {
64
        [% FOREACH itemtype IN ItemTypes.Get() %]
65
            "[% itemtype.itemtype %]": [% To.json( itemtype ) %],
66
        [% END %]
67
    };
68
    </script>
69
[% END %]
70
<link rel="stylesheet" type="text/css" href="[% interface %]/[% theme %]/css/humanmsg.css" />
71
[% INCLUDE 'intranet-bottom.inc' %]
(-)a/koha-tmpl/intranet-tmpl/prog/js/src/admin/policy/app.js (+227 lines)
Line 0 Link Here
1
"use strict";
2
3
/* global __, Koha, fetch, $, humanMsg */
4
5
import _ from "lodash";
6
import React from "react";
7
8
import { DropDown } from "./common";
9
import rules from "./rules";
10
import { focusCell } from "./rule-cell";
11
import { BranchRuleEditor } from "./rule-editors";
12
13
function RuleGroupSelector( { group: selectedGroup, onChangeGroup } ) {
14
    let renderButton = ( group, className="btn-default", text = group ) => {
15
        return <button
16
            type="button"
17
            className={ "btn " + ( selectedGroup == group ? "active " : "" ) + className }
18
            onClick={ () => { onChangeGroup( group ); } }>
19
            <i className={ "fa " + ( selectedGroup == group ? "fa-check-circle-o" : "fa-circle-o" ) } />
20
            {" " + text}
21
        </button>;
22
    };
23
24
    return <div id="toolbar-right" className="btn-group">
25
        { renderButton( null, "btn-primary", __("Show all rules") ) }
26
        { renderButton(__("Circulation")) }
27
        { renderButton(__("Fines")) }
28
        { renderButton(__("Holds")) }
29
    </div>;
30
}
31
32
class PolicyAppToolbar extends React.Component {
33
    constructor( props ) {
34
        super( props );
35
36
        this.state = {
37
            selectedBranch: props.branch,
38
        };
39
    }
40
41
    render() {
42
        let selectedBranch = this.props.branch == "*" ? this.state.selectedBranch : this.props.branch;
43
        let hasChanges= rules.hasChangedRules();
44
45
        return <div id="toolbar" className="btn-toolbar" ref={ el => { this.toolbarElem = el; } }>
46
            <div id="toolbar-left">
47
                <button className="btn btn-success save-all" type="submit" disabled={ !hasChanges } onClick={this.props.onSaveClick}><i className="fa fa-save" /> {__("Save rules")}</button>
48
                <button className="btn btn-default" type="button" onClick={this.props.onCleanupClick}><i className="fa fa-clone" /> {__("Cleanup rules")}</button>
49
            </div>
50
            { window.restricted_to_library ? <div id="toolbar-center"></div> : <div id="toolbar-center">
51
                <label>
52
                    {__("Select a library: ")}
53
                    <DropDown
54
                        value={selectedBranch}
55
                        choices={
56
                            [ [ "", "Defaults for all libraries" ] ].concat(
57
                                _( Object.keys( Koha.BRANCHES ) )
58
                                    .map( branchcode => [ branchcode, Koha.BRANCHES[ branchcode ].branchname ] )
59
                                    .sortBy( 1 )
60
                                    .value()
61
                            )
62
                        }
63
                        onChange={ branch => {
64
                            this.setState( { selectedBranch: branch } );
65
                            this.props.onChange( branch );
66
                        } }
67
                    />
68
                </label>
69
                <label className="show-all">
70
                    <input type="checkbox" checked={ this.props.branch == "*" } onChange={ e => this.props.onChange( e.target.checked ? "*" : this.state.selectedBranch ) } />
71
                    {__("Show all")}
72
                </label>
73
            </div> }
74
            <RuleGroupSelector group={this.props.group} onChangeGroup={this.props.onChangeGroup} />
75
        </div>;
76
    }
77
78
    componentDidMount() {
79
        $( this.toolbarElem ).fixFloat();
80
    }
81
}
82
83
export default class PolicyApp extends React.Component {
84
    constructor( props ) {
85
        super( props );
86
87
        this.state = {
88
            branch: window.restricted_to_library || "",
89
            kindGroup: null,
90
        };
91
    }
92
93
    onSaveClick() {
94
        fetch( "/api/v1/circulation-rules", {
95
            method: "POST",
96
            credentials: "include",
97
            body: JSON.stringify( rules.pullChangedRules() ),
98
        } ).then( response => {
99
            switch( response.status ) {
100
                case 200:
101
                    rules.clearChangedRules();
102
                    humanMsg.displayMsg( __("<h3>Rules saved.</h3>"), { className: "humanSuccess" } );
103
                    this.forceUpdate();
104
                    break;
105
106
                case 401:
107
                    humanMsg.displayAlert( __("<h3>Rules not saved</h3><p>Please reload the page to log in again</p>"), { className: "humanError" } );
108
                    break;
109
110
                default:
111
                    humanMsg.displayAlert( __("<h3>Rules not saved</h3><p>Internal error</p>"), { className: "humanError" } );
112
                    break;
113
            }
114
        } );
115
    }
116
117
    onCleanupClick() {
118
        if ( !confirm( __("This will remove any redundant rules (ones that are set to the default value).\n\nThis should not change any circulation behavior, but you will be able to review these changes before saving.") ) ) {
119
            return;
120
        }
121
122
        for ( let rule of rules.getAllDefined( {} ) ) {
123
            let defaultRule = {};
124
125
            // We are trying to find any default rules, ignoring any scopes that we're already a
126
            // default rule for.
127
            //
128
            // If we have found a default rule besides the built-in default, the scope will be
129
            // something besides null.
130
131
            if ( rule.itemtype != "" && defaultRule.itemtype == null ) {
132
                defaultRule = rules.lookup( {
133
                    branchcode: rule.branchcode,
134
                    categorycode: rule.categorycode,
135
                    itemtype: null,
136
                    rule_name: rule.rule_name,
137
                } );
138
            }
139
140
            if ( rule.categorycode != "" && defaultRule.categorycode == null ) {
141
                defaultRule = rules.lookup( {
142
                    branchcode: rule.branchcode,
143
                    categorycode: null,
144
                    itemtype: rule.itemtype,
145
                    rule_name: rule.rule_name,
146
                } );
147
            }
148
149
            if ( rule.branchcode != "" && defaultRule.branchcode == null ) {
150
                defaultRule = rules.lookup( {
151
                    branchcode: null,
152
                    categorycode: rule.categorycode,
153
                    itemtype: rule.itemtype,
154
                    rule_name: rule.rule_name,
155
                } );
156
            }
157
158
            if ( rule.rule_value == defaultRule.rule_value ) {
159
                rules.unset( {
160
                    branchcode: rule.branchcode,
161
                    categorycode: rule.categorycode,
162
                    itemtype: rule.itemtype,
163
                    rule_name: rule.rule_name,
164
                } );
165
            }
166
        }
167
168
        this.forceUpdate();
169
    }
170
171
    render() {
172
        let onChangeRule = ( rule ) => {
173
            if ( rule.rule_value === null ) {
174
                rules.unset( rule );
175
            } else {
176
                rules.set( rule );
177
            }
178
            this.forceUpdate();
179
        };
180
181
        let onFocusRule = ( rule, inheritedRuleID ) => {
182
            let callback = () => {
183
                focusCell( document.getElementById( inheritedRuleID ), true );
184
            };
185
186
            if ( this.state.branch == "*" || rule.branchcode == this.state.branch ) {
187
                callback();
188
            } else {
189
                this.setState( { branch: rule.branchcode }, callback );
190
            }
191
        };
192
193
        let branchEditors;
194
195
        if ( this.state.branch == "*" ) {
196
            branchEditors = [ <BranchRuleEditor branch={null} key="" onChangeRule={onChangeRule} /> ].concat(
197
                Object.keys( Koha.BRANCHES ).map( branchcode => <BranchRuleEditor
198
                    key={branchcode}
199
                    branch={Koha.BRANCHES[ branchcode ]}
200
                    onChangeRule={onChangeRule}
201
                    onFocusRule={onFocusRule}
202
                /> )
203
            );
204
        } else {
205
            branchEditors = <BranchRuleEditor
206
                branch={this.state.branch ? Koha.BRANCHES[ this.state.branch ] : null}
207
                kindGroup={this.state.kindGroup}
208
                key={this.state.branch}
209
                onChangeRule={onChangeRule}
210
                onFocusRule={onFocusRule}
211
            />;
212
        }
213
214
        return <section>
215
            <h1>{__( "Circulation, fine and hold policy" )}</h1>
216
            <PolicyAppToolbar
217
                branch={this.state.branch}
218
                group={this.state.kindGroup}
219
                onChange={ branch => this.setState( { branch } ) }
220
                onChangeGroup={ group => this.setState( { kindGroup: group } ) }
221
                onSaveClick={ () => { this.onSaveClick(); } }
222
                onCleanupClick={ () => { this.onCleanupClick(); } }
223
            />
224
            {branchEditors}
225
        </section>;
226
    }
227
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/src/admin/policy/common.js (+38 lines)
Line 0 Link Here
1
"use strict";
2
3
/* global __ */
4
5
import React from "react";
6
7
export class DropDown extends React.Component {
8
    focus() {
9
        this.select.focus();
10
    }
11
12
    render() {
13
        const props = this.props;
14
15
        return <select ref={ c => this.select = c } value={props.value} onChange={ e => props.onChange( e.target.value ) }>
16
            { props.caption && <option disabled value="">{props.caption}</option> }
17
            { props.allowAll && <option value="">{__("Defaults")}</option> }
18
            { props.choices.map( ( [ key, description ] ) => <option key={key} value={key}>{description}</option> ) }
19
        </select>;
20
    }
21
}
22
23
export class GroupedDropDown extends React.Component {
24
    focus() {
25
        this.select.focus();
26
    }
27
28
    render() {
29
        const props = this.props;
30
31
        return <select ref={ c => this.select = c } value={props.value} onChange={ e => props.onChange( e.target.value ) }>
32
            { props.caption && <option disabled value="">{props.caption}</option> }
33
            { props.choices.map( ( [ groupName, choices ] ) => <optgroup label={groupName} key={groupName}>
34
                { choices.map( ( [ key, description ] ) => <option key={key} value={key}>{description}</option> ) }
35
            </optgroup> ) }
36
        </select>;
37
    }
38
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/src/admin/policy/core.js (+274 lines)
Line 0 Link Here
1
"use strict";
2
3
/* global __, fetch */
4
5
import _ from "lodash";
6
import rules from "./rules";
7
8
const RULE_DISPLAY_INFO = {
9
    refund: {
10
        description: __("Refund lost item fee"),
11
        group: __("Fines"),
12
        choices: [
13
            [ 0, __("No") ],
14
            [ 1, __("Yes") ],
15
        ],
16
        showByDefault: true,
17
    },
18
19
    patron_maxissueqty: {
20
        description: __("Total current checkouts allowed"),
21
        group: __("Circulation"),
22
        type: "amount",
23
        defaultText: __("Unlimited"),
24
        showByDefault: true,
25
    },
26
    patron_maxonsiteissueqty: {
27
        description: __("Total current on-site checkouts allowed"),
28
        group: __("Circulation"),
29
        type: "amount",
30
        defaultText: __("Unlimited"),
31
        showByDefault: true,
32
    },
33
    max_holds: {
34
        description: __("Maximum total holds allowed"),
35
        group: __("Holds"),
36
        type: "amount",
37
        defaultText: __("Unlimited"),
38
        showByDefault: true,
39
    },
40
41
    holdallowed: {
42
        description: __("Hold policy"),
43
        group: __("Holds"),
44
        choices: [
45
            [ 2, __("From any library") ],
46
            [ 1, __("From home library") ],
47
            [ 0, __("No holds allowed") ],
48
        ],
49
        showByDefault: true,
50
    },
51
    hold_fulfillment_policy: {
52
        description: __("Hold pickup library match"),
53
        group: __("Holds"),
54
        choices: [
55
            [ "any", __("any library") ],
56
            [ "homebranch", __("item's home library") ],
57
            [ "holdingbranch", __("item's holding library") ],
58
        ],
59
        showByDefault: true,
60
    },
61
    returnbranch: {
62
        description: __("Return policy"),
63
        group: __("Circulation"),
64
        choices: [
65
            [ "homebranch", __("Item returns home") ],
66
            [ "holdingbranch", __("Item returns to issuing library") ],
67
            [ "noreturn", __("Item floats") ],
68
        ],
69
        showByDefault: true,
70
    },
71
72
    article_requests: {
73
        description: __("Article requests"),
74
        group: __("Circulation"),
75
        choices: [
76
            [ "no", __("No") ],
77
            [ "yes", __("Yes") ],
78
            [ "bib_only", __("Record only") ],
79
            [ "item_only", __("Item only") ],
80
        ],
81
    },
82
    auto_renew: {
83
        description: __("Automatic renewal"),
84
        group: __("Circulation"),
85
        choices: [
86
            [ "no", __("No") ],
87
            [ "yes", __("Yes") ],
88
        ],
89
    },
90
    cap_fine_to_replacement_price: {
91
        description: __("Cap fine at replacement price"),
92
        group: __("Fines"),
93
        choices: [
94
            [ 0, __("No") ],
95
            [ 1, __("Yes") ],
96
        ],
97
    },
98
    chargeperiod: {
99
        description: __("Fine charging interval"),
100
        group: __("Fines"),
101
        type: "amount",
102
    },
103
    chargeperiod_charge_at: {
104
        description: __("When to charge"),
105
        group: __("Circulation"),
106
        choices: [
107
            [ 0, __("End of interval") ],
108
            [ 1, __("Start of interval") ],
109
        ],
110
    },
111
    fine: {
112
        description: __("Fine amount"),
113
        group: __("Fines"),
114
        type: "currency",
115
        showByDefault: true,
116
    },
117
    finedays: {
118
        description: __("Suspension in days"),
119
        group: __("Fines"),
120
        type: "amount",
121
    },
122
    firstremind: {
123
        description: __("Fine grace period"),
124
        group: __("Fines"),
125
        type: "amount",
126
        showByDefault: true,
127
    },
128
    hardduedate: {
129
        description: __("Hard due date"),
130
        group: __("Circulation"),
131
        type: "date",
132
        defaultText: __("None"),
133
    },
134
    hardduedatecompare: {
135
        description: __("Hard due date type"),
136
        group: __("Circulation"),
137
        choices: [
138
            [ -1, __("Before") ],
139
            [ 0, __("Exactly on") ],
140
            [ 1, __("After") ],
141
        ],
142
    },
143
    holds_per_record: {
144
        description: __("Holds per record (count)"),
145
        group: __("Holds"),
146
        type: "amount",
147
    },
148
    issuelength: {
149
        description: __("Loan period"),
150
        group: __("Circulation"),
151
        type: "amount",
152
        showByDefault: true,
153
    },
154
    lengthunit: {
155
        description: __("Loan period unit"),
156
        group: __("Circulation"),
157
        choices: [
158
            [ "days", __("Days") ],
159
            [ "hours", __("Hours") ],
160
        ],
161
        showByDefault: true,
162
    },
163
    maxissueqty: {
164
        description: __("Current checkouts allowed"),
165
        group: __("Circulation"),
166
        type: "amount",
167
        defaultText: __("Unlimited"),
168
        showByDefault: true,
169
    },
170
    maxonsiteissueqty: {
171
        description: __("Current on-site checkouts allowed"),
172
        group: __("Circulation"),
173
        defaultText: __("Unlimited"),
174
        type: "amount",
175
    },
176
    maxsuspensiondays: {
177
        description: __("Max. suspension duration (days)"),
178
        group: __("Fines"),
179
        defaultText: __("Unlimited"),
180
        type: "amount",
181
    },
182
    no_auto_renewal_after: {
183
        description: __("No automatic renewal after"),
184
        group: __("Circulation"),
185
        type: "amount",
186
        defaultText: __("Unset"),
187
    },
188
    no_auto_renewal_after_hard_limit: {
189
        description: __("No automatic renewal after (hard limit)"),
190
        group: __("Circulation"),
191
        type: "date",
192
        defaultText: __("Unset"),
193
    },
194
    norenewalbefore: {
195
        description: __("No renewal before"),
196
        group: __("Circulation"),
197
        type: "amount",
198
        defaultText: __("Unset"),
199
    },
200
    onshelfholds: {
201
        description: __("On shelf holds allowed"),
202
        group: __("Holds"),
203
        choices: [
204
            [ 1, __("Yes") ],
205
            [ 0, __("If any unavailable") ],
206
            [ 2, __("If all unavailable") ],
207
        ],
208
    },
209
    opacitemholds: {
210
        description: __("Item-level holds"),
211
        group: __("Holds"),
212
        choices: [
213
            [ "N", __("Don't allow") ],
214
            [ "Y", __("Allow") ],
215
            [ "F", __("Force") ],
216
        ],
217
    },
218
    overduefinescap: {
219
        description: __("Overdue fines cap (amount)"),
220
        group: __("Fines"),
221
        type: "currency",
222
        defaultText: __("Unlimited"),
223
    },
224
    renewalperiod: {
225
        description: __("Renewal period"),
226
        group: __("Circulation"),
227
        type: "amount",
228
    },
229
    renewalsallowed: {
230
        description: __("Renewals allowed (count)"),
231
        group: __("Circulation"),
232
        type: "amount",
233
        defaultText: __("None"),
234
    },
235
    rentaldiscount: {
236
        description: __("Rental discount (%)"),
237
        group: __("Circulation"),
238
        type: "percent",
239
        defaultText: __("None"),
240
    },
241
    reservesallowed: {
242
        description: __("Holds allowed (count)"),
243
        group: __("Holds"),
244
        type: "amount",
245
        defaultText: __("None"),
246
    },
247
    // Not included (deprecated?):
248
    //   * accountsent
249
    //   * chargename
250
    //   * reservecharge
251
    //   * restrictedtype
252
};
253
254
export var RULE_KINDS = {}, KINDS_BY_SCOPE = {};
255
256
export function load() {
257
    return Promise.all( [
258
        fetch( "/api/v1/circulation-rules/kinds" ).then( result => result.json() ).then( result => {
259
            _.merge( RULE_KINDS, RULE_DISPLAY_INFO, result );
260
            for ( let [ name, kind ] of Object.entries( RULE_KINDS ) ) {
261
                // Display _something_ sensible for unknown rule kinds.
262
                if ( !kind.description ) kind.description = name;
263
            }
264
265
            KINDS_BY_SCOPE = _( RULE_KINDS )
266
                .map( ( kind, name ) => Object.assign( kind, { name, scope_name: kind.scope.join( "_" ) } ) )
267
                .groupBy( kind => kind.scope_name )
268
                .value();
269
        } ),
270
        fetch( "/api/v1/circulation-rules" ).then( result => result.json() ).then( result => {
271
            rules.load( result );
272
        } ),
273
    ] );
274
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/src/admin/policy/entry.js (+28 lines)
Line 0 Link Here
1
"use strict";
2
3
/* global __ */
4
5
import React from "react";
6
import ReactDOM from "react-dom";
7
8
import PromisePolyfill from "promise-polyfill";
9
if ( !window.Promise ) window.Promise = PromisePolyfill;
10
11
// This polyfill installs itself
12
import "whatwg-fetch";
13
14
import { load } from "./core";
15
import PolicyApp from "./app";
16
import rules from "./rules";
17
18
window.addEventListener( "beforeunload", ( e ) => {
19
    // NOTE: This string is not shown in most browsers; its exact contents aren't critical.
20
    if ( rules.hasChangedRules() ) e.returnValue = __("Unsaved changes to rules, please save before closing the page");
21
} );
22
23
load().then( () => {
24
    ReactDOM.render(
25
        <PolicyApp />,
26
        document.getElementById( "react-base" )
27
    );
28
} );
(-)a/koha-tmpl/intranet-tmpl/prog/js/src/admin/policy/rule-cell.js (+256 lines)
Line 0 Link Here
1
"use strict";
2
3
/* global __, __x, Koha, fetch, is_valid_date, MSG_PLEASE_ENTER_A_VALID_DATE, get_dateformat_str */
4
5
import _ from "lodash";
6
import React from "react";
7
import ReactDOM from "react-dom";
8
9
import { DropDown } from "./common";
10
import { RULE_KINDS } from "./core";
11
import rules from "./rules";
12
13
const RULE_DEBOUNCE_MS = 200;
14
15
export function focusCell( cell, scrollTo = false ) {
16
    let cellInput = cell.querySelector( "input, select" );
17
    cellInput.focus();
18
19
    if ( scrollTo ) {
20
        // Get the cell in view (for horizontal scrolling), get the table itself all the way in
21
        // view, then fudge a bit for the floating toolbar.
22
        cell.scrollIntoView( true, { block: "start", inline: "nearest", behavior: "smooth" } );
23
        let tableTop = $( cell ).closest( 'table' )[0].offsetTop;
24
        ( document.scrollingElement || document.documentElement || document.body ).scrollTop = tableTop - document.querySelector("#toolbar").clientHeight * 2;
25
    }
26
27
    if ( cellInput.nodeName == "INPUT" && cellInput.type == "text" ) {
28
        cellInput.setSelectionRange( 0, cellInput.value.length );
29
    }
30
}
31
32
export default class RuleCell extends React.Component {
33
    constructor(props) {
34
        super(props);
35
36
        this.state = { toFocus: false };
37
        this.debounceTimeout = null;
38
    }
39
40
    shouldComponentUpdate( nextProps, nextState ) {
41
        const { ruleProperties: { branchcode, categorycode = "", itemtype = "", rule_name } } = nextProps;
42
        if ( nextProps != this.props || nextState != this.state ) return true;
43
44
        return rules.isChangedRule( { branchcode, categorycode, itemtype, rule_name } );
45
    }
46
47
    render() {
48
        const { ruleProperties: { branchcode, categorycode = "", itemtype = "", rule_name }, onChangeRule, onFocusRule } = this.props;
49
50
        let rule = rules.lookup( {
51
            branchcode,
52
            categorycode,
53
            itemtype,
54
            rule_name
55
        } ) || {};
56
        let kindInfo = RULE_KINDS[rule_name];
57
        let kindChoices = kindInfo.choices ? kindInfo.choices : null;
58
        let kindDescriptions = kindInfo.choices ? _.fromPairs(kindInfo.choices) : {};
59
60
        let onInheritedClick = ( e ) => {
61
            let defaultValue = "";
62
63
            if ( rule.rule_value ) {
64
                defaultValue = rule.rule_value;
65
            } else if ( kindInfo.choices ) {
66
                defaultValue = kindInfo.choices[0][0];
67
            }
68
69
            onChangeRule( { branchcode, categorycode, itemtype, rule_name, rule_value: defaultValue } );
70
            this.setState( { toFocus: true } );
71
72
            e.preventDefault();
73
        };
74
75
        let onInputChange = ( value ) => {
76
            if ( value === rule.rule_value ) return;
77
78
            onChangeRule( { branchcode, categorycode, itemtype, rule_name, rule_value: value } );
79
        };
80
81
        let onUnsetClick = ( e ) => {
82
            onChangeRule( { branchcode, categorycode, itemtype, rule_name, rule_value: null } );
83
84
            e.preventDefault();
85
        };
86
87
        let checkInput = () => {
88
            if ( kindInfo.type == "date" ) {
89
                if ( !is_valid_date( this.input.value ) ) {
90
                    this.input.setCustomValidity( MSG_PLEASE_ENTER_A_VALID_DATE.format( get_dateformat_str( Koha.Preferences.dateformat ) ) );
91
                } else {
92
                    this.input.setCustomValidity( "" );
93
                }
94
            }
95
96
            return !( this.input.validity.patternMismatch || this.input.validity.customError );
97
        };
98
99
        let onKeyDown = ( e ) => {
100
            if ( e.key == "Enter" ) {
101
                if ( checkInput() ) {
102
                    onInputChange( this.input.value );
103
                }
104
                e.preventDefault();
105
            }
106
        };
107
108
        let onChange = () => {
109
            if ( this.debounceTimeout ) {
110
                clearTimeout( this.debounceTimeout );
111
            }
112
113
            this.debounceTimeout = setTimeout( () => {
114
                if ( checkInput() ) {
115
                    onInputChange( this.input.value );
116
                }
117
            }, RULE_DEBOUNCE_MS );
118
        };
119
120
        let isBuiltInDefaultRule = ( rule ) => ( rule.branchcode === null && rule.categorycode === null && rule.itemtype === null );
121
122
        let renderRulePath = ( rule ) => {
123
            let result = [];
124
125
            if ( isBuiltInDefaultRule( rule ) ) {
126
                return __("Built-in default value");
127
            }
128
129
            for ( let part of RULE_KINDS[ rule.rule_name ].scope ) {
130
                switch ( part ) {
131
                    case "branchcode":
132
                        result.push(
133
                            rule.branchcode == "" ?
134
                                __("Library defaults") :
135
                                ( "\u201c" + Koha.BRANCHES[ rule.branchcode ].branchname + "\u201d" )
136
                        );
137
                        break;
138
139
                    case "categorycode":
140
                        result.push(
141
                            rule.categorycode == "" ?
142
                                __("Category defaults") :
143
                                ( "\u201c" + Koha.PATRON_CATEGORIES[ rule.categorycode ].description + "\u201d" )
144
                        );
145
                        break;
146
147
                    case "itemtype":
148
                        result.push(
149
                            rule.itemtype == "" ?
150
                                __("Item type defaults") :
151
                                ( "\u201c" + Koha.ITEM_TYPES[ rule.itemtype ].translated_description + "\u201d" )
152
                        );
153
                        break;
154
                }
155
            }
156
157
            return result.join( " \u203a " );
158
        };
159
160
        let contents, tools;
161
162
        if ( branchcode === rule.branchcode && categorycode === rule.categorycode && itemtype === rule.itemtype ) {
163
            // The rule we found is actually defined for the given combination
164
            if ( kindChoices ) {
165
                contents = <DropDown
166
                    ref={ input => this.input = input }
167
                    choices={kindChoices}
168
                    value={rule.rule_value}
169
                    onChange={onInputChange}
170
                />;
171
            } else {
172
                let pattern;
173
174
                switch ( kindInfo.type ) {
175
                    case "amount":
176
                        pattern = String.raw`\d+`;
177
                        break;
178
179
                    case "currency":
180
                        pattern = String.raw`\d+|\d*\.\d+?`;
181
                        break;
182
                }
183
184
                contents = <input
185
                    type={ kindInfo.type == "amount" ? "number" : "text" }
186
                    min={ kindInfo.type == "amount" ? 0 : null }
187
                    ref={ input => this.input = input }
188
                    defaultValue={rule.rule_value}
189
                    pattern={pattern}
190
                    onBlur={ e => {
191
                        if ( checkInput() ) {
192
                            onInputChange( this.input.value );
193
                        }
194
195
                        e.preventDefault();
196
                    } }
197
                    onChange={onChange}
198
                    onKeyDown={onKeyDown}
199
                />;
200
            }
201
            tools = <div className="rule-tools">
202
                <a href="#" title={__("Unset this rule")} onClick={onUnsetClick}><i className="fa fa-remove"></i></a>
203
            </div>;
204
        } else {
205
            let value = rule.rule_value;
206
207
            if ( rule.rule_value == null ) {
208
                value = __("Unset");
209
            } else if ( kindChoices && kindDescriptions[rule.rule_value] ) {
210
                value = kindDescriptions[rule.rule_value];
211
            } else if ( isBuiltInDefaultRule( rule ) && RULE_KINDS[rule_name].defaultText ) {
212
                value = RULE_KINDS[rule_name].defaultText;
213
            }
214
215
            let inheritedRuleID = `rule-${rule.branchcode}-${rule.categorycode}-${rule.itemtype}-${rule_name}`;
216
            contents = <span className="inherited-rule">{value}</span>;
217
            tools = <div className="rule-tools">
218
                <a href="#" title={__("Define this rule")} onClick={onInheritedClick}><i className="fa fa-pencil"></i></a>
219
                {rule.rule_value != null && <a
220
                    onClick={ ( e ) => {
221
                        if ( !isBuiltInDefaultRule( rule ) ) {
222
                            onFocusRule( rule, inheritedRuleID );
223
                        }
224
225
                        e.preventDefault();
226
                    } }
227
                    href={ "#" + inheritedRuleID }
228
                    title={ __x( "Set by: {path}", {
229
                        path: renderRulePath( rule )
230
                    } ) }>
231
                    <i className="fa fa-question"></i>
232
                </a>}
233
            </div>;
234
        }
235
236
        return <div
237
            className={"policy-rule" + (
238
                (
239
                    rules.isChangedRule( { branchcode, categorycode, itemtype, rule_name } ) ||
240
                    rules.isChangedRule( rule )
241
                ) ? " changed-rule" : "" )
242
            }
243
            id={`rule-${branchcode}-${categorycode}-${itemtype}-${rule_name}`}
244
        >
245
            {contents}
246
            {tools}
247
        </div>;
248
    }
249
250
    componentDidUpdate() {
251
        if ( this.state.toFocus && this.input ) {
252
            focusCell( ReactDOM.findDOMNode( this ) );
253
            this.setState( { toFocus: false } );
254
        }
255
    }
256
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/src/admin/policy/rule-editors.js (+308 lines)
Line 0 Link Here
1
"use strict";
2
3
/* global __, Koha, fetch */
4
5
import _ from "lodash";
6
import React from "react";
7
8
import { DropDown, GroupedDropDown } from "./common";
9
import { RULE_KINDS, KINDS_BY_SCOPE } from "./core";
10
import rules from "./rules";
11
import RuleCell from "./rule-cell";
12
13
function AllHiddenHeader() {
14
    return <th className="all-hidden" title={__("All rules are currently hidden; to see them, change your filter in the toolbar.")}>
15
        {__("All rules hidden")}
16
    </th>;
17
}
18
19
class RuleEditor extends React.Component {
20
    constructor( props ) {
21
        super( props );
22
23
        this.state = {
24
            visibleChoices: _.union( [ "" ], _.sortBy( this._configuredChoices, choice => this._allChoices[choice] ) ),
25
            visibleKinds: _.uniq(
26
                KINDS_BY_SCOPE[this.scopeName]
27
                    .filter( kind => kind.showByDefault )
28
                    .map( kind => kind.name )
29
                    .concat( this._ownRules.map( rule => rule.rule_name ) )
30
            ),
31
        };
32
    }
33
34
    get branchcode() {
35
        return this.props.branch ? this.props.branch.branchcode : "";
36
    }
37
38
    get categorycode() {
39
        // We have to handle both the case where there's never a categorycode and where the category
40
        // is unset (default rules).
41
        if ( this.props.category === null ) {
42
            return "";
43
        } else if ( this.props.category === undefined ) {
44
            return undefined;
45
        } else {
46
            return this.props.category.categorycode;
47
        }
48
    }
49
50
    get _ownRules() {
51
        return rules.getAllDefined( { branchcode: this.branchcode, categorycode: this.categorycode, scope_name: this.scopeName } );
52
    }
53
54
    addChoice( choice ) {
55
        this.setState( {
56
            visibleChoices: this.state.visibleChoices.concat( [ choice ] ),
57
        } );
58
    }
59
60
    addRuleName( choice ) {
61
        this.setState( {
62
            visibleKinds: this.state.visibleKinds.concat( [ choice ] ),
63
        } );
64
    }
65
66
    render() {
67
        const { props, state } = this;
68
        let choices = this._allChoices;
69
        let availableChoices = _( choices )
70
            .filter( ( [ choice ] ) => !state.visibleChoices.includes( choice ) )
71
            .sortBy( 1 )
72
            .value();
73
        let choiceDescriptions = _.fromPairs( choices );
74
        let visibleChoices = _.sortBy( state.visibleChoices, choice => choice ? choiceDescriptions[choice] : "" );
75
76
        let allKinds = KINDS_BY_SCOPE[this.scopeName];
77
        let allKindNames = allKinds.map( rule => rule.name );
78
        let kindGroups = _( allKindNames )
79
            .difference( state.visibleKinds )
80
            .map( rule_name => [ rule_name, RULE_KINDS[rule_name].description ] )
81
            .sortBy( 1 )
82
            .groupBy( ( [ rule_name ] ) => RULE_KINDS[rule_name].group )
83
            .value();
84
85
        let kindChoices = _( props.kindGroup ? { [props.kindGroup]: kindGroups[props.kindGroup] } : kindGroups )
86
            .toPairs()
87
            .sortBy( 0 )
88
            .value();
89
90
        if ( kindChoices.length && kindChoices[0][1] == undefined ) kindChoices = null;
91
92
        let visibleKinds = state.visibleKinds.filter( rule_name => !props.kindGroup || RULE_KINDS[rule_name].group == props.kindGroup );
93
94
        return <table className="table">
95
            { props.caption && <caption>{props.caption}</caption> }
96
            <thead>
97
                <tr>
98
                    <th>&nbsp;</th>
99
                    { !visibleKinds.length ? <AllHiddenHeader /> : visibleKinds.map( rule_name =>
100
                        <th key={rule_name}>{ RULE_KINDS[rule_name].description }</th>
101
                    ) }
102
                    { _.isEmpty( kindChoices ) || <th className="add-rule">
103
                        <GroupedDropDown
104
                            caption={__("Choose rule...")}
105
                            value=""
106
                            choices={ kindChoices }
107
                            onChange={ choice => this.addRuleName(choice) }
108
                        />
109
                    </th> }
110
                </tr>
111
            </thead>
112
            <tbody>
113
                { visibleChoices.map( choice => <tr key={choice}>
114
                    <th scope="row" className={ choice == "" ? "default" : "" }>{ choice ? choiceDescriptions[choice] : __("Defaults") }</th>
115
                    { visibleKinds.map( rule_name => <td key={rule_name}>
116
                        <RuleCell
117
                            key={rule_name}
118
                            ruleProperties={{
119
                                branchcode: this.branchcode,
120
                                categorycode: props.category ? props.category.categorycode : undefined,
121
                                [this.ruleKey]: choice,
122
                                rule_name
123
                            }}
124
                            onChangeRule={props.onChangeRule}
125
                            onFocusRule={props.onFocusRule}
126
                        />
127
                   </td> ) }
128
                </tr> ) }
129
                { _.isEmpty( availableChoices ) || <tr>
130
                    <th scope="row">
131
                        <DropDown
132
                            caption={this._dropDownCaption}
133
                            value=""
134
                            choices={availableChoices}
135
                            onChange={ choice => this.addChoice(choice) }
136
                        />
137
                    </th>
138
                </tr> }
139
            </tbody>
140
        </table>;
141
    }
142
}
143
144
export class CategoryRuleEditor extends RuleEditor {
145
    get scopeName() { return "branchcode_categorycode"; }
146
    get ruleKey() { return "categorycode"; }
147
    get _dropDownCaption() { return __("Choose category..."); }
148
149
    get _allChoices() {
150
        return Object.keys( Koha.PATRON_CATEGORIES ).map( categorycode => [ categorycode, Koha.PATRON_CATEGORIES[categorycode].description ] );
151
    }
152
153
    get _configuredChoices() {
154
        return _.uniq( this._ownRules.map( rule => rule.categorycode ) );
155
    }
156
}
157
158
// This has to be slightly differently set up than the CategoryRuleEditor, because it's used for
159
// both branch/itemtype and branch/category/itemtype rules.
160
export class ItemTypeRuleEditor extends RuleEditor {
161
    static get defaultProps() {
162
        return { scopeName: "branchcode_itemtype" };
163
    }
164
165
    get scopeName() { return this.props.scopeName; }
166
    get ruleKey() { return "itemtype"; }
167
    get _dropDownCaption() { return __("Choose item type..."); }
168
169
    get _allChoices() {
170
        return Object.keys( Koha.ITEM_TYPES ).map( itemtype => [ itemtype, Koha.ITEM_TYPES[itemtype].translated_description ] );
171
    }
172
173
    get _configuredChoices() {
174
        return _.uniq( this._ownRules.map( rule => rule.itemtype ) );
175
    }
176
}
177
178
export class CategoryItemTypeRuleEditor extends React.Component {
179
    get branchname() {
180
        return this.props.branch ? this.props.branch.branchname : null;
181
    }
182
183
    render() {
184
        const props = this.props;
185
        var defaults = props.category == null;
186
187
        return <section className={ "category-rules" + ( defaults ? " category-default-rules" : "" ) }>
188
            <h4>{ defaults ? __("Defaults for all categories") : props.category.description }</h4>
189
            <div className="table-scroll-wrapper">
190
                <ItemTypeRuleEditor
191
                    branch={props.branch}
192
                    kindGroup={props.kindGroup}
193
                    category={props.category}
194
                    scopeName="branchcode_categorycode_itemtype"
195
                    onChangeRule={props.onChangeRule}
196
                    onFocusRule={props.onFocusRule}
197
                />
198
            </div>
199
        </section>;
200
    }
201
}
202
203
function BranchGlobalRuleEditor( { branch, kindGroup, onChangeRule, onFocusRule } ) {
204
    let allKinds = KINDS_BY_SCOPE["branchcode"];
205
    let allKindNames = allKinds.filter( rule => !kindGroup || RULE_KINDS[ rule.name ].group == kindGroup ).map( rule => rule.name );
206
207
    return <table className="table">
208
        <thead>
209
            <tr>
210
                { !allKindNames.length ? <AllHiddenHeader /> : allKindNames.map( rule_name =>
211
                    <th key={rule_name}>{ RULE_KINDS[rule_name].description }</th>
212
                ) }
213
            </tr>
214
        </thead>
215
        <tbody>
216
            <tr>
217
                { allKindNames.map( rule_name => <td key={rule_name}>
218
                    <RuleCell
219
                        key={rule_name}
220
                        ruleProperties={{
221
                            branchcode: branch ? branch.branchcode : "",
222
                            rule_name
223
                        }}
224
                        onChangeRule={onChangeRule}
225
                        onFocusRule={onFocusRule}
226
                    />
227
                </td> ) }
228
            </tr>
229
        </tbody>
230
    </table>;
231
}
232
233
export class BranchRuleEditor extends React.Component {
234
    constructor( props ) {
235
        super( props );
236
        this.state = {
237
            visibleCategories: _.union( [ "" ], _.uniq( rules.getAllDefined( { branchcode: this.branchcode, scope_name: "branchcode_categorycode_itemtype" } ) ).map( rule => rule.categorycode ) ),
238
        };
239
    }
240
241
    get branchcode() {
242
        return this.props.branch ? this.props.branch.branchcode : "";
243
    }
244
245
    addCategory( choice ) {
246
        this.setState( {
247
            visibleCategories: this.state.visibleCategories.concat( [ choice ] ),
248
        } );
249
    }
250
251
    render() {
252
        const { props, state } = this;
253
        var defaults = props.branch == null;
254
255
        let allCategories = Object.keys( Koha.PATRON_CATEGORIES ).map( categorycode => [ categorycode, Koha.PATRON_CATEGORIES[categorycode].description ] );
256
        let availableCategories = _( allCategories )
257
            .filter( ( [ categorycode ] ) => !state.visibleCategories.includes( categorycode ) )
258
            .sortBy( 1 )
259
            .value();
260
        let categoryDescriptions = _.fromPairs( allCategories );
261
        let visibleCategories = _.sortBy( state.visibleCategories, category => category ? categoryDescriptions[category] : "" );
262
263
        return <section className={ "branch-rules" + ( defaults ? " default-rules" : "" ) }>
264
            <h2>{ defaults ? __( "Defaults for all libraries" ) : props.branch.branchname }</h2>
265
            <h3>{__( "Rules only by library" )}</h3>
266
            <BranchGlobalRuleEditor
267
                branch={props.branch}
268
                kindGroup={props.kindGroup}
269
                onChangeRule={props.onChangeRule}
270
                onFocusRule={props.onFocusRule}
271
            />
272
            <CategoryRuleEditor
273
                caption={__( "Rules by category" )}
274
                branch={props.branch}
275
                kindGroup={props.kindGroup}
276
                onChangeRule={props.onChangeRule}
277
                onFocusRule={props.onFocusRule}
278
            />
279
            <ItemTypeRuleEditor
280
                caption={__( "Rules by item type" )}
281
                branch={props.branch}
282
                kindGroup={props.kindGroup}
283
                onChangeRule={props.onChangeRule}
284
                onFocusRule={props.onFocusRule}
285
            />
286
            <h3>{__( "Rules by category and item type" )}</h3>
287
            { visibleCategories.map( categorycode =>
288
                <CategoryItemTypeRuleEditor
289
                    key={categorycode}
290
                    branch={props.branch}
291
                    kindGroup={props.kindGroup}
292
                    category={categorycode ? Koha.PATRON_CATEGORIES[categorycode] : null}
293
                    onChangeRule={props.onChangeRule}
294
                    onFocusRule={props.onFocusRule}
295
                />
296
            ) }
297
            {_.isEmpty( availableCategories ) ||
298
            <h4>
299
                <DropDown
300
                    caption={__("Define item-type rules for category...")}
301
                    value=""
302
                    choices={ availableCategories }
303
                    onChange={ choice => this.addCategory(choice) }
304
                />
305
            </h4> }
306
        </section>;
307
    }
308
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/src/admin/policy/rules.js (-1 / +151 lines)
Line 0 Link Here
0
- 
1
"use strict";
2
3
import _ from "lodash";
4
5
import { RULE_KINDS } from "./core";
6
7
export default ( window.rules = new class {
8
    constructor() {
9
        this.rules = {};
10
        this.changedRules = {};
11
    }
12
13
    load( ruleData ) {
14
        // NOTE: We use "" within the frontend, as distinguishing "null" and null gets too funky.
15
        for ( let { branchcode, categorycode, itemtype, rule_name, rule_value } of ruleData ) {
16
            this.set( {
17
                branchcode: branchcode || "",
18
                categorycode: categorycode || "",
19
                itemtype: itemtype || "",
20
                rule_name,
21
                rule_value
22
            } );
23
        }
24
25
        this.clearChangedRules();
26
    }
27
28
    // Find all the defined rules for a given set of criteria.
29
    getAllDefined( { branchcode, categorycode, itemtype, scope_name } ) {
30
        let result = [];
31
32
        for ( let ruleBranchCode of (
33
            branchcode === undefined ?
34
                Object.keys( this.rules ) :
35
                [ branchcode ]
36
        ) ) {
37
            if ( !this.rules[ruleBranchCode] ) continue;
38
39
            for ( let ruleCategoryCode of (
40
                categorycode === undefined ?
41
                    Object.keys( this.rules[ruleBranchCode] ) :
42
                    [ categorycode ]
43
            ) ) {
44
                if ( !this.rules[ruleBranchCode][ruleCategoryCode] ) continue;
45
46
                for ( let ruleItemType of (
47
                    itemtype === undefined ?
48
                        Object.keys( this.rules[ruleBranchCode][ruleCategoryCode] ) :
49
                        [ itemtype ]
50
                ) ) {
51
52
                    if ( !this.rules[ruleBranchCode][ruleCategoryCode][ruleItemType] ) continue;
53
54
                    for ( let [ rule_name, rule_value ] of Object.entries( this.rules[ruleBranchCode][ruleCategoryCode][ruleItemType] ) ) {
55
                        if ( scope_name !== undefined && scope_name != RULE_KINDS[rule_name].scope_name ) continue;
56
57
                        result.push( {
58
                            rule_name,
59
                            rule_value,
60
61
                            branchcode: ruleBranchCode,
62
                            categorycode: ruleCategoryCode,
63
                            itemtype: ruleItemType,
64
                        } );
65
                    }
66
                }
67
            }
68
        }
69
70
        return result;
71
    }
72
73
    lookup( { branchcode, categorycode, itemtype, rule_name } ) {
74
        for ( let ruleBranchCode of [ branchcode, "" ] ) {
75
            if ( !this.rules[ruleBranchCode] ) continue;
76
77
            for ( let ruleCategoryCode of [ categorycode, "" ] ) {
78
                if ( !this.rules[ruleBranchCode][ruleCategoryCode] ) continue;
79
80
                for ( let ruleItemType of [ itemtype, "" ] ) {
81
                    let itemTypeRules = this.rules[ruleBranchCode][ruleCategoryCode][ruleItemType];
82
83
                    if( itemTypeRules && itemTypeRules[rule_name] !== undefined ) {
84
                        return {
85
                            rule_name: rule_name,
86
                            rule_value: itemTypeRules[rule_name],
87
88
                            branchcode: ruleBranchCode,
89
                            categorycode: ruleCategoryCode,
90
                            itemtype: ruleItemType,
91
                        };
92
                    }
93
                }
94
            }
95
        }
96
97
        return {
98
            branchcode: null,
99
            categorycode: null,
100
            itemtype: null,
101
102
            rule_name,
103
            rule_value: RULE_KINDS[rule_name].default_value
104
        };
105
    }
106
107
    set( { branchcode, categorycode, itemtype, rule_name, rule_value } ) {
108
        _.set( this.rules, [branchcode, categorycode, itemtype, rule_name ], rule_value );
109
        _.set( this.changedRules, [branchcode, categorycode, itemtype, rule_name ], rule_value );
110
    }
111
112
    unset( { branchcode, categorycode, itemtype, rule_name } ) {
113
        _.unset( this.rules, [branchcode, categorycode, itemtype, rule_name ] );
114
        _.set( this.changedRules, [branchcode, categorycode, itemtype, rule_name ], null );
115
    }
116
117
    // Returns a list of changed rules (with defaults as null, per the backend).
118
    pullChangedRules() {
119
        let changedRules = [];
120
121
        for ( let [ ruleBranchCode, branchRules ] of Object.entries( this.changedRules ) ) {
122
            for ( let [ ruleCategoryCode, categoryRules ] of Object.entries( branchRules ) ) {
123
                for ( let [ ruleItemType, itemtypeRules ] of Object.entries( categoryRules ) ) {
124
                    for ( let [ rule_name, rule_value ] of Object.entries( itemtypeRules ) ) {
125
                        changedRules.push( {
126
                            branchcode: ruleBranchCode || null,
127
                            categorycode: ruleCategoryCode || null,
128
                            itemtype: ruleItemType || null,
129
                            rule_name,
130
                            rule_value
131
                        } );
132
                    }
133
                }
134
            }
135
        }
136
137
        return changedRules;
138
    }
139
140
    hasChangedRules() {
141
        return !_.isEmpty( this.changedRules );
142
    }
143
144
    isChangedRule( { branchcode, categorycode, itemtype, rule_name } ) {
145
        return _.has( this.changedRules, [branchcode, categorycode, itemtype, rule_name ] );
146
    }
147
148
    clearChangedRules() {
149
        this.changedRules = {};
150
    }
151
} );

Return to bug 15522