Browse Source

added quotation module

tigger 2 years ago
parent
commit
78c045e16e

+ 4 - 0
src/app/app.routes.ts

@@ -4,4 +4,8 @@ import { DashboardComponent } from './dashboard/dashboard.component';
 export const routes: Routes = [
     { path: '', redirectTo: '/dashboard', pathMatch: 'full' },
     {path:'dashboard', component: DashboardComponent, data: {title: 'SwOPT Angular'}},
+    {
+        path: 'quotation',
+        loadChildren: () => import('./quotation/quotation.module').then(m => m.QuotationModule)
+    }
 ];

+ 25 - 0
src/app/dashboard/dashboard.component.html

@@ -77,6 +77,31 @@
       <div class="right-side">
         @if (!loginService.user?.token?.jwt) {
           <login />
+        } @else {
+          <div class="pill-group">
+            @for (item of [
+              { title: 'Quotation', link: '/quotation/sales' }
+            ]; track item.title) {
+              <a
+                class="pill"
+                [routerLink]="item.link"
+                rel="noopener"
+              >
+                <span>{{ item.title }}</span>
+                <svg
+                  xmlns="http://www.w3.org/2000/svg"
+                  height="14"
+                  viewBox="0 -960 960 960"
+                  width="14"
+                  fill="currentColor"
+                >
+                  <path
+                    d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h280v80H200v560h560v-280h80v280q0 33-23.5 56.5T760-120H200Zm188-212-56-56 372-372H560v-80h280v280h-80v-144L388-332Z"
+                  />
+                </svg>
+              </a>
+            }
+          </div>
         }
         <div class="social-links">
           

+ 64 - 0
src/app/quotation/controllers/form.controller.ts

@@ -0,0 +1,64 @@
+import { Select, Store } from "@ngxs/store";
+import { BehaviorSubject, Observable, map, take, takeWhile } from "rxjs";
+import { FormController } from "angularlib/formx/formx.controller";
+import { TMService } from "tm-ui/tm.service";
+import { Fields } from "fis-commons/src/ui/field/field.i";
+import { QuotationComponent } from "../quotation.component";
+import { Document } from "fis-commons/src/tm/document/tm.document.i";
+import { BusinessData } from "tm-ui/business.data/business.data.i";
+import { ServiceID } from "../serviceid";
+import { untilDestroy } from "tm-ui/base.component";
+import { Process } from "fis-commons/process";
+
+export class QuotationFormController extends FormController {
+    fields: Fields;
+    private _data: BehaviorSubject<any> = new BehaviorSubject(null);
+
+    protected process: Process = new Process();
+
+    constructor(
+        private store: Store,
+        private tms: TMService,
+        private doc: Document,
+        private qtc: QuotationComponent
+    ) {
+        super();
+        const DocHeaderDataBDM: BusinessData.Model = {
+            serviceId: ServiceID.DocumentHeaderData,
+            parameter: `id=${this.doc.docId}`
+        };
+
+        this.tms.data.get(DocHeaderDataBDM,this.process).pipe(untilDestroy(this.qtc)).subscribe((res:any) => {
+            if (res) this.data = res.data?.GenericFisData?.data?.DataService?.rows?.row[0]?.column;
+            this.data.tenderstartdate = this.data.tenderstartdate?.substring(0,19)+'+08:00';
+            this.data.tenderclosingdate = this.data.tenderclosingdate?.substring(0,19)+'+08:00';
+            this.data.doc_post_dt = this.data.doc_post_dt?.substring(0,19)+'+08:00';
+            if (!this.data.tenderername) this.data.tenderername = `${this.qtc?.loginService?.user?.fisInfo?.name} (${this.qtc?.loginService?.user?.fisInfo?.defaultTendererCode})`;       
+        });
+    }
+
+    
+
+    generateFields(fieldColumns: any) {
+        fieldColumns.tenderdisplaystatus = fieldColumns.tenderstatus;
+        this.fields = new Fields(this.tms.fields.convert(fieldColumns));
+        this.fields.map(field => {
+            if (field.key === 'doc_ref') field.label = {key:'cust_key', default: 'Document Ref.'};
+            if (field.key === 'doc_post_dt') field.label = {key:'cust_key', default: 'Submit Date'};
+            if (field.type === 'date' || field.type === 'datetime') field.attributes.dateFormat = 'yyyy-MM-dd (E) hh:mm a';
+            return field;
+        });
+        this.fields.forEach(field => {
+            field.scope = ''+field.key;
+        });
+        return this.fields;
+    }
+
+    get data() {
+        return this._data.getValue();
+    }
+
+    set data(value) {
+        this._data.next(value);
+    }
+}

+ 504 - 0
src/app/quotation/controllers/listing.controller.ts

