From 0aeeafba2826b3902b4bfc6c50c498204419aa27 Mon Sep 17 00:00:00 2001
From: Agustin Moyano <agustinmoyano@theke.io>
Date: Tue, 20 Oct 2020 13:32:03 -0300
Subject: [PATCH] Bug 15522: (Vue alternative) Add vue scripts

This patch adds vue scripts for circulation rules

To test:
1. Apply all patches
2. restart_all
3. yarn install
4. yarn run build
5. go to circulation rules in staff interface

You'll notice the working area is divided in 3 parts..

On the left you've got a list area. That list area has a combo where you can choose
between libraries, patron categories and item types. What you choose
there are the rules you'll be watching and editing.

The middle area is where rules are shown. That area is divided by circulation rules, fines
rules and hold rules, and each of that sections are divided by rules
specific for libraries, libraries and categories, libraries and
item types or libraries, categories and item types. To edit a rule click
on the pencil icon in the section title. If you're editing default
values (Values for all libraries, categories or item types) the
corresponding edit control (input, select, etc) is shown. If you are
editing for a particular library, category or item type, above the
editing control you get a checkbox to use default value or to overwrite
that rule.

In the area to the right you've got a label with the currently selected
library, category or item type. If when selecting items in the list area
you do ctrl+click (cmd+click on mac) you can select multiple elements
from the list. All selected elements will appear in the right area.
Notice that only one of them is still selected. You can rapidly change
from one selected item to the other by clicking on those labels
---
 .../js/src/admin/components/datepicker.vue    |  33 ++
 .../admin/policy/components/policyMain.vue    | 351 ++++++++++++++++++
 .../admin/policy/components/policySidebar.vue | 102 +++++
 .../admin/policy/components/policyTags.vue    |  68 ++++
 .../prog/js/src/admin/policy/main.d.ts        |  22 ++
 .../prog/js/src/admin/policy/main.ts          |  73 ++++
 6 files changed, 649 insertions(+)
 create mode 100644 koha-tmpl/intranet-tmpl/prog/js/src/admin/components/datepicker.vue
 create mode 100644 koha-tmpl/intranet-tmpl/prog/js/src/admin/policy/components/policyMain.vue
 create mode 100644 koha-tmpl/intranet-tmpl/prog/js/src/admin/policy/components/policySidebar.vue
 create mode 100644 koha-tmpl/intranet-tmpl/prog/js/src/admin/policy/components/policyTags.vue
 create mode 100644 koha-tmpl/intranet-tmpl/prog/js/src/admin/policy/main.d.ts
 create mode 100644 koha-tmpl/intranet-tmpl/prog/js/src/admin/policy/main.ts

