LP1840050 org unit admin UI WIP
authorBill Erickson <berickxx@gmail.com>
Mon, 12 Aug 2019 20:16:01 +0000 (16:16 -0400)
committerBill Erickson <berickxx@gmail.com>
Fri, 16 Aug 2019 20:39:49 +0000 (16:39 -0400)
Angular port of Admin => Server Administration => Organizational Units.

Initial UI component.

Adds IDL labels and required attributes for Org Unit Addresses

Org Select sanity checks on selected org being unset

Signed-off-by: Bill Erickson <berickxx@gmail.com>
Open-ILS/examples/fm_IDL.xml
Open-ILS/src/eg2/src/app/share/org-select/org-select.component.html
Open-ILS/src/eg2/src/app/share/org-select/org-select.component.ts
Open-ILS/src/eg2/src/app/staff/admin/server/admin-server-splash.component.html
Open-ILS/src/eg2/src/app/staff/admin/server/admin-server.module.ts
Open-ILS/src/eg2/src/app/staff/admin/server/org-unit.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/admin/server/org-unit.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/admin/server/routing.module.ts

index d28d708..cb2b8bf 100644 (file)
@@ -6205,17 +6205,17 @@ SELECT  usr,
        </class>
        <class id="aoa" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="actor::org_address" oils_persist:tablename="actor.org_address" reporter:label="Org Address">
                <fields oils_persist:primary="id" oils_persist:sequence="actor.org_address_id_seq">
-                       <field name="address_type"  reporter:datatype="text"/>
-                       <field name="city"  reporter:datatype="text"/>
-                       <field name="country"  reporter:datatype="text"/>
+                       <field name="address_type"  reporter:datatype="text" oils_obj:required="true"/>
+                       <field name="city"  reporter:datatype="text" oils_obj:required="true"/>
+                       <field name="country"  reporter:datatype="text" oils_obj:required="true"/>
                        <field name="county"  reporter:datatype="text"/>
                        <field name="id" reporter:datatype="id" />
-                       <field name="org_unit" reporter:datatype="org_unit"/>
-                       <field name="post_code"  reporter:datatype="text"/>
+                       <field name="org_unit" reporter:datatype="org_unit" oils_obj:required="true"/>
+                       <field name="post_code"  reporter:datatype="text" oils_obj:required="true"/>
                        <field name="state"  reporter:datatype="text"/>
-                       <field name="street1"  reporter:datatype="text"/>
+                       <field name="street1"  reporter:datatype="text" oils_obj:required="true"/>
                        <field name="street2"  reporter:datatype="text"/>
-                       <field name="valid" reporter:datatype="bool"/>
+                       <field name="valid" reporter:datatype="bool" oils_obj:required="true"/>
                        <field name="san" reporter:datatype="text" reporter:label="SAN"/>
                </fields>
                <links>
@@ -6742,14 +6742,14 @@ SELECT  usr,
                        <field reporter:label="Organizational Unit ID" name="id" reporter:datatype="org_unit" reporter:selector="shortname"/>
                        <field reporter:label="ILL Receiving Address" name="ill_address" reporter:datatype="link"/>
                        <field reporter:label="Mailing Address" name="mailing_address" reporter:datatype="link"/>
-                       <field reporter:label="Name" name="name" reporter:datatype="text" oils_persist:i18n="true"/>
-                       <field reporter:label="Organizational Unit Type" name="ou_type" reporter:datatype="link"/>
+                       <field reporter:label="Name" name="name" reporter:datatype="text" oils_persist:i18n="true" oils_obj:required="true"/>
+                       <field reporter:label="Organizational Unit Type" name="ou_type" reporter:datatype="link" oils_obj:required="true"/>
                        <field reporter:label="Parent Organizational Unit" name="parent_ou" reporter:datatype="link"/>
                        <field reporter:label="Short (Policy) Name" name="shortname" reporter:datatype="text" oils_obj:required="true" oils_obj:validate="^.+$"/>
                        <field reporter:label="Email Address" name="email" reporter:datatype="text"/>
                        <field reporter:label="Phone Number" name="phone" reporter:datatype="text"/>
                        <field reporter:label="OPAC Visible" name="opac_visible" reporter:datatype="bool"/>