@@ -0,0 +1,504 @@
+import { Store } from "@ngxs/store";
+import { TMService } from "tm-ui/tm.service";
+import { Controller } from "fis-commons/src/controller/controller";
+import { Field, Fields } from "fis-commons/src/ui/field/field.i";
+import documentSchema from '../qt.document.schema.json';
+import { BehaviorSubject, MonoTypeOperatorFunction, Observable, Subject, concat, firstValueFrom, lastValueFrom, map, skip, take, takeWhile } from "rxjs";
+import { Document } from "fis-commons/src/tm/document/tm.document.i";
+import { Table, UITable } from "angularlib/table/table.i";
+import { Event } from "fis-commons/src/event/event.i";
+import { TreeTableEvent } from "angularlib/table/tree.table/tree.table.e";
+import { Ops } from "dp-ui/message.interface";
+import { ComponentService } from "angularlib/component.service";
+import { ServiceID } from "../serviceid";
+import { Process } from "fis-commons/process";
+
+/**operator to decide whether to use cahced or live responses
+ * @param skipCached boolean, if undefined, default value is false, which returns cached data
+*/
+const skipCache = (skipCached?: boolean) => (source: Observable<any>) => {
+    if (skipCached) return source.pipe(skip(1),take(1)); // skip first response, which usually is the cached data, take following response from backend
+    return source.pipe(take(1)); //takes first response; either cached or live
+}
+
+export class QuotationListingController extends Controller {
+    
+    protected tag: string = 'qttable';
+    protected submitted:boolean;
+
+    /**has submit submit / allow for submit */
+    protected submitable: boolean = true;
+    
+    /** latest tender submission document */
+    protected submittedItems: {[key:string]: Document};
+    
+    private _table: BehaviorSubject<UITable> = new BehaviorSubject({
+        fields: [],
+        data: []
+    });
+
+    protected options: Table.Options = {
+         showAppendControls: false
+    }
+
+    protected eventListener: Subject<Event> = new Subject();
+
+    protected process: Process = new Process();
+
+    private activeEntryNo;
+    private errors = [];
+    private successMessages = [];
+
+     
+
+    constructor(
+        private store: Store,
+        private tms: TMService,
+        private cs: ComponentService,
+        private fieldColumns: any[],
+        public document: Document,
+        private documentCache: Document[],
+        private untilDestroy?: MonoTypeOperatorFunction<any>
+    ) {
+        super();
+        if (!this.document.editState) this.document.editState = "VIEW";
+        
+        // get the document header data, check if document had been posted
+        this.tms.data.get({
+            serviceId: ServiceID.DocumentHeaderData,
+            parameter: `id=${this.document.docId}`
+        },this.process).pipe(skipCache(true)).subscribe(res => {
+            const data = res.data?.GenericFisData?.data?.DataService?.rows?.row[0]?.column;
+            this.generateFields(fieldColumns);
+            this.update().then((res) => {
+                this.table = {
+                    ...this.table,
+                    data: res
+                }
+            });
+        });
+    }
+
+    generateFields(fieldColumns) {
+        let fields = new Fields([]);
+        let tempColumns = {};
+        documentSchema.details.visibleFields.forEach(key => {
+            if (!fieldColumns) window.location.reload();
+            let column = fieldColumns[key];
+            tempColumns = {
+                ...tempColumns,
+                [key]: column
+            };
+        });
+        fields = new Fields(this.tms.fields.convert(tempColumns));
+        fields.map((f: Field) => {
+            f.scope = f.key;
+            if (f.type === 'number') {f.attributes.align='right';f.attributes.decimal='2'};
+            if (f.key === 'prd_uom') {f.attributes.align='center';};
+            if (f.key === 'prd_qty_credit') {f.attributes.decimal='0';};
+            if (f.key === 'entry_ref') {f.label= {key:'prd_code',default:'Item Code'}};
+            if (f.key === 'prd_price') {f.attributes.hint = {key:'enter_unit_price',default:'Please enter unit price'}};
+            if (f.key === 'prd_price' && this.submitted === true) {f.attributes.readonly = true};
+            if (f.type === 'date' || f.type === 'datetime') f.attributes.dateFormat = 'yyyy-MM-dd';
+            if (this.document.notEditable()) f.attributes.readonly = true;
+        });
+        this.table.fields = fields;
+        this.table.editColDef = JSON.parse(JSON.stringify(documentSchema.details.editColDef));
+        return fields;
+    }
+
+    override update(skipCached?: boolean): Promise<any> {
+        return new Promise<any>((resolve,reject) => {
+            // get quoted items listed by tenderee (tenderee offers contract)
+            const getInviteItems = this.tms.data.get({
+                serviceId: ServiceID.DetailData,
+                parameter: '__clearSearchValue__=true,doctypecodelist=S2'
+            },this.process,{caching:"false"}).pipe(takeWhile(res => res !== undefined), // skip undefined responses
+                skipCache(skipCached),
+                map((res:any) => {
+                    const rows = res?.data?.GenericFisData?.data?.DataService?.rows?.row?.
+                        filter(x => x.column.doc_id === this.document.docId)?.
+                        map(x => {return x.column});
+                    return rows?rows:[];
+            }));
+
+            // get quoted items submitted by current tenderer (tenderer submits quotation offered by tenderee)
+            const getSubmittedItems =  this.tms.data.get({
+                serviceId: ServiceID.DetailData,
+                parameter: '__clearSearchValue__=true,doctypecodelist=S3'
+            },null,{caching:"false"}).pipe(takeWhile(res => res !== undefined), // skip undefined responses
+                skipCache(skipCached),
+                map((res:any) => {
+                    let rows = res?.data?.GenericFisData?.data?.DataService?.rows?.row?.
+                    filter(x => x.column.allocatedtodocid === this.document.docId)?.
+                    map(x => {return {...x.column,rowId:x.rowId,rowNumber:x.rowNumber}});
+                    if (rows?.length > 0) {
+                        const latestSubmission = rows.reduce((prev,curr) => {
+                            const prevDate = new Date(prev.doc_dt);
+                            const currDate = new Date(curr.doc_dt);
+                            return (prevDate > currDate)?prev:curr
+                        });
+                        this.submittedItems = rows.filter(x => x.doc_id === latestSubmission.doc_id)?.reduce((prev,curr) => {
+                            prev[curr.allocatedtodocentryno] = prev[curr.allocatedtodocentryno] || [];
+                            prev[curr.allocatedtodocentryno].push(curr);
+                            return prev;
+                        },{});
+                    }
+                    return rows?rows:[];
+            }));
+
+            getInviteItems.pipe(map(async inviteItems => {
+                await firstValueFrom(getSubmittedItems).catch(error => console.error(error)).then(submittedItems => {});
+                inviteItems = inviteItems.map(item => {
+                    if (this.submittedItems && this.submittedItems[item.entry_no_a0]) {
+                        item = {...item,prd_price_original:item.prd_price,prd_price:this.submittedItems[item.entry_no_a0][0].prd_price,
+                            entry_cur_credit:this.submittedItems[item.entry_no_a0][0].entry_cur_credit
+                        };
+                    }
+                    return item;
+                });
+                return inviteItems;
+            })).subscribe({
+                next: promise => {
+                    promise.catch(error => reject(error)).then(res => {
+                        res.map(row => {
+                            const prd_price = parseFloat(row.prd_price)?.toFixed(2);
+                            if (!row.prd_price_original) row.prd_price_original = 0;
+                            const prd_price_original = parseFloat(row.prd_price_original)?.toFixed(2);
+                            row.prd_price_hint = "Original requested price: "+prd_price_original;
+                            row.prd_uom = row.prd_package_name;
+                            row.prd_qty_credit = parseInt(row.prd_qty_credit);
+                            row.prd_price = prd_price;
+                            row.entry_cur_credit = row.prd_price * row.prd_qty_credit,
+                            row.checked = false; //set default checkbox state to unchecked;
+                            row.checkbox_hint = 'check to bid'; 
+                        });
+                        let data: any[] = res;
+                        resolve(data);
+                    });
+                }
+            });
+        });
+    }
+
+    protected onChangeListener(event: Event) {
+        switch (event.name) {
+            case TreeTableEvent.ValueChange().name: {
+                this.onValueChangeEvent(event);
+                break;
+            }
+            case TreeTableEvent.InputFocus().name: {
+                this.errors = [];
+                switch (true) {
+                    // no existing tender submission document:
+                    case this.document.editState === "VIEW" && !this.submittedItems : {
+                        this.sendNewCommand()?.then(newRes => {
+                            this.sendExecuteCommand(event.payload);
+                        });
+                        break;
+                    }
+                    // has existing tender submission document:
+                    case this.document.editState === "VIEW" && this.submittedItems !== undefined : {
+                        this.sendExecuteCommand(event.payload);
+                        break;
+                    }
+                    // when another row is selected:
+                    case (this.document.editState === 'NEW' || this.document.editState === 'MODIFY') && 
+                        event.payload?.entry_no_a0 !== this.activeEntryNo && 
+                        !this.process.hasActiveProcesses: {
+                        this.sendExecuteCommand(event.payload);
+                        break;
+                    }
+                    default: break;
+                }
+                break;
+            }
+            default: break;
+        }
+        //this.eventListener.next(event);
+    }
+
+    sendNewCommand() {
+        const NEW = new Observable(observer => {
+            const processId = this.process.addActiveProcess('New');
+            this.tms.command({
+                serviceId: ServiceID.Submission,
+                operation: Ops.NEW,
+                payload: {
+                    serviceId: ServiceID.Submission,
+                }
+            }).pipe(take(1)).subscribe({
+                next: res => {
+                    this.process.endActiveProcess(processId);
+                    this.document.editState = "NEW";
+                    this.document.submitted = false;
+                    observer.next(res);
+                    observer.complete();
+                },
+                error: error => {
+                    observer.error(error);
+                }
+            }); 
+        });
+        const SET_TENDERER_ID = new Observable(observer => {
+            const processId = this.process.addActiveProcess('SetColumn - tendererid');
+            this.tms.setColumn({
+                serviceId: ServiceID.Submission,
+                alias: 'header',
+                column: {
+                    row: 1,
+                    name: 'tendererid',
+                    value: this.document.tendererId
+                }
+            }).pipe(take(1)).subscribe({
+                next: res => {
+                    this.process.endActiveProcess(processId);
+                    observer.next(res);
+                    observer.complete();
+                },
+                error: error => {
+                    this.process.endActiveProcess(processId);
+                    observer.error(error);
+                }
+            }); 
+        });
+        
+        return !this.submitted?lastValueFrom(concat(NEW, SET_TENDERER_ID)):null;
+    }
+
+    /**retrieve tender submission document 
+     * @param {{doc_id,entry_no_a0}} payload 
+     * @param {string} [parameter] Execute parameter
+    */
+    sendExecuteCommand(payload, parameter?: string) {
+        if (!parameter) parameter = `eventname=ue_trigger_copy,doc_id=${payload.doc_id},entry_no=${payload.entry_no_a0}`;
+        this.submitted = true;
+        this.activeEntryNo = payload.entry_no_a0;
+        const EXECUTE = new Observable(observer => {
+            const processId = this.process.addActiveProcess('Execute');
+            this.tms.command({
+                serviceId: ServiceID.Submission,
+                operation: Ops.Execute,
+                messageName: 'Retrieve Tender Submission',
+                payload:{
+                    //parameter: `objecttype=TM,eventname=ue_trigger_copy,doc_id=836569,entry_no=2`,
+                    serviceId: ServiceID.Submission,
+                    parameter: `objecttype=TM,${parameter}`,
+                    dataRow: null
+                }
+            }).pipe(take(1)).subscribe({
+                next: res => {
+                    this.process.endActiveProcess(processId);
+                    observer.complete();
+                },
+                error: error => {
+                    observer.error(error);
+                    observer.complete();
+                    this.process.endActiveProcess(processId);
+                }
+            });
+            
+        });
+        const GET_DATA_AFTER_EXECUTE = new Observable(observer => {
+            const processId = this.process.addActiveProcess('GetData after Execute');
+            this.tms.query(
+                ServiceID.Submission
+            ).pipe(take(1)).subscribe({
+                next: res => {
+                    this.process.endActiveProcess(processId);
+                    observer.complete();
+                }
+            });
+        });
+
+        concat(EXECUTE,GET_DATA_AFTER_EXECUTE).subscribe();
+    }
+
+    sendCancelChangesCommand(): Promise<unknown> {
+        this.errors = [];
+        this.successMessages = [];
+        return new Promise((resolve, reject) => {
+            const processId = this.process.addActiveProcess('Cancel Changes');
+            this.tms.command({
+                serviceId: ServiceID.Submission,
+                operation: Ops.CANCEL_CHANGES,
+                payload: {
+                    serviceId: ServiceID.Submission,
+                }
+            }).pipe(take(1)).subscribe({
+                next: res => {
+                    this.documentCache.map(doc => {
+                        if (doc.docId !== this.document.docId) {
+                            doc.listingController.document.editState = 'VIEW';
+                        }
+                        return doc;
+                    });
+                    this.process.endActiveProcess(processId);
+                    resolve(res);
+                },
+                error: error => reject(error)
+            });
+        });
+    }
+    
+    private onValueChangeEvent(event: Event) {
+        if (this.document.editState !== 'VIEW') new Observable(observer => {
+            const processId = this.process.addActiveProcess(`SetColumn - ${event.payload?.field}`);
+            this.tms.setColumn({
+                serviceId: ServiceID.Submission,
+                alias: 'details',
+                column: {
+                    row: event.payload?.entry_no_a0,
+                    name: event.payload?.field,
+                    value: event.payload[event.payload?.field]
+                }
+            }).pipe(take(1)).subscribe({
+                next: res => {
+                    this.setAmount(event.payload).then(amount => {
+                        observer.complete();
+                        const data = this.table.data;
+                        data.map(row => {
+                            if (row.entry_no_a0 === event.payload?.entry_no_a0) {
+                                row[event.payload?.field+'_error'] = undefined;
+                                row.entry_cur_credit = amount;
+                            }
+                        });
+                        this.table = {
+                            ...this.table,
+                            data: data
+                        }
+                        this.process.endActiveProcess(processId);
+                    });
+                },
+                error: error => {
+                    this.process.endActiveProcess(processId);
+                    const data = this.table.data;
+                    data.map(row => {
+                        if (row.entry_no_a0 === event.payload?.entry_no_a0) {
+                            row[event.payload?.field+'_error'] = error.message;
+                        }
+                    });
+                    this.table = {
+                        ...this.table,
+                        data: data
+                    }
+                    observer.error(error)
+                }
+            });
+        }).subscribe();
+    }
+
+    onSave() {
+        this.errors = [];
+        const processId = this.process.addActiveProcess('Save');
+        new Promise((resolve, reject) => {
+            this.tms.command({
+                serviceId: ServiceID.Submission,
+                operation: Ops.SAVE
+            }).pipe(take(1)).subscribe({
+                next: save => {
+                    this.document.editState = 'VIEW';
+                    this.process.endActiveProcess(processId);
+                    this.successMessages = [this.cs.getLabel('saved','Saved')];
+                    // this.update(true).then(res => {
+                    //     this.table = {
+                    //         ...this.table,
+                    //         data: res
+                    //     }
+                    // });
+                    resolve(save);
+    
+                },
+                error: error => reject(error)
+            });
+
+        }).catch(error => {
+            console.error(error);
+            this.process.endActiveProcess(processId);
+            this.errors.push(error.message);
+        });
+    }
+
+    onSubmit() {
+        this.errors = [];
+        this.successMessages = [];
+        const POST = new Observable<any>(observer => {
+            const processId = this.process.addActiveProcess(`Post (${ServiceID.Submission})`);
+            this.tms.command({
+                serviceId: ServiceID.Submission,
+                operation: Ops.POST
+            }).pipe(take(1)).subscribe({
+                next: res => {
+                    observer.next(res);
+                    this.process.endActiveProcess(processId);
+                    this.document.editState = "VIEW";
+                    this.document.submitted = true;
+                    let fields: Fields = new Fields(this.table.fields);
+                    let priceF: Field = fields.find(x => x.key === 'prd_price');
+                    fields.replace('prd_price',{
+                        ...priceF,
+                        attributes:{...priceF.attributes,readonly:true}
+                    });
+                    this.table = {
+                        ...this.table,
+                        fields: fields
+                    };
+                    observer.complete();
+                },
+                error: error => {
+                    this.process.endActiveProcess(processId);
+                    observer.error(error);
+                }
+            });
+        });
+        
+        this.cs.dialog.showComfirmDialog({
+            title: 'Submit',
+            content: 'Do you want to submit this tender?<div><i><small><b>You\'ll no longer be able to edit this tender after submitting.</b></small></i></div>',
+            confirm: () => {
+                POST.subscribe({
+                    next: res => {
+                        this.successMessages = [this.cs.getLabel('submitted','Submitted')];
+                    },
+                    error: error => this.errors = [error.message]
+                })
+            }
+        });
+    }
+
+    private setAmount(payload) {
+        return new Promise<number>((resolve, reject) => {
+            const amount = +payload.prd_price * payload.prd_qty_credit;
+            const found = this.table.data.find(x => x.allocatedToDocEntryNo === payload.allocatedToDocEntryNo);
+            if (this.document.editState !== 'VIEW') new Observable(observer => {
+                const processId = this.process.addActiveProcess(`SetColumn - entry_cur_credit`);
+                this.tms.setColumn({
+                    serviceId: ServiceID.Submission,
+                    alias: 'details',
+                    column: {
+                        row: payload?.entry_no_a0,
+                        name: 'entry_cur_credit',
+                        value: amount
+                    }
+                }).pipe(take(1)).subscribe({
+                    next: res => {
+                        observer.complete();
+                        this.process.endActiveProcess(processId);
+                    },
+                    error: error => observer.error(error)
+                })
+            }).subscribe();
+            resolve(amount);
+        });
+    }
+
+    get table() {
+        return this._table.getValue();
+    }
+
+    set table(table) {
+        this._table.next(table);
+    }
+
+    
+}

