agmission/Development/client/src/app/job/job-list/job-list.component.ts

580 lines
19 KiB
TypeScript

import { Component, OnInit, OnDestroy, ViewChild, AfterViewInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Subscription, interval } from 'rxjs';
import { SelectItem } from 'primeng/api';
import { Dropdown } from 'primeng/dropdown';
import { Table } from 'primeng/table';
import { FilterUtils } from 'primeng/utils';
import { IUIJob } from '../models/job.model';
import * as jobActions from '../actions/job.actions';
import * as clientActions from '@app/client/actions/client.actions';
import { select } from '@ngrx/store';
import * as fromJobs from '../reducers/';
import * as fromClients from '@app/client/reducers';
import { GC, RoleIds, globals, jobInvoiceStatus, jobListStatus, locales } from '@app/shared/global';
import { DatePipe } from '@angular/common';
import { Client } from '@app/client/models/client.model';
import { BaseComp } from '@app/shared/base/base.component';
import { Utils } from '@app/shared/utils';
import { selectLimit } from '@app/reducers';
import { Acre } from '@app/domain/models/subscription.model';
import { SUB, SubTexts, SubType } from '@app/profile/common';
import { InvoiceService } from '@app/domain/services/invoice.service';
import { RestoreTableState } from '@app/shared/restore-table-state';
import { GAService } from '@app/shared/ga.service';
import { JobCacheService } from '@app/domain/services/job-cache.service';
import { FilterDefinition, FilterChangeEvent } from '@app/shared/dynamic-filter/dynamic-filter.component';
@Component({
selector: 'agm-job-list',
templateUrl: './job-list.component.html',
styleUrls: ['./job-list.component.css']
})
export class JobListComponent extends BaseComp implements OnInit, AfterViewInit, OnDestroy {
globals = globals;
readonly dropdownStyle = { 'min-width': '170px', 'color': 'black' };
jobs: Array<IUIJob> = [];
filteredJobs: Array<IUIJob> = [];
currentJob: IUIJob;
currClient: SelectItem;
filterClientLocked = false;
clients: SelectItem[];
defaultInvoiceSetting;
private currentByTime: string[] | undefined;
private lastFiltersQuery: Record<string, any> | undefined;
jobFilterDefinitions: FilterDefinition[] = [];
readonly defaultDynamicFilters = [
...(!this.isClientUser ? [{ key: 'client', value: null }] : []),
{ key: 'createdAt', value: '1m' }
];
@ViewChild('dt') public dt: Table;
private _cl: Dropdown;
@ViewChild('cl') set cl(dropdown: Dropdown) {
this._cl = dropdown;
if (dropdown) {
dropdown.registerOnChange((newVal) => {
this.currClient = newVal;
this.filteredJobs = newVal.value
? this.jobs.filter(j => j.client?._id === newVal.value)
: this.jobs;
this.dt.first = 0;
});
}
}
rows1Page = [10, 15, 30, 60, 100];
cols: any[];
status: SelectItem[] = [GC.selAll, ...GC.selJobStatuses];
statusFilter;
startDateFilter: Date;
endDateFilter: Date;
reloadOps: SelectItem[];
reloadBy = 0;
reload$: Subscription;
showStatusPlus: boolean;
totalJobs;
acre: Acre;
searchAccordionOpen = sessionStorage.getItem('job-list-accordion') === 'true';
get canWrite(): boolean {
return this.authSvc.hasRole([RoleIds.APP, RoleIds.APP_ADM, RoleIds.OFFICER, RoleIds.PILOT, RoleIds.CLIENT]);
}
get canWriteInvoice(): boolean {
return this.authSvc.canAccessInvoice
&& this.jobs?.length > 0;
}
constructor(
private readonly route: ActivatedRoute,
private readonly datePipe: DatePipe,
private readonly invoiceSvc: InvoiceService,
private readonly restoreTableSvc: RestoreTableState,
private readonly gaService: GAService,
private readonly jobCache: JobCacheService
) {
super();
this.currClient = ({ label: globals.all, value: null });
this.totalJobs = { '=0': '', '=1': '1 ' + $localize`:@@job:job`.toLocaleLowerCase(), 'other': $localize`:@@total#Jobs:Total: # jobs` };
this.status = [
{ label: globals.all, value: jobListStatus.ALL },
{ label: globals.statusNew, value: jobListStatus.NEW },
{ label: globals.statusReady, value: jobListStatus.READY },
{ label: globals.statusDownloaded, value: jobListStatus.DOWNLOAD },
{ label: globals.statusSprayed, value: jobListStatus.SPRAY },
{ label: globals.statusInvoiced, value: jobListStatus.INVOICED },
];
this.statusFilter = jobListStatus.ALL;
this.cols = [
{ field: '_id', header: $localize`:@@id:Id` + ' ' + globals.num, width: '10%', filtered: true, filterMatchMode: 'contains' },
{
field: 'orderNumber',
header: $localize`:@@order:Order` + ' ' + globals.num,
width: '10%',
filtered: true,
filterMatchMode: 'contains'
},
{ field: 'name', header: globals.name, width: this.isClientUser ? '34%' : '20%', filtered: true, filterMatchMode: 'contains' },
{ field: 'startDate', header: $localize`:@@startDate:Start Date`, width: '12%' },
{ field: 'endDate', header: $localize`:@@endDate:End Date`, width: '12%' },
{ field: 'status', header: $localize`:@@status:Status`, width: '22%' },
];
if (!this.isClientUser) {
this.cols.unshift({ field: 'client.name', header: $localize`:@@client:Client`, width: '14%' });
}
this.reloadOps = [
{ label: globals.noReload, value: 0 },
{ label: globals.reloadByMinutes.replace('#count#', '5'), value: 5 },
{ label: globals.reloadByMinutes.replace('#count#', '10'), value: 10 },
{ label: globals.reloadByMinutes.replace('#count#', '15'), value: 15 }
];
this.showStatusPlus = !this.authSvc.hasRole([RoleIds.CLIENT, RoleIds.INSPECTOR]);
this.defaultInvoiceSetting = this.invoiceSvc.defaultSetting;
(FilterUtils as any)['dateIs'] = (value: any, filter: any): boolean => {
if (!filter) { return true; }
if (!value) { return false; }
const valDate = new Date(value);
const filterDate = new Date(filter);
return valDate.getFullYear() === filterDate.getFullYear()
&& valDate.getMonth() === filterDate.getMonth()
&& valDate.getDate() === filterDate.getDate();
};
this.jobFilterDefinitions = [
...(!this.isClientUser ? [{ key: 'client', label: $localize`:@@client:Client`, dataType: 'select' as const, options: [] }] : []),
{ key: '_id', label: $localize`:@@id:Id` + ' ' + globals.num, dataType: 'text' as const },
{ key: 'orderNumber', label: $localize`:@@order:Order` + ' ' + globals.num, dataType: 'text' as const },
{ key: 'name', label: globals.name, dataType: 'text' as const },
{ key: 'startDate', label: $localize`:@@startDate:Start Date`, dataType: 'date' as const },
{ key: 'endDate', label: $localize`:@@endDate:End Date`, dataType: 'date' as const },
{ key: 'createdAt', label: $localize`:@@createdDate:Created Date`, dataType: 'date-preset' as const },
{ key: 'status', label: $localize`:@@status:Status`, dataType: 'select-multi' as const, options: GC.selJobStatuses },
];
}
ngOnInit() {
// Initialize subscriptions first to get accurate data
this.sub$ = this.store.pipe(select(fromClients.getAllClients)).subscribe(clients => {
if (Utils.isEmptyArray(clients)) {
return;
}
this.clients = clients.map(it => ({ value: it._id, label: it.name }));
if (!this.isClientUser) {
this.clients.unshift(({ label: globals.all, value: null }));
const clientDef = this.jobFilterDefinitions.find(f => f.key === 'client');
if (clientDef) { clientDef.options = this.clients; }
}
});
this.sub$.add(this.store.pipe(select(fromClients.getSelectedClient)).subscribe(client => {
if (client) {
if (this.currClient.value !== client._id) {
this.currClient = ({ label: client.name, value: client._id });
}
} else {
this.currClient = ({ label: globals.all, value: null });
}
})); this.sub$.add(this.store.pipe(select(fromJobs.getJobsByClient)).subscribe(jobs => {
this.jobs = jobs;
this.filteredJobs = jobs;
}));
this.sub$.add(this.store.pipe(select(fromJobs.getSelectedJob)).subscribe((job) => {
this.currentJob = job;
}));
this.sub$.add(this.store.select(selectLimit(SubType.PACKAGE)).subscribe((pkg) => {
if (pkg) {
const lookupKey = this.authSvc.getCurLookupKey(SubType.PACKAGE);
// If lookup key is empty (user data not loaded yet), find first package key
let effectiveLookupKey = lookupKey;
if (!lookupKey && pkg) {
const packageKeys = Object.keys(pkg);
if (packageKeys.length > 0) {
effectiveLookupKey = packageKeys[0]; // Use first available package
}
}
this.acre = pkg[effectiveLookupKey]?.acre;
}
}));
}
ngAfterViewInit(): void {
// Track job list viewed ONCE when component is fully initialized
this.trackJobListViewedEvent();
const listFilter = sessionStorage.getItem('jtb-ops') ? JSON.parse(sessionStorage.getItem('jtb-ops')) : null;
if (listFilter?.filters) {
const status = listFilter.filters.status?.value;
const invoiced = listFilter.filters.invoiceStatus?.value;
this.restoreStatusState(status, invoiced);
}
setTimeout(() => {
if (this.dt.rows >= this.dt.totalRecords) {
this.dt.first = 0;
}
}, 100);
}
private trackJobListViewedEvent(): void {
// Track agricultural business intelligence (complements automatic page_view)
this.gaService.trackJobListViewed({
user_id: this.authSvc.user?._id || 'anonymous',
platform: 'web',
view_type: 'table',
total_jobs: this.jobs?.length || 0,
displayed_jobs: this.jobs?.length || 0,
sort_by: this.dt?.sortField || null,
filter_count: this.getActiveFilterCount(),
client_filter_applied: !!this.currClient?.value,
reload_interval: this.reloadBy
});
}
restoreStatusState(status, invoiced) {
const statusMap = {
0: jobListStatus.NEW,
1: jobListStatus.READY,
2: jobListStatus.DOWNLOAD,
3: jobListStatus.SPRAY,
[jobInvoiceStatus.INVOICED]: jobListStatus.INVOICED
};
this.statusFilter = statusMap[status] ?? statusMap[invoiced] ?? jobListStatus.ALL;
}
fetchJobsByClient(clientId) {
const statusMap = {
[jobListStatus.ALL]: jobListStatus.ALL,
[jobListStatus.NEW]: 0,
[jobListStatus.READY]: 1,
[jobListStatus.DOWNLOAD]: 2,
[jobListStatus.SPRAY]: 3,
[jobListStatus.INVOICED]: jobInvoiceStatus.INVOICED
};
const statusValue = statusMap[this.statusFilter] ?? jobListStatus.ALL;
this.store.dispatch(new jobActions.Fetch({
clientId: clientId,
jobsByPilot: (this.authSvc.isPilotUser && this.settings.jobsByPilot),
byTime: this.currentByTime,
status: statusValue
}));
}
onCreatedDateChanged(byTime: string[]): void {
this.currentByTime = byTime;
this.reloadJobs();
}
onDateFilter(value: Date, field: string) {
this.dt.filter(value, field, 'dateIs');
}
onAccordionToggle(expanded: boolean) {
sessionStorage.setItem('job-list-accordion', String(expanded));
}
restoreTableFirst() {
this.restoreTableSvc.restoreTableFirst(this.dt);
}
onPageChange(e) {
this.restoreTableSvc.onPageChange(this.dt, e);
}
onRowSelect(event) {
this.store.dispatch(new jobActions.Select(this.currentJob));
// Track job selection
if (this.currentJob) {
const positionInList = this.jobs.findIndex(job => job._id === this.currentJob._id) + 1;
this.gaService.trackJobSelected({
user_id: this.authSvc.user?._id || 'anonymous',
platform: 'web',
job_id: this.currentJob._id.toString(),
selection_method: 'row_click',
position_in_list: positionInList,
job_type: this.currentJob.appType || 'unknown',
job_status: this.currentJob.status?.toString() || 'unknown'
});
}
}
get canAddNew(): boolean {
// Check subscription package loaded (!!this.acre) and not over limit
// Note: With unlimited acres (limit: null), overLimit will always be false,
// but keep this check for defensive programming in case limited plans return
return !!this.acre && !this.acre.overLimit;
}
displaySubDia() {
return this.confirmSvc.confirm({
header: SubTexts.textUpgradeSub,
message: SubTexts.textUpgradeSubMsg,
accept: () => {
this.router.navigate([SUB.PROFILE, SUB.MY_SERVICES]);
}
});
}
newJob() {
if (this.canAddNew) {
return this.router.navigate(['./0/edit'], { relativeTo: this.route });
}
return this.displaySubDia();
}
duplicateJob() {
if (this.canAddNew) {
// Track bulk action (duplicate)
this.gaService.trackJobBulkAction({
user_id: this.authSvc.user?._id || 'anonymous',
platform: 'web',
action_type: 'duplicate',
job_count: 1,
job_ids: [this.currentJob._id.toString()],
success_rate: 1.0
});
return this.router.navigate([`./${this.currentJob._id}/edit`, { dup: true }], { relativeTo: this.route });
}
return this.displaySubDia();
}
editJob() {
this.router.navigate([`./${this.currentJob._id}/edit`], { relativeTo: this.route });
}
editJobMap() {
this.router.navigate([`./${this.currentJob._id}/editMap`, { flag: 0 }], { relativeTo: this.route });
}
canEdit() {
return (this.currentJob && this.currentJob._id !== 0);
}
canCreateInvoice() {
return (this.currentJob &&
this.currentJob.status != 0 &&
this.currentJob.costings &&
this.currentJob.costings.billableAmount &&
this.currentJob.invoiceStatus == jobInvoiceStatus.NONE);
}
reloadJobs() {
const startTime = performance.now();
this.jobCache.invalidate();
if (this.lastFiltersQuery) {
this.store.dispatch(new jobActions.Fetch({
jobsByPilot: (this.authSvc.isPilotUser && this.settings.jobsByPilot),
filters: JSON.stringify(this.lastFiltersQuery)
}));
} else {
this.fetchJobsByClient(this.currClient && this.currClient.value);
}
// Track job list reload
setTimeout(() => {
const endTime = performance.now();
this.gaService.trackJobListViewed({
user_id: this.authSvc.user?._id || 'anonymous',
platform: 'web',
view_type: 'table',
total_jobs: this.jobs?.length || 0,
displayed_jobs: this.jobs?.length || 0,
sort_by: this.dt?.sortField || null,
filter_count: this.getActiveFilterCount(),
load_time_ms: Math.round(endTime - startTime),
client_filter_applied: !!this.currClient?.value,
reload_interval: this.reloadBy
});
}, 100);
}
reloadChanged(value) {
if (this.reload$) {
this.reload$.unsubscribe();
}
if (!value) {
return;
}
this.reload$ = interval(value * 60 * 1000).subscribe(() => this.reloadJobs());
}
deleteJob() {
this.confirmSvc.confirm({
message: globals.confirmDeleteThing.replace('#thing#', globals.job),
accept: () => {
this.store.dispatch(new jobActions.Delete(this.currentJob));
this.currentJob = null;
}
});
}
createInvoice() {
if (!this.defaultInvoiceSetting) {
this.msgSvc.addFailedMsg($localize`:@@noInvoiceSettingOnCreateInvoiceErr:Please create invoice setting before create invoice`);
return;
}
if (this.defaultInvoiceSetting && this.currentJob.costings.currency != this.defaultInvoiceSetting.currency) {
this.msgSvc.addFailedMsg($localize`:@@jobCurrencyNotMatchSettingErr:This job's currency does not match with invoice currency setting.`);
return;
}
this.router.navigate(['/invoices/edit/0']);
}
gotoClients() {
this.router.navigate(['/clients']);
}
onFiltersSubmit(event: FilterChangeEvent) {
const q = { ...event.query };
// Ensure createdAt always has a value so the server always applies a date range.
// Default to 'Past 1 Month' if the user has not added a Created Date filter.
if (!q.createdAt) {
q.createdAt = { value: '1m', operator: 'and', valueOperator: 'exact', dataType: 'date-preset' };
}
// Sync the table's client dropdown to match the client selected in the search filters.
const clientId = q.client?.value ?? null;
const matchedClient = this.clients?.find(c => c.value === clientId);
this.currClient = matchedClient || { label: globals.all, value: null };
this.filterClientLocked = !!clientId;
this.lastFiltersQuery = q;
this.store.dispatch(new jobActions.Fetch({
jobsByPilot: (this.authSvc.isPilotUser && this.settings.jobsByPilot),
filters: JSON.stringify(q)
}));
}
getUsers(byUsers) {
if (!byUsers || !Array.isArray(byUsers) || byUsers.length === 0) {
return '';
}
let byStr = '';
for (let i = 0; i < byUsers.length; i++) {
const it = byUsers[i];
byStr += `${it.user} - ${this.datePipe.transform(it.date, 'MMM.dd')}`;
if (i !== byUsers.length - 1) {
byStr += ', ';
}
}
return $localize`:@@by:by` + ': <br/>' + byStr;
}
handleStatusFilter(value) {
const previousCount = this.jobs?.length || 0;
switch (value) {
case jobListStatus.ALL:
this.dt.filter(null, 'status', 'equals');
this.dt.filter('', 'invoiceStatus', 'contains');
this.statusFilter = jobListStatus.ALL;
break;
case jobListStatus.NEW:
this.dt.filter(0, 'status', 'equals');
this.dt.filter('', 'invoiceStatus', 'contains');
this.statusFilter = jobListStatus.NEW;
break;
case jobListStatus.READY:
this.dt.filter(1, 'status', 'equals');
this.dt.filter('', 'invoiceStatus', 'contains');
this.statusFilter = jobListStatus.READY;
break;
case jobListStatus.DOWNLOAD:
this.dt.filter(2, 'status', 'equals');
this.dt.filter('', 'invoiceStatus', 'contains');
this.statusFilter = jobListStatus.DOWNLOAD;
break;
case jobListStatus.SPRAY:
this.dt.filter(3, 'status', 'equals');
this.dt.filter('', 'invoiceStatus', 'contains');
this.statusFilter = jobListStatus.SPRAY;
break;
case jobListStatus.INVOICED:
this.dt.filter(null, 'status', 'equals');
this.dt.filter(jobInvoiceStatus.INVOICED, 'invoiceStatus', 'contains');
this.statusFilter = jobListStatus.INVOICED;
break;
}
// Track filter usage
setTimeout(() => {
const currentCount = this.jobs?.length || 0;
this.gaService.trackJobListFiltered({
user_id: this.authSvc.user?._id || 'anonymous',
platform: 'web',
filter_type: 'status',
filter_value: value,
results_before: previousCount,
results_after: currentCount,
filter_effectiveness: previousCount > 0 ? (currentCount / previousCount) : 0
});
}, 100);
}
// Helper method to count active filters
private getActiveFilterCount(): number {
let count = 0;
// Check status filter
if (this.statusFilter && this.statusFilter !== jobListStatus.ALL) {
count++;
}
// Check client filter
if (this.currClient?.value) {
count++;
}
// Check date filter
if (this.currentByTime && this.currentByTime.length > 0) {
count++;
}
// Check table column filters
if (this.dt?.filters) {
Object.keys(this.dt.filters).forEach(key => {
const filter = this.dt.filters[key];
if (filter && filter.value && filter.value !== '') {
count++;
}
});
}
return count;
}
ngOnDestroy() {
super.ngOnDestroy();
if (this.reload$) {
this.reload$.unsubscribe();
}
}
}