-                       <field reporter:label="Fiscal Calendar" name="fiscal_calendar" reporter:datatype="link"/>
+                       <field reporter:label="Fiscal Calendar" name="fiscal_calendar" reporter:datatype="link" oils_obj:required="true"/>
                        <field reporter:label="Users" name="users" oils_persist:virtual="true" reporter:datatype="link"/>
                        <field reporter:label="Closed Dates" name="closed_dates" oils_persist:virtual="true" reporter:datatype="link"/>
                        <field reporter:label="Circulations" name="circulations" oils_persist:virtual="true" reporter:datatype="link"/>
index d49217c..32bc94e 100644 (file)
@@ -4,7 +4,7 @@
 {{r.label}}
 </ng-template>
 
-<ng-container *ngIf="readOnly">
+<ng-container *ngIf="readOnly && selected">
   <span>{{selected.label}}</span>
 </ng-container>
 
index dee3248..dc14deb 100644 (file)
@@ -182,7 +182,7 @@ export class OrgSelectComponent implements OnInit {
     }
 
     // Remove the tree-padding spaces when matching.
-    formatter = (result: OrgDisplay) => result.label.trim();
+    formatter = (result: OrgDisplay) => result ? result.label.trim() : '';
 
     // reset the state of the component
     reset() {
index 99cb478..7c62f20 100644 (file)
@@ -76,7 +76,7 @@
     <eg-link-table-link i18n-label label="Org Unit Setting Types"  
       routerLink="/staff/admin/server/config/org_unit_setting_type"></eg-link-table-link>
     <eg-link-table-link i18n-label label="Organizational Units"  
-      url="/eg/staff/admin/server/legacy/actor/org_unit"></eg-link-table-link>
+      routerLink="/staff/admin/server/actor/org_unit"></eg-link-table-link>
     <eg-link-table-link i18n-label label="Permission Groups"  
       url="/eg/staff/admin/server/legacy/permission/grp_tree"></eg-link-table-link>
     <eg-link-table-link i18n-label label="Permissions"  
index 8e76239..a7fe825 100644 (file)
@@ -5,11 +5,13 @@ import {AdminServerRoutingModule} from './routing.module';
 import {AdminCommonModule} from '@eg/staff/admin/common.module';
 import {AdminServerSplashComponent} from './admin-server-splash.component';
 import {OrgUnitTypeComponent} from './org-unit-type.component';
+import {OrgUnitComponent} from './org-unit.component';
 
 @NgModule({
   declarations: [
       AdminServerSplashComponent,
-      OrgUnitTypeComponent
+      OrgUnitTypeComponent,
+      OrgUnitComponent
   ],
   imports: [
     AdminCommonModule,
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/server/org-unit.component.html b/Open-ILS/src/eg2/src/app/staff/admin/server/org-unit.component.html
new file mode 100644 (file)
index 0000000..3aad241
--- /dev/null
@@ -0,0 +1,154 @@
+<eg-staff-banner bannerText="Org Unit Configuration" i18n-bannerText>
+</eg-staff-banner>
+
+<ng-template #editStrTmpl i18n>Update Succeeded</ng-template>
+<eg-string #editString [template]="editStrTmpl"></eg-string>
+
+<ng-template #errorStrTmpl i18n>Update Failed</ng-template>
+<eg-string #errorString [template]="errorStrTmpl"></eg-string>
+
+<eg-confirm-dialog #delConfirm 
+  i18n-dialogTitle i18n-dialogBody
+  dialogTitle="Confirm Delete"
+  dialogBody="Delete Org Unit {{selected ? selected.label : ''}}?">
+</eg-confirm-dialog>
+
+<ng-template #treeNodeLabelTmpl let-org="org">
+  <span *ngIf="org" i18n>{{org.name()}} ({{org.shortname()}})</span>
+</ng-template>
+<eg-string #treeNodeLabel key='admin.server.org_unit.treenode' 
+  [template]="treeNodeLabelTmpl"></eg-string>
+
+<div class="row">
+  <div class="col-lg-4">
+    <h3 i18n>Org Units</h3>
+    <eg-tree [tree]="tree" (nodeClicked)="nodeClicked($event)"></eg-tree>
+  </div>
+  <div class="col-lg-8">
+    <div class="alert alert-info">
+      <div *ngIf="selected" i18n>
+        {{currentOrg().name()}} ({{currentOrg().shortname()}})
+      </div>
+    </div>
+    <ngb-tabset #rootTabs (tabChange)="tabChanged($event)">
+      <ngb-tab title="Main Settings" i18n-title id="main">
+        <ng-template ngbTabContent>
+          <div class="mt-2">
+            <eg-fm-record-editor *ngIf="currentOrg()" #editDialog idlClass="aou" 
+              [mode]="currentOrg().isnew() ? 'create': 'update'" [hideBanner]="true" 
+              (recordSaved)="orgSaved($event)" displayMode="inline" 
+              readonlyFields="parent,ou_type,parent_ou" 
+              [record]="currentOrg().isnew() ? currentOrg() : null"
+              [recordId]="currentOrg().isnew() ? null : currentOrg().id()"
+              fieldOrder="parent_ou,ou_type,name,shortname,phone,email,opac_visible,fiscal_calendar"
+              hiddenFields="id,billing_address,mailing_address,holds_address,ill_address">
+            </eg-fm-record-editor>
+          </div>
+        </ng-template>
+      </ngb-tab>
+      <ngb-tab title="Hours of Operation" i18n-title id="hours">
+        <ng-template ngbTabContent>
+          <div class="mt-2 common-form striped-even">
+            <div class="row font-weight-bold mb-2">
+              <div class="col-lg-3 offset-lg-2" i18n>Open Time</div>
+              <div class="col-lg-3" i18n>Close Time</div>
+            </div>
+            <div class="row mb-2" *ngFor="let dow of [0,1,2,3,4,5,6]">
+              <div class="col-lg-2" [ngSwitch]="dow">
+                <span *ngSwitchCase="0" i18n>Monday</span>
+                <span *ngSwitchCase="1" i18n>Tuesday</span>
+                <span *ngSwitchCase="2" i18n>Wednesday</span>
+                <span *ngSwitchCase="3" i18n>Thursday</span>
+                <span *ngSwitchCase="4" i18n>Friday</span>
+                <span *ngSwitchCase="5" i18n>Saturday</span>
+                <span *ngSwitchCase="6" i18n>Sunday</span>
+              </div>
+              <div class="col-lg-3">
+                <input class="form-control" type='time' step="60" 
+                  [ngModel]="hours(dow, 'open')" min="00:00:00" max="23:59:59"
+                  (ngModelChange)="hours(dow, 'open', $event)"/>
+              </div>
+              <div class="col-lg-3">
+                <input  class="form-control" type='time' step="60"
+                  [ngModel]="hours(dow, 'close')" min="00:00:00" max="23:59:59"
+                  (ngModelChange)="hours(dow, 'close', $event)"/>
+              </div>
+              <div class="col-lg-2">
+                <button class="btn btn-outline-dark" (click)="closedOn(dow)" 
+                  [disabled]="isClosed(dow)" i18n>Closed</button>
+              </div>
+            </div>
+            <div class="row d-flex justify-content-end">
+              <div>
+                <button class="btn btn-info" (click)="saveHours()" i18n>
+                  Apply Changes
+                </button>
+              </div>
+              <div class="col-lg-2"><!-- alignment --></div>
+            </div>
+          </div>
+        </ng-template>
+      </ngb-tab>
+      <ngb-tab title="Addresses" i18n-title id="addresses">
+        <ng-template ngbTabContent>
+          <div class="mt-2">
+            <ngb-tabset #addressTabs>
+              <ngb-tab title="Physical Address" i18n-title id="physical">
+                <ng-template ngbTabContent>
+                  <eg-fm-record-editor idlClass="aoa" readonlyFields="org_unit" 
+                    [mode]="currentOrg().billing_address().isnew() ? 'create': 'update'" 
+                    [hideBanner]="true" displayMode="inline" hiddenFields="id"
+                    (recordSaved)="orgSaved()"
+                    [recordId]="currentOrg().billing_address().isnew() ? null : currentOrg().billing_address().id()"
+                    [record]="currentOrg().billing_address().isnew() ? currentOrg().billing_address() : null"
+                    fieldOrder="address_type,street1,street2,city,county,state,country,post_code,san,valid"
+                    >
+                  </eg-fm-record-editor>
+                </ng-template>
+              </ngb-tab>
+              <ngb-tab title="Holds Address" i18n-title id="holds">
+                <ng-template ngbTabContent>
+                  <eg-fm-record-editor idlClass="aoa" readonlyFields="org_unit" 
+                    [mode]="currentOrg().holds_address().isnew() ? 'create': 'update'" 
+                    [hideBanner]="true" displayMode="inline" hiddenFields="id"
+                    (recordSaved)="orgSaved()"
+                    [recordId]="currentOrg().holds_address().isnew() ? null : currentOrg().holds_address().id()"
+                    [record]="currentOrg().holds_address().isnew() ? currentOrg().holds_address() : null"
+                    fieldOrder="address_type,street1,street2,city,county,state,country,post_code,san,valid"
+                    >
+                  </eg-fm-record-editor>
+                </ng-template>
+              </ngb-tab>
+              <ngb-tab title="Mailing Address" i18n-title id="mailing">
+                <ng-template ngbTabContent>
+                  <eg-fm-record-editor idlClass="aoa" readonlyFields="org_unit" 
+                    [mode]="currentOrg().mailing_address().isnew() ? 'create': 'update'" 
+                    [hideBanner]="true" displayMode="inline" hiddenFields="id"
+                    (recordSaved)="orgSaved()"
+                    [recordId]="currentOrg().mailing_address().isnew() ? null : currentOrg().mailing_address().id()"
+                    [record]="currentOrg().mailing_address().isnew() ? currentOrg().mailing_address() : null"
+                    fieldOrder="address_type,street1,street2,city,county,state,country,post_code,san,valid"
+                    >
+                  </eg-fm-record-editor>
+                </ng-template>
+              </ngb-tab>
+              <ngb-tab title="ILL Address" i18n-title id="ill">
+                <ng-template ngbTabContent>
+                  <eg-fm-record-editor idlClass="aoa" readonlyFields="org_unit" 
+                    [mode]="currentOrg().ill_address().isnew() ? 'create': 'update'" 
+                    [hideBanner]="true" displayMode="inline" hiddenFields="id"
+                    (recordSaved)="orgSaved()"
+                    [recordId]="currentOrg().ill_address().isnew() ? null : currentOrg().ill_address().id()"
+                    [record]="currentOrg().ill_address().isnew() ? currentOrg().ill_address() : null"
+                    fieldOrder="address_type,street1,street2,city,county,state,country,post_code,san,valid"
+                    >
+                  </eg-fm-record-editor>
+                </ng-template>
+              </ngb-tab>
+            </ngb-tabset>
+          </div>
+        </ng-template>
+      </ngb-tab>
+    </ngb-tabset>
+  </div>
+</div>
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/server/org-unit.component.ts b/Open-ILS/src/eg2/src/app/staff/admin/server/org-unit.component.ts
new file mode 100644 (file)
index 0000000..6afef79
--- /dev/null
@@ -0,0 +1,232 @@
+import {Component, Input, ViewChild, OnInit} from '@angular/core';
+import {Tree, TreeNode} from '@eg/share/tree/tree';
+import {IdlService, IdlObject} from '@eg/core/idl.service';
+import {NgbTabset, NgbTabChangeEvent} from '@ng-bootstrap/ng-bootstrap';
+import {OrgService} from '@eg/core/org.service';
+import {AuthService} from '@eg/core/auth.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {ToastService} from '@eg/share/toast/toast.service';
+import {StringComponent} from '@eg/share/string/string.component';
+import {StringService} from '@eg/share/string/string.service';
+import {ConfirmDialogComponent} from '@eg/share/dialog/confirm.component';
+import {FmRecordEditorComponent} from '@eg/share/fm-editor/fm-editor.component';
+
+@Component({
+    templateUrl: './org-unit.component.html'
+})
+export class OrgUnitComponent implements OnInit {
+
+    tree: Tree;
+    selected: TreeNode;
+    @ViewChild('editString') editString: StringComponent;
+    @ViewChild('errorString') errorString: StringComponent;
+    @ViewChild('delConfirm') delConfirm: ConfirmDialogComponent;
+
+    constructor(
+        private idl: IdlService,
+        private org: OrgService,
+        private auth: AuthService,
+        private pcrud: PcrudService,
+        private strings: StringService,
+        private toast: ToastService
+    ) {}
+
+
+    ngOnInit() {
+        this.loadAouTree(this.org.root().id());
+    }
+
+    tabChanged(evt: NgbTabChangeEvent) {
+        const tab = evt.nextId;
+        // stubbing out in case we need it.
+    }
+
+    orgSaved(orgId: number) {
+        if (!orgId && this.currentOrg()) {
+            orgId = this.currentOrg().id();
+        }
+        this.loadAouTree(orgId).then(_ => this.postUpdate(this.editString));
+    }
+
+    loadAouTree(selectNodeId?: number): Promise<any> {
+        return this.pcrud.search('aou', {parent_ou : null},
+            {flesh : -1, flesh_fields : {aou : [
+                'children', 'ou_type', 'hours_of_operation', 'ill_address',
+                'holds_address', 'mailing_address', 'billing_address'
+            ]}}, {authoritative: true}
+        ).toPromise().then(tree => {
+            this.ingestAouTree(tree);
+            if (selectNodeId) {
+                const node = this.tree.findNode(selectNodeId);
+                this.selected = node;
+                this.tree.selectNode(node);
+            }
+        });
+    }
+
+    // Translate the org unt type tree into a structure EgTree can use.
+    ingestAouTree(aouTree) {
+
+        const handleNode = (orgNode: IdlObject): TreeNode => {
+            if (!orgNode) { return; }
+
+            if (!orgNode.hours_of_operation()) {
+                this.generateHours(orgNode);
+            }
+
+            this.addAddresses(orgNode);
+
+            const treeNode = new TreeNode({
+                id: orgNode.id(),
+                label: orgNode.name(),
+                callerData: {orgUnit: orgNode}
+            });
+
+            // Apply the compiled label asynchronously
+            this.strings.interpolate(
+                'admin.server.org_unit.treenode', {org: orgNode}
+            ).then(label => treeNode.label = label);
+
+            orgNode.children().forEach(childNode =>
+                treeNode.children.push(handleNode(childNode))
+            );
+
+            return treeNode;
+        };
+
+        const rootNode = handleNode(aouTree);
+        this.tree = new Tree(rootNode);
+    }
+
+    nodeClicked($event: any) {
+        this.selected = $event;
+    }
+
+    // Fills the org unit in with 'new' addresses for any fields
+    // that are missing.
+    addAddresses(org: IdlObject) {
+        ['billing_address', 'mailing_address', 'holds_address', 'ill_address']
+        .forEach(addrType => {
+            if (org[addrType]()) { return ; }
+            const addr = this.idl.create('aoa');
+            addr.isnew(true);
+            addr.valid('t');
+            addr.org_unit(org.id());
+            org[addrType](addr);
+        });
+    }
+
+    generateHours(org: IdlObject) {
+        const hours = this.idl.create('aouhoo');
+        hours.id(org.id());
+        hours.isnew(true);
+
+        [0, 1, 2, 3, 4, 5, 6].forEach(dow => {
+            this.hours(dow, 'open', '09:00:00', hours);
+            this.hours(dow, 'close', '17:00:00', hours);
+        });
+
+        org.hours_of_operation(hours);
+    }
+
+    // if a 'value' is passed, it will be applied to the optional
+    // hours-of-operation object, otherwise the hours on the currently
+    // selected org unit.
+    hours(dow: number, which: 'open' | 'close', value?: string, hoo?: IdlObject): string {
+        if (!hoo && !this.selected) { return null; }
+
+        const hours = hoo || this.selected.callerData.orgUnit.hours_of_operation();
+
+        if (value) {
+            hours[`dow_${dow}_${which}`](value);
+            hours.ischanged(true);
+        }
+
+        return hours[`dow_${dow}_${which}`]();
+    }
+
+    isClosed(dow: number): boolean {
+        return (
+            this.hours(dow, 'open') === '00:00:00' &&
+            this.hours(dow, 'close') === '00:00:00'
+        );
+    }
+
+    closedOn(dow: number) {
+        this.hours(dow, 'open', '00:00:00');
+        this.hours(dow, 'close', '00:00:00');
+    }
+
+    saveHours() {
+        const org = this.currentOrg();
+        const hours = org.hours_of_operation();
+        this.pcrud.autoApply(hours).subscribe(
+            result => {
+                console.debug('Hours saved ', result);
+                this.editString.current()
+                    .then(msg => this.toast.success(msg));
+            },
+            error => {
+                this.errorString.current()
+                    .then(msg => this.toast.danger(msg));
+            },
+            () => this.loadAouTree(this.selected.id)
+        );
+    }
+
+    currentOrg(): IdlObject {
+        return this.selected ? this.selected.callerData.orgUnit : null;
+    }
+
+    postUpdate(message: StringComponent) {
+        // Modifying org unit types means refetching the org unit
+        // data normally fetched on page load, since it includes
+        // org unit type data.
+        this.org.fetchOrgs().then(() =>
+            message.current().then(str => this.toast.success(str)));
+    }
+
+    remove() {
+        this.delConfirm.open().subscribe(confirmed => {
+            if (!confirmed) { return; }
+
+            const org = this.selected.callerData.orgUnit;
+
+            this.pcrud.remove(org).subscribe(
+                ok2 => {},
+                err => {
+                    this.errorString.current()
+                      .then(str => this.toast.danger(str));
+                },
+                ()  => {
+                    // Avoid updating until we know the entire
+                    // pcrud action/transaction completed.
+                    // After removal, select the parent org if available
+                    // otherwise the root org.
+                    const orgId = org.parent_ou() ?
+                        org.parent_ou() : this.org.root().id();
+                    this.loadAouTree(orgId).then(_ =>
+                        this.postUpdate(this.editString));
+                }
+            );
+        });
+    }
+
+    addChild() {
+        const parentTreeNode = this.selected;
+        const parentOrg = parentTreeNode.callerData.orgUnit;
+
+        const org = this.idl.create('aou');
+        org.isnew(true);
+        org.parent(parentOrg.id());
+        // TODO: limit org type selector to types at parent depth + 1
+
+        // Create a dummy, detached org node to keep the UI happy.
+        this.selected = new TreeNode({
+            id: org.id(),
+            label: org.name(),
+            callerData: {orgUnit: org}
+        });
+    }
+}
+
index c971ed7..aa4ec31 100644 (file)
@@ -3,6 +3,7 @@ import {RouterModule, Routes} from '@angular/router';
 import {AdminServerSplashComponent} from './admin-server-splash.component';
 import {BasicAdminPageComponent} from '@eg/staff/admin/basic-admin-page.component';
 import {OrgUnitTypeComponent} from './org-unit-type.component';
+import {OrgUnitComponent} from './org-unit.component';
 
 const routes: Routes = [{
     path: 'splash',
@@ -11,6 +12,9 @@ const routes: Routes = [{
     path: 'actor/org_unit_type',
     component: OrgUnitTypeComponent
 }, {
+    path: 'actor/org_unit',
+    component: OrgUnitComponent
+}, {
     path: ':schema/:table',
     component: BasicAdminPageComponent
 }];