Simple Reporter Angular App
authorJason Boyer <JBoyer@equinoxOLI.org>
Tue, 14 Dec 2021 19:14:35 +0000 (14:14 -0500)
committerMike Rylander <mrylander@gmail.com>
Thu, 24 Mar 2022 19:10:12 +0000 (15:10 -0400)
Simply put, it reports.

Sponsored-by: C/W MARS
Sponsored-by: Missouri Evergreen Consortium

Signed-off-by: Jason Boyer <JBoyer@equinoxOLI.org>
Signed-off-by: rfrasur <rfrasur@library.in.gov>
Signed-off-by: Mike Rylander <mrylander@gmail.com>

26 files changed:
Open-ILS/src/eg2/package-lock.json
Open-ILS/src/eg2/package.json
Open-ILS/src/eg2/src/app/staff/reporter/routing.module.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/reporter/simple/routing.module.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/reporter/simple/simple-reporter.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/reporter/simple/simple-reporter.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/reporter/simple/simple-reporter.module.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/reporter/simple/simple-reporter.service.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/reporter/simple/sr-editor.component.css [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/reporter/simple/sr-editor.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/reporter/simple/sr-editor.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/reporter/simple/sr-field-chooser.component.css [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/reporter/simple/sr-field-chooser.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/reporter/simple/sr-field-chooser.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/reporter/simple/sr-field.component.css [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/reporter/simple/sr-field.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/reporter/simple/sr-field.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/reporter/simple/sr-my-outputs.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/reporter/simple/sr-my-outputs.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/reporter/simple/sr-my-reports.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/reporter/simple/sr-my-reports.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/reporter/simple/sr-output-options.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/reporter/simple/sr-output-options.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/reporter/simple/sr-sort-order.component.css [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/reporter/simple/sr-sort-order.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/reporter/simple/sr-sort-order.component.ts [new file with mode: 0644]

index 21e6ae4..1c4fb64 100644 (file)
         "postcss-selector-parser": "^6.0.4"
       }
     },
+    "ts-md5": {
+      "version": "1.2.9",
+      "resolved": "https://registry.npmjs.org/ts-md5/-/ts-md5-1.2.9.tgz",
+      "integrity": "sha512-/Efr7ZfGf8P+d9HXh0PLQD1CDipqD8j9apCFG96pODDoEaFLxXpV4En6tAc6y3fWyfhFGrqtNBRBS+eLVIB2uQ=="
+    },
+    "ts-node": {
+      "version": "8.10.2",
+      "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-8.10.2.tgz",
+      "integrity": "sha512-ISJJGgkIpDdBhWVu3jufsWpK3Rzo7bdiIXJjQc0ynKxVOVcg2oIrf2H2cejminGrptVc6q6/uynAHNCuWGbpVA==",
+      "dev": true,
+      "requires": {
+        "browserslist": "^4.16.0",
+        "postcss-selector-parser": "^6.0.4"
+      }
+    },
     "stylus": {
       "version": "0.54.8",
       "resolved": "https://registry.npmjs.org/stylus/-/stylus-0.54.8.tgz",
index a50141f..eac8ef3 100644 (file)
@@ -38,7 +38,8 @@
     "moment-timezone": "^0.5.33",
     "ngx-cookie": "^5.0.2",
     "rxjs": "^6.6.2",
-    "zone.js": "^0.11.4"
+    "zone.js": "^0.11.4",
+    "ts-md5": "^1.2.9"
   },
   "//": "NOTE: version of angular/cli should be kept in sync with Open-ILS/src/extras/install/Makefile.common",
   "devDependencies": {
diff --git a/Open-ILS/src/eg2/src/app/staff/reporter/routing.module.ts b/Open-ILS/src/eg2/src/app/staff/reporter/routing.module.ts
new file mode 100644 (file)
index 0000000..90426cd
--- /dev/null
@@ -0,0 +1,18 @@
+import {NgModule} from '@angular/core';
+import {RouterModule, Routes} from '@angular/router';
+
+const routes: Routes = [
+  { path: 'simple',
+    loadChildren: () =>
+      import('./simple/simple-reporter.module').then(m => m.SimpleReporterModule)
+  }
+];
+
+@NgModule({
+  imports: [RouterModule.forChild(routes)],
+  exports: [RouterModule]
+})
+
+export class ReporterRoutingModule {
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/reporter/simple/routing.module.ts b/Open-ILS/src/eg2/src/app/staff/reporter/simple/routing.module.ts
new file mode 100644 (file)
index 0000000..83fecd3
--- /dev/null
@@ -0,0 +1,40 @@
+import {NgModule} from '@angular/core';
+import {RouterModule, Routes, RouterStateSnapshot, ActivatedRouteSnapshot} from '@angular/router';
+import {SimpleReporterComponent} from './simple-reporter.component';
+import {SREditorComponent} from './sr-editor.component';
+import {SimpleReporterServiceResolver} from './simple-reporter.service';
+
+const routes: Routes = [
+    { path: '',
+        component: SimpleReporterComponent,
+        resolve: { srSvcResolver: SimpleReporterServiceResolver },
+    },
+    { path: 'new',
+        component: SREditorComponent,
+        resolve: { srSvcResolver: SimpleReporterServiceResolver },
+        canDeactivate: ['canLeaveEditor'],
+        runGuardsAndResolvers: 'always'
+    },
+    { path: 'edit/:id',
+        component: SREditorComponent,
+        resolve: { srSvcResolver: SimpleReporterServiceResolver },
+        canDeactivate: ['canLeaveEditor'],
+        runGuardsAndResolvers: 'always'
+    },
+];
+
+@NgModule({
+    imports: [RouterModule.forChild(routes)],
+    exports: [RouterModule],
+    providers: [SimpleReporterServiceResolver,
+        {
+            provide: 'canLeaveEditor',
+            useValue: (component: SREditorComponent, currentRoute: ActivatedRouteSnapshot,
+                currentState: RouterStateSnapshot, nextState: RouterStateSnapshot) => component.canLeaveEditor()
+        }
+    ]
+})
+
+export class SimpleReporterRoutingModule {
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/reporter/simple/simple-reporter.component.html b/Open-ILS/src/eg2/src/app/staff/reporter/simple/simple-reporter.component.html
new file mode 100644 (file)
index 0000000..4723a14
--- /dev/null
@@ -0,0 +1,18 @@
+<eg-staff-banner bannerText="Simple Reports" i18n-bannerText>
+</eg-staff-banner>
+
+<div class="row" id="simple-reporter-main">
+  <div class="col-lg-12">
+    <ul ngbNav #simpleRptTabs="ngbNav" class="nav-tabs">
+      <li [ngbNavItem]="'myreports'">
+        <a ngbNavLink i18n>My Reports</a>
+           <ng-template ngbNavContent><eg-sr-reports></eg-sr-reports></ng-template>
+      </li>
+      <li [ngbNavItem]="'outputs'">
+        <a ngbNavLink i18n>My Outputs</a>
+           <ng-template ngbNavContent><eg-sr-outputs></eg-sr-outputs></ng-template>
+      </li>
+    </ul>
+    <div [ngbNavOutlet]="simpleRptTabs"></div>
+  </div>
+</div>
diff --git a/Open-ILS/src/eg2/src/app/staff/reporter/simple/simple-reporter.component.ts b/Open-ILS/src/eg2/src/app/staff/reporter/simple/simple-reporter.component.ts
new file mode 100644 (file)
index 0000000..dcbc038
--- /dev/null
@@ -0,0 +1,34 @@
+import {Component, OnInit, ViewChild} from '@angular/core';
+import {Router} from '@angular/router';
+import {NgbNav} from '@ng-bootstrap/ng-bootstrap';
+import {StaffCommonModule} from '@eg/staff/common.module';
+import {IdlService, IdlObject} from '@eg/core/idl.service';
+import {AuthService} from '@eg/core/auth.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {SimpleReporterService} from './simple-reporter.service';
+import {SROutputsComponent} from './sr-my-outputs.component';
+
+@Component({
+    templateUrl: './simple-reporter.component.html',
+})
+
+export class SimpleReporterComponent implements OnInit {
+
+    @ViewChild('simpleRptTabs', { static: true }) tabs: NgbNav;
+
+    constructor(
+        private router: Router,
+        private auth: AuthService,
+        private idl: IdlService,
+        private pcrud: PcrudService,
+        private srSvc: SimpleReporterService
+    ) {
+
+    }
+
+
+    ngOnInit() {
+
+    }
+
+}
diff --git a/Open-ILS/src/eg2/src/app/staff/reporter/simple/simple-reporter.module.ts b/Open-ILS/src/eg2/src/app/staff/reporter/simple/simple-reporter.module.ts
new file mode 100644 (file)
index 0000000..c7f56e2
--- /dev/null
@@ -0,0 +1,34 @@
+import {NgModule} from '@angular/core';
+import {StaffCommonModule} from '@eg/staff/common.module';
+import {OrgFamilySelectModule} from '@eg/share/org-family-select/org-family-select.module';
+import {SimpleReporterComponent} from './simple-reporter.component';
+import {SROutputsComponent} from './sr-my-outputs.component';
+import {SRReportsComponent} from './sr-my-reports.component';
+import {SREditorComponent} from './sr-editor.component';
+import {SRFieldChooserComponent} from './sr-field-chooser.component';
+import {SRSortOrderComponent} from './sr-sort-order.component';
+import {SROutputOptionsComponent} from './sr-output-options.component';
+import {SRFieldComponent} from './sr-field.component';
+import {SimpleReporterRoutingModule} from './routing.module';
+
+@NgModule({
+    declarations: [
+        SimpleReporterComponent,
+        SROutputsComponent,
+        SRReportsComponent,
+        SRFieldChooserComponent,
+        SRSortOrderComponent,
+        SROutputOptionsComponent,
+        SRFieldComponent,
+        SREditorComponent
+    ],
+    imports: [
+        StaffCommonModule,
+        SimpleReporterRoutingModule,
+        OrgFamilySelectModule
+    ]
+})
+
+export class SimpleReporterModule {
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/reporter/simple/simple-reporter.service.ts b/Open-ILS/src/eg2/src/app/staff/reporter/simple/simple-reporter.service.ts
new file mode 100644 (file)
index 0000000..1755cb5
--- /dev/null
@@ -0,0 +1,914 @@
+import {Injectable} from '@angular/core';
+import {Router, Resolve, RouterStateSnapshot,
+                ActivatedRouteSnapshot} from '@angular/router';
+import * as moment from 'moment-timezone';
+import {Md5} from 'ts-md5';
+import {EMPTY} from 'rxjs';
+import {map, mergeMap, defaultIfEmpty, last} from 'rxjs/operators';
+import {Observable, of, from} from 'rxjs';
+import {AuthService} from '@eg/core/auth.service';
+import {PermService} from '@eg/core/perm.service';
+import {GridDataSource} from '@eg/share/grid/grid';
+import {Pager} from '@eg/share/util/pager';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {IdlService, IdlObject} from '@eg/core/idl.service';
+import {EventService} from '@eg/core/event.service';
+
+const defaultFolderName = 'Simple Reporter';
+
+const transforms = [
+    {
+        name: 'Bare',
+        aggregate: false
+    },
+    {
+        name: 'upper',
+        aggregate: false,
+        datatypes: ['text']
+    },
+    {
+        name: 'lower',
+        aggregate: false,
+        datatypes: ['text']
+    },
+    {
+        name: 'substring',
+        aggregate: false,
+        datatypes: ['text']
+    },
+    {
+        name: 'day_name',
+        final_datatype: 'text',
+        aggregate: false,
+        datatypes: ['timestamp']
+    },
+    {
+        name: 'month_name',
+        final_datatype: 'text',
+        aggregate: false,
+        datatypes: ['timestamp']
+    },
+    {
+        name: 'doy',
+        aggregate: false,
+        final_datatype: 'number',
+        datatypes: ['timestamp']
+    },
+    {
+        name: 'woy',
+        aggregate: false,
+        final_datatype: 'number',
+        datatypes: ['timestamp']
+    },
+    {
+        name: 'moy',
+        aggregate: false,
+        final_datatype: 'number',
+        datatypes: ['timestamp']
+    },
+    {
+        name: 'qoy',
+        aggregate: false,
+        final_datatype: 'number',
+        datatypes: ['timestamp']
+    },
+    {
+        name: 'dom',
+        aggregate: false,
+        final_datatype: 'number',
+        datatypes: ['timestamp']
+    },
+    {
+        name: 'dow',
+        aggregate: false,
+        final_datatype: 'number',
+        datatypes: ['timestamp']
+    },
+    {
+        name: 'year_trunc',
+        aggregate: false,
+        final_datatype: 'number',
+        datatypes: ['timestamp']
+    },
+    {
+        name: 'month_trunc',
+        aggregate: false,
+        final_datatype: 'text',
+        datatypes: ['timestamp']
+    },
+    {
+        name: 'date_trunc',
+        aggregate: false,
+        datatypes: ['timestamp']
+    },
+    {
+        name: 'hour_trunc',
+        aggregate: false,
+        datatypes: ['timestamp']
+    },
+    {
+        name: 'quarter',
+        aggregate: false,
+        datatypes: ['timestamp']
+    },
+    {
+        name: 'months_ago',
+        aggregate: false,
+        final_datatype: 'number',
+        datatypes: ['timestamp']
+    },
+    {
+        name: 'hod',
+        aggregate: false,
+        final_datatype: 'number',
+        datatypes: ['timestamp']
+    },
+    {
+        name: 'quarters_ago',
+        aggregate: false,
+        final_datatype: 'number',
+        datatypes: ['timestamp']
+    },
+    {
+        name: 'age',
+        aggregate: false,
+        final_datatype: 'interval',
+        datatypes: ['timestamp']
+    },
+    {
+        name: 'first',
+        aggregate: true
+    },
+    {
+        name: 'last',
+        aggregate: true
+    },
+    {
+        name: 'min',
+        aggregate: true
+    },
+    {
+        name: 'max',
+        aggregate: true
+    },
+    // "Simple" would be to only offer the choice that's almost always what you mean.
+    /*{
+        name: 'count',
+        aggregate: true
+    },*/
+    {
+        name: 'count_distinct',
+        final_datatype: 'number',
+        aggregate: true
+    },
+    {
+        name: 'sum',
+        aggregate: true,
+        datatypes: ['float', 'int', 'money', 'number']
+    },
+    {
+        name: 'average',
+        aggregate: true,
+        datatypes: ['float', 'int', 'money', 'number']
+    }
+];
+
+const operators = [
+    {
+        name: '=',
+        datatypes: ['link', 'text', 'timestamp', 'interval', 'float', 'int', 'money', 'number'],
+        arity: 1
+    },
+    {
+        arity: 1,
+        datatypes: ['bool', 'org_unit'],
+        name: '= any'
+    },
+    {
+        arity: 1,
+        datatypes: ['bool', 'org_unit'],
+        name: '<> any'
+    },
+    // If I had a dollar for every time someone wanted a case sensitive substring search, I might be able to buy a coffee.
+    /*{
+        name: 'like',
+        arity: 1,
+        datatypes: ['text']
+    },*/
+    {
+        name: 'ilike',
+        arity: 1,
+        datatypes: ['text']
+    },
+    {
+        name: '>',
+        arity: 1,
+        datatypes: ['text', 'timestamp', 'interval', 'float', 'int', 'money', 'number']
+    },
+    {
+        name: '>=',
+        arity: 1,
+        datatypes: ['text', 'timestamp', 'interval', 'float', 'int', 'money', 'number']
+    },
+    {
+        name: '<',
+        arity: 1,
+        datatypes: ['text', 'timestamp', 'interval', 'float', 'int', 'money', 'number']
+
+    },
+    {
+        name: '<=',
+        arity: 1,
+        datatypes: ['text', 'timestamp', 'interval', 'float', 'int', 'money', 'number']
+
+    },
+    {
+        name: 'in',
+        arity: 3,
+        datatypes: ['text', 'link', 'org_unit', 'float', 'int', 'money', 'number']
+    },
+    {
+        name: 'not in',
+        arity: 3,
+        datatypes: ['text', 'link', 'org_unit', 'float', 'int', 'money', 'number']
+
+    },
+    {
+        name: 'between',
+        arity: 2,
+        datatypes: ['text', 'timestamp', 'interval', 'float', 'int', 'money', 'number']
+
+    },
+    {
+        name: 'not between',
+        arity: 2,
+        datatypes: ['text', 'timestamp', 'interval', 'float', 'int', 'money', 'number']
+
+    },
+    {
+        arity: 0,
+        name: 'is'
+    },
+    {
+        arity: 0,
+        name: 'is not'
+    },
+    {
+        arity: 0,
+        name: 'is blank',
+        datatypes: ['text']
+    },
+    {
+        arity: 0,
+        name: 'is not blank',
+        datatypes: ['text']
+    }
+];
+
+const DEFAULT_TRANSFORM = 'Bare';
+const DEFAULT_OPERATOR = '=';
+
+export class SRTemplate {
+    id = -1;
+    rtIdl: IdlObject = null;
+    name = '';
+    description = ''; // description isn't currently used but someday could be
+    create_time = null;
+    fmClass = '';
+    displayFields: IdlObject[] = [];
+    orderByNames: string[] = [];
+    filterFields: IdlObject[] = [];
+    isNew = true;
+    recurring = false;
+    recurrence = null;
+    excelOutput = false;
+    csvOutput = true;
+    htmlOutput = true;
+    barCharts = false;
+    lineCharts = false;
+    email = '';
+    runNow = 'now';
+    runTime: moment.Moment = null;
+
+    constructor(idlObj: IdlObject = null) {
+        if ( idlObj !== null ) {
+            this.isNew = false;
+            this.id = Number(idlObj.id());
+            this.create_time = idlObj.create_time();
+            this.name = idlObj.name();
+            this.description = idlObj.description();
+
+            const simple_report = JSON.parse(idlObj.data()).simple_report;
+            this.fmClass = simple_report.fmClass;
+            this.displayFields = simple_report.displayFields;
+            this.orderByNames = simple_report.orderByNames;
+            this.filterFields = simple_report.filterFields;
+            if (idlObj.reports()?.length) {
+                const activeReport = idlObj.reports().reduce((prev, curr) =>
+                    prev.create_time() > curr.create_time() ? prev : curr
+                );
+                if (activeReport) {
+                    this.recurring = activeReport.recur() === 't';
+                    this.recurrence = activeReport.recurrence();
+                }
+                // then fetch the most recent completed rs
+                if (activeReport.runs().length) {
+                    const latestSched = activeReport.runs().reduce((prev, curr) =>
+                        prev.run_time() > curr.run_time() ? prev : curr
+                    );
+                    if (latestSched) {
+                        this.excelOutput = latestSched.excel_format() === 't';
+                        this.csvOutput = latestSched.csv_format() === 't';
+                        this.htmlOutput = latestSched.html_format() === 't';
+                        this.barCharts = latestSched.chart_bar() === 't';
+                        this.lineCharts = latestSched.chart_line() === 't';
+                        this.email = latestSched.email();
+                        this.runTime = latestSched.run_time().length ? moment(latestSched.run_time()) : moment();
+                        this.runNow = this.runTime.isAfter(moment()) ? 'later' : 'now';
+                    }
+                }
+            }
+        }
+    }
+}
+
+
+@Injectable({
+    providedIn: 'root'
+})
+export class SimpleReporterService {
+
+    templateFolder: IdlObject = null;
+    reportFolder: IdlObject = null;
+    outputFolder: IdlObject = null;
+
+    constructor (
+        private evt: EventService,
+        private auth: AuthService,
+        private idl: IdlService,
+        private pcrud: PcrudService
+    ) {
+    }
+
+    _initFolders(): Promise<any[]> {
+        if (this.templateFolder &&
+            this.reportFolder &&
+            this.outputFolder
+        ) {
+            return Promise.resolve([]);
+        }
+
+        return Promise.all([
+            new Promise((resolve, reject) => {
+                // Verify folders exist, create if not
+                this.getDefaultFolder('rtf')
+                .then(f => {
+                    if (f) {
+                        this.templateFolder = f;
+                        resolve();
+                    } else {
+                        this.createDefaultFolder('rtf')
+                        .then(n => {
+                            this.templateFolder = n;
+                            resolve();
+                        });
+                    }
+                });
+            }),
+            new Promise((resolve, reject) => {
+                this.getDefaultFolder('rrf')
+                .then(f => {
+                    if (f) {
+                        this.reportFolder = f;
+                        resolve();
+                    } else {
+                        this.createDefaultFolder('rrf')
+                        .then(n => {
+                            this.reportFolder = n;
+                            resolve();
+                        });
+                    }
+                });
+            }),
+            new Promise((resolve, reject) => {
+                this.getDefaultFolder('rof')
+                .then(f => {
+                    if (f) {
+                        resolve();
+                        this.outputFolder = f;
+                    } else {
+                        this.createDefaultFolder('rof')
+                        .then(n => {
+                            this.outputFolder = n;
+                            resolve();
+                        });
+                    }
+                });
+            })
+        ]);
+    }
+
+    getTransformsForDatatype(datatype: string) {
+        const ret = [];
+        transforms.forEach(el => {
+            if ( typeof el.datatypes === 'undefined' ||
+            (el.datatypes.findIndex(dt => dt === datatype) > -1) ) {
+                ret.push(el);
+            }
+        });
+        return ret;
+    }
+
+    getOperatorsForDatatype(datatype: string) {
+        const ret = [];
+        operators.forEach(el => {
+            if ( typeof el.datatypes === 'undefined' ||
+            (el.datatypes.findIndex(dt => dt === datatype) > -1) ) {
+                ret.push(el);
+            }
+        });
+        return ret;
+    }
+
+    defaultTransform() {
+        return this.getTransformByName(DEFAULT_TRANSFORM);
+    }
+
+    defaultOperator(dt) {
+        if (this.getOperatorByName(DEFAULT_OPERATOR).datatypes.indexOf(dt) >= 0) {
+            return this.getOperatorByName(DEFAULT_OPERATOR);
+        }
+        return this.getOperatorsForDatatype(dt)[0];
+    }
+
+    getTransformByName(name: string) {
+        return { ...transforms[transforms.findIndex(el => el.name === name)] };
+    }
+
+    getGenericTransformWithParams(ary: Array<any>) {
+        const copy = Array.from(ary);
+        const name = copy.shift();
+        const params = [];
+        const transform = { name: name, aggregate: false };
+
+        copy.forEach(el => {
+            switch (el) { // for now there's only user id for the current user, but in future ... ??
+                case 'SR__USER_ID':
+                    params.push(this.auth.user().id());
+                    break;
+                default:
+                    params.push(el);
+            }
+        });
+
+        if ( params.length ) {
+            transform['params'] = params;
+        }
+
+        return transform;
+    }
+
+    getOperatorByName(name: string) {
+        return { ...operators[operators.findIndex(el => el.name === name)] };
+    }
+
+    getDefaultFolder(fmClass: string): Promise<IdlObject> {
+        return this.pcrud.search(fmClass,
+            { owner: this.auth.user().id(), 'simple_reporter': 't', name: defaultFolderName },
+        {}).toPromise();
+    }
+
+    createDefaultFolder(fmClass: string): Promise<IdlObject> {
+        const rf = this.idl.create(fmClass);
+        rf.isnew(true);
+        rf.owner(this.auth.user().id());
+        rf.simple_reporter(true);
+        rf.name(defaultFolderName);
+        return this.pcrud.create(rf).toPromise();
+    }
+
+    loadTemplate(id: number): Promise<IdlObject> {
+        const searchOps = {
+            flesh: 2,
+            flesh_fields: {
+                rt: ['reports'],
+                rr: ['runs']
+            }
+        };
+        return this.pcrud.search('rt', { id: id }, searchOps).toPromise();
+    }
+
+    saveTemplate(
+        templ: SRTemplate,
+        scheduleNow: boolean = false
+    ): Promise<any> { // IdlObject or Number? It depends!
+        const rtData = this.buildTemplateData(templ);
+
+        // gather our parameters
+        const rrData = {};
+        templ.filterFields.forEach((el, idx) => {
+            rrData[el.filter_placeholder] = el.force_filtervalues ? el.force_filtervalues : el.filter_value;
+        });
+
+        // Here's where we'd add rr-flags like __do_rollup to rrData
+
+        const rtIdl = this.idl.create('rt');
+        const rrIdl = this.idl.create('rr');
+
+        if ( templ.id === -1 ) {
+            rtIdl.isnew(true);
+            rrIdl.isnew(true);
+        } else {
+            rtIdl.isnew(false);
+            rrIdl.isnew(false);
+            rtIdl.id(templ.id);
+            rtIdl.create_time(templ.create_time);
+        }
+        rtIdl.name(templ.name);
+        rtIdl.description(templ.description);
+        rtIdl.data(JSON.stringify(rtData));
+        rtIdl.owner(this.auth.user().id());
+        rtIdl.folder(this.templateFolder.id());
+
+        rrIdl.name(templ.name);
+        rrIdl.data(JSON.stringify(rrData));
+        rrIdl.owner(this.auth.user().id());
+        rrIdl.folder(this.reportFolder.id());
+        rrIdl.template(templ.id);
+        rrIdl.create_time('now'); // rr create time is serving as the edit time
+                                  // of the SR template as a whole
+
+        rrIdl.recur(templ.recurring ? 't' : 'f');
+        rrIdl.recurrence(templ.recurrence);
+
+        return this.pcrud.search('rt', { name: rtIdl.name(), folder: rtIdl.folder() })
+        .pipe(defaultIfEmpty(rtIdl), map(existing => {
+            if (existing.id() !== rtIdl.id()) { // oh no! dup name
+                throw new Error(': Duplicate Report Name');
+            }
+
+            if ( templ.id === -1 ) {
+                return this.pcrud.create(rtIdl).pipe(mergeMap(rt => {
+                    rrIdl.template(rt.id());
+                    // after saving the rr, return an Observable of the rt
+                    // to the caller
+                    return this.pcrud.create(rrIdl).pipe(mergeMap(
+                        rr => this.scheduleReport(templ, rr, scheduleNow).pipe(mergeMap(rs => of(rt)))
+                    ));
+                })).toPromise();
+            } else {
+                const emptyRR = this.idl.create('rr');
+                emptyRR.id('no_rr');
+                return this.pcrud.update(rtIdl).pipe(mergeMap(rtId => {
+                    // we may or may not have the rr already created, so
+                    // test and act accordingly
+                    return this.pcrud.search('rr', { template: rtId }).pipe(defaultIfEmpty(emptyRR), mergeMap(rr => {
+                        if (rr.id() === 'no_rr') {
+                            rrIdl.isnew(true);
+                            return this.pcrud.create(rrIdl).pipe(mergeMap(rr2 =>
+                                this.scheduleReport(templ, rr2, scheduleNow).pipe(mergeMap(rs => of(rtId)))
+                            ));
+                        } else {
+                            rr.create_time('now'); // rr create time is serving as the
+                                                                         // edit time of the SR template as a whole
+                            rr.recur(templ.recurring ? 't' : 'f');
+                            rr.recurrence(templ.recurrence);
+                            rr.data(rrIdl.data());
+                            return this.pcrud.update(rr).pipe(mergeMap(
+                                rr2 => this.scheduleReport(templ, rr, scheduleNow).pipe(mergeMap(rs => of(rtId) ))
+                            ));
+                        }
+                    }));
+                })).toPromise();
+            }
+        })).toPromise();
+    }
+
+    scheduleReport(templ: SRTemplate, rr: IdlObject, scheduleNow: boolean): Observable<IdlObject> {
+        const rs = this.idl.create('rs');
+        if (!scheduleNow) {
+                return of(rs); // return a placeholder
+        }
+        rs.isnew(true);
+        rs.report(rr.id());
+        rs.folder(this.outputFolder.id());
+        rs.runner(rr.owner());
+        if (templ.runNow === 'now') {
+            rs.run_time('now');
+        } else {
+            rs.run_time(templ.runTime.toISOString());
+        }
+        rs.email(templ.email);
+        rs.excel_format(templ.excelOutput ? 't' : 'f');
+        rs.csv_format(templ.csvOutput ? 't' : 'f');
+        rs.html_format(templ.htmlOutput ? 't' : 'f');
+        rs.chart_line(templ.lineCharts ? 't' : 'f');
+        rs.chart_bar(templ.barCharts ? 't' : 'f');
+        rs.isnew(true);
+
+        // clear any un-run schedules, then add the new one
+        const emptyRS = this.idl.create('rs');
+        emptyRS.id('no_rs');
+        return this.pcrud.search('rs', { report: rr.id(), start_time: {'=' : null} }, {}, {atomic: true}).pipe(mergeMap(old_rs => {
+            if (old_rs.length > 0) {
+                old_rs.forEach(x => x.isdeleted(true));
+                old_rs.push(rs);
+                return this.pcrud.autoApply(old_rs).pipe(last()); // note that we don't care
+                                                                  // what the last one processed
+                                                                  // actually is
+            } else {
+                return this.pcrud.create(rs);
+            }
+        }));
+    }
+
+    // The template generated by this can obviously be trimmed to only those things
+    // that SQLBuilder.pm cares about, but for now it's basically the same as the
+    // existing template builder.
+    buildTemplateData(
+        templ: SRTemplate
+    ) {
+        const fmClass = templ.fmClass;
+        const sourceClass = this.idl.classes[fmClass];
+        const md5Name = Md5.hashStr(fmClass); // Just the one with SR since there are no joins
+        let conditionCount = 0;
+
+        // The simplified template that can be edited and re-saved
+        const simpleReport = {
+            name: templ.name,
+            fmClass: fmClass,
+            displayFields: templ.displayFields,
+            orderByNames: templ.orderByNames,
+            filterFields: templ.filterFields,
+        };
+        const reportTemplate = {
+            simple_report: simpleReport,
+            version: 5,
+            core_class: fmClass,
+            'from': {
+                alias: md5Name,
+                path: fmClass + '-' + fmClass,
+                table: sourceClass.source,
+                idlclass: fmClass,
+                label: sourceClass.label
+            },
+            select: [],
+            where: [],
+            having: [],
+            order_by: []
+        };
+        // fill in select[] and display_cols[] simultaneously
+        templ.displayFields.forEach((el, idx) => {
+            reportTemplate.select.push({
+                alias: el.alias,
+                path: fmClass + '-' + el.name,
+                relation: md5Name,
+                column: {
+                    colname: el.name,
+                    transform: el.transform.name,
+                    aggregate: el.transform.aggregate
+                }
+            });
+
+        }); // select[]
+
+        // where[] and having[] are the same save for aggregate == true
+        templ.filterFields.forEach((el, idx) => {
+            let whereObj = {};
+
+            whereObj = {
+                alias: el.alias,
+                path: fmClass + '-' + el.name,
+                relation: md5Name,
+                column: {
+                    colname: el.name,
+                    transform: el.transform.name,
+                    aggregate: el.transform.aggregate
+                },
+                condition: {}
+            };
+
+            // No test for el.filterValue because currently all filter values are assigned at schedule time
+            whereObj['condition'][el.operator.name] = '::P' + conditionCount;
+            el.filter_placeholder = 'P' + conditionCount;
+            conditionCount++;
+
+            // handle force transforms
+            if (el.force_transform) {
+                whereObj['column']['params'] = el.transform.params;
+            }
+
+            if ( el.transform.aggregate ) {
+                reportTemplate.having.push(whereObj);
+            } else {
+                reportTemplate.where.push(whereObj);
+            }
+
+        }); // where[] and having[]
+
+        templ.orderByNames.forEach(ob => { // order_by and select have the same shape
+            const el = templ.displayFields[templ.displayFields.findIndex(fl => fl.name === ob)];
+            reportTemplate.order_by.push({
+                alias: el.alias,
+                path: fmClass + '-' + el.name,
+                relation: md5Name,
+                direction: el.direction ? el.direction : 'ascending',
+                column: {
+                    colname: el.name,
+                    transform: el.transform.name,
+                    aggregate: el.transform.aggregate
+                }
+            });
+        });
+
+        return reportTemplate;
+    }
+
+    getOutputDatasource() {
+        const gridSource = new GridDataSource();
+
+        gridSource.sort = [{ name: 'complete_time', dir: 'DESC' }];
+
+        gridSource.getRows = (pager: Pager, sort: any[]) => {
+
+            // start setting up query
+            const base: Object = {};
+            base['runner'] = this.auth.user().id();
+            base['output_folder'] = this.outputFolder.id();
+            const query: any = new Array();
+            query.push(base);
+
+            // and add any filters
+            Object.keys(gridSource.filters).forEach(key => {
+                Object.keys(gridSource.filters[key]).forEach(key2 => {
+                    query.push(gridSource.filters[key][key2]);
+                });
+            });
+
+            const orderBy: any = {};
+            if (sort.length) {
+                orderBy.rcr = sort[0].name + ' ' + sort[0].dir;
+            }
+
+            const searchOpts = {
+                flesh: 2,
+                flesh_fields: {
+                        rcr: ['run'],
+                },
+                offset: pager.offset,
+                limit: pager.limit,
+                order_by: orderBy
+            };
+
+            return this.pcrud.search('rcr', query, searchOpts)
+            .pipe(map(row => {
+                if ( this.evt.parse(row) ) {
+                    throw new Error(row);
+                } else {
+                    return {
+                        template_name: row.template_name(),
+                        complete_time: row.complete_time(),
+                        id: row.run().id(),
+                        report_id: row.report(),
+                        template_id: row.template(),
+                        error_code: row.run().error_code(),
+                        error_text: row.run().error_text(),
+                        _rs: row.run()
+                    };
+                }
+            }));
+
+        };
+
+        return gridSource;
+    }
+
+    getReportsDatasource() {
+        const gridSource = new GridDataSource();
+
+        gridSource.getRows = (pager: Pager, sort: any[]) => {
+            const orderBy: any = {};
+
+            if (sort.length) {
+                orderBy.rt = sort[0].name + ' ' + sort[0].dir;
+            } else {
+                orderBy.rt = 'create_time desc';
+            }
+
+            // start setting up query
+            const base: Object = {};
+            base['owner'] = this.auth.user().id();
+            base['folder'] = this.templateFolder.id();
+
+            const query: any = new Array();
+            query.push(base);
+
+            // and add any filters
+            Object.keys(gridSource.filters).forEach(key => {
+                Object.keys(gridSource.filters[key]).forEach(key2 => {
+                    query.push(gridSource.filters[key][key2]);
+                });
+            });
+
+            const searchOps = {
+                flesh: 2,
+                flesh_fields: {
+                    rt: ['reports'],
+                    rr: ['runs']
+                },
+                offset: pager.offset,
+                limit: pager.limit,
+                order_by: orderBy
+            };
+
+            return this.pcrud.search('rt', query, searchOps).pipe(map(row => {
+                let edit_time = null;
+                let last_run = null;
+                let past = [];
+                let future = [];
+                let next_run = null;
+                let recurring = false;
+
+                // there should be exactly one rr associated with the template,
+                // but in case not, we'll just pick the one with the most
+                // recent create time
+                if (row.reports().length) {
+                    const activeReport = row.reports().reduce((prev, curr) =>
+                        prev.create_time() > curr.create_time() ? prev : curr
+                    );
+                    if (activeReport) {
+                        // note that we're (ab)using the rr create_time
+                        // to be the edit time of the SR rt + rr combo
+                        edit_time = activeReport.create_time();
+                        recurring = activeReport.recur() === 't';
+                    }
+                    // then fetch the most recent completed rs
+                    if (activeReport.runs().length) {
+                        let lastRun = null;
+                        past = activeReport.runs().filter(el => el.start_time() !== null);
+                        if (past.length) {
+                            lastRun = past.reduce((prev, curr) =>
+                                prev.complete_time() > curr.complete_time() ? prev : curr
+                            );
+                        }
+                        if (lastRun) {
+                            last_run = lastRun.complete_time();
+                        }
+
+                        // And the next rs not yet in progress
+                        let nextRun = null;
+                        future = activeReport.runs().filter(el => el.start_time() === null);
+                        if (future.length) {
+                            nextRun = future.reduce((prev, curr) =>
+                                prev.run_time() < curr.run_time() ? prev : curr
+                            );
+                        }
+                        if (nextRun) {
+                            next_run = nextRun.run_time();
+                        }
+                    }
+                }
+                return {
+                    name: row.name(),
+                    rt_id: row.id(),
+                    create_time: row.create_time(),
+                    edit_time: edit_time,
+                    last_run: last_run,
+                    next_run: next_run,
+                    recurring: recurring,
+                };
+            }));
+        };
+
+        return gridSource;
+    }
+}
+
+@Injectable()
+export class SimpleReporterServiceResolver implements Resolve<Promise<any[]>> {
+
+        constructor(
+                private router: Router,
+                private perm: PermService,
+                private svc: SimpleReporterService
+        ) {}
+
+        resolve(
+                route: ActivatedRouteSnapshot,
+                state: RouterStateSnapshot): Promise<any[]> {
+
+                return from(this.perm.hasWorkPermHere('RUN_SIMPLE_REPORTS')).pipe(mergeMap(
+                        permResult => {
+                                if (permResult['RUN_SIMPLE_REPORTS']) {
+                                        return Promise.all([
+                                                this.svc._initFolders()
+                                        ]);
+                                } else {
+                                        this.router.navigate(['/staff/no_permission']);
+                                        return EMPTY;
+                                }
+                        }
+                )).toPromise();
+        }
+
+}
diff --git a/Open-ILS/src/eg2/src/app/staff/reporter/simple/sr-editor.component.css b/Open-ILS/src/eg2/src/app/staff/reporter/simple/sr-editor.component.css
new file mode 100644 (file)
index 0000000..0ea9a45
--- /dev/null
@@ -0,0 +1,7 @@
+.sr-name-empty {
+  border-left: 5px solid #FA787E;
+}
+
+.sr-name-notempty {
+  border-left: 5px solid #78FA89;
+}
diff --git a/Open-ILS/src/eg2/src/app/staff/reporter/simple/sr-editor.component.html b/Open-ILS/src/eg2/src/app/staff/reporter/simple/sr-editor.component.html
new file mode 100644 (file)
index 0000000..acfbd88
--- /dev/null
@@ -0,0 +1,102 @@
+<eg-string #templateSaved i18n-text text="Report Saved Successfully"></eg-string>
+<eg-string #templateSaveError i18n-text text="Error Saving Report"></eg-string>
+<eg-string #newTitle i18n-text text="New Simple Report"></eg-string>
+<eg-string #editTitle i18n-text text="Edit Simple Report"></eg-string>
+
+<eg-staff-banner #banner [bannerText]="pageTitle">
+</eg-staff-banner>
+
+<eg-confirm-dialog
+  #changeTypeDialog
+  i18n-dialogTitle i18n-dialogBody
+  dialogTitle="Confirm Report Type Change"
+  dialogBody="You have already started creating a report; changing the report type will remove your progress. Continue?"
+></eg-confirm-dialog>
+
+<eg-confirm-dialog
+  #closeFormDialog
+  i18n-dialogTitle i18n-dialogBody
+  dialogTitle="Confirm Close Report Editor"
+  dialogBody="Close report editor, abandoning any unsaved changes?"
+></eg-confirm-dialog>
+
+<div class="row" id="sr-basic-info">
+  <div class="col-sm-2">
+    <label for="report-type" i18n>Report Type:</label>
+  </div>
+  <div class="col-lg-3">
+    <select class="form-control" id="report-type" [ngModelOptions]="{standalone: true}" 
+      [(ngModel)]="rptType" (change)="changeReportType()" [disabled]="!isNew">
+      <option value="" disabled="disabled">Please Select a Report Type</option>
+      <option value="srcirc">Circulation</option>
+      <option value="srcp">Collections</option>
+      <option value="srwd">Weeding</option>
+      <option value="srusr">Patrons</option>
+      <option value="srbps">Billings and Payments Transaction Summary</option>
+    </select>
+  </div>
+  <div class="col-sm-2">
+    <label for="report-name" i18n>Report Name</label>
+  </div>
+  <div class="col-lg-3">
+    <input id="report-name" class="form-control sr-name-{{ (name !== '' && name !== null) ? 'not' : '' }}empty" [(ngModel)]="name" (ngModelChange)="dirty()" />
+  </div>
+  <div class="col-sm-2">
+    <button class="btn btn-success" (click)="saveTemplate(false)" [disabled]="!readyToSave()" i18n>Save</button>
+    <button class="btn btn-outline-dark ml-1" (click)="closeForm()" i18n>Close</button>
+  </div>
+</div>
+<div *ngIf="rptType != ''" class="row mt-2" id="sr-editor-main">
+  <div class="col-lg-12">
+    <ul ngbNav #srEditorTabs="ngbNav" class="nav-tabs">
+
+      <li [ngbNavItem]="'rptFields'">
+        <a ngbNavLink i18n>Display Fields</a>
+       <ng-template ngbNavContent>
+          <eg-sr-field-chooser
+            [fieldType]="'display'"
+            [allFields]="allFields"
+            [fieldGroups]="fieldGroups"
+            [(selectedFields)]="templ.displayFields"
+            (selectedFieldsChange)="dirty()"
+            [(orderByNames)]="templ.orderByNames"
+            (orderByNamesChange)="dirty()"
+          >
+          </eg-sr-field-chooser>
+        </ng-template>
+      </li>
+
+      <li [ngbNavItem]="'rptSortFields'">
+        <a ngbNavLink i18n>Output Order</a>
+       <ng-template ngbNavContent><eg-sr-sort-order [(fields)]="templ.displayFields" [(orderByNames)]="templ.orderByNames" (orderByNamesChange)="dirty()"></eg-sr-sort-order></ng-template>
+      </li>
+
+      <li [ngbNavItem]="'rptFilterFields'">
+        <a ngbNavLink i18n>Filters</a>
+       <ng-template ngbNavContent>
+          <eg-sr-field-chooser
+            [fieldType]="'filter'"
+            [allFields]="allFields"
+            [fieldGroups]="fieldGroups"
+            [(selectedFields)]="templ.filterFields"
+            (selectedFieldsChange)="dirty()"
+            [listFields]="templ.displayFields"
+          >
+          </eg-sr-field-chooser>
+        </ng-template>
+      </li>
+
+      <li [ngbNavItem]="'rptOutputOptions'">
+        <a ngbNavLink i18n>Output Options</a>
+        <ng-template ngbNavContent>
+          <eg-sr-output-options
+            [readyToSchedule]="readyToSchedule"
+            [saveTemplate]="saveTemplate"
+            [templ]="templ"
+          ></eg-sr-output-options>
+        </ng-template>
+      </li>
+    </ul>
+    <div [ngbNavOutlet]="srEditorTabs"></div>
+  </div>
+</div>
diff --git a/Open-ILS/src/eg2/src/app/staff/reporter/simple/sr-editor.component.ts b/Open-ILS/src/eg2/src/app/staff/reporter/simple/sr-editor.component.ts
new file mode 100644 (file)
index 0000000..f409b23
--- /dev/null
@@ -0,0 +1,239 @@
+import {Component, OnInit, ViewChild} from '@angular/core';
+import {Router, ActivatedRoute} from '@angular/router';
+import {Location} from '@angular/common';
+import {of} from 'rxjs';
+import {NgbNav} from '@ng-bootstrap/ng-bootstrap';
+import {StaffCommonModule} from '@eg/staff/common.module';
+import {EventService} from '@eg/core/event.service';
+import {ToastService} from '@eg/share/toast/toast.service';
+import {ConfirmDialogComponent} from '@eg/share/dialog/confirm.component';
+import {IdlService, IdlObject} from '@eg/core/idl.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {StringComponent} from '@eg/share/string/string.component';
+import {SimpleReporterService, SRTemplate} from './simple-reporter.service';
+import {SRFieldChooserComponent} from './sr-field-chooser.component';
+import {SRSortOrderComponent} from './sr-sort-order.component';
+import {SROutputOptionsComponent} from './sr-output-options.component';
+
+@Component({
+    templateUrl: './sr-editor.component.html',
+    styleUrls: ['./sr-editor.component.css'],
+})
+
+export class SREditorComponent implements OnInit {
+
+    rptType = '';
+    oldRptType = '';
+    name = '';
+    templ: SRTemplate = null;
+    isNew = true;
+    sourceClass: IdlObject = null;
+    fieldGroups: IdlObject[] = [];
+    allFields: IdlObject[] = [];
+    pageTitle = '';
+    forcedFields = 0;
+    _isDirty = false;
+
+    @ViewChild('templateSaved', { static: true }) templateSavedString: StringComponent;
+    @ViewChild('templateSaveError', { static: true }) templateSaveErrorString: StringComponent;
+    @ViewChild('newTitle', { static: true }) newTitleString: StringComponent;
+    @ViewChild('editTitle', { static: true }) editTitleString: StringComponent;
+    @ViewChild('srEditorTabs', { static: true }) tabs: NgbNav;
+    @ViewChild('changeTypeDialog', { static: false }) changeTypeDialog: ConfirmDialogComponent;
+    @ViewChild('closeFormDialog', { static: false }) closeFormDialog: ConfirmDialogComponent;
+
+
+    constructor(
+        private route: ActivatedRoute,
+        private router: Router,
+        private location: Location,
+        private toast: ToastService,
+        private evt: EventService,
+        private idl: IdlService,
+        private pcrud: PcrudService,
+        private srSvc: SimpleReporterService
+    ) {
+        const id = this.route.snapshot.paramMap.get('id');
+        if ( id === null ) {
+            this.templ = new SRTemplate();
+        } else {
+            this.isNew = false;
+            this.loadTemplate(Number(id))
+            .then( x => this.reloadFields(this.templ.fmClass));
+        }
+
+    }
+
+    ngOnInit() {
+        this._setPageTitle();
+    }
+
+    _setPageTitle() {
+         if ( this.isNew ) {
+            this.newTitleString.current()
+            .then(str => this.pageTitle = str );
+        } else {
+            this.editTitleString.current()
+            .then(str => this.pageTitle = str );
+        }
+    }
+
+    reloadFields(fmClass: string) {
+        this.allFields = [];
+        this.forcedFields = 0;
+        this.sourceClass = this.idl.classes[fmClass];
+        ('field_groups' in this.sourceClass) ?
+            // grab a clone
+            this.fieldGroups = this.sourceClass.field_groups.map(x => ({...x})) :
+            this.fieldGroups = [];
+
+        this.sourceClass.fields.forEach(f => {
+
+            f.transform = this.srSvc.defaultTransform();
+            f.operator = this.srSvc.defaultOperator(f.datatype);
+
+            if ( f.suggest_transform ) {
+                f.transform = this.srSvc.getTransformByName(f.suggest_transform);
+            }
+
+            if ( f.suggest_operator ) {
+                f.operator = this.srSvc.getOperatorByName(f.suggest_operator);
+            }
+
+            if ( f.force_transform ) {
+                if ( typeof f.force_transform === 'string' ) {
+                    f.transform = this.srSvc.getTransformByName(f.force_transform);
+                } else {
+                    f.transform = this.srSvc.getGenericTransformWithParams(f.force_transform);
+                }
+            }
+
+            if ( f.force_operator ) {
+                f.operator = this.srSvc.getOperatorByName(f.force_operator);
+            }
+
+            if ( f.force_filter ) {
+                if ( this.templ.filterFields.findIndex(el => el.name === f.name) === -1 ) {
+                    this.templ.filterFields.push(f);
+                    this.forcedFields++;
+                }
+            }
+
+            this.allFields.push(f);
+            if ( 'field_groups' in f ) {
+                f.field_groups.forEach(g => {
+                    const idx = this.fieldGroups.findIndex(el => el.name === g);
+                    if ( idx > -1 ) {
+                        if ( !('members' in this.fieldGroups[idx]) ) {
+                            this.fieldGroups[idx].members = [];
+                        }
+                        this.fieldGroups[idx].members.push(f);
+                    }
+                });
+            }
+        });
+
+        this.allFields.sort( (a, b) => a.label.localeCompare(b.label) );
+
+    }
+
+    changeReportType() {
+        if ( this.oldRptType === '' || (this.templ.displayFields.length === 0 && this.templ.filterFields.length === this.forcedFields) ) {
+            this.oldRptType = this.rptType;
+            this.templ = new SRTemplate();
+            this.templ.fmClass = this.rptType;
+            this.reloadFields(this.rptType);
+            this._isDirty = true;
+        } else {
+            return this.changeTypeDialog.open()
+            .subscribe(confirmed => {
+                if ( confirmed ) {
+                    this.oldRptType = this.rptType;
+                    this.templ = new SRTemplate();
+                    this.templ.fmClass = this.rptType;
+                    this.reloadFields(this.rptType);
+                    this._isDirty = true;
+                } else {
+                    this.rptType = this.oldRptType;
+                }
+            });
+        }
+    }
+
+    dirty() {
+        this._isDirty = true;
+    }
+
+    isDirty() {
+        return this._isDirty;
+    }
+
+    readyToSave() {
+        return ( this.sourceClass !== null && this.name !== '' );
+    }
+
+    readyToSchedule = () => {
+        return ( this.readyToSave() && this.templ.displayFields.length > 0 );
+    }
+
+    canLeaveEditor() {
+        if ( this.isDirty() ) {
+            return this.closeFormDialog.open();
+        } else {
+            return of(true);
+        }
+    }
+
+    loadTemplate(id: number) {
+        return this.srSvc.loadTemplate(id)
+        .then(idl => {
+            this.templ = new SRTemplate(idl);
+            this.name = this.templ.name;
+            this.rptType = this.templ.fmClass;
+            this.oldRptType = this.templ.fmClass;
+        });
+    }
+
+    saveTemplate = (scheduleNow) => {
+        this.templ.name = this.name;
+
+        this.srSvc.saveTemplate(this.templ, scheduleNow)
+        .then(rt => {
+            this._isDirty = false;
+            // It appears that calling pcrud.create will return the newly created object,
+            // while pcrud.update just gives you back the id of the updated object.
+            if ( typeof rt === 'object' ) {
+                this.templ = new SRTemplate(rt); // pick up the id and create_time fields
+            }
+            this.templateSavedString.current()
+            .then(str => {
+                this.toast.success(str);
+            });
+            if (scheduleNow) {
+                // we're done, so jump to the main page
+                this.router.navigate(['/staff/reporter/simple']);
+            } else if (this.isNew) {
+                // we've successfully saved, so we're no longer new
+                // adjust page title...
+                this.isNew = false;
+                this._setPageTitle();
+                // ... and make the URL say that we're editing
+                const url = this.router.createUrlTree(['/staff/reporter/simple/edit/' + this.templ.id]).toString();
+                this.location.go(url); // go without reloading
+            }
+        },
+        err => {
+            this.templateSaveErrorString.current()
+            .then(str => {
+                this.toast.danger(str + err);
+                console.error('Error saving template: %o', err);
+            });
+        });
+    }
+
+    closeForm() {
+        this.router.navigate(['/staff/reporter/simple']);
+    }
+
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/reporter/simple/sr-field-chooser.component.css b/Open-ILS/src/eg2/src/app/staff/reporter/simple/sr-field-chooser.component.css
new file mode 100644 (file)
index 0000000..310fdd7
--- /dev/null
@@ -0,0 +1,9 @@
+.chooser-row {
+  display: flex;
+  justify-content: space-between;
+}
+
+.sr-chooser-display-list {
+  font-size: 80%;
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/reporter/simple/sr-field-chooser.component.html b/Open-ILS/src/eg2/src/app/staff/reporter/simple/sr-field-chooser.component.html
new file mode 100644 (file)
index 0000000..a874544
--- /dev/null
@@ -0,0 +1,108 @@
+<div class="chooser-row">
+
+<ngb-accordion #fieldChooser="ngbAccordion" [closeOthers]="true" class="col-md-4">
+  <ngb-panel *ngIf="fieldType === 'filter'" id="suggested_filters" title="Suggested Filters" i18n-title>
+    <ng-template ngbPanelContent>
+      <ng-container *ngFor="let f of allFields">
+        <eg-sr-field
+          *ngIf="f.suggest_filter"
+          [field]=f
+          [withSelect]=true
+          [selected]="fieldIsSelected(f)"
+          (selectEvent)="toggleSelect(f)"
+        >
+        </eg-sr-field>
+      </ng-container>
+    </ng-template>
+  </ngb-panel>
+  <ngb-panel *ngFor="let g of fieldGroups" id="{{g.name}}" title="{{g.label}}">
+    <ng-template ngbPanelContent>
+      <ng-container *ngFor="let f of g.members">
+        <eg-sr-field
+          *ngIf="!hideField(f)"
+          [field]=f
+          [withSelect]=true
+          [selected]="fieldIsSelected(f)"
+          (selectEvent)="toggleSelect(f)"
+        >
+        </eg-sr-field>
+      </ng-container>
+    </ng-template>
+  </ngb-panel>
+  <ngb-panel *ngIf="allFields.length > 0" id="all" title="All Fields" i18n-title>
+    <ng-template ngbPanelContent>
+      <ng-container *ngFor="let f of allFields">
+        <eg-sr-field
+          *ngIf="!hideField(f) && !f.virtual"
+          [field]=f
+          [withSelect]=true
+          [selected]="fieldIsSelected(f)"
+          (selectEvent)="toggleSelect(f)"
+        >
+        </eg-sr-field>
+      </ng-container>
+    </ng-template>
+  </ngb-panel>
+</ngb-accordion>
+
+<ngb-accordion #selectedList="ngbAccordion" activeIds="display-field-list,sort-field-list" class="col-md-8">
+  <ngb-panel id="display-field-list" *ngIf="fieldType === 'filter' && listFields.length > 0">
+    <ng-template ngbPanelHeader let-opened=true>
+      <div class="d-flex align-items-center justify-content-between">
+        <h5 class="m-0" i18n>Fields Selected for Display</h5>
+      </div>
+    </ng-template>
+    <ng-template ngbPanelContent>
+      <span *ngFor="let f of listFields; index as idx" class="sr-chooser-display-list">{{f.alias}}{{idx === (listFields.length - 1) ? '' : ', '}}</span>
+    </ng-template>
+  </ngb-panel>
+  <ngb-panel id="sort-field-list">
+    <ng-template ngbPanelHeader let-opened=true>
+      <div class="d-flex align-items-center justify-content-between">
+        <h5 *ngIf="fieldType === 'display'" class="m-0" i18n>Field Display Order</h5>
+        <h5 *ngIf="fieldType === 'filter'" class="m-0" i18n>Filter Fields and Values</h5>
+      </div>
+    </ng-template>
+    <ng-template ngbPanelContent>
+
+      <ng-container *ngIf="fieldType === 'display'">
+        <ng-container *ngFor="let f of selectedFields; index as idx">
+          <eg-sr-field
+            *ngIf="!hideField(f)"
+            [field]=f
+            [withDeselect]=true
+            [withAlias]=true
+            [withTransforms]=true
+            [withUpDown]=true
+            [disableUp]="idx === 0"
+            [disableDown]="idx === (selectedFields.length - 1)"
+            (fieldChange)="updateField($event)"
+            (deselectEvent)="toggleSelect(f)"
+            (upEvent)="moveUp(idx)"
+            (downEvent)="moveDown(idx)"
+          >
+          </eg-sr-field>
+        </ng-container>
+      </ng-container>
+
+      <ng-container *ngIf="fieldType === 'filter'">
+        <ng-container *ngFor="let f of selectedFields; index as idx">
+          <eg-sr-field
+            *ngIf="!hideField(f)"
+            [field]=f
+            [withDeselect]=true
+            [withTransforms]=true
+            [withOperators]=true
+            [withValueInput]=true
+            (fieldChange)="updateField($event)"
+            (deselectEvent)="toggleSelect(f)"
+          >
+          </eg-sr-field>
+        </ng-container>
+      </ng-container>
+
+    </ng-template>
+  </ngb-panel>
+</ngb-accordion>
+
+</div>
diff --git a/Open-ILS/src/eg2/src/app/staff/reporter/simple/sr-field-chooser.component.ts b/Open-ILS/src/eg2/src/app/staff/reporter/simple/sr-field-chooser.component.ts
new file mode 100644 (file)
index 0000000..db88650
--- /dev/null
@@ -0,0 +1,97 @@
+import {Component, Input, Output, EventEmitter, OnInit, ViewChild} from '@angular/core';
+import {NgbAccordion} from '@ng-bootstrap/ng-bootstrap';
+import {IdlService, IdlObject} from '@eg/core/idl.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {SimpleReporterService} from './simple-reporter.service';
+import {SRFieldComponent} from './sr-field.component';
+
+@Component({
+    selector: 'eg-sr-field-chooser',
+    styleUrls: ['./sr-field-chooser.component.css'],
+    templateUrl: './sr-field-chooser.component.html'
+})
+
+export class SRFieldChooserComponent implements OnInit {
+
+    @Input() fieldType = 'display';
+    @Input() allFields: IdlObject[] = [];
+    @Input() fieldGroups: IdlObject[] = [];
+    @Input() orderByNames: string[] = [];
+    @Output() orderByNamesChange = new EventEmitter<string[]>();
+    @Input() selectedFields: IdlObject[] = [];
+    @Output() selectedFieldsChange = new EventEmitter<IdlObject[]>();
+    @Input() listFields: IdlObject[] = [];
+
+    @ViewChild('fieldChooser', { static: false }) fieldChooser: NgbAccordion;
+    @ViewChild('selectedList', { static: false }) selectedList: NgbAccordion;
+
+    constructor(
+        private idl: IdlService,
+        private srSvc: SimpleReporterService
+    ) {
+    }
+
+    ngOnInit() {
+    }
+
+    fieldIsSelected(field: IdlObject) {
+     return this.selectedFields.findIndex(el => el.name === field.name) > -1;
+    }
+
+    hideField(field: IdlObject) {
+        if ( typeof field.hide_from === 'undefined' ) {
+            return false;
+        }
+        return (field.hide_from.indexOf(this.fieldType) > -1);
+    }
+
+    toggleSelect(field: IdlObject) {
+        const idx = this.selectedFields.findIndex(el => el.name === field.name);
+        if ( idx > -1 ) {
+            if ( field.forced_filter ) { return; } // These should just be hidden, but if not...
+            this.selectedFields.splice(idx, 1);
+            if ( this.fieldType === 'display' ) {
+                this.orderByNames.splice(this.orderByNames.findIndex(el => el === field.name), 1);
+            }
+        } else {
+            const f = { ...field };
+
+            if ( this.fieldType === 'display' ) {
+                f['alias'] = f.label; // can be edited
+                this.orderByNames.push(f.name);
+            }
+            this.selectedFields.push(f);
+        }
+
+        this.selectedFieldsChange.emit(this.selectedFields);
+
+        if ( this.fieldType === 'display' ) {
+            this.orderByNamesChange.emit(this.orderByNames);
+        }
+    }
+
+    updateField(field: IdlObject) {
+        const idx = this.selectedFields.findIndex(el => el.name === field.name);
+        this.selectedFields[idx] = field;
+        this.selectedFieldsChange.emit(this.selectedFields);
+    }
+
+    moveUp(idx: number) {
+        if ( idx > 0 ) { // should always be the case, but we check anyway
+        const hold: IdlObject = this.selectedFields[idx - 1];
+        this.selectedFields[idx - 1] = this.selectedFields[idx];
+        this.selectedFields[idx] = hold;
+        this.selectedFieldsChange.emit(this.selectedFields);
+        }
+    }
+
+    moveDown(idx: number) {
+        if ( idx < this.selectedFields.length ) { // see above comment
+        const hold: IdlObject = this.selectedFields[idx + 1];
+        this.selectedFields[idx + 1] = this.selectedFields[idx];
+        this.selectedFields[idx] = hold;
+        this.selectedFieldsChange.emit(this.selectedFields);
+        }
+    }
+
+}
diff --git a/Open-ILS/src/eg2/src/app/staff/reporter/simple/sr-field.component.css b/Open-ILS/src/eg2/src/app/staff/reporter/simple/sr-field.component.css
new file mode 100644 (file)
index 0000000..39003ff
--- /dev/null
@@ -0,0 +1,29 @@
+.sr-field-container {
+  border-bottom: 1px solid lightgray;
+  padding-bottom: .75em;
+  margin-bottom: .75em;
+}
+
+.sr-field-select {
+  display: flex;
+}
+
+.sr-checkbox {
+  margin-top: .3em;
+}
+
+.sr-field {
+  display: flex;
+  flex-wrap: wrap;
+  justify-content: space-between;
+}
+
+.sr-field-explainer {
+  font-size: 80%;
+}
+
+.sr-field-disp-ind {
+  padding-left: .3em;
+  padding-bottom: .3em;
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/reporter/simple/sr-field.component.html b/Open-ILS/src/eg2/src/app/staff/reporter/simple/sr-field.component.html
new file mode 100644 (file)
index 0000000..4ee7f29
--- /dev/null
@@ -0,0 +1,210 @@
+<div class="sr-field-container">
+
+  <div class="sr-field{{ withSelect === true ? '-select' : '' }}" (click)="selectAction()">
+
+    <div *ngIf="withSelect" class="sr-field-select col-md-1">
+      <input (change)="selectAction()" (click)="selectAction()" class="sr-checkbox" type="checkbox"
+        id="chk{{field.name}}" checked="{{ selected === true ? 'checked' : '' }}" />
+    </div>
+
+    <div *ngIf="withDeselect" class="sr-field-deselect col-md-1">
+      <button class="btn btn-sm material-icon-button p-1 sr-checkbox" title="Un-Select Field" (click)="deselectAction()" title-i18n>
+        <span class="material-icons">remove_circle_outline</span></button>
+    </div>
+
+    <div *ngIf="withAlias" class="sr-field-name col-md-auto">
+      <span class="sr-field-explainer" i18n>Name</span>
+      <input class="form-control" type="textbox" [disabled]="!editAlias" [(ngModel)]="field.alias" />
+      <span *ngIf="field.alias !== field.label" class="sr-field-explainer">({{field.label}})</span>
+    </div>
+    <div *ngIf="!withAlias" class="sr-field-name col-md-auto">
+      <span *ngIf="withTransforms || withOperators || withValueInput" class="sr-field-explainer"><br /></span>
+      <label *ngIf="withSelect" (click)="selectAction()" class="sr-field-label" for="chk{{field.name}}">{{field.label}}</label>
+      <label *ngIf="!withSelect" class="sr-field-label">{{field.label}}</label>
+    </div>
+
+    <div *ngIf="withTransforms || withOperators" class="sr-field-xform col-md-3">
+
+      <div *ngIf="withTransforms">
+        <span class="sr-field-explainer" i18n>Transform</span>
+        <select class="form-control" (change)="transformChange($event)" [disabled]="field.force_transform">
+          <option *ngFor="let t of transforms" value="{{t.name}}" selected="{{ field.transform.name === t.name ? 'selected' : '' }}">
+            <span *ngIf="t.name === 'Bare'" i18n>Raw Data</span>
+            <span *ngIf="t.name === 'upper'" i18n>Upper Case</span>
+            <span *ngIf="t.name === 'lower'" i18n>Lower Case</span>
+            <span *ngIf="t.name === 'substring'" i18n>Substring</span>
+            <span *ngIf="t.name === 'day_name'" i18n>Day Name</span>
+            <span *ngIf="t.name === 'month_name'" i18n>Month Name</span>
+            <span *ngIf="t.name === 'doy'" i18n>Day of Year</span>
+            <span *ngIf="t.name === 'woy'" i18n>Week of Year</span>
+            <span *ngIf="t.name === 'moy'" i18n>Month of Year</span>
+            <span *ngIf="t.name === 'qoy'" i18n>Quarter of Year</span>
+            <span *ngIf="t.name === 'dom'" i18n>Day of Month</span>
+            <span *ngIf="t.name === 'dow'" i18n>Day of Week</span>
+            <span *ngIf="t.name === 'year_trunc'" i18n>Year</span>
+            <span *ngIf="t.name === 'month_trunc'" i18n>Month</span>
+            <span *ngIf="t.name === 'date_trunc'" i18n>Date</span>
+            <span *ngIf="t.name === 'hour_trunc'" i18n>Hour</span>
+            <span *ngIf="t.name === 'quarter'" i18n>Quarter</span>
+            <span *ngIf="t.name === 'months_ago'" i18n>Months Ago</span>
+            <span *ngIf="t.name === 'hod'" i18n>Hour of Day</span>
+            <span *ngIf="t.name === 'quarters_ago'" i18n>Quarters Ago</span>
+            <span *ngIf="t.name === 'age'" i18n>Age</span>
+            <span *ngIf="t.name === 'first'" i18n>First Value</span>
+            <span *ngIf="t.name === 'last'" i18n>Last Value</span>
+            <span *ngIf="t.name === 'min'" i18n>Minimum Value</span>
+            <span *ngIf="t.name === 'max'" i18n>Maximum Value</span>
+            <span *ngIf="t.name === 'count'" i18n>Count</span>
+            <span *ngIf="t.name === 'count_distinct'" i18n>Count</span>{{ '' // This is currently the only Count transform offered; if that's changed this will need its 'Distinct' back. }}
+            <span *ngIf="t.name === 'sum'" i18n>Sum</span>
+            <span *ngIf="t.name === 'average'" i18n>Average</span>
+          </option>
+        </select>
+      </div>
+
+      <div *ngIf="withOperators">
+        <span class="sr-field-explainer" i18n>Operator</span>
+        <select class="form-control" (change)="operatorChange($event)" [disabled]="field.force_operator">
+          <option *ngFor="let o of operators" value="{{o.name}}" selected="{{ field.operator.name === o.name ? 'selected' : '' }}">
+            <span *ngIf="o.name === '= any'" i18n>Equals</span> {{ '' // this and the next are used for bools only }}
+            <span *ngIf="o.name === '<> any'" i18n>Does Not Equal</span>
+            <span *ngIf="o.name === '='" i18n>Equals</span>
+            <span *ngIf="o.name === 'like'" i18n>Contains Matching Substring (Case Sensitive)</span>{{ '' // This is on hiatus along with non-distinct counting }}
+            <span *ngIf="o.name === 'ilike'" i18n>Contains String</span>
+            <span *ngIf="o.name === '>' && (field.transform.final_datatype || field.datatype) === 'timestamp'" i18n>After</span>
+            <span *ngIf="o.name === '>' && (field.transform.final_datatype || field.datatype) !== 'timestamp'" i18n>Greater Than</span>
+            <span *ngIf="o.name === '>=' && (field.transform.final_datatype || field.datatype) === 'timestamp'" i18n>On or After</span>
+            <span *ngIf="o.name === '>=' && (field.transform.final_datatype || field.datatype) !== 'timestamp'" i18n>Greater Than or Equal to</span>
+            <span *ngIf="o.name === '<' && (field.transform.final_datatype || field.datatype) === 'timestamp'" i18n>Before</span>
+            <span *ngIf="o.name === '<' && (field.transform.final_datatype || field.datatype) !== 'timestamp'" i18n>Less Than</span>
+            <span *ngIf="o.name === '<=' && (field.transform.final_datatype || field.datatype) === 'timestamp'" i18n>On or Before</span>
+            <span *ngIf="o.name === '<=' && (field.transform.final_datatype || field.datatype) !== 'timestamp'" i18n>Less Than or Equal to</span>
+            <span *ngIf="o.name === 'in'" i18n>In List</span>
+            <span *ngIf="o.name === 'not in'" i18n>Not In List</span>
+            <span *ngIf="o.name === 'between'" i18n>Between</span>
+            <span *ngIf="o.name === 'not between'" i18n>Not Between</span>
+            <span *ngIf="o.name === 'is'" i18n>Is Null</span>
+            <span *ngIf="o.name === 'is not'" i18n>Is Not Null</span>
+            <span *ngIf="o.name === 'is blank'" i18n>Is Null or Blank</span>
+            <span *ngIf="o.name === 'is not blank'" i18n>Is Not Null or Blank</span>
+          </option>
+        </select>
+      </div>
+
+    </div>
+
+    <div *ngIf="withValueInput" class="sr-field-value col-md-5">
+      <span class="sr-field-explainer" i18n>Filter value</span>
+
+      <div *ngIf="field.operator.name.indexOf('in') > -1">
+        <div [ngSwitch]="field.transform.final_datatype || field.datatype">
+          <div *ngSwitchCase="'link'">
+            <eg-multi-select [linkedLibraryLabel]="field.org_filter_field" [idlBaseQuery]="linkedIdlBaseQuery" [idlClass]="field.class" [startValue]="getBracketListValue(field.filter_value)"
+              (onChange)="setBracketListValue($event)">
+            </eg-multi-select>
+          </div>
+          <div *ngSwitchCase="'org_unit'">
+            <eg-multi-select [idlClass]="'aou'" [startValue]="getBracketListValue(field.filter_value)"
+              (onChange)="setBracketListValue($event)">
+            </eg-multi-select>
+          </div>
+          <div *ngSwitchDefault>
+            <eg-text-multi-select [startValue]="field.filter_value"
+              (onChange)="setSingleValue($event)">
+            </eg-text-multi-select>
+          </div>
+        </div>
+      </div>
+
+      <div *ngIf="field.operator.name.indexOf('between') > -1">
+        <div [ngSwitch]="field.transform.final_datatype || field.datatype">
+          <div *ngSwitchCase="'interval'">
+            <eg-interval-input [initialValue]="field.filter_value[0]" (onChange)="firstBetweenValue($event)"></eg-interval-input>
+            <span i18n>and</span>
+            <eg-interval-input [initialValue]="field.filter_value[1]" (onChange)="secondBetweenValue($event)"> </eg-interval-input>
+          </div>
+          <div *ngSwitchCase="'timestamp'">
+            <eg-date-select [initialIso]="field.filter_value[0]" (onChangeAsIso)="firstBetweenValue($event)"></eg-date-select>
+            <span i18n>and</span>
+            <eg-date-select [initialIso]="field.filter_value[1]" (onChangeAsIso)="secondBetweenValue($event)"></eg-date-select>
+          </div>
+          <div *ngSwitchCase="'link'">{{ '' }}</div>
+          <div *ngSwitchCase="'org_unit'">{{ '' }}</div>
+          <div *ngSwitchCase="'bool'">{{ '' }}</div>
+          <div *ngSwitchDefault>
+            <input class="form-control" type="textbox" value="{{field.filter_value[0]}}" (change)="firstBetweenValue($event.target.value)" />
+            <span i18n>and</span>
+            <input class="form-control" type="textbox" value="{{field.filter_value[1]}}" (change)="secondBetweenValue($event.target.value)" />
+          </div>
+        </div>
+      </div>
+
+      <div *ngIf="field.operator.name.indexOf('between') === -1 && field.operator.name.indexOf('in') === -1 && field.operator.name.indexOf('is') === -1">
+        <div [ngSwitch]="field.transform.final_datatype || field.datatype">
+          <div *ngSwitchCase="'org_unit'">
+            <eg-org-family-select
+              (onChange)="setOrgFamilyValue($event)"
+              [selectedOrgId]="field._org_family_primaryOrgId"
+              [ancestorSelectorChecked]="field._org_family_includeAncestors"
+              [descendantSelectorChecked]="field._org_family_includeDescendants">
+            </eg-org-family-select>
+          </div>
+          <div *ngSwitchCase="'link'">
+            <eg-combobox i18n-placeholder placeholder="Select..." idlClass="{{field.class}}"
+              id="{{field.name}}-{{field.class}}-{{field.key}}"
+              [idlBaseQuery]="linkedIdlBaseQuery"
+              [idlIncludeLibraryInLabel]="field.org_filter_field"
+              [asyncSupportsEmptyTermClick]="true"
+              [selectedId]="field.filter_value"
+              (onChange)="setSingleValue($event[field.key])">
+            </eg-combobox>
+          </div>
+          <div *ngSwitchCase="'timestamp'">
+            <eg-date-select [initialIso]="field.filter_value" (onChangeAsIso)="setSingleValue($event)"></eg-date-select>
+          </div>
+          <div *ngSwitchCase="'interval'">
+            <eg-interval-input [initialValue]="field.filter_value" (onChange)="setSingleValue($event)"></eg-interval-input>
+          </div>
+          <div *ngSwitchCase="'bool'">
+            <select class="form-control" (change)="setSingleValue($event.target.value)">
+              <option selected="{{ !field.filter_value ? 'selected' : '' }}" disabled="disabled" i18n>Select one</option>
+              <option value="{t}" selected="{{ field.filter_value === '{t}' ? 'selected' : '' }}" i18n>True</option>
+              <option value="{f}" selected="{{ field.filter_value === '{f}' ? 'selected' : '' }}" i18n>False</option>
+              <option value="{t,f}" selected="{{ (field.filter_value && (field.filter_value !== '{t}' && field.filter_value !== '{f}') ) ? 'selected' : '' }}" i18n>Don't Care</option>
+            </select>
+          </div>
+
+          <div *ngSwitchDefault>
+            <input class="form-control" type="textbox" value="{{field.filter_value}}" (change)="setSingleValue($event.target.value)" />
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <div *ngIf="withSortDirection" class="sr-field-sortdir col-md-5">
+      <span class="sr-field-explainer" i18n>Direction</span>
+      <select class="form-control" (change)="directionChange($event)">
+
+        <option *ngIf="field.datatype === 'timestamp'" value="ascending" selected="{{ field.direction === 'ascending' ? 'selected' : '' }}" i18n>Later dates at the bottom</option>
+        <option *ngIf="field.datatype === 'number' || field.datatype === 'int' || field.datatype === 'float' || field.datatype === 'money'"
+          value="ascending" selected="{{ field.direction === 'ascending' ? 'selected' : '' }}" i18n>Larger numbers at the bottom</option>
+        <option *ngIf="field.datatype !== 'timestamp' && field.datatype !== 'number' && field.datatype !== 'int' && field.datatype !== 'float' && field.datatype !== 'money'"
+          value="ascending" selected="{{ field.direction === 'ascending' ? 'selected' : '' }}" i18n>Ascending (1, 2, a, b, A, B)</option>
+
+        <option *ngIf="field.datatype === 'timestamp'" value="descending" selected="{{ field.direction === 'descending' ? 'selected' : '' }}" i18n>Later dates at the top</option>
+        <option *ngIf="field.datatype === 'number' || field.datatype === 'int' || field.datatype === 'float' || field.datatype === 'money'"
+          value="descending" selected="{{ field.direction === 'descending' ? 'selected' : '' }}" i18n>Larger numbers at the top</option>
+        <option *ngIf="field.datatype !== 'timestamp' && field.datatype !== 'number' && field.datatype !== 'int' && field.datatype !== 'float' && field.datatype !== 'money'"
+          value="descending" selected="{{ field.direction === 'descending' ? 'selected' : '' }}" i18n>Descending (B, A, b, a, 2, 1)</option>
+
+      </select>
+    </div>
+
+    <div *ngIf="withUpDown" class="sr-field-updown col-md-2">
+      <button (click)="upAction()" class="btn btn-outline-primary btn-sm" [disabled]="disableUp"><span class="material-icons">arrow_upward</span></button>
+      <button (click)="downAction()" class="btn btn-outline-primary btn-sm" [disabled]="disableDown"><span class="material-icons">arrow_downward</span></button>
+    </div>
+
+  </div>
+
+</div>
diff --git a/Open-ILS/src/eg2/src/app/staff/reporter/simple/sr-field.component.ts b/Open-ILS/src/eg2/src/app/staff/reporter/simple/sr-field.component.ts
new file mode 100644 (file)
index 0000000..92f126c
--- /dev/null
@@ -0,0 +1,187 @@
+import {Component, Input, Output, EventEmitter, OnInit} from '@angular/core';
+import {IdlObject} from '@eg/core/idl.service';
+import {OrgService} from '@eg/core/org.service';
+import {AuthService} from '@eg/core/auth.service';
+import {SimpleReporterService} from './simple-reporter.service';
+
+@Component({
+    selector: 'eg-sr-field',
+    templateUrl: './sr-field.component.html',
+    styleUrls: ['./sr-field.component.css'],
+})
+export class SRFieldComponent implements OnInit {
+
+    operators = [];
+    transforms = [];
+    wsContextOrgs = [];
+    linkedIdlBaseQuery = {};
+
+    @Input() field: IdlObject = null;
+    @Output() fieldChange = new EventEmitter<IdlObject>();
+    @Input() withAlias = false;
+    @Input() editAlias = true;
+    @Input() withTransforms = false;
+    @Input() withOperators = false;
+    @Input() withValueInput = false;
+    @Input() withSelect = false;
+    @Input() withDeselect = false;
+    @Input() withSortDirection = false;
+    @Output() selectEvent = new EventEmitter();
+    @Output() deselectEvent = new EventEmitter();
+    @Input() selected = false;
+    @Input() withUpDown = false;
+    @Output() upEvent = new EventEmitter();
+    @Output() downEvent = new EventEmitter();
+    @Input() disableUp = false;
+    @Input() disableDown = false;
+
+    constructor(
+        private org: OrgService,
+        private auth: AuthService,
+        private srSvc: SimpleReporterService
+    ) {
+    }
+
+    ngOnInit() {
+
+        if ( this.withTransforms ) {
+            this.transforms = this.srSvc.getTransformsForDatatype(this.field.datatype);
+        }
+
+        if ( this.withOperators ) {
+            this.operators = this.srSvc.getOperatorsForDatatype(this.field.datatype);
+        }
+
+        this.wsContextOrgs = this.org.fullPath(this.auth.user().ws_ou(), true);
+        if (this.field.org_filter_field) {
+            this.linkedIdlBaseQuery[this.field.org_filter_field] = this.wsContextOrgs;
+        }
+    }
+
+    clearFilterValue() {
+        this.field.filter_value = this.field.operator.arity > 1 ? [] : null;
+        delete this.field._org_family_includeAncestors;
+        delete this.field._org_family_includeDescendants;
+        delete this.field._org_family_primaryOrgId;
+    }
+
+    operatorChange($event) {
+        const new_op = this.srSvc.getOperatorByName($event.target.value);
+        if (new_op.arity !== this.field.operator.arity) { // param count of the old and new ops are different
+            this.field.operator = new_op;
+            this.clearFilterValue(); // clear the filter value
+        } else {
+            this.field.operator = new_op;
+        }
+        this.fieldChange.emit(this.field);
+    }
+
+    transformChange($event) {
+        const new_transform = this.srSvc.getTransformByName($event.target.value);
+
+        if (new_transform.final_datatype) { // new has a final_datatype
+            if (this.field.transform.final_datatype) { // and so does old
+                if (new_transform.final_datatype !== this.field.transform.final_datatype) { // and they're different
+                    this.clearFilterValue(); // clear
+                }
+            } else if (new_transform.final_datatype !== this.field.datatype) { // old does not, and base is different from new
+                this.clearFilterValue(); // clear
+            }
+        } else if (this.field.transform.final_datatype) {// old has a final_datatype, new doesn't
+            if (this.field.transform.final_datatype !== this.field.datatype) { // and it's different from the base type
+                this.clearFilterValue(); // clear
+            }
+        }
+
+        this.field.transform = new_transform;
+        if (new_transform.final_datatype) {
+            this.operators = this.srSvc.getOperatorsForDatatype(new_transform.final_datatype);
+        } else {
+            this.operators = this.srSvc.getOperatorsForDatatype(this.field.datatype);
+        }
+
+        this.selectEvent.emit();
+        this.fieldChange.emit(this.field);
+    }
+
+    firstBetweenValue($event) {
+        if (!Array.isArray(this.field.filter_value)) {
+            this.field.filter_value = [];
+        }
+        this.field.filter_value[0] = $event;
+        this.fieldChange.emit(this.field);
+    }
+
+    secondBetweenValue($event) {
+        if (!Array.isArray(this.field.filter_value)) {
+            this.field.filter_value = [];
+        }
+        this.field.filter_value[1] = $event;
+        this.fieldChange.emit(this.field);
+    }
+
+    setSingleValue($event) {
+        if (Array.isArray(this.field.filter_value)) {
+            this.field.filter_value = null;
+        }
+        this.field.filter_value = $event;
+        this.fieldChange.emit(this.field);
+    }
+
+    getBracketListValue(list_value) {
+        let output = '{';
+        if (Array.isArray(list_value)) {
+            list_value.forEach((v, i) => {
+                if (i > 0) {
+                    output += ',';
+                }
+                output += v;
+            });
+        }
+        output += '}';
+        return output;
+    }
+
+    setOrgFamilyValue($event) {
+        this.field.filter_value = this.getBracketListValue($event.orgIds);
+        this.field._org_family_includeAncestors = $event.includeAncestors;
+        this.field._org_family_includeDescendants = $event.includeDescendants;
+        this.field._org_family_primaryOrgId = $event.primaryOrgId;
+        this.fieldChange.emit(this.field);
+    }
+
+    setBracketListValue($event) {
+        if (Array.isArray(this.field.filter_value)) {
+            this.field.filter_value = null;
+        }
+        let valstr = $event;
+        valstr = valstr.replace(/^{/, '');
+        valstr = valstr.replace(/}$/, '');
+        const ids = valstr.split(',');
+        this.field.filter_value = [...ids];
+        this.fieldChange.emit(this.field);
+    }
+
+    directionChange($event) {
+        this.field['direction'] = $event.target.value;
+        this.fieldChange.emit(this.field);
+    }
+
+    selectAction() {
+        this.selectEvent.emit();
+    }
+
+    deselectAction() {
+        this.deselectEvent.emit();
+    }
+
+    upAction() {
+        this.upEvent.emit();
+    }
+
+    downAction() {
+        this.downEvent.emit();
+    }
+
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/reporter/simple/sr-my-outputs.component.html b/Open-ILS/src/eg2/src/app/staff/reporter/simple/sr-my-outputs.component.html
new file mode 100644 (file)
index 0000000..00007ff
--- /dev/null
@@ -0,0 +1,72 @@
+<ng-template #deletedOutputStringTmpl let-num="num" i18n>{num, plural, =1 {Output Deleted} other {{{num}} Outputs Deleted}}</ng-template>
+<eg-string #deleted [template]="deletedOutputStringTmpl"></eg-string>
+
+<ng-template #confirmDeleteOutputStringTmpl let-num="num" i18n>{num, plural, =1 {Are you sure you want to delete this output?} other {Are you sure you want to delete these {{num}} outputs?}}</ng-template>
+<eg-string #delete [template]="confirmDeleteOutputStringTmpl"></eg-string>
+
+<eg-confirm-dialog
+  #confirmDelete
+  i18n-dialogTitle
+  dialogTitle="Confirm Deletion"
+></eg-confirm-dialog>
+
+<ng-template #outputTmpl let-rpt="row">
+  <ul *ngIf="!rpt.error_code" class="list-group list-group-horizontal">
+    <li *ngIf="rpt._rs.html_format() === 't'" class="list-group-item">
+      <a href="{{outputPath(rpt, 'report-data.html.raw.html')}}" target="_blank" i18n>
+        HTML
+      </a>
+    </li>
+    <li *ngIf="rpt._rs.csv_format() === 't'" class="list-group-item">
+      <a href="{{outputPath(rpt, 'report-data.csv')}}" i18n>
+        CSV
+      </a>
+    </li>
+    <li *ngIf="rpt._rs.excel_format() === 't'" class="list-group-item">
+      <a href="{{outputPath(rpt, 'report-data.xlsx')}}" i18n>
+        Excel
+      </a>
+    </li>
+    <li *ngIf="rpt._rs.chart_line() === 't'" class="list-group-item">
+      <a href="{{outputPath(rpt, 'report-data.html.line.gif')}}" target="_blank" i18n>
+        Line Chart
+      </a>
+    </li>
+    <li *ngIf="rpt._rs.chart_bar() === 't'" class="list-group-item">
+      <a href="{{outputPath(rpt, 'report-data.html.bar.gif')}}" target="_blank" i18n>
+        Bar Chart
+      </a>
+    </li>
+  </ul>
+  <span *ngIf="rpt.error_code" i18n>
+    Error running report
+  </span>
+</ng-template>
+
+<div class="mt-2">
+  <eg-grid #srOutputsGrid
+    persistKey="reporter.simple.outputs"
+    [dataSource]="gridSource"
+    [stickyHeader]="true"
+    [filterable]="true"
+    [sortable]="true"
+    [cellTextGenerator]="cellTextGenerator"
+    [showDeclaredFieldsOnly]="true">
+
+    <eg-grid-toolbar-button label="Refresh" i18n-label
+      (onClick)="refreshGrid($event)">
+    </eg-grid-toolbar-button>
+
+    <eg-grid-toolbar-action label="Delete Output" i18n-label
+      (onClick)="deleteOutputs($event)"
+      [disableOnRows]="zeroSelectedRows">
+    </eg-grid-toolbar-action>
+
+    <eg-grid-column path="id" [hidden]=true [index]="true" i18n-label label="Run ID" [filterable]="false" [sortable]="false"></eg-grid-column>
+    <eg-grid-column path="template_name" i18n-label label="Report"></eg-grid-column>
+    <eg-grid-column path="complete_time" i18n-label label="Finish Time" datatype="timestamp" [datePlusTime]="true"></eg-grid-column>
+    <eg-grid-column path="_output"  [cellTemplate]="outputTmpl" i18n-label label="Output" [sortable]="false" [filterable]="false" [disableTooltip]="true"></eg-grid-column>
+    <eg-grid-column path="error_text"[hidden]=true i18n-label label="Error Text"></eg-grid-column>
+
+  </eg-grid>
+</div>
diff --git a/Open-ILS/src/eg2/src/app/staff/reporter/simple/sr-my-outputs.component.ts b/Open-ILS/src/eg2/src/app/staff/reporter/simple/sr-my-outputs.component.ts
new file mode 100644 (file)
index 0000000..ac9f920
--- /dev/null
@@ -0,0 +1,90 @@
+import {Component, OnInit, ViewChild} from '@angular/core';
+import {AuthService} from '@eg/core/auth.service';
+import {IdlObject, IdlService} from '@eg/core/idl.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {StringComponent} from '@eg/share/string/string.component';
+import {ToastService} from '@eg/share/toast/toast.service';
+import {ConfirmDialogComponent} from '@eg/share/dialog/confirm.component';
+import {Pager} from '@eg/share/util/pager';
+import {GridComponent} from '@eg/share/grid/grid.component';
+import {GridDataSource, GridCellTextGenerator} from '@eg/share/grid/grid';
+import {SimpleReporterService} from './simple-reporter.service';
+
+@Component({
+    selector: 'eg-sr-outputs',
+    templateUrl: 'sr-my-outputs.component.html',
+})
+
+export class SROutputsComponent implements OnInit {
+
+    gridSource: GridDataSource;
+    @ViewChild('srOutputsGrid', { static: true }) outputsGrid: GridComponent;
+    @ViewChild('confirmDelete', { static: false }) confirmDeleteDialog: ConfirmDialogComponent;
+    @ViewChild('deleted', { static: true} ) deletedString: StringComponent;
+    @ViewChild('delete', { static: true} ) confirmDeleteString: StringComponent;
+
+    cellTextGenerator: GridCellTextGenerator;
+
+    constructor(
+        private auth: AuthService,
+        private pcrud: PcrudService,
+        private idl: IdlService,
+        private toast: ToastService,
+        private srSvc: SimpleReporterService,
+    ) {
+        // These values are all replaced via custom templates and cause warnings if not specified here.
+        this.cellTextGenerator = {
+            _output: row => ''
+        };
+
+    }
+
+    ngOnInit() {
+        this.gridSource = this.srSvc.getOutputDatasource();
+
+    }
+
+    // Expects an rt object with fleshed report to grab the template id.
+    outputPath(row: any, file: string) {
+        return `/reporter/${row.template_id}/${row.report_id}/${row.id}/${file}?ses=${this.auth.token()}`;
+    }
+
+    zeroSelectedRows(rows: any) {
+        return rows.length === 0;
+    }
+
+    notOneSelectedRow(rows: any) {
+        return rows.length !== 1;
+    }
+
+    deleteOutputs(rows: any[]) {
+        if ( rows.length <= 0 ) { return; }
+        this.confirmDeleteString.current({ num: rows.length })
+        .then(str => {
+            this.confirmDeleteDialog.dialogBody = str;
+            this.confirmDeleteDialog.open()
+            .subscribe(confirmed => {
+                if ( confirmed ) { this.doDeleteOutputs(rows.map(x => x._rs)); }
+            });
+        });
+    }
+
+    doDeleteOutputs(outs: IdlObject[]) {
+        const deletedCount = outs.length;
+        this.pcrud.remove(outs).toPromise()
+        .then(res => {
+            this.outputsGrid.reload();
+            this.deletedString.current({num: outs.length})
+            .then(str => {
+                this.toast.success(str);
+            });
+        });
+
+    }
+
+    refreshGrid($event) {
+        this.outputsGrid.reload();
+    }
+
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/reporter/simple/sr-my-reports.component.html b/Open-ILS/src/eg2/src/app/staff/reporter/simple/sr-my-reports.component.html
new file mode 100644 (file)
index 0000000..06547ed
--- /dev/null
@@ -0,0 +1,66 @@
+<ng-template #deleteSuccesstringTmpl let-ct="ct" i18n>{ct, plural, =1 {Deleted 1 Report} other {Deleted {{ct}} Reports}}</ng-template>
+<eg-string #deleteSuccess i18n-text [template]="deleteSuccesstringTmpl"></eg-string>
+<ng-template #deleteFailureStringTmpl let-ct="ct" i18n>{ct, plural, =1 {Failed to Delete 1 Report} other {Failed to Delete {{ct}} Reports}}</ng-template>
+<eg-string #deleteFailure i18n-text [template]="deleteFailureStringTmpl"></eg-string>
+<ng-template #mixedResultsStringTmpl let-fail="fail" let-success="success" i18n>{fail, plural, =1 {Failed to Delete 1 Report But Succeeded In Deleting {{success}}} other {Failed to Delete {{fail}} Reports But Succeeded in Deleting {{success}}}}</ng-template>
+<eg-string #mixedResults i18n-text [template]="mixedResultsStringTmpl"></eg-string>
+<ng-template #deleteStringTmpl let-ct="ct" i18n>{ct, plural, =1 {Are you sure you want to delete this report and its output?} other {Are you sure you want to delete these {{ct}} reports and their output?}}</ng-template>
+<eg-string #delete [template]="deleteStringTmpl"></eg-string>
+<ng-template #promptCloneOutputStringTmpl let-old="old" i18n>Enter a new name for the clone of: {{old}}</ng-template>
+<eg-string #clone [template]="promptCloneOutputStringTmpl"></eg-string>
+
+<eg-string #templateSaved i18n-text text="Report Saved Successfully"></eg-string>
+<eg-string #templateSaveError i18n-text text="Error Saving Report"></eg-string>
+
+<eg-confirm-dialog
+  #confirmDelete
+  i18n-dialogTitle
+  dialogTitle="Confirm Deletion"
+></eg-confirm-dialog>
+
+<eg-prompt-dialog
+  #promptClone
+  i18n-dialogTitle
+  dialogTitle="Clone Report"
+></eg-prompt-dialog>
+
+<div class="mt-2">
+  <eg-grid #srReportsGrid
+    persistKey="reporter.simple.reports"
+    [dataSource]="gridSource"
+    [stickyHeader]="true"
+    [filterable]="true"
+    [sortable]="true"
+    [cellTextGenerator]="cellTextGenerator"
+    [showDeclaredFieldsOnly]="true"
+    (onRowActivate)="editSelected([$event])">
+  
+    <eg-grid-toolbar-button label="New" i18n-label
+      (onClick)="newReport($event)">
+    </eg-grid-toolbar-button>
+  
+    <eg-grid-toolbar-action label="Edit" i18n-label
+      (onClick)="editSelected($event)"
+      [disableOnRows]="notOneSelectedRow">
+    </eg-grid-toolbar-action>
+  
+    <eg-grid-toolbar-action label="Delete" i18n-label
+      (onClick)="deleteSelected($event)"
+      [disableOnRows]="zeroSelectedRows">
+    </eg-grid-toolbar-action>
+
+    <eg-grid-toolbar-action label="Clone" i18n-label
+      (onClick)="cloneSelected($event)"
+      [disableOnRows]="notOneSelectedRow">
+    </eg-grid-toolbar-action>
+  
+    <eg-grid-column path="rt_id" i18n-label label="Simple Report Template ID" [hidden]="true" [index]="true" [filterable]="false" [sortable]="false"></eg-grid-column>
+    <eg-grid-column path="name" i18n-label label="Report Name"></eg-grid-column>
+    <eg-grid-column path="create_time" i18n-label label="Date Created" datatype="timestamp" [datePlusTime]="true"></eg-grid-column>
+    <eg-grid-column path="edit_time" i18n-label label="Last Edited" datatype="timestamp" [datePlusTime]="true" [filterable]="false" [sortable]="false"></eg-grid-column>
+    <eg-grid-column path="last_run" i18n-label label="Last Run" datatype="timestamp" [datePlusTime]="true" [filterable]="false" [sortable]="false"></eg-grid-column>
+    <eg-grid-column path="next_run" i18n-label label="Next Run" datatype="timestamp" [datePlusTime]="true" [filterable]="false" [sortable]="false"></eg-grid-column>
+    <eg-grid-column path="recurring" i18n-label label="Recurring?" datatype="bool" [filterable]="false" [sortable]="false"></eg-grid-column>
+  
+  </eg-grid>
+<div>
diff --git a/Open-ILS/src/eg2/src/app/staff/reporter/simple/sr-my-reports.component.ts b/Open-ILS/src/eg2/src/app/staff/reporter/simple/sr-my-reports.component.ts
new file mode 100644 (file)
index 0000000..1ce844f
--- /dev/null
@@ -0,0 +1,165 @@
+import {Component, OnInit, ViewChild} from '@angular/core';
+import {Router, ActivatedRoute} from '@angular/router';
+import {map, concatMap} from 'rxjs/operators';
+import {from} from 'rxjs';
+import {AuthService} from '@eg/core/auth.service';
+import {IdlObject, IdlService} from '@eg/core/idl.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {Pager} from '@eg/share/util/pager';
+import {GridComponent} from '@eg/share/grid/grid.component';
+import {GridDataSource, GridCellTextGenerator} from '@eg/share/grid/grid';
+import {SimpleReporterService, SRTemplate} from './simple-reporter.service';
+import {StringComponent} from '@eg/share/string/string.component';
+import {ToastService} from '@eg/share/toast/toast.service';
+import {ConfirmDialogComponent} from '@eg/share/dialog/confirm.component';
+import {PromptDialogComponent} from '@eg/share/dialog/prompt.component';
+import {NetService} from '@eg/core/net.service';
+
+@Component({
+    selector: 'eg-sr-reports',
+    templateUrl: 'sr-my-reports.component.html',
+})
+
+export class SRReportsComponent implements OnInit {
+
+    gridSource: GridDataSource;
+    editSelected: ($event: any) => void;
+    newReport: ($event: any) => void;
+    @ViewChild('srReportsGrid', { static: true }) reportsGrid: GridComponent;
+    @ViewChild('confirmDelete', { static: true }) deleteDialog: ConfirmDialogComponent;
+    @ViewChild('promptClone', { static: true }) cloneDialog: PromptDialogComponent;
+    @ViewChild('delete', { static: true} ) deleteString: StringComponent;
+    @ViewChild('clone', { static: true} ) cloneString: StringComponent;
+    @ViewChild('deleteSuccess', { static: true} ) deleteSuccessString: StringComponent;
+    @ViewChild('deleteFailure', { static: true} ) deleteFailureString: StringComponent;
+    @ViewChild('mixedResults', { static: true} ) mixedResultsString: StringComponent;
+    @ViewChild('templateSaved', { static: true }) templateSavedString: StringComponent;
+    @ViewChild('templateSaveError', { static: true }) templateSaveErrorString: StringComponent;
+
+
+    cellTextGenerator: GridCellTextGenerator;
+
+    constructor(
+        private router: Router,
+        private route: ActivatedRoute,
+        private auth: AuthService,
+        private pcrud: PcrudService,
+        private idl: IdlService,
+        private srSvc: SimpleReporterService,
+        private toast: ToastService,
+        private net: NetService
+    ) {
+    }
+
+    ngOnInit() {
+        this.gridSource = this.srSvc.getReportsDatasource();
+
+        this.editSelected = ($event) => {
+                this.router.navigate(['edit', $event[0].rt_id], { relativeTo: this.route });
+        };
+
+        this.newReport = ($event) => {
+                this.router.navigate(['new'], { relativeTo: this.route });
+        };
+    }
+
+    zeroSelectedRows(rows: any) {
+        return rows.length === 0;
+    }
+
+    notOneSelectedRow(rows: any) {
+        return rows.length !== 1;
+    }
+
+    deleteSelected(rows: any) {
+        if ( rows.length <= 0 ) { return; }
+
+        let successes = 0;
+        let failures = 0;
+
+        this.deleteString.current({ct: rows.length})
+        .then(str => {
+            this.deleteDialog.dialogBody = str;
+            this.deleteDialog.open()
+            .subscribe(confirmed => {
+                if ( confirmed ) {
+                    from(rows.map(x => x.rt_id)).pipe(concatMap(rt_id =>
+                        this.net.request(
+                            'open-ils.reporter',
+                            'open-ils.reporter.template.delete.cascade',
+                            this.auth.token(),
+                            rt_id
+                        ).pipe(map(res => ({
+                            result: res,
+                            rt_id: rt_id
+                        })))
+                    )).subscribe(
+                        (res) => {
+                            if (Number(res.result) === 2) {
+                                successes++;
+                            } else {
+                                failures++;
+                            }
+                        },
+                        (err) => {},
+                        () => {
+                            if (successes === rows.length) {
+                                this.deleteSuccessString.current({ct: successes}).then(str2 => { this.toast.success(str2); });
+                            } else if (failures && !successes) {
+                                this.deleteFailureString.current({ct: failures}).then(str2 => { this.toast.danger(str2); });
+                            } else {
+                                this.mixedResultsString.current({fail: failures, success: successes})
+                                    .then(str2 => { this.toast.warning(str2); });
+                            }
+                            this.reportsGrid.reload();
+                         }
+                    );
+                }
+            });
+        });
+    }
+
+    cloneSelected(row: any) {
+        if ( row.length <= 0 ) { return; }
+        if ( row.length > 1 ) { return; }
+
+        const rt_row = row[0];
+
+        this.cloneString.current({old: rt_row.name})
+        .then(str => {
+            this.cloneDialog.dialogBody = str;
+            this.cloneDialog.promptValue = rt_row.name + ' (Clone)';
+            this.cloneDialog.open()
+            .subscribe(new_name => {
+                if ( new_name ) {
+                    this.srSvc.loadTemplate(rt_row.rt_id)
+                    .then(idl => {
+                        // build a new clone
+                        const new_templ = new SRTemplate(idl);
+                        new_templ.name = new_name;
+                        new_templ.id = -1;
+                        new_templ.isNew = true;
+                        new_templ.create_time = null;
+                        new_templ.runNow = 'now';
+                        new_templ.runTime = null;
+
+                        // and save it
+                        this.srSvc.saveTemplate(new_templ, false)
+                        .then(rt => {
+                            this.router.navigate(['edit', rt.id()], { relativeTo: this.route });
+                        },
+                        err => {
+                            this.templateSaveErrorString.current()
+                            .then(errstr => {
+                                this.toast.danger(errstr + err);
+                                console.error('Error saving template: %o', err);
+                            });
+                        });
+
+                    });
+                }
+            });
+        });
+    }
+
+}
diff --git a/Open-ILS/src/eg2/src/app/staff/reporter/simple/sr-output-options.component.html b/Open-ILS/src/eg2/src/app/staff/reporter/simple/sr-output-options.component.html
new file mode 100644 (file)
index 0000000..360d797
--- /dev/null
@@ -0,0 +1,65 @@
+<form #srOutputOptionsForm="ngForm" role="form" class="form-validated common-form">
+  <div class="form-group row">
+        <legend class="col-form-label col-sm-1 pt-0" i18n>Choose your output format(s)</legend>
+        <div class="col-sm-10">
+          <div class="form-check">
+            <input class="form-check-input" type="checkbox" id="srExcelOutput" name="srExcelOutput" [(ngModel)]="templ.excelOutput">
+            <label class="form-check-label" for="srExcelOutput" i18n>Excel Output</label>
+          </div>
+          <div class="form-check">
+            <input class="form-check-input" type="checkbox" id="srCsvOutput" name="srCsvOutput" [(ngModel)]="templ.csvOutput">
+            <label class="form-check-label" for="srCsvOutput" i18n>CSV Output</label>
+          </div>
+          <div class="form-check">
+            <input class="form-check-input" type="checkbox" id="srHtmlOutput" name="srHtmlOutput" [(ngModel)]="templ.htmlOutput">
+            <label class="form-check-label" for="srHtmlOutput" i18n>HTML Output</label>
+          </div>
+          <div class="form-check">
+            <input class="form-check-input" type="checkbox" id="srBarCharts" name="srBarCharts" [(ngModel)]="templ.barCharts">
+            <label class="form-check-label" for="srBarCharts" i18n>Bar Chart</label>
+          </div>
+          <div class="form-check">
+            <input class="form-check-input" type="checkbox" id="srLineCharts" name="srLineCharts" [(ngModel)]="templ.lineCharts">
+            <label class="form-check-label" for="srLineCharts" i18n>Line Chart</label>
+          </div>
+        </div>
+  </div>
+  <div class="form-group row">
+    <legend class="col-form-label col-sm-1 pt-0" i18n>Recurrence</legend>
+    <div class="col-sm-10 form-inline">
+      <div class="form-check mr-sm-2">
+        <input class="form-check-input" type="checkbox" id="srRecurring" name="srRecurring" [(ngModel)]="templ.recurring">
+        <label class="form-check-label" for="srRecurring" i18n>Recurring Report?</label>
+      </div>
+      <label *ngIf="templ.recurring" class="mr-sm-2" for="srRecurrenceInterval">Recurrence Interval</label>
+      <eg-interval-input *ngIf="templ.recurring" [(ngModel)]="templ.recurrence" id="srRecurrenceInterval" name="srRecurrenceInterval">
+      </eg-interval-input>
+    </div>
+  </div>
+  <div class="form-group row">{{ '' // Can't use form-inline here because it breaks the calendar display }}
+    <legend class="col-form-label col-sm-1 pt-0" i18n>Scheduling</legend>
+    <div class="col-sm-10 ">
+      <div class="form-check form-check-inline">
+        <input class="form-check-input" type="radio" id="srRunNow" name="srRun" value="now" [(ngModel)]="templ.runNow">
+        <label class="form-check-label" for="srRunNow" i18n>Run Report Now</label>
+      </div>
+      <div class="form-check form-check-inline">
+        <input class="form-check-input" type="radio" id="srScheduleLater" name="srRun" value="later" (change)="defaultTime()" [(ngModel)]="templ.runNow">
+        <label class="form-check-label" for="srScheduleLater" i18n>Schedule Report For Later</label>
+      </div>
+      <div class="col-sm-3">
+        <eg-datetime-select *ngIf="templ.runNow === 'later'" [(ngModel)]="templ.runTime" name="srRunTime"></eg-datetime-select>
+      </div>
+    </div>
+  </div>
+  <div class="form-group row">
+    <legend class="col-form-label col-sm-1 pt-0" i18n>Email</legend>
+    <div class="col-sm-10 form-inline">
+      <label class="form-control-label mr-sm-2" for="srEmail" i18n>Email Address</label>
+      <input class="form-control" type="text" id="srEmail" name="srEmail" [(ngModel)]="templ.email">
+    </div>
+  </div>
+  <div class="form-group row">
+    <button class="btn btn-success" (click)="saveTemplate(true)" [disabled]="!readyToSchedule()" i18n>Save and Schedule Report</button>
+  </div>
+</form>
diff --git a/Open-ILS/src/eg2/src/app/staff/reporter/simple/sr-output-options.component.ts b/Open-ILS/src/eg2/src/app/staff/reporter/simple/sr-output-options.component.ts
new file mode 100644 (file)
index 0000000..5c2398d
--- /dev/null
@@ -0,0 +1,34 @@
+import {Component, Input, Output, EventEmitter, OnInit, ViewChild} from '@angular/core';
+import {NgForm} from '@angular/forms';
+import {IdlService, IdlObject} from '@eg/core/idl.service';
+import {SimpleReporterService, SRTemplate} from './simple-reporter.service';
+import * as moment from 'moment-timezone';
+
+@Component({
+  selector: 'eg-sr-output-options',
+  templateUrl: './sr-output-options.component.html'
+})
+
+export class SROutputOptionsComponent implements OnInit {
+
+    @Input() templ: SRTemplate;
+    @Input() readyToSchedule: () => boolean;
+    @Input() saveTemplate: (args: any) => void;
+
+    constructor(
+        private idl: IdlService,
+        private srSvc: SimpleReporterService
+    ) { }
+
+    ngOnInit() {}
+
+    defaultTime() {
+        // When changing to Later for the first time default minutes to the quarter hour
+        if (this.templ.runNow === 'later' && this.templ.runTime === null) {
+            const now = moment();
+            const nextQ = now.add(15 - (now.minutes() % 15), 'minutes');
+            this.templ.runTime = nextQ;
+        }
+    }
+
+}
diff --git a/Open-ILS/src/eg2/src/app/staff/reporter/simple/sr-sort-order.component.css b/Open-ILS/src/eg2/src/app/staff/reporter/simple/sr-sort-order.component.css
new file mode 100644 (file)
index 0000000..3966475
--- /dev/null
@@ -0,0 +1,5 @@
+.order-row {
+  display: flex;
+  justify-content: space-between;
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/reporter/simple/sr-sort-order.component.html b/Open-ILS/src/eg2/src/app/staff/reporter/simple/sr-sort-order.component.html
new file mode 100644 (file)
index 0000000..1ff3856
--- /dev/null
@@ -0,0 +1,59 @@
+<div class="order-row">
+
+<ngb-accordion #displayList="ngbAccordion" activeIds="display-field-list" class="col-md-6">
+
+  <ngb-panel id="display-field-list">
+    <ng-template ngbPanelHeader let-opened=true>
+      <h5 class="m-0" i18n>Field Display Order</h5>
+    </ng-template>
+    <ng-template ngbPanelContent>
+
+      <ng-container *ngFor="let f of fields; index as idx">
+        <eg-sr-field
+          [field]=f
+          [withAlias]=true
+          [withTransforms]=true
+          [withUpDown]=true
+          [disableUp]="idx === 0"
+          [disableDown]="idx === (fields.length - 1)"
+          (fieldChange)="updateField($event)"
+          (upEvent)="moveDisplayUp(idx)"
+          (downEvent)="moveDisplayDown(idx)"
+        >
+        </eg-sr-field>
+      </ng-container>
+
+    </ng-template>
+  </ngb-panel>
+
+</ngb-accordion>
+
+<ngb-accordion #orderList="ngbAccordion" activeIds="order-field-list" class="col-md-6">
+  <ngb-panel id="order-field-list">
+    <ng-template ngbPanelHeader let-opened=true>
+      <h5 class="m-0" i18n>Field Sort Order</h5>
+    </ng-template>
+    <ng-template ngbPanelContent>
+
+      <ng-container *ngFor="let f of fieldsInOrderByOrder(); index as idx">
+        <eg-sr-field
+          [field]=f
+          [withAlias]=true
+          [editAlias]=false
+          [withUpDown]=true
+          [withSortDirection]=true
+          [disableUp]="idx === 0"
+          [disableDown]="idx === (fields.length - 1)"
+          (fieldChange)="updateField($event)"
+          (upEvent)="moveOrderUp(idx)"
+          (downEvent)="moveOrderDown(idx)"
+        >
+        </eg-sr-field>
+      </ng-container>
+
+    </ng-template>
+  </ngb-panel>
+
+</ngb-accordion>
+
+</div>
diff --git a/Open-ILS/src/eg2/src/app/staff/reporter/simple/sr-sort-order.component.ts b/Open-ILS/src/eg2/src/app/staff/reporter/simple/sr-sort-order.component.ts
new file mode 100644 (file)
index 0000000..75b7e32
--- /dev/null
@@ -0,0 +1,83 @@
+import {Component, Input, Output, EventEmitter, OnInit, ViewChild} from '@angular/core';
+import {NgbAccordion} from '@ng-bootstrap/ng-bootstrap';
+import {IdlService, IdlObject} from '@eg/core/idl.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {SimpleReporterService} from './simple-reporter.service';
+import {SRFieldComponent} from './sr-field.component';
+
+@Component({
+    selector: 'eg-sr-sort-order',
+    styleUrls: ['./sr-sort-order.component.css'],
+    templateUrl: './sr-sort-order.component.html'
+})
+
+export class SRSortOrderComponent implements OnInit {
+
+    @Input() fields: IdlObject[] = [];
+    @Output() fieldsChange = new EventEmitter<IdlObject[]>();
+    @Input() orderByNames: string[] = [];
+    @Output() orderByNamesChange = new EventEmitter<string[]>();
+
+    @ViewChild('displayList', { static: false }) displayList: NgbAccordion;
+    @ViewChild('orderList', { static: false }) orderList: NgbAccordion;
+
+    constructor(
+        private idl: IdlService,
+        private srSvc: SimpleReporterService
+    ) {
+    }
+
+    ngOnInit() {
+    }
+
+    updateField(field: IdlObject) {
+        const idx = this.fields.findIndex(el => el.name === field.name);
+        this.fields[idx] = field;
+        this.fieldsChange.emit(this.fields);
+    }
+
+    moveDisplayUp(idx: number) {
+        if ( idx > 0 ) { // should always be the case, but we check anyway
+            const hold: IdlObject = this.fields[idx - 1];
+            this.fields[idx - 1] = this.fields[idx];
+            this.fields[idx] = hold;
+            this.fieldsChange.emit(this.fields);
+        }
+    }
+
+    moveDisplayDown(idx: number) {
+        if ( idx < this.fields.length ) { // see above comment
+            const hold: IdlObject = this.fields[idx + 1];
+            this.fields[idx + 1] = this.fields[idx];
+            this.fields[idx] = hold;
+            this.fieldsChange.emit(this.fields);
+        }
+    }
+
+    moveOrderUp(idx: number) {
+        if ( idx > 0 ) {
+            const hold: string = this.orderByNames[idx - 1];
+            this.orderByNames[idx - 1] = this.orderByNames[idx];
+            this.orderByNames[idx] = hold;
+            this.orderByNamesChange.emit(this.orderByNames);
+        }
+    }
+
+    moveOrderDown(idx: number) {
+        if ( idx < this.orderByNames.length ) {
+            const hold: string = this.orderByNames[idx + 1];
+            this.orderByNames[idx + 1] = this.orderByNames[idx];
+            this.orderByNames[idx] = hold;
+            this.orderByNamesChange.emit(this.orderByNames);
+        }
+    }
+
+    fieldsInOrderByOrder() {
+        const sorted = [];
+        this.orderByNames.forEach(el => {
+            sorted.push(this.fields[this.fields.findIndex(fl => fl.name === el)]);
+        });
+        return sorted;
+    }
+
+}