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 = []; filteredJobs: Array = []; currentJob: IUIJob; currClient: SelectItem; filterClientLocked = false; clients: SelectItem[]; defaultInvoiceSetting; private currentByTime: string[] | undefined; private lastFiltersQuery: Record | 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` + ':
' + 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(); } } }