+ 57 - 0
src/app/quotation/controllers/tree.controller.ts

@@ -0,0 +1,57 @@
+import { Store } from "@ngxs/store";
+import { TMService } from "tm-ui/tm.service";
+import { Controller } from "fis-commons/src/controller/controller";
+import { Field, Fields } from "fis-commons/src/ui/field/field.i";
+import documentSchema from '../qt.document.schema.json';
+import { Subject } from "rxjs";
+import { Event } from "fis-commons/src/event/event.i";
+
+export class QuotationTreeController extends Controller {
+    
+    protected tag: string = 'qttable';
+    
+    protected eventListener: Subject<Event> = new Subject();
+
+    fields: Fields =  new Fields([]);
+    
+    constructor(
+        private store: Store,
+        private tms: TMService,
+        private fieldColumns: any[]
+    ) {
+        super();
+        this.fields =this.generateFields(fieldColumns);
+        
+    }
+
+    generateFields(fieldColumns) {
+        let fields = new Fields([]);
+        let tempColumns = {};
+        documentSchema?.tree?.visibleFields?.forEach(key => {
+            console.debug(key,fieldColumns);
+            if (fieldColumns[key]) {
+                let column = fieldColumns[key];
+                tempColumns = {
+                    ...tempColumns,
+                    [key]: column
+                };
+            }
+        });
+        fields = new Fields(this.tms.fields.convert(tempColumns));
+        fields.addAfter('tenderclosingdate',{
+            key: 'tenderdisplaystatus',
+            label: {key:'tenderdisplaystatus',default:'Status'},
+            type: 'string',
+            attributes: { align: 'right'}
+        });
+        fields.map((f: Field) => {
+            if (f.type === 'datetime') f.attributes.dateFormat = 'yyyy-MM-dd';
+            if (f.key === 'tenderclosingdate') f.label = {key:f.key,default:'Expiry Date'};
+            if (f.key === 'doc_post_dt') f.label = {key:f.key,default: 'Submit Date'}
+            f.scope = f.key;
+        });
+        return fields;
+    }
+
+    
+}

