From: Bill Erickson Date: Fri, 16 Aug 2019 21:01:51 +0000 (-0400) Subject: LP1840050 Angular Org Unit Admin page port X-Git-Url: https://old-git.evergreen-ils.org/?a=commitdiff_plain;h=c839ede49b09f6f24c31f293f1d49f4a8dc53f82;p=evergreen%2Fpines.git LP1840050 Angular Org Unit Admin page port 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 Signed-off-by: Galen Charlton --- diff --git a/Open-ILS/src/eg2/src/app/core/org.service.ts b/Open-ILS/src/eg2/src/app/core/org.service.ts index ba2b4e39f8..6615851312 100644 --- a/Open-ILS/src/eg2/src/app/core/org.service.ts +++ b/Open-ILS/src/eg2/src/app/core/org.service.ts @@ -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)); } diff --git a/Open-ILS/src/eg2/src/app/staff/admin/server/admin-server-splash.component.html b/Open-ILS/src/eg2/src/app/staff/admin/server/admin-server-splash.component.html index a6e3d2b2a9..ab8ed5b09e 100644 --- a/Open-ILS/src/eg2/src/app/staff/admin/server/admin-server-splash.component.html +++ b/Open-ILS/src/eg2/src/app/staff/admin/server/admin-server-splash.component.html @@ -78,7 +78,7 @@ + routerLink="/staff/admin/server/actor/org_unit"> + + type = {{type}} + + + + + + + + + + +
+ This address is used for multiple address types. + +
+
+
+
+
+ + 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 index 0000000000..2092075104 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/admin/server/org-addr.component.ts @@ -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; + + constructor( + private idl: IdlService, + private org: OrgService, + private pcrud: PcrudService + ) { + this.addrChange = new EventEmitter(); + 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 index 0000000000..cbd36804f8 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/admin/server/org-unit-routing.module.ts @@ -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 index 0000000000..294d62c2bf --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/admin/server/org-unit.component.html @@ -0,0 +1,125 @@ + + + +Update Succeeded + + +Update Failed + + + + + + + {{org.name()}} -- {{org.shortname()}} + + + +
+
+

Org Units

+ +
+
+
+
+ + {{currentOrg().name()}} ({{currentOrg().shortname()}}) + + + Add Name + +
+
+ + + +
+ + + +
+
+
+ + +
+
+
Open Time
+
Close Time
+
+
+
+ Monday + Tuesday + Wednesday + Thursday + Friday + Saturday + Sunday +
+
+ +
+
+ +
+
+ +
+
+
+
+ Hours of Operation Have Not Yet Been Saved. +
+
+ +
+
+ +
+
+
+
+
+
+ + +
+ + +
+
+
+
+
+
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 index 0000000000..714d7b7e51 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/admin/server/org-unit.component.ts @@ -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 { + + 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 index 0000000000..09150a803a --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/admin/server/org-unit.module.ts @@ -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 { +} + + diff --git a/Open-ILS/src/eg2/src/app/staff/admin/server/routing.module.ts b/Open-ILS/src/eg2/src/app/staff/admin/server/routing.module.ts index 20228783ef..6ce93653e4 100644 --- a/Open-ILS/src/eg2/src/app/staff/admin/server/routing.module.ts +++ b/Open-ILS/src/eg2/src/app/staff/admin/server/routing.module.ts @@ -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 index 0000000000..58a8f5eb0b --- /dev/null +++ b/docs/RELEASE_NOTES_NEXT/Administration/org-unit-admin.adoc @@ -0,0 +1,6 @@ +Angular Org Unit Admin Page +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Migrate the Administration => Server Administration => Organizational Units +page to Angular. +