all Data Export API changes from April 28 2026
Some checks are pending
Server Tests / Mocha – Unit & Utility Tests (push) Waiting to run

This commit is contained in:
Devin Major 2026-04-28 15:44:46 -04:00
parent df31b2080d
commit 9303274349
34 changed files with 1166 additions and 21 deletions

View File

@ -52,6 +52,11 @@
"glob": "**/*", "glob": "**/*",
"input": "node_modules/leaflet/dist/images", "input": "node_modules/leaflet/dist/images",
"output": "/assets/images" "output": "/assets/images"
},
{
"glob": "CHANGELOG.md",
"input": "docs",
"output": "/assets/docs"
} }
], ],
"styles": [ "styles": [

View File

@ -0,0 +1,8 @@
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [revision 1603] - 2026-04-28
- Created the changelog file

View File

@ -40,8 +40,10 @@
"geodesy": "^1.1.3", "geodesy": "^1.1.3",
"intl": "^1.2.5", "intl": "^1.2.5",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"marked": "^1.2.9",
"ngrx-store-localstorage": "^9.0.0", "ngrx-store-localstorage": "^9.0.0",
"ngx-captcha": "^8.0.1", "ngx-captcha": "^8.0.1",
"ngx-markdown": "^9.1.1",
"primeng-lts": "^9.2.8", "primeng-lts": "^9.2.8",
"quill": "^1.3.7", "quill": "^1.3.7",
"rbush": "^3.0.1", "rbush": "^3.0.1",
@ -2838,6 +2840,11 @@
"@types/leaflet": "*" "@types/leaflet": "*"
} }
}, },
"node_modules/@types/marked": {
"version": "0.7.4",
"resolved": "https://registry.npmjs.org/@types/marked/-/marked-0.7.4.tgz",
"integrity": "sha512-fdg0NO4qpuHWtZk6dASgsrBggY+8N4dWthl1bAQG9ceKUNKFjqpHaDKCAhRUI6y8vavG7hLSJ4YBwJtZyZEXqw=="
},
"node_modules/@types/minimatch": { "node_modules/@types/minimatch": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-6.0.0.tgz", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-6.0.0.tgz",
@ -5032,8 +5039,7 @@
"node_modules/commander": { "node_modules/commander": {
"version": "2.20.3", "version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="
"dev": true
}, },
"node_modules/commondir": { "node_modules/commondir": {
"version": "1.0.1", "version": "1.0.1",
@ -6455,6 +6461,11 @@
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
}, },
"node_modules/emoji-toolkit": {
"version": "5.5.1",
"resolved": "https://registry.npmjs.org/emoji-toolkit/-/emoji-toolkit-5.5.1.tgz",
"integrity": "sha512-H8E6DNTsRLgy1FVWAiyuW4nqHka0rvUkXhmJPzL28gXo4pLKvuoEi6VhodJ1RfIZOZZ7Zmxo1sENYinyytl/ww=="
},
"node_modules/emojis-list": { "node_modules/emojis-list": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz",
@ -10165,6 +10176,17 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/katex": {
"version": "0.11.1",
"resolved": "https://registry.npmjs.org/katex/-/katex-0.11.1.tgz",
"integrity": "sha512-5oANDICCTX0NqYIyAiFCCwjQ7ERu3DQG2JFHLbYOf+fXaMoH8eg/zOq5WSYJsKMi/QebW+Eh3gSM+oss1H/bww==",
"dependencies": {
"commander": "^2.19.0"
},
"bin": {
"katex": "cli.js"
}
},
"node_modules/killable": { "node_modules/killable": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/killable/-/killable-1.0.1.tgz", "resolved": "https://registry.npmjs.org/killable/-/killable-1.0.1.tgz",
@ -10660,6 +10682,17 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/marked": {
"version": "1.2.9",
"resolved": "https://registry.npmjs.org/marked/-/marked-1.2.9.tgz",
"integrity": "sha512-H8lIX2SvyitGX+TRdtS06m1jHMijKN/XjfH6Ooii9fvxMlh8QdqBfBDkGUpMWH2kQNrtixjzYUa3SH8ROTgRRw==",
"bin": {
"marked": "bin/marked"
},
"engines": {
"node": ">= 8.16.2"
}
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@ -11268,6 +11301,26 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/ngx-markdown": {
"version": "9.1.1",
"resolved": "https://registry.npmjs.org/ngx-markdown/-/ngx-markdown-9.1.1.tgz",
"integrity": "sha512-dEuR1KBa/Ivb1HT+DvSW1p6wLSx79EZz8/WpgDxiEZfL1PADTQUziNLgmtwAEgBTDjsXFhZ/Af7Zq5J9dQf6KQ==",
"dependencies": {
"@types/marked": "^0.7.4",
"emoji-toolkit": "^5.5.0",
"katex": "^0.11.0",
"marked": "^1.1.0",
"prismjs": "^1.20.0"
},
"peerDependencies": {
"@angular/common": "^8.0.0 || ^9.0.0",
"@angular/core": "^8.0.0 || ^9.0.0",
"@angular/platform-browser": "^8.0.0 || ^9.0.0",
"rxjs": "^6.0.0",
"tslib": "^1.10.0",
"zone.js": "^0.9.0 || ^0.10.0"
}
},
"node_modules/nice-try": { "node_modules/nice-try": {
"version": "1.0.5", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz",
@ -13301,6 +13354,14 @@
"zone.js": "^0.10.2" "zone.js": "^0.10.2"
} }
}, },
"node_modules/prismjs": {
"version": "1.30.0",
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
"integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==",
"engines": {
"node": ">=6"
}
},
"node_modules/process": { "node_modules/process": {
"version": "0.11.10", "version": "0.11.10",
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
@ -21768,6 +21829,11 @@
"@types/leaflet": "*" "@types/leaflet": "*"
} }
}, },
"@types/marked": {
"version": "0.7.4",
"resolved": "https://registry.npmjs.org/@types/marked/-/marked-0.7.4.tgz",
"integrity": "sha512-fdg0NO4qpuHWtZk6dASgsrBggY+8N4dWthl1bAQG9ceKUNKFjqpHaDKCAhRUI6y8vavG7hLSJ4YBwJtZyZEXqw=="
},
"@types/minimatch": { "@types/minimatch": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-6.0.0.tgz", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-6.0.0.tgz",
@ -23550,8 +23616,7 @@
"commander": { "commander": {
"version": "2.20.3", "version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="
"dev": true
}, },
"commondir": { "commondir": {
"version": "1.0.1", "version": "1.0.1",
@ -24713,6 +24778,11 @@
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
}, },
"emoji-toolkit": {
"version": "5.5.1",
"resolved": "https://registry.npmjs.org/emoji-toolkit/-/emoji-toolkit-5.5.1.tgz",
"integrity": "sha512-H8E6DNTsRLgy1FVWAiyuW4nqHka0rvUkXhmJPzL28gXo4pLKvuoEi6VhodJ1RfIZOZZ7Zmxo1sENYinyytl/ww=="
},
"emojis-list": { "emojis-list": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz",
@ -27599,6 +27669,14 @@
"source-map-support": "^0.5.5" "source-map-support": "^0.5.5"
} }
}, },
"katex": {
"version": "0.11.1",
"resolved": "https://registry.npmjs.org/katex/-/katex-0.11.1.tgz",
"integrity": "sha512-5oANDICCTX0NqYIyAiFCCwjQ7ERu3DQG2JFHLbYOf+fXaMoH8eg/zOq5WSYJsKMi/QebW+Eh3gSM+oss1H/bww==",
"requires": {
"commander": "^2.19.0"
}
},
"killable": { "killable": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/killable/-/killable-1.0.1.tgz", "resolved": "https://registry.npmjs.org/killable/-/killable-1.0.1.tgz",
@ -28002,6 +28080,11 @@
"object-visit": "^1.0.0" "object-visit": "^1.0.0"
} }
}, },
"marked": {
"version": "1.2.9",
"resolved": "https://registry.npmjs.org/marked/-/marked-1.2.9.tgz",
"integrity": "sha512-H8lIX2SvyitGX+TRdtS06m1jHMijKN/XjfH6Ooii9fvxMlh8QdqBfBDkGUpMWH2kQNrtixjzYUa3SH8ROTgRRw=="
},
"math-intrinsics": { "math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@ -28492,6 +28575,18 @@
"xmldom": "^0.1.27" "xmldom": "^0.1.27"
} }
}, },
"ngx-markdown": {
"version": "9.1.1",
"resolved": "https://registry.npmjs.org/ngx-markdown/-/ngx-markdown-9.1.1.tgz",
"integrity": "sha512-dEuR1KBa/Ivb1HT+DvSW1p6wLSx79EZz8/WpgDxiEZfL1PADTQUziNLgmtwAEgBTDjsXFhZ/Af7Zq5J9dQf6KQ==",
"requires": {
"@types/marked": "^0.7.4",
"emoji-toolkit": "^5.5.0",
"katex": "^0.11.0",
"marked": "^1.1.0",
"prismjs": "^1.20.0"
}
},
"nice-try": { "nice-try": {
"version": "1.0.5", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz",
@ -30193,6 +30288,11 @@
"integrity": "sha512-0cxWuVEuruMFT5GovcnNSqyzn+f/qgCUdEXHEcJ297ySp5P1S5HB8IfsmHkCEpI8BDU+X6k3seP9FtzWeqxigw==", "integrity": "sha512-0cxWuVEuruMFT5GovcnNSqyzn+f/qgCUdEXHEcJ297ySp5P1S5HB8IfsmHkCEpI8BDU+X6k3seP9FtzWeqxigw==",
"requires": {} "requires": {}
}, },
"prismjs": {
"version": "1.30.0",
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
"integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="
},
"process": { "process": {
"version": "0.11.10", "version": "0.11.10",
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",

View File

@ -58,8 +58,10 @@
"geodesy": "^1.1.3", "geodesy": "^1.1.3",
"intl": "^1.2.5", "intl": "^1.2.5",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"marked": "^1.2.9",
"ngrx-store-localstorage": "^9.0.0", "ngrx-store-localstorage": "^9.0.0",
"ngx-captcha": "^8.0.1", "ngx-captcha": "^8.0.1",
"ngx-markdown": "^9.1.1",
"primeng-lts": "^9.2.8", "primeng-lts": "^9.2.8",
"quill": "^1.3.7", "quill": "^1.3.7",
"rbush": "^3.0.1", "rbush": "^3.0.1",

View File

@ -74,6 +74,11 @@ const routes: Routes = [
loadChildren: () => import('./tools/dlq-monitor/dlq-monitor.module').then(m => m.DlqMonitorModule), loadChildren: () => import('./tools/dlq-monitor/dlq-monitor.module').then(m => m.DlqMonitorModule),
runGuardsAndResolvers: 'always', runGuardsAndResolvers: 'always',
}, },
{
path: 'dealers',
loadChildren: () => import('./tools/dealers/dealers.module').then(m => m.DealersModule),
runGuardsAndResolvers: 'always',
},
{ {
path: 'track', path: 'track',
loadChildren: () => import('./track/track.module').then(m => m.TrackModule), loadChildren: () => import('./track/track.module').then(m => m.TrackModule),
@ -100,6 +105,11 @@ const routes: Routes = [
loadChildren: () => import('./settings/api-keys/api-keys.module').then(m => m.ApiKeysModule), loadChildren: () => import('./settings/api-keys/api-keys.module').then(m => m.ApiKeysModule),
runGuardsAndResolvers: 'always' runGuardsAndResolvers: 'always'
}, },
{
path: 'changelog',
loadChildren: () => import('./changelog/changelog.module').then(m => m.ChangelogModule),
runGuardsAndResolvers: 'always'
},
], ],
}, },
{ {

View File

@ -39,6 +39,7 @@ export class AppMenuComponent implements OnInit {
const mItems: MenuItem[] = [ const mItems: MenuItem[] = [
{ id: 'dashboard', label: $localize`:@@dashboard:Dashboard`, icon: 'dashboard', routerLink: ['/home'] }, { id: 'dashboard', label: $localize`:@@dashboard:Dashboard`, icon: 'dashboard', routerLink: ['/home'] },
{ id: 'customers', label: $localize`:@@customers:Customers`, icon: 'assignment_ind', routerLink: ['/customers'] }, { id: 'customers', label: $localize`:@@customers:Customers`, icon: 'assignment_ind', routerLink: ['/customers'] },
{ id: 'dealers', label: $localize`:@@dealers:Dealers`, icon: 'store', routerLink: ['/dealers'] },
{ id: 'partners', label: $localize`:@@partnerMgnt:Partner Management`, icon: 'business', routerLink: ['/partners'] }, { id: 'partners', label: $localize`:@@partnerMgnt:Partner Management`, icon: 'business', routerLink: ['/partners'] },
{ label: $localize`:@@billing:Billing`, icon: 'monetization_on', routerLink: ['/billing'] }, { label: $localize`:@@billing:Billing`, icon: 'monetization_on', routerLink: ['/billing'] },
{ {
@ -58,6 +59,7 @@ export class AppMenuComponent implements OnInit {
{ id: 'subscription', label: $localize`:@@promoManagement:Promo Management`, icon: 'credit_card', routerLink: ['/settings/subscription'] } { id: 'subscription', label: $localize`:@@promoManagement:Promo Management`, icon: 'credit_card', routerLink: ['/settings/subscription'] }
] ]
}, },
{ id: 'changelog', label: $localize`:@@changelog:Changelog`, icon: 'history', routerLink: ['/changelog'] },
]; ];
this.model = mItems; this.model = mItems;
} }
@ -219,7 +221,7 @@ export class AppMenuComponent implements OnInit {
{ id: 'upload', label: $localize`:@@uploadJobData:Upload Job Data`, icon: 'cloud_upload', routerLink: ['/tools/upload'] }, { id: 'upload', label: $localize`:@@uploadJobData:Upload Job Data`, icon: 'cloud_upload', routerLink: ['/tools/upload'] },
{ id: 'areaLib', label: $localize`:@@manageAreasLib:Manage Areas Library`, icon: 'folder_special', routerLink: ['/tools/areas'] }, { id: 'areaLib', label: $localize`:@@manageAreasLib:Manage Areas Library`, icon: 'folder_special', routerLink: ['/tools/areas'] },
{ id: 'settings', label: $localize`:@@settings:Settings`, icon: 'settings', routerLink: ['/tools/settings'] }, { id: 'settings', label: $localize`:@@settings:Settings`, icon: 'settings', routerLink: ['/tools/settings'] },
...( this.authSvc.hasRole([RoleIds.APP, RoleIds.APP_ADM]) ...( this.authSvc.hasRole([RoleIds.APP])
? [{ id: 'api-keys', label: $localize`:@@apiKeys:API Keys`, icon: 'vpn_key', routerLink: ['/api-keys'] }] ? [{ id: 'api-keys', label: $localize`:@@apiKeys:API Keys`, icon: 'vpn_key', routerLink: ['/api-keys'] }]
: [] ) : [] )
] ]

