LP1840050 Angular Org Unit Admin page port
authorBill Erickson <berickxx@gmail.com>
Fri, 16 Aug 2019 21:01:51 +0000 (17:01 -0400)
committerGalen Charlton <gmc@equinoxinitiative.org>
Fri, 6 Sep 2019 17:00:12 +0000 (13:00 -0400)
Migrate the Dojo Org Unit administration page to Angular:

Administration => Server Administration => Organizational Units

Includes org unit, hours of operation, and address settings.

The new UI adds details on which org unit addresses are shared, offering a
way to clone a shared address to a new address.

Includes release notes.

Signed-off-by: Bill Erickson <berickxx@gmail.com>
Signed-off-by: Galen Charlton <gmc@equinoxinitiative.org>
Open-ILS/src/eg2/src/app/core/org.service.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-addr.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/admin/server/org-addr.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/admin/server/org-unit-routing.module.ts [new file with mode: 0644]
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/org-unit.module.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/admin/server/routing.module.ts
docs/RELEASE_NOTES_NEXT/Administration/org-unit-admin.adoc [new file with mode: 0644]

index ba2b4e3..6615851 100644 (file)
@@ -27,6 +27,9 @@ export class OrgService {
     private orgMap: {[id: number]: IdlObject} = {};
     private settingsCache: OrgSettingsBatch = {};
 
+    private orgTypeMap: {[id: number]: IdlObject} = {};
+    private orgTypeList: IdlObject[] = [];
+
     constructor(
         private net: NetService,
         private auth: AuthService,
@@ -44,15 +47,12 @@ export class OrgService {
         return this.orgList;
     }
 
-    // Returns a list of org unit type objects
     typeList(): IdlObject[] {
-        const types = [];
-        this.list().forEach(org => {
-            if ((types.filter(t => t.id() === org.ou_type().id())).length === 0) {
-                types.push(org.ou_type());
-            }
-        });
-        return types;
+        return this.orgTypeList;
+    }
+
+    typeMap(): {[id: number]: IdlObject} {
+        return this.orgTypeMap;
     }
 
     /**
@@ -173,9 +173,16 @@ export class OrgService {
             node = this.orgTree;
             this.orgMap = {};
             this.orgList = [];
+            this.orgTypeMap = {};
         }
         this.orgMap[node.id()] = node;
         this.orgList.push(node);
+
+        this.orgTypeMap[node.ou_type().id()] = node.ou_type();
+        if (!this.orgTypeList.filter(t => t.id() === node.ou_type().id())[0]) {
+            this.orgTypeList.push(node.ou_type());
+        }
+
         node.children().forEach(c => this.absorbTree(c));
     }
 
index a6e3d2b..ab8ed5b 100644 (file)
@@ -78,7 +78,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"  
       routerLink="/staff/admin/server/permission/grp_tree"></eg-link-table-link>
     <eg-link-table-link i18n-label label="Permissions"  
index cd628a3..9f49a7d 100644 (file)
@@ -1,7 +1,7 @@
 import {NgModule} from '@angular/core';
 import {TreeModule} from '@eg/share/tree/tree.module';
-import {AdminServerRoutingModule} from './routing.module';
 import {AdminCommonModule} from '@eg/staff/admin/common.module';
+import {AdminServerRoutingModule} from './routing.module';
 import {AdminServerSplashComponent} from './admin-server-splash.component';
 import {OrgUnitTypeComponent} from './org-unit-type.component';
 import {PrintTemplateComponent} from './print-template.component';
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/server/org-addr.component.html b/Open-ILS/src/eg2/src/app/staff/admin/server/org-addr.component.html
new file mode 100644 (file)
index 0000000..b3a787b
--- /dev/null
@@ -0,0 +1,36 @@
+
+<ngb-tabset #addressTabs *ngIf="orgUnit" (tabChange)="tabChanged($event)">
+  <ng-container *ngFor="let type of addrTypes()">
+    <b>type = {{type}}</b>
+
+    <ngb-tab *ngIf="addr(type)"
+      i18n-title id="{{type}}"
+      title="{{type === 'billing_address' ? 'Physical Address' : 
+        (type === 'holds_address' ? 'Holds Address' : 
+        (type === 'mailing_address' ? 'Mailing Address' : 'ILL Address'))}}">
+
+      <ng-template ngbTabContent>
+        <eg-fm-record-editor idlClass="aoa" readonlyFields="org_unit" 
+          [mode]="addr(type).isnew() ? 'create': 'update'" 
+          [hideBanner]="true" displayMode="inline" hiddenFields="id"
+          (recordSaved)="addrSaved($event)" 
+          [record]="addr(type)"
+          fieldOrder="address_type,street1,street2,city,county,state,country,post_code,san,valid"
+          >
+          <eg-fm-record-editor-action i18n-label label="Delete" *ngIf="!addr(type).isnew()"
+            (actionClick)="deleteAddress($event)" buttonCss="btn-warning">
+          </eg-fm-record-editor-action>
+        </eg-fm-record-editor>
+
+        <ng-container *ngIf="sharedAddress(addr(type).id())">
+          <div class="alert alert-info">
+            <span i18n>This address is used for multiple address types.</span>
+            <button (click)="cloneAddress(type)" 
+              class="btn btn-light ml-3" i18n>Clone As New Address</button>
+          </div>
+        </ng-container>
+      </ng-template>
+    </ngb-tab>
+  </ng-container>
+</ngb-tabset>
+
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/server/org-addr.component.ts b/Open-ILS/src/eg2/src/app/staff/admin/server/org-addr.component.ts
new file mode 100644 (file)
index 0000000..2092075
--- /dev/null
@@ -0,0 +1,163 @@
+import {Component, Input, Output, EventEmitter} from '@angular/core';
+import {IdlService, IdlObject} from '@eg/core/idl.service';
+import {OrgService} from '@eg/core/org.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {NgbTabChangeEvent} from '@ng-bootstrap/ng-bootstrap';
+
+const ADDR_TYPES =
+    ['billing_address', 'holds_address', 'mailing_address', 'ill_address'];
+
+@Component({
+    selector: 'eg-admin-org-address',
+    templateUrl: './org-addr.component.html'
+})
+export class OrgAddressComponent {
+
+    orgUnit: IdlObject = null;
+    private tabName: string;
+
+    private _orgId: number;
+
+    get orgId(): number { return this._orgId; }
+
+    @Input() set orgId(newId: number) {
+        if (newId) {
+            if (!this._orgId || this._orgId !== newId) {
+                this._orgId = newId;
+                this.init();
+            }
+        } else {
+            this._orgId = null;
+        }
+    }
+
+    @Output() addrChange: EventEmitter<IdlObject>;
+
+    constructor(
+        private idl: IdlService,
+        private org: OrgService,
+        private pcrud: PcrudService
+    ) {
+        this.addrChange = new EventEmitter<IdlObject>();
+        this.tabName = 'billing_address';
+    }
+
+    init() {
+        if (!this.orgId) { return; }
+
+        return this.pcrud.retrieve('aou', this.orgId,
+            {flesh : 1, flesh_fields : {aou : ADDR_TYPES}},
+            {authoritative: true}
+        ).subscribe(org => {
+            this.orgUnit = org;
+            ADDR_TYPES.forEach(aType => {
+                if (!this.addr(aType)) {
+                    this.createAddress(aType);
+                }
+            });
+        });
+    }
+
+    tabChanged($event: NgbTabChangeEvent) {
+        this.tabName = $event.nextId;
+    }
+
+    addrTypes(): string[] { // for UI
+        return ADDR_TYPES;
+    }
+
+    // Template shorthand -- get a specific address by type.
+    addr(addrType: string) {
+        return this.orgUnit ? this.orgUnit[addrType]() : null;
+    }
+
+    createAddress(addrType: string) {
+        const addr = this.idl.create('aoa');
+        addr.isnew(true);
+        addr.valid('t');
+        addr.org_unit(this.orgId);
+        this.orgUnit[addrType](addr);
+    }
+
+    cloneAddress(addrType: string) {
+
+        // Find the address
+        let fromAddr: IdlObject;
+        ADDR_TYPES.forEach(aType => {
+            if (aType !== addrType &&
+                this.addr(aType).id() === this.addr(addrType).id()) {
+                fromAddr = this.addr(aType);
+            }
+        });
+
+        const addr = this.idl.clone(fromAddr);
+        addr.id(null);
+        addr.isnew(true);
+        addr.valid('t');
+        this.orgUnit[addrType](addr);
+    }
+
+    // True if the provided address is used for more than one addr type.
+    sharedAddress(addrId: number): boolean {
+        return ADDR_TYPES.filter(aType => {
+            return (
+                !this.addr(aType).isnew() && this.addr(aType).id() === addrId
+            );
+        }).length > 1;
+    }
+
+    deleteAddress($event: any) {
+        const addr = $event.record;
+        const tmpOrg = this.updatableOrg();
+
+        // Set the FKey to NULL on the org unit for deleted addresses
+        ADDR_TYPES.forEach(aType => {
+            const a = this.addr(aType);
+            if (a && a.id() === addr.id()) {
+                tmpOrg[aType](null);
+                this.createAddress(aType);
+            }
+        });
+
+        this.pcrud.update(tmpOrg).toPromise()
+        .then(_ => this.pcrud.remove(addr).toPromise())
+        .then(_ => this.addrChange.emit(addr));
+    }
+
+    // Addr saved by fm-editor.
+    // In the case of new address creation, point the org unit at
+    // the new address ID.
+    addrSaved(addr: number | IdlObject) {
+
+        if (typeof addr !== 'object') {
+            // pcrud returns a number on 'update' calls.  No need to
+            // reload the data on a simple address change. it's changed
+            // in place.
+            return;
+        }
+
+        // update local copy with version that has an ID.
+        this.orgUnit[this.tabName](addr);
+
+        const org = this.updatableOrg();
+        org[this.tabName](addr.id());
+
+        // Creating a new address -- tell our org about it.
+        this.pcrud.update(org).toPromise().then(_ => this.addrChange.emit(addr));
+    }
+
+    // Create an unfleshed org unit object that's a clone of this.orgUnit
+    // to use when pushing updates to the server.
+    updatableOrg(): IdlObject {
+        const org = this.idl.clone(this.orgUnit);
+
+        ADDR_TYPES.forEach(aType => {
+            const addr = this.addr(aType);
+            if (addr) { org[aType](addr.id()); }
+        });
+
+        return org;
+    }
+
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/server/org-unit-routing.module.ts b/Open-ILS/src/eg2/src/app/staff/admin/server/org-unit-routing.module.ts
new file mode 100644 (file)
index 0000000..cbd3680
--- /dev/null
@@ -0,0 +1,19 @@
+import {NgModule} from '@angular/core';
+import {RouterModule, Routes} from '@angular/router';
+import {OrgUnitComponent} from './org-unit.component';
+
+// Since org-unit admin has its own module with page-level components,
+// it needs its own routing module as well to define which component
+// to display at page load time.
+
+const routes: Routes = [{
+    path: '',
+    component: OrgUnitComponent
+}];
+
+@NgModule({
+  imports: [RouterModule.forChild(routes)],
+  exports: [RouterModule]
+})
+
+export class OrgUnitRoutingModule {}
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..294d62c
--- /dev/null
@@ -0,0 +1,125 @@
+<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="currentOrg()">
+        <span *ngIf="currentOrg().name()" i18n>
+          {{currentOrg().name()}} ({{currentOrg().shortname()}})
+        </span>
+        <span *ngIf="!currentOrg().name()" class="font-italic" i18n>
+          Add Name
+        </span>
+      </div>
+    </div>
+    <ngb-tabset #rootTabs (tabChange)="tabChanged($event)" *ngIf="currentOrg()">
+      <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" 
+              (recordDeleted)="orgDeleted()"
+              readonlyFields="parent,parent_ou" [preloadLinkedValues]="true"
+              [fieldOptions]="{ou_type: {customValues: orgTypeOptions()}}"
+              [record]="currentOrg().isnew() ? currentOrg() : null"
+              [recordId]="currentOrg().isnew() ? null : currentOrg().id()"
+              [showDelete]="!orgHasChildren()"
+              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-action label="Add Child" i18n-label 
+                [disabled]="orgChildTypes().length === 0 || currentOrg().isnew()"
+                buttonCss="btn-outline-info"
+                (actionClick)="addChild()"></eg-fm-record-editor-action>
+            </eg-fm-record-editor>
+          </div>
+        </ng-template>
+      </ngb-tab>
+      <ngb-tab title="Hours of Operation" i18n-title id="hours" 
+        [disabled]="currentOrg().isnew()">
+        <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 class="alert alert-warning mr-2 p-1" 
+                *ngIf="currentOrg().hours_of_operation().isnew()">
+                Hours of Operation Have Not Yet Been Saved.
+              </div>
+              <div class="mr-2">
+                <button class="btn btn-warning" (click)="deleteHours()" i18n>
+                  Clear Hours of Operation
+                </button>
+              </div>
+              <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" 
+        [disabled]="currentOrg().isnew()">
+        <ng-template ngbTabContent>
+          <div class="mt-2">
+            <eg-admin-org-address [orgId]="currentOrg().id()" (addrChange)="addressChanged($event)">
+            </eg-admin-org-address>
+          </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..714d7b7
--- /dev/null
@@ -0,0 +1,274 @@
+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';
+import {ComboboxEntry} from '@eg/share/combobox/combobox.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 | IdlObject) {
+        let id;
+
+        if (orgId) { // new org created, focus it.
+            id = typeof orgId === 'object' ? orgId.id() : orgId;
+        } else if (this.currentOrg()) {
+            id = this.currentOrg().id();
+        }
+
+        this.loadAouTree(id).then(_ => this.postUpdate(this.editString));
+    }
+
+    orgDeleted() {
+        this.loadAouTree();
+    }
+
+    loadAouTree(selectNodeId?: number): Promise<any> {
+
+        const flesh = ['children', 'ou_type', 'hours_of_operation'];
+
+        return this.pcrud.search('aou', {parent_ou : null},
+            {flesh : -1, flesh_fields : {aou : flesh}}, {authoritative: true}
+
+        ).toPromise().then(tree => {
+            this.ingestAouTree(tree);
+            if (!selectNodeId) { selectNodeId = this.org.root().id(); }
+
+            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);
+            }
+
+            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;
+    }
+
+    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)
+        );
+    }
+
+    deleteHours() {
+        const hours = this.currentOrg().hours_of_operation();
+        const promise = hours.isnew() ? Promise.resolve() :
+            this.pcrud.remove(hours).toPromise();
+
+        promise.then(_ => this.generateHours(this.currentOrg()));
+    }
+
+    currentOrg(): IdlObject {
+        return this.selected ? this.selected.callerData.orgUnit : null;
+    }
+
+    orgHasChildren(): boolean {
+        const org = this.currentOrg();
+        return (org && org.children().length > 0);
+    }
+
+    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));
+                }
+            );
+        });
+    }
+
+    orgTypeOptions(): ComboboxEntry[] {
+        let ouType = this.currentOrg().ou_type();
+
+        if (typeof ouType === 'number') {
+            // May not be fleshed for new org units
+            ouType = this.org.typeMap()[ouType];
+        }
+        const curDepth = ouType.depth();
+
+        return this.org.typeList()
+            .filter(type_ => type_.depth() === curDepth)
+            .map(type_ => ({id: type_.id(), label: type_.name()}));
+    }
+
+    orgChildTypes(): IdlObject[] {
+        let ouType = this.currentOrg().ou_type();
+
+        if (typeof ouType === 'number') {
+            // May not be fleshed for new org units
+            ouType = this.org.typeMap()[ouType];
+        }
+
+        const depth = ouType.depth();
+        return this.org.typeList()
+            .filter(type_ => type_.depth() === depth + 1);
+    }
+
+    addChild() {
+        const parentTreeNode = this.selected;
+        const parentOrg = this.currentOrg();
+        const newType = this.orgChildTypes()[0];
+
+        const org = this.idl.create('aou');
+        org.isnew(true);
+        org.parent_ou(parentOrg.id());
+        org.ou_type(newType.id());
+        org.children([]);
+
+        // Create a dummy, detached org node to keep the UI happy.
+        this.selected = new TreeNode({
+            id: org.id(),
+            label: org.name(),
+            callerData: {orgUnit: org}
+        });
+    }
+
+    addressChanged(thing: any) {
+        // Reload to pick up org unit address changes.
+        this.orgSaved(this.currentOrg().id());
+    }
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/server/org-unit.module.ts b/Open-ILS/src/eg2/src/app/staff/admin/server/org-unit.module.ts
new file mode 100644 (file)
index 0000000..09150a8
--- /dev/null
@@ -0,0 +1,27 @@
+import {NgModule} from '@angular/core';
+import {TreeModule} from '@eg/share/tree/tree.module';
+import {AdminCommonModule} from '@eg/staff/admin/common.module';
+import {OrgUnitComponent} from './org-unit.component';
+import {OrgAddressComponent} from './org-addr.component';
+import {OrgUnitRoutingModule} from './org-unit-routing.module';
+
+@NgModule({
+  declarations: [
+    OrgUnitComponent,
+    OrgAddressComponent
+  ],
+  imports: [
+    AdminCommonModule,
+    OrgUnitRoutingModule,
+    TreeModule
+  ],
+  exports: [
+  ],
+  providers: [
+  ]
+})
+
+export class OrgUnitModule {
+}
+
+
index 2022878..6ce9365 100644 (file)
@@ -19,6 +19,9 @@ const routes: Routes = [{
     path: 'permission/grp_tree',
     component: PermGroupTreeComponent
 }, {
+    path: 'actor/org_unit',
+    loadChildren: '@eg/staff/admin/server/org-unit.module#OrgUnitModule'
+}, {
     path: ':schema/:table',
     component: BasicAdminPageComponent
 }];
diff --git a/docs/RELEASE_NOTES_NEXT/Administration/org-unit-admin.adoc b/docs/RELEASE_NOTES_NEXT/Administration/org-unit-admin.adoc
new file mode 100644 (file)
index 0000000..58a8f5e
--- /dev/null
@@ -0,0 +1,6 @@
+Angular Org Unit Admin Page
+^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Migrate the Administration => Server Administration => Organizational Units 
+page to Angular.
+