+ 55 - 0
src/app/quotation/qt.document.schema.json

@@ -0,0 +1,55 @@
+{
+	"header": {
+		"uiSchema": {
+			"uiType": "expandable",
+			"text": "Document Info.",
+			"expandable": {"expanded": true},
+			"elements": [
+				{
+					"uiType": "hlayout",
+					"elements": [
+						{"uiType": "vlayout",
+							"scope": "root",
+							"elements":[
+								{"uiType": "nefield","scope":"doc_ref"},
+								{"uiType": "nefield","scope":"tenderername"},
+								{"uiType": "nefield","scope":"doc_desc"},
+								{"uiType": "nefield","scope":"doc_remarks"}
+							]
+						},
+						{"uiType": "vlayout",
+							"scope": "root",
+							"elements":[
+								{"uiType": "nefield","scope":"tenderstartdate","label":"Tender Start"},
+								{"uiType": "nefield","scope":"tenderclosingdate"},
+								{"uiType": "nefield","scope":"tenderdisplaystatus"},
+								{"uiType": "nefield","scope":"doc_post_dt"}
+							]
+						}
+
+					]
+				}
+			]
+		}
+	},
+	"details": {
+		"visibleFields": [
+			"prd_code",
+			"entry_desc",
+			"prd_qty_credit",
+			"prd_uom",
+			"prd_price",
+			"entry_cur_credit"
+		],
+		"editColDef": {
+			"0": {"columns":["prd_price"]}
+		}
+	},
+	"tree": {
+		"visibleFields": [
+			"doc_ref",
+			"tenderclosingdate",
+			"doc_post_dt"
+		]
+	}
+}