diff --git a/koha-tmpl/intranet-tmpl/prog/js/src/admin/components/datepicker.vue b/koha-tmpl/intranet-tmpl/prog/js/src/admin/components/datepicker.vue
new file mode 100644
index 0000000000..60fdf9605e
--- /dev/null
+++ b/koha-tmpl/intranet-tmpl/prog/js/src/admin/components/datepicker.vue
@@ -0,0 +1,33 @@
+<template>
+    <input type="text" :class="{[random]: true}" :value="date" style="width: calc(100% - 16px); display: inline-block"/>
+</template>
+<script lang="ts">
+import {defineComponent} from 'vue'
+export default defineComponent({
+    props: [
+        'date'
+    ],
+    name: 'DatePicker',
+    computed: {
+        random() {
+            return 'rnd_'+Math.round(Math.random() * 1000)
+        }
+    },
+    mounted() {
+        const self = this;
+        this.$nextTick(()=>{
+            $('.'+self.random).datepicker({
+                onClose: (dateText, inst) => {
+                    validate_date(dateText, inst)
+                }
+            }).on("change", function () {
+                if (!is_valid_date($(this).val())) {
+                    $(this).val("")
+                }
+                self.$emit('update:date', $(this).val())
+                self.$emit('change');
+            });
+        })
+    }
+})
+</script>
\ No newline at end of file
diff --git a/koha-tmpl/intranet-tmpl/prog/js/src/admin/policy/components/policyMain.vue b/koha-tmpl/intranet-tmpl/prog/js/src/admin/policy/components/policyMain.vue
new file mode 100644
index 0000000000..901dbdbbaa
--- /dev/null
+++ b/koha-tmpl/intranet-tmpl/prog/js/src/admin/policy/components/policyMain.vue
@@ -0,0 +1,351 @@
+<template>
+    <div>
+        <div class="group" v-for="group in groups" :key="group.key">
+            <div  class="row">
+                <h2 class="col-md-9">{{group.title}}</h2>
+                <div class="col-md-3">
+                    <button @click="group.expand = !group.expand">{{group.expand ? collapseText : expandText}}</button>
+                </div>
+            </div>
+            <div class="text-muted">
+                {{group.description}}
+            </div>
+            <form v-if="group.expand" class="form-horizontal">
+                <template v-for="panel in group.panels" :key="panel.key">
+                    <div class="panel panel-default" v-if="has_kinds(group.key, panel.key)">
+                        <div class="panel-heading">
+                            <div class="row">
+                                <div class="col-md-11">{{self[panel.key+'_panel_heading']}}</div>
+                                <div class="col-md-1">
+                                    <span class="fa fa-pencil" @click="panel.edit = !panel.edit"></span>
+                                </div>
+                            </div>
+                        </div>
+                        <div class="panel-body">
+                            <div v-for="(rule, key) in effective_rules[group.key][panel.key]" :key="key" class="form-group">
+                                <label for="{{key}}" class="col-md-3 control-label">{{rule.kind.description}}</label>
+                                <div class="col-md-9" v-if="!panel.edit">
+                                    <div class="form-control-static">{{get_rule_value(rule)}}</div>
+                                    <div class="text-muted" v-if="rule.rule._use_default && !rule.is_default">Using default value</div>
+                                </div>
+                                <div class="col-md-9" v-if="panel.edit">
+                                    <template v-if="!rule.is_default">
+                                        <div>
+                                            <input type="checkbox" :id="key+'_default'" v-model="rule.rule._use_default" />Use default value ({{get_default_value(rule)}})
+                                        </div>
+                                        <div>
+                                            <input v-if="!rule.kind.choices && rule.kind.type != 'date'" :readonly="rule.rule._use_default" :disabled="rule.rule._use_default" type="text" :id="key" v-model="rule.rule.rule_value" @change="edit(rule.rule)" class="form-control" :class="{datepicker: rule.kind.type == 'date'}"/>
+                                            <date-picker v-if="!rule.kind.choices && rule.kind.type == 'date'" :readonly="rule.rule._use_default" :disabled="rule.rule._use_default" :id="key" v-model:date="rule.rule.rule_value" @change="edit(rule.rule)" class="form-control"/>
+                                            <select v-if="rule.kind.choices" :readonly="rule.rule._use_default" :disabled="rule.rule._use_default" :id="key" v-model="rule.rule.rule_value" @change="edit(rule.rule)" class="form-control">
+                                                <option v-for="choice in rule.kind.choices" :key="choice[0]" :value="choice[0]">{{choice[1]}}</option>
+                                            </select>
+                                        </div>
+                                    </template>
+                                    <template v-if="rule.is_default">
+                                        <input v-if="!rule.kind.choices && rule.kind.type != 'date'" type="text" :id="key" v-model="rule.default.rule_value" @change="edit(rule.default)" class="form-control" :class="{datepicker: rule.kind.type == 'date'}"/>
+                                        <date-picker v-if="!rule.kind.choices && rule.kind.type == 'date'" :id="key" v-model:date="rule.default.rule_value" @change="edit(rule.default)" class="form-control"/>
+                                        <select v-if="rule.kind.choices" :id="key" v-model="rule.default.rule_value" @change="edit(rule.default)" class="form-control">
+                                            <option v-for="choice in rule.kind.choices" :key="choice[0]" :value="choice[0]">{{choice[1]}}</option>
+                                        </select>
+                                    </template>
+
+                                </div>
+                            </div>
+                        </div>
+                    </div>
+                </template>
+            </form>
+        </div>
+    </div>
+</template>
+<script lang="ts">
+    import { defineComponent, readonly, reactive } from 'vue'
+    import DatePicker from '../../components/datepicker.vue'
+
+    export default defineComponent({
+        name: 'PolicyMain',
+        components: {
+            DatePicker
+        },
+        props: [
+            'current',
+            'libraries',
+            'categories',
+            'itemtypes',
+            'doSave',
+            'doClear'
+        ],
+        data() {
+            return {
+                self: this,
+                expandText: _('Expand'),
+                collapseText: _('Collapse'),
+                kinds: {},
+                origRules: {},
+                rules: {},
+                groups: [
+                    {
+                        key: 'circulation',
+                        title: _("Circulation rules"),
+                        description: _("A brief and tiny text that explains how awesome and great cirulation rules are"),
+                        expanded: false,
+                        panels: [
+                            {
+                                scope: ['branchcode'],
+                                key: 'lib'
+                            },
+                            {
+                                scope: ['branchcode', 'categorycode'],
+                                key: 'cat'
+                            },
+                            {
+                                scope: ['branchcode', 'itemtype'],
+                                key: 'itype'
+                            },
+                            {
+                                scope: ['branchcode', 'categorycode', 'itemtype'],
+                                key: 'all'
+                            }
+                        ]
+                    },
+                    {
+                        key: 'fines',
+                        title: _("Fines rules"),
+                        description: _("A long and tedious text that explains how deadly serious fines rules are"),
+                        expanded: false,
+                        panels: [
+                            {
+                                scope: ['branchcode'],
+                                key: 'lib'
+                            },
+                            {
+                                scope: ['branchcode', 'categorycode'],
+                                key: 'cat'
+                            },
+                            {
+                                scope: ['branchcode', 'itemtype'],
+                                key: 'itype'
+                            },
+                            {
+                                scope: ['branchcode', 'categorycode', 'itemtype'],
+                                key: 'all'
+                            }
+                        ]
+                    },
+                    {
+                        key: 'holds',
+                        title: _("Holds rules"),
+                        description: _("A light text that explains why you cannot live without holds rules"),
+                        expanded: false,
+                        panels: [
+                            {
+                                scope: ['branchcode'],
+                                key: 'lib'
+                            },
+                            {
+                                scope: ['branchcode', 'categorycode'],
+                                key: 'cat'
+                            },
+                            {
+                                scope: ['branchcode', 'itemtype'],
+                                key: 'itype'
+                            },
+                            {
+                                scope: ['branchcode', 'categorycode', 'itemtype'],
+                                key: 'all'
+                            }
+                        ]
+                    },
+                ],
+                edited: []
+            }
+        },
+        async created() {
+            this.kinds = await fetch( "/api/v1/circulation-rules/kinds" ).then( result => result.json() )
+            this.rules = await fetch( "/api/v1/circulation-rules" ).then( result => result.json() )
+            this.origRules = readonly(JSON.parse(JSON.stringify(this.rules)));
+        },
+        computed: {
+            lib_panel_heading() {
+                return 'For '+this.get_current('lib')
+            },
+            cat_panel_heading() {
+                return 'For '+this.get_current('lib')+' and '+this.get_current('cat')
+            },
+            itype_panel_heading() {
+                return 'For '+this.get_current('lib')+' and '+this.get_current('itype')
+            },
+            all_panel_heading() {
+                return 'For '+this.get_current('lib')+', '+this.get_current('cat')+' and '+this.get_current('itype')
+            },
+            effective_rules() {
+                const groups = {}
+                const missing_rules = [];
+                this.groups.forEach(group => {
+                    groups[group.key] = {},
+                    group.panels.forEach(panel => {
+                        groups[group.key][panel.key] = {}
+                        Object.keys(this.kinds).filter(key => {
+                            const kind = this.kinds[key]
+                            if(!kind.description) return;
+                            if(kind.group == group.key && !this.simetric_diff(kind.scope, panel.scope).length) {
+                                groups[group.key][panel.key][key] = {kind}
+                                const rules = this.rules
+                                    .filter(rule => rule.rule_name == key)
+                                rules.forEach(rule => {
+                                        if(rule.branchcode == null) rule.branchcode = '';
+                                        if(rule.categorycode == null) rule.categorycode = '';
+                                        if(rule.itemtype == null) rule.itemtype = '';
+                                    })
+                                groups[group.key][panel.key][key].default = rules.find(rule => rule.branchcode == '' && rule.categorycode == '' && rule.itemtype == '')
+                                if(!groups[group.key][panel.key][key].default) {
+                                    missing_rules.push({
+                                        rule_value: '',
+                                        rule_name: key,
+                                        branchcode: '',
+                                        categorycode: '',
+                                        itemtype: ''
+                                    })
+                                }
+                                if(panel.key == 'lib' && this.current.lib == '') {
+                                    groups[group.key][panel.key][key].is_default = true
+                                } else if(panel.key == 'cat' && this.current.lib == '' && this.current.cat == '') {
+                                    groups[group.key][panel.key][key].is_default = true
+                                } else if(panel.key == 'itype' && this.current.lib == '' && this.current.itype == '') {
+                                    groups[group.key][panel.key][key].is_default = true
+                                } else if(panel.key == 'all' && this.current.lib == '' && this.current.cat == '' && this.current.itype == '') {
+                                    groups[group.key][panel.key][key].is_default = true
+                                } else {
+                                    groups[group.key][panel.key][key].is_default = false
+                                }
+
+                                groups[group.key][panel.key][key].rule = rules.find(rule => rule.branchcode == this.current.lib && rule.categorycode == this.current.cat && rule.itemtype == this.current.itype)
+                                if(!groups[group.key][panel.key][key].rule) {
+                                    missing_rules.push({
+                                        rule_name: key,
+                                        rule_value: null,
+                                        branchcode: this.current.lib,
+                                        categorycode: this.current.cat,
+                                        itemtype: this.current.itype,
+                                        _use_default: true
+                                    });
+                                }
+                            }
+                        })
+                    })
+                })
+                if(missing_rules.length) {
+                    this.$nextTick(()=>{
+                        this.rules = this.rules.concat(missing_rules)
+                    })
+                    return null
+                }
+                return groups
+            }
+        },
+        methods: {
+            simetric_diff(left, right) {
+                const rightSet = new Set(right)
+                const leftSet = new Set(left)
+                return left.filter(item => !rightSet.has(item)).concat(right.filter(item => !leftSet.has(item)))
+            },
+            has_kinds(group, panel) {
+                return this.effective_rules && this.effective_rules[group] && this.effective_rules[group][panel] && Object.keys(this.effective_rules[group][panel]).length
+            },
+            get_default_value(rule) {
+                if(!rule.default) {
+                   return ''
+                }
+                if(rule.kind.choices && rule.kind.choices) {
+                    const choice = rule.kind.choices.find(choice => choice[0]==rule.default.rule_value)
+                    if(choice) return choice[1]
+                }
+                return rule.default.rule_value
+            },
+            get_rule_value(rule) {
+                if(rule.rule._use_default) return this.get_default_value(rule);
+                if(rule.kind.choices && rule.kind.choices) {
+                    const choice = rule.kind.choices.find(choice => choice[0]==rule.rule.rule_value)
+                    if(choice) return choice[1]
+                }
+                return rule.rule.rule_value
+            },
+            get_current(type) {
+                if(type == 'lib') {
+                    let curr_name = this.libraries.find(lib => lib.code == this.current.lib).name
+                    return this.current[type]==''?curr_name.toLowerCase():curr_name
+                }
+                if(type == 'cat') {
+                    let curr_name = this.categories.find(cat => cat.code == this.current.cat).name
+                    return this.current[type]==''?curr_name.toLowerCase():_('%s category').format(curr_name)
+                }
+                if(type == 'itype') {
+                    let curr_name = this.itemtypes.find(itype => itype.code == this.current.itype).name
+                    return this.current[type]==''?curr_name.toLowerCase():_('%s item type').format(curr_name)
+                }
+            },
+            edit(rule) {
+                if(!this.edited.includes(rule)) this.edited.push(rule)
+            },
+            clear() {
+                this.rules = reactive(JSON.parse(JSON.stringify(this.origRules)))
+                this.edited = []
+            },
+            save() {
+                const payload = this.edited
+                    .filter(rule => {
+                        return rule.id || !rule._use_default
+                    })
+                    .map(origRule => {
+                        const rule = {
+                            rule_name: origRule.rule_name,
+                            rule_value: origRule.id && origRule._use_default?null:origRule.rule_value,
+                            branchcode: origRule.branchcode == ''?null:origRule.branchcode,
+                            categorycode: origRule.categorycode == ''?null:origRule.categorycode,
+                            itemtype: origRule.itemtype == ''?null:origRule.itemtype
+                        };
+                        if(origRule.id) rule.id = origRule.id
+
+                        return rule
+                    })
+                fetch( "/api/v1/circulation-rules", {
+                    method: "POST",
+                    credentials: "include",
+                    body: JSON.stringify(payload),
+                } ).then( response => {
+                    switch( response.status ) {
+                        case 200:
+                            this.edited = []
+                            this.origRules = readonly(JSON.parse(JSON.stringify(this.rules)))
+                            humanMsg.displayMsg( '<h3>'+_("Rules saved.")+'</h3>', { className: "humanSuccess" } )
+                            break
+                        case 401:
+                            humanMsg.displayAlert( '<h3>'+_("Rules not saved")+'</h3><p>'+_("Please reload the page to log in again")+'</p>', { className: "humanError" } )
+                            break
+                        default:
+                            humanMsg.displayAlert( '<h3>'+_("Rules not saved")+'</h3><p>'+_("Internal error")+'</p>', { className: "humanError" } )
+                            break
+                    }
+                } )
+            }
+        },
+        watch: {
+            doSave(val, oldVal) {
+                if(val) {
+                    this.save()
+                }
+            },
+            doClear(val, oldVal) {
+                if(val) {
+                    this.clear()
+                }
+            },
+            edited: {
+                handler(val, oldVal) {
+                this.$emit('edited', val)
+                },
+                deep: true
+            }
+        }
+    })
+</script>
\ No newline at end of file
diff --git a/koha-tmpl/intranet-tmpl/prog/js/src/admin/policy/components/policySidebar.vue b/koha-tmpl/intranet-tmpl/prog/js/src/admin/policy/components/policySidebar.vue
new file mode 100644
index 0000000000..51cf2798a2
--- /dev/null
+++ b/koha-tmpl/intranet-tmpl/prog/js/src/admin/policy/components/policySidebar.vue
@@ -0,0 +1,102 @@
+<template>
+    <div>
+        <form>
+            <div class="form-group">
+                <label for="choose" class="control-label">Choose list</label>
+                <select v-model="choose" id="choose" class="form-control">
+                    <option value="lib">Library</option>
+                    <option value="cat">Patron category</option>
+                    <option value="itype">Item type</option>
+                </select>
+            </div>
+            <div class="form-group">
+                <label for="filter" class="control-label">Filter list</label>
+                <input type="text" id="filter" v-model="filter"  class="form-control"/>
+            </div>
+        </form>
+        <div class="list-group">
+            <a class="list-group-item" v-for="item in list" :key="item.code" :class="{active: is_selected(item.code)}" @click.exact="do_select(item.code)" @click.ctrl="add_select(item.code)" @click.meta="add_select(item.code)">
+                {{item.name}}
+            </a>
+        </div>
+    </div>
+</template>
+<script lang="ts">
+/// <reference path="../main.d.ts" />
+    import { defineComponent } from 'vue'
+    export default defineComponent({
+        name: 'PolicySidebar',
+        props: [
+            'current',
+            'select',
+            'libraries',
+            'categories',
+            'itemtypes'
+        ],
+        data() {
+            return {
+                choose: 'lib',
+                filter: null,
+                libList: [],
+                catList: [],
+                itypeList: [],
+                localCurrent: {
+                    lib: '',
+                    cat: '',
+                    itype: ''
+                },
+                localSelected: {
+                    lib: [''],
+                    cat: [''],
+                    itype: ['']
+                }
+            }
+        },
+        created() {
+            this.$emit('update:current', this.localCurrent)
+            this.$emit('update:select', this.localSelected)
+            this.libList = this.libraries
+            this.catList = this.categories
+            this.itypeList = this.itemtypes
+        },
+        methods: {
+            do_select(code) {
+                this.localCurrent[this.choose] = code
+                this.localSelected[this.choose] = [code]
+                this.$emit('update:current', this.localCurrent)
+                this.$emit('update:select', this.localSelected)
+            },
+            add_select(code) {
+                this.localSelected[this.choose].push(code)
+                this.$emit('update:select', this.localSelected)
+            },
+            is_selected(code) {
+                return this.localSelected[this.choose].includes(code)
+            }
+        },
+        computed: {
+            list() {
+                if(this.choose == 'lib') return this.libList.filter(item => item.show)
+                if(this.choose == 'cat') return this.catList.filter(item => item.show)
+                return this.itypeList.filter(item => item.show)
+            }
+        },
+        watch: {
+            choose() {
+                this.filter = null;
+            },
+            filter() {
+                if(this.filter == null) {
+                    this.libList.forEach(item => item.show = true)
+                    this.catList.forEach(item => item.show = true)
+                    this.itypeList.forEach(item => item.show = true)
+                } else {
+                    let reg = new RegExp(this.filter, 'i')
+                    this.libList.forEach(item => item.show = reg.test(item.name))
+                    this.catList.forEach(item => item.show = reg.test(item.name))
+                    this.itypeList.forEach(item => item.show = reg.test(item.name))
+                }
+            }
+        }
+    })
+</script>
\ No newline at end of file
diff --git a/koha-tmpl/intranet-tmpl/prog/js/src/admin/policy/components/policyTags.vue b/koha-tmpl/intranet-tmpl/prog/js/src/admin/policy/components/policyTags.vue
new file mode 100644
index 0000000000..1ddb64aacb
--- /dev/null
+++ b/koha-tmpl/intranet-tmpl/prog/js/src/admin/policy/components/policyTags.vue
@@ -0,0 +1,68 @@
+<template>
+    <div>
+        <div class="panel panel-default">
+            <div class="panel-heading">
+                <h3 class="panel-title">Libraries</h3>
+            </div>
+            <div class="panel-body">
+                <div class="container-fluid">
+                    <div class="row">
+                        <span class="label col-md-6" v-for="lib in select.lib" :key="lib" :class="{'label-primary': is_current('lib', lib), 'label-default': !is_current('lib', lib)}" @click="set_current('lib', lib)">{{get_name('lib', lib)}}</span>
+                    </div>
+                </div>
+            </div>
+        </div>
+        <div class="panel panel-default">
+            <div class="panel-heading">
+                <h3 class="panel-title">Patron categories</h3>
+            </div>
+            <div class="panel-body">
+                <div class="container-fluid">
+                    <div class="row">
+                        <span class="label col-md-6" v-for="cat in select.cat" :key="cat" :class="{'label-primary': is_current('cat', cat), 'label-default': !is_current('cat', cat)}" @click="set_current('cat', cat)">{{get_name('cat', cat)}}</span>
+                    </div>
+                </div>
+            </div>
+        </div>
+        <div class="panel panel-default">
+            <div class="panel-heading">
+                <h3 class="panel-title">Item types</h3>
+            </div>
+            <div class="panel-body">
+                <div class="container-fluid">
+                    <div class="row">
+                        <span class="label col-md-6" v-for="itype in select.itype" :key="itype" :class="{'label-primary': is_current('itype', itype), 'label-default': !is_current('itype', itype)}" @click="set_current('itype', itype)">{{get_name('itype', itype)}}</span>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+</template>
+<script lang="ts">
+/// <reference path="../main.d.ts" />
+    import { defineComponent } from 'vue'
+    export default defineComponent({
+        name: 'PolicyTags',
+        props: [
+            'current',
+            'select',
+            'libraries',
+            'categories',
+            'itemtypes'
+        ],
+        methods: {
+            is_current(type, code) {
+                return this.current[type] == code;
+            },
+            set_current(type, code) {
+                this.current[type] = code
+                this.$emit('update:current', this.current)
+            },
+            get_name(type, code) {
+                if(type == 'lib') return this.libraries.find(item => item.code == code).name
+                if(type == 'cat') return this.categories.find(item => item.code == code).name
+                if(type == 'itype') return this.itemtypes.find(item => item.code == code).name
+            }
+        }
+    })
+</script>
\ No newline at end of file
diff --git a/koha-tmpl/intranet-tmpl/prog/js/src/admin/policy/main.d.ts b/koha-tmpl/intranet-tmpl/prog/js/src/admin/policy/main.d.ts
new file mode 100644
index 0000000000..3ad6aec7e3
--- /dev/null
+++ b/koha-tmpl/intranet-tmpl/prog/js/src/admin/policy/main.d.ts
@@ -0,0 +1,22 @@
+declare function _ (text: string, ...params: any[]): string
+declare function $ (text: string, ...params: any[]): any
+declare function validate_date (date: string, obj: object): void
+declare function is_valid_date (date: string): boolean
+
+type KOHA = {
+    BRANCHES: object,
+    ITEM_TYPES: object,
+    PATRON_CATEGORIES: object
+}
+
+declare interface String {
+    format(...string):string
+}
+
+type HUMANMSG = {
+    displayMsg(string, any): void,
+    displayAlert(string, any): void
+}
+
+declare var Koha: KOHA
+ declare var humanMsg:HUMANMSG
\ No newline at end of file
diff --git a/koha-tmpl/intranet-tmpl/prog/js/src/admin/policy/main.ts b/koha-tmpl/intranet-tmpl/prog/js/src/admin/policy/main.ts
new file mode 100644
index 0000000000..7d0b07ff7e
--- /dev/null
+++ b/koha-tmpl/intranet-tmpl/prog/js/src/admin/policy/main.ts
@@ -0,0 +1,73 @@
+/// <reference path="main.d.ts" />
+
+import {createApp} from "vue/dist/vue.esm-bundler";
+// @ts-ignore
+import PolicySidebar from "./components/policySidebar.vue";
+// @ts-ignore
+import PolicyMain from "./components/policyMain.vue";
+// @ts-ignore
+import PolicyTags from "./components/policyTags.vue";
+
+
+createApp({
+    template: `
+    <div class="container-fluid">
+        <div class="row">
+            <h1 class="col-md-10">{{title}}</h1>
+            <div class="col-md-2">
+                <button type="button" class="btn btn-primary" :disabled="!edited.length" @click="doSave = true">Save</button>
+                <button type="button" class="btn btn-default" :disabled="!edited.length" @click="doClear = true">Clear</button>
+            </div>
+        </div>
+        <hr/>
+        <div class="row">
+            <policy-sidebar class="col-md-3" v-model:current="current" v-model:select="selected" :libraries="libraries" :categories="categories" :itemtypes="itemtypes"></policy-sidebar>
+            <policy-main class="col-md-7" :current="current" :libraries="libraries" :categories="categories" :itemtypes="itemtypes" :doSave="doSave" :doClear="doClear" @edited="setEdited"></policy-main>
+            <policy-tags class="col-md-2" v-model:current="current" :select="selected" :libraries="libraries" :categories="categories" :itemtypes="itemtypes"></policy-tags>
+        </div>
+    </div>
+    `,
+    data() {
+        return {
+            title: _('Circulation, fines, and holds rules'),
+            current: {},
+            selected: {},
+            doSave: false,
+            doClear: false,
+            edited: [],
+            saved: false
+        }
+    },
+    computed: {
+        libraries() {
+            const libs = Object.keys(Koha.BRANCHES).map(code => {
+                return {code, name: Koha.BRANCHES[code].branchname, show: true}
+            })
+            return [{code: '', name: _('All libraries'), show: true}].concat(libs)
+        },
+        categories() {
+            const cats = Object.keys(Koha.PATRON_CATEGORIES).map(code => {
+                return {code, name: Koha.PATRON_CATEGORIES[code].description, show: true}
+            })
+            return [{code: '', name: _('All categories'), show: true}].concat(cats)
+        },
+        itemtypes() {
+            const itypes = Object.keys(Koha.ITEM_TYPES).map(code => {
+                return {code, name: Koha.ITEM_TYPES[code].description, show: true}
+            })
+            return [{code: '', name: _('All item types'), show: true}].concat(itypes)
+        }
+    },
+    methods: {
+        setEdited(edited) {
+            this.edited = edited
+            this.doSave = false
+            this.doClear = false
+        }
+    },
+    components: {
+        PolicySidebar,
+        PolicyMain,
+        PolicyTags
+    }
+}).mount("#vue-base");
\ No newline at end of file
-- 
2.25.0