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>
</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>
<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"/>
{{r.label}}
</ng-template>
-<ng-container *ngIf="readOnly">
+<ng-container *ngIf="readOnly && selected">
<span>{{selected.label}}</span>
</ng-container>
}
// 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() {
<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"
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,
--- /dev/null
+<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>
--- /dev/null
+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}
+ });
+ }
+}
+
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',
path: 'actor/org_unit_type',
component: OrgUnitTypeComponent
}, {
+ path: 'actor/org_unit',
+ component: OrgUnitComponent
+}, {
path: ':schema/:table',
component: BasicAdminPageComponent
}];