+ 23 - 0
src/app/quotation/quotation.component.spec.ts

@@ -0,0 +1,23 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { QuotationComponent } from './quotation.component';
+
+describe('QuotationComponent', () => {
+  let component: QuotationComponent;
+  let fixture: ComponentFixture<QuotationComponent>;
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      declarations: [ QuotationComponent ]
+    })
+    .compileComponents();
+
+    fixture = TestBed.createComponent(QuotationComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});

+ 221 - 0
src/app/quotation/quotation.component.ts

@@ -0,0 +1,221 @@
+import { Component, HostListener, OnInit } from '@angular/core';
+import { ActivatedRoute, NavigationStart, Router } from '@angular/router';
+import { Select, Store } from '@ngxs/store';
+import { Observable, Subject, takeUntil } from 'rxjs';
+import { first, map, skip, take, takeWhile } from 'rxjs/operators';
+import { TreeNode } from 'angularlib/tree/tree.state';
+import { TMService, skipCache } from 'tm-ui/tm.service';
+import documentSchema from './qt.document.schema.json';
+import { Document, TMDocumentSchema } from 'fis-commons/src/tm/document/tm.document.i';
+import { MetadataState } from 'tm-ui/metadata/metadata.state';
+import { QuotationFormController } from './controllers/form.controller';
+import { QuotationListingController } from './controllers/listing.controller';
+import { Ops } from 'fis-commons/src/domain.proxy/message.interface';
+import { Event } from 'fis-commons/src/event/event.i';
+import { QuotationTreeController } from './controllers/tree.controller';
+import { ServiceID } from './serviceid';
+import { FISError } from 'fis-commons/src/error/error';
+import { TMComponent } from 'tm-ui/tm.component';
+import { ComponentService } from 'angularlib/component.service';
+import { EventService } from 'angularlib/event/event.service';
+import { LoginService } from 'angularlib/login/login.service';
+import { untilDestroy } from 'tm-ui/base.component';
+import { TreeEvent } from 'angularlib/tree/tree.i';
+import { Process } from 'fis-commons/process';
+
+@Component({
+  selector: 'quotation',
+  templateUrl: './template.html',
+  styleUrls: ['./styles.scss']
+})
+export class QuotationComponent extends TMComponent implements OnInit {
+ 
+  shpFromFormMetadata = {};
+  shpToFormMetadata = {};
+
+  @Select(MetadataState.getMetadata) $metadata: Observable<any>;
+
+  protected tmDocumentSchema: TMDocumentSchema = documentSchema;
+
+  protected treeController: QuotationTreeController;
+
+  protected activeDocNode: TreeNode;
+  
+  constructor(
+    protected store: Store,
+    public cs: ComponentService,
+    protected es: EventService,
+    protected tms: TMService,
+    public loginService?: LoginService,
+    protected route?: ActivatedRoute,
+    private router?: Router 
+  ) {
+    super(tms);
+    this.serviceId = 'Quotation';
+   }
+
+   ngOnInit() {
+     super.onInit();
+
+     const routeParams = this.route.snapshot.paramMap;
+     const routeDocId = routeParams.get('docId');
+     if (routeDocId) this.sideNavOpened = false;
+     
+     this.tms.metadata.get(ServiceID.Submission).pipe(skipCache(true,2)).subscribe({
+      next: metadata => {
+        if (metadata.header) {
+          this.metadata = metadata;
+          this.treeController = new QuotationTreeController(this.store,this.tms, this.metadata?.header?.column  || this.metadata?.DataService?.column);
+          this.fields = this.treeController?.fields;
+          this.getDocumentListing(
+            ServiceID.ListingData, 
+            //`__clearSearchValue__=true,doctypecodelist=S2`  
+            `__clearSearchValue__=true,userId=${this.loginService.user?.fisInfo?.id},orgnUnitId=${this.loginService.user?.fisInfo?.organisationUnitId},doctypecodelist=S2`,
+            {key:'tenderclosingdate',order:'DSC'}
+          ).then(res => {
+            // load document if docId is provided in routerParam
+            if (routeDocId) {
+              const found = res.find(x => x.payload.docId === +routeDocId);
+              if (found) this.setActiveDocument({
+                docId: found?.payload?.doc_id,
+                docRef: found?.payload?.docRef,
+                allocatedToDocId: found?.payload?.allocatedtodocid,
+                docStatus: found?.payload?.tenderdisplaystatus?.toUpperCase(),
+                notEditable: () => {
+                  return found?.payload?.tenderdisplaystatus?.toUpperCase() === 'CLOSED' ||
+                  found?.payload?.tenderdisplaystatus?.toUpperCase() === 'SUBMITTED' ||
+                  found?.payload?.tenderdisplaystatus?.toUpperCase() === 'CANCELLED' ||
+                  found?.payload?.tenderdisplaystatus?.toUpperCase() === 'ACCEPTED' ||
+                  found?.payload?.tenderdisplaystatus?.toUpperCase() === 'REJECTED'
+                }
+              });
+            }
+          });
+
+        }
+      },
+      error: (error: FISError) => {
+        console.error(error);
+        switch (error.category) {
+          case 'AUTH': {
+            this.cs.snackbar(error.displayMessage).afterOpened().pipe(take(1)).subscribe(action => {
+              this.cs.logout();
+            });
+            break;
+          }
+          default: break;
+        }
+      }
+     });
+
+     this.router.events.pipe(untilDestroy(this)).subscribe({
+      next: event => {
+        if (event instanceof NavigationStart) this.activeDocument?.listingController?.sendCancelChangesCommand();
+      }
+     })
+     
+   }
+   //detect browser reload
+   @HostListener("window:beforeunload", ["$event"])
+   protected unloadHandler(event) {
+    if (this.activeDocument?.editState === 'NEW' || 'MODIFY') {
+        this.activeDocument?.listingController?.sendCancelChangesCommand();
+      }
+  }
+
+   private submitDocument(tenderDocId) {
+    const getData = this.tms.query(ServiceID.Submission,{argument:`${tenderDocId}`}).pipe(take(1));
+    const post = this.tms.command({
+      serviceId: ServiceID.Submission,
+      operation: Ops.POST,
+      payload: {
+        serviceId: ServiceID.Submission
+      }
+    });
+    getData.pipe(map(res => {post.subscribe({
+      next: () => {
+        this.submitSuccess.next(null);
+        this.cs.snackbar('Tender submitted successfully',{duration:10000},"Dismiss");
+      },
+      error: error => console.error(error)
+    })})).subscribe({
+      error: error => console.error(error)
+    });
+   }
+
+  protected onTreeItemSelected(event: Event) {
+    const docData = event.payload?.data?.payload;
+    this.route.params.pipe(take(1)).subscribe(params => {
+      this.cs.navigate('quotation',{...params,docId: docData?.doc_id});
+    });
+    // this.setActiveDocument({
+    //   docId: docData?.doc_id,
+    //   docRef: docData?.docRef,
+    //   allocatedToDocId: docData?.allocatedtodocid,
+    //   docStatus: docData?.tenderdisplaystatus?.toUpperCase(),
+    //   notEditable: () => {
+    //     return docData?.tenderdisplaystatus?.toUpperCase() === 'CLOSED' ||
+    //     docData?.tenderdisplaystatus?.toUpperCase() === 'SUBMITTED' ||
+    //     docData?.tenderdisplaystatus?.toUpperCase() === 'CANCELLED' ||
+    //     docData?.tenderdisplaystatus?.toUpperCase() === 'ACCEPTED' ||
+    //     docData?.tenderdisplaystatus?.toUpperCase() === 'REJECTED'
+    //   }
+    // });
+  }
+  
+  
+  override onChangeListener(event: any): void {
+    switch (event.name) {
+      case TreeEvent.ItemSelected: {
+        this.onTreeItemSelected(event);
+        break;
+      }
+      case 'document.submit': {
+        this.activeDocument?.listingController?.onSubmit();
+        break;
+      }
+      default: break;
+    }  
+    super.onChangeListener(event);
+  }
+
+  protected onSave() {
+
+  }
+
+  
+
+  protected setActiveDocument(document: Document) {
+    //let doc = this.documentCache.find(x => x.docId === document.docId);
+    let doc;
+    //if (!doc) {
+      const process = new Process();
+      if (!document.editStatus) document.editStatus 
+      doc = {
+        ...document,
+
+        process: process,
+        formController: new QuotationFormController(this.store,this.tms,
+          {...document,process:process,tendererId:this.loginService.user?.fisInfo?.defaultTendererId},
+          this
+        ),
+        listingController: new QuotationListingController(
+          this.store,this.tms,
+          this.cs,
+          this.metadata?.details?.column || this.metadata?.DataService?.column,
+          {...document,process:process,tendererId:this.loginService.user?.fisInfo?.defaultTendererId},
+          // this.documentCache,
+          [document],
+          untilDestroy(this))
+      };
+      doc.formController.generateFields(
+        this.metadata?.header?.column  || this.metadata?.DataService?.column
+        );
+      // this.documentCache.push(doc);
+    //}
+    this.activeDocument = doc;
+    return doc;
+  }
+
+}
+