View File

@ -0,0 +1,18 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { AuthGuard } from '../domain/guards/auth.guard';
import { ChangelogComponent } from './changelog.component';
const routes: Routes = [
{
path: '',
component: ChangelogComponent,
canActivate: [AuthGuard]
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class ChangelogRoutingModule { }

View File

@ -0,0 +1,20 @@
<div class="ui-g">
<div class="ui-g-12">
<div class="card card-w-title">
<h1 i18n="@@changelog">Changelog</h1>
<div *ngIf="loading" style="text-align:center; padding: 2rem;">
<p-progressSpinner></p-progressSpinner>
</div>
<ng-container *ngIf="!loading">
<p-panel
*ngFor="let section of sections; let i = index"
[header]="section.header"
[toggleable]="true"
[collapsed]="i !== 0"
styleClass="changelog-panel">
<div class="changelog-content" [innerHTML]="section.content"></div>
</p-panel>
</ng-container>
</div>
</div>
</div>

View File

@ -0,0 +1,62 @@
import { Component, OnInit } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import * as marked from 'marked';
const parseMarkdown: (src: string) => string = (marked as any).marked ?? (marked as any).default ?? (marked as any);
interface ChangelogSection {
header: string;
content: SafeHtml;
}
@Component({
selector: 'app-changelog',
templateUrl: './changelog.component.html'
})
export class ChangelogComponent implements OnInit {
sections: ChangelogSection[] = [];
loading = true;
constructor(
private readonly http: HttpClient,
private readonly sanitizer: DomSanitizer
) { }
ngOnInit(): void {
this.http.get('/assets/docs/CHANGELOG.md', { responseType: 'text' }).subscribe({
next: (md) => {
this.sections = this.parseSections(md);
this.loading = false;
},
error: () => { this.loading = false; }
});
}
private parseSections(md: string): ChangelogSection[] {
const sections: ChangelogSection[] = [];
let currentHeader = '';
let currentBody = '';
for (const line of md.split('\n')) {
if (line.startsWith('## ')) {
if (currentHeader) {
sections.push({
header: currentHeader,
content: this.sanitizer.bypassSecurityTrustHtml(parseMarkdown(currentBody.trim()))
});
}
currentHeader = line.replace(/^##\s*/, '');
currentBody = '';
} else if (currentHeader) {
currentBody += line + '\n';
}
}
if (currentHeader) {
sections.push({
header: currentHeader,
content: this.sanitizer.bypassSecurityTrustHtml(parseMarkdown(currentBody.trim()))
});
}
return sections;
}
}

View File

@ -0,0 +1,20 @@
import { NgModule } from '@angular/core';
import { HttpClientModule } from '@angular/common/http';
import { ProgressSpinnerModule } from 'primeng/progressspinner';
import { PanelModule } from 'primeng/panel';
import { AppSharedModule } from '../shared/app-shared.module';
import { ChangelogRoutingModule } from './changelog-routing.module';
import { ChangelogComponent } from './changelog.component';
@NgModule({
imports: [
AppSharedModule,
HttpClientModule,
ProgressSpinnerModule,
PanelModule,
ChangelogRoutingModule
],
declarations: [ChangelogComponent]
})
export class ChangelogModule { }

View File

@ -56,6 +56,20 @@
</div> </div>
</div> </div>
<!-- Dealer Selection -->
<div class="ui-g-12 ui-md-6 ui-lg-6 form-row">
<span class="form-label-span">Dealer:</span>
<p-dropdown id="dealer" name="dealer" formControlName="dealer"
[options]="dealerOptions"
[style]="{'min-width': '200px'}"
placeholder="Select Dealer"
[loading]="dealerLoading"
[filter]="true"
filterBy="label"
appendTo="body">
</p-dropdown>
</div>
<div class="ui-g-12 ui-md-6 ui-lg-6 form-row"> <div class="ui-g-12 ui-md-6 ui-lg-6 form-row">
<p-checkbox id="billable" name="billable" formControlName="billable" label="Billable" <p-checkbox id="billable" name="billable" formControlName="billable" label="Billable"
binary="true"></p-checkbox> binary="true"></p-checkbox>

View File

@ -6,6 +6,7 @@ import { Customer, Partner } from '../models/customer.model';
import * as customerActions from '../actions/customer.actions'; import * as customerActions from '../actions/customer.actions';
import { UserService } from '@app/domain/services/user.service'; import { UserService } from '@app/domain/services/user.service';
import { PartnerService } from '@app/partners/services/partner.service'; import { PartnerService } from '@app/partners/services/partner.service';
import { Dealer, DealerService } from '@app/tools/dealers/dealer.service';
import { BaseComp } from '@app/shared/base/base.component'; import { BaseComp } from '@app/shared/base/base.component';
import { GC, RoleIds, globals, Labels } from '@app/shared/global'; import { GC, RoleIds, globals, Labels } from '@app/shared/global';
import { AGNavSubscription, Trial } from '@app/domain/models/subscription.model'; import { AGNavSubscription, Trial } from '@app/domain/models/subscription.model';
@ -40,6 +41,10 @@ export class CustomerEditComponent extends BaseComp implements OnInit {
partnerLoading = false; partnerLoading = false;
partnerError: string | null = null; partnerError: string | null = null;
// Dealer Selection Properties
dealerOptions: SelectItem[] = [];
dealerLoading = false;
private _customer: Customer; private _customer: Customer;
get customer(): Customer { return this._customer; } get customer(): Customer { return this._customer; }
set customer(customer: Customer) { set customer(customer: Customer) {
@ -51,7 +56,8 @@ export class CustomerEditComponent extends BaseComp implements OnInit {
premium: this.selectedItem.premium, premium: this.selectedItem.premium,
billable: this.selectedItem.billable, billable: this.selectedItem.billable,
trials: this.selectedItem.membership?.trials, trials: this.selectedItem.membership?.trials,
partner: this.selectedItem.partner || null partner: this.selectedItem.partner || null,
dealer: this.selectedItem.dealer || null
}); });
// Set partner selection based on customer.partner field, or null if not set // Set partner selection based on customer.partner field, or null if not set
@ -67,6 +73,7 @@ export class CustomerEditComponent extends BaseComp implements OnInit {
private readonly route: ActivatedRoute, private readonly route: ActivatedRoute,
private readonly userSvc: UserService, private readonly userSvc: UserService,
private readonly partnerSvc: PartnerService, private readonly partnerSvc: PartnerService,
private readonly dealerSvc: DealerService,
private readonly fb: FormBuilder private readonly fb: FormBuilder
) { ) {
super(); super();
@ -83,7 +90,9 @@ export class CustomerEditComponent extends BaseComp implements OnInit {
billable: [], billable: [],
trials: [], trials: [],
// Partner form control // Partner form control
partner: [null] partner: [null],
// Dealer form control
dealer: [null]
}); });
this.lang = this.authSvc.locale; this.lang = this.authSvc.locale;
@ -106,6 +115,7 @@ export class CustomerEditComponent extends BaseComp implements OnInit {
} }
// Load partners from service // Load partners from service
this.loadPartners(); this.loadPartners();
this.loadDealers();
} }
}); });
@ -171,7 +181,8 @@ export class CustomerEditComponent extends BaseComp implements OnInit {
custObj = Object.assign(this.selectedItem, this.form.value.profile, this.form.value.account, custObj = Object.assign(this.selectedItem, this.form.value.profile, this.form.value.account,
{ premium: this.form.value.premium || false }, { premium: this.form.value.premium || false },
{ billable: this.form.value.billable || false }, { billable: this.form.value.billable || false },
{ partner: this.form.value.partner || null }); { partner: this.form.value.partner || null },
{ dealer: this.form.value.dealer?._id || this.form.value.dealer || null });
this.membership this.membership
? custObj = Object.assign(custObj, { membership: updateTrialMembship(this.membership) }) ? custObj = Object.assign(custObj, { membership: updateTrialMembship(this.membership) })
@ -209,6 +220,30 @@ export class CustomerEditComponent extends BaseComp implements OnInit {
return DateUtils.dateToTS(date); return DateUtils.dateToTS(date);
} }
// Dealer Methods
private loadDealers(): void {
this.dealerLoading = true;
this.dealerSvc.getAll().subscribe({
next: (dealers: Dealer[]) => {
this.dealerOptions = [
{ label: 'None', value: null },
...dealers
.sort((a, b) => a.companyName.localeCompare(b.companyName))
.map(d => ({
label: d.country ? `${d.companyName} (${d.country})` : d.companyName,
value: d
}))
];
if (this.customer?.dealer) {
const match = this.dealerOptions.find(o => o.value && o.value._id === (this.customer.dealer as any)?._id);
if (match) { this.form.patchValue({ dealer: match.value }); }
}
this.dealerLoading = false;
},
error: () => { this.dealerLoading = false; }
});
}
// Partner Methods // Partner Methods
private loadPartners(): void { private loadPartners(): void {
this.partnerLoading = true; this.partnerLoading = true;

View File

@ -1,6 +1,7 @@
import { RoleIds } from '@app/shared/global'; import { RoleIds } from '@app/shared/global';
import { createNewUser, User } from '@app/accounts/models/user.model'; import { createNewUser, User } from '@app/accounts/models/user.model';
import { IMembership } from '@app/auth/models/user.model'; import { IMembership } from '@app/auth/models/user.model';
import { Dealer } from '@app/tools/dealers/dealer.service';
export interface Customer extends User { export interface Customer extends User {
contact?: string; contact?: string;
@ -10,6 +11,7 @@ export interface Customer extends User {
totalJobs?: number; totalJobs?: number;
membership: IMembership, membership: IMembership,
partner?: Partner; partner?: Partner;
dealer?: Dealer;
selfSignup?: boolean; selfSignup?: boolean;
} }

View File

@ -2,6 +2,7 @@ import { Injectable } from '@angular/core';
import { from, Observable, of } from 'rxjs'; import { from, Observable, of } from 'rxjs';
import { catchError, switchMap } from 'rxjs/operators'; import { catchError, switchMap } from 'rxjs/operators';
import { AppConfigService } from './app-config.service'; import { AppConfigService } from './app-config.service';
import { AuthService } from './auth.service';
/** Shape of every entry stored in Cache Storage. */ /** Shape of every entry stored in Cache Storage. */
export interface BrowserCacheEntry<T> { export interface BrowserCacheEntry<T> {
@ -37,10 +38,14 @@ export class BrowserCacheService {
private readonly supported = typeof caches !== 'undefined'; private readonly supported = typeof caches !== 'undefined';
private readonly fallbackMaxAgeMs = 60_000; private readonly fallbackMaxAgeMs = 60_000;
constructor(private readonly appConfig: AppConfigService) {} constructor(
private readonly appConfig: AppConfigService,
private readonly authSvc: AuthService
) {}
private ttlStorageKey(cacheName: string): string { private ttlStorageKey(cacheName: string): string {
return `browser-cache-ttl:${cacheName}`; const userId = this.authSvc.user?._id || 'anonymous';
return `browser-cache-ttl:${userId}:${cacheName}`;
} }
private get defaultMaxAgeMs(): number { private get defaultMaxAgeMs(): number {

View File

@ -53,13 +53,15 @@
} }
.badge-active { .badge-active {
background: #A5D6A7; background: #4CAF50;
color: #2E7D32; border: 1px solid #2E7D32;
color: #fff;
} }
.badge-revoked { .badge-revoked {
background: #ffcdd2; background: #f44336;
color: #b71c1c; border: 1px solid #d32f2f;
color: #fff;
} }
.empty-message { .empty-message {

View File

@ -10,7 +10,7 @@ const routes: Routes = [
path: '', path: '',
component: ApiKeyManagerComponent, component: ApiKeyManagerComponent,
data: { data: {
roles: [RoleIds.ADMIN, RoleIds.APP, RoleIds.APP_ADM] roles: [RoleIds.ADMIN, RoleIds.APP]
}, },
canActivate: [AuthGuard] canActivate: [AuthGuard]
} }

View File

@ -224,10 +224,19 @@
<ng-template #partnerSection let-formGroup="formGroup"> <ng-template #partnerSection let-formGroup="formGroup">
<div class="ui-g ui-g-12"> <div class="ui-g ui-g-12">
<div class="ui-g-12 sub-heading" i18n="@@partners" style="font-weight: bold;">Partners</div> <div class="ui-g-6 ui-sm-12">
<div class="ui-g-12" i18n="@@enterPartner" style="padding-top: 0;">Select a partner if you are using AgMission with our partner's systems.</div> <div class="ui-g-12 sub-heading" i18n="@@partners" style="font-weight: bold;">Partners</div>
<div class="ui-g-4 ui-sm-6"> <div class="ui-g-12" i18n="@@enterPartner" style="padding-top: 0;">Select a partner if you are using AgMission with our partner's systems.</div>
<ng-container [ngTemplateOutlet]="dropdown" [ngTemplateOutletContext]="{options: partners, filter: false, formControlName: partner, formGroup: formGroup}"></ng-container> <div class="ui-g-8 ui-sm-12">
<ng-container [ngTemplateOutlet]="dropdown" [ngTemplateOutletContext]="{options: partners, filter: false, formControlName: partner, formGroup: formGroup}"></ng-container>
</div>
</div>
<div class="ui-g-6 ui-sm-12">
<div class="ui-g-12 sub-heading" i18n="@@dealers" style="font-weight: bold;">Dealer</div>
<div class="ui-g-12" i18n="@@enterDealer" style="padding-top: 0;">Select the dealer who sold or supports your AG-NAV system.</div>
<div class="ui-g-8 ui-sm-12">
<ng-container [ngTemplateOutlet]="dropdown" [ngTemplateOutletContext]="{options: dealers, filter: true, formControlName: dealer, formGroup: formGroup}"></ng-container>
</div>
</div> </div>
</div> </div>
</ng-template> </ng-template>

View File

@ -15,6 +15,7 @@ import { catchError, switchMap, tap } from 'rxjs/operators';
import { UniqueUserValidator } from '@app/shared/user-unique.directive'; import { UniqueUserValidator } from '@app/shared/user-unique.directive';
import { CommonService } from '@app/domain/services/common.service'; import { CommonService } from '@app/domain/services/common.service';
import { PartnerService } from '@app/partners/services/partner.service'; import { PartnerService } from '@app/partners/services/partner.service';
import { DealerService } from '@app/tools/dealers/dealer.service';
import { of } from 'rxjs'; import { of } from 'rxjs';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { GAService } from '@app/shared/ga.service'; import { GAService } from '@app/shared/ga.service';
@ -58,6 +59,7 @@ export class SignupFormComponent extends BaseComp implements OnInit, OnDestroy,
readonly billing = 'billing'; readonly billing = 'billing';
readonly password = 'password'; readonly password = 'password';
readonly partner = 'partner'; readonly partner = 'partner';
readonly dealer = 'dealer';
@ViewChild("captchaElem") captchaElem: ReCaptcha2Component; @ViewChild("captchaElem") captchaElem: ReCaptcha2Component;
@ViewChild("captchaContainer", { static: false }) captchaContainer: ElementRef; @ViewChild("captchaContainer", { static: false }) captchaContainer: ElementRef;
@ -93,6 +95,7 @@ export class SignupFormComponent extends BaseComp implements OnInit, OnDestroy,
{ label: $localize`:@@3rdParty:3rd Party`, value: '3rd_party' } { label: $localize`:@@3rdParty:3rd Party`, value: '3rd_party' }
]; ];
partners: SelectItem[] = []; partners: SelectItem[] = [];
dealers: SelectItem[] = [];
filteredPlaces: BoundLocation[]; filteredPlaces: BoundLocation[];
error: { code?: string, message: string } | null = null; error: { code?: string, message: string } | null = null;
@ -182,6 +185,7 @@ export class SignupFormComponent extends BaseComp implements OnInit, OnDestroy,
private readonly uniqueUserValidator: UniqueUserValidator, private readonly uniqueUserValidator: UniqueUserValidator,
private readonly commonSvc: CommonService, private readonly commonSvc: CommonService,
private readonly partnerSvc: PartnerService, private readonly partnerSvc: PartnerService,
private readonly dealerSvc: DealerService,
private readonly route: ActivatedRoute, private readonly route: ActivatedRoute,
private readonly gaService: GAService private readonly gaService: GAService
) { ) {
@ -224,6 +228,7 @@ export class SignupFormComponent extends BaseComp implements OnInit, OnDestroy,
refSources: this.fb.array([]) refSources: this.fb.array([])
}), }),
[this.partner]: [''], [this.partner]: [''],
[this.dealer]: [''],
recaptcha: ['', Validators.required], recaptcha: ['', Validators.required],
lang: [this.authSvc.locale, Validators.required], lang: [this.authSvc.locale, Validators.required],
[this.password]: ['', [Validators.required, Validators.minLength(8)]], [this.password]: ['', [Validators.required, Validators.minLength(8)]],
@ -258,6 +263,19 @@ export class SignupFormComponent extends BaseComp implements OnInit, OnDestroy,
this.lang = this.authSvc.locale; this.lang = this.authSvc.locale;
this.setupFormSubscriptions(); this.setupFormSubscriptions();
}), }),
switchMap(() => this.dealerSvc.getAll()),
tap((dealers: any[]) => {
this.dealers = [
{ label: $localize`:@@none:None`, value: '' },
...dealers
.sort((a, b) => a.companyName.localeCompare(b.companyName))
.map(d => ({
label: d.country ? `${d.companyName} (${d.country})` : d.companyName,
value: d._id
}))
];
this.signupForm.patchValue({ [this.dealer]: '' });
}),
catchError(err => { catchError(err => {
console.error('Error during signup loading:', err); console.error('Error during signup loading:', err);
this.error = handleSignupErr({ error: err, opt: { tag: signupCode.signupLoadingError } }); this.error = handleSignupErr({ error: err, opt: { tag: signupCode.signupLoadingError } });

View File

@ -0,0 +1,43 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
export interface Dealer {
_id?: string;
companyName: string;
country: string;
contactName?: string;
address?: string;
phone?: string;
cell?: string;
fax?: string;
email?: string;
website?: string;
isCertifiedRepair?: boolean;
notes?: string;
createdAt?: string;
updatedAt?: string;
}
@Injectable({ providedIn: 'root' })
export class DealerService {
private readonly base = '/dealers';
constructor(private readonly http: HttpClient) {}
getAll(): Observable<Dealer[]> {
return this.http.get<Dealer[]>(this.base);
}
create(dealer: Dealer): Observable<Dealer> {
return this.http.post<Dealer>(this.base, dealer);
}
update(id: string, dealer: Dealer): Observable<Dealer> {
return this.http.put<Dealer>(`${this.base}/${id}`, dealer);
}
delete(id: string): Observable<{ ok: boolean }> {
return this.http.delete<{ ok: boolean }>(`${this.base}/${id}`);
}
}

View File

@ -0,0 +1,23 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { AuthGuard } from '../../domain/guards/auth.guard';
import { SettingsGuard } from '../../domain/guards/settings-guard.service';
import { RoleIds } from '../../shared/global';
import { DealersComponent } from './dealers.component';
const routes: Routes = [
{
path: '',
component: DealersComponent,
data: { roles: [RoleIds.ADMIN] },
canActivate: [AuthGuard, SettingsGuard]
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
providers: [AuthGuard]
})
export class DealersRoutingModule { }

View File

@ -0,0 +1,12 @@
.dealer-label {
display: block;
font-weight: 600;
font-size: 0.82rem;
color: #555;
margin-bottom: 0.25rem;
margin-top: 0.625rem;
}
.dealer-required {
color: #f44336;
}

View File

@ -0,0 +1,176 @@
<div class="ui-g">
<div class="ui-g-12">
<div class="card card-w-title">
<p-table #dt [value]="dealers" [columns]="cols" selectionMode="single"
[(selection)]="selectedDealer" dataKey="_id"
[paginator]="true" [rows]="15" [rowsPerPageOptions]="[15, 30, 50]"
[alwaysShowPaginator]="true" [responsive]="true"
[loading]="loading" stateStorage="session" stateKey="dealers-tbl">
<ng-template pTemplate="caption">
<span class="table-caption-1" style="display:block; text-align:center;">Dealers</span>
</ng-template>
<ng-template pTemplate="header" let-columns>
<tr>
<th *ngFor="let col of columns" [pSortableColumn]="col.field" [style.width]="col.width">
{{ col.header }}
<p-sortIcon [field]="col.field"></p-sortIcon>
</th>
</tr>
<tr>
<th *ngFor="let col of columns" class="ui-fluid">
<ng-container *ngIf="col.filtered">
<div class="input-with-icon" *ngIf="col.filterType === 'text'">
<i class="ui-icon-search"></i>
<input pInputText type="text"
(input)="dt.filter($event.target.value, col.field, 'contains')"
[value]="dt.filters[col.field]?.value || ''">
</div>
<p-dropdown *ngIf="col.filterType === 'dropdown'"
[options]="col.field === 'country' ? countryOptions : certifiedOptions"
(onChange)="dt.filter($event.value, col.field, 'equals')"
placeholder="All"
[style]="{'width':'100%'}"
appendTo="body">
</p-dropdown>
</ng-container>
</th>
</tr>
</ng-template>
<ng-template pTemplate="body" let-row>
<tr [pSelectableRow]="row">
<td>
<span class="ui-column-title">Company</span>
{{ row.companyName }}
</td>
<td>
<span class="ui-column-title">Country</span>
{{ row.country }}
</td>
<td>
<span class="ui-column-title">Contact</span>
{{ row.contactName }}
</td>
<td>
<span class="ui-column-title">Phone</span>
{{ row.phone }}
</td>
<td>
<span class="ui-column-title">Email</span>
<a *ngIf="row.email" [href]="'mailto:' + row.email" style="color:inherit;">{{ row.email }}</a>
</td>
<td class="table-col-center">
<span class="ui-column-title">Certified</span>
<p-checkbox [ngModel]="row.isCertifiedRepair" [binary]="true" [disabled]="true"></p-checkbox>
</td>
<td>
<span class="ui-column-title">Website</span>
<a *ngIf="row.website" [href]="row.website" target="_blank" rel="noopener noreferrer">
<i class="material-icons" style="font-size:1rem; vertical-align:middle;">open_in_new</i>
</a>
</td>
</tr>
</ng-template>
<ng-template pTemplate="emptymessage">
<tr>
<td colspan="7" style="text-align:center; color:#757575; padding:1.5rem;">
<i class="material-icons" style="vertical-align:middle; margin-right:0.25rem;">store</i>
No dealers found.
</td>
</tr>
</ng-template>
<ng-template pTemplate="paginatorleft" let-state>
{{ state.totalRecords | i18nPlural: totalItems }}
</ng-template>
</p-table>
<div class="ui-widget-header ui-helper-clearfix toolbar">
<button pButton type="button" icon="ui-icon-plus" label="New" (click)="openNew()"></button>
<button pButton type="button" icon="ui-icon-edit" label="Edit"
[disabled]="!selectedDealer" (click)="openEdit()"></button>
<button pButton type="button" icon="ui-icon-delete" label="Delete"
[disabled]="!selectedDealer" (click)="deleteDealer()"></button>
<button pButton type="button" icon="ui-icon-refresh" label="Refresh"
class="blue-btn" (click)="load()"></button>
</div>
</div>
</div>
</div>
<!-- Create / Edit Dialog -->
<p-dialog
[header]="isEditing ? 'Edit Dealer' : 'New Dealer'"
[(visible)]="showDialog"
[modal]="true"
[style]="{width:'560px'}"
[closable]="true"
[draggable]="false">
<div class="ui-g ui-g-fluid">
<div class="ui-g-12 ui-md-8">
<label class="dealer-label">Company Name <span class="dealer-required">*</span></label>
<input pInputText type="text" [(ngModel)]="form.companyName" style="width:100%;" placeholder="e.g. Aerotec">
</div>
<div class="ui-g-12 ui-md-4">
<label class="dealer-label">Country <span class="dealer-required">*</span></label>
<input pInputText type="text" [(ngModel)]="form.country" style="width:100%;" placeholder="e.g. Argentina">
</div>
<div class="ui-g-12 ui-md-6">
<label class="dealer-label">Contact Name</label>
<input pInputText type="text" [(ngModel)]="form.contactName" style="width:100%;" placeholder="Full name">
</div>
<div class="ui-g-12 ui-md-6">
<label class="dealer-label">Email</label>
<input pInputText type="email" [(ngModel)]="form.email" style="width:100%;" placeholder="contact@example.com">
</div>
<div class="ui-g-12">
<label class="dealer-label">Address</label>
<input pInputText type="text" [(ngModel)]="form.address" style="width:100%;" placeholder="Street, city, postal code">
</div>
<div class="ui-g-12 ui-md-4">
<label class="dealer-label">Phone</label>
<input pInputText type="text" [(ngModel)]="form.phone" style="width:100%;" placeholder="+1 555 000 0000">
</div>
<div class="ui-g-12 ui-md-4">
<label class="dealer-label">Cell</label>
<input pInputText type="text" [(ngModel)]="form.cell" style="width:100%;">
</div>
<div class="ui-g-12 ui-md-4">
<label class="dealer-label">Fax</label>
<input pInputText type="text" [(ngModel)]="form.fax" style="width:100%;">
</div>
<div class="ui-g-12 ui-md-9">
<label class="dealer-label">Website</label>
<input pInputText type="url" [(ngModel)]="form.website" style="width:100%;" placeholder="https://...">
</div>
<div class="ui-g-12 ui-md-3" style="display:flex; align-items:flex-end; padding-bottom:0.25rem;">
<p-checkbox [(ngModel)]="form.isCertifiedRepair" [binary]="true"
label="Certified Repair"></p-checkbox>
</div>
<div class="ui-g-12">
<label class="dealer-label">Notes</label>
<textarea pInputTextarea [(ngModel)]="form.notes" style="width:100%;" rows="2"
placeholder="Additional information…"></textarea>
</div>
</div>
<p-footer>
<button pButton type="button" icon="ui-icon-close" label="Cancel"
(click)="showDialog = false" [disabled]="saving"></button>
<button pButton type="button" icon="ui-icon-save" label="Save" class="green-btn"
[disabled]="!form.companyName?.trim() || !form.country?.trim() || saving"
(click)="saveDealer()"></button>
</p-footer>
</p-dialog>

View File

@ -0,0 +1,131 @@
import { Component, OnInit } from '@angular/core';
import { BaseComp } from '@app/shared/base/base.component';
import { Dealer, DealerService } from './dealer.service';
@Component({
selector: 'agm-dealers',
templateUrl: './dealers.component.html',
styleUrls: ['./dealers.component.css']
})
export class DealersComponent extends BaseComp implements OnInit {
dealers: Dealer[] = [];
selectedDealer: Dealer | null = null;
loading = false;
showDialog = false;
isEditing = false;
saving = false;
form: Dealer = this.emptyForm();
cols = [
{ field: 'companyName', header: 'Company', width: '22%', filtered: true, filterType: 'text' },
{ field: 'country', header: 'Country', width: '14%', filtered: true, filterType: 'dropdown' },
{ field: 'contactName', header: 'Contact', width: '16%', filtered: true, filterType: 'text' },
{ field: 'phone', header: 'Phone', width: '13%', filtered: true, filterType: 'text' },
{ field: 'email', header: 'Email', width: '16%', filtered: true, filterType: 'text' },
{ field: 'isCertifiedRepair', header: 'Certified', width: '7%', filtered: true, filterType: 'dropdown' },
{ field: 'website', header: 'Website', width: '12%', filtered: false },
];
countryOptions: { label: string; value: string | null }[] = [];
certifiedOptions = [
{ label: 'All', value: null },
{ label: 'Yes', value: true },
{ label: 'No', value: false },
];
totalItems = { '=0': 'No dealers', '=1': '1 dealer', 'other': '# dealers' };
constructor(private readonly dealerSvc: DealerService) {
super();
}
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.dealerSvc.getAll().subscribe({
next: (data) => {
this.dealers = data;
this.countryOptions = [
{ label: 'All', value: null },
...Array.from(new Set(data.map(d => d.country))).sort()
.map(c => ({ label: c, value: c }))
];
this.loading = false;
},
error: (err) => {
this.msgSvc.addFailedMsg('Failed to load dealers: ' + (err?.error?.error?.message || err.message));
this.loading = false;
}
});
}
openNew(): void {
this.form = this.emptyForm();
this.isEditing = false;
this.showDialog = true;
}
openEdit(): void {
if (!this.selectedDealer) { return; }
this.form = { ...this.selectedDealer };
this.isEditing = true;
this.showDialog = true;
}
saveDealer(): void {
if (!this.form.companyName?.trim() || !this.form.country?.trim()) { return; }
this.saving = true;
const action$ = this.isEditing
? this.dealerSvc.update(this.form._id, this.form)
: this.dealerSvc.create(this.form);
action$.subscribe({
next: () => {
this.msgSvc.addSuccessMsg(this.isEditing ? 'Dealer updated.' : 'Dealer created.');
this.showDialog = false;
this.selectedDealer = null;
this.saving = false;
this.load();
},
error: (err) => {
this.msgSvc.addFailedMsg('Save failed: ' + (err?.error?.error?.message || err.message));
this.saving = false;
}
});
}
deleteDealer(): void {
if (!this.selectedDealer) { return; }
this.confirmSvc.confirm({
message: `Delete dealer "${this.selectedDealer.companyName}"?`,
header: 'Confirm Delete',
icon: 'pi pi-exclamation-triangle',
accept: () => {
this.dealerSvc.delete(this.selectedDealer._id).subscribe({
next: () => {
this.msgSvc.addSuccessMsg('Dealer deleted.');
this.selectedDealer = null;
this.load();
},
error: (err) => this.msgSvc.addFailedMsg('Delete failed: ' + (err?.error?.error?.message || err.message))
});
}
});
}
private emptyForm(): Dealer {
return {
companyName: '', country: '', contactName: '', address: '',
phone: '', cell: '', fax: '', email: '', website: '',
isCertifiedRepair: false, notes: ''
};
}
}

View File

@ -0,0 +1,23 @@
import { NgModule } from '@angular/core';
import { DialogModule } from 'primeng-lts/dialog';
import { ConfirmDialogModule } from 'primeng-lts/confirmdialog';
import { TableModule } from 'primeng-lts/table';
import { CheckboxModule } from 'primeng-lts/checkbox';
import { AppSharedModule } from '../../shared/app-shared.module';
import { DealersRoutingModule } from './dealers-routing.module';
import { DealersComponent } from './dealers.component';
@NgModule({
imports: [
AppSharedModule,
DialogModule,
ConfirmDialogModule,
TableModule,
CheckboxModule,
DealersRoutingModule
],
declarations: [DealersComponent]
})
export class DealersModule { }

View File

@ -112,7 +112,8 @@ async function getCustomer_get(req, res) {
const view = req.query.view; const view = req.query.view;
let query = Customer.findOne({ _id: ObjectId(cId) }, null, { lean: true }) let query = Customer.findOne({ _id: ObjectId(cId) }, null, { lean: true })
.populate({ path: 'Country', select: 'code name -_id' }) .populate({ path: 'Country', select: 'code name -_id' })
.populate({ path: 'partner', select: 'name description' }); .populate({ path: 'partner', select: 'name description' })
.populate({ path: 'dealer', select: 'companyName country contactName phone email' });
if (view !== 'edit') { if (view !== 'edit') {
query = query.select('-password'); query = query.select('-password');

View File

@ -0,0 +1,46 @@
'use strict';
const Dealer = require('../model/dealer'),
{ AppParamError } = require('../helpers/app_error'),
assert = require('assert');
async function getDealers_get(req, res) {
const dealers = await Dealer.find().sort({ country: 1, companyName: 1 }).lean();
res.json(dealers);
}
async function createDealer_post(req, res) {
const body = req.body;
assert(body && body.companyName && body.country, AppParamError.create());
delete body._id;
const dealer = new Dealer(body);
const saved = await dealer.save();
res.json(saved);
}
async function updateDealer_put(req, res) {
const { id } = req.params;
const body = req.body;
assert(id, AppParamError.create());
const updated = await Dealer.findByIdAndUpdate(id, body, { new: true, runValidators: true });
if (!updated) AppParamError.throw();
res.json(updated);
}
async function deleteDealer_delete(req, res) {
const { id } = req.params;
assert(id, AppParamError.create());
const deleted = await Dealer.findByIdAndDelete(id);
if (!deleted) AppParamError.throw();
res.json({ ok: true });
}
module.exports = {
getDealers_get,
createDealer_post,
updateDealer_put,
deleteDealer_delete
};

View File

@ -487,6 +487,9 @@ async function signup_post(req, res) {
if (typeof input.partner === 'string' && input.partner?.trim().length !== 0) { if (typeof input.partner === 'string' && input.partner?.trim().length !== 0) {
customerData.partner = input.partner.trim(); customerData.partner = input.partner.trim();
} }
if (typeof input.dealer === 'string' && input.dealer?.trim().length !== 0) {
customerData.dealer = input.dealer.trim();
}
const newCustomer = new CustomerModel(customerData); const newCustomer = new CustomerModel(customerData);
const emailData = { const emailData = {

View File

@ -27,6 +27,7 @@ function isSecuredRoute(routePath, method) {
{ path: '/resetPassword', method: 'ALL' }, { path: '/resetPassword', method: 'ALL' },
{ path: '/signup', method: 'ALL' }, { path: '/signup', method: 'ALL' },
{ path: '/api/partners', method: 'GET', exact: true }, // Allow unauthenticated GET /api/partners only (not subroutes) { path: '/api/partners', method: 'GET', exact: true }, // Allow unauthenticated GET /api/partners only (not subroutes)
{ path: '/api/dealers', method: 'GET', exact: true }, // Allow unauthenticated GET /api/dealers only (used on signup page)
{ path: '/exists', method: 'POST' }, { path: '/exists', method: 'POST' },
{ path: '/countries', method: 'GET' }, { path: '/countries', method: 'GET' },
{ path: '/testAuth', method: 'ALL' }, { path: '/testAuth', method: 'ALL' },

View File

@ -0,0 +1,32 @@
'use strict';
const mongoose = require('mongoose'),
Schema = mongoose.Schema;
const dealerSchema = new Schema({
companyName: { type: String, required: true, trim: true },
country: { type: String, required: true, trim: true },
contactName: { type: String, trim: true, default: '' },
address: { type: String, trim: true, default: '' },
phone: { type: String, trim: true, default: '' },
cell: { type: String, trim: true, default: '' },
fax: { type: String, trim: true, default: '' },
email: { type: String, trim: true, lowercase: true, default: '' },
website: { type: String, trim: true, default: '' },
isCertifiedRepair: { type: Boolean, default: false },
notes: { type: String, trim: true, default: '' },
createdAt: { type: Date, default: Date.now },
updatedAt: { type: Date, default: Date.now }
}, { strictQuery: false });
dealerSchema.pre('save', function (next) {
this.updatedAt = new Date();
next();
});
dealerSchema.pre('findOneAndUpdate', function (next) {
this.set({ updatedAt: new Date() });
next();
});
module.exports = mongoose.model('Dealer', dealerSchema);

View File

@ -46,6 +46,9 @@ const schema = new Schema({
// Reference to the Partner collection (optional) - used for customers with partner integrations // Reference to the Partner collection (optional) - used for customers with partner integrations
partner: { type: Schema.Types.ObjectId, ref: 'User', required: false }, partner: { type: Schema.Types.ObjectId, ref: 'User', required: false },
// Reference to the Dealer collection (optional) - the dealer that sold/supports this customer
dealer: { type: Schema.Types.ObjectId, ref: 'Dealer', required: false },
loggedInAt: { type: Date }, loggedInAt: { type: Date },
markedDelete: { type: Boolean, default: false }, markedDelete: { type: Boolean, default: false },

View File

@ -0,0 +1,14 @@
'use strict';
module.exports = function (app) {
const router = require('express').Router(),
{ authAllowAdmin } = require('../middlewares/validate'),
dealerCtl = require('../controllers/dealer');
router.get('/', dealerCtl.getDealers_get);
router.post('/', authAllowAdmin(), dealerCtl.createDealer_post);
router.put('/:id', authAllowAdmin(), dealerCtl.updateDealer_put);
router.delete('/:id', authAllowAdmin(), dealerCtl.deleteDealer_delete);
app.use('/api/dealers', router);
};

View File

@ -29,6 +29,7 @@ module.exports = function (app) {
require('./costing_items')(app); require('./costing_items')(app);
require('./log_payment')(app); require('./log_payment')(app);
require('./partner')(app); require('./partner')(app);
require('./dealer')(app);
require('./health')(app); require('./health')(app);
// Data Export public API (X-API-Key auth) and key management (JWT auth) // Data Export public API (X-API-Key auth) and key management (JWT auth)
require('./api_pub')(app); require('./api_pub')(app);

View File

@ -53,6 +53,7 @@ module.exports = function (app) {
taxId: Joi.string().allow('').optional(), taxId: Joi.string().allow('').optional(),
lang: Joi.string().default(DEFAULT_LANG).optional(), lang: Joi.string().default(DEFAULT_LANG).optional(),
partner: Joi.objectId().allow('').allow(null).optional(), partner: Joi.objectId().allow('').allow(null).optional(),
dealer: Joi.objectId().allow('').allow(null).optional(),
emailToken: Joi.string().optional(), emailToken: Joi.string().optional(),
token: Joi.string().optional(), token: Joi.string().optional(),
}) })

View File

@ -0,0 +1,303 @@
'use strict';
/**
* Dealer Seed Script
*
* Inserts the initial AG-NAV world-wide dealer network into the database.
* Safe to re-run: upserts on (companyName + country) so no duplicates are created.
*
* Usage (from repo root):
* set -a && source server/environment.env && set +a && DEBUG=agm:seed-dealers node server/scripts/seedDealers.js
*
* Dry-run (no writes):
* set -a && source server/environment.env && set +a && DEBUG=agm:seed-dealers node server/scripts/seedDealers.js --dry-run
*/
const debug = require('debug')('agm:seed-dealers');
const { DBConnection } = require('../helpers/db/connect.js');
const Dealer = require('../model/dealer.js');
const args = process.argv.slice(2);
const isDryRun = args.includes('--dry-run');
if (isDryRun) {
debug('Running in DRY-RUN mode — no changes will be made');
}
const DEALERS = [
{
companyName: 'Aerotec',
country: 'Argentina',
contactName: 'Diego M. Cardama Mendoza',
address: 'Aerodromo Mario Cardama (5577) Comandante Torres 100 Rivadavia Buenos Aires; Aerodromo Aeroclub Lujan Beschtedt S/N Hangar 1',
phone: '+54 (263) 444 3212',
cell: '+54 (9) 261 569 2744',
email: 'diego@aerotec-argentina.com.ar',
website: 'http://aerotec.com.ar/',
isCertifiedRepair: false,
},
{
companyName: 'APAC Heli Solutions',
country: 'Australia',
contactName: 'Scott Simpson',
address: 'Kurrajong NSW 2758, Australia',
phone: '+61 (0) 418 484 515',
email: 'ssimpson@apachelisolutions.com',
website: 'https://www.apachelisolutions.com/',
isCertifiedRepair: false,
},
{
companyName: 'Trabajo Aereo Agricola T.A.A',
country: 'Bolivia',
contactName: 'Verly Valdez Ruiz',
address: 'Lagunillas # 301 esq. Villamontes, Braniff Santa Cruz, Bolivia',
phone: '591 3 352 6578',
cell: '591 716 48864',
email: 'verlyvaldez@hotmail.com',
isCertifiedRepair: false,
},
{
companyName: 'DGPS & CIA',
country: 'Brazil',
contactName: 'Miguel Paim',
address: 'Rua dos Hangares No. 453 Bairro: Parque Industrial Aeroporto, Primavera do Leste MT, CEP: 78850-000',
phone: '+55 (66) 3497-3400',
cell: '+55 (66) 9986-1198',
email: 'miguelpaim@dgpsecia.com.br',
website: 'http://www.dgpsecia.com.br',
isCertifiedRepair: true,
},
{
companyName: 'Dinnarc Tecnologia Agricola',
country: 'Brazil',
contactName: 'Augusto e Ramon',
address: 'Avenida Adolino Bedin Nº 875, CEP: 78.894-132 Jardim das Américas, Sorriso, MT, Brazil',
phone: '+55 (66) 99981-8300',
cell: '+55 (66) 99200-7447',
email: 'contato@dinnarc.com.br',
isCertifiedRepair: false,
},
{
companyName: 'ABA Manutencao de Aeronaves',
country: 'Brazil',
contactName: 'Ruddiger Alves Da Silva',
address: 'Rua da Prainha, 3320 Cond. Sitio de Voo ABA Lotes 05 e 06, Barreirinhas, Barreiras',
phone: '+55 (66) 99987-0727',
email: 'ruddigger@abamanutencao.com.br',
isCertifiedRepair: false,
},
{
companyName: 'Galindo e Galindo Comercio e Servicos Eletronicos Ltda',
country: 'Brazil',
contactName: 'Francisco Galindo',
address: 'Rua D, 20 - Balneario Recreativa de Campo, CEP: 14.073-808, Ribeirao Preto - SP',
phone: '+55 (16) 3629-3317',
cell: '+55 (16) 99137-1517',
email: 'fgalindo@galindodgps.com.br',
website: 'http://www.galindodgps.com.br',
isCertifiedRepair: false,
},
{
companyName: 'Aeroglobo Aeronaves',
country: 'Brazil',
address: 'Rua José Dal Farra, 654, Jardim Dona Carolina, Botucatu São Paulo, 18.602.020',
phone: '+55 14 3814-3450',
email: 'contato@aeroglobo.com.br',
website: 'https://www.aeroglobo.com.br',
isCertifiedRepair: false,
},
{
companyName: 'Provincial Airways',
country: 'Canada',
contactName: 'James',
address: 'Box 2170, Hwy 301N, Moose Jaw, SK, S6H 7T2',
phone: '306 692 7335',
fax: '306 693 5288',
cell: '306 693 0877',
email: 'james@provincialairways.net',
website: 'http://www.provincialairways.net',
isCertifiedRepair: false,
},
{
companyName: 'Carlos Ilabaca Vacarezza',
country: 'Chile',
contactName: 'Carlos Ilabaca Vacarezza',
address: 'La Serena, Chile',
phone: '56 097 849 0244',
email: 'cpilabaca@gmail.com',
isCertifiedRepair: true,
},
{
companyName: 'Jarly Camacho Torres',
country: 'Colombia',
contactName: 'Jarly Camacho Torres',
address: 'Calle 6, casa # 12-52 Barrio Pescadito, Santa Marta, Colombia',
phone: '(57) 431-6187',
cell: '(57) 321-525-6367',
fax: '(57) 317-525-4385',
email: 'jcamachot87@yahoo.es',
isCertifiedRepair: true,
},
{
companyName: 'Mario Berrones Corp',
country: 'Ecuador',
contactName: 'Mario Berrones',
address: 'Eloy Alfaro 126 y Tarqui, Canton Yaguachi, Guayas, Ecuador',
phone: '011-593-93910438',
email: 'mabescorp@hotmail.com',
isCertifiedRepair: true,
},
{
companyName: 'Aero Agricola Paraguaya SA',
country: 'Paraguay',
address: 'Ruta 1 KM 286, Aeropuerto Aero Agricola Paraguaya, General Delgado, Itapua 6860, Paraguay',
phone: '+595 985-220286',
email: 'administracion@aeroagricolaparaguaya.com.py',
isCertifiedRepair: false,
},
{
companyName: 'Davao Aerowurkz Corporation',
country: 'Philippines',
address: 'BTC Hangar, Old Airport, Sasa, Davao City, 8000, Philippines',
phone: '+63 (082) 234-8843',
email: 'aerowurks_aviation@yahoo.com.ph',
isCertifiedRepair: false,
},
{
companyName: 'Business Development Services',
country: 'Poland',
contactName: 'Lukasz Kempys',
address: 'UL. USTRONIE 31, NOWY TARG, 34-400, Poland',
phone: '+48-694-473-616',
email: 'lukaszkempys@gmail.com',
website: 'http://www.SkyFun.pl',
isCertifiedRepair: true,
},
{
companyName: 'Steve Viviers Aviation',
country: 'South Africa',
contactName: 'Steve Viviers',
address: 'P.O. Box 1952, Kroonstad, 9500, South Africa',
phone: '+27-836378504',
fax: '+27-56-212-3436',
cell: '+27-82-800-1508',
email: 'viviersaviation@act.co.za',
isCertifiedRepair: true,
},
{
companyName: 'Lane Aviation',
country: 'United States',
contactName: 'Dona Jorden',
address: '3205 FM 2218 Rd, Rosenberg, TX 77471, USA',
phone: '(281) 342-5451',
fax: '(281) 232-5401',
email: 'dona@laneav.com',
isCertifiedRepair: false,
},
{
companyName: 'Frost Flying Inc.',
country: 'United States',
contactName: 'Garret Frost',
address: '3393 Hwy. 121 West, Marianna, AR 72360, USA',
phone: '(870) 295-6218',
fax: '(870) 295-6237',
email: 'frostparts@hotmail.com',
isCertifiedRepair: false,
},
{
companyName: 'Crosslands International, LLC',
country: 'United States',
contactName: 'John M. Mishler',
address: '17921 S US Hwy 377, Cresson, TX 76035, USA',
phone: '(817) 478-9933',
email: 'john@crosslandsinternational.com',
isCertifiedRepair: false,
},
{
companyName: 'Summit Helicopters, Inc.',
country: 'United States',
contactName: 'Jeff Partain',
address: 'Box 909, 525 McCelland Street, Salem, VA 24153, USA',
phone: '(540) 375-8909',
email: 'jeff.partain@summithelicopters.com',
isCertifiedRepair: false,
},
{
companyName: 'Thomas Helicopters',
country: 'United States',
contactName: 'Rod Thomas',
address: '1553 South 1800 East, Gooding, ID 83330, USA',
phone: '(208) 934-8298',
fax: '(208) 934-5934',
email: 'rodheli@aol.com',
isCertifiedRepair: false,
},
{
companyName: 'Collective Aviation',
country: 'United States',
contactName: 'Kristopher Petter',
address: '2874 Henry Wallace Rd., Orient, Iowa 50858, USA',
phone: '(206) 484-8749',
email: 'kris@collectiveaviationservices.com',
isCertifiedRepair: false,
},
{
companyName: 'Servicio Aeroagricola De Flores S.R.L.',
country: 'Uruguay',
contactName: 'Julio Placeres / Juan Perez',
address: 'Manuel Irazabal 530, Trinidad, Flores, Uruguay',
phone: '+598 4364-4686',
fax: '+598 4364-4686',
email: 'juliopla@adinet.com.uy',
isCertifiedRepair: true,
},
];
async function seedDealers() {
debug(`Seeding ${DEALERS.length} dealers (dry-run: ${isDryRun})`);
let inserted = 0, skipped = 0;
for (const dealer of DEALERS) {
const filter = { companyName: dealer.companyName, country: dealer.country };
if (isDryRun) {
const existing = await Dealer.findOne(filter).lean();
if (existing) {
debug(`[DRY-RUN] Would skip — ${dealer.companyName} (${dealer.country}) already exists`);
skipped++;
} else {
debug(`[DRY-RUN] Would insert — ${dealer.companyName} (${dealer.country})`);
inserted++;
}
continue;
}
const existing = await Dealer.findOne(filter).lean();
if (existing) {
debug(`[skipped] ${dealer.companyName} (${dealer.country})`);
skipped++;
} else {
await Dealer.create(dealer);
debug(`[inserted] ${dealer.companyName} (${dealer.country})`);
inserted++;
}
}
debug(`Done. Inserted: ${inserted}, Skipped: ${skipped}`);
}
const workerDB = new DBConnection('Dealer Seed Script');
workerDB.initialize({
setupExitHandlers: false,
onReady: async () => {
try {
await seedDealers();
process.exit(0);
} catch (err) {
debug('Seed failed:', err);
process.exit(1);
}
}
});