+ 36 - 0
src/app/quotation/quotation.module.ts

@@ -0,0 +1,36 @@
+import { NgModule } from '@angular/core';
+import { MatModule } from 'angularlib/mat.module';
+import { FormModule } from 'angularlib/formx/form.module';
+import { TableModule } from 'angularlib/table/table.module';
+import { QuotationComponent } from './quotation.component';
+import { AuthGuard } from 'angularlib/login/auth.guard';
+import { RouterModule, Routes } from '@angular/router';
+import { ServiceID } from './serviceid';
+import { TMModule } from 'tm-ui/tm.module';
+import { TreeModule } from 'angularlib/tree/tree.module';
+import { CommonModule } from '@angular/common';
+import { LabelModule } from 'angularlib/labels/label.module';
+
+const routes: Routes = [
+  { path: 'sales', component: QuotationComponent, canActivate: [AuthGuard], data: {serviceId: ServiceID.Submission, type: 'sales'} },
+]
+
+@NgModule({
+  declarations: [
+    QuotationComponent
+  ],
+  imports: [
+    CommonModule,
+    RouterModule.forChild(routes),
+    MatModule,
+    LabelModule,
+    FormModule, 
+    TableModule,
+    TMModule,
+    TreeModule
+  ],
+  providers: [
+
+  ]
+})
+export class QuotationModule { }

+ 7 - 0
src/app/quotation/serviceid.ts

@@ -0,0 +1,7 @@
+export enum ServiceID {
+    ListingData = 'Tender Document Listing Data',
+    DetailData = 'Tender Document Detail Data',
+    Submission = 'Sales Tender Submission',
+    DocumentHeaderData = 'Tender Document Header Data',
+    AwardedData = 'Sales Tender Award',
+  }

+ 178 - 0
src/app/quotation/styles.scss

@@ -0,0 +1,178 @@
+@import 'angularlib/styles/common.scss';
+
+@keyframes fadeOut {
+    0% {opacity: 1;}
+    50% {opacity: 1;}
+    100% {opacity: 0;}
+ }
+
+ .fadeOut {
+    animation-name: fadeOut;
+ }
+
+#main-container {
+    display: flex;
+    flex-direction: row;
+    height: 100%;
+}
+
+mat-sidenav-container {
+    height: 100%;
+}
+mat-sidenav {
+    min-width: 160px;
+}
+
+.flex-container {
+    display: flex;
+    flex-direction: row;
+    height: 100%;
+}
+
+.flex-container > #header {
+    margin: 5px;
+    flex-grow: 1;
+}
+
+.flex-container > #details {
+    flex-grow: 2;
+    max-width: 66.66%;
+}
+
+#toggle {
+    height: 95%;
+    display: flex;
+    align-items: center;
+    position: fixed;
+    left: auto;
+    bottom: 50;
+    background-color: #00000010;
+    z-index: 100;
+}
+
+[class*='dark'] #toggle {
+    background-color: #ffffff10;
+}
+
+
+#content {
+    width: 100%;
+    display: block;
+    flex: 8;
+    padding-left: 5px;
+}
+
+.documentOptions {
+    z-index: 1000;
+    display: block;
+    min-width: 200px;
+}
+
+#noDocumentSelected {
+    text-align: start;
+    padding-top: 20px;
+    width: 100%;
+}
+
+:host ::ng-deep .mat-tab-label-active {
+    box-shadow: 0px -2px 5px 3px $fis-red;
+    font-size: 12pt;
+    font-weight: bold;
+}
+
+.dark :host ::ng-deep .mat-tab-label-active {
+    box-shadow: 0px -2px 5px 3px #c7c7c7;
+}
+
+.button-bottom {
+    width: 20%;
+}
+
+.error-message {
+    color: $text-color-dark-error;
+    font-size: small;
+    font-weight: bold;    
+}
+
+[class*='dark'] .error-message {
+    color: $text-color-light-error;
+}
+
+.success-message {
+    font-size: small;
+    font-weight: bold;
+    position: absolute;
+    display: flex;
+    left: 50%;
+    right: 50%;
+    top: 5px;
+    color: $text-color-light-success;
+    animation-duration: 5s;
+    animation-name: fadeOut;
+    animation-fill-mode: both;
+}
+
+[class*='dark'] .success-message {
+    color: $text-color-dark-success;
+}
+
+.no-items {
+    width: 100%;
+    height: 100%;
+    position: absolute;
+    left: 0; right: 0;
+    top: 0; bottom:0;
+    margin: auto;
+    background: #dfdfdfec;
+    color: #3d3d3d;
+    z-index: 50;
+    border-radius: 5px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+}
+
+[class*='dark'] .no-items {
+    background: #505050ec;
+    color: #c7c7c7;
+}
+
+.flex-container-tab {display:none}
+
+@mixin mobile-layout() {
+    mat-sidenav {
+        max-width: 350px;
+    }
+
+    #main-container {
+        flex-direction: column;
+    }
+
+    .documentOptions {
+        width: 100%;
+        max-height: 30%;
+    }
+
+    #content {
+        flex: 8;
+    }
+
+    #toggle {
+        height: 93%;
+    }
+
+    
+
+    @media only screen and (orientation=portrait) {
+        .flex-container {display: none}
+        .flex-container-tab {display: flex;}
+
+    }
+
+}
+
+/* tablet && mobile */
+@media only screen and (pointer=none),(pointer=coarse) {
+    @include mobile-layout();
+}
+

+ 77 - 0
src/app/quotation/template.html

@@ -0,0 +1,77 @@
+<mat-sidenav-container>
+    <mat-sidenav #sidenav [opened]="true" mode="push" [disableClose]="activeDocument === undefined">
+        @if (documentOptionsLoaded) {
+            <tree 
+                [treeData]="documentOptions"
+                [fields]="treeController?.fields"
+                [config]="{navigateOnNodeSelected:false}"
+                [(activatedNode)]="activeDocNode"
+                (onChange)="onTreeItemSelected($event);sidenav.toggle()"></tree>
+        } @else {
+            <mat-progress-bar mode="indeterminate"></mat-progress-bar>
+
+        }
+    </mat-sidenav>
+    <mat-sidenav-content [ngClass]="cs.appSettings.theme">
+        @if (process.hasActiveProcesses) {
+            <mat-progress-bar mode="indeterminate"></mat-progress-bar>
+        }
+        <div id="toggle" (click)="sidenav.toggle()" title="Document Options">
+            <mat-icon *ngIf="!sidenav.opened">arrow_right</mat-icon>
+            <mat-icon *ngIf="sidenav.opened">arrow_left</mat-icon>
+        </div>
+        <div class="flex-container">
+            <div id="header">
+                <ng-container *ngTemplateOutlet="docHeader"></ng-container>
+            </div>
+            
+            <mat-card id="details">
+                <div *ngIf="activeDocument?.listingController?.table?.data?.length === 0 && activeDocument?.listingController?.process?.hasActiveProcesses === false"
+                    class="no-items"><h1>{{('no_items_found'|tr:'No items found').toUpperCase()}}</h1></div>
+                <ng-container *ngTemplateOutlet="docDetails"></ng-container>
+            </mat-card>
+        </div>
+
+        <mat-tab-group class="flex-container-tab" mat-stretch-tabs 
+            [selectedIndex]="1" color="accent">
+            <mat-tab #header label="Document Info">
+                <ng-container *ngTemplateOutlet="docHeader"></ng-container>
+            </mat-tab>
+            <mat-tab #details label="Items">
+                <ng-container *ngTemplateOutlet="docDetails"></ng-container>
+            </mat-tab>
+        </mat-tab-group>
+
+    </mat-sidenav-content>
+</mat-sidenav-container>
+
+<ng-template #docHeader>
+    <mat-progress-bar *ngIf="activeDocument?.formController?.process?.hasActiveProcesses" mode="indeterminate"></mat-progress-bar>
+    <formx *ngIf="activeDocument?.formController?.fields" [fields]="activeDocument.formController?.fields" 
+        [uiSchema]="tmDocumentSchema.header.uiSchema"
+        [data]="activeDocument?.formController?.data"></formx>
+</ng-template>
+
+<ng-template #docDetails>
+    <h4>{{'items'|tr:'Items'}}</h4>
+    @if (activeDocument?.listingController?.errors?.length > 0) {
+        @for (error of activeDocument?.listingController?.errors; track $index) {
+            <div class="error-message"><i>{{error}}</i></div>
+        }
+    }
+    @if (activeDocument?.listingController?.successMessages?.length > 0) {
+        @for (message of activeDocument?.listingController?.successMessages; track $index) {
+            <div class="success-message"><i>{{message}}</i></div>
+        }
+    }
+    <!--<tm-document *ngIf="activeDocument" 
+        [document]="activeDocument" [schema]="tmDocumentSchema" (onChange)="onChangeListener($event)">
+        <ng-container buttons>
+            <button mat-button class="button-bottom"
+                [disabled]="activeDocument?.listingController?.process?.hasActiveProcesses || activeDocument?.listingController?.document?.editState === 'VIEW' || activeDocument?.listingController?.submitted === true ||activeDocument?.notEditable()"
+                (click)="activeDocument?.listingController?.onSave()">
+                {{'save'|tr:'Save'}}</button>
+        </ng-container>
+    </tm-document>-->
+</ng-template>
+