Compare commits
22 Commits
master
...
feature/da
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ff07c9d74 | |||
| 83a0aa1109 | |||
| cdf6fb7b7e | |||
| 1e7977cdba | |||
| ad7db99f07 | |||
| ea46cbeb02 | |||
| a6d75af447 | |||
| 0a2b93a8c7 | |||
| 4dad304f86 | |||
| 365292dac1 | |||
| 7b63f9164d | |||
| b46c995b3d | |||
| f6700793e8 | |||
| 6fff4011ad | |||
| 9303274349 | |||
| df31b2080d | |||
| d99ffa9b40 | |||
| 35dad9bfff | |||
| fbfa44ba97 | |||
| 40e405ac57 | |||
| fbe71daa86 | |||
| 9ea0a43ae7 |
218
.gitea/workflows/run-tests.yaml
Normal file
218
.gitea/workflows/run-tests.yaml
Normal file
@ -0,0 +1,218 @@
|
||||
# Gitea Actions – Server Tests
|
||||
#
|
||||
# Two jobs run on every push to any branch:
|
||||
# 1. jest-integration – Jest integration tests using an in-memory MongoDB
|
||||
# instance (mongodb-memory-server). No external database
|
||||
# or repository secrets are required.
|
||||
# 2. mocha-unit – Existing Mocha/Chai unit tests in tests/ and tests/utils/
|
||||
#
|
||||
# Optional repository secret:
|
||||
# TOKEN_SECRET – JWT secret used by the server's auth helpers.
|
||||
# A safe default is used automatically when absent.
|
||||
|
||||
name: Server Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '**'
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
jobs:
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
# Job 1: Jest integration tests (in-memory MongoDB via mongodb-memory-server)
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
jest-integration:
|
||||
name: Jest – Integration Tests
|
||||
runs-on: self-hosted
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: Development/server
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: Development/server/package-lock.json
|
||||
|
||||
- name: Prepare MongoDB runtime compatibility libraries
|
||||
run: |
|
||||
set -e
|
||||
|
||||
if command -v ldconfig >/dev/null 2>&1 && ldconfig -p | grep -q 'libcrypto.so.1.1'; then
|
||||
echo "OpenSSL 1.1 compatibility already present"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
LIBSSL_DIR="${RUNNER_TEMP:-/tmp}/libssl11"
|
||||
DEB_PATH="${RUNNER_TEMP:-/tmp}/libssl1.1.deb"
|
||||
mkdir -p "$LIBSSL_DIR"
|
||||
|
||||
if [ ! -f "$DEB_PATH" ]; then
|
||||
curl -fsSL \
|
||||
-o "$DEB_PATH" \
|
||||
"http://security.ubuntu.com/ubuntu/pool/main/o/openssl/libssl1.1_1.1.1f-1ubuntu2.24_amd64.deb"
|
||||
fi
|
||||
|
||||
dpkg-deb -x "$DEB_PATH" "$LIBSSL_DIR"
|
||||
|
||||
LIB_DIR="$LIBSSL_DIR/usr/lib/x86_64-linux-gnu"
|
||||
if [ ! -f "$LIB_DIR/libcrypto.so.1.1" ]; then
|
||||
echo "libcrypto.so.1.1 was not found after extracting $DEB_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "LD_LIBRARY_PATH=$LIB_DIR${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" >> "$GITHUB_ENV"
|
||||
echo "Prepared OpenSSL 1.1 compatibility libraries in $LIB_DIR"
|
||||
|
||||
- name: Install dependencies
|
||||
env:
|
||||
NPM_CONFIG_CACHE: /tmp/npm-cache
|
||||
run: npm ci --prefer-offline
|
||||
|
||||
- name: Write test environment file
|
||||
run: |
|
||||
cat > environment.test.env <<'EOF'
|
||||
NODE_ENV=test
|
||||
TOKEN_SECRET=${{ secrets.TOKEN_SECRET || 'ci-test-secret-not-for-production' }}
|
||||
PRODUCTION=false
|
||||
NO_EMAIL_MODE=true
|
||||
ENABLE_SUBSCRIPTION=false
|
||||
INV_IMG_VIR_DIR=/tmp/inv-img
|
||||
INV_UPLOAD_DIR=/tmp/inv-upload
|
||||
INV_MAX_UPLOAD_SIZE_MB=5
|
||||
PAGINATION_DEFAULT_LIMIT=1000
|
||||
PAGINATION_MAX_LIMIT=14000
|
||||
STRIPE_SEC_KEY=sk_test_placeholder
|
||||
STRIPE_API_VERSION=2022-11-15
|
||||
EOF
|
||||
|
||||
- name: Enforce controller integration contract
|
||||
env:
|
||||
TEST_ENV_FILE: ./environment.test.env
|
||||
NODE_ENV: test
|
||||
MONGOMS_VERSION: 4.4.28
|
||||
run: npm run test:integration:contract
|
||||
|
||||
- name: Run Jest integration tests
|
||||
env:
|
||||
TEST_ENV_FILE: ./environment.test.env
|
||||
NODE_ENV: test
|
||||
MONGOMS_VERSION: 4.4.28
|
||||
run: |
|
||||
set -o pipefail
|
||||
npm run test:integration:jest -- --ci --coverage 2>&1 | tee jest-integration.log
|
||||
|
||||
- name: Upload Jest results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: jest-integration-results
|
||||
path: Development/server/jest-integration.log
|
||||
|
||||
- name: Upload Jest coverage
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: jest-integration-coverage
|
||||
path: Development/server/coverage/integration
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
# Job 2: Mocha/Chai tests – tests/ and tests/utils/
|
||||
# These tests are self-contained unit tests that do not require MongoDB.
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
mocha-unit:
|
||||
name: Mocha – Unit & Utility Tests
|
||||
runs-on: self-hosted
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: Development/server
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
|
||||
# Redirect the npm cache away from /root/.npm (root-owned on this runner)
|
||||
# to a writable temp directory. Also clears any stale node_modules left
|
||||
# by a previous run that the root-owned cache could not clean up.
|
||||
- name: Fix npm cache permissions
|
||||
run: |
|
||||
mkdir -p /tmp/npm-cache
|
||||
rm -rf node_modules || true
|
||||
|
||||
- name: Install dependencies
|
||||
env:
|
||||
NPM_CONFIG_CACHE: /tmp/npm-cache
|
||||
run: npm ci
|
||||
|
||||
# Write a minimal env file so dotenv does not error on startup.
|
||||
# These tests do not hit the database; DB_* values are placeholders.
|
||||
- name: Write test environment file
|
||||
run: |
|
||||
cat > environment.env <<'EOF'
|
||||
NODE_ENV=test
|
||||
DB_HOSTS=127.0.0.1:27017
|
||||
DB_NAME=agmission_test
|
||||
DB_USR=
|
||||
DB_PWD=
|
||||
TOKEN_SECRET=${{ secrets.TOKEN_SECRET || 'ci-test-secret-not-for-production' }}
|
||||
PRODUCTION=false
|
||||
NO_EMAIL_MODE=true
|
||||
ENABLE_SUBSCRIPTION=false
|
||||
STRIPE_SEC_KEY=sk_test_placeholder
|
||||
STRIPE_API_VERSION=2022-11-15
|
||||
EOF
|
||||
|
||||
# Run all top-level test_*.js files (skips integration/ and satloc/ sub-dirs
|
||||
# which are covered by the jest-integration job or need live connections).
|
||||
# Coverage is aggregated across this run and tests/utils/.
|
||||
- name: Run Mocha unit tests with coverage
|
||||
run: |
|
||||
set -o pipefail
|
||||
rm -rf .nyc_output coverage/mocha mocha-unit.log
|
||||
|
||||
npx nyc \
|
||||
--silent \
|
||||
--temp-dir .nyc_output \
|
||||
npx mocha --exit --timeout 120000 \
|
||||
--require tests/setup.js \
|
||||
'tests/test_*.js' 2>&1 | tee mocha-unit.log
|
||||
|
||||
npx nyc \
|
||||
--silent \
|
||||
--temp-dir .nyc_output \
|
||||
--no-clean \
|
||||
npm run test:utils 2>&1 | tee -a mocha-unit.log
|
||||
|
||||
npx nyc report \
|
||||
--temp-dir .nyc_output \
|
||||
--report-dir coverage/mocha \
|
||||
--reporter=text \
|
||||
--reporter=lcov \
|
||||
--reporter=json-summary | tee -a mocha-unit.log
|
||||
|
||||
- name: Upload Mocha results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: mocha-unit-results
|
||||
path: Development/server/mocha-unit.log
|
||||
|
||||
- name: Upload Mocha coverage
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: mocha-unit-coverage
|
||||
path: Development/server/coverage/mocha
|
||||
@ -52,6 +52,16 @@
|
||||
"glob": "**/*",
|
||||
"input": "node_modules/leaflet/dist/images",
|
||||
"output": "/assets/images"
|
||||
},
|
||||
{
|
||||
"glob": "CHANGELOG.md",
|
||||
"input": "docs",
|
||||
"output": "/assets/docs"
|
||||
},
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "docs/releases",
|
||||
"output": "/assets/docs/releases"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
|
||||
149
Development/client/docs/BROWSER_CACHE_SERVICE.md
Normal file
149
Development/client/docs/BROWSER_CACHE_SERVICE.md
Normal file
@ -0,0 +1,149 @@
|
||||
# BrowserCacheService
|
||||
|
||||
**File**: `src/app/domain/services/browser-cache.service.ts`
|
||||
|
||||
A generic, injectable Angular service that provides a typed read/write/invalidate API over the browser's [Cache Storage API](https://developer.mozilla.org/en-US/docs/Web/API/CacheStorage). Intended as a shared foundation for any feature that wants to cache HTTP responses across navigation events without a Service Worker.
|
||||
|
||||
---
|
||||
|
||||
## Why Cache Storage?
|
||||
|
||||
| Mechanism | Survives navigation | Survives page reload | Configurable TTL | Storage limit |
|
||||
|---|---|---|---|---|
|
||||
| Component state | ✗ | ✗ | — | Memory |
|
||||
| NgRx store | ✓ (same tab) | ✗ | — | Memory |
|
||||
| `sessionStorage` | ✓ | ✗ | Manual | ~5 MB |
|
||||
| `localStorage` | ✓ | ✓ | Manual | ~5 MB |
|
||||
| **Cache Storage** | ✓ | ✓ | ✓ (per entry) | Quota-managed |
|
||||
|
||||
Cache Storage was chosen because it:
|
||||
- Is available in all modern browsers (Chrome 40+, Firefox 44+, Safari 11.1+)
|
||||
- Stores structured data alongside an expiry timestamp without size pressure
|
||||
- Is already used by Service Workers and the browser's native HTTP cache, so quota management is handled by the browser
|
||||
- Falls back gracefully (service becomes a no-op) when unavailable
|
||||
|
||||
---
|
||||
|
||||
## API
|
||||
|
||||
```typescript
|
||||
@Injectable({ providedIn: 'root' })
|
||||
class BrowserCacheService {
|
||||
|
||||
get<T>(cacheName: string, key: string, maxAgeMs?: number): Observable<T | null>
|
||||
|
||||
put<T>(cacheName: string, key: string, data: T): void
|
||||
|
||||
invalidate(cacheName: string): void
|
||||
}
|
||||
```
|
||||
|
||||
### `get<T>(cacheName, key, maxAgeMs?)`
|
||||
|
||||
Returns an `Observable` that emits the cached value (`T`) or `null` when:
|
||||
|
||||
- The Cache Storage API is unavailable (e.g. older browser, unit test environment)
|
||||
- No entry exists for the given `cacheName` + `key` combination
|
||||
- The entry is older than `maxAgeMs` (default: `60 000` ms / 1 minute)
|
||||
|
||||
Errors from the Cache API are caught and converted to `null` — they never propagate to the caller.
|
||||
|
||||
### `put<T>(cacheName, key, data)`
|
||||
|
||||
Stores `data` in the named cache bucket under `key`. A `cachedAt` timestamp is embedded alongside the data so staleness can be checked on the next `get`.
|
||||
|
||||
Fire-and-forget: errors are silently swallowed.
|
||||
|
||||
### `invalidate(cacheName)`
|
||||
|
||||
Deletes the **entire** Cache Storage bucket for `cacheName`. This removes all entries for that feature in one call.
|
||||
|
||||
Fire-and-forget: errors are silently swallowed.
|
||||
|
||||
---
|
||||
|
||||
## Cache key format
|
||||
|
||||
Internally, entries are stored under a pseudo-URL:
|
||||
|
||||
```
|
||||
/browser-cache/<encodedCacheName>?<key>
|
||||
```
|
||||
|
||||
This keeps entries within a single Cache Storage bucket readable via browser DevTools (Application → Cache Storage).
|
||||
|
||||
---
|
||||
|
||||
## Adding a new feature cache
|
||||
|
||||
Create a typed facade service that delegates to `BrowserCacheService`. This keeps the cache name and TTL in one place and gives callers a clean domain API.
|
||||
|
||||
```typescript
|
||||
// src/app/domain/services/customer-cache.service.ts
|
||||
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { BrowserCacheService } from './browser-cache.service';
|
||||
import { ICustomer } from '../../customers/models/customer.model';
|
||||
|
||||
const CACHE_NAME = 'agm-customer-list-v1';
|
||||
const MAX_AGE_MS = 60_000; // 1 minute
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class CustomerCacheService {
|
||||
|
||||
constructor(private readonly browserCache: BrowserCacheService) {}
|
||||
|
||||
get(queryParams: string): Observable<ICustomer[] | null> {
|
||||
return this.browserCache.get<ICustomer[]>(CACHE_NAME, queryParams, MAX_AGE_MS);
|
||||
}
|
||||
|
||||
put(queryParams: string, data: ICustomer[]): void {
|
||||
this.browserCache.put(CACHE_NAME, queryParams, data);
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.browserCache.invalidate(CACHE_NAME);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then, in the corresponding service:
|
||||
|
||||
```typescript
|
||||
// In CustomerService.loadCustomers():
|
||||
const cacheKey = params.toString();
|
||||
|
||||
return this.customerCache.get(cacheKey).pipe(
|
||||
switchMap(cached => {
|
||||
if (cached !== null) return of(cached);
|
||||
return this.http.get<ICustomer[]>(this.url, { params }).pipe(
|
||||
tap(data => this.customerCache.put(cacheKey, data))
|
||||
);
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
And in the effects, call `this.customerCache.invalidate()` after any create / update / delete action succeeds.
|
||||
|
||||
---
|
||||
|
||||
## Existing implementations
|
||||
|
||||
| Feature | Facade | Cache name | TTL |
|
||||
|---|---|---|---|
|
||||
| Job list | `JobCacheService` | `agm-jobs-list-v1` | 60 s |
|
||||
|
||||
---
|
||||
|
||||
## Versioning the cache name
|
||||
|
||||
Append a version suffix (e.g. `-v1`, `-v2`) to `cacheName` whenever the shape of the stored data changes. The old bucket will be orphaned in the browser until the browser's quota manager evicts it, or you can explicitly delete the old name during app initialisation.
|
||||
|
||||
---
|
||||
|
||||
## Browser DevTools
|
||||
|
||||
Cached entries are visible under:
|
||||
|
||||
**Chrome DevTools** → Application tab → Cache Storage → `agm-jobs-list-v1`
|
||||
36904
Development/client/package-lock.json
generated
Normal file
36904
Development/client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -4,12 +4,19 @@
|
||||
"license": "COMMERCIAL",
|
||||
"angular-cli": {},
|
||||
"scripts": {
|
||||
"generate-release-manifest": "node scripts/generate-release-manifest.js",
|
||||
"ng": "ng",
|
||||
"prestart-cert": "npm run generate-release-manifest",
|
||||
"start-cert": "ng serve --ssl true --sslKey ~/ssl/server.key --sslCert ~/ssl/server.crt --proxy-config proxy.config.json --host 0.0.0.0 --disableHostCheck",
|
||||
"prestart": "npm run generate-release-manifest",
|
||||
"start": "CHOKIDAR_USEPOLLING=true ng serve --ssl true --proxy-config proxy.config.json --host 0.0.0.0 --disableHostCheck",
|
||||
"prestart-es": "npm run generate-release-manifest",
|
||||
"start-es": "ng serve --ssl true --proxy-config proxy.config.json --host 0.0.0.0 --disableHostCheck --configuration=es",
|
||||
"prestart-pt": "npm run generate-release-manifest",
|
||||
"start-pt": "ng serve --ssl true --proxy-config proxy.config.json --host 0.0.0.0 --disableHostCheck --configuration=pt",
|
||||
"prebuild": "npm run generate-release-manifest",
|
||||
"build": "ng build",
|
||||
"prebuild-prep": "npm run generate-release-manifest",
|
||||
"build-prep": "ng build --aot --localize=false",
|
||||
"test": "ng test",
|
||||
"lint": "ng lint",
|
||||
@ -22,7 +29,9 @@
|
||||
"sync-i18n": "npm run build-prep && npm run i18n-extract && npm run i18n-merge",
|
||||
"sync-i18n-w": "npm run build-prep && npm run i18n-extract-w && npm run i18n-merge-w",
|
||||
"pre-translate": "npx translation start && npm run sync-i18n && npx translation translate && npx translation cleanup",
|
||||
"prebuild-prod": "npm run generate-release-manifest",
|
||||
"build-prod": "ng build --prod --localize && cp -R dist/en/* dist/ && rm -R dist/en",
|
||||
"prebuild-prod-window": "npm run generate-release-manifest",
|
||||
"build-prod-window": "ng build --prod --localize && xcopy /E /Y dist\\en\\* dist\\ && rmdir /S /Q dist\\en"
|
||||
},
|
||||
"private": true,
|
||||
@ -58,8 +67,13 @@
|
||||
"geodesy": "^1.1.3",
|
||||
"intl": "^1.2.5",
|
||||
"leaflet": "^1.9.4",
|
||||
"leaflet-river": "^1.0.1",
|
||||
"marked": "^1.2.9",
|
||||
"mermaid": "^8.14.0",
|
||||
"ngrx-store-localstorage": "^9.0.0",
|
||||
"ngx-captcha": "^8.0.1",
|
||||
"ngx-markdown": "^9.1.1",
|
||||
"polygon-clipping": "^0.15.7",
|
||||
"primeng-lts": "^9.2.8",
|
||||
"quill": "^1.3.7",
|
||||
"rbush": "^3.0.1",
|
||||
|
||||
36
Development/client/scripts/generate-release-manifest.js
Normal file
36
Development/client/scripts/generate-release-manifest.js
Normal file
@ -0,0 +1,36 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const releasesDir = path.resolve(__dirname, '..', 'docs', 'releases');
|
||||
const manifestPath = path.join(releasesDir, 'releases-manifest.json');
|
||||
|
||||
function toTitle(fileName) {
|
||||
return path.basename(fileName, path.extname(fileName));
|
||||
}
|
||||
|
||||
function getReleaseEntries() {
|
||||
if (!fs.existsSync(releasesDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return fs.readdirSync(releasesDir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isFile() && path.extname(entry.name).toLowerCase() === '.md')
|
||||
.map((entry) => entry.name)
|
||||
.sort((left, right) => right.localeCompare(left, undefined, { numeric: true, sensitivity: 'base' }))
|
||||
.map((fileName) => ({
|
||||
fileName,
|
||||
title: toTitle(fileName)
|
||||
}));
|
||||
}
|
||||
|
||||
function main() {
|
||||
fs.mkdirSync(releasesDir, { recursive: true });
|
||||
|
||||
const manifest = {
|
||||
revisions: getReleaseEntries()
|
||||
};
|
||||
|
||||
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
|
||||
}
|
||||
|
||||
main();
|
||||
@ -22,6 +22,8 @@
|
||||
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, col.filterMatchMode)"
|
||||
[value]="dt.filters[col.field]?.value">
|
||||
</div>
|
||||
<p-dropdown *ngIf="col.field === ACTIVE" [options]="activeOpts" [style]="{'width':'100%'}" [ngModel]="dt.filters[col.field]?.value" (onChange)="dt.filter($event.value, col.field, 'equals')"></p-dropdown>
|
||||
<p-dropdown *ngIf="col.field === KIND" [options]="kindOpts" [style]="{'width':'100%'}" [ngModel]="dt.filters[col.field]?.value" (onChange)="dt.filter($event.value, col.field, 'equals')"></p-dropdown>
|
||||
<span *ngSwitchDefault></span>
|
||||
</th>
|
||||
</tr>
|
||||
|
||||
@ -7,7 +7,7 @@ import { User } from '../models/user.model';
|
||||
import * as fromUsers from '../reducers';
|
||||
import * as userActions from '../actions/account.actions';
|
||||
|
||||
import { RoleIds, globals, OperationalStatus, Labels } from '@app/shared/global';
|
||||
import { RoleIds, Roles, globals, OperationalStatus, Labels } from '@app/shared/global';
|
||||
import { BaseComp } from '@app/shared/base/base.component';
|
||||
import { Utils } from '@app/shared/utils';
|
||||
|
||||
@ -21,6 +21,15 @@ export class AccountListComponent extends BaseComp implements OnInit, OnDestroy
|
||||
readonly resolveFieldData = Utils.resolveFieldData;
|
||||
readonly KIND = 'kind';
|
||||
readonly ACTIVE = OperationalStatus.ACTIVE;
|
||||
activeOpts = [
|
||||
{ label: globals.all, value: null },
|
||||
{ label: globals.active, value: true },
|
||||
{ label: globals.notActive, value: false },
|
||||
];
|
||||
kindOpts = [
|
||||
{ label: globals.all, value: null },
|
||||
...Object.entries(Roles).map(([value, label]) => ({ label: label as string, value }))
|
||||
];
|
||||
accounts: Array<User>;
|
||||
isLoading: boolean;
|
||||
currAcc: User;
|
||||
|
||||
@ -69,6 +69,16 @@ const routes: Routes = [
|
||||
loadChildren: () => import('./tools/tools.module').then(m => m.ToolsModule),
|
||||
runGuardsAndResolvers: 'always',
|
||||
},
|
||||
{
|
||||
path: 'dlq',
|
||||
loadChildren: () => import('./tools/dlq-monitor/dlq-monitor.module').then(m => m.DlqMonitorModule),
|
||||
runGuardsAndResolvers: 'always',
|
||||
},
|
||||
{
|
||||
path: 'dealers',
|
||||
loadChildren: () => import('./tools/dealers/dealers.module').then(m => m.DealersModule),
|
||||
runGuardsAndResolvers: 'always',
|
||||
},
|
||||
{
|
||||
path: 'track',
|
||||
loadChildren: () => import('./track/track.module').then(m => m.TrackModule),
|
||||
@ -90,6 +100,21 @@ const routes: Routes = [
|
||||
loadChildren: () => import('./settings/settings.module').then(m => m.SettingsModule),
|
||||
runGuardsAndResolvers: 'always'
|
||||
},
|
||||
{
|
||||
path: 'api-keys',
|
||||
loadChildren: () => import('./settings/api-keys/api-keys.module').then(m => m.ApiKeysModule),
|
||||
runGuardsAndResolvers: 'always'
|
||||
},
|
||||
{
|
||||
path: 'release-notes',
|
||||
loadChildren: () => import('./release-notes/release-notes.module').then(m => m.ReleaseNotesModule),
|
||||
runGuardsAndResolvers: 'always'
|
||||
},
|
||||
{
|
||||
path: 'changelog',
|
||||
redirectTo: 'release-notes',
|
||||
pathMatch: 'full'
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@ -27,7 +27,16 @@
|
||||
|
||||
<app-topbar></app-topbar>
|
||||
|
||||
<!-- Mobile-only left-edge tab to open/close the navigation panel -->
|
||||
<button id="mobile-menu-tab" (click)="onMenuButtonClick($event)" aria-label="Toggle navigation">
|
||||
<i class="material-icons">chevron_right</i>
|
||||
</button>
|
||||
|
||||
<div class="layout-menu" [ngClass]="{'layout-menu-dark':darkMenu}" (click)="onMenuClick($event)">
|
||||
<div class="menu-user-info" *ngIf="user$ | async as user">
|
||||
<app-inline-profile [user]="user" [expiryWarning]="expiryWarning$ | async"
|
||||
(navigateToSubscription)="onNavigateToManageSubscription()"></app-inline-profile>
|
||||
</div>
|
||||
<app-menu></app-menu>
|
||||
</div>
|
||||
|
||||
|
||||
@ -39,8 +39,18 @@ export class AppMenuComponent implements OnInit {
|
||||
const mItems: MenuItem[] = [
|
||||
{ id: 'dashboard', label: $localize`:@@dashboard:Dashboard`, icon: 'dashboard', routerLink: ['/home'] },
|
||||
{ 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'] },
|
||||
{ label: $localize`:@@billing:Billing`, icon: 'monetization_on', routerLink: ['/billing'] },
|
||||
{
|
||||
id: 'tools',
|
||||
label: $localize`:@@tools:Tools`, icon: 'extension',
|
||||
routerLink: ['/tools'],
|
||||
items: [
|
||||
{ id: 'api-keys', label: $localize`:@@apiKeys:API Keys`, icon: 'vpn_key', routerLink: ['/api-keys'] },
|
||||
{ id: 'dlq-monitor', label: $localize`:@@dlqMonitor:DLQ Monitor`, icon: 'bug_report', routerLink: ['/dlq'] }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
label: $localize`:@@settings:Settings`, icon: 'settings',
|
||||
@ -49,6 +59,7 @@ export class AppMenuComponent implements OnInit {
|
||||
{ id: 'subscription', label: $localize`:@@promoManagement:Promo Management`, icon: 'credit_card', routerLink: ['/settings/subscription'] }
|
||||
]
|
||||
},
|
||||
{ id: 'release-notes', label: $localize`:@@releaseNotes:Release Notes`, icon: 'history', routerLink: ['/release-notes'] },
|
||||
];
|
||||
this.model = mItems;
|
||||
}
|
||||
@ -65,12 +76,7 @@ export class AppMenuComponent implements OnInit {
|
||||
{
|
||||
id: 'Help',
|
||||
label: $localize`:@@help:Help`, icon: 'help_outline',
|
||||
items: [{
|
||||
label: $localize`:@@trainingVideos:Training Videos`,
|
||||
icon: 'video_library',
|
||||
url: 'https://www.youtube.com/watch?v=QjGZan5QdAo&list=PLSMll_kIgHA3eamxiSH0Dgl95v60okMcV',
|
||||
target: '_blank'
|
||||
}]
|
||||
items: this.buildHelpMenuItems()
|
||||
}
|
||||
];
|
||||
this.model = mItems;
|
||||
@ -113,16 +119,28 @@ export class AppMenuComponent implements OnInit {
|
||||
{
|
||||
id: 'Help',
|
||||
label: $localize`:@@help:Help`, icon: 'help_outline',
|
||||
items: [{
|
||||
label: $localize`:@@trainingVideos:Training Videos`,
|
||||
icon: 'video_library',
|
||||
url: 'https://www.youtube.com/watch?v=QjGZan5QdAo&list=PLSMll_kIgHA3eamxiSH0Dgl95v60okMcV',
|
||||
target: '_blank'
|
||||
}]
|
||||
items: this.buildHelpMenuItems()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private buildHelpMenuItems(): MenuItem[] {
|
||||
return [
|
||||
{
|
||||
id: 'release-notes',
|
||||
label: $localize`:@@releaseNotes:Release Notes`,
|
||||
icon: 'history',
|
||||
routerLink: ['/release-notes']
|
||||
},
|
||||
{
|
||||
label: $localize`:@@trainingVideos:Training Videos`,
|
||||
icon: 'video_library',
|
||||
url: 'https://www.youtube.com/watch?v=QjGZan5QdAo&list=PLSMll_kIgHA3eamxiSH0Dgl95v60okMcV',
|
||||
target: '_blank'
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
private addOnlyTrackingItems(mItems: MenuItem[]) {
|
||||
if (!this.authSvc.hasRole([RoleIds.INSPECTOR])) {
|
||||
mItems.push(
|
||||
@ -209,7 +227,10 @@ export class AppMenuComponent implements OnInit {
|
||||
items: [
|
||||
{ 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: '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])
|
||||
? [{ id: 'api-keys', label: $localize`:@@apiKeys:API Keys`, icon: 'vpn_key', routerLink: ['/api-keys'] }]
|
||||
: [] )
|
||||
]
|
||||
}
|
||||
);
|
||||
|
||||
@ -8,15 +8,18 @@
|
||||
|
||||
.account-summary-info .account-username {
|
||||
margin-right: 0.5em;
|
||||
text-align:center;
|
||||
}
|
||||
|
||||
.account-summary-info .account-type {
|
||||
margin-right: 0.5em;
|
||||
font-style: italic;
|
||||
opacity: 0.85;
|
||||
text-align:center;
|
||||
}
|
||||
|
||||
.account-summary-info .account-contact {
|
||||
color: #ffd700;
|
||||
opacity: 0.9;
|
||||
text-align:center;
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import { Client } from "../models/client.model";
|
||||
export const FETCH = '[CLIENTS] Fetch clients';
|
||||
export class Fetch implements Action {
|
||||
type: typeof FETCH = FETCH;
|
||||
constructor(readonly payload?: { filters?: string; useCache?: boolean }) { }
|
||||
}
|
||||
|
||||
export const FETCH_SUCCESS = '[CLIENTS] Fetch clients success';
|
||||
|
||||
@ -4,3 +4,87 @@ Ref:https://stackoverflow.com/questions/48675497/how-to-disable-the-option-to-de
|
||||
tr.ui-state-highlight {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.cache-ttl-caption {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.cache-ttl-caption-title {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cache-ttl-caption-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.cache-ttl-help {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
vertical-align: middle;
|
||||
margin-left: 6px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.cache-ttl-help-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
font-weight: bold;
|
||||
cursor: help;
|
||||
color: #fff;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.cache-ttl-help-text {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
width: 220px;
|
||||
white-space: normal;
|
||||
padding: 8px 10px;
|
||||
border-radius: 4px;
|
||||
background: #323232;
|
||||
color: #fff;
|
||||
text-align: left;
|
||||
line-height: 1.35;
|
||||
font-size: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.cache-ttl-help:hover .cache-ttl-help-text,
|
||||
.cache-ttl-help:focus .cache-ttl-help-text,
|
||||
.cache-ttl-help:focus-within .cache-ttl-help-text {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.cache-ttl-caption-title,
|
||||
.cache-ttl-caption-controls {
|
||||
width: auto;
|
||||
float: none;
|
||||
}
|
||||
|
||||
.cache-ttl-caption-controls {
|
||||
padding-left: 4px;
|
||||
}
|
||||
}
|
||||
@ -1,9 +1,27 @@
|
||||
<div class="ui-g">
|
||||
<div class="ui-g-12">
|
||||
<div class="card">
|
||||
<p-accordion styleClass="agm-accordion" [style]="{'display':'block', 'margin-bottom':'0.75rem'}">
|
||||
<p-accordionTab i18n-header="@@searchClients" header="Search Clients" [transitionOptions]="'250ms'" [selected]="searchAccordionOpen"
|
||||
(selectedChange)="searchAccordionOpen = $event; onAccordionToggle($event)">
|
||||
<agm-dynamic-filter [filterDefinitions]="clientFilterDefinitions" [locale]="locale" stateKey="client-list-filters" (filtersSubmit)="onFiltersSubmit($event)"></agm-dynamic-filter>
|
||||
</p-accordionTab>
|
||||
</p-accordion>
|
||||
<p-table #dt [value]="clients" [columns]="cols" selectionMode="single" (onRowSelect)="onRowSelect($event)" [paginator]="true" [rows]="15" [pageLinks]="5" [rowsPerPageOptions]="[15,30,50]" [alwaysShowPaginator]="false" [(selection)]="currClient" dataKey="_id" [resetPageOnSort]="false" stateStorage="session" stateKey="cltb-ops" [responsive]="true">
|
||||
<ng-template pTemplate="caption">
|
||||
<span class="table-caption-1" i18n="@@clientList">Client List</span>
|
||||
<div class="ui-g ui-g-nopad cache-ttl-caption">
|
||||
<div class="ui-g-6 ui-sm-12 cache-ttl-caption-title">
|
||||
<span class="table-caption-1" style="display:block; text-align:left;" i18n="@@clientList">Client List</span>
|
||||
</div>
|
||||
<div class="ui-g-6 ui-sm-12 cache-ttl-caption-controls">
|
||||
<input pInputText type="number" min="0" step="1" placeholder="Cache TTL" [(ngModel)]="cacheTtlSeconds"
|
||||
(blur)="updateCacheTtl()" style="width: 3.5rem;">
|
||||
<span class="cache-ttl-help" tabindex="0">
|
||||
<span class="cache-ttl-help-icon">?</span>
|
||||
<span class="cache-ttl-help-text">Controls how long results stay cached after you return to this page. Value is in seconds.</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
<ng-template pTemplate="header" let-columns>
|
||||
<tr>
|
||||
@ -16,6 +34,10 @@
|
||||
<i class="ui-icon-search"></i>
|
||||
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, col.filterMatchMode)" [value]="dt.filters[col.field]?.value">
|
||||
</div>
|
||||
<div class="input-with-icon" *ngIf="col.field === 'address'">
|
||||
<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>
|
||||
<span *ngSwitchDefault></span>
|
||||
</th>
|
||||
</tr>
|
||||
|
||||
@ -11,6 +11,9 @@ import { RoleIds, globals } from '../../shared/global';
|
||||
import { JobService } from '../../domain/services/job.service';
|
||||
import { Utils } from 'src/app/shared/utils';
|
||||
import { BaseComp } from 'src/app/shared/base/base.component';
|
||||
import { ClientCacheService } from '@app/domain/services/client-cache.service';
|
||||
import { ListReturnCacheService } from '@app/domain/services/list-return-cache.service';
|
||||
import { FilterDefinition, FilterChangeEvent } from '@app/shared/dynamic-filter/dynamic-filter.component';
|
||||
|
||||
|
||||
@Component({
|
||||
@ -29,6 +32,20 @@ export class ClientListComponent extends BaseComp implements OnInit, OnDestroy {
|
||||
cols: any[];
|
||||
loading$ = this.store.select(fromClients.isLoading);
|
||||
|
||||
searchAccordionOpen = sessionStorage.getItem('client-list-accordion') === 'true';
|
||||
private lastFiltersQuery: Record<string, any> | undefined;
|
||||
private useCacheOnReturn = false;
|
||||
cacheTtlSeconds: number;
|
||||
|
||||
clientFilterDefinitions: FilterDefinition[] = [
|
||||
{ key: 'name', label: globals.name, dataType: 'text' },
|
||||
{ key: 'username', label: globals.userName, dataType: 'text' },
|
||||
{ key: 'email', label: globals.email, dataType: 'text' },
|
||||
{ key: 'phone', label: globals.phone + ' ' + $localize`:@@Num:N°`, dataType: 'text' },
|
||||
{ key: 'contact', label: globals.contact, dataType: 'text' },
|
||||
{ key: 'address', label: globals.address, dataType: 'text' },
|
||||
];
|
||||
|
||||
get canWrite(): boolean {
|
||||
return this.authSvc.hasRole([RoleIds.APP, RoleIds.APP_ADM, RoleIds.OFFICER]);
|
||||
}
|
||||
@ -36,9 +53,11 @@ export class ClientListComponent extends BaseComp implements OnInit, OnDestroy {
|
||||
constructor(
|
||||
private readonly route: ActivatedRoute,
|
||||
private readonly jobService: JobService,
|
||||
|
||||
private readonly clientCache: ClientCacheService,
|
||||
private readonly listReturnCache: ListReturnCacheService,
|
||||
) {
|
||||
super();
|
||||
this.cacheTtlSeconds = Math.round(this.clientCache.getTtlMs() / 1000);
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
@ -58,7 +77,51 @@ export class ClientListComponent extends BaseComp implements OnInit, OnDestroy {
|
||||
this.currClient = client;
|
||||
}));
|
||||
|
||||
this.store.dispatch(new clientActions.Fetch());
|
||||
this.useCacheOnReturn = this.listReturnCache.startVisit('clients');
|
||||
const savedFilters = sessionStorage.getItem('client-list-last-filters');
|
||||
if (savedFilters) {
|
||||
try {
|
||||
this.lastFiltersQuery = JSON.parse(savedFilters);
|
||||
} catch (_err) {
|
||||
this.lastFiltersQuery = undefined;
|
||||
}
|
||||
}
|
||||
this.store.dispatch(savedFilters
|
||||
? new clientActions.Fetch({ filters: savedFilters, useCache: this.useCacheOnReturn })
|
||||
: new clientActions.Fetch({ useCache: this.useCacheOnReturn })
|
||||
);
|
||||
}
|
||||
|
||||
onAccordionToggle(expanded: boolean) {
|
||||
sessionStorage.setItem('client-list-accordion', String(expanded));
|
||||
}
|
||||
|
||||
updateCacheTtl(): void {
|
||||
const ttlMs = this.clientCache.setTtlMs(Number(this.cacheTtlSeconds || 0) * 1000);
|
||||
this.cacheTtlSeconds = Math.round(ttlMs / 1000);
|
||||
}
|
||||
|
||||
onFiltersSubmit(event: FilterChangeEvent) {
|
||||
const q = { ...event.query };
|
||||
const filtersStr = JSON.stringify(q);
|
||||
const prevFilters = sessionStorage.getItem('client-list-last-filters');
|
||||
if (filtersStr !== prevFilters) {
|
||||
this.clientCache.invalidate();
|
||||
this.useCacheOnReturn = false;
|
||||
}
|
||||
this.lastFiltersQuery = q;
|
||||
sessionStorage.setItem('client-list-last-filters', filtersStr);
|
||||
this.store.dispatch(new clientActions.Fetch({ filters: filtersStr, useCache: this.useCacheOnReturn }));
|
||||
}
|
||||
|
||||
reloadClients() {
|
||||
this.clientCache.invalidate();
|
||||
this.useCacheOnReturn = false;
|
||||
if (this.lastFiltersQuery) {
|
||||
this.store.dispatch(new clientActions.Fetch({ filters: JSON.stringify(this.lastFiltersQuery), useCache: false }));
|
||||
} else {
|
||||
this.store.dispatch(new clientActions.Fetch({ useCache: false }));
|
||||
}
|
||||
}
|
||||
|
||||
onRowSelect(event) {
|
||||
@ -74,6 +137,7 @@ export class ClientListComponent extends BaseComp implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
editClient() {
|
||||
this.listReturnCache.markPending('clients');
|
||||
this.router.navigate(['client', this.currClient._id], { relativeTo: this.route });
|
||||
}
|
||||
|
||||
|
||||
@ -13,6 +13,7 @@ import { DropdownModule } from 'primeng/dropdown';
|
||||
|
||||
import { TableModule } from 'primeng/table';
|
||||
import { ToastModule } from 'primeng/toast';
|
||||
import { AccordionModule } from 'primeng/accordion';
|
||||
|
||||
import { StoreModule } from '@ngrx/store';
|
||||
import { EffectsModule } from '@ngrx/effects';
|
||||
@ -28,7 +29,7 @@ import { AppSharedModule } from '../shared/app-shared.module';
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule, TableModule, PaginatorModule, DialogModule, ConfirmDialogModule, ToastModule, MessagesModule, InputTextModule,
|
||||
CheckboxModule, ToolbarModule, ButtonModule, DropdownModule, AppSharedModule,
|
||||
CheckboxModule, ToolbarModule, ButtonModule, DropdownModule, AccordionModule, AppSharedModule,
|
||||
StoreModule.forFeature(fromClients.FEATURE_KEY, fromClients.reducer),
|
||||
EffectsModule.forFeature([ClientEffects]),
|
||||
ClientsRoutingModule
|
||||
|
||||
@ -10,6 +10,7 @@ import { ClientService } from '@app/domain/services/client.service';
|
||||
import { AuthService } from '@app/domain/services/auth.service';
|
||||
import { AppMessageService } from '@app/shared/app-message.service';
|
||||
import { globals } from '@app/shared/global';
|
||||
import { ClientCacheService } from '@app/domain/services/client-cache.service';
|
||||
|
||||
@Injectable()
|
||||
export class ClientEffects {
|
||||
@ -17,15 +18,20 @@ export class ClientEffects {
|
||||
private readonly actions$: Actions,
|
||||
private readonly clientSvc: ClientService,
|
||||
private readonly authSvc: AuthService,
|
||||
private readonly msgSvc: AppMessageService
|
||||
private readonly msgSvc: AppMessageService,
|
||||
private readonly clientCache: ClientCacheService
|
||||
) {
|
||||
}
|
||||
|
||||
@Effect()
|
||||
loadClients$: Observable<Action> = this.actions$.pipe(
|
||||
ofType<clientActions.Fetch>(clientActions.FETCH),
|
||||
switchMap(() =>
|
||||
this.clientSvc.loadClients({ byPuid: this.authSvc.user.parent }).pipe(
|
||||
switchMap(({ payload }) =>
|
||||
this.clientSvc.loadClients({
|
||||
byPuid: this.authSvc.user.parent,
|
||||
useCache: payload?.useCache,
|
||||
...(payload?.filters ? { filters: payload.filters } : {})
|
||||
}).pipe(
|
||||
map(clients => new clientActions.FetchSuccess(clients)),
|
||||
catchError(err => {
|
||||
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.load).replace('#thing#', globals.clients));
|
||||
@ -40,7 +46,10 @@ export class ClientEffects {
|
||||
ofType<clientActions.Create>(clientActions.CREATE),
|
||||
switchMap(({ payload }) =>
|
||||
this.clientSvc.saveClient(payload).pipe(
|
||||
map((client) => new clientActions.CreateSuccess(client)),
|
||||
map((client) => {
|
||||
this.clientCache.invalidate();
|
||||
return new clientActions.CreateSuccess(client);
|
||||
}),
|
||||
catchError(err => {
|
||||
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.create).replace('#thing#', globals.client));
|
||||
return of(new clientActions.CreateFailed())
|
||||
@ -54,7 +63,9 @@ export class ClientEffects {
|
||||
ofType<clientActions.Update>(clientActions.UPDATE),
|
||||
switchMap(({ payload }) =>
|
||||
this.clientSvc.saveClient(payload).pipe(
|
||||
map(() => new clientActions.UpdateSuccess(payload)),
|
||||
map(() => {
|
||||
return new clientActions.UpdateSuccess(payload);
|
||||
}),
|
||||
catchError(err => {
|
||||
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.save).replace('#thing#', globals.client));
|
||||
return of(new clientActions.UpdateFailed());
|
||||
@ -68,7 +79,10 @@ export class ClientEffects {
|
||||
ofType<clientActions.Delete>(clientActions.DELETE),
|
||||
switchMap(({ payload }) =>
|
||||
this.clientSvc.deleteClient(payload).pipe(
|
||||
map(() => new clientActions.DeleteSuccess(payload)),
|
||||
map(() => {
|
||||
this.clientCache.invalidate();
|
||||
return new clientActions.DeleteSuccess(payload);
|
||||
}),
|
||||
catchError(err => {
|
||||
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.delete).replace('#thing#', globals.client));
|
||||
return of(new clientActions.UpdateFailed())
|
||||
|
||||
@ -4,6 +4,7 @@ import { Customer } from "../models/customer.model";
|
||||
export const FETCH = '[CUSTOMERS] Fetch customers';
|
||||
export class Fetch implements Action {
|
||||
type: typeof FETCH = FETCH;
|
||||
constructor(readonly payload?: { filters?: string; useCache?: boolean }) {}
|
||||
}
|
||||
|
||||
export const FETCH_SUCCESS = '[CUSTOMERS] Fetch customers success';
|
||||
|
||||
@ -56,6 +56,20 @@
|
||||
</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">
|
||||
<p-checkbox id="billable" name="billable" formControlName="billable" label="Billable"
|
||||
binary="true"></p-checkbox>
|
||||
@ -90,6 +104,12 @@
|
||||
required="true" i18n-title="@@accessAccount" title="Access Account" showActive="true">
|
||||
</agm-account-editor>
|
||||
</div>
|
||||
|
||||
<!-- API Key Manager (existing customers only) -->
|
||||
<div class="ui-g-12" *ngIf="!isNew">
|
||||
<agm-api-key-manager [ownerId]="customer._id" [toggleable]="true" [collapsed]="true"></agm-api-key-manager>
|
||||
</div>
|
||||
|
||||
<div class="ui-g-12 toolbar padtop1 ui-fluid">
|
||||
<button pButton [disabled]="form.invalid || partnerLoading" type="button" style="width:auto"
|
||||
[icon]="isNew ? 'ui-icon-plus' : 'ui-icon-save'" [label]="isNew ? globals.create : globals.save"
|
||||
|
||||
@ -6,6 +6,7 @@ import { Customer, Partner } from '../models/customer.model';
|
||||
import * as customerActions from '../actions/customer.actions';
|
||||
import { UserService } from '@app/domain/services/user.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 { GC, RoleIds, globals, Labels } from '@app/shared/global';
|
||||
import { AGNavSubscription, Trial } from '@app/domain/models/subscription.model';
|
||||
@ -40,6 +41,10 @@ export class CustomerEditComponent extends BaseComp implements OnInit {
|
||||
partnerLoading = false;
|
||||
partnerError: string | null = null;
|
||||
|
||||
// Dealer Selection Properties
|
||||
dealerOptions: SelectItem[] = [];
|
||||
dealerLoading = false;
|
||||
|
||||
private _customer: Customer;
|
||||
get customer(): Customer { return this._customer; }
|
||||
set customer(customer: Customer) {
|
||||
@ -51,7 +56,8 @@ export class CustomerEditComponent extends BaseComp implements OnInit {
|
||||
premium: this.selectedItem.premium,
|
||||
billable: this.selectedItem.billable,
|
||||
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
|
||||
@ -67,6 +73,7 @@ export class CustomerEditComponent extends BaseComp implements OnInit {
|
||||
private readonly route: ActivatedRoute,
|
||||
private readonly userSvc: UserService,
|
||||
private readonly partnerSvc: PartnerService,
|
||||
private readonly dealerSvc: DealerService,
|
||||
private readonly fb: FormBuilder
|
||||
) {
|
||||
super();
|
||||
@ -83,7 +90,9 @@ export class CustomerEditComponent extends BaseComp implements OnInit {
|
||||
billable: [],
|
||||
trials: [],
|
||||
// Partner form control
|
||||
partner: [null]
|
||||
partner: [null],
|
||||
// Dealer form control
|
||||
dealer: [null]
|
||||
});
|
||||
this.lang = this.authSvc.locale;
|
||||
|
||||
@ -106,6 +115,7 @@ export class CustomerEditComponent extends BaseComp implements OnInit {
|
||||
}
|
||||
// Load partners from service
|
||||
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,
|
||||
{ premium: this.form.value.premium || 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
|
||||
? custObj = Object.assign(custObj, { membership: updateTrialMembship(this.membership) })
|
||||
@ -209,6 +220,30 @@ export class CustomerEditComponent extends BaseComp implements OnInit {
|
||||
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
|
||||
private loadPartners(): void {
|
||||
this.partnerLoading = true;
|
||||
|
||||
@ -0,0 +1,83 @@
|
||||
.cache-ttl-caption {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.cache-ttl-caption-title {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cache-ttl-caption-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.cache-ttl-help {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
vertical-align: middle;
|
||||
margin-right: 8px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.cache-ttl-help-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
font-weight: bold;
|
||||
cursor: help;
|
||||
color: #fff;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.cache-ttl-help-text {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
width: 220px;
|
||||
white-space: normal;
|
||||
padding: 8px 10px;
|
||||
border-radius: 4px;
|
||||
background: #323232;
|
||||
color: #fff;
|
||||
text-align: left;
|
||||
line-height: 1.35;
|
||||
font-size: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.cache-ttl-help:hover .cache-ttl-help-text,
|
||||
.cache-ttl-help:focus .cache-ttl-help-text,
|
||||
.cache-ttl-help:focus-within .cache-ttl-help-text {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.cache-ttl-caption-title,
|
||||
.cache-ttl-caption-controls {
|
||||
width: auto;
|
||||
float: none;
|
||||
}
|
||||
|
||||
.cache-ttl-caption-controls {
|
||||
padding-left: 4px;
|
||||
}
|
||||
}
|
||||
@ -1,15 +1,25 @@
|
||||
<div class="ui-g">
|
||||
<div class="ui-g-12">
|
||||
<div class="card">
|
||||
<p-accordion styleClass="agm-accordion" [style]="{'display':'block', 'margin-bottom':'0.75rem'}">
|
||||
<p-accordionTab i18n-header="@@searchCustomers" header="Search Customers" [transitionOptions]="'250ms'" [selected]="searchAccordionOpen"
|
||||
(selectedChange)="searchAccordionOpen = $event; onAccordionToggle($event)">
|
||||
<agm-dynamic-filter [filterDefinitions]="customerFilterDefinitions" [locale]="locale" stateKey="customers-list-filters" (filtersSubmit)="onFiltersSubmit($event)"></agm-dynamic-filter>
|
||||
</p-accordionTab>
|
||||
</p-accordion>
|
||||
<p-table #dt [value]="customers" [columns]="cols" selectionMode="single" (onRowSelect)="onRowSelect($event)" [paginator]="true" [rows]="15" [pageLinks]="5" [rowsPerPageOptions]="[10, 15, 30]" [alwaysShowPaginator]="true" [(selection)]="curCust" dataKey="_id" [resetPageOnSort]="false" stateStorage="session" stateKey="ctb-ops" [responsive]="true">
|
||||
<ng-template pTemplate="caption">
|
||||
<div class="ui-g ui-g-nopad">
|
||||
<div class="ui-g-6 cc-field-label">
|
||||
<span class="table-caption-1" i18n="@@customerList">Customer List</span>
|
||||
<div class="ui-g ui-g-nopad cache-ttl-caption">
|
||||
<div class="ui-g-6 cc-field-label cache-ttl-caption-title">
|
||||
<span class="table-caption-1" style="display:block; text-align:left;" i18n="@@customerList">Customer List</span>
|
||||
</div>
|
||||
<div class="ui-g-6 cc-field-label">
|
||||
<label style="margin-right: 8px;">Self Signup Accounts {{ isSelfSignup ? 'On' : 'Off' }}</label>
|
||||
<p-inputSwitch [(ngModel)]="isSelfSignup" (onChange)="onToggle($event)"></p-inputSwitch>
|
||||
<div class="ui-g-6 cc-field-label cache-ttl-caption-controls">
|
||||
<input pInputText type="number" min="0" step="1" placeholder="Cache TTL" [(ngModel)]="cacheTtlSeconds"
|
||||
(blur)="updateCacheTtl()" style="width: 3.5rem; margin-right: 8px;">
|
||||
<span class="cache-ttl-help" tabindex="0">
|
||||
<span class="cache-ttl-help-icon">?</span>
|
||||
<span class="cache-ttl-help-text">Controls how long results stay cached after you return to this page. Value is in seconds.</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
@ -30,6 +40,10 @@
|
||||
|
||||
<p-dropdown *ngIf="col.field === PARTNER_NAME" [options]="partners" [style]="{'width':'100%'}" [ngModel]="dt.filters[col.field]?.value" (onChange)="dt.filter($event.value, col.field, 'equals')"></p-dropdown>
|
||||
|
||||
<div class="input-with-icon" *ngIf="col.field === 'contact'">
|
||||
<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>
|
||||
<span *ngSwitchDefault></span>
|
||||
</th>
|
||||
</tr>
|
||||
|
||||
@ -10,6 +10,9 @@ import * as customerActions from '../actions/customer.actions';
|
||||
import { globals, OperationalStatus } from '@app/shared/global';
|
||||
|
||||
import { BaseComp } from '@app/shared/base/base.component';
|
||||
import { CustomerCacheService } from '@app/domain/services/customer-cache.service';
|
||||
import { ListReturnCacheService } from '@app/domain/services/list-return-cache.service';
|
||||
import { FilterDefinition, FilterChangeEvent } from '@app/shared/dynamic-filter/dynamic-filter.component';
|
||||
|
||||
@Component({
|
||||
selector: 'agm-customer-list',
|
||||
@ -32,13 +35,20 @@ export class CustomerListComponent extends BaseComp implements OnInit, OnDestroy
|
||||
partners: SelectItem[];
|
||||
cols: any[];
|
||||
totalItems;
|
||||
isSelfSignup = false;
|
||||
|
||||
searchAccordionOpen = sessionStorage.getItem('customers-list-accordion') === 'true';
|
||||
private lastFiltersQuery: Record<string, any> | undefined;
|
||||
private useCacheOnReturn = false;
|
||||
cacheTtlSeconds: number;
|
||||
customerFilterDefinitions: FilterDefinition[];
|
||||
|
||||
constructor(
|
||||
private readonly route: ActivatedRoute,
|
||||
|
||||
private readonly customerCache: CustomerCacheService,
|
||||
private readonly listReturnCache: ListReturnCacheService,
|
||||
) {
|
||||
super();
|
||||
this.cacheTtlSeconds = Math.round(this.customerCache.getTtlMs() / 1000);
|
||||
this.totalItems = { '=0': '', '=1': '1 ' + $localize`:@@customer:customer`.toLocaleLowerCase(), 'other': $localize`:@@total#Customers:Total: # customers` };
|
||||
|
||||
this.statuses = [
|
||||
@ -56,12 +66,21 @@ export class CustomerListComponent extends BaseComp implements OnInit, OnDestroy
|
||||
{ field: this.ACTIVE, header: globals.active, width: '9%' },
|
||||
{ field: this.PARTNER_NAME, header: globals.partner, width: '9%' }
|
||||
];
|
||||
|
||||
this.customerFilterDefinitions = [
|
||||
{ key: 'name', label: globals.name, dataType: 'text' },
|
||||
{ key: 'username', label: globals.userName, dataType: 'text' },
|
||||
{ key: 'email', label: globals.email, dataType: 'text' },
|
||||
{ key: 'contact', label: globals.contact, dataType: 'text' },
|
||||
{ key: 'createdAt', label: globals.from, dataType: 'date-preset' },
|
||||
{ key: 'selfSignup', label: 'Self Signup', dataType: 'select', options: [
|
||||
{ label: 'True', value: true },
|
||||
{ label: 'False', value: false },
|
||||
]},
|
||||
];
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
const saved = localStorage.getItem('isSelfSignup');
|
||||
this.isSelfSignup = saved === 'true';
|
||||
|
||||
this.sub$ = this.store.select(fromCustomers.getAllCustomers).subscribe(customers => {
|
||||
this.setCustomersAndPartners(customers);
|
||||
});
|
||||
@ -70,12 +89,23 @@ export class CustomerListComponent extends BaseComp implements OnInit, OnDestroy
|
||||
this.curCust = cust;
|
||||
}));
|
||||
|
||||
this.store.dispatch(new customerActions.Fetch());
|
||||
this.useCacheOnReturn = this.listReturnCache.startVisit('customers');
|
||||
const savedFilters = sessionStorage.getItem('customers-list-last-filters');
|
||||
if (savedFilters) {
|
||||
try {
|
||||
this.lastFiltersQuery = JSON.parse(savedFilters);
|
||||
} catch (_err) {
|
||||
this.lastFiltersQuery = undefined;
|
||||
}
|
||||
}
|
||||
this.store.dispatch(savedFilters
|
||||
? new customerActions.Fetch({ filters: savedFilters, useCache: this.useCacheOnReturn })
|
||||
: new customerActions.Fetch({ useCache: this.useCacheOnReturn })
|
||||
);
|
||||
}
|
||||
|
||||
private setCustomersAndPartners(customers: Customer[]) {
|
||||
const filtered = this.isSelfSignup ? customers.filter(c => c.selfSignup) : customers;
|
||||
this.customers = filtered.map(c => ({
|
||||
this.customers = customers.map(c => ({
|
||||
...c,
|
||||
partnerName: c.partner?.name || null
|
||||
}));
|
||||
@ -89,18 +119,32 @@ export class CustomerListComponent extends BaseComp implements OnInit, OnDestroy
|
||||
];
|
||||
}
|
||||
|
||||
onToggle(event: any): void {
|
||||
this.isSelfSignup = event.checked;
|
||||
localStorage.setItem('isSelfSignup', String(this.isSelfSignup));
|
||||
this.store.select(fromCustomers.getAllCustomers).subscribe(customers => {
|
||||
this.setCustomersAndPartners(customers);
|
||||
});
|
||||
}
|
||||
|
||||
onRowSelect(event) {
|
||||
this.store.dispatch(new customerActions.Select(event.data));
|
||||
}
|
||||
|
||||
onAccordionToggle(expanded: boolean) {
|
||||
sessionStorage.setItem('customers-list-accordion', String(expanded));
|
||||
}
|
||||
|
||||
updateCacheTtl(): void {
|
||||
const ttlMs = this.customerCache.setTtlMs(Number(this.cacheTtlSeconds || 0) * 1000);
|
||||
this.cacheTtlSeconds = Math.round(ttlMs / 1000);
|
||||
}
|
||||
|
||||
onFiltersSubmit(event: FilterChangeEvent) {
|
||||
const q = { ...event.query };
|
||||
const filtersStr = JSON.stringify(q);
|
||||
const prevFilters = sessionStorage.getItem('customers-list-last-filters');
|
||||
if (filtersStr !== prevFilters) {
|
||||
this.customerCache.invalidate();
|
||||
this.useCacheOnReturn = false;
|
||||
}
|
||||
this.lastFiltersQuery = q;
|
||||
sessionStorage.setItem('customers-list-last-filters', filtersStr);
|
||||
this.store.dispatch(new customerActions.Fetch({ filters: filtersStr, useCache: this.useCacheOnReturn }));
|
||||
}
|
||||
|
||||
get canEdit() {
|
||||
return (this.curCust && this.curCust._id !== '0');
|
||||
}
|
||||
@ -110,6 +154,7 @@ export class CustomerListComponent extends BaseComp implements OnInit, OnDestroy
|
||||
}
|
||||
|
||||
editCustomer() {
|
||||
this.listReturnCache.markPending('customers');
|
||||
this.router.navigate(['customer', this.curCust._id], { relativeTo: this.route });
|
||||
}
|
||||
|
||||
|
||||
@ -12,8 +12,10 @@ import { MessageModule } from 'primeng/message';
|
||||
import { TableModule } from 'primeng/table';
|
||||
import { ToastModule } from 'primeng/toast';
|
||||
import { MessagesModule } from 'primeng/messages';
|
||||
import { AccordionModule } from 'primeng/accordion';
|
||||
|
||||
import { AppSharedModule } from '../shared/app-shared.module';
|
||||
import { ApiKeySharedModule } from '../settings/api-keys/api-key-shared.module';
|
||||
|
||||
import { StoreModule } from '@ngrx/store';
|
||||
import { EffectsModule } from '@ngrx/effects';
|
||||
@ -41,6 +43,8 @@ import { TrialComponent } from './trial/trial.component';
|
||||
SplitButtonModule,
|
||||
TableModule,
|
||||
AppSharedModule,
|
||||
ApiKeySharedModule,
|
||||
AccordionModule,
|
||||
|
||||
StoreModule.forFeature(fromCustomers.FEATURE_KEY, fromCustomers.reducer),
|
||||
EffectsModule.forFeature([CustomerEffects]),
|
||||
|
||||
@ -9,21 +9,23 @@ import * as customerActions from '../actions/customer.actions';
|
||||
import { CustomerService } from '@app/domain/services/customer.service';
|
||||
import { AppMessageService } from '@app/shared/app-message.service';
|
||||
import { globals } from '@app/shared/global';
|
||||
import { CustomerCacheService } from '@app/domain/services/customer-cache.service';
|
||||
|
||||
@Injectable()
|
||||
export class CustomerEffects {
|
||||
constructor(
|
||||
private readonly actions$: Actions,
|
||||
private readonly customerSvc: CustomerService,
|
||||
private readonly msgSvc: AppMessageService
|
||||
private readonly msgSvc: AppMessageService,
|
||||
private readonly customerCache: CustomerCacheService
|
||||
) {
|
||||
}
|
||||
|
||||
@Effect()
|
||||
loadCustomers$: Observable<Action> = this.actions$.pipe(
|
||||
ofType<customerActions.Fetch>(customerActions.FETCH),
|
||||
switchMap(() =>
|
||||
this.customerSvc.loadCustomers().pipe(
|
||||
switchMap(({ payload }) =>
|
||||
this.customerSvc.loadCustomers(payload?.filters, payload?.useCache).pipe(
|
||||
map(customers => new customerActions.FetchSuccess(customers)),
|
||||
catchError(err => {
|
||||
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.load).replace('#thing#', globals.customers));
|
||||
@ -38,7 +40,10 @@ export class CustomerEffects {
|
||||
ofType<customerActions.Create>(customerActions.CREATE),
|
||||
switchMap(({ payload }) =>
|
||||
this.customerSvc.saveCustomer(payload).pipe(
|
||||
map((customer) => new customerActions.CreateSuccess(customer)),
|
||||
map((customer) => {
|
||||
this.customerCache.invalidate();
|
||||
return new customerActions.CreateSuccess(customer);
|
||||
}),
|
||||
catchError(err => {
|
||||
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.create).replace('#thing#', globals.customer));
|
||||
return of(new customerActions.CreateFailed())
|
||||
@ -52,7 +57,9 @@ export class CustomerEffects {
|
||||
ofType<customerActions.Update>(customerActions.UPDATE),
|
||||
switchMap(({ payload }) =>
|
||||
this.customerSvc.saveCustomer(payload).pipe(
|
||||
map(() => new customerActions.UpdateSuccess(payload)),
|
||||
map(() => {
|
||||
return new customerActions.UpdateSuccess(payload);
|
||||
}),
|
||||
catchError(err => {
|
||||
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.save).replace('#thing#', globals.customer));
|
||||
return of(new customerActions.UpdateFailed());
|
||||
@ -66,7 +73,10 @@ export class CustomerEffects {
|
||||
ofType<customerActions.Delete>(customerActions.DELETE),
|
||||
switchMap(({ payload }) =>
|
||||
this.customerSvc.deleteCustomer(payload).pipe(
|
||||
map(() => new customerActions.DeleteSuccess(payload)),
|
||||
map(() => {
|
||||
this.customerCache.invalidate();
|
||||
return new customerActions.DeleteSuccess(payload);
|
||||
}),
|
||||
catchError(err => {
|
||||
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.delete).replace('#thing#', globals.customer));
|
||||
return of(new customerActions.UpdateFailed())
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { RoleIds } from '@app/shared/global';
|
||||
import { createNewUser, User } from '@app/accounts/models/user.model';
|
||||
import { IMembership } from '@app/auth/models/user.model';
|
||||
import { Dealer } from '@app/tools/dealers/dealer.service';
|
||||
|
||||
export interface Customer extends User {
|
||||
contact?: string;
|
||||
@ -10,6 +11,7 @@ export interface Customer extends User {
|
||||
totalJobs?: number;
|
||||
membership: IMembership,
|
||||
partner?: Partner;
|
||||
dealer?: Dealer;
|
||||
selfSignup?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@ -24,6 +24,7 @@ export interface IAppConfig {
|
||||
|
||||
noPopup: boolean;
|
||||
trialDays: [number];
|
||||
browserListCacheTtlMs?: number;
|
||||
/** Grace-period days for promo Valid Until (sysadmin only). From PROMO_MIN_EXPIRY_DAYS env. */
|
||||
promoMinExpiryDays?: number;
|
||||
}
|
||||
|
||||
@ -0,0 +1,32 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { ApiKey, CreateApiKeyRequest, CreateApiKeyResponse } from '../../settings/api-keys/models/api-key.model';
|
||||
|
||||
@Injectable()
|
||||
export class ApiKeyService {
|
||||
private readonly apiURL = '/keys';
|
||||
|
||||
constructor(private readonly http: HttpClient) {}
|
||||
|
||||
listKeys(ownerId?: string): Observable<ApiKey[]> {
|
||||
const params = ownerId ? new HttpParams().set('ownerId', ownerId) : undefined;
|
||||
return this.http.get<ApiKey[]>(this.apiURL, { params });
|
||||
}
|
||||
|
||||
createKey(req: CreateApiKeyRequest): Observable<CreateApiKeyResponse> {
|
||||
return this.http.post<CreateApiKeyResponse>(this.apiURL, req);
|
||||
}
|
||||
|
||||
revokeKey(keyId: string): Observable<void> {
|
||||
return this.http.patch<void>(`${this.apiURL}/${keyId}/revoke`, {});
|
||||
}
|
||||
|
||||
deleteKey(keyId: string): Observable<void> {
|
||||
return this.http.delete<void>(`${this.apiURL}/${keyId}`);
|
||||
}
|
||||
|
||||
regenerateKey(keyId: string): Observable<CreateApiKeyResponse> {
|
||||
return this.http.post<CreateApiKeyResponse>(`${this.apiURL}/${keyId}/regenerate`, {});
|
||||
}
|
||||
}
|
||||
@ -130,6 +130,8 @@ export class AppConfigService {
|
||||
}
|
||||
if (Utils.isNulOrUndef(settings['matType']))
|
||||
settings['matType'] = MatType.LIQUID;
|
||||
if (Utils.isNulOrUndef(settings['browserListCacheTtlMs']))
|
||||
settings['browserListCacheTtlMs'] = 60 * 1000;
|
||||
|
||||
this.settings = settings;
|
||||
this.wasSetDefault = true;
|
||||
|
||||
@ -41,7 +41,7 @@ export class AuthInterceptor implements HttpInterceptor {
|
||||
});
|
||||
|
||||
let url = req.url;
|
||||
if (!StringUtils.contains(req.url, '/track'))
|
||||
if (!StringUtils.contains(req.url, '/track') && !req.url.startsWith('/assets'))
|
||||
url = `/api${!req.url.startsWith('/') ? '' + req.url : req.url}`;
|
||||
|
||||
const authReq = req.clone({ url: url, headers: headers });
|
||||
|
||||
@ -0,0 +1,142 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { from, Observable, of } from 'rxjs';
|
||||
import { catchError, switchMap } from 'rxjs/operators';
|
||||
import { AppConfigService } from './app-config.service';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
/** Shape of every entry stored in Cache Storage. */
|
||||
export interface BrowserCacheEntry<T> {
|
||||
data: T;
|
||||
cachedAt: number; // epoch ms
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic browser-side Cache Storage wrapper.
|
||||
*
|
||||
* Each logical cache is identified by a **cacheName** (e.g. `'agm-jobs-list-v1'`).
|
||||
* Within that cache, individual entries are keyed by an arbitrary **key** string
|
||||
* (typically a serialised set of query params).
|
||||
*
|
||||
* Usage:
|
||||
* ```ts
|
||||
* // Read
|
||||
* this.browserCache.get<MyModel[]>('my-cache-v1', key, 60_000).subscribe(data => { ... });
|
||||
*
|
||||
* // Write
|
||||
* this.browserCache.put('my-cache-v1', key, data);
|
||||
*
|
||||
* // Invalidate
|
||||
* this.browserCache.invalidate('my-cache-v1');
|
||||
* ```
|
||||
*
|
||||
* All operations are no-ops when the Cache Storage API is unavailable
|
||||
* (e.g. in unit tests or older browsers).
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class BrowserCacheService {
|
||||
|
||||
private readonly supported = typeof caches !== 'undefined';
|
||||
private readonly fallbackMaxAgeMs = 60_000;
|
||||
|
||||
constructor(
|
||||
private readonly appConfig: AppConfigService,
|
||||
private readonly authSvc: AuthService
|
||||
) {}
|
||||
|
||||
private ttlStorageKey(cacheName: string): string {
|
||||
const userId = this.authSvc.user?._id || 'anonymous';
|
||||
return `browser-cache-ttl:${userId}:${cacheName}`;
|
||||
}
|
||||
|
||||
private get defaultMaxAgeMs(): number {
|
||||
return this.appConfig.settings?.browserListCacheTtlMs || this.fallbackMaxAgeMs;
|
||||
}
|
||||
|
||||
getTtl(cacheName: string): number {
|
||||
const storedValue = localStorage.getItem(this.ttlStorageKey(cacheName));
|
||||
if (storedValue === null) {
|
||||
return this.defaultMaxAgeMs;
|
||||
}
|
||||
|
||||
const parsedValue = Number(storedValue);
|
||||
return Number.isFinite(parsedValue) && parsedValue >= 0
|
||||
? parsedValue
|
||||
: this.defaultMaxAgeMs;
|
||||
}
|
||||
|
||||
setTtl(cacheName: string, ttlMs: number): number {
|
||||
const normalizedValue = Number.isFinite(ttlMs) && ttlMs >= 0
|
||||
? Math.floor(ttlMs)
|
||||
: this.defaultMaxAgeMs;
|
||||
localStorage.setItem(this.ttlStorageKey(cacheName), String(normalizedValue));
|
||||
return normalizedValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the pseudo-URL used as the cache key inside a named Cache bucket.
|
||||
* We prefix with a fixed path so it looks like a valid Request URL.
|
||||
*/
|
||||
private entryUrl(cacheName: string, key: string): string {
|
||||
return `/browser-cache/${encodeURIComponent(cacheName)}?${key}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a cached value.
|
||||
*
|
||||
* @param cacheName Name of the Cache Storage bucket (e.g. `'agm-jobs-list-v1'`).
|
||||
* @param key Entry key — typically serialised query params.
|
||||
* @param maxAgeMs Maximum age in milliseconds before the entry is treated as stale.
|
||||
* Defaults to `appConfig.browserListCacheTtlMs` or 60 000.
|
||||
* @returns The cached value, or `null` when unavailable / stale / missing.
|
||||
*/
|
||||
get<T>(cacheName: string, key: string, maxAgeMs?: number): Observable<T | null> {
|
||||
if (!this.supported) return of(null);
|
||||
|
||||
const effectiveMaxAgeMs = maxAgeMs ?? this.getTtl(cacheName);
|
||||
|
||||
return from(caches.open(cacheName)).pipe(
|
||||
switchMap(cache => from(cache.match(this.entryUrl(cacheName, key)))),
|
||||
switchMap(response => {
|
||||
if (!response) return of(null);
|
||||
return from(response.json() as Promise<BrowserCacheEntry<T>>);
|
||||
}),
|
||||
switchMap((entry: BrowserCacheEntry<T> | null) => {
|
||||
if (!entry) return of(null);
|
||||
if (Date.now() - entry.cachedAt > effectiveMaxAgeMs) return of(null);
|
||||
return of(entry.data);
|
||||
}),
|
||||
catchError(() => of(null))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a value in Cache Storage.
|
||||
* Fire-and-forget — errors are silently swallowed so they never block the caller.
|
||||
*
|
||||
* @param cacheName Name of the Cache Storage bucket.
|
||||
* @param key Entry key.
|
||||
* @param data Value to store (must be JSON-serialisable).
|
||||
*/
|
||||
put<T>(cacheName: string, key: string, data: T): void {
|
||||
if (!this.supported) return;
|
||||
|
||||
const entry: BrowserCacheEntry<T> = { data, cachedAt: Date.now() };
|
||||
caches.open(cacheName)
|
||||
.then(cache => cache.put(
|
||||
this.entryUrl(cacheName, key),
|
||||
new Response(JSON.stringify(entry), { headers: { 'Content-Type': 'application/json' } })
|
||||
))
|
||||
.catch(() => { /* silent */ });
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an entire Cache Storage bucket, invalidating all its entries.
|
||||
* Typically called after a mutation (create / update / delete).
|
||||
*
|
||||
* @param cacheName Name of the Cache Storage bucket to delete.
|
||||
*/
|
||||
invalidate(cacheName: string): void {
|
||||
if (!this.supported) return;
|
||||
caches.delete(cacheName).catch(() => { /* silent */ });
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { BrowserCacheService } from './browser-cache.service';
|
||||
|
||||
const CACHE_NAME = 'agm-clients-list-v1';
|
||||
|
||||
/**
|
||||
* Clients-list-specific facade over {@link BrowserCacheService}.
|
||||
*
|
||||
* Encapsulates the cache name and TTL so callers (ClientService, ClientEffects)
|
||||
* don't need to know those details.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ClientCacheService {
|
||||
|
||||
constructor(private readonly browserCache: BrowserCacheService) {}
|
||||
|
||||
getTtlMs(): number {
|
||||
return this.browserCache.getTtl(CACHE_NAME);
|
||||
}
|
||||
|
||||
setTtlMs(ttlMs: number): number {
|
||||
return this.browserCache.setTtl(CACHE_NAME, ttlMs);
|
||||
}
|
||||
|
||||
/** Return cached clients for the given query-param string, or null if stale/missing. */
|
||||
get(queryParams: string): Observable<any[] | null> {
|
||||
return this.browserCache.get<any[]>(CACHE_NAME, queryParams);
|
||||
}
|
||||
|
||||
/** Store a fresh clients list for the given query-param string. */
|
||||
put(queryParams: string, data: any[]): void {
|
||||
this.browserCache.put(CACHE_NAME, queryParams, data);
|
||||
}
|
||||
|
||||
/** Invalidate all cached client-list entries (call after any client mutation). */
|
||||
invalidate(): void {
|
||||
this.browserCache.invalidate(CACHE_NAME);
|
||||
}
|
||||
}
|
||||
@ -1,11 +1,13 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
|
||||
import { Observable } from 'rxjs';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { switchMap, tap } from 'rxjs/operators';
|
||||
|
||||
import { Store } from '@ngrx/store';
|
||||
import { Client } from '../../client/models/client.model';
|
||||
import { CustomerInvoiceSetting } from '@app/invoices/models/customer-invoice-setting.model';
|
||||
import { ClientCacheService } from './client-cache.service';
|
||||
|
||||
@Injectable()
|
||||
export class ClientService {
|
||||
@ -14,12 +16,35 @@ export class ClientService {
|
||||
|
||||
constructor(
|
||||
private store: Store<{}>,
|
||||
private http: HttpClient
|
||||
private http: HttpClient,
|
||||
private readonly clientCache: ClientCacheService
|
||||
) {
|
||||
}
|
||||
|
||||
loadClients(options?: LoadClientOps): Observable<Client[]> {
|
||||
return this.http.post<Client[]>(this.clientURL + '/search', options);
|
||||
const cacheKey = JSON.stringify({
|
||||
byPuid: options?.byPuid,
|
||||
filters: options?.filters || ''
|
||||
});
|
||||
const requestBody = {
|
||||
byPuid: options?.byPuid,
|
||||
...(options?.filters ? { filters: options.filters } : {})
|
||||
};
|
||||
|
||||
if (options?.useCache) {
|
||||
return this.clientCache.get(cacheKey).pipe(
|
||||
switchMap(cached => {
|
||||
if (cached !== null) return of(cached as Client[]);
|
||||
return this.http.post<Client[]>(this.clientURL + '/search', requestBody).pipe(
|
||||
tap(data => this.clientCache.put(cacheKey, data))
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return this.http.post<Client[]>(this.clientURL + '/search', requestBody).pipe(
|
||||
tap(data => this.clientCache.put(cacheKey, data))
|
||||
);
|
||||
}
|
||||
|
||||
getClient(id: string): Observable<Client> {
|
||||
@ -53,6 +78,8 @@ export class ClientService {
|
||||
|
||||
export interface LoadClientOps {
|
||||
byPuid: string;
|
||||
filters?: string;
|
||||
useCache?: boolean;
|
||||
}
|
||||
|
||||
export interface ClientWithSetting extends Client {
|
||||
|
||||
@ -0,0 +1,31 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { BrowserCacheService } from './browser-cache.service';
|
||||
|
||||
const CACHE_NAME = 'agm-customers-list-v1';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class CustomerCacheService {
|
||||
|
||||
constructor(private readonly browserCache: BrowserCacheService) {}
|
||||
|
||||
getTtlMs(): number {
|
||||
return this.browserCache.getTtl(CACHE_NAME);
|
||||
}
|
||||
|
||||
setTtlMs(ttlMs: number): number {
|
||||
return this.browserCache.setTtl(CACHE_NAME, ttlMs);
|
||||
}
|
||||
|
||||
get(queryParams: string): Observable<any[] | null> {
|
||||
return this.browserCache.get<any[]>(CACHE_NAME, queryParams);
|
||||
}
|
||||
|
||||
put(queryParams: string, data: any[]): void {
|
||||
this.browserCache.put(CACHE_NAME, queryParams, data);
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.browserCache.invalidate(CACHE_NAME);
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,9 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { switchMap, tap } from 'rxjs/operators';
|
||||
import { Customer } from '../../customers/models/customer.model';
|
||||
import { CustomerCacheService } from './customer-cache.service';
|
||||
|
||||
@Injectable()
|
||||
export class CustomerService {
|
||||
@ -9,12 +11,30 @@ export class CustomerService {
|
||||
private readonly customerURL = '/customers';
|
||||
|
||||
constructor(
|
||||
private http: HttpClient
|
||||
private http: HttpClient,
|
||||
private readonly customerCache: CustomerCacheService
|
||||
) {
|
||||
}
|
||||
|
||||
loadCustomers(): Observable<Customer[]> {
|
||||
return this.http.get<Customer[]>(this.customerURL);
|
||||
loadCustomers(filters?: string, useCache: boolean = false): Observable<Customer[]> {
|
||||
const cacheKey = filters || '';
|
||||
const params: any = {};
|
||||
if (filters) params.filters = filters;
|
||||
|
||||
if (useCache) {
|
||||
return this.customerCache.get(cacheKey).pipe(
|
||||
switchMap(cached => {
|
||||
if (cached !== null) return of(cached as Customer[]);
|
||||
return this.http.get<Customer[]>(this.customerURL, { params }).pipe(
|
||||
tap(data => this.customerCache.put(cacheKey, data))
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return this.http.get<Customer[]>(this.customerURL, { params }).pipe(
|
||||
tap(data => this.customerCache.put(cacheKey, data))
|
||||
);
|
||||
}
|
||||
|
||||
getCustomer(id: string, view?: string): Observable<Customer> {
|
||||
|
||||
@ -0,0 +1,31 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { BrowserCacheService } from './browser-cache.service';
|
||||
|
||||
const CACHE_NAME = 'agm-invoices-list-v1';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class InvoiceCacheService {
|
||||
|
||||
constructor(private readonly browserCache: BrowserCacheService) {}
|
||||
|
||||
getTtlMs(): number {
|
||||
return this.browserCache.getTtl(CACHE_NAME);
|
||||
}
|
||||
|
||||
setTtlMs(ttlMs: number): number {
|
||||
return this.browserCache.setTtl(CACHE_NAME, ttlMs);
|
||||
}
|
||||
|
||||
get(queryParams: string): Observable<any[] | null> {
|
||||
return this.browserCache.get<any[]>(CACHE_NAME, queryParams);
|
||||
}
|
||||
|
||||
put(queryParams: string, data: any[]): void {
|
||||
this.browserCache.put(CACHE_NAME, queryParams, data);
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.browserCache.invalidate(CACHE_NAME);
|
||||
}
|
||||
}
|
||||
@ -5,10 +5,11 @@ import { Observable, of } from 'rxjs';
|
||||
import { Client, Invoice } from '@app/invoices/models/invoice.model';
|
||||
import { CostingItem } from '@app/invoices/models/costing-item.model';
|
||||
import { CustomerInvoiceSetting } from '@app/invoices/models/customer-invoice-setting.model';
|
||||
import { catchError, map } from 'rxjs/operators';
|
||||
import { catchError, map, switchMap, tap } from 'rxjs/operators';
|
||||
import { AppMessageService } from '@app/shared/app-message.service';
|
||||
import { RouterUtilsService } from '@app/shared/router-utils.service';
|
||||
import { Utils } from '@app/shared/utils';
|
||||
import { InvoiceCacheService } from './invoice-cache.service';
|
||||
|
||||
@Injectable()
|
||||
export class InvoiceService {
|
||||
@ -28,7 +29,8 @@ export class InvoiceService {
|
||||
constructor(
|
||||
private http: HttpClient,
|
||||
private readonly appMsgSvc: AppMessageService,
|
||||
private readonly routerUtils: RouterUtilsService
|
||||
private readonly routerUtils: RouterUtilsService,
|
||||
private readonly invoiceCache: InvoiceCacheService
|
||||
) { }
|
||||
|
||||
// Setting
|
||||
@ -71,8 +73,25 @@ export class InvoiceService {
|
||||
}
|
||||
|
||||
// Invoice
|
||||
getInvoices(): Observable<Invoice[]> {
|
||||
return this.http.get<Invoice[]>(this.invoiceURL);
|
||||
getInvoices(filters?: string, useCache: boolean = false): Observable<Invoice[]> {
|
||||
const cacheKey = filters || '';
|
||||
const params: any = {};
|
||||
if (filters) params.filters = filters;
|
||||
|
||||
if (useCache) {
|
||||
return this.invoiceCache.get(cacheKey).pipe(
|
||||
switchMap(cached => {
|
||||
if (cached !== null) return of(cached as Invoice[]);
|
||||
return this.http.get<Invoice[]>(this.invoiceURL, { params }).pipe(
|
||||
tap(data => this.invoiceCache.put(cacheKey, data))
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return this.http.get<Invoice[]>(this.invoiceURL, { params }).pipe(
|
||||
tap(data => this.invoiceCache.put(cacheKey, data))
|
||||
);
|
||||
}
|
||||
|
||||
getInvoiceById(id): Observable<Invoice> {
|
||||
|
||||
@ -0,0 +1,41 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { BrowserCacheService } from './browser-cache.service';
|
||||
|
||||
const CACHE_NAME = 'agm-jobs-list-v1';
|
||||
|
||||
/**
|
||||
* Jobs-list-specific facade over {@link BrowserCacheService}.
|
||||
*
|
||||
* Encapsulates the cache name and TTL so callers (JobService, JobEffects)
|
||||
* don't need to know those details.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class JobCacheService {
|
||||
|
||||
constructor(private readonly browserCache: BrowserCacheService) {}
|
||||
|
||||
getTtlMs(): number {
|
||||
return this.browserCache.getTtl(CACHE_NAME);
|
||||
}
|
||||
|
||||
setTtlMs(ttlMs: number): number {
|
||||
return this.browserCache.setTtl(CACHE_NAME, ttlMs);
|
||||
}
|
||||
|
||||
/** Return cached jobs for the given query-param string, or null if stale/missing. */
|
||||
get(queryParams: string): Observable<any[] | null> {
|
||||
return this.browserCache.get<any[]>(CACHE_NAME, queryParams);
|
||||
}
|
||||
|
||||
/** Store a fresh jobs list for the given query-param string. */
|
||||
put(queryParams: string, data: any[]): void {
|
||||
this.browserCache.put(CACHE_NAME, queryParams, data);
|
||||
}
|
||||
|
||||
/** Invalidate all cached job-list entries (call after any job mutation). */
|
||||
invalidate(): void {
|
||||
this.browserCache.invalidate(CACHE_NAME);
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,11 +2,12 @@ import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { map, switchMap, tap } from 'rxjs/operators';
|
||||
|
||||
import { IJob, IUIJob, JobLog, RptOption, toJob } from '../../job/models/job.model';
|
||||
import { AppFile } from '../models/shared.model';
|
||||
import { UpdateJobOps } from '../../job/actions/job.actions';
|
||||
import { JobCacheService } from './job-cache.service';
|
||||
|
||||
@Injectable()
|
||||
export class JobService {
|
||||
@ -14,27 +15,55 @@ export class JobService {
|
||||
private readonly jobURL = '/jobs';
|
||||
|
||||
constructor(
|
||||
private http: HttpClient
|
||||
private http: HttpClient,
|
||||
private jobCache: JobCacheService
|
||||
) {
|
||||
}
|
||||
|
||||
loadJobs(ops: any): Observable<IJob[]> {
|
||||
let _ops = new HttpParams()
|
||||
.set('clientId', ops?.clientId || '')
|
||||
.set('jpo', ops?.jobsByPilot || 'false')
|
||||
.set('status', ops?.status || '');
|
||||
.set('jpo', ops?.jobsByPilot || 'false');
|
||||
|
||||
if (ops?.byTime?.length === 2) {
|
||||
for (const time of ops.byTime) {
|
||||
if (time) {
|
||||
_ops = _ops.append('byTime', time.toISOString());
|
||||
}
|
||||
}
|
||||
if (ops?.filters != null) {
|
||||
// Filter-submit path: all filtering is encoded in the filters param
|
||||
_ops = _ops.set('filters', ops.filters);
|
||||
} else {
|
||||
_ops = _ops.append('byTime', ops?.byTime[0] || '');
|
||||
// Legacy reload path: use individual params
|
||||
_ops = _ops
|
||||
.set('clientId', ops?.clientId || '')
|
||||
.set('status', ops?.status || '');
|
||||
if (ops?.byTime?.length === 2) {
|
||||
for (const time of ops.byTime) {
|
||||
if (time) {
|
||||
_ops = _ops.append('byTime', time.toISOString());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
_ops = _ops.append('byTime', ops?.byTime?.[0] || '');
|
||||
}
|
||||
}
|
||||
|
||||
return this.http.get<IJob[]>(this.jobURL, { params: _ops });
|
||||
const cacheKey = _ops.toString();
|
||||
|
||||
if (ops?.useCache) {
|
||||
return this.jobCache.get(cacheKey).pipe(
|
||||
switchMap(cached => {
|
||||
if (cached !== null) {
|
||||
return new Observable<IJob[]>(observer => {
|
||||
observer.next(cached as IJob[]);
|
||||
observer.complete();
|
||||
});
|
||||
}
|
||||
return this.http.get<IJob[]>(this.jobURL, { params: _ops }).pipe(
|
||||
tap(data => this.jobCache.put(cacheKey, data))
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return this.http.get<IJob[]>(this.jobURL, { params: _ops }).pipe(
|
||||
tap(data => this.jobCache.put(cacheKey, data))
|
||||
);
|
||||
}
|
||||
|
||||
getJob(id: number, withItems: boolean = false, withLines?: boolean): Observable<IUIJob> {
|
||||
|
||||
@ -0,0 +1,20 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ListReturnCacheService {
|
||||
|
||||
private storageKey(listKey: string): string {
|
||||
return `list-return-cache:${listKey}`;
|
||||
}
|
||||
|
||||
markPending(listKey: string): void {
|
||||
sessionStorage.setItem(this.storageKey(listKey), '1');
|
||||
}
|
||||
|
||||
startVisit(listKey: string): boolean {
|
||||
const storageKey = this.storageKey(listKey);
|
||||
const shouldUseCache = sessionStorage.getItem(storageKey) === '1';
|
||||
sessionStorage.removeItem(storageKey);
|
||||
return shouldUseCache;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
.ui-g-12.ui-sm-12.ui-md-12.ui-lg-10.ui-xl-10 {
|
||||
width: 100% !important;
|
||||
}
|
||||
@ -16,6 +16,22 @@
|
||||
<i class="ui-icon-search"></i>
|
||||
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, col.filterMatchMode)" [value]="dt.filters[col.field]?.value">
|
||||
</div>
|
||||
<p-dropdown *ngIf="col.field === 'color'" [options]="colorFilterOpts" [ngModel]="dt.filters[col.field]?.value" (onChange)="dt.filter($event.value, 'color', 'equals')">
|
||||
<ng-template let-item pTemplate="selectedItem">
|
||||
<div class="color-box" [ngStyle]="{ 'background-color': item.value }"></div>
|
||||
<span style="vertical-align:middle; margin-left: .5em">{{item.label}}</span>
|
||||
</ng-template>
|
||||
<ng-template let-item pTemplate="item">
|
||||
<div style="display:flex; align-items:center; justify-content:center; gap:.5em;">
|
||||
<div class="color-box" [ngStyle]="{ 'background-color': item.value }"></div>
|
||||
<span>{{item.label}}</span>
|
||||
</div>
|
||||
</ng-template>
|
||||
</p-dropdown>
|
||||
<div class="input-with-icon" *ngIf="col.field === 'desc'">
|
||||
<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>
|
||||
<span *ngSwitchDefault></span>
|
||||
</th>
|
||||
</tr>
|
||||
@ -65,9 +81,9 @@
|
||||
<span style="vertical-align:middle; margin-left: .5em">{{item.label}}</span>
|
||||
</ng-template>
|
||||
<ng-template let-item pTemplate="item">
|
||||
<div class="ui-helper-clearfix" style="position:relative;">
|
||||
<div class="color-box" style="margin-left:3px" [ngStyle]="{ 'background-color': item.value }"></div>
|
||||
<div style="float:right; margin-right: .15em;">{{item.label}}</div>
|
||||
<div style="display:flex; align-items:center; justify-content:center; gap:.5em;">
|
||||
<div class="color-box" [ngStyle]="{ 'background-color': item.value }"></div>
|
||||
<span>{{item.label}}</span>
|
||||
</div>
|
||||
</ng-template>
|
||||
</p-dropdown>
|
||||
|
||||
@ -34,6 +34,7 @@ export class CropListComponent extends BaseComp implements OnInit, AfterViewInit
|
||||
loading$ = this.store.select(fromEntity.getCropsLoading);
|
||||
|
||||
sprZoneColors: SelectItem[] = [...GC.selSprZoneColors];
|
||||
colorFilterOpts: SelectItem[] = [GC.selAll, ...GC.selSprZoneColors];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
@ -17,6 +17,10 @@
|
||||
<i class="ui-icon-search"></i>
|
||||
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, col.filterMatchMode)" [value]="dt.filters[col.field]?.value">
|
||||
</div>
|
||||
<div class="input-with-icon" *ngIf="col.field === 'address'">
|
||||
<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>
|
||||
<span *ngSwitchDefault></span>
|
||||
</th>
|
||||
</tr>
|
||||
|
||||
@ -17,6 +17,15 @@
|
||||
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, col.filterMatchMode)" [value]="dt.filters[col.field]?.value">
|
||||
</div>
|
||||
<p-dropdown *ngIf="col.field === 'type'" [options]="prodTypes" [ngModel]="dt.filters[col.field]?.value" (onChange)="dt.filter($event.value, 'type', 'equals')"></p-dropdown>
|
||||
<p-dropdown *ngIf="col.field === 'restricted'" [options]="restrictedOpts" [ngModel]="dt.filters[col.field]?.value" (onChange)="dt.filter($event.value, 'restricted', 'equals')"></p-dropdown>
|
||||
<div class="input-with-icon" *ngIf="col.field === 'rate'">
|
||||
<i class="ui-icon-search"></i>
|
||||
<input pInputText type="text" (input)="dt.filter($event.target.value, 'rateStr', 'contains')" [value]="dt.filters['rateStr']?.value">
|
||||
</div>
|
||||
<div class="input-with-icon" *ngIf="col.field === 'desc'">
|
||||
<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>
|
||||
<span *ngSwitchDefault></span>
|
||||
</th>
|
||||
</tr>
|
||||
|
||||
@ -30,6 +30,11 @@ export class ProductListComponent extends BaseComp implements OnInit, AfterViewI
|
||||
|
||||
prodTypes: SelectItem[] = [GC.selAll, ...GC.selProdTypes];
|
||||
prodTypes2: SelectItem[] = [...GC.selProdTypes];
|
||||
restrictedOpts: SelectItem[] = [
|
||||
{ label: globals.all, value: null },
|
||||
{ label: $localize`:@@yes:Yes`, value: true },
|
||||
{ label: $localize`:@@no:No`, value: false },
|
||||
];
|
||||
rateUnits: SelectItem[] = [
|
||||
{ label: 'oz/ac', value: 0 },
|
||||
{ label: 'gal/ac', value: 1 },
|
||||
@ -61,7 +66,7 @@ export class ProductListComponent extends BaseComp implements OnInit, AfterViewI
|
||||
ngOnInit() {
|
||||
this.sub$ = this.store.pipe(select(fromEntity.getAllProducts))
|
||||
.subscribe((items) => {
|
||||
this.products = items;
|
||||
this.products = items.map(p => ({ ...p, rateStr: this.getRate(p.rate) }));
|
||||
});
|
||||
|
||||
this.sub$.add(this.appActions.ofTypes([productActions.CREATE_SUCCESS, productActions.UPDATE_SUCCESS]).subscribe((action) => {
|
||||
|
||||
@ -30,7 +30,7 @@ export const getEntityState = createFeatureSelector<EntityState>(FEATURE_KEY);
|
||||
|
||||
export const getCropsState = createSelector(
|
||||
getEntityState,
|
||||
state => state.crops
|
||||
state => state ? state.crops : fromCrops.initialState
|
||||
)
|
||||
export const {
|
||||
selectIds: getCropIds,
|
||||
@ -44,7 +44,7 @@ export const getCropsLoading = createSelector(getCropsState, fromCrops.getIsLoad
|
||||
|
||||
export const getPilotsState = createSelector(
|
||||
getEntityState,
|
||||
state => state.pilots
|
||||
state => state ? state.pilots : fromPilots.initialState
|
||||
)
|
||||
export const {
|
||||
selectIds: getPilotIds,
|
||||
@ -56,7 +56,7 @@ export const {
|
||||
|
||||
export const getProductsState = createSelector(
|
||||
getEntityState,
|
||||
state => state.products
|
||||
state => state ? state.products : fromProducts.initialState
|
||||
)
|
||||
export const {
|
||||
selectIds: getProductIds,
|
||||
@ -68,7 +68,7 @@ export const {
|
||||
|
||||
export const getVehilesState = createSelector(
|
||||
getEntityState,
|
||||
state => state.vehicles
|
||||
state => state ? state.vehicles : fromVehicles.initialState
|
||||
)
|
||||
export const {
|
||||
selectIds: getVehicleIds,
|
||||
|
||||
@ -60,6 +60,31 @@
|
||||
[ngTemplateOutletContext]="{numOfVehicle: pkgLimit?.airCraft?.numOfVehicle || 0}"></ng-container>
|
||||
<p-dropdown *ngIf="col.field === VEHICLE_TYPE" [options]="acTypes" [ngModel]="dt.filters[col.field]?.value"
|
||||
(onChange)="dt.filter($event.value, VEHICLE_TYPE, 'equals')"></p-dropdown>
|
||||
<p-dropdown *ngIf="col.field === ACTIVE" [options]="activeOpts" [ngModel]="dt.filters[col.field]?.value"
|
||||
(onChange)="dt.filter($event.value, ACTIVE, 'equals')"></p-dropdown>
|
||||
<p-dropdown *ngIf="col.field === SOURCE_SYSTEM" [options]="sourceSystemOpts" [ngModel]="dt.filters[col.field]?.value"
|
||||
(onChange)="dt.filter($event.value, SOURCE_SYSTEM, 'equals')"></p-dropdown>
|
||||
<p-dropdown *ngIf="col.field === COLOR" [options]="colorFilterOpts" [ngModel]="dt.filters[col.field]?.value"
|
||||
(onChange)="dt.filter($event.value, COLOR, 'equals')">
|
||||
<ng-template let-item pTemplate="selectedItem">
|
||||
<div class="color-box" [ngStyle]="{ 'background-color': item.value }"></div>
|
||||
<span style="vertical-align:middle; margin-left: .5em">{{item.label}}</span>
|
||||
</ng-template>
|
||||
<ng-template let-item pTemplate="item">
|
||||
<div style="display:flex; align-items:center; justify-content:center; gap:.5em;">
|
||||
<div class="color-box" [ngStyle]="{ 'background-color': item.value }"></div>
|
||||
<span>{{item.label}}</span>
|
||||
</div>
|
||||
</ng-template>
|
||||
</p-dropdown>
|
||||
<div class="input-with-icon" *ngIf="col.field === MODEL">
|
||||
<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>
|
||||
<div class="input-with-icon" *ngIf="col.field === TRK_ON_DATE">
|
||||
<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>
|
||||
<span *ngSwitchDefault></span>
|
||||
</th>
|
||||
</tr>
|
||||
|
||||
@ -5,7 +5,7 @@ import { ConfirmationService, SelectItem } from 'primeng/api';
|
||||
import { Vehicle } from '../../models/vehicle.model';
|
||||
import * as vehicleActions from '../../actions/vehicle.actions';
|
||||
import * as fromEntity from '../../reducers';
|
||||
import { RoleIds, globals, vehTypes, VehType, SourceSystem, Labels } from '@app/shared/global';
|
||||
import { GC, RoleIds, globals, vehTypes, VehType, SourceSystem, Labels } from '@app/shared/global';
|
||||
import { DateUtils, Utils } from '@app/shared/utils';
|
||||
import { BaseComp } from '@app/shared/base/base.component';
|
||||
import { PartnerUtilsService } from '@app/shared/services/partner-utils.service';
|
||||
@ -59,6 +59,8 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
|
||||
@ViewChild('updateBtn') updateBtn: ElementRef;
|
||||
cols: any[] = [];
|
||||
acTypes: SelectItem[];
|
||||
activeOpts: SelectItem[];
|
||||
colorFilterOpts: SelectItem[];
|
||||
loading$ = this.store.select(fromEntity.getVehiclesLoading);
|
||||
trkLimit: Limit;
|
||||
pkgLimit: Limit;
|
||||
@ -141,6 +143,26 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
|
||||
{ label: vehTypes[VehType.FIXEDSWING], value: VehType.FIXEDSWING },
|
||||
{ label: vehTypes[VehType.HELICOPTER], value: VehType.HELICOPTER }
|
||||
];
|
||||
this.activeOpts = [
|
||||
{ label: globals.all, value: null },
|
||||
{ label: globals.active, value: true },
|
||||
{ label: globals.notActive, value: false },
|
||||
];
|
||||
this.colorFilterOpts = [GC.selAll, ...GC.selSprZoneColors];
|
||||
}
|
||||
|
||||
get sourceSystemOpts(): SelectItem[] {
|
||||
const seen = new Set<string>();
|
||||
const opts: SelectItem[] = [{ label: globals.all, value: null }];
|
||||
for (const v of (this.vehicles || [])) {
|
||||
const val = v.partnerSystem || SourceSystem.AGNAV;
|
||||
if (!seen.has(val)) {
|
||||
seen.add(val);
|
||||
const label = val === SourceSystem.AGNAV ? Labels.AGNAV_BRAND_NAME : val;
|
||||
opts.push({ label, value: val });
|
||||
}
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
@ -499,7 +521,7 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
|
||||
initVehList() {
|
||||
this.sub$ = this.store.select(fromEntity.getAllVehicles).pipe(
|
||||
map((vehicles) => {
|
||||
this.vehicles = vehicles;
|
||||
this.vehicles = vehicles.map(v => ({ ...v, sourceSystem: v.partnerInfo?.metadata?.partnerSystem || SourceSystem.AGNAV }));
|
||||
this.vehSelLastUpdated = this.createVehSelections(vehicles);
|
||||
this.vehiclesChanged = this.isVehSelChanged();
|
||||
|
||||
|
||||
@ -5,6 +5,7 @@ export const FETCH = '[INVOICES] Fetch invoices';
|
||||
|
||||
export class Fetch implements Action {
|
||||
type: typeof FETCH = FETCH;
|
||||
constructor(readonly payload?: { filters?: string; useCache?: boolean }) {}
|
||||
}
|
||||
|
||||
export const FETCH_SUCCESS = '[INVOICES] Fetch invoices success';
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
<p-table #ci [value]="costingItems" [columns]="cols" selectionMode="single" [paginator]="true" (firstChange)="restoreTableFirst()" (onPage)="onPageChange($event)" (onFilter)="restoreTableFirst()" [rows]="rows1Page[0]" [pageLinks]="5" [rowsPerPageOptions]="rows1Page" [alwaysShowPaginator]="true" stateStorage="session" stateKey="costingItem-ops" dataKey="_id" mutable="false" [responsive]="true" [resetPageOnSort]="false" [(selection)]="selectedItem">
|
||||
<ng-template pTemplate="caption">
|
||||
<div class="ui-g ui-g-nopad">
|
||||
<div class="ui-g-6 ui-g-nopad" style="text-align: left">
|
||||
<div class="ui-g-12 ui-g-nopad" style="text-align: center">
|
||||
<span class="table-caption-1" style="line-height: 1.35em;" i18n="@@costingItems">Costing Items</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -23,6 +23,7 @@
|
||||
<input pInputText type="text" (input)="ci.filter($event.target.value, col.field, col.filterMatchMode)" [value]="ci.filters[col.field]?.value">
|
||||
</div>
|
||||
<p-dropdown *ngIf="col.field === 'type'" [options]="costingItemTypeOpt" [ngModel]="ci.filters[col.field]?.value" (onChange)="ci.filter($event.value, 'type', 'equals')"></p-dropdown>
|
||||
<p-dropdown *ngIf="col.field === 'unit'" [options]="unitFilterOpts" [ngModel]="ci.filters[col.field]?.value" (onChange)="ci.filter($event.value, 'unit', 'equals')"></p-dropdown>
|
||||
<span *ngSwitchDefault></span>
|
||||
</th>
|
||||
</tr>
|
||||
|
||||
@ -35,6 +35,17 @@ export class CostingItemComponent extends BaseComp implements OnInit, OnDestroy
|
||||
costingTypes;
|
||||
costingItemTypeOpt;
|
||||
amountUnits;
|
||||
unitFilterOpts = [
|
||||
{ label: globals.all, value: null },
|
||||
{ label: 'acre', value: CostingItemUnit.ACRE },
|
||||
{ label: 'ha', value: CostingItemUnit.HA },
|
||||
{ label: 'oz', value: CostingItemUnit.OZ },
|
||||
{ label: 'gal', value: CostingItemUnit.GAL },
|
||||
{ label: 'lb', value: CostingItemUnit.LB },
|
||||
{ label: 'lit', value: CostingItemUnit.LIT },
|
||||
{ label: 'kg', value: CostingItemUnit.KG },
|
||||
{ label: 'hour', value: CostingItemUnit.HOUR },
|
||||
];
|
||||
currencyUnit;
|
||||
totalCostingItems;
|
||||
isNewItem = true;
|
||||
|
||||
@ -7,13 +7,15 @@ import { Action } from '@ngrx/store';
|
||||
import * as invoiceActions from '../actions/invoice.actions';
|
||||
import { catchError, map, switchMap } from 'rxjs/operators';
|
||||
import { globals } from '@app/shared/global';
|
||||
import { InvoiceCacheService } from '@app/domain/services/invoice-cache.service';
|
||||
|
||||
@Injectable()
|
||||
export class InvoiceEffects {
|
||||
constructor(
|
||||
private readonly actions$: Actions,
|
||||
private readonly invoiceSvc: InvoiceService,
|
||||
private readonly msgSvc: AppMessageService
|
||||
private readonly msgSvc: AppMessageService,
|
||||
private readonly invoiceCache: InvoiceCacheService
|
||||
) {
|
||||
}
|
||||
|
||||
@ -24,6 +26,7 @@ export class InvoiceEffects {
|
||||
const isNew = true;
|
||||
return this.invoiceSvc.saveInvoice(payload, isNew).pipe(
|
||||
map(invoice => {
|
||||
this.invoiceCache.invalidate();
|
||||
this.msgSvc.addSuccessMsg(globals.doThingsSuccess.replace('#do#', globals.create).replace('#thing#', globals.invoice));
|
||||
return new invoiceActions.CreateSuccess(invoice);
|
||||
}),
|
||||
@ -56,8 +59,8 @@ export class InvoiceEffects {
|
||||
@Effect()
|
||||
loadInvoice$: Observable<Action> = this.actions$.pipe(
|
||||
ofType<invoiceActions.Fetch>(invoiceActions.FETCH),
|
||||
switchMap(() => {
|
||||
return this.invoiceSvc.getInvoices().pipe(
|
||||
switchMap(({ payload }) => {
|
||||
return this.invoiceSvc.getInvoices(payload?.filters, payload?.useCache).pipe(
|
||||
map(res => {
|
||||
return new invoiceActions.FetchSuccess(res);
|
||||
}),
|
||||
@ -75,6 +78,7 @@ export class InvoiceEffects {
|
||||
switchMap(({ payload }) => {
|
||||
return this.invoiceSvc.deleteInvoice(payload).pipe(
|
||||
map((res: any[]) => {
|
||||
this.invoiceCache.invalidate();
|
||||
return new invoiceActions.DeleteSuccess(res?.map(i => i._id));
|
||||
}),
|
||||
catchError(err => {
|
||||
|
||||
@ -11,3 +11,87 @@
|
||||
border: 1px solid #4caf50;
|
||||
background-color: #4caf50;
|
||||
}
|
||||
|
||||
.cache-ttl-caption {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.cache-ttl-caption-title {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cache-ttl-caption-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.cache-ttl-help {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
vertical-align: middle;
|
||||
margin-left: 6px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.cache-ttl-help-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
font-weight: bold;
|
||||
cursor: help;
|
||||
color: #fff;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.cache-ttl-help-text {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
width: 220px;
|
||||
white-space: normal;
|
||||
padding: 8px 10px;
|
||||
border-radius: 4px;
|
||||
background: #323232;
|
||||
color: #fff;
|
||||
text-align: left;
|
||||
line-height: 1.35;
|
||||
font-size: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.cache-ttl-help:hover .cache-ttl-help-text,
|
||||
.cache-ttl-help:focus .cache-ttl-help-text,
|
||||
.cache-ttl-help:focus-within .cache-ttl-help-text {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.cache-ttl-caption-title,
|
||||
.cache-ttl-caption-controls {
|
||||
width: auto;
|
||||
float: none;
|
||||
}
|
||||
|
||||
.cache-ttl-caption-controls {
|
||||
padding-left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,11 +1,25 @@
|
||||
<div class="ui-g">
|
||||
<div class="ui-g-12">
|
||||
<div class="card clearfix">
|
||||
<p-accordion styleClass="agm-accordion" [style]="{'display':'block', 'margin-bottom':'0.75rem'}">
|
||||
<p-accordionTab i18n-header="@@searchInvoices" header="Search Invoices" [transitionOptions]="'250ms'" [selected]="searchAccordionOpen"
|
||||
(selectedChange)="searchAccordionOpen = $event; onAccordionToggle($event)">
|
||||
<agm-dynamic-filter [filterDefinitions]="invoiceFilterDefinitions" [locale]="locale" stateKey="invoices-list-filters" (filtersSubmit)="onFiltersSubmit($event)"></agm-dynamic-filter>
|
||||
</p-accordionTab>
|
||||
</p-accordion>
|
||||
<p-table #il [value]="invoices" [columns]="cols" (firstChange)="restoreTableFirst()" (onPage)="onPageChange($event)" (onFilter)="restoreTableFirst()" selectionMode="multiple" (onRowSelect)="onSelectInvoice($event)" (onRowUnselect)="onUnselectInvoice($event)" [paginator]="true" [rows]="rows1Page[0]" [pageLinks]="5" [rowsPerPageOptions]="rows1Page" [alwaysShowPaginator]="true" stateStorage="session" stateKey="inv-ops" dataKey="_id" mutable="false" [responsive]="true" [resetPageOnSort]="false" [(selection)]="selectedInvoice">
|
||||
<ng-template pTemplate="caption">
|
||||
<div class="ui-g ui-g-nopad">
|
||||
<div class="ui-g-6 ui-g-nopad text-left">
|
||||
<span class="table-caption-1" style="line-height: 1.35em;" i18n="@@invoiceList">Invoice List</span>
|
||||
<div class="ui-g ui-g-nopad cache-ttl-caption">
|
||||
<div class="ui-g-6 ui-sm-12 cache-ttl-caption-title">
|
||||
<span class="table-caption-1" style="display:block; text-align:left;" i18n="@@invoiceList">Invoice List</span>
|
||||
</div>
|
||||
<div class="ui-g-6 ui-sm-12 cache-ttl-caption-controls">
|
||||
<input pInputText type="number" min="0" step="1" placeholder="Cache TTL" [(ngModel)]="cacheTtlSeconds"
|
||||
(blur)="updateCacheTtl()" style="width: 3.5rem;">
|
||||
<span class="cache-ttl-help" tabindex="0">
|
||||
<span class="cache-ttl-help-icon">?</span>
|
||||
<span class="cache-ttl-help-text">Controls how long results stay cached after you return to this page. Value is in seconds.</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
@ -16,6 +16,9 @@ import { FilterUtils } from 'primeng/utils';
|
||||
import { DateUtils, Utils } from '@app/shared/utils';
|
||||
import { RestoreTableState } from '@app/shared/restore-table-state';
|
||||
import { GAService } from '@app/shared/ga.service';
|
||||
import { InvoiceCacheService } from '@app/domain/services/invoice-cache.service';
|
||||
import { ListReturnCacheService } from '@app/domain/services/list-return-cache.service';
|
||||
import { FilterDefinition, FilterChangeEvent } from '@app/shared/dynamic-filter/dynamic-filter.component';
|
||||
|
||||
@Component({
|
||||
selector: 'agm-invoices-list',
|
||||
@ -43,13 +46,23 @@ export class InvoicesListComponent extends BaseComp implements OnInit, OnDestroy
|
||||
|
||||
readonly invoiceStatus = invoiceStatus;
|
||||
|
||||
searchAccordionOpen = sessionStorage.getItem('invoices-list-accordion') === 'true';
|
||||
private lastFiltersQuery: Record<string, any> | undefined;
|
||||
private useCacheOnReturn = false;
|
||||
cacheTtlSeconds: number;
|
||||
|
||||
invoiceFilterDefinitions: FilterDefinition[];
|
||||
|
||||
constructor(
|
||||
private readonly route: ActivatedRoute,
|
||||
private readonly datePipe: DatePipe,
|
||||
private readonly invoiceSvc: InvoiceService,
|
||||
private readonly restoreTableSvc: RestoreTableState
|
||||
private readonly restoreTableSvc: RestoreTableState,
|
||||
private readonly invoiceCache: InvoiceCacheService,
|
||||
private readonly listReturnCache: ListReturnCacheService
|
||||
) {
|
||||
super();
|
||||
this.cacheTtlSeconds = Math.round(this.invoiceCache.getTtlMs() / 1000);
|
||||
this.totalInvoices = {
|
||||
'=0': '',
|
||||
'=1': $localize`:@@total#invoice:Total: # invoice`,
|
||||
@ -74,6 +87,14 @@ export class InvoicesListComponent extends BaseComp implements OnInit, OnDestroy
|
||||
];
|
||||
|
||||
this.statusFilter = [];
|
||||
|
||||
this.invoiceFilterDefinitions = [
|
||||
{ key: 'code', label: $localize`:@@invoiceNumber:Invoice Number`, dataType: 'text' },
|
||||
{ key: 'status', label: $localize`:@@status:Status`, dataType: 'select-multi', options: this.status },
|
||||
{ key: 'openDate', label: $localize`:@@openDate:Open Date`, dataType: 'date' },
|
||||
{ key: 'dueDate', label: $localize`:@@dueDate:Due Date`, dataType: 'date' },
|
||||
{ key: 'createdAt', label: $localize`:@@createdAt:Created Date`, dataType: 'date-preset' },
|
||||
];
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
@ -97,7 +118,19 @@ export class InvoicesListComponent extends BaseComp implements OnInit, OnDestroy
|
||||
});
|
||||
}
|
||||
});
|
||||
this.store.dispatch(new invoiceActions.Fetch());
|
||||
this.useCacheOnReturn = this.listReturnCache.startVisit('invoices');
|
||||
const savedFilters = sessionStorage.getItem('invoices-list-last-filters');
|
||||
if (savedFilters) {
|
||||
try {
|
||||
this.lastFiltersQuery = JSON.parse(savedFilters);
|
||||
} catch (_err) {
|
||||
this.lastFiltersQuery = undefined;
|
||||
}
|
||||
}
|
||||
this.store.dispatch(savedFilters
|
||||
? new invoiceActions.Fetch({ filters: savedFilters, useCache: this.useCacheOnReturn })
|
||||
: new invoiceActions.Fetch({ useCache: this.useCacheOnReturn })
|
||||
);
|
||||
FilterUtils[this.openDateFilter] = (value, filter): boolean => {
|
||||
if (filter === undefined || filter === null) {
|
||||
return true;
|
||||
@ -180,6 +213,28 @@ export class InvoicesListComponent extends BaseComp implements OnInit, OnDestroy
|
||||
this.restoreTableSvc.restoreTableFirst(this.dt);
|
||||
}
|
||||
|
||||
onAccordionToggle(expanded: boolean) {
|
||||
sessionStorage.setItem('invoices-list-accordion', String(expanded));
|
||||
}
|
||||
|
||||
updateCacheTtl(): void {
|
||||
const ttlMs = this.invoiceCache.setTtlMs(Number(this.cacheTtlSeconds || 0) * 1000);
|
||||
this.cacheTtlSeconds = Math.round(ttlMs / 1000);
|
||||
}
|
||||
|
||||
onFiltersSubmit(event: FilterChangeEvent) {
|
||||
const q = { ...event.query };
|
||||
const filtersStr = JSON.stringify(q);
|
||||
const prevFilters = sessionStorage.getItem('invoices-list-last-filters');
|
||||
if (filtersStr !== prevFilters) {
|
||||
this.invoiceCache.invalidate();
|
||||
this.useCacheOnReturn = false;
|
||||
}
|
||||
this.lastFiltersQuery = q;
|
||||
sessionStorage.setItem('invoices-list-last-filters', filtersStr);
|
||||
this.store.dispatch(new invoiceActions.Fetch({ filters: filtersStr, useCache: this.useCacheOnReturn }));
|
||||
}
|
||||
|
||||
onPageChange(e) {
|
||||
this.restoreTableSvc.onPageChange(this.dt, e);
|
||||
}
|
||||
@ -208,6 +263,7 @@ export class InvoicesListComponent extends BaseComp implements OnInit, OnDestroy
|
||||
|
||||
editInvoice(invoice: Invoice) {
|
||||
this.selectInvoice(invoice);
|
||||
this.listReturnCache.markPending('invoices');
|
||||
|
||||
// Track invoice selection
|
||||
this.gaSvc.trackInvoiceSelected({
|
||||
@ -225,6 +281,7 @@ export class InvoicesListComponent extends BaseComp implements OnInit, OnDestroy
|
||||
|
||||
viewInvoice(invoice: Invoice) {
|
||||
this.selectInvoice(invoice);
|
||||
this.listReturnCache.markPending('invoices');
|
||||
|
||||
// Track invoice selection
|
||||
this.gaSvc.trackInvoiceSelected({
|
||||
|
||||
@ -39,6 +39,7 @@ import { CurrencyNamePipe } from '@app/invoices/pipes/currency-name.pipe';
|
||||
import { ScrollPanelModule } from 'primeng/scrollpanel';
|
||||
import { CurrencyCodePositionPipe } from '@app/invoices/pipes/currency-code-position.pipe';
|
||||
import { InvoiceStatusPipe } from '@app/invoices/pipes/invoice-status.pipe';
|
||||
import { AccordionModule } from 'primeng/accordion';
|
||||
|
||||
|
||||
@NgModule({
|
||||
@ -66,6 +67,7 @@ import { InvoiceStatusPipe } from '@app/invoices/pipes/invoice-status.pipe';
|
||||
EffectsModule.forFeature([SettingEffects, InvoiceEffects, CostingItemEffects, JobEffects]),
|
||||
PanelModule,
|
||||
ScrollPanelModule,
|
||||
AccordionModule,
|
||||
],
|
||||
declarations: [InvoicesListComponent, InvoicesMgtComponent, SettingsComponent, CustomerSettingsListComponent, CustomerSettingsComponent, InvoiceEditComponent, CostingItemComponent, CostingItemTypePipe, CostingItemUnitPipe, CurrencyNamePipe, CurrencyCodePositionPipe, InvoiceStatusPipe, InvoiceDetailComponent],
|
||||
exports: [CostingItemTypePipe, CostingItemUnitPipe, CurrencyNamePipe, CurrencyCodePositionPipe, InvoiceStatusPipe],
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
<div class="ui-g ui-fluid" style="max-width: 1025px;">
|
||||
<div class="ui-g ui-fluid">
|
||||
<div class="ui-g-12">
|
||||
<div class="card card-w-title">
|
||||
<h1 i18n="@@invoiceSettings">Invoice Settings</h1>
|
||||
|
||||
@ -8,6 +8,7 @@ import { toJob } from '../models/job.model';
|
||||
import * as jobActions from '../actions/job.actions';
|
||||
|
||||
import { JobService } from '@app/domain/services/job.service';
|
||||
import { JobCacheService } from '@app/domain/services/job-cache.service';
|
||||
import { AppMessageService } from '@app/shared/app-message.service';
|
||||
|
||||
import { globals } from '@app/shared/global';
|
||||
@ -19,6 +20,7 @@ export class JobEffects {
|
||||
|
||||
private readonly actions$: Actions,
|
||||
private readonly jobSvc: JobService,
|
||||
private readonly jobCache: JobCacheService,
|
||||
private readonly msgSvc: AppMessageService,
|
||||
private readonly gaSvc: GAService
|
||||
) {
|
||||
@ -63,6 +65,7 @@ export class JobEffects {
|
||||
priority: 'medium' // Default priority
|
||||
});
|
||||
|
||||
this.jobCache.invalidate();
|
||||
return new jobActions.CreateSuccess(job);
|
||||
}),
|
||||
catchError(err => {
|
||||
@ -139,6 +142,7 @@ export class JobEffects {
|
||||
Math.floor((new Date().getTime() - new Date(payload.createdAt).getTime()) / (1000 * 60 * 60)) : 0
|
||||
});
|
||||
|
||||
this.jobCache.invalidate();
|
||||
return new jobActions.DeleteSuccess(payload)
|
||||
}),
|
||||
catchError(err => {
|
||||
|
||||
@ -8,14 +8,76 @@
|
||||
.inline-flex-end {
|
||||
display: inline-flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
:host ::ng-deep .ui-calendar input,
|
||||
:host ::ng-deep .ui-calendar .ui-datepicker-trigger {
|
||||
opacity: 0;
|
||||
height: 1px;
|
||||
width: 1px;
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
pointer-events: auto;
|
||||
.cache-ttl-caption-controls {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cache-ttl-help {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
vertical-align: middle;
|
||||
margin-right: 6px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.cache-ttl-help-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
font-weight: bold;
|
||||
cursor: help;
|
||||
color: #fff;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.cache-ttl-help-text {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
width: 220px;
|
||||
white-space: normal;
|
||||
padding: 8px 10px;
|
||||
border-radius: 4px;
|
||||
background: #323232;
|
||||
color: #fff;
|
||||
text-align: left;
|
||||
line-height: 1.35;
|
||||
font-size: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.cache-ttl-help:hover .cache-ttl-help-text,
|
||||
.cache-ttl-help:focus .cache-ttl-help-text,
|
||||
.cache-ttl-help:focus-within .cache-ttl-help-text {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
:host ::ng-deep .ui-fluid .ui-calendar {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.cache-ttl-caption-controls {
|
||||
width: auto;
|
||||
float: none;
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,13 @@
|
||||
<div class="ui-g">
|
||||
<div class="ui-g-12">
|
||||
<div class="card clearfix">
|
||||
<p-table #dt [value]="jobs" [columns]="cols" selectionMode="single" (firstChange)="restoreTableFirst()"
|
||||
<p-accordion styleClass="agm-accordion" [style]="{'display':'block', 'margin-bottom':'0.75rem'}">
|
||||
<p-accordionTab i18n-header="@@searchJobs" header="Search Jobs" [transitionOptions]="'250ms'" [selected]="searchAccordionOpen"
|
||||
(selectedChange)="searchAccordionOpen = $event; onAccordionToggle($event)">
|
||||
<agm-dynamic-filter [filterDefinitions]="jobFilterDefinitions" [locale]="locale" [defaultFilters]="defaultDynamicFilters" stateKey="job-list-filters" (filtersSubmit)="onFiltersSubmit($event)"></agm-dynamic-filter>
|
||||
</p-accordionTab>
|
||||
</p-accordion>
|
||||
<p-table #dt [value]="filteredJobs" [columns]="cols" selectionMode="single" (firstChange)="restoreTableFirst()"
|
||||
(onPage)="onPageChange($event)" (onFilter)="restoreTableFirst()" (onRowSelect)="onRowSelect($event)"
|
||||
(onRowUnselect)="onRowSelect($event)" [paginator]="true" [rows]="rows1Page[0]" [pageLinks]="5"
|
||||
[rowsPerPageOptions]="rows1Page" [alwaysShowPaginator]="true" [(selection)]="currentJob" stateStorage="session"
|
||||
@ -30,10 +36,19 @@
|
||||
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, col.filterMatchMode)"
|
||||
[value]="dt.filters[col.field]?.value">
|
||||
</div>
|
||||
<p-dropdown #cl *ngIf="col.field === 'client.name'" name="clients" [options]="clients" optionLabel="label"
|
||||
<p-dropdown #cl *ngIf="col.field === 'client.name' && !filterClientLocked" name="clients" [options]="clients" optionLabel="label"
|
||||
[ngModel]="currClient" filter="true" [emptyFilterMessage]="globals.emptyFilterMsg"></p-dropdown>
|
||||
<span *ngIf="col.field === 'client.name' && filterClientLocked">{{ currClient.label }}</span>
|
||||
<p-dropdown *ngIf="col.field === 'status'" [options]="status" [ngModel]="statusFilter"
|
||||
(onChange)="handleStatusFilter($event.value)"></p-dropdown>
|
||||
<p-calendar *ngIf="col.field === 'startDate'" [(ngModel)]="startDateFilter" [locale]="locale"
|
||||
[dateFormat]="locale.dateFormat" [showButtonBar]="true" [showIcon]="true" appendTo="body"
|
||||
(onSelect)="onDateFilter($event, 'startDate')" (onClearClick)="onDateFilter(null, 'startDate')"
|
||||
[style]="{'width':'100%'}" i18n-placeholder="@@filterDate" placeholder="Filter..."></p-calendar>
|
||||
<p-calendar *ngIf="col.field === 'endDate'" [(ngModel)]="endDateFilter" [locale]="locale"
|
||||
[dateFormat]="locale.dateFormat" [showButtonBar]="true" [showIcon]="true" appendTo="body"
|
||||
(onSelect)="onDateFilter($event, 'endDate')" (onClearClick)="onDateFilter(null, 'endDate')"
|
||||
[style]="{'width':'100%'}" i18n-placeholder="@@filterDate" placeholder="Filter..."></p-calendar>
|
||||
<span *ngSwitchDefault></span>
|
||||
</th>
|
||||
</tr>
|
||||
@ -86,28 +101,14 @@
|
||||
</div>
|
||||
|
||||
<ng-template #dropdowns>
|
||||
<div class="ui-g ui-g-6 ui-sm-12 ui-g-nopad">
|
||||
<div class="ui-g-8 ui-lg-8 ui-md-12 ui-sm-12 inline-flex-end">
|
||||
<div class="ui-g">
|
||||
<div class="ui-g-12">
|
||||
<span i18n="@@filtJobsByCreatedDate">Filter Jobs By Created Date</span>
|
||||
<p-calendar #calendar [(ngModel)]="selCalDate" selectionMode="range" [readonlyInput]="true"
|
||||
[showButtonBar]="true" [showIcon]="true" (onClose)="onCalClose()"></p-calendar>
|
||||
</div>
|
||||
</div>
|
||||
<p-dropdown [style]="dropdownStyle" [options]="dateOptions" [(ngModel)]="selDate"
|
||||
(onChange)="onDropdownChange($event)">
|
||||
<ng-template let-item pTemplate="item">
|
||||
<div class="ui-g">
|
||||
<div [ngClass]="isShowXBtn(item) ? 'ui-g-8' : 'ui-g-12'" class="ui-g-nopad">{{ item.label }}</div>
|
||||
<div *ngIf="isShowXBtn(item)" class="ui-g-4 ui-g-nopad" style="text-align: center;"><button
|
||||
style="border: unset; background: none; cursor: pointer;" class="pi pi-times"
|
||||
(click)="onCalClick()"></button></div>
|
||||
</div>
|
||||
</ng-template>
|
||||
</p-dropdown>
|
||||
</div>
|
||||
<div class="ui-g-4 ui-lg-4 ui-md-12 ui-sm-12 inline-flex-end">
|
||||
<div class="ui-g ui-g-6 ui-sm-12 ui-g-nopad cache-ttl-caption-controls">
|
||||
<div class="ui-g-12 inline-flex-end">
|
||||
<input pInputText type="number" min="0" step="1" placeholder="Cache TTL" [(ngModel)]="cacheTtlSeconds"
|
||||
(blur)="updateCacheTtl()" style="width: 3.5rem; margin-right: 6px;">
|
||||
<span class="cache-ttl-help" tabindex="0">
|
||||
<span class="cache-ttl-help-icon">?</span>
|
||||
<span class="cache-ttl-help-text">Controls how long results stay cached after you return to this page. Value is in seconds.</span>
|
||||
</span>
|
||||
<p-dropdown [options]="reloadOps" [style]="dropdownStyle" [(ngModel)]="reloadBy"
|
||||
(onChange)="reloadChanged($event.value)">
|
||||
</p-dropdown>
|
||||
|
||||
@ -6,6 +6,7 @@ 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';
|
||||
@ -26,8 +27,10 @@ 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 { SubscriptionService } from '@app/domain/services/subscription.service';
|
||||
import { GAService } from '@app/shared/ga.service';
|
||||
import { JobCacheService } from '@app/domain/services/job-cache.service';
|
||||
import { ListReturnCacheService } from '@app/domain/services/list-return-cache.service';
|
||||
import { FilterDefinition, FilterChangeEvent } from '@app/shared/dynamic-filter/dynamic-filter.component';
|
||||
|
||||
|
||||
@Component({
|
||||
@ -38,23 +41,48 @@ import { GAService } from '@app/shared/ga.service';
|
||||
export class JobListComponent extends BaseComp implements OnInit, AfterViewInit, OnDestroy {
|
||||
globals = globals;
|
||||
readonly dropdownStyle = { 'min-width': '170px', 'color': 'black' };
|
||||
readonly customeDate = 'customDate';
|
||||
|
||||
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;
|
||||
private useCacheOnReturn = false;
|
||||
cacheTtlSeconds: number;
|
||||
|
||||
jobFilterDefinitions: FilterDefinition[] = [];
|
||||
readonly defaultDynamicFilters = [
|
||||
...(!this.isClientUser ? [{ key: 'client', value: null }] : []),
|
||||
{ key: 'createdAt', value: '1m' }
|
||||
];
|
||||
|
||||
@ViewChild('dt') public dt: Table;
|
||||
@ViewChild('cl') public cl: Dropdown;
|
||||
@ViewChild('calendar') calendar: any;
|
||||
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;
|
||||
@ -63,13 +91,8 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
||||
totalJobs;
|
||||
|
||||
acre: Acre;
|
||||
dateOptions: {
|
||||
label: string;
|
||||
value: string;
|
||||
}[];
|
||||
selDate: string;
|
||||
|
||||
selCalDate: [Date, Date];
|
||||
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]);
|
||||
@ -85,11 +108,13 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
||||
private readonly datePipe: DatePipe,
|
||||
private readonly invoiceSvc: InvoiceService,
|
||||
private readonly restoreTableSvc: RestoreTableState,
|
||||
private readonly subscriptionService: SubscriptionService,
|
||||
private readonly gaService: GAService
|
||||
private readonly gaService: GAService,
|
||||
private readonly jobCache: JobCacheService,
|
||||
private readonly listReturnCache: ListReturnCacheService
|
||||
) {
|
||||
super();
|
||||
this.currClient = ({ label: globals.all, value: null });
|
||||
this.cacheTtlSeconds = Math.round(this.jobCache.getTtlMs() / 1000);
|
||||
this.totalJobs = { '=0': '', '=1': '1 ' + $localize`:@@job:job`.toLocaleLowerCase(), 'other': $localize`:@@total#Jobs:Total: # jobs` };
|
||||
|
||||
this.status = [
|
||||
@ -130,8 +155,26 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
||||
this.showStatusPlus = !this.authSvc.hasRole([RoleIds.CLIENT, RoleIds.INSPECTOR]);
|
||||
this.defaultInvoiceSetting = this.invoiceSvc.defaultSetting;
|
||||
|
||||
this.dateOptions = this.subscriptionService.getDateOptions();
|
||||
this.dateOptions.push({ label: $localize`:@@customDate:Custom Date`, value: this.customeDate });
|
||||
(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, removable: false },
|
||||
{ key: 'status', label: $localize`:@@status:Status`, dataType: 'select-multi' as const, options: GC.selJobStatuses },
|
||||
];
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
@ -144,6 +187,8 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
||||
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 => {
|
||||
@ -156,6 +201,7 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
||||
}
|
||||
})); 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;
|
||||
@ -177,6 +223,8 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
||||
this.acre = pkg[effectiveLookupKey]?.acre;
|
||||
}
|
||||
}));
|
||||
|
||||
this.useCacheOnReturn = this.listReturnCache.startVisit('jobs');
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
@ -190,32 +238,7 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
||||
const invoiced = listFilter.filters.invoiceStatus?.value;
|
||||
this.restoreStatusState(status, invoiced);
|
||||
}
|
||||
const storedDateSelection = sessionStorage.getItem('jobListSelDate');
|
||||
if (storedDateSelection) {
|
||||
const parsedDateSelection = JSON.parse(storedDateSelection);
|
||||
if (parsedDateSelection.selDate) {
|
||||
this.selDate = parsedDateSelection.selDate;
|
||||
} else {
|
||||
if (parsedDateSelection.selCalDate) {
|
||||
if (parsedDateSelection.selCalDate[0] && parsedDateSelection.selCalDate[1]) {
|
||||
this.selCalDate = [new Date(parsedDateSelection.selCalDate[0]), new Date(parsedDateSelection.selCalDate[1])];
|
||||
} else {
|
||||
this.selCalDate = [new Date(parsedDateSelection.selCalDate[0]), null];
|
||||
}
|
||||
}
|
||||
this.selDate = this.selCalDate ? this.customeDate : this.dateOptions[0].value;
|
||||
this.setCustomDateLabel();
|
||||
}
|
||||
}
|
||||
if (this.cl) {
|
||||
this.cl.registerOnChange((newVal) => {
|
||||
this.store.dispatch(new clientActions.Select(<Client>({ _id: newVal.value })));
|
||||
this.fetchJobsByClient(this.currClient.value);
|
||||
this.dt.first = 0;
|
||||
});
|
||||
}
|
||||
setTimeout(() => {
|
||||
this.fetchJobsByClient(this.currClient.value);
|
||||
if (this.dt.rows >= this.dt.totalRecords) {
|
||||
this.dt.first = 0;
|
||||
}
|
||||
@ -258,22 +281,34 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
||||
[jobListStatus.INVOICED]: jobInvoiceStatus.INVOICED
|
||||
};
|
||||
|
||||
const byTime =
|
||||
this.selDate
|
||||
? this.selDate == this.customeDate
|
||||
? this.selCalDate
|
||||
: [this.selDate]
|
||||
: [this.dateOptions[0].value];
|
||||
|
||||
const statusValue = statusMap[this.statusFilter] ?? jobListStatus.ALL;
|
||||
this.store.dispatch(new jobActions.Fetch({
|
||||
clientId: clientId,
|
||||
jobsByPilot: (this.authSvc.isPilotUser && this.settings.jobsByPilot),
|
||||
byTime,
|
||||
status: statusValue
|
||||
byTime: this.currentByTime,
|
||||
status: statusValue,
|
||||
useCache: this.useCacheOnReturn
|
||||
}));
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
updateCacheTtl(): void {
|
||||
const ttlMs = this.jobCache.setTtlMs(Number(this.cacheTtlSeconds || 0) * 1000);
|
||||
this.cacheTtlSeconds = Math.round(ttlMs / 1000);
|
||||
}
|
||||
|
||||
restoreTableFirst() {
|
||||
this.restoreTableSvc.restoreTableFirst(this.dt);
|
||||
}
|
||||
@ -327,6 +362,7 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
||||
|
||||
duplicateJob() {
|
||||
if (this.canAddNew) {
|
||||
this.listReturnCache.markPending('jobs');
|
||||
// Track bulk action (duplicate)
|
||||
this.gaService.trackJobBulkAction({
|
||||
user_id: this.authSvc.user?._id || 'anonymous',
|
||||
@ -343,10 +379,12 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
||||
}
|
||||
|
||||
editJob() {
|
||||
this.listReturnCache.markPending('jobs');
|
||||
this.router.navigate([`./${this.currentJob._id}/edit`], { relativeTo: this.route });
|
||||
}
|
||||
|
||||
editJobMap() {
|
||||
this.listReturnCache.markPending('jobs');
|
||||
this.router.navigate([`./${this.currentJob._id}/editMap`, { flag: 0 }], { relativeTo: this.route });
|
||||
}
|
||||
|
||||
@ -364,8 +402,18 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
||||
|
||||
reloadJobs() {
|
||||
const startTime = performance.now();
|
||||
this.jobCache.invalidate();
|
||||
this.useCacheOnReturn = false;
|
||||
|
||||
this.fetchJobsByClient(this.currClient && this.currClient.value);
|
||||
if (this.lastFiltersQuery) {
|
||||
this.store.dispatch(new jobActions.Fetch({
|
||||
jobsByPilot: (this.authSvc.isPilotUser && this.settings.jobsByPilot),
|
||||
filters: JSON.stringify(this.lastFiltersQuery),
|
||||
useCache: false
|
||||
}));
|
||||
} else {
|
||||
this.fetchJobsByClient(this.currClient && this.currClient.value);
|
||||
}
|
||||
|
||||
// Track job list reload
|
||||
setTimeout(() => {
|
||||
@ -421,6 +469,35 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
||||
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;
|
||||
|
||||
const filtersStr = JSON.stringify(q);
|
||||
const prevFilters = sessionStorage.getItem('job-list-last-filters');
|
||||
if (filtersStr !== prevFilters) {
|
||||
this.useCacheOnReturn = false;
|
||||
}
|
||||
|
||||
this.lastFiltersQuery = q;
|
||||
sessionStorage.setItem('job-list-last-filters', filtersStr);
|
||||
this.store.dispatch(new jobActions.Fetch({
|
||||
jobsByPilot: (this.authSvc.isPilotUser && this.settings.jobsByPilot),
|
||||
filters: filtersStr,
|
||||
useCache: this.useCacheOnReturn
|
||||
}));
|
||||
}
|
||||
|
||||
getUsers(byUsers) {
|
||||
if (!byUsers || !Array.isArray(byUsers) || byUsers.length === 0) {
|
||||
return '';
|
||||
@ -487,96 +564,6 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
||||
}, 100);
|
||||
}
|
||||
|
||||
private setJobListSelDate(dateSelection): void {
|
||||
sessionStorage.setItem('jobListSelDate', JSON.stringify(dateSelection));
|
||||
}
|
||||
|
||||
private setCustomDateLabel(): void {
|
||||
const dateFormat = this.locale.dateFormat.replace(/(^|\/)mm(\/|$)/g, '$1MM$2');
|
||||
|
||||
if (!this.selCalDate) {
|
||||
this.dateOptions.find(it => it.value === this.customeDate).label = $localize`:@@customDate:Custom Date`;
|
||||
} else if (!this.selCalDate[1]) {
|
||||
this.dateOptions.find(it => it.value === this.customeDate).label =
|
||||
`${this.datePipe.transform(this.selCalDate[0], dateFormat)}`;
|
||||
} else {
|
||||
this.dateOptions.find(it => it.value === this.customeDate).label =
|
||||
`${this.datePipe.transform(this.selCalDate[0], dateFormat)} - ${this.datePipe.transform(this.selCalDate[1], dateFormat)}`;
|
||||
}
|
||||
}
|
||||
|
||||
onDropdownChange(evt): void {
|
||||
const previousCount = this.jobs?.length || 0;
|
||||
|
||||
if (evt.value === this.customeDate) {
|
||||
setTimeout(() => this.showCal());
|
||||
} else {
|
||||
this.setJobListSelDate({ selDate: evt.value, selCalDate: null });
|
||||
this.reloadJobs();
|
||||
|
||||
// Track date filter usage
|
||||
setTimeout(() => {
|
||||
const currentCount = this.jobs?.length || 0;
|
||||
this.gaService.trackJobListFiltered({
|
||||
user_id: this.authSvc.user?._id || 'anonymous',
|
||||
platform: 'web',
|
||||
filter_type: 'date',
|
||||
filter_value: evt.value,
|
||||
results_before: previousCount,
|
||||
results_after: currentCount,
|
||||
filter_effectiveness: previousCount > 0 ? (currentCount / previousCount) : 0,
|
||||
date_filter_type: this.getDateFilterType(evt.value)
|
||||
});
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
onCalClose(): void {
|
||||
const previousCount = this.jobs?.length || 0;
|
||||
|
||||
this.setCustomDateLabel();
|
||||
if (this.selCalDate) {
|
||||
this.setJobListSelDate({ selDate: null, selCalDate: this.selCalDate });
|
||||
} else {
|
||||
this.selDate = this.dateOptions[0].value;
|
||||
this.setJobListSelDate({ selDate: this.selDate, selCalDate: null });
|
||||
}
|
||||
this.reloadJobs();
|
||||
|
||||
// Track custom date filter usage
|
||||
if (this.selCalDate) {
|
||||
setTimeout(() => {
|
||||
const currentCount = this.jobs?.length || 0;
|
||||
this.gaService.trackJobListFiltered({
|
||||
user_id: this.authSvc.user?._id || 'anonymous',
|
||||
platform: 'web',
|
||||
filter_type: 'date',
|
||||
filter_value: 'custom_date_range',
|
||||
results_before: previousCount,
|
||||
results_after: currentCount,
|
||||
filter_effectiveness: previousCount > 0 ? (currentCount / previousCount) : 0,
|
||||
date_filter_type: 'custom',
|
||||
custom_date_range: [
|
||||
this.selCalDate[0]?.toISOString().split('T')[0],
|
||||
this.selCalDate[1]?.toISOString().split('T')[0]
|
||||
]
|
||||
});
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
onCalClick() {
|
||||
setTimeout(() => this.showCal());
|
||||
}
|
||||
|
||||
showCal() {
|
||||
this.calendar?.el.nativeElement.querySelector('button')?.click();
|
||||
}
|
||||
|
||||
isShowXBtn(item) {
|
||||
return item?.value == this.customeDate && this.selDate == this.customeDate
|
||||
}
|
||||
|
||||
// Helper method to count active filters
|
||||
private getActiveFilterCount(): number {
|
||||
let count = 0;
|
||||
@ -592,7 +579,7 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
||||
}
|
||||
|
||||
// Check date filter
|
||||
if (this.selDate && this.selDate !== this.dateOptions[0]?.value) {
|
||||
if (this.currentByTime && this.currentByTime.length > 0) {
|
||||
count++;
|
||||
}
|
||||
|
||||
@ -609,16 +596,6 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
||||
return count;
|
||||
}
|
||||
|
||||
// Helper method to determine date filter type
|
||||
private getDateFilterType(value: string): 'today' | 'week' | 'month' | 'quarter' | 'custom' {
|
||||
if (value === this.customeDate) return 'custom';
|
||||
if (value?.includes('today')) return 'today';
|
||||
if (value?.includes('week')) return 'week';
|
||||
if (value?.includes('month')) return 'month';
|
||||
if (value?.includes('quarter')) return 'quarter';
|
||||
return 'custom';
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
super.ngOnDestroy();
|
||||
if (this.reload$) {
|
||||
|
||||
@ -0,0 +1,120 @@
|
||||
.buf-editor-panel {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
z-index: 1000;
|
||||
background: #fff;
|
||||
max-width: 50em;
|
||||
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.buf-editor-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
background: #f5f5f5;
|
||||
border-radius: 4px 4px 0 0;
|
||||
}
|
||||
|
||||
.buf-editor-title {
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.buf-editor-drag-handle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: grab;
|
||||
margin-right: 6px;
|
||||
color: #757575;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.buf-editor-drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.buf-editor-footer {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.buf-editor-body {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.buf-editor-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.buf-editor-name-label {
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.buf-editor-name-input.ui-inputtext {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
padding: 2px 4px !important;
|
||||
}
|
||||
|
||||
.buf-editor-controls-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.buf-editor-width-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.buf-editor-slider {
|
||||
width: 100px;
|
||||
cursor: pointer;
|
||||
accent-color: #388e3c;
|
||||
}
|
||||
|
||||
.buf-editor-width-input.ui-inputtext {
|
||||
width: 54px !important;
|
||||
text-align: right;
|
||||
padding: 2px 4px !important;
|
||||
font-size: 12px;
|
||||
margin: 0 2px 0 4px;
|
||||
}
|
||||
|
||||
.buf-editor-unit-label {
|
||||
font-size: 12px;
|
||||
color: #555;
|
||||
min-width: 16px;
|
||||
}
|
||||
|
||||
.buf-editor-icon-btn.ui-button {
|
||||
width: 28px !important;
|
||||
height: 28px !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.buf-editor-create-another-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@ -0,0 +1,65 @@
|
||||
<div *ngIf="active" class="buf-editor-panel leaflet-bar"
|
||||
[style.top.px]="panelTop" [style.left.px]="panelLeft"
|
||||
(mousedown)="$event.stopPropagation()" (dblclick)="$event.stopPropagation()"
|
||||
(wheel)="$event.stopPropagation()">
|
||||
|
||||
<!-- Toolbar / drag handle -->
|
||||
<div class="buf-editor-toolbar">
|
||||
<span class="buf-editor-drag-handle" (mousedown)="dragStart.emit($event)" title="Drag to move">
|
||||
<i class="ui-icon-drag-handle"></i>
|
||||
</span>
|
||||
<span class="buf-editor-title">{{ title }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Instruction text (before geometry is ready) -->
|
||||
<div *ngIf="!confirmReady && instructionText" class="buf-editor-body">
|
||||
<span>{{ instructionText }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Controls: name + width (always shown once panel is active) -->
|
||||
<div class="buf-editor-body buf-editor-name-row">
|
||||
<label class="buf-editor-name-label" i18n="@@name">Name</label>
|
||||
<input type="text" pInputText maxlength="20" class="buf-editor-name-input"
|
||||
[value]="name" (input)="nameChange.emit($any($event.target).value)">
|
||||
</div>
|
||||
|
||||
<div class="buf-editor-body buf-editor-controls-row">
|
||||
<div class="buf-editor-width-group">
|
||||
<label i18n="@@width">Width</label>
|
||||
<input type="range" min="1" max="100" step="1" [value]="widthSlider"
|
||||
class="buf-editor-slider"
|
||||
(input)="widthSliderChange.emit(+$any($event.target).value)">
|
||||
<input type="number" pInputText min="1" [max]="maxWidthInUnit" step="1" [value]="widthInUnit"
|
||||
class="buf-editor-width-input"
|
||||
(change)="widthInputChange.emit(+$any($event.target).value)">
|
||||
<span class="buf-editor-unit-label">{{ widthUnit }}</span>
|
||||
</div>
|
||||
<p-selectButton *ngIf="isEdge" [options]="edgeSideOptions" [ngModel]="edgeSide"
|
||||
[ngModelOptions]="{standalone: true}"
|
||||
(onChange)="edgeSideChange.emit($event.value)"></p-selectButton>
|
||||
</div>
|
||||
|
||||
<!-- Optional projected content (e.g. feature-type selector) -->
|
||||
<ng-content></ng-content>
|
||||
|
||||
<!-- Footer buttons -->
|
||||
<div class="buf-editor-footer">
|
||||
<button *ngIf="isEdge && canFlip" pButton type="button" icon="ui-icon-swap-horiz"
|
||||
class="ui-button-secondary buf-editor-icon-btn"
|
||||
i18n-pTooltip="@@flipDirection" pTooltip="Flip direction" tooltipPosition="top"
|
||||
(click)="flipClick.emit()"></button>
|
||||
<button *ngIf="confirmReady" pButton type="button" icon="ui-icon-check"
|
||||
class="green-btn buf-editor-icon-btn"
|
||||
i18n-pTooltip="@@confirm" pTooltip="Confirm" tooltipPosition="top"
|
||||
(click)="confirmClick.emit()"></button>
|
||||
<button pButton type="button" icon="ui-icon-cancel"
|
||||
class="orange-btn buf-editor-icon-btn"
|
||||
i18n-pTooltip="@@cancel" pTooltip="Cancel" tooltipPosition="top"
|
||||
(click)="cancelClick.emit()"></button>
|
||||
<label *ngIf="confirmReady && !isEditing" class="buf-editor-create-another-label">
|
||||
<input type="checkbox" [checked]="createAnother"
|
||||
(change)="createAnotherChange.emit($any($event.target).checked)">
|
||||
<span i18n="@@createAnother">Create another</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,45 @@
|
||||
import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { SelectItem } from 'primeng/api';
|
||||
|
||||
@Component({
|
||||
selector: 'app-buf-editor-panel',
|
||||
templateUrl: './buf-editor-panel.component.html',
|
||||
styleUrls: ['./buf-editor-panel.component.css'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class BufEditorPanelComponent {
|
||||
/** Whether the panel is visible. */
|
||||
@Input() active = false;
|
||||
/** Title shown in the toolbar. */
|
||||
@Input() title = 'Buffer Zone';
|
||||
/** True = edge-buffer mode (shows edge-side selector + flip button). */
|
||||
@Input() isEdge = false;
|
||||
/** True once the geometry is ready and Confirm should be shown. */
|
||||
@Input() confirmReady = false;
|
||||
/** Hides "Create another" when editing an existing buffer. */
|
||||
@Input() isEditing = false;
|
||||
/** Instruction text shown before confirmReady. */
|
||||
@Input() instructionText = '';
|
||||
|
||||
@Input() name = '';
|
||||
@Input() widthSlider = 0;
|
||||
@Input() widthInUnit = 30;
|
||||
@Input() maxWidthInUnit = 100;
|
||||
@Input() widthUnit: 'm' | 'ft' = 'm';
|
||||
@Input() edgeSideOptions: SelectItem[] = [];
|
||||
@Input() edgeSide = 'on';
|
||||
@Input() canFlip = false;
|
||||
@Input() createAnother = false;
|
||||
@Input() panelTop = 10;
|
||||
@Input() panelLeft = 10;
|
||||
|
||||
@Output() nameChange = new EventEmitter<string>();
|
||||
@Output() widthSliderChange = new EventEmitter<number>();
|
||||
@Output() widthInputChange = new EventEmitter<number>();
|
||||
@Output() edgeSideChange = new EventEmitter<string>();
|
||||
@Output() flipClick = new EventEmitter<void>();
|
||||
@Output() confirmClick = new EventEmitter<void>();
|
||||
@Output() cancelClick = new EventEmitter<void>();
|
||||
@Output() createAnotherChange = new EventEmitter<boolean>();
|
||||
@Output() dragStart = new EventEmitter<MouseEvent>();
|
||||
}
|
||||
@ -3,6 +3,25 @@
|
||||
margin-top : 1em;
|
||||
}
|
||||
|
||||
/* Projected content inside app-buf-editor-panel — must live here due to Angular view encapsulation */
|
||||
.buf-editor-feature-row {
|
||||
padding: 0 10px 10px 10px;
|
||||
gap: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.buf-editor-feature-row label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.buf-editor-feature-row select {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
.data-detail-box {
|
||||
border: 1px solid lightgray;
|
||||
}
|
||||
@ -42,3 +61,193 @@
|
||||
.loc-time {
|
||||
padding-top: .45em;
|
||||
}
|
||||
|
||||
/* Advanced Buffer Tools chooser panel */
|
||||
.adv-buf-chooser-panel {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.adv-buf-chooser-body {
|
||||
padding: 10px 12px 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.adv-buf-chooser-label {
|
||||
font-size: 12px;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.adv-buf-chooser-btns {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.adv-buf-type-btn.ui-button {
|
||||
width: 40px !important;
|
||||
height: 40px !important;
|
||||
padding: 0 !important;
|
||||
font-size: 20px !important;
|
||||
}
|
||||
|
||||
.adv-buf-type-btn.ui-button .ui-button-icon-left {
|
||||
font-size: 20px;
|
||||
margin-top: -10px;
|
||||
margin-left: -10px;
|
||||
}
|
||||
|
||||
.adv-buf-type-btn--disabled.ui-button {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Edge Buffer overlay panel – positioned inside the Leaflet map at top-left, matching Leaflet control pane */
|
||||
.edge-buf-panel {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
z-index: 1000;
|
||||
background: #fff;
|
||||
max-width: 50em;
|
||||
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.edge-buf-panel-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
background: #f5f5f5;
|
||||
border-radius: 4px 4px 0 0;
|
||||
}
|
||||
|
||||
.edge-buf-panel-title {
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.edge-buf-drag-handle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: grab;
|
||||
margin-right: 6px;
|
||||
color: #757575;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.edge-buf-drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.edge-buf-panel-footer {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.edge-buf-controls-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.edge-buf-width-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.edge-buf-slider {
|
||||
width: 100px;
|
||||
cursor: pointer;
|
||||
accent-color: #388e3c;
|
||||
}
|
||||
|
||||
.edge-buf-width-input.ui-inputtext {
|
||||
width: 54px !important;
|
||||
text-align: right;
|
||||
padding: 2px 4px !important;
|
||||
font-size: 12px;
|
||||
margin: 0 2px 0 4px;
|
||||
}
|
||||
|
||||
.edge-buf-unit-select {
|
||||
font-size: 12px;
|
||||
border: 1px solid #bdbdbd;
|
||||
border-radius: 3px;
|
||||
padding: 1px 2px;
|
||||
cursor: pointer;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.edge-buf-icon-btn.ui-button {
|
||||
width: 28px !important;
|
||||
height: 28px !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.edge-buf-panel-body {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.edge-buf-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.edge-buf-name-label {
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.edge-buf-name-input.ui-inputtext {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
padding: 2px 4px !important;
|
||||
}
|
||||
|
||||
.edge-buf-create-another-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Edge Buffer snap-to-edge drawing mode */
|
||||
.edge-buf-snap-marker {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: #fff;
|
||||
border: 2px solid #e65100;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.edge-buf-cursor-tip {
|
||||
position: fixed;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
color: #fff;
|
||||
border-radius: 4px;
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
|
||||
left: -9999px;
|
||||
top: -9999px;
|
||||
}
|
||||
|
||||
@ -71,7 +71,7 @@
|
||||
<ng-template pTemplate="paginatorleft" let-state>
|
||||
{{ state.totalRecords | i18nPlural: totalItems }}
|
||||
</ng-template>
|
||||
<ng-template pTemplate="emptymessage">
|
||||
<ng-template pTemplate="empty sage">
|
||||
<tr>
|
||||
<td [attr.colspan]="4">
|
||||
<div class="ui-messages-error" *ngIf="!hasItems()">
|
||||
@ -157,6 +157,171 @@
|
||||
</p-toolbar>
|
||||
</div>
|
||||
<div #map id="map" [style.height]="mapHeight" leaflet (leafletMapReady)="onMapReady($event)" [leafletOptions]="mapOps" [leafletLayers]="layers" [leafletLayersControl]="layersControl">
|
||||
<!-- Advanced Buffer Tools: Buffer Creation Mode chooser panel -->
|
||||
<div *ngIf="advBufPanelActive && !edgeBufActive && !segBufPanelActive && !featureBufActive && !featureBufPanelActive" class="edge-buf-panel leaflet-bar adv-buf-chooser-panel"
|
||||
[style.top.px]="edgeBufPanelTop" [style.left.px]="edgeBufPanelLeft"
|
||||
(mousedown)="$event.stopPropagation()" (dblclick)="$event.stopPropagation()"
|
||||
(wheel)="$event.stopPropagation()">
|
||||
<div class="edge-buf-panel-toolbar">
|
||||
<span class="edge-buf-drag-handle" (mousedown)="onEdgeBufPanelDragStart($event)" title="Drag to move">
|
||||
<i class="ui-icon-drag-handle"></i>
|
||||
</span>
|
||||
<span class="edge-buf-panel-title" i18n="@@advBufTitle">Buffer Creation Mode</span>
|
||||
</div>
|
||||
<div class="adv-buf-chooser-body">
|
||||
<span class="adv-buf-chooser-label" i18n="@@advBufSelectType">Please select a buffer type</span>
|
||||
<div class="adv-buf-chooser-btns">
|
||||
<button pButton type="button" icon="ui-icon-timeline"
|
||||
class="ui-button-secondary adv-buf-type-btn"
|
||||
pTooltip="Segment" tooltipPosition="bottom"
|
||||
(click)="onAdvBufSegment()"></button>
|
||||
<button pButton type="button" icon="ui-icon-border-style"
|
||||
class="ui-button-secondary adv-buf-type-btn"
|
||||
pTooltip="Edge" tooltipPosition="bottom"
|
||||
(click)="onAdvBufEdge()"></button>
|
||||
<button pButton type="button" icon="ui-icon-terrain"
|
||||
class="ui-button-secondary adv-buf-type-btn"
|
||||
pTooltip="Feature" tooltipPosition="bottom"
|
||||
(click)="onAdvBufFeature()"></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="edge-buf-panel-footer">
|
||||
<button pButton type="button" icon="ui-icon-cancel"
|
||||
class="orange-btn edge-buf-icon-btn"
|
||||
i18n-pTooltip="@@cancel" pTooltip="Cancel" tooltipPosition="top"
|
||||
(click)="closeAdvBuf()"></button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Segment Buffer Zone editor panel -->
|
||||
<app-buf-editor-panel
|
||||
[active]="segBufPanelActive"
|
||||
title="Segment Buffer Zone"
|
||||
[isEdge]="false"
|
||||
[confirmReady]="segBufConfirmReady"
|
||||
[isEditing]="false"
|
||||
[name]="segBufName"
|
||||
[widthSlider]="_bufWidthSlider"
|
||||
[widthInUnit]="bufWidthInUnit"
|
||||
[maxWidthInUnit]="maxBufInUnit"
|
||||
[widthUnit]="bufWidthUnit"
|
||||
[createAnother]="segBufCreateAnother"
|
||||
[panelTop]="edgeBufPanelTop"
|
||||
[panelLeft]="edgeBufPanelLeft"
|
||||
(nameChange)="segBufName = $event"
|
||||
(widthSliderChange)="onBufWidthSliderChange($event)"
|
||||
(widthInputChange)="onBufWidthInputChange($event)"
|
||||
(confirmClick)="confirmSegBuf()"
|
||||
(cancelClick)="cancelSegBuf()"
|
||||
(createAnotherChange)="segBufCreateAnother = $event"
|
||||
(dragStart)="onEdgeBufPanelDragStart($event)">
|
||||
</app-buf-editor-panel>
|
||||
<!-- Feature Buffer Zone editor panel (water bodies + schools via OpenStreetMap) -->
|
||||
<app-buf-editor-panel
|
||||
[active]="featureBufPanelActive || featureBufLoading"
|
||||
title="Feature Buffer Zone"
|
||||
[isEdge]="false"
|
||||
[confirmReady]="featureBufConfirmReady && !featureBufLoading"
|
||||
[isEditing]="false"
|
||||
[instructionText]="featureBufLoading ? 'Loading features from OpenStreetMap…' : (!featureBufConfirmReady ? 'No features found in the selected area.' : '')"
|
||||
[name]="featureBufName"
|
||||
[widthSlider]="_bufWidthSlider"
|
||||
[widthInUnit]="bufWidthInUnit"
|
||||
[maxWidthInUnit]="maxBufInUnit"
|
||||
[widthUnit]="bufWidthUnit"
|
||||
[createAnother]="featureBufCreateAnother"
|
||||
[panelTop]="edgeBufPanelTop"
|
||||
[panelLeft]="edgeBufPanelLeft"
|
||||
(nameChange)="featureBufName = $event"
|
||||
(widthSliderChange)="onBufWidthSliderChange($event)"
|
||||
(widthInputChange)="onBufWidthInputChange($event)"
|
||||
(confirmClick)="confirmFeatureBuf()"
|
||||
(cancelClick)="cancelFeatureBuf()"
|
||||
(createAnotherChange)="featureBufCreateAnother = $event"
|
||||
(dragStart)="onEdgeBufPanelDragStart($event)">
|
||||
<div class="buf-editor-feature-row">
|
||||
<label class="buf-editor-name-label">Feature</label>
|
||||
<select [value]="featureBufFeatureType"
|
||||
(change)="onFeatureBufFeatureTypeChange($any($event.target).value)">
|
||||
<option value="water">Water</option>
|
||||
<option value="schools">Schools</option>
|
||||
<option value="both">All</option>
|
||||
</select>
|
||||
</div>
|
||||
</app-buf-editor-panel>
|
||||
<!-- Edge Buffer Zone editor dialog (opened by clicking an existing buffer on the map) -->
|
||||
<p-dialog header="Edge Buffer Zone" [(visible)]="edgeBufDlgVisible"
|
||||
modal="true" [resizable]="false" [style]="{'width':'380px'}"
|
||||
[contentStyle]="{'overflow':'visible'}"
|
||||
styleClass="edge-buf-dialog"
|
||||
(onHide)="cancelEdgeBuf()">
|
||||
<div class="ui-g ui-g-fluid ui-g-nopad" style="margin-bottom: 16px">
|
||||
<div class="ui-g-12 ui-g-nopad">
|
||||
<div class="ui-g-4"><label i18n="@@name">Name</label></div>
|
||||
<div class="ui-g-8">
|
||||
<input type="text" pInputText maxlength="20"
|
||||
[value]="edgeBufName" (input)="edgeBufName = $any($event.target).value">
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui-g-12 ui-g-nopad">
|
||||
<div class="ui-g-4"><label i18n="@@width">Width</label></div>
|
||||
<div class="ui-g-8">
|
||||
<input type="number" pInputText min="1" [max]="maxBufInUnit" step="1"
|
||||
[value]="bufWidthInUnit" style="width:120px"
|
||||
(change)="onBufWidthInputChange(+$any($event.target).value)">
|
||||
<span> {{ bufWidthUnit }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui-g-12 ui-g-nopad">
|
||||
<div class="ui-g-4"><label i18n="@@edgeSide">Edge Side</label></div>
|
||||
<div class="ui-g-8">
|
||||
<p-selectButton [options]="edgeSideOptions" [ngModel]="bufEdgeSide"
|
||||
[ngModelOptions]="{standalone: true}"
|
||||
styleClass="edge-buf-side-btn"
|
||||
(onChange)="onEdgeSideChange($event.value)"></p-selectButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p-footer>
|
||||
<div class="ui-helper-clearfix">
|
||||
<button *ngIf="canFlipEdgeBuf" pButton type="button" icon="ui-icon-swap-horiz"
|
||||
class="ui-button-secondary" style="margin-right:4px"
|
||||
i18n-label="@@flipDirection" label="Flip"
|
||||
(click)="flipEdgeBufDirection()"></button>
|
||||
<button pButton type="button" icon="ui-icon-save"
|
||||
i18n-label="@@OK" label="OK"
|
||||
(click)="confirmEdgeBuf()"></button>
|
||||
</div>
|
||||
</p-footer>
|
||||
</p-dialog>
|
||||
<!-- Edge Buffer Zone editor panel (floating, used during toolbar-driven creation) -->
|
||||
<app-buf-editor-panel
|
||||
[active]="edgeBufActive"
|
||||
title="Edge Buffer Zone"
|
||||
[isEdge]="true"
|
||||
[confirmReady]="edgeBufConfirmReady"
|
||||
[isEditing]="edgeBufIsEditing"
|
||||
instructionText=""
|
||||
[name]="edgeBufName"
|
||||
[widthSlider]="_bufWidthSlider"
|
||||
[widthInUnit]="bufWidthInUnit"
|
||||
[maxWidthInUnit]="maxBufInUnit"
|
||||
[widthUnit]="bufWidthUnit"
|
||||
[edgeSideOptions]="edgeSideOptions"
|
||||
[edgeSide]="bufEdgeSide"
|
||||
[canFlip]="canFlipEdgeBuf"
|
||||
[createAnother]="edgeBufCreateAnother"
|
||||
[panelTop]="edgeBufPanelTop"
|
||||
[panelLeft]="edgeBufPanelLeft"
|
||||
(nameChange)="edgeBufName = $event"
|
||||
(widthSliderChange)="onBufWidthSliderChange($event)"
|
||||
(widthInputChange)="onBufWidthInputChange($event)"
|
||||
(edgeSideChange)="onEdgeSideChange($event)"
|
||||
(flipClick)="flipEdgeBufDirection()"
|
||||
(confirmClick)="confirmEdgeBuf()"
|
||||
(cancelClick)="cancelEdgeBuf()"
|
||||
(createAnotherChange)="edgeBufCreateAnother = $event"
|
||||
(dragStart)="onEdgeBufPanelDragStart($event)">
|
||||
</app-buf-editor-panel>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -212,8 +377,16 @@
|
||||
<label for="width" i18n="@@width">Width</label>
|
||||
</div>
|
||||
<div class="ui-g-8">
|
||||
<input id="width" name="width" type="number" [min]="minBuf" [max]="maxBuf" step="0.5" [(ngModel)]="curItem.width" pInputText pKeyFilter="pnum" style="width:120px">
|
||||
<span>{{ job.measureUnit | lengthUnit }}</span>
|
||||
<input id="width" name="width" type="number" [min]="minBuf" [max]="maxBuf" step="1" [(ngModel)]="curItem.width" pInputText pKeyFilter="pnum" style="width:120px">
|
||||
<span>{{ job.measureUnit | lengthUnit }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div *ngIf="curItem.type === ITEM.BUFFER && curItem.edgeSide != null" class="ui-g-12 ui-g-nopad">
|
||||
<div class="ui-g-4">
|
||||
<label i18n="@@edgeSide">Edge Side</label>
|
||||
</div>
|
||||
<div class="ui-g-8">
|
||||
<p-selectButton [options]="edgeSideOptions" [(ngModel)]="curItem.edgeSide" name="edgeSide"></p-selectButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -583,6 +756,8 @@
|
||||
</div>
|
||||
</p-dialog>
|
||||
|
||||
|
||||
|
||||
<p-dialog #gridGen position="topleft" showEffect="fade" [(visible)]="gridGenOn" header="" [resizable]="false" [closable]="false" [closeOnEscape]="false" [contentStyle]="{'overflow':'visible'}" [style]="{ 'width': '300px'}" [modal]="false">
|
||||
<div class="ui-g-12 ui-g-nopad">
|
||||
<p-toolbar>
|
||||
@ -627,6 +802,7 @@
|
||||
</div>
|
||||
</p-dialog>
|
||||
|
||||
|
||||
<p-dialog #playbackSpr position="topleft" showEffect="fade" [(visible)]="playbackOn" header="" [resizable]="false" [closable]="false" [closeOnEscape]="false" [contentStyle]="{'overflow':'visible'}" [style]="{ 'width': '360px' }" [modal]="false" (onShow)="onPlayDlgShow()">
|
||||
<div class="ui-g-12 ui-g-nopad">
|
||||
<p-toolbar>
|
||||
@ -850,7 +1026,6 @@
|
||||
<div class="ui-g-4 data-field field-name">Pilot Name</div>
|
||||
<div class="ui-g-8 data-field">{{curPlayRec.pilotName}}</div>
|
||||
<div class="ui-g-4 data-field field-name">Applic.Rate</div>
|
||||
<!-- <div class="ui-g-8 data-field">{{ curPlayRec.applicRate | number:'1.2-2':'en'}} {{ curPlayRec.applicRateUnit | rateUnit:null:false }}</div> -->
|
||||
<ng-container *ngIf="isPlayingAgNavFile; else PARTNERATE">
|
||||
<div class="ui-g-8 data-field">{{ curPlayRec.applicRate | number:'1.2-2':'en'}} {{ curPlayRec.applicRateUnit | rateUnit:2:false }}</div>
|
||||
</ng-container>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -23,11 +23,15 @@ import { TooltipModule } from 'primeng/tooltip';
|
||||
import { TabViewModule } from 'primeng/tabview';
|
||||
import { SliderModule } from 'primeng/slider';
|
||||
import { OrderListModule } from 'primeng/orderlist';
|
||||
import { AccordionModule } from 'primeng/accordion';
|
||||
import { SelectButtonModule } from 'primeng/selectbutton';
|
||||
|
||||
import { StoreModule } from '@ngrx/store';
|
||||
import { EffectsModule } from '@ngrx/effects';
|
||||
import * as fromJobs from './reducers/jobs.reducer';
|
||||
import { JobEffects } from './effects/job.effects';
|
||||
import * as fromClients from '../client/reducers/clients.reducer';
|
||||
import { ClientEffects } from '../client/effects/client.effects';
|
||||
|
||||
import { JobMgtComponent } from './job-mgt.component';
|
||||
import { AppSharedModule } from '../shared/app-shared.module';
|
||||
@ -35,6 +39,7 @@ import { JobListComponent } from './job-list/job-list.component';
|
||||
import { JobEditComponent } from './job-edit/job-edit.component';
|
||||
import { JobAssignmentComponent } from './job-assignment/job-assignment.component';
|
||||
import { JobMapEditComponent } from './job-map-edit/job-map-edit.component';
|
||||
import { BufEditorPanelComponent } from './job-map-edit/buf-editor-panel/buf-editor-panel.component';
|
||||
import { JobsRoutingModule } from './job-routing.module';
|
||||
import { InvoicesModule } from '@app/invoices/invoices.module';
|
||||
|
||||
@ -44,14 +49,15 @@ import { InvoicesModule } from '@app/invoices/invoices.module';
|
||||
LeafletModule,
|
||||
PaginatorModule, DialogModule, ConfirmDialogModule, ToastModule, MessagesModule,
|
||||
CheckboxModule, AutoCompleteModule, ToolbarModule, InputSwitchModule, SplitButtonModule,
|
||||
CalendarModule, FileUploadModule, PanelModule, ProgressSpinnerModule,
|
||||
PickListModule, TableModule, ToggleButtonModule, TooltipModule, TabViewModule, SliderModule, OrderListModule,
|
||||
CalendarModule, FileUploadModule, PanelModule, ProgressSpinnerModule, AccordionModule,
|
||||
PickListModule, TableModule, ToggleButtonModule, TooltipModule, TabViewModule, SliderModule, OrderListModule, SelectButtonModule,
|
||||
|
||||
JobsRoutingModule,
|
||||
StoreModule.forFeature(fromJobs.FEATURE_KEY, fromJobs.reducer),
|
||||
EffectsModule.forFeature([JobEffects]), InvoicesModule,
|
||||
StoreModule.forFeature(fromClients.FEATURE_KEY, fromClients.reducer),
|
||||
EffectsModule.forFeature([JobEffects, ClientEffects]), InvoicesModule,
|
||||
],
|
||||
declarations: [JobMgtComponent, JobListComponent, JobEditComponent, JobAssignmentComponent, JobMapEditComponent],
|
||||
declarations: [JobMgtComponent, JobListComponent, JobEditComponent, JobAssignmentComponent, JobMapEditComponent, BufEditorPanelComponent],
|
||||
providers: [DatePipe],
|
||||
schemas: [
|
||||
CUSTOM_ELEMENTS_SCHEMA
|
||||
|
||||
@ -29,6 +29,12 @@ export interface BufferZone {
|
||||
type: ITEM;
|
||||
name?: string;
|
||||
width: number;
|
||||
/** Computed display offset in metres (derived from edgeSide + width). */
|
||||
offset?: number;
|
||||
/** Which side of the traced edge the corridor occupies. */
|
||||
edgeSide?: 'on' | 'inside' | 'outside';
|
||||
/** Winding sign of the source polygon ring: +1 = CCW (left of travel = inside), -1 = CW. */
|
||||
edgeSign?: number;
|
||||
}
|
||||
|
||||
export interface MapFeature {
|
||||
|
||||
@ -23,6 +23,10 @@
|
||||
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, col.filterMatchMode)" [value]="dt.filters[col.field]?.value">
|
||||
</div>
|
||||
<p-dropdown *ngIf="col.field === 'active'" [options]="statuses" [style]="{'width':'100%'}" [ngModel]="dt.filters[col.field]?.value" (onChange)="dt.filter($event.value, col.field, 'equals')"></p-dropdown>
|
||||
<div class="input-with-icon" *ngIf="col.field === 'createdAt'">
|
||||
<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>
|
||||
<span *ngSwitchDefault></span>
|
||||
</th>
|
||||
</tr>
|
||||
|
||||
@ -1,3 +1,8 @@
|
||||
*:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.ui-g-12.ui-lg-10.ui-xl-8 {
|
||||
min-width: 19rem;
|
||||
width:100vw;
|
||||
}
|
||||
@ -1,22 +1,19 @@
|
||||
<div class="ui-g">
|
||||
<div class="ui-g-12 ui-lg-10 ui-xl-8" style="margin: auto;">
|
||||
<div class="ui-g" style="padding: 1em;">
|
||||
<h1 style="margin-bottom: 1em;" i18n="@@billingAddresses">Billing Addresses</h1>
|
||||
<div class="ui-g-12 ui-lg-10 ui-xl-8">
|
||||
<div class="ui-g">
|
||||
<div class="ui-g-12 card in-card-pad">
|
||||
<p class="large-font align-vertical" i18n="@@selBillingAddress">Select a billing address</p>
|
||||
<h1 class="large-font align-vertical" i18n="@@selBillingAddress" style="margin-bottom:1rem;">Billing Addresses</h1>
|
||||
<hr style="width: 100%;margin-bottom:1rem;" />
|
||||
|
||||
<div class="ui-g-12 card in-card-pad">
|
||||
<ng-container *ngIf="user.addresses?.length > 0">
|
||||
<ng-container *ngTemplateOutlet="header"></ng-container>
|
||||
<ng-container *ngTemplateOutlet="content"></ng-container>
|
||||
</ng-container>
|
||||
<button type="button" pButton icon="ui-icon-plus" i18n-label="@@addAdr" label="Add Address" (click)="add()"></button>
|
||||
<span class="ui-message ui-messages-error" style="width: 100%; font-size: 1em;">{{error}}</span>
|
||||
</div>
|
||||
<ng-container *ngIf="user.addresses?.length > 0">
|
||||
<ng-container *ngTemplateOutlet="header"></ng-container>
|
||||
<ng-container *ngTemplateOutlet="content"></ng-container>
|
||||
</ng-container>
|
||||
<span class="ui-message ui-messages-error" style="width: 100%; font-size: 1em;">{{error}}</span>
|
||||
|
||||
<hr style="width: 100%;" />
|
||||
|
||||
<div class="ui-g-12" style="text-align: right;">
|
||||
<div class="ui-g-12">
|
||||
<ng-container *ngTemplateOutlet="btn"></ng-container>
|
||||
</div>
|
||||
</div>
|
||||
@ -26,7 +23,7 @@
|
||||
|
||||
<ng-template #header>
|
||||
<div class="ui-g ui-g-nopad" style="justify-content: space-around;">
|
||||
<div class="ui-g-4 ui-sm-12 ui-g-nopad row-space"><strong><ng-container i18n="@@address">Address</ng-container></strong></div>
|
||||
<div class="ui-g-4 ui-sm-12 ui-g-nopad row-space"><strong><ng-container i18n="@@address">Select a billing address</ng-container></strong></div>
|
||||
<div class="ui-g-2 ui-sm-12 ui-g-nopad row-space"><strong><ng-container i18n="@@name">Name</ng-container></strong></div>
|
||||
<div class="ui-g-4 ui-sm-12 ui-g-nopad row-space"><strong><ng-container i18n="@@cityStateZip">City, State, Zip/Postal Code</ng-container></strong></div>
|
||||
</div>
|
||||
@ -55,9 +52,8 @@
|
||||
|
||||
<ng-template #btn>
|
||||
<button pButton type="button" i18n-label="@@back" label="Back" class="inline-space" (click)="gotoMySubs()"></button>
|
||||
<ng-container *ngIf="user.addresses?.length > 1">
|
||||
<button pButton type="button" [disabled]="!selectedAddress || selectedAddress.isBilling" [label]="SubTexts.labelChngBilAddr" (click)="changeBilAdr(selectedAddress)"></button>
|
||||
</ng-container>
|
||||
<button type="button" pButton icon="ui-icon-plus" i18n-label="@@addAdr" label="Add Address" (click)="add()"></button>
|
||||
<button *ngIf="user.addresses?.length > 1" style="margin-left: 0.5rem;" pButton type="button" [disabled]="!selectedAddress || selectedAddress.isBilling" [label]="SubTexts.labelChngBilAddr" (click)="changeBilAdr(selectedAddress)"></button>
|
||||
</ng-template>
|
||||
|
||||
<p-dialog [(visible)]="displayAddressDialog" [style]="{'width': '600px'}" [contentStyle]="{'overflow':'visible'}" resizable="false" modal="true">
|
||||
|
||||
@ -1,61 +1,67 @@
|
||||
<ng-container *ngIf="isCompLoaded(); else err">
|
||||
<div class="ui-g">
|
||||
<div class="ui-g-12 ui-md-11 ui-lg-10 ui-xl-8" style="margin: auto;;min-width: 564px">
|
||||
<div class="ui-g">
|
||||
<h1 style="margin-bottom: 1em;" i18n="@@pmtHist">Payment history</h1>
|
||||
<div class="ui-g ui-g-12">
|
||||
<div class="ui-g-12">
|
||||
<div class="ui-g-12">
|
||||
<div class="card clearfix">
|
||||
<div class="ui-g">
|
||||
<div class="ui-g-12 ui-md-11 ui-lg-10 ui-xl-8" style="margin: auto;">
|
||||
<div class="ui-g">
|
||||
<div class="ui-g-8"><ng-container i18n="@@pmtHistMsg">If you recently made a payment, please allow 24 hours for the payment to appear in the history.</ng-container></div>
|
||||
<div class="ui-g-4" style="display: flex; justify-content: end;">
|
||||
<p-dropdown [options]="options" [(ngModel)]="optKey" (onChange)="onDateChange($event)"></p-dropdown>
|
||||
<h1 style="margin-bottom: 1em;" i18n="@@pmtHist">Payment history</h1>
|
||||
<div class="ui-g ui-g-12">
|
||||
<div class="ui-g-12">
|
||||
<div class="ui-g">
|
||||
<div class="ui-g-8"><ng-container i18n="@@pmtHistMsg">If you recently made a payment, please allow 24 hours for the payment to appear in the history.</ng-container></div>
|
||||
<div class="ui-g-4" style="display: flex; justify-content: end;">
|
||||
<p-dropdown [options]="options" [(ngModel)]="optKey" (onChange)="onDateChange($event)"></p-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui-g-12">
|
||||
<p-table (sortFunction)="customSort($event)" [customSort]="true" [value]="payments" [columns]="cols" [paginator]="true" [responsive]="true" [rows]="10" [rowsPerPageOptions]="[5,10,20]" [sortField]="date" sortOrder="-1">
|
||||
<ng-template pTemplate="header">
|
||||
<tr>
|
||||
<th [pSortableColumn]="col.field" class="pm-history-header" *ngFor="let col of cols" [width]="col.width">
|
||||
{{col.header}}
|
||||
<p-sortIcon [field]="col.field"></p-sortIcon>
|
||||
</th>
|
||||
</tr>
|
||||
</ng-template>
|
||||
<ng-template pTemplate="body" let-rowData let-columns="columns">
|
||||
<tr>
|
||||
<td *ngFor="let col of columns" [ngSwitch]="col.field">
|
||||
<span class="ui-column-title">{{col.header}}</span>
|
||||
<span *ngSwitchCase="date">{{rowData[col.field] | tsToDate: lang}}</span>
|
||||
|
||||
<span *ngSwitchCase="TYPE">
|
||||
<span *ngIf="rowData.object === InvType.INVOICE" i18n="@@bill">Bill</span>
|
||||
<span *ngIf="rowData.object === InvType.CHARGE" i18n="@@refund">Refund</span>
|
||||
</span>
|
||||
|
||||
<span *ngSwitchCase="AMT_DUE">
|
||||
<ng-container *ngIf="rowData.object === InvType.INVOICE">
|
||||
{{rowData.amount_due | usCurrency}}
|
||||
</ng-container>
|
||||
<ng-container *ngIf="rowData.object === InvType.CHARGE">
|
||||
{{rowData.amount_refunded | usCurrency | creditCurrency}}
|
||||
</ng-container>
|
||||
</span>
|
||||
|
||||
<span *ngSwitchCase="AMT_PAID">
|
||||
<ng-container *ngIf="rowData.object === InvType.INVOICE">
|
||||
{{rowData.amount_paid | usCurrency}}
|
||||
</ng-container>
|
||||
<ng-container *ngIf="rowData.object === InvType.CHARGE">
|
||||
{{rowData.amount_refunded | usCurrency | creditCurrency}}
|
||||
</ng-container>
|
||||
</span>
|
||||
<span *ngSwitchCase="ACTIONS"><button (click)="gotoPaymentDetail(rowData)" pButton icon="ui-icon-zoom-in"></button></span>
|
||||
</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
</p-table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui-g-12">
|
||||
<p-table (sortFunction)="customSort($event)" [customSort]="true" [value]="payments" [columns]="cols" [paginator]="true" [responsive]="true" [rows]="10" [rowsPerPageOptions]="[5,10,20]" [sortField]="date" sortOrder="-1">
|
||||
<ng-template pTemplate="header">
|
||||
<tr>
|
||||
<th [pSortableColumn]="col.field" class="pm-history-header" *ngFor="let col of cols" [width]="col.width">
|
||||
{{col.header}}
|
||||
<p-sortIcon [field]="col.field"></p-sortIcon>
|
||||
</th>
|
||||
</tr>
|
||||
</ng-template>
|
||||
<ng-template pTemplate="body" let-rowData let-columns="columns">
|
||||
<tr>
|
||||
<td *ngFor="let col of columns" [ngSwitch]="col.field">
|
||||
<span class="ui-column-title">{{col.header}}</span>
|
||||
<span *ngSwitchCase="date">{{rowData[col.field] | tsToDate: lang}}</span>
|
||||
|
||||
<span *ngSwitchCase="TYPE">
|
||||
<span *ngIf="rowData.object === InvType.INVOICE" i18n="@@bill">Bill</span>
|
||||
<span *ngIf="rowData.object === InvType.CHARGE" i18n="@@refund">Refund</span>
|
||||
</span>
|
||||
|
||||
<span *ngSwitchCase="AMT_DUE">
|
||||
<ng-container *ngIf="rowData.object === InvType.INVOICE">
|
||||
{{rowData.amount_due | usCurrency}}
|
||||
</ng-container>
|
||||
<ng-container *ngIf="rowData.object === InvType.CHARGE">
|
||||
{{rowData.amount_refunded | usCurrency | creditCurrency}}
|
||||
</ng-container>
|
||||
</span>
|
||||
|
||||
<span *ngSwitchCase="AMT_PAID">
|
||||
<ng-container *ngIf="rowData.object === InvType.INVOICE">
|
||||
{{rowData.amount_paid | usCurrency}}
|
||||
</ng-container>
|
||||
<ng-container *ngIf="rowData.object === InvType.CHARGE">
|
||||
{{rowData.amount_refunded | usCurrency | creditCurrency}}
|
||||
</ng-container>
|
||||
</span>
|
||||
<span *ngSwitchCase="ACTIONS"><button (click)="gotoPaymentDetail(rowData)" pButton icon="ui-icon-zoom-in"></button></span>
|
||||
</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
</p-table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
<div class="ui-g" style="max-width: 1025px">
|
||||
<div class="ui-g">
|
||||
<div class="ui-g-12">
|
||||
<div class="card card-w-title">
|
||||
<h1>{{ isApplicator ? globals.applProfile : globals.userProfile }}</h1>
|
||||
|
||||
@ -0,0 +1,18 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { Routes, RouterModule } from '@angular/router';
|
||||
import { AuthGuard } from '../domain/guards/auth.guard';
|
||||
import { ReleaseNotesComponent } from './release-notes.component';
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: ReleaseNotesComponent,
|
||||
canActivate: [AuthGuard]
|
||||
}
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class ReleaseNotesRoutingModule { }
|
||||
@ -0,0 +1,212 @@
|
||||
.changelog-empty {
|
||||
padding: 2rem 1rem;
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.changelog-releases-layout {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
align-items: stretch;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.changelog-left-column {
|
||||
flex: 0 0 270px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
align-self: flex-start;
|
||||
position: sticky;
|
||||
top: 9.5vh;
|
||||
max-height: 100vh;
|
||||
overflow-y: auto;
|
||||
background: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.changelog-releases-wrapper {
|
||||
/* width controlled by parent column */
|
||||
}
|
||||
|
||||
.changelog-toc-wrapper {
|
||||
/* width controlled by parent column */
|
||||
}
|
||||
|
||||
/* Ensure PrimeNG p-panel custom elements stretch to full column width */
|
||||
:host ::ng-deep .changelog-left-column p-panel {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Make the release content panel fill the column and scroll only inside */
|
||||
:host ::ng-deep .changelog-releases-content p-panel,
|
||||
:host ::ng-deep .changelog-releases-content .ui-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
:host ::ng-deep .changelog-releases-content .ui-panel-content-wrapper {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:host ::ng-deep .changelog-releases-content .ui-panel-content {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Collapse/expand toggle button */
|
||||
.changelog-left-toggle {
|
||||
position: sticky;
|
||||
top: calc(50vh - 1rem);
|
||||
align-self: flex-start;
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.5rem;
|
||||
border: 0;
|
||||
background: #4CAF50;
|
||||
cursor: pointer;
|
||||
border-radius: 4px 0 0 4px;
|
||||
color: #ffffff;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.4rem 0;
|
||||
box-shadow: -2px 0 6px rgba(0, 0, 0, 0.35);
|
||||
transition: background 0.2s ease;
|
||||
z-index: 2;
|
||||
margin-left: -0.75rem;
|
||||
}
|
||||
|
||||
.changelog-left-toggle--hidden {
|
||||
margin-left: 0;
|
||||
border-radius: 0 4px 4px 0;
|
||||
box-shadow: 2px 0 6px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.changelog-left-toggle:hover {
|
||||
background: #2E7D32;
|
||||
}
|
||||
|
||||
/* Resize handle between left column and content */
|
||||
.changelog-left-resize {
|
||||
flex: 0 0 3px;
|
||||
cursor: col-resize;
|
||||
background: none;
|
||||
position: relative;
|
||||
align-self: stretch;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.changelog-left-resize::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 2px;
|
||||
right: 2px;
|
||||
background: transparent;
|
||||
border-radius: 3px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.changelog-left-resize,
|
||||
.changelog-left-resize {
|
||||
background: #8fa3bb;
|
||||
}
|
||||
|
||||
.changelog-releases-content {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #ffffff;
|
||||
height: 85vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.changelog-releases-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.changelog-releases-list li {
|
||||
padding-top: 0.35rem;
|
||||
padding-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.changelog-releases-item--active > a {
|
||||
font-weight: 700;
|
||||
color: #1a4f7a;
|
||||
}
|
||||
|
||||
.changelog-releases-latest-badge {
|
||||
display: inline-block;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
background: #2e7d32;
|
||||
color: #fff;
|
||||
border-radius: 3px;
|
||||
padding: 0 0.35rem;
|
||||
margin-left: 0.35rem;
|
||||
vertical-align: middle;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.changelog-releases-group {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.changelog-releases-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 0.3rem;
|
||||
font-weight: 600;
|
||||
color: #3a5068;
|
||||
text-decoration: none;
|
||||
padding: 0.35rem 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.changelog-releases-group-header:hover {
|
||||
color: #1a4f7a;
|
||||
}
|
||||
|
||||
.changelog-releases-group-icon {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.changelog-releases-group-list {
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.changelog-releases-layout {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.changelog-left-column {
|
||||
flex-basis: auto !important;
|
||||
max-width: none !important;
|
||||
width: 100%;
|
||||
position: static;
|
||||
max-height: none;
|
||||
overflow-y: visible;
|
||||
}
|
||||
|
||||
.changelog-left-toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.changelog-left-resize {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,112 @@
|
||||
<div class="ui-g">
|
||||
<div class="ui-g-12">
|
||||
<div class="card card-w-title">
|
||||
|
||||
<div *ngIf="loading" style="text-align:center; padding: 2rem;">
|
||||
<p-progressSpinner></p-progressSpinner>
|
||||
</div>
|
||||
|
||||
<div *ngIf="!loading && !sections.length" class="changelog-empty" i18n="@@noReleaseNotes">
|
||||
No release notes have been uploaded yet.
|
||||
</div>
|
||||
|
||||
<div *ngIf="!loading && sections.length" class="changelog-releases-layout">
|
||||
|
||||
<!-- Left column: Release Notes + TOC -->
|
||||
<div
|
||||
*ngIf="leftColumnVisible"
|
||||
class="changelog-left-column"
|
||||
[style.flex-basis.px]="leftColumnWidth"
|
||||
[style.max-width.px]="leftColumnWidth">
|
||||
|
||||
<div class="changelog-releases-wrapper">
|
||||
<p-panel header="Release Notes" i18n-header="@@releaseNotes" [toggleable]="true">
|
||||
<ul class="changelog-releases-list">
|
||||
|
||||
<!-- Latest release (top-level) -->
|
||||
<ng-container *ngIf="latestSectionIndex !== -1">
|
||||
<li [class.changelog-releases-item--active]="latestSectionIndex === activeSectionIndex">
|
||||
<a href="#" (click)="selectSection($event, latestSectionIndex)">
|
||||
{{ sections[latestSectionIndex].title }}
|
||||
<span class="changelog-releases-latest-badge" i18n="@@latest">Latest</span>
|
||||
</a>
|
||||
</li>
|
||||
</ng-container>
|
||||
|
||||
<!-- Previous Releases collapsible group -->
|
||||
<li *ngIf="sections.length > 1" class="changelog-releases-group">
|
||||
<a href="#" class="changelog-releases-group-header" (click)="togglePreviousReleases($event)">
|
||||
<span class="changelog-releases-group-icon">{{ previousReleasesExpanded ? '▾' : '▸' }}</span>
|
||||
<span i18n="@@previousReleases">Previous Releases</span>
|
||||
</a>
|
||||
<ul *ngIf="previousReleasesExpanded" class="changelog-releases-list changelog-releases-group-list">
|
||||
<li
|
||||
*ngFor="let section of sections; let i = index"
|
||||
[class.changelog-releases-item--active]="i === activeSectionIndex"
|
||||
[style.display]="i === latestSectionIndex ? 'none' : ''">
|
||||
<a href="#" (click)="selectSection($event, i)">{{ section.title }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<!-- No latest marked: show all flat -->
|
||||
<ng-container *ngIf="latestSectionIndex === -1">
|
||||
<li
|
||||
*ngFor="let section of sections; let i = index"
|
||||
[class.changelog-releases-item--active]="i === activeSectionIndex">
|
||||
<a href="#" (click)="selectSection($event, i)">{{ section.title }}</a>
|
||||
</li>
|
||||
</ng-container>
|
||||
|
||||
</ul>
|
||||
</p-panel>
|
||||
</div>
|
||||
|
||||
<!-- Table of Contents p-panel (populated from markdown-viewer output) -->
|
||||
<div *ngIf="tocItems.length" class="changelog-toc-wrapper">
|
||||
<p-panel header="Table of Contents" i18n-header="@@tableOfContents" [toggleable]="true">
|
||||
<ul class="changelog-releases-list">
|
||||
<li *ngFor="let item of tocItems">
|
||||
<a href="#" (click)="scrollToContent($event, item.anchorId)">{{ item.label }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</p-panel>
|
||||
</div>
|
||||
|
||||
</div><!-- end .changelog-left-column -->
|
||||
|
||||
<!-- Toggle button: always in the flex row, sticky at 50vh -->
|
||||
<button
|
||||
type="button"
|
||||
class="changelog-left-toggle"
|
||||
[class.changelog-left-toggle--hidden]="!leftColumnVisible"
|
||||
(click)="toggleLeftColumn()"
|
||||
[title]="leftColumnVisible ? 'Hide sidebar' : 'Show sidebar'">
|
||||
<span>{{ leftColumnVisible ? '❮' : '❯' }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Resize handle -->
|
||||
<div
|
||||
*ngIf="leftColumnVisible"
|
||||
class="changelog-left-resize"
|
||||
(mousedown)="onResizeStart($event)">
|
||||
</div>
|
||||
|
||||
<!-- Active release content -->
|
||||
<div class="changelog-releases-content" *ngIf="sections.length">
|
||||
<p-panel
|
||||
[header]="sections[activeSectionIndex].title"
|
||||
[toggleable]="true"
|
||||
styleClass="changelog-panel">
|
||||
<app-markdown-viewer
|
||||
[markdown]="activeMarkdown"
|
||||
[showFindBar]="true"
|
||||
(tocItemsChange)="tocItems = $event">
|
||||
</app-markdown-viewer>
|
||||
</p-panel>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,121 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Component, HostListener, OnInit, ViewChild } from '@angular/core';
|
||||
import { forkJoin } from 'rxjs';
|
||||
import { MarkdownViewerComponent } from '../shared/markdown-viewer/markdown-viewer.component';
|
||||
|
||||
interface ReleaseManifestEntry {
|
||||
fileName: string;
|
||||
title: string;
|
||||
ver: string;
|
||||
}
|
||||
|
||||
interface ReleaseSection {
|
||||
title: string;
|
||||
markdown: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-release-notes',
|
||||
templateUrl: './release-notes.component.html',
|
||||
styleUrls: ['./release-notes.component.css']
|
||||
})
|
||||
export class ReleaseNotesComponent implements OnInit {
|
||||
|
||||
@ViewChild(MarkdownViewerComponent) markdownViewer?: MarkdownViewerComponent;
|
||||
|
||||
private readonly releasesApiBase = '/releases';
|
||||
private readonly releasesStaticBase = '/releases';
|
||||
|
||||
sections: ReleaseSection[] = [];
|
||||
loading = false;
|
||||
activeSectionIndex = 0;
|
||||
latestSectionIndex = -1;
|
||||
previousReleasesExpanded = false;
|
||||
tocItems: { label: string; anchorId: string }[] = [];
|
||||
|
||||
leftColumnVisible = true;
|
||||
leftColumnWidth = 270;
|
||||
private isResizing = false;
|
||||
private resizeStartX = 0;
|
||||
private resizeStartWidth = 0;
|
||||
|
||||
constructor(private readonly http: HttpClient) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadManifest();
|
||||
}
|
||||
|
||||
get activeMarkdown(): string {
|
||||
return this.sections[this.activeSectionIndex]?.markdown ?? '';
|
||||
}
|
||||
|
||||
private loadManifest(): void {
|
||||
this.loading = true;
|
||||
this.http.get<ReleaseManifestEntry[]>(this.releasesApiBase).subscribe({
|
||||
next: (revisions) => {
|
||||
if (!revisions || revisions.length === 0) {
|
||||
this.loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const requests = revisions.map((r: ReleaseManifestEntry) =>
|
||||
this.http.get(`${this.releasesStaticBase}/${encodeURIComponent(r.fileName)}`, { responseType: 'text' })
|
||||
);
|
||||
|
||||
forkJoin(requests).subscribe({
|
||||
next: (markdownFiles: string[]) => {
|
||||
this.sections = markdownFiles.map((md: string, i: number) => ({
|
||||
title: revisions[i].title || revisions[i].fileName.replace(/\.md$/i, ''),
|
||||
markdown: md
|
||||
}));
|
||||
// Server returns entries sorted descending by ver; index 0 is always the latest.
|
||||
this.latestSectionIndex = revisions.length > 0 ? 0 : -1;
|
||||
this.activeSectionIndex = 0;
|
||||
this.loading = false;
|
||||
},
|
||||
error: () => { this.loading = false; }
|
||||
});
|
||||
},
|
||||
error: () => { this.loading = false; }
|
||||
});
|
||||
}
|
||||
|
||||
selectSection(event: MouseEvent, index: number): void {
|
||||
event.preventDefault();
|
||||
this.activeSectionIndex = index;
|
||||
}
|
||||
|
||||
scrollToContent(event: MouseEvent, anchorId: string): void {
|
||||
event.preventDefault();
|
||||
this.markdownViewer?.scrollToId(anchorId);
|
||||
}
|
||||
|
||||
togglePreviousReleases(event: MouseEvent): void {
|
||||
event.preventDefault();
|
||||
this.previousReleasesExpanded = !this.previousReleasesExpanded;
|
||||
}
|
||||
|
||||
toggleLeftColumn(): void {
|
||||
this.leftColumnVisible = !this.leftColumnVisible;
|
||||
}
|
||||
|
||||
onResizeStart(event: MouseEvent): void {
|
||||
this.isResizing = true;
|
||||
this.resizeStartX = event.clientX;
|
||||
this.resizeStartWidth = this.leftColumnWidth;
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
@HostListener('document:mousemove', ['$event'])
|
||||
onResizeMove(event: MouseEvent): void {
|
||||
if (this.isResizing) {
|
||||
const delta = event.clientX - this.resizeStartX;
|
||||
this.leftColumnWidth = Math.max(160, Math.min(480, this.resizeStartWidth + delta));
|
||||
}
|
||||
}
|
||||
|
||||
@HostListener('document:mouseup')
|
||||
onResizeEnd(): void {
|
||||
this.isResizing = false;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { PanelModule } from 'primeng/panel';
|
||||
import { ProgressSpinnerModule } from 'primeng/progressspinner';
|
||||
|
||||
import { AppSharedModule } from '../shared/app-shared.module';
|
||||
import { ReleaseNotesRoutingModule } from './release-notes-routing.module';
|
||||
import { ReleaseNotesComponent } from './release-notes.component';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
AppSharedModule,
|
||||
ReleaseNotesRoutingModule,
|
||||
PanelModule,
|
||||
ProgressSpinnerModule
|
||||
],
|
||||
declarations: [ReleaseNotesComponent]
|
||||
})
|
||||
export class ReleaseNotesModule { }
|
||||
@ -0,0 +1,79 @@
|
||||
import { createAction, props } from '@ngrx/store';
|
||||
import { ApiKey, CreateApiKeyRequest, CreateApiKeyResponse } from '../api-keys/models/api-key.model';
|
||||
|
||||
export const loadApiKeys = createAction(
|
||||
'[ApiKey] Load Keys',
|
||||
props<{ ownerId?: string }>()
|
||||
);
|
||||
|
||||
export const loadApiKeysSuccess = createAction(
|
||||
'[ApiKey] Load Keys Success',
|
||||
props<{ keys: ApiKey[] }>()
|
||||
);
|
||||
|
||||
export const loadApiKeysFailure = createAction(
|
||||
'[ApiKey] Load Keys Failure',
|
||||
props<{ error: string }>()
|
||||
);
|
||||
|
||||
export const createApiKey = createAction(
|
||||
'[ApiKey] Create Key',
|
||||
props<{ request: CreateApiKeyRequest }>()
|
||||
);
|
||||
|
||||
export const createApiKeySuccess = createAction(
|
||||
'[ApiKey] Create Key Success',
|
||||
props<{ response: CreateApiKeyResponse }>()
|
||||
);
|
||||
|
||||
export const createApiKeyFailure = createAction(
|
||||
'[ApiKey] Create Key Failure',
|
||||
props<{ error: string }>()
|
||||
);
|
||||
|
||||
export const revokeApiKey = createAction(
|
||||
'[ApiKey] Revoke Key',
|
||||
props<{ keyId: string; ownerId?: string }>()
|
||||
);
|
||||
|
||||
export const revokeApiKeySuccess = createAction(
|
||||
'[ApiKey] Revoke Key Success',
|
||||
props<{ keyId: string; ownerId?: string }>()
|
||||
);
|
||||
|
||||
export const revokeApiKeyFailure = createAction(
|
||||
'[ApiKey] Revoke Key Failure',
|
||||
props<{ error: string }>()
|
||||
);
|
||||
|
||||
export const dismissNewKey = createAction('[ApiKey] Dismiss New Key');
|
||||
|
||||
export const deleteApiKey = createAction(
|
||||
'[ApiKey] Delete Key',
|
||||
props<{ keyId: string; ownerId?: string }>()
|
||||
);
|
||||
|
||||
export const deleteApiKeySuccess = createAction(
|
||||
'[ApiKey] Delete Key Success',
|
||||
props<{ keyId: string; ownerId?: string }>()
|
||||
);
|
||||
|
||||
export const deleteApiKeyFailure = createAction(
|
||||
'[ApiKey] Delete Key Failure',
|
||||
props<{ error: string }>()
|
||||
);
|
||||
|
||||
export const regenerateApiKey = createAction(
|
||||
'[ApiKey] Regenerate Key',
|
||||
props<{ keyId: string; ownerId?: string }>()
|
||||
);
|
||||
|
||||
export const regenerateApiKeySuccess = createAction(
|
||||
'[ApiKey] Regenerate Key Success',
|
||||
props<{ response: CreateApiKeyResponse; ownerId?: string }>()
|
||||
);
|
||||
|
||||
export const regenerateApiKeyFailure = createAction(
|
||||
'[ApiKey] Regenerate Key Failure',
|
||||
props<{ error: string }>()
|
||||
);
|
||||
@ -0,0 +1,173 @@
|
||||
/* New Key Banner */
|
||||
.new-key-banner {
|
||||
background: #e8f5e9;
|
||||
border: 1px solid #A5D6A7;
|
||||
border-radius: 0.25rem;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.new-key-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.new-key-header i {
|
||||
font-size: 1.2rem;
|
||||
color: #2E7D32;
|
||||
}
|
||||
|
||||
.new-key-value-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.new-key-value {
|
||||
flex: 1 1 auto;
|
||||
background: #fff;
|
||||
border: 1px solid #A5D6A7;
|
||||
border-radius: 0.2rem;
|
||||
padding: 0.35rem 0.6rem;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.82rem;
|
||||
word-break: break-all;
|
||||
color: #2E7D32;
|
||||
}
|
||||
|
||||
/* Table */
|
||||
.revoked-row {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge-active {
|
||||
background: #4CAF50;
|
||||
border: 1px solid #2E7D32;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.badge-revoked {
|
||||
background: #f44336;
|
||||
border: 1px solid #d32f2f;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.empty-message {
|
||||
text-align: center;
|
||||
padding: 2rem 1rem;
|
||||
color: #777;
|
||||
}
|
||||
|
||||
/* Row expansion */
|
||||
.row-expansion > td {
|
||||
background: #f9f9f9;
|
||||
border-top: none;
|
||||
padding: 0.75rem 1rem 1rem 1rem;
|
||||
}
|
||||
|
||||
.expansion-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1.25rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.expansion-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 8.75rem;
|
||||
}
|
||||
|
||||
.expansion-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #888;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.expansion-value {
|
||||
font-size: 0.9rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.expansion-actions {
|
||||
justify-content: flex-end;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.expansion-grid {
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.expansion-item {
|
||||
flex-direction: row;
|
||||
align-items: baseline;
|
||||
flex: unset;
|
||||
min-width: unset;
|
||||
padding: 0.5em 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.expansion-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.expansion-label {
|
||||
display: inline-block;
|
||||
min-width: 40%;
|
||||
margin-bottom: 0;
|
||||
margin-right: 1em;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.expansion-value {
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
}
|
||||
}
|
||||
|
||||
/* Create dialog */
|
||||
.create-form {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.form-field {
|
||||
flex: 1;
|
||||
min-width: 12.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.form-field label {
|
||||
font-family: "Roboto", "Helvetica Neue", sans-serif;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: #757575;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
@ -0,0 +1,244 @@
|
||||
<div class="ui-g" *ngIf="!toggleable; else toggleablePanel">
|
||||
<div class="ui-g-12">
|
||||
<div class="card">
|
||||
<ng-container *ngTemplateOutlet="content"></ng-container>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ng-template #toggleablePanel>
|
||||
<p-panel i18n-header="@@apiKeys" header="API Keys"
|
||||
[toggleable]="true" [collapsed]="collapsed">
|
||||
<ng-container *ngTemplateOutlet="content"></ng-container>
|
||||
</p-panel>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #content>
|
||||
<p-messages *ngIf="error$ | async as err" severity="error">
|
||||
<ng-template pTemplate>{{ err }}</ng-template>
|
||||
</p-messages>
|
||||
|
||||
<!-- New key banner — shown after creation -->
|
||||
<div *ngIf="newKey" class="new-key-banner">
|
||||
<div class="new-key-header">
|
||||
<i class="ui-icon-vpn-key"></i>
|
||||
<span i18n="@@newKeyCreated">Key <strong>{{ newKey.label }}</strong><ng-container *ngIf="newKeyOwnerLabel"> for <strong>{{ newKeyOwnerLabel }}</strong></ng-container> created. Copy it now — it will not be shown again.</span>
|
||||
</div>
|
||||
<div class="new-key-value-row">
|
||||
<code class="new-key-value">{{ newKey.key }}</code>
|
||||
<button pButton type="button" icon="ui-icon-content-copy"
|
||||
[label]="keyCopied ? ('Copied!' | titlecase) : 'Copy'"
|
||||
[class]="keyCopied ? 'ui-button-secondary' : 'ui-button-primary'"
|
||||
(click)="copyKey()">
|
||||
</button>
|
||||
<button pButton type="button" icon="ui-icon-close"
|
||||
class="ui-button-secondary"
|
||||
i18n-label="@@dismiss" label="Dismiss"
|
||||
(click)="dismissNewKey()">
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dynamic filters (admin only) -->
|
||||
<p-accordion *ngIf="isAdmin && !isMasterAccount && !ownerId" styleClass="agm-accordion" [style]="{'display':'block', 'margin-bottom':'0.75rem'}">
|
||||
<p-accordionTab i18n-header="@@searchApiKeys" header="Search API Keys" [transitionOptions]="'250ms'"
|
||||
[selected]="filterAccordionOpen"
|
||||
(selectedChange)="filterAccordionOpen = $event; onAccordionToggle($event)">
|
||||
<agm-dynamic-filter
|
||||
[filterDefinitions]="filterDefinitions"
|
||||
[locale]="locale"
|
||||
[autoSaveOnChange]="true"
|
||||
stateKey="api-key-manager-filters"
|
||||
(filtersChanged)="onFiltersChanged($event)"
|
||||
(filtersSubmit)="onFiltersSubmit($event)">
|
||||
</agm-dynamic-filter>
|
||||
</p-accordionTab>
|
||||
</p-accordion>
|
||||
|
||||
<!-- Keys table -->
|
||||
<p-table #dt [value]="filteredKeys$ | async" [columns]="cols" [loading]="loading$ | async"
|
||||
[paginator]="true" [rows]="15" [pageLinks]="5" [rowsPerPageOptions]="[10, 15, 30]"
|
||||
[alwaysShowPaginator]="true" dataKey="_id" [responsive]="true"
|
||||
selectionMode="single" [(selection)]="selectedKey"
|
||||
[(expandedRowKeys)]="expandedRows"
|
||||
[resetPageOnSort]="false">
|
||||
|
||||
<ng-template pTemplate="caption">
|
||||
<span class="table-caption-1" i18n="@@apiKeys">API Keys</span>
|
||||
</ng-template>
|
||||
|
||||
<ng-template pTemplate="header" let-columns>
|
||||
<tr>
|
||||
<th style="width: 3rem;"></th>
|
||||
<th *ngFor="let col of columns" [pSortableColumn]="col.field">
|
||||
{{ col.header }}
|
||||
<p-sortIcon [field]="col.field"></p-sortIcon>
|
||||
</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th *ngFor="let col of columns" [ngSwitch]="col.field" class="ui-fluid">
|
||||
<div class="input-with-icon" *ngSwitchCase="'owner.name'">
|
||||
<i class="ui-icon-search"></i>
|
||||
<input pInputText type="text"
|
||||
(input)="dt.filter($event.target.value, col.field, col.filterMatchMode)"
|
||||
[value]="dt.filters[col.field]?.value || ''">
|
||||
</div>
|
||||
<div class="input-with-icon" *ngSwitchCase="'owner.username'">
|
||||
<i class="ui-icon-search"></i>
|
||||
<input pInputText type="text"
|
||||
(input)="dt.filter($event.target.value, col.field, col.filterMatchMode)"
|
||||
[value]="dt.filters[col.field]?.value || ''">
|
||||
</div>
|
||||
<div class="input-with-icon" *ngSwitchCase="'owner.contact'">
|
||||
<i class="ui-icon-search"></i>
|
||||
<input pInputText type="text"
|
||||
(input)="dt.filter($event.target.value, col.field, col.filterMatchMode)"
|
||||
[value]="dt.filters[col.field]?.value || ''">
|
||||
</div>
|
||||
<div class="input-with-icon" *ngSwitchCase="'label'">
|
||||
<i class="ui-icon-search"></i>
|
||||
<input pInputText type="text"
|
||||
(input)="dt.filter($event.target.value, col.field, col.filterMatchMode)"
|
||||
[value]="dt.filters[col.field]?.value || ''">
|
||||
</div>
|
||||
<div class="input-with-icon" *ngSwitchCase="'prefix'">
|
||||
<i class="ui-icon-search"></i>
|
||||
<input pInputText type="text"
|
||||
(input)="dt.filter($event.target.value, col.field, col.filterMatchMode)"
|
||||
[value]="dt.filters[col.field]?.value || ''">
|
||||
</div>
|
||||
<div class="input-with-icon" *ngSwitchCase="'service'">
|
||||
<i class="ui-icon-search"></i>
|
||||
<input pInputText type="text"
|
||||
(input)="dt.filter($event.target.value, col.field, col.filterMatchMode)"
|
||||
[value]="dt.filters[col.field]?.value || ''">
|
||||
</div>
|
||||
<p-dropdown *ngIf="col.field === 'active'" [options]="statusOptions" [style]="{'width':'100%'}"
|
||||
[ngModel]="dt.filters['active']?.value"
|
||||
(onChange)="dt.filter($event.value, 'active', 'equals')">
|
||||
</p-dropdown>
|
||||
<span *ngSwitchDefault></span>
|
||||
</th>
|
||||
</tr>
|
||||
</ng-template>
|
||||
|
||||
<ng-template pTemplate="body" let-key let-expanded="expanded">
|
||||
<tr [class.revoked-row]="!key.active" [pSelectableRow]="key">
|
||||
<td>
|
||||
<button pButton type="button"
|
||||
[icon]="expanded ? 'ui-icon-keyboard-arrow-up' : 'ui-icon-keyboard-arrow-down'"
|
||||
class="ui-button-text ui-button-plain"
|
||||
[pRowToggler]="key">
|
||||
</button>
|
||||
</td>
|
||||
<td *ngIf="isAdmin && !ownerId"><span class="ui-column-title" i18n="@@name">Name</span>{{ key.owner?.name || '—' }}</td>
|
||||
<td *ngIf="isAdmin && !ownerId"><span class="ui-column-title" i18n="@@userName">Username</span>{{ key.owner?.username || '—' }}</td>
|
||||
<td *ngIf="isAdmin && !ownerId"><span class="ui-column-title" i18n="@@contact">Contact</span>{{ key.owner?.contact || '—' }}</td>
|
||||
<td><span class="ui-column-title" i18n="@@label">Label</span>{{ key.label }}<ng-container *ngIf="!isAdmin || ownerId"> <span [class]="key.active ? 'badge badge-active' : 'badge badge-revoked'">{{ key.active ? 'Active' : 'Revoked' }}</span></ng-container></td>
|
||||
<td><span class="ui-column-title" i18n="@@prefix">Prefix</span><code>{{ key.prefix }}…</code></td>
|
||||
<td><span class="ui-column-title" i18n="@@service">Service</span>{{ serviceLabels[key.service] || key.service }}</td>
|
||||
<td *ngIf="isAdmin && !ownerId"><span class="ui-column-title" i18n="@@status">Status</span><span [class]="key.active ? 'badge badge-active' : 'badge badge-revoked'">{{ key.active ? 'Active' : 'Revoked' }}</span></td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
|
||||
<ng-template pTemplate="rowexpansion" let-key let-columns="columns">
|
||||
<tr class="row-expansion">
|
||||
<td [attr.colspan]="cols.length + 1">
|
||||
<div class="expansion-grid">
|
||||
<div class="expansion-item">
|
||||
<span class="expansion-label" i18n="@@service">Service</span>
|
||||
<span class="expansion-value">{{ serviceLabels[key.service] || key.service }}</span>
|
||||
</div>
|
||||
<div class="expansion-item">
|
||||
<span class="expansion-label" i18n="@@createdDate">Created Date</span>
|
||||
<span class="expansion-value">{{ key.createdAt | date:'short' }}</span>
|
||||
</div>
|
||||
<div class="expansion-item">
|
||||
<span class="expansion-label" i18n="@@lastUsed">Last Used</span>
|
||||
<span class="expansion-value">{{ key.lastUsedAt ? (key.lastUsedAt | date:'short') : '—' }}</span>
|
||||
</div>
|
||||
<div class="expansion-item">
|
||||
<span class="expansion-label" i18n="@@requests">Requests</span>
|
||||
<span class="expansion-value">{{ key.requestCount != null ? (key.requestCount | number) : 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
|
||||
<ng-template pTemplate="emptymessage">
|
||||
<tr>
|
||||
<td [attr.colspan]="cols.length + 1" class="empty-message">
|
||||
<span i18n="@@noApiKeys">No API keys yet. Click <strong>Generate Key</strong> to create one.</span>
|
||||
</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
</p-table>
|
||||
<div class="ui-widget-header ui-helper-clearfix toolbar">
|
||||
<button type="button" pButton icon="ui-icon-add"
|
||||
i18n-label="@@new" label="New"
|
||||
(click)="openNewDialog()">
|
||||
</button>
|
||||
<button type="button" pButton icon="ui-icon-refresh"
|
||||
[disabled]="!keys.length || !selectedKey"
|
||||
i18n-label="@@regenerateKey" label="Regenerate"
|
||||
(click)="confirmRegenerate(selectedKey)">
|
||||
</button>
|
||||
<button *ngIf="isAdmin" type="button" pButton icon="ui-icon-block"
|
||||
[disabled]="!keys.length || !selectedKey || !selectedKey.active"
|
||||
i18n-label="@@revokeKey" label="Revoke"
|
||||
(click)="confirmRevoke(selectedKey)">
|
||||
</button>
|
||||
<button type="button" pButton icon="ui-icon-trash"
|
||||
[disabled]="!keys.length || !selectedKey"
|
||||
i18n-label="@@deleteKey" label="Delete"
|
||||
(click)="confirmDelete(selectedKey)">
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Generate Key dialog -->
|
||||
<p-dialog i18n-header="@@generateKey" header="Generate Key"
|
||||
[(visible)]="showNewDialog" [modal]="true" [responsive]="true"
|
||||
[style]="{'width':'480px'}" [closable]="true">
|
||||
<div class="create-form">
|
||||
<div class="form-row">
|
||||
<div *ngIf="isAdmin && !ownerId" class="form-field">
|
||||
<label i18n="@@customer">Customer</label>
|
||||
<p-dropdown [options]="customerOptions" [(ngModel)]="newKeyOwnerId"
|
||||
i18n-placeholder="@@selectCustomer" placeholder="Select a customer..."
|
||||
[filter]="true" filterBy="label" appendTo="body"
|
||||
[style]="{'width':'100%'}">
|
||||
</p-dropdown>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label i18n="@@service">Service</label>
|
||||
<p-dropdown [options]="serviceOptions" [(ngModel)]="newService"
|
||||
[style]="{'width':'100%'}">
|
||||
</p-dropdown>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label for="keyLabel" i18n="@@keyLabel">Label</label>
|
||||
<input id="keyLabel" pInputText type="text" [(ngModel)]="newKeyLabel"
|
||||
i18n-placeholder="@@keyLabelPlaceholder" placeholder="e.g. Power BI connector"
|
||||
class="full-width" maxlength="100" (keydown.enter)="submitCreate()">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p-footer>
|
||||
<button pButton type="button" icon="ui-icon-add"
|
||||
i18n-label="@@generate" label="Generate"
|
||||
[disabled]="!newKeyLabel.trim() || (isAdmin && !ownerId && !newKeyOwnerId)"
|
||||
(click)="submitCreate()">
|
||||
</button>
|
||||
<button pButton type="button" icon="ui-icon-close"
|
||||
class="ui-button-secondary"
|
||||
i18n-label="@@cancel" label="Cancel"
|
||||
(click)="showNewDialog = false">
|
||||
</button>
|
||||
</p-footer>
|
||||
</p-dialog>
|
||||
</ng-template>
|
||||
|
||||
<p-toast key="apiKeyToast" position="bottom-center" life="3000"></p-toast>
|
||||
<p-confirmDialog [style]="{ width: '420px' }"></p-confirmDialog>
|
||||
@ -0,0 +1,314 @@
|
||||
import { Component, OnInit, OnDestroy, OnChanges, SimpleChanges, Input, ViewChild } from '@angular/core';
|
||||
import { Observable, Subject, BehaviorSubject, combineLatest } from 'rxjs';
|
||||
import { takeUntil, map } from 'rxjs/operators';
|
||||
import { Table } from 'primeng/table';
|
||||
|
||||
import { ApiKey, CreateApiKeyResponse } from '../models/api-key.model';
|
||||
import { ApiKeyState, FEATURE_KEY } from '../../reducers';
|
||||
import * as ApiKeyActions from '../../actions/api-key.actions';
|
||||
import { BaseComp } from '@app/shared/base/base.component';
|
||||
import { RoleIds } from '@app/shared/global';
|
||||
import { CustomerService } from '@app/domain/services/customer.service';
|
||||
import { FilterDefinition, FilterChangeEvent, ActiveFilter } from '@app/shared/dynamic-filter/dynamic-filter.component';
|
||||
|
||||
@Component({
|
||||
selector: 'agm-api-key-manager',
|
||||
templateUrl: './api-key-manager.component.html',
|
||||
styleUrls: ['./api-key-manager.component.css'],
|
||||
})
|
||||
export class ApiKeyManagerComponent extends BaseComp implements OnInit, OnDestroy, OnChanges {
|
||||
@Input() ownerId: string;
|
||||
@Input() toggleable = false;
|
||||
@Input() collapsed = false;
|
||||
@ViewChild('dt') dt!: Table;
|
||||
keys$: Observable<ApiKey[]>;
|
||||
filteredKeys$: Observable<ApiKey[]>;
|
||||
loading$: Observable<boolean>;
|
||||
error$: Observable<string | null>;
|
||||
|
||||
filterDefinitions: FilterDefinition[] = [];
|
||||
filterAccordionOpen = sessionStorage.getItem('api-key-filter-accordion') === 'true';
|
||||
private readonly activeFilters$ = new BehaviorSubject<ActiveFilter[]>([]);
|
||||
|
||||
newKey: CreateApiKeyResponse | null = null;
|
||||
newKeyOwnerLabel: string | null = null;
|
||||
newKeyLabel = '';
|
||||
newService = 'data_export';
|
||||
newKeyOwnerId: string | null = null;
|
||||
keyCopied = false;
|
||||
isAdmin = false;
|
||||
isMasterAccount = false;
|
||||
customerOptions: { label: string; value: string }[] = [];
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
cols: any[] = [];
|
||||
expandedRows: { [id: string]: boolean } = {};
|
||||
selectedKey: ApiKey | null = null;
|
||||
keys: ApiKey[] = [];
|
||||
showNewDialog = false;
|
||||
|
||||
createdAtFilter: Date | null = null;
|
||||
lastUsedAtFilter: Date | null = null;
|
||||
|
||||
statusOptions = [
|
||||
{ label: $localize`:@@all:All`, value: null },
|
||||
{ label: $localize`:@@active:Active`, value: true },
|
||||
{ label: $localize`:@@revoked:Revoked`, value: false },
|
||||
];
|
||||
|
||||
serviceOptions = [
|
||||
{ label: $localize`:@@dataExportApi:Data Export API`, value: 'data_export' },
|
||||
{ label: $localize`:@@partnerApi:Partner API`, value: 'partner_api' },
|
||||
];
|
||||
|
||||
readonly serviceLabels: Record<string, string> = {
|
||||
data_export: $localize`:@@dataExportApi:Data Export API`,
|
||||
partner_api: $localize`:@@partnerApi:Partner API`,
|
||||
};
|
||||
|
||||
constructor(private readonly customerSvc: CustomerService) {
|
||||
super();
|
||||
this.keys$ = this.store.select((s: any) => s[FEATURE_KEY].keys);
|
||||
this.loading$ = this.store.select((s: any) => s[FEATURE_KEY].loading);
|
||||
this.error$ = this.store.select((s: any) => s[FEATURE_KEY].error);
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.filteredKeys$ = combineLatest([
|
||||
this.keys$.pipe(map(k => k || [])),
|
||||
this.activeFilters$,
|
||||
]).pipe(
|
||||
map(([keys, filters]) => this.applyFilters(keys, filters))
|
||||
);
|
||||
|
||||
this.filteredKeys$.pipe(takeUntil(this.destroy$)).subscribe(k => { this.keys = k; });
|
||||
|
||||
this.isAdmin = this.authSvc.hasRole([RoleIds.ADMIN]);
|
||||
this.isMasterAccount = this.authSvc.hasRole([RoleIds.APP]);
|
||||
|
||||
const serviceFilterOptions = [
|
||||
{ label: $localize`:@@all:All`, value: null },
|
||||
...this.serviceOptions.map(o => ({ label: o.label, value: o.value })),
|
||||
];
|
||||
const statusFilterOptions = [
|
||||
{ label: $localize`:@@all:All`, value: null },
|
||||
{ label: $localize`:@@active:Active`, value: true },
|
||||
{ label: $localize`:@@revoked:Revoked`, value: false },
|
||||
];
|
||||
|
||||
this.filterDefinitions = [
|
||||
{ key: 'label', label: $localize`:@@label:Label`, dataType: 'text' },
|
||||
{ key: 'prefix', label: $localize`:@@prefix:Prefix`, dataType: 'text' },
|
||||
{ key: 'service', label: $localize`:@@service:Service`, dataType: 'select', options: serviceFilterOptions },
|
||||
{ key: 'active', label: $localize`:@@status:Status`, dataType: 'select', options: statusFilterOptions },
|
||||
{ key: 'createdAt', label: $localize`:@@createdDate:Created Date`, dataType: 'date' },
|
||||
{ key: 'lastUsedAt', label: $localize`:@@lastUsed:Last Used`, dataType: 'date' },
|
||||
{ key: 'requestCount', label: $localize`:@@requests:Requests`, dataType: 'number' },
|
||||
];
|
||||
|
||||
if (this.isAdmin && !this.ownerId) {
|
||||
this.filterDefinitions = [
|
||||
{ key: 'owner.name', label: $localize`:@@name:Name`, dataType: 'text' },
|
||||
{ key: 'owner.username', label: $localize`:@@userName:Username`, dataType: 'text' },
|
||||
{ key: 'owner.contact', label: $localize`:@@contact:Contact`, dataType: 'text' },
|
||||
...this.filterDefinitions,
|
||||
];
|
||||
}
|
||||
|
||||
this.cols = [
|
||||
{ field: 'label', header: $localize`:@@label:Label`, filtered: true, filterMatchMode: 'contains' },
|
||||
{ field: 'prefix', header: $localize`:@@prefix:Prefix`, filtered: true, filterMatchMode: 'contains' },
|
||||
{ field: 'service', header: $localize`:@@service:Service`, filtered: true, filterMatchMode: 'contains' },
|
||||
];
|
||||
|
||||
if (this.isAdmin && !this.ownerId) {
|
||||
this.cols = [
|
||||
{ field: 'owner.name', header: $localize`:@@name:Name`, filtered: true, filterMatchMode: 'contains' },
|
||||
{ field: 'owner.username', header: $localize`:@@userName:Username`, filtered: true, filterMatchMode: 'contains' },
|
||||
{ field: 'owner.contact', header: $localize`:@@contact:Contact`, filtered: true, filterMatchMode: 'contains' },
|
||||
...this.cols,
|
||||
{ field: 'active', header: $localize`:@@status:Status` },
|
||||
];
|
||||
}
|
||||
|
||||
this.store.dispatch(ApiKeyActions.loadApiKeys({ ownerId: this.ownerId }));
|
||||
|
||||
if (this.isAdmin && !this.ownerId) {
|
||||
this.customerSvc.loadCustomers().pipe(takeUntil(this.destroy$)).subscribe(customers => {
|
||||
this.customerOptions = customers.map(c => ({ label: c.username || c.name || c._id, value: c._id }));
|
||||
});
|
||||
}
|
||||
|
||||
this.store.select((s: any) => s[FEATURE_KEY].newKey)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(key => {
|
||||
this.newKey = key;
|
||||
if (key) { this.showNewDialog = false; }
|
||||
});
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes.ownerId && !changes.ownerId.firstChange) {
|
||||
this.store.dispatch(ApiKeyActions.dismissNewKey());
|
||||
this.store.dispatch(ApiKeyActions.loadApiKeys({ ownerId: this.ownerId }));
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.store.dispatch(ApiKeyActions.dismissNewKey());
|
||||
this.activeFilters$.complete();
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
openNewDialog(): void {
|
||||
this.newKeyLabel = '';
|
||||
this.newService = 'data_export';
|
||||
this.newKeyOwnerId = null;
|
||||
this.showNewDialog = true;
|
||||
}
|
||||
|
||||
submitCreate(): void {
|
||||
const label = this.newKeyLabel.trim();
|
||||
if (!label) { return; }
|
||||
const effectiveOwnerId = this.ownerId || (this.isAdmin ? this.newKeyOwnerId : null);
|
||||
if (this.isAdmin && !this.ownerId && !effectiveOwnerId) { return; }
|
||||
const request: any = { label, service: this.newService };
|
||||
if (effectiveOwnerId) { request.ownerId = effectiveOwnerId; }
|
||||
const ownerOpt = this.customerOptions.find(o => o.value === effectiveOwnerId);
|
||||
this.newKeyOwnerLabel = ownerOpt ? ownerOpt.label : null;
|
||||
this.store.dispatch(ApiKeyActions.createApiKey({ request }));
|
||||
this.newKeyLabel = '';
|
||||
this.newService = 'data_export';
|
||||
this.newKeyOwnerId = null;
|
||||
}
|
||||
|
||||
dismissNewKey(): void {
|
||||
this.newKey = null;
|
||||
this.newKeyOwnerLabel = null;
|
||||
this.store.dispatch(ApiKeyActions.dismissNewKey());
|
||||
this.keyCopied = false;
|
||||
}
|
||||
|
||||
copyKey(): void {
|
||||
if (!this.newKey?.key) { return; }
|
||||
navigator.clipboard.writeText(this.newKey.key).then(() => {
|
||||
this.keyCopied = true;
|
||||
});
|
||||
}
|
||||
|
||||
confirmRegenerate(key: ApiKey): void {
|
||||
this.confirmSvc.confirm({
|
||||
message: $localize`:@@regenerateKeyConfirm:Regenerate the key "${key.label}"? The old key will stop working immediately.`,
|
||||
header: $localize`:@@regenerateKey:Regenerate Key`,
|
||||
icon: 'ui-icon-refresh',
|
||||
accept: () => {
|
||||
this.store.dispatch(ApiKeyActions.regenerateApiKey({ keyId: key._id, ownerId: this.ownerId }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
confirmRevoke(key: ApiKey): void {
|
||||
this.confirmSvc.confirm({
|
||||
message: $localize`:@@revokeKeyConfirm:Revoke the key "${key.label}"? This cannot be undone.`,
|
||||
header: $localize`:@@revokeKey:Revoke Key`,
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
accept: () => {
|
||||
this.store.dispatch(ApiKeyActions.revokeApiKey({ keyId: key._id, ownerId: this.ownerId }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
confirmDelete(key: ApiKey): void {
|
||||
this.confirmSvc.confirm({
|
||||
message: $localize`:@@deleteKeyConfirm:Permanently delete the key "${key.label}"? This action cannot be undone.`,
|
||||
header: $localize`:@@deleteKey:Delete Key`,
|
||||
icon: 'pi pi-trash',
|
||||
accept: () => {
|
||||
this.selectedKey = null;
|
||||
this.store.dispatch(ApiKeyActions.deleteApiKey({ keyId: key._id, ownerId: this.ownerId }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onFiltersChanged(event: FilterChangeEvent): void {
|
||||
// Apply immediately only when a filter is removed or all are cleared
|
||||
if (event.filters.length < this.activeFilters$.value.length) {
|
||||
this.activeFilters$.next(event.filters);
|
||||
}
|
||||
}
|
||||
|
||||
onFiltersSubmit(event: FilterChangeEvent): void {
|
||||
this.activeFilters$.next(event.filters);
|
||||
}
|
||||
|
||||
onAccordionToggle(expanded: boolean): void {
|
||||
sessionStorage.setItem('api-key-filter-accordion', String(expanded));
|
||||
}
|
||||
|
||||
onDateFilter(value: Date, field: string): void {
|
||||
this.dt.filter(value, field, 'dateIs');
|
||||
}
|
||||
|
||||
private applyFilters(keys: ApiKey[], filters: ActiveFilter[]): ApiKey[] {
|
||||
if (!filters.length) { return keys; }
|
||||
return keys.filter(key => {
|
||||
// Left-to-right operator evaluation, matching server buildDynamicFilter logic:
|
||||
// filter[i].operator describes how filter[i] combines with the accumulated result.
|
||||
// e.g. A(and) B(and) C(or) D(and) → ((A ∧ B) ∨ C) ∧ D
|
||||
let result = this.matchesFilter(key, filters[0]);
|
||||
for (let i = 1; i < filters.length; i++) {
|
||||
const match = this.matchesFilter(key, filters[i]);
|
||||
result = filters[i].operator === 'or' ? result || match : result && match;
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
private resolveField(obj: any, path: string): any {
|
||||
return path.split('.').reduce((cur, part) => (cur != null ? cur[part] : undefined), obj);
|
||||
}
|
||||
|
||||
private matchesFilter(key: ApiKey, filter: ActiveFilter): boolean {
|
||||
const val = this.resolveField(key, filter.definition.key);
|
||||
const fval = filter.value;
|
||||
|
||||
if (fval == null || fval === '') { return true; }
|
||||
|
||||
switch (filter.definition.dataType) {
|
||||
case 'text': {
|
||||
const s = String(val ?? '').toLowerCase();
|
||||
const q = String(fval).toLowerCase();
|
||||
switch (filter.valueOperator) {
|
||||
case 'startsWith': return s.startsWith(q);
|
||||
case 'exact': return s === q;
|
||||
default: return s.includes(q);
|
||||
}
|
||||
}
|
||||
case 'select':
|
||||
return val === fval;
|
||||
case 'date': {
|
||||
if (!val) { return false; }
|
||||
const d = new Date(val).setHours(0, 0, 0, 0);
|
||||
const fd = new Date(fval).setHours(0, 0, 0, 0);
|
||||
switch (filter.valueOperator) {
|
||||
case 'before': return d < fd;
|
||||
case 'after': return d > fd;
|
||||
default: return d === fd;
|
||||
}
|
||||
}
|
||||
case 'number': {
|
||||
const n = Number(val ?? 0);
|
||||
const fn = Number(fval);
|
||||
switch (filter.valueOperator) {
|
||||
case 'greaterThan': return n > fn;
|
||||
case 'lessThan': return n < fn;
|
||||
default: return n === fn;
|
||||
}
|
||||
}
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,74 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { CommonModule, TitleCasePipe } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
|
||||
import { StoreModule } from '@ngrx/store';
|
||||
import { EffectsModule } from '@ngrx/effects';
|
||||
|
||||
// PrimeNG
|
||||
import { ButtonModule } from 'primeng/button';
|
||||
import { InputTextModule } from 'primeng/inputtext';
|
||||
import { DropdownModule } from 'primeng/dropdown';
|
||||
import { CalendarModule } from 'primeng/calendar';
|
||||
import { TableModule } from 'primeng/table';
|
||||
import { ConfirmDialogModule } from 'primeng/confirmdialog';
|
||||
import { DialogModule } from 'primeng/dialog';
|
||||
import { TooltipModule } from 'primeng/tooltip';
|
||||
import { MessagesModule } from 'primeng/messages';
|
||||
import { MessageModule } from 'primeng/message';
|
||||
import { ProgressSpinnerModule } from 'primeng/progressspinner';
|
||||
import { PanelModule } from 'primeng/panel';
|
||||
import { ToastModule } from 'primeng/toast';
|
||||
import { AccordionModule } from 'primeng/accordion';
|
||||
import { ConfirmationService } from 'primeng/api';
|
||||
|
||||
// Store
|
||||
import { FEATURE_KEY, apiKeyReducer } from '../reducers';
|
||||
import { ApiKeyEffects } from '../effects/api-key.effects';
|
||||
|
||||
// Shared
|
||||
import { AppSharedModule } from '@app/shared/app-shared.module';
|
||||
|
||||
// Service
|
||||
import { ApiKeyService } from '@app/domain/services/api-key.service';
|
||||
import { CustomerService } from '@app/domain/services/customer.service';
|
||||
|
||||
// Component
|
||||
import { ApiKeyManagerComponent } from './api-key-manager/api-key-manager.component';
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
ApiKeyManagerComponent
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
AppSharedModule,
|
||||
StoreModule.forFeature(FEATURE_KEY, apiKeyReducer),
|
||||
EffectsModule.forFeature([ApiKeyEffects]),
|
||||
ButtonModule,
|
||||
InputTextModule,
|
||||
DropdownModule,
|
||||
CalendarModule,
|
||||
TableModule,
|
||||
ConfirmDialogModule,
|
||||
TooltipModule,
|
||||
MessagesModule,
|
||||
MessageModule,
|
||||
ProgressSpinnerModule,
|
||||
PanelModule,
|
||||
ToastModule,
|
||||
AccordionModule,
|
||||
DialogModule,
|
||||
],
|
||||
exports: [
|
||||
ApiKeyManagerComponent
|
||||
],
|
||||
providers: [
|
||||
TitleCasePipe,
|
||||
ConfirmationService,
|
||||
ApiKeyService,
|
||||
CustomerService,
|
||||
]
|
||||
})
|
||||
export class ApiKeySharedModule {}
|
||||
@ -0,0 +1,23 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { Routes, RouterModule } from '@angular/router';
|
||||
|
||||
import { AuthGuard } from '../../domain/guards/auth.guard';
|
||||
import { RoleIds } from '../../shared/global';
|
||||
import { ApiKeyManagerComponent } from './api-key-manager/api-key-manager.component';
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: ApiKeyManagerComponent,
|
||||
data: {
|
||||
roles: [RoleIds.ADMIN, RoleIds.APP]
|
||||
},
|
||||
canActivate: [AuthGuard]
|
||||
}
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class ApiKeysRoutingModule {}
|
||||
@ -0,0 +1,15 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
|
||||
// Routing
|
||||
import { ApiKeysRoutingModule } from './api-keys-routing.module';
|
||||
|
||||
// Shared module (declares + exports ApiKeyManagerComponent, registers store/effects)
|
||||
import { ApiKeySharedModule } from './api-key-shared.module';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
ApiKeysRoutingModule,
|
||||
ApiKeySharedModule,
|
||||
],
|
||||
})
|
||||
export class ApiKeysModule {}
|
||||
@ -0,0 +1,22 @@
|
||||
export interface ApiKey {
|
||||
_id: string;
|
||||
label: string;
|
||||
prefix: string;
|
||||
active: boolean;
|
||||
service: string;
|
||||
managedBy: 'owner' | 'admin';
|
||||
createdAt: string;
|
||||
lastUsedAt?: string;
|
||||
requestCount: number;
|
||||
owner?: string | { _id: string; username: string; name?: string; contact?: string };
|
||||
}
|
||||
|
||||
export interface CreateApiKeyResponse extends ApiKey {
|
||||
key: string; // plain key — returned once only
|
||||
}
|
||||
|
||||
export interface CreateApiKeyRequest {
|
||||
label: string;
|
||||
service: string;
|
||||
ownerId?: string; // admin only
|
||||
}
|
||||
112
Development/client/src/app/settings/effects/api-key.effects.ts
Normal file
112
Development/client/src/app/settings/effects/api-key.effects.ts
Normal file
@ -0,0 +1,112 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Actions, createEffect, ofType } from '@ngrx/effects';
|
||||
import { of } from 'rxjs';
|
||||
import { map, mergeMap, catchError, repeat } from 'rxjs/operators';
|
||||
import { MessageService } from 'primeng/api';
|
||||
|
||||
import { ApiKeyService } from '@app/domain/services/api-key.service';
|
||||
import * as ApiKeyActions from '../actions/api-key.actions';
|
||||
|
||||
@Injectable()
|
||||
export class ApiKeyEffects {
|
||||
constructor(
|
||||
private readonly actions$: Actions,
|
||||
private readonly apiKeySvc: ApiKeyService,
|
||||
private readonly messageSvc: MessageService,
|
||||
) {}
|
||||
|
||||
loadKeys$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(ApiKeyActions.loadApiKeys),
|
||||
mergeMap(action =>
|
||||
this.apiKeySvc.listKeys(action.ownerId).pipe(
|
||||
map(keys => ApiKeyActions.loadApiKeysSuccess({ keys })),
|
||||
catchError(err => of(ApiKeyActions.loadApiKeysFailure({ error: err.message })))
|
||||
)
|
||||
),
|
||||
repeat()
|
||||
)
|
||||
);
|
||||
|
||||
createKey$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(ApiKeyActions.createApiKey),
|
||||
mergeMap(action =>
|
||||
this.apiKeySvc.createKey(action.request).pipe(
|
||||
map(response => ApiKeyActions.createApiKeySuccess({ response })),
|
||||
catchError(err => of(ApiKeyActions.createApiKeyFailure({ error: err.error?.message || err.message })))
|
||||
)
|
||||
),
|
||||
repeat()
|
||||
)
|
||||
);
|
||||
|
||||
revokeKey$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(ApiKeyActions.revokeApiKey),
|
||||
mergeMap(action =>
|
||||
this.apiKeySvc.revokeKey(action.keyId).pipe(
|
||||
map(() => ApiKeyActions.revokeApiKeySuccess({ keyId: action.keyId, ownerId: action.ownerId })),
|
||||
catchError(err => of(ApiKeyActions.revokeApiKeyFailure({ error: err.error?.message || err.message })))
|
||||
)
|
||||
),
|
||||
repeat()
|
||||
)
|
||||
);
|
||||
|
||||
revokeSuccess$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(ApiKeyActions.revokeApiKeySuccess),
|
||||
map(action => {
|
||||
this.messageSvc.add({ key: 'apiKeyToast', severity: 'success', summary: 'Key Revoked', detail: 'API key has been revoked.' });
|
||||
return ApiKeyActions.loadApiKeys({ ownerId: action.ownerId });
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
deleteKey$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(ApiKeyActions.deleteApiKey),
|
||||
mergeMap(action =>
|
||||
this.apiKeySvc.deleteKey(action.keyId).pipe(
|
||||
map(() => ApiKeyActions.deleteApiKeySuccess({ keyId: action.keyId, ownerId: action.ownerId })),
|
||||
catchError(err => of(ApiKeyActions.deleteApiKeyFailure({ error: err.error?.message || err.message })))
|
||||
)
|
||||
),
|
||||
repeat()
|
||||
)
|
||||
);
|
||||
|
||||
deleteSuccess$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(ApiKeyActions.deleteApiKeySuccess),
|
||||
map(action => {
|
||||
this.messageSvc.add({ key: 'apiKeyToast', severity: 'success', summary: 'Key Deleted', detail: 'API key has been permanently deleted.' });
|
||||
return ApiKeyActions.loadApiKeys({ ownerId: action.ownerId });
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
regenerateKey$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(ApiKeyActions.regenerateApiKey),
|
||||
mergeMap(action =>
|
||||
this.apiKeySvc.regenerateKey(action.keyId).pipe(
|
||||
map(response => ApiKeyActions.regenerateApiKeySuccess({ response, ownerId: action.ownerId })),
|
||||
catchError(err => of(ApiKeyActions.regenerateApiKeyFailure({ error: err.error?.message || err.message })))
|
||||
)
|
||||
),
|
||||
repeat()
|
||||
)
|
||||
);
|
||||
|
||||
failure$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(ApiKeyActions.loadApiKeysFailure, ApiKeyActions.createApiKeyFailure, ApiKeyActions.revokeApiKeyFailure, ApiKeyActions.deleteApiKeyFailure, ApiKeyActions.regenerateApiKeyFailure),
|
||||
map(action => {
|
||||
this.messageSvc.add({ key: 'apiKeyToast', severity: 'error', summary: 'Error', detail: action.error });
|
||||
return { type: '[ApiKey] Noop' };
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,92 @@
|
||||
import { createReducer, on } from '@ngrx/store';
|
||||
import { ApiKey, CreateApiKeyResponse } from '../api-keys/models/api-key.model';
|
||||
import * as ApiKeyActions from '../actions/api-key.actions';
|
||||
|
||||
export interface ApiKeyState {
|
||||
keys: ApiKey[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
newKey: CreateApiKeyResponse | null; // holds the just-created key (plain key visible once)
|
||||
}
|
||||
|
||||
export const initialState: ApiKeyState = {
|
||||
keys: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
newKey: null,
|
||||
};
|
||||
|
||||
export const FEATURE_KEY = 'apiKey';
|
||||
|
||||
export const apiKeyReducer = createReducer(
|
||||
initialState,
|
||||
|
||||
on(ApiKeyActions.loadApiKeys, (state) => ({
|
||||
...state, loading: true, error: null
|
||||
})),
|
||||
on(ApiKeyActions.loadApiKeysSuccess, (state, { keys }) => ({
|
||||
...state, keys, loading: false
|
||||
})),
|
||||
on(ApiKeyActions.loadApiKeysFailure, (state, { error }) => ({
|
||||
...state, loading: false, error
|
||||
})),
|
||||
|
||||
on(ApiKeyActions.createApiKey, (state) => ({
|
||||
...state, loading: true, error: null
|
||||
})),
|
||||
on(ApiKeyActions.createApiKeySuccess, (state, { response }) => ({
|
||||
...state,
|
||||
loading: false,
|
||||
newKey: response,
|
||||
// Add new key to list (without the plain key field)
|
||||
keys: [{ _id: response._id, label: response.label, prefix: response.prefix,
|
||||
active: response.active, service: response.service, managedBy: response.managedBy,
|
||||
createdAt: response.createdAt, owner: response.owner }, ...state.keys],
|
||||
})),
|
||||
on(ApiKeyActions.createApiKeyFailure, (state, { error }) => ({
|
||||
...state, loading: false, error
|
||||
})),
|
||||
|
||||
on(ApiKeyActions.revokeApiKey, (state) => ({
|
||||
...state, loading: true, error: null
|
||||
})),
|
||||
on(ApiKeyActions.revokeApiKeySuccess, (state, { keyId }) => ({
|
||||
...state,
|
||||
loading: false,
|
||||
keys: state.keys.map(k => k._id === keyId ? { ...k, active: false } : k),
|
||||
})),
|
||||
on(ApiKeyActions.revokeApiKeyFailure, (state, { error }) => ({
|
||||
...state, loading: false, error
|
||||
})),
|
||||
|
||||
on(ApiKeyActions.deleteApiKey, (state) => ({
|
||||
...state, loading: true, error: null
|
||||
})),
|
||||
on(ApiKeyActions.deleteApiKeySuccess, (state, { keyId }) => ({
|
||||
...state,
|
||||
loading: false,
|
||||
keys: state.keys.filter(k => k._id !== keyId),
|
||||
})),
|
||||
on(ApiKeyActions.deleteApiKeyFailure, (state, { error }) => ({
|
||||
...state, loading: false, error
|
||||
})),
|
||||
|
||||
on(ApiKeyActions.regenerateApiKey, (state) => ({
|
||||
...state, loading: true, error: null
|
||||
})),
|
||||
on(ApiKeyActions.regenerateApiKeySuccess, (state, { response }) => ({
|
||||
...state,
|
||||
loading: false,
|
||||
newKey: response,
|
||||
keys: state.keys.map(k => k._id === response._id
|
||||
? { ...k, prefix: response.prefix, active: true }
|
||||
: k),
|
||||
})),
|
||||
on(ApiKeyActions.regenerateApiKeyFailure, (state, { error }) => ({
|
||||
...state, loading: false, error
|
||||
})),
|
||||
|
||||
on(ApiKeyActions.dismissNewKey, (state) => ({
|
||||
...state, newKey: null
|
||||
})),
|
||||
);
|
||||
9
Development/client/src/app/settings/reducers/index.ts
Normal file
9
Development/client/src/app/settings/reducers/index.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { createFeatureSelector } from '@ngrx/store';
|
||||
|
||||
import * as fromApiKey from './api-key.reducer';
|
||||
|
||||
export { FEATURE_KEY } from './api-key.reducer';
|
||||
export type { ApiKeyState } from './api-key.reducer';
|
||||
export { apiKeyReducer } from './api-key.reducer';
|
||||
|
||||
export const getApiKeyState = createFeatureSelector<fromApiKey.ApiKeyState>(fromApiKey.FEATURE_KEY);
|
||||
@ -1,7 +1,6 @@
|
||||
import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { HttpClientModule } from '@angular/common/http';
|
||||
|
||||
import { SettingsRoutingModule } from './settings-routing.module';
|
||||
import { SubscriptionMgtComponent } from './subscription/subscription-mgt.component';
|
||||
@ -30,7 +29,6 @@ import { ProgressSpinnerModule } from 'primeng/progressspinner';
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
HttpClientModule,
|
||||
SettingsRoutingModule,
|
||||
AppSharedModule,
|
||||
// PrimeNG
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ReactiveFormsModule } from '@angular/forms';
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
|
||||
import { SharedModule } from 'primeng/api';
|
||||
import { InputTextModule } from 'primeng/inputtext';
|
||||
@ -9,11 +9,13 @@ import { DropdownModule } from 'primeng/dropdown';
|
||||
import { CheckboxModule } from 'primeng/checkbox';
|
||||
import { KeyFilterModule } from 'primeng/keyfilter';
|
||||
import { PanelModule } from 'primeng/panel';
|
||||
import { ProgressSpinnerModule } from 'primeng/progressspinner';
|
||||
import { MessagesModule } from 'primeng/messages';
|
||||
import { MessageModule } from 'primeng/message';
|
||||
import { RadioButtonModule } from 'primeng/radiobutton';
|
||||
import { CalendarModule } from 'primeng/calendar';
|
||||
import { DialogModule } from 'primeng/dialog';
|
||||
import { MultiSelectModule } from 'primeng/multiselect';
|
||||
|
||||
import { LengthUnitPipe } from './pipes/length-unit.pipe';
|
||||
import { RateUnitPipe } from './pipes/rate-unit.pipe';
|
||||
@ -76,12 +78,15 @@ import { BadgeComponent } from './badge/badge.component';
|
||||
import { PromoLabelComponent } from './promo-label/promo-label.component';
|
||||
import { ActivePromoLabelComponent } from './active-promo-label/active-promo-label.component';
|
||||
import { LegacyNoticeLabelComponent } from './legacy-notice-label/legacy-notice-label.component';
|
||||
import { DynamicFilterComponent } from './dynamic-filter/dynamic-filter.component';
|
||||
import { MarkdownViewerComponent } from './markdown-viewer/markdown-viewer.component';
|
||||
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule, GlobalModule, SharedModule, InputTextModule, ButtonModule, DropdownModule, KeyFilterModule, ReactiveFormsModule, CheckboxModule, PanelModule,
|
||||
MessagesModule, MessageModule, InputNumberModule, CalendarModule, DialogModule
|
||||
ProgressSpinnerModule, MessagesModule, MessageModule, InputNumberModule, CalendarModule, DialogModule,
|
||||
MultiSelectModule, FormsModule
|
||||
],
|
||||
declarations: [
|
||||
LengthUnitPipe, RateUnitPipe, UserTypePipe, AreaUnitPipe, NoCommaPipe,
|
||||
@ -90,18 +95,19 @@ import { LegacyNoticeLabelComponent } from './legacy-notice-label/legacy-notice-
|
||||
JobStatusPipe, VehicleTypePipe, FlowRatePipe, LockLinePipe, XtractPipe, SubscriptionPkgPipe, UsCurrencyPipe, TsDatePipe, CreditCurrencyPipe,
|
||||
DebounceDirective, UnitIdUniqueDirective, AppVolumePipe, ProfileFormComponent, CreditcardFormComponent, CardInfoComponent, PaymentSummaryComponent,
|
||||
PaymentMethodSummaryComponent, PaymentInfoComponent, SubPlansDirective, PaymentAmountComponent, CreditcardExpCalComponent, CreditcardComponent, ReviewAircraftComponent, GenericMessageComponent, TrialMessageComponent, InputTrimDirective, BillingAddressEltComponent, AppFooterComponent, LanguageSwicherComponent, ConstraintMessageComponent, BadgeComponent, PromoLabelComponent, ActivePromoLabelComponent, LegacyNoticeLabelComponent,
|
||||
|
||||
DynamicFilterComponent, MarkdownViewerComponent
|
||||
],
|
||||
exports: [
|
||||
CommonModule, GlobalModule, SharedModule, ReactiveFormsModule,
|
||||
InputTextModule, ButtonModule, DropdownModule, KeyFilterModule, CheckboxModule, MessagesModule, MessageModule, InputNumberModule, RadioButtonModule,
|
||||
CommonModule, GlobalModule, SharedModule, ReactiveFormsModule, FormsModule,
|
||||
InputTextModule, ButtonModule, DropdownModule, KeyFilterModule, CheckboxModule, MessagesModule, MessageModule, InputNumberModule, RadioButtonModule, MultiSelectModule,
|
||||
ItemEditorComponent, ProductEditorComponent, AccountEditorComponent, DisplayConfigComponent, CropEditorComponent,
|
||||
LengthUnitPipe, RateUnitPipe, AreaUnitPipe, UserTypePipe, NoCommaPipe, UniqueUserValidatorDirective, UnitPipe, ProductTypePipe,
|
||||
ActivityPipe, CoordinatePipe, SpeedPipe, LengthPipe, TemperaturePipe, AppRatePipe, DistancePipe, JobStatusPipe,
|
||||
VehicleTypePipe, FlowRatePipe, LockLinePipe, XtractPipe, AppVolumePipe, SubscriptionPkgPipe, UsCurrencyPipe, TsDatePipe, CreditCurrencyPipe,
|
||||
DebounceDirective, UnitIdUniqueDirective,
|
||||
ProfileFormComponent, CreditcardFormComponent, CardInfoComponent,
|
||||
PaymentInfoComponent, PaymentSummaryComponent, PaymentMethodSummaryComponent, SubPlansDirective, PaymentAmountComponent, CreditcardExpCalComponent, CreditcardComponent, ReviewAircraftComponent, GenericMessageComponent, TrialMessageComponent, InputTrimDirective, BillingAddressEltComponent, AppFooterComponent, LanguageSwicherComponent, ConstraintMessageComponent, BadgeComponent, PromoLabelComponent, ActivePromoLabelComponent, LegacyNoticeLabelComponent
|
||||
PaymentInfoComponent, PaymentSummaryComponent, PaymentMethodSummaryComponent, SubPlansDirective, PaymentAmountComponent, CreditcardExpCalComponent, CreditcardComponent, ReviewAircraftComponent, GenericMessageComponent, TrialMessageComponent, InputTrimDirective, BillingAddressEltComponent, AppFooterComponent, LanguageSwicherComponent, ConstraintMessageComponent, BadgeComponent, PromoLabelComponent, ActivePromoLabelComponent, LegacyNoticeLabelComponent,
|
||||
DynamicFilterComponent, MarkdownViewerComponent
|
||||
],
|
||||
providers: [RateUnitPipe, LengthUnitPipe, UnitPipe, ProductTypePipe, CostingItemTypePipe, CostingItemUnitPipe, CurrencyNamePipe, CurrencyCodePositionPipe]
|
||||
})
|
||||
|
||||
@ -316,7 +316,10 @@ export class MapBaseComp extends BaseComp implements OnDestroy {
|
||||
color: props.color,
|
||||
area: parseFloat(props.area),
|
||||
appRate: parseFloat(props.appRate),
|
||||
width: NumUtils.round(props.width, 1)
|
||||
width: NumUtils.round(props.width, 1),
|
||||
offset: props.offset !== undefined ? Number(props.offset) : undefined,
|
||||
edgeSide: props.edgeSide,
|
||||
edgeSign: props.edgeSign !== undefined ? Number(props.edgeSign) : undefined
|
||||
};
|
||||
if (item.type === ITEM.SPRAY || item.type === ITEM.XCL) {
|
||||
if (!item.area)
|
||||
@ -415,4 +418,7 @@ export interface MapItem {
|
||||
client?: string;
|
||||
lat?: number;
|
||||
lon?: number;
|
||||
offset?: number;
|
||||
edgeSide?: 'on' | 'inside' | 'outside';
|
||||
edgeSign?: number;
|
||||
}
|
||||
|
||||
@ -363,16 +363,21 @@ export class MapEditBaseComp extends MapBaseComp implements OnInit, OnDestroy {
|
||||
xclArea += dA;
|
||||
}
|
||||
} else if (exType === ITEM.BUFFER) {
|
||||
// Check whether the buffer zone within (at least one point within) the spray poly to count exlusion
|
||||
xclLayers[j].updateArea();
|
||||
const llns = xclLayers[j].getLatLngs();
|
||||
if (llns.length) {
|
||||
let ll;
|
||||
for (let k = 0; k < 1; k++) {
|
||||
ll = llns[k];
|
||||
if (turf.booleanPointInPolygon([ll.lng, ll.lat], sprayPoly)) {
|
||||
const firstEl = llns[0];
|
||||
if (Array.isArray(firstEl)) {
|
||||
// Polygon-type buffer (e.g. edge buffer zone) — use turf.intersect for accuracy
|
||||
const diff = turf.intersect(sprayPoly, xclPoly);
|
||||
if (diff) {
|
||||
const dA = turf.area(diff);
|
||||
if (dA) xclArea += dA;
|
||||
}
|
||||
} else {
|
||||
// L.Corridor buffer — use corridor's own area calculation
|
||||
xclLayers[j].updateArea();
|
||||
if (turf.booleanPointInPolygon([firstEl.lng, firstEl.lat], sprayPoly)) {
|
||||
xclArea += xclLayers[j].getArea();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -446,7 +451,7 @@ export class MapEditBaseComp extends MapBaseComp implements OnInit, OnDestroy {
|
||||
this.postTypeChanged(e);
|
||||
}
|
||||
|
||||
protected getDefaultName(layer) {
|
||||
protected getDefaultName(layer, extraOffset = 0) {
|
||||
if (!layer.feature || !layer.feature.properties) {
|
||||
return '';
|
||||
}
|
||||
@ -479,7 +484,7 @@ export class MapEditBaseComp extends MapBaseComp implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
}
|
||||
number = layers.filter(l => (<any>l).feature.properties.type === type).length;
|
||||
number = layers.filter(l => (<any>l).feature.properties.type === type).length + extraOffset;
|
||||
}
|
||||
return `${name.trim()}_${NumUtils.padZero(number, 2)}`;
|
||||
}
|
||||
@ -555,7 +560,10 @@ export class MapEditBaseComp extends MapBaseComp implements OnInit, OnDestroy {
|
||||
else {
|
||||
if (type === ITEM.BUFFER) {
|
||||
this.map.fitBounds((<any>layer).getBounds(), GC.fbOps);
|
||||
this.selItem = layer.openTooltip();
|
||||
layer.openTooltip();
|
||||
// Use a proxy so cleanup calls closeTooltip() but never removeLayer()
|
||||
// (instanceof L.Polygon would match feature buffers and wrongly remove them)
|
||||
this.selItem = { closeTooltip: () => layer.closeTooltip() };
|
||||
} else {
|
||||
setTimeout(() => this.map.setView((<any>layer).getLatLng(), Math.min(GC.MAX_ZOOM_ITEM, this.map.getZoom())), 200);
|
||||
if (!layer.isTooltipOpen())
|
||||
|
||||
@ -0,0 +1,221 @@
|
||||
:host {
|
||||
display: block;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.dynamic-filter {
|
||||
min-width: 16.25rem;
|
||||
}
|
||||
|
||||
.filter-add-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.filter-add-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.filter-action-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:host ::ng-deep .filter-selector-dropdown {
|
||||
min-width: 180px;
|
||||
width: 220px;
|
||||
flex: 1 1 180px;
|
||||
}
|
||||
|
||||
:host ::ng-deep .logic-operator-dropdown {
|
||||
width: 70px;
|
||||
}
|
||||
|
||||
:host ::ng-deep .value-operator-dropdown {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
:host ::ng-deep .full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.filter-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-field {
|
||||
padding: 0.2rem 0.25rem;
|
||||
box-sizing: border-box;
|
||||
min-width:16rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.filter-field {
|
||||
width: calc(100% / 4);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.filter-field {
|
||||
width: calc(100% / 2);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.filter-field {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-field-inner {
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.filter-field-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.2rem 0.35rem;
|
||||
background: #f0f0f0;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.filter-field-header label {
|
||||
font-weight: 600;
|
||||
font-size: 0.9em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.filter-field-header-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 2rem;
|
||||
min-height: 2rem;
|
||||
}
|
||||
|
||||
.filter-field-header .remove-btn {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
padding: 0.15rem 0.35rem;
|
||||
font-size: 0.75em;
|
||||
}
|
||||
|
||||
:host ::ng-deep .filter-field-header .remove-btn .ui-button-icon {
|
||||
color: #e53935;
|
||||
}
|
||||
|
||||
:host ::ng-deep .filter-field-header .remove-btn:hover .ui-button-icon {
|
||||
color: #b71c1c;
|
||||
}
|
||||
|
||||
:host ::ng-deep body .ui-button.remove-btn .pi,
|
||||
:host ::ng-deep .filter-field-header .remove-btn .pi {
|
||||
color: #e53935;
|
||||
}
|
||||
|
||||
:host ::ng-deep .filter-field-header .remove-btn:hover .pi {
|
||||
color: #b71c1c;
|
||||
}
|
||||
|
||||
.filter-field-body {
|
||||
padding: 0.25rem 0.35rem;
|
||||
}
|
||||
|
||||
.filter-operators-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.date-input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
border-bottom: 1px solid #a6a6a6;
|
||||
cursor: pointer;
|
||||
min-height: 1.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.date-input-row:hover {
|
||||
border-bottom-color: #007ad9;
|
||||
}
|
||||
|
||||
.date-input-icon {
|
||||
font-size: 0.9em;
|
||||
color: #555;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.date-input-row span {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.date-placeholder {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.date-clear-btn {
|
||||
font-size: 0.85em;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.date-clear-btn:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.date-cal-anchor {
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.date-cal-anchor ::ng-deep .ui-calendar {
|
||||
display: block;
|
||||
height: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.date-cal-anchor ::ng-deep .ui-calendar .ui-inputtext {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.date-cal-anchor ::ng-deep .ui-calendar .ui-calendar-button {
|
||||
visibility: hidden;
|
||||
width: 1px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
@ -0,0 +1,136 @@
|
||||
<div class="dynamic-filter">
|
||||
<!-- Add filter row -->
|
||||
<div class="filter-add-row">
|
||||
<div class="filter-add-group">
|
||||
<p-dropdown [options]="availableFilters" [(ngModel)]="selectedFilterKey"
|
||||
styleClass="filter-selector-dropdown" placeholder="-- Select Filter --" appendTo="body">
|
||||
</p-dropdown>
|
||||
<button pButton type="button" icon="pi pi-plus" class="ui-button-success add-btn"
|
||||
[disabled]="!selectedFilterKey" (click)="addFilter()">
|
||||
</button>
|
||||
</div>
|
||||
<div class="filter-action-group" *ngIf="activeFilters.length">
|
||||
<button pButton type="button" icon="ui-icon-clear-all"
|
||||
class="ui-button-secondary clear-btn" (click)="clearAll()" i18n-label="@@clearFilters" label="Clear Filters">
|
||||
</button>
|
||||
<button *ngIf="showSearch" pButton type="button" icon="pi pi-search"
|
||||
class="ui-button-primary submit-btn" (click)="submit()" i18n-label="@@applyFilters" label="Search">
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active filters -->
|
||||
<div class="filter-grid" *ngIf="activeFilters.length">
|
||||
<div class="filter-field" *ngFor="let filter of activeFilters; let i = index">
|
||||
<div class="filter-field-inner">
|
||||
<!-- Header: label + remove -->
|
||||
<div class="filter-field-header">
|
||||
<label>{{ filter.definition.label }}</label>
|
||||
<span class="filter-field-header-action">
|
||||
<button *ngIf="isFilterRemovable(filter)" pButton type="button" icon="pi pi-times" class="ui-button-text remove-btn"
|
||||
(click)="removeFilter(filter.id)">
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="filter-field-body">
|
||||
<!-- And/Or + Label + Value operator row -->
|
||||
<div class="filter-operators-row">
|
||||
<p-dropdown *ngIf="i > 0" [options]="[{label: 'And', value: 'and'}, {label: 'Or', value: 'or'}]"
|
||||
[(ngModel)]="filter.operator" styleClass="logic-operator-dropdown"
|
||||
(onChange)="onOperatorChange()" appendTo="body">
|
||||
</p-dropdown>
|
||||
<p>{{ filter.definition.label }}</p>
|
||||
<p *ngIf="filter.definition.dataType === 'select' || filter.definition.dataType === 'select-multi' || filter.definition.dataType === 'numeric-enum'">is</p>
|
||||
<p-dropdown *ngIf="getValueOperatorOptions(filter.definition.dataType).length"
|
||||
[options]="getValueOperatorOptions(filter.definition.dataType)"
|
||||
[(ngModel)]="filter.valueOperator" styleClass="value-operator-dropdown"
|
||||
(onChange)="onValueOperatorChange(filter)" appendTo="body">
|
||||
</p-dropdown>
|
||||
</div>
|
||||
|
||||
<!-- Text input -->
|
||||
<input *ngIf="filter.definition.dataType === 'text'" pInputText type="text"
|
||||
[(ngModel)]="filter.value" (input)="onValueChange()" placeholder="Search..." class="full-width">
|
||||
|
||||
<!-- Number input -->
|
||||
<input *ngIf="filter.definition.dataType === 'number'" pInputText type="number"
|
||||
[(ngModel)]="filter.value" (input)="onValueChange()" placeholder="Enter number..." class="full-width">
|
||||
|
||||
<!-- Select — single select -->
|
||||
<p-dropdown *ngIf="filter.definition.dataType === 'select'"
|
||||
[options]="filter.definition.options" [(ngModel)]="filter.value"
|
||||
styleClass="full-width" [filter]="true" (onChange)="onValueChange()"
|
||||
placeholder="Select..." appendTo="body">
|
||||
</p-dropdown>
|
||||
|
||||
<!-- Select — multi select -->
|
||||
<p-multiSelect *ngIf="filter.definition.dataType === 'select-multi'"
|
||||
[options]="filter.definition.options" [(ngModel)]="filter.value"
|
||||
styleClass="full-width" (onChange)="onValueChange()"
|
||||
defaultLabel="Select..." appendTo="body">
|
||||
</p-multiSelect>
|
||||
|
||||
<!-- Date — single date (before / after / exact) -->
|
||||
<ng-container *ngIf="filter.definition.dataType === 'date' && filter.valueOperator !== 'range'">
|
||||
<div class="date-cal-anchor">
|
||||
<div class="date-input-row" (click)="openCal(filter.id, false)">
|
||||
<i class="pi pi-calendar date-input-icon"></i>
|
||||
<span *ngIf="!filter.value" class="date-placeholder" i18n="@@selectDate">Select Date...</span>
|
||||
<span *ngIf="filter.value">{{ filter.value | date:'shortDate' }}</span>
|
||||
<i *ngIf="filter.value" class="pi pi-times date-clear-btn" (click)="clearDate($event, filter)"></i>
|
||||
</div>
|
||||
<p-calendar [attr.data-filter-cal]="filter.id" [(ngModel)]="filter.value" [locale]="locale" [showIcon]="true"
|
||||
[dateFormat]="locale?.dateFormat || 'mm/dd/yy'" (onSelect)="onValueChange()"
|
||||
(onClearClick)="onValueChange()" [showButtonBar]="true">
|
||||
</p-calendar>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<!-- Date — range mode -->
|
||||
<ng-container *ngIf="filter.definition.dataType === 'date' && filter.valueOperator === 'range'">
|
||||
<div class="date-cal-anchor">
|
||||
<div class="date-input-row" (click)="openCal(filter.id, true)">
|
||||
<i class="pi pi-calendar date-input-icon"></i>
|
||||
<span *ngIf="!filter.value || !filter.value[0]" class="date-placeholder" i18n="@@selectDate">Select Date...</span>
|
||||
<span *ngIf="filter.value && filter.value[0] && !filter.value[1]">{{ filter.value[0] | date:'shortDate' }}</span>
|
||||
<span *ngIf="filter.value && filter.value[0] && filter.value[1]">{{ filter.value[0] | date:'shortDate' }} - {{ filter.value[1] | date:'shortDate' }}</span>
|
||||
<i *ngIf="filter.value" class="pi pi-times date-clear-btn" (click)="clearDate($event, filter)"></i>
|
||||
</div>
|
||||
<p-calendar [attr.data-filter-cal-range]="filter.id" [(ngModel)]="filter.value" [locale]="locale" [showIcon]="true"
|
||||
[dateFormat]="locale?.dateFormat || 'mm/dd/yy'" (onSelect)="onValueChange()"
|
||||
(onClearClick)="onValueChange()" [showButtonBar]="true"
|
||||
selectionMode="range" [readonlyInput]="true">
|
||||
</p-calendar>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<!-- Date preset — dropdown with presets + optional custom calendar -->
|
||||
<ng-container *ngIf="filter.definition.dataType === 'date-preset'">
|
||||
<p-dropdown [options]="datePresetOptions"
|
||||
[ngModel]="datePresetSelected.get(filter.id) || null"
|
||||
styleClass="full-width" placeholder="-- Select --"
|
||||
(onChange)="onDatePresetChange(filter, $event)" appendTo="body">
|
||||
</p-dropdown>
|
||||
<ng-container *ngIf="isDatePresetCustom(filter.id)">
|
||||
<div class="date-cal-anchor" style="margin-top: 0.25rem;">
|
||||
<div class="date-input-row" (click)="openCal(filter.id, true)">
|
||||
<i class="pi pi-calendar date-input-icon"></i>
|
||||
<span *ngIf="!filter.value || !filter.value[0]" class="date-placeholder" i18n="@@selectDate">Select Date...</span>
|
||||
<span *ngIf="filter.value && filter.value[0] && !filter.value[1]">{{ filter.value[0] | date:'shortDate' }}</span>
|
||||
<span *ngIf="filter.value && filter.value[0] && filter.value[1]">{{ filter.value[0] | date:'shortDate' }} – {{ filter.value[1] | date:'shortDate' }}</span>
|
||||
<i *ngIf="filter.value && filter.value[0]" class="pi pi-times date-clear-btn" (click)="clearDate($event, filter)"></i>
|
||||
</div>
|
||||
<p-calendar [attr.data-filter-cal-range]="filter.id" [(ngModel)]="filter.value" [locale]="locale" [showIcon]="true"
|
||||
[dateFormat]="locale?.dateFormat || 'mm/dd/yy'" (onSelect)="onValueChange()"
|
||||
(onClearClick)="onValueChange()" [showButtonBar]="true"
|
||||
selectionMode="range" [readonlyInput]="true">
|
||||
</p-calendar>
|
||||
</div>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</div><!-- /.filter-field-body -->
|
||||
</div><!-- /.filter-field-inner -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,439 @@
|
||||
import { Component, ElementRef, EventEmitter, Input, OnChanges, OnInit, Output, SimpleChanges } from '@angular/core';
|
||||
import { SelectItem } from 'primeng/api';
|
||||
|
||||
export type FilterDataType = 'text' | 'select' | 'select-multi' | 'date' | 'date-preset' | 'number';
|
||||
|
||||
export type TextValueOperator = 'contains' | 'startsWith' | 'exact';
|
||||
export type SelectValueOperator = 'multi';
|
||||
export type DateValueOperator = 'before' | 'after' | 'exact' | 'range';
|
||||
export type NumberValueOperator = 'exact' | 'greaterThan' | 'lessThan';
|
||||
export type ValueOperator = TextValueOperator | SelectValueOperator | DateValueOperator | NumberValueOperator;
|
||||
|
||||
export interface FilterDefinition {
|
||||
key: string;
|
||||
label: string;
|
||||
dataType: FilterDataType;
|
||||
options?: SelectItem[];
|
||||
removable?: boolean;
|
||||
}
|
||||
|
||||
export type FilterOperator = 'and' | 'or';
|
||||
|
||||
export interface ActiveFilter {
|
||||
id: number;
|
||||
definition: FilterDefinition;
|
||||
value: any;
|
||||
operator: FilterOperator;
|
||||
valueOperator: ValueOperator;
|
||||
}
|
||||
|
||||
export interface FilterChangeEvent {
|
||||
filters: ActiveFilter[];
|
||||
query: Record<string, any>;
|
||||
}
|
||||
|
||||
export const VALUE_OPERATOR_OPTIONS: Record<FilterDataType, SelectItem[]> = {
|
||||
text: [
|
||||
{ label: 'Contains', value: 'contains' },
|
||||
{ label: 'Starts With', value: 'startsWith' },
|
||||
{ label: 'Is', value: 'exact' },
|
||||
],
|
||||
select: [],
|
||||
'select-multi': [],
|
||||
date: [
|
||||
{ label: 'Before', value: 'before' },
|
||||
{ label: 'After', value: 'after' },
|
||||
{ label: 'Is', value: 'exact' },
|
||||
{ label: 'Between', value: 'range' },
|
||||
],
|
||||
'date-preset': [],
|
||||
number: [
|
||||
{ label: 'Is', value: 'exact' },
|
||||
{ label: 'Greater Than', value: 'greaterThan' },
|
||||
{ label: 'Less Than', value: 'lessThan' },
|
||||
],
|
||||
};
|
||||
|
||||
export const DEFAULT_VALUE_OPERATOR: Record<FilterDataType, ValueOperator> = {
|
||||
text: 'contains',
|
||||
select: 'multi',
|
||||
'select-multi': 'multi',
|
||||
date: 'exact',
|
||||
'date-preset': 'exact',
|
||||
number: 'exact',
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert active filters into a plain query object for API requests.
|
||||
*
|
||||
* Each filter produces a key in the result whose value depends on the
|
||||
* data type and operator. Consumers can map these keys to their own
|
||||
* API parameter names.
|
||||
*/
|
||||
export function buildFilterQuery(activeFilters: ActiveFilter[]): Record<string, any> {
|
||||
const query: Record<string, any> = {};
|
||||
|
||||
for (const f of activeFilters) {
|
||||
if (f.value == null) { continue; }
|
||||
if (f.definition.dataType === 'text' && f.value === '') { continue; }
|
||||
if (f.definition.dataType === 'select' && f.value == null) { continue; }
|
||||
if (f.definition.dataType === 'select-multi' && (!Array.isArray(f.value) || f.value.length === 0)) { continue; }
|
||||
if (f.definition.dataType === 'date' && f.valueOperator === 'range'
|
||||
&& (!Array.isArray(f.value) || f.value[0] == null)) { continue; }
|
||||
if (f.definition.dataType === 'date-preset' && f.value == null) { continue; }
|
||||
if (f.definition.dataType === 'date-preset' && Array.isArray(f.value) && !f.value[0]) { continue; }
|
||||
|
||||
const hasValueOperator = VALUE_OPERATOR_OPTIONS[f.definition.dataType]?.length > 0;
|
||||
const queryValue = (f.definition.dataType === 'date-preset' && Array.isArray(f.value))
|
||||
? f.value.filter((v: any) => v != null)
|
||||
: f.value;
|
||||
query[f.definition.key] = {
|
||||
value: queryValue,
|
||||
operator: f.operator,
|
||||
...(hasValueOperator ? { valueOperator: f.valueOperator } : {}),
|
||||
dataType: f.definition.dataType,
|
||||
};
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'agm-dynamic-filter',
|
||||
templateUrl: './dynamic-filter.component.html',
|
||||
styleUrls: ['./dynamic-filter.component.css']
|
||||
})
|
||||
export class DynamicFilterComponent implements OnInit, OnChanges {
|
||||
@Input() filterDefinitions: FilterDefinition[] = [];
|
||||
@Input() locale: any = {};
|
||||
@Input() stateKey?: string;
|
||||
@Input() defaultFilters: Array<{ key: string; value: any }> = [];
|
||||
@Input() showSearch = true;
|
||||
@Input() autoSaveOnChange = false;
|
||||
|
||||
@Output() filtersChanged = new EventEmitter<FilterChangeEvent>();
|
||||
@Output() filtersSubmit = new EventEmitter<FilterChangeEvent>();
|
||||
|
||||
availableFilters: SelectItem[] = [];
|
||||
selectedFilterKey: string | null = null;
|
||||
activeFilters: ActiveFilter[] = [];
|
||||
datePresetOptions: SelectItem[] = [];
|
||||
datePresetSelected = new Map<number, string>();
|
||||
|
||||
private nextId = 1;
|
||||
|
||||
constructor(private readonly el: ElementRef) {}
|
||||
|
||||
private stateRestored = false;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.buildAvailableFilters();
|
||||
this.buildDatePresetOptions();
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes.filterDefinitions && this.filterDefinitions?.length && !this.stateRestored) {
|
||||
this.restoreState();
|
||||
}
|
||||
}
|
||||
|
||||
private buildDatePresetOptions(): void {
|
||||
const year = new Date().getFullYear();
|
||||
this.datePresetOptions = [
|
||||
{ label: '-- Select --', value: null },
|
||||
{ label: 'Past 1 Month', value: '1m' },
|
||||
{ label: 'Past 3 Months', value: '3m' },
|
||||
{ label: 'Past 6 Months', value: '6m' },
|
||||
{ label: String(year), value: String(year) },
|
||||
{ label: String(year - 1), value: String(year - 1) },
|
||||
{ label: String(year - 2), value: String(year - 2) },
|
||||
{ label: 'Custom', value: 'custom' },
|
||||
];
|
||||
}
|
||||
|
||||
addFilter(): void {
|
||||
if (!this.selectedFilterKey) { return; }
|
||||
const def = this.filterDefinitions.find(f => f.key === this.selectedFilterKey);
|
||||
if (!def) { return; }
|
||||
|
||||
const defaultOp = DEFAULT_VALUE_OPERATOR[def.dataType];
|
||||
const filter: ActiveFilter = {
|
||||
id: this.nextId++,
|
||||
definition: def,
|
||||
value: this.getDefaultValue(def, defaultOp),
|
||||
operator: 'and',
|
||||
valueOperator: defaultOp
|
||||
};
|
||||
|
||||
this.activeFilters.push(filter);
|
||||
if (def.dataType === 'date-preset') {
|
||||
this.datePresetSelected.set(filter.id, filter.value);
|
||||
}
|
||||
this.selectedFilterKey = null;
|
||||
this.buildAvailableFilters();
|
||||
this.emitChange();
|
||||
}
|
||||
|
||||
getValueOperatorOptions(dataType: FilterDataType): SelectItem[] {
|
||||
return VALUE_OPERATOR_OPTIONS[dataType] || [];
|
||||
}
|
||||
|
||||
onValueOperatorChange(filter: ActiveFilter): void {
|
||||
// Reset value when operator changes to avoid type mismatches
|
||||
filter.value = this.getDefaultValue(filter.definition, filter.valueOperator);
|
||||
this.emitChange();
|
||||
}
|
||||
|
||||
removeFilter(id: number): void {
|
||||
const filter = this.activeFilters.find(f => f.id === id);
|
||||
if (!filter || !this.isFilterRemovable(filter)) { return; }
|
||||
|
||||
this.activeFilters = this.activeFilters.filter(f => f.id !== id);
|
||||
this.datePresetSelected.delete(id);
|
||||
this.buildAvailableFilters();
|
||||
this.emitChange();
|
||||
this.submit();
|
||||
}
|
||||
|
||||
onValueChange(): void {
|
||||
this.emitChange();
|
||||
}
|
||||
|
||||
onOperatorChange(): void {
|
||||
this.emitChange();
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
const event: FilterChangeEvent = {
|
||||
filters: [...this.activeFilters],
|
||||
query: buildFilterQuery(this.activeFilters)
|
||||
};
|
||||
this.saveState();
|
||||
this.filtersSubmit.emit(event);
|
||||
}
|
||||
|
||||
clearAll(): void {
|
||||
this.activeFilters = this.activeFilters
|
||||
.filter((filter: ActiveFilter) => !this.isFilterRemovable(filter))
|
||||
.map((filter: ActiveFilter) => this.resetFilter(filter));
|
||||
this.selectedFilterKey = null;
|
||||
this.datePresetSelected.clear();
|
||||
this.activeFilters.forEach((filter: ActiveFilter) => {
|
||||
if (filter.definition.dataType === 'date-preset' && filter.value != null) {
|
||||
this.datePresetSelected.set(filter.id, filter.value);
|
||||
}
|
||||
});
|
||||
this.buildAvailableFilters();
|
||||
this.emitChange();
|
||||
this.submit();
|
||||
}
|
||||
|
||||
onDatePresetChange(filter: ActiveFilter, event: any): void {
|
||||
const key = event.value;
|
||||
if (!key) {
|
||||
filter.value = null;
|
||||
this.datePresetSelected.delete(filter.id);
|
||||
this.emitChange();
|
||||
return;
|
||||
}
|
||||
this.datePresetSelected.set(filter.id, key);
|
||||
if (key === 'custom') {
|
||||
filter.value = null;
|
||||
} else {
|
||||
filter.value = key;
|
||||
}
|
||||
this.emitChange();
|
||||
}
|
||||
|
||||
isDatePresetCustom(filterId: number): boolean {
|
||||
return this.datePresetSelected.get(filterId) === 'custom';
|
||||
}
|
||||
|
||||
isFilterRemovable(filter: ActiveFilter): boolean {
|
||||
return filter.definition.removable !== false;
|
||||
}
|
||||
|
||||
openCal(filterId: number, isRange: boolean): void {
|
||||
const attr = isRange ? `data-filter-cal-range` : `data-filter-cal`;
|
||||
const calHost = this.el.nativeElement.querySelector(`[${attr}="${filterId}"]`);
|
||||
if (calHost) {
|
||||
const btn = calHost.querySelector('.ui-calendar-button') || calHost.querySelector('button');
|
||||
btn?.click();
|
||||
}
|
||||
}
|
||||
|
||||
clearDate(event: Event, filter: ActiveFilter): void {
|
||||
event.stopPropagation();
|
||||
filter.value = filter.valueOperator === 'range' ? null : null;
|
||||
this.emitChange();
|
||||
}
|
||||
|
||||
private buildAvailableFilters(): void {
|
||||
const activeKeys = new Set(this.activeFilters.map(f => f.definition.key));
|
||||
this.availableFilters = [
|
||||
{ label: '-- Select Filter --', value: null },
|
||||
...this.filterDefinitions
|
||||
.filter(f => !activeKeys.has(f.key))
|
||||
.map(f => ({ label: f.label, value: f.key }))
|
||||
];
|
||||
}
|
||||
|
||||
private emitChange(): void {
|
||||
const event: FilterChangeEvent = {
|
||||
filters: [...this.activeFilters],
|
||||
query: buildFilterQuery(this.activeFilters)
|
||||
};
|
||||
if (this.autoSaveOnChange) { this.saveState(); }
|
||||
this.filtersChanged.emit(event);
|
||||
}
|
||||
|
||||
private getDefaultValue(def: FilterDefinition, op: ValueOperator): any {
|
||||
switch (def.dataType) {
|
||||
case 'text': return '';
|
||||
case 'number': return null;
|
||||
case 'select': return null;
|
||||
case 'select-multi': return [];
|
||||
case 'date': return op === 'range' ? null : null;
|
||||
case 'date-preset': return null;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
private saveState(): void {
|
||||
if (!this.stateKey) { return; }
|
||||
if (!this.activeFilters.length) {
|
||||
this.clearState();
|
||||
return;
|
||||
}
|
||||
|
||||
const state = this.activeFilters.map(f => ({
|
||||
key: f.definition.key,
|
||||
value: f.value,
|
||||
operator: f.operator,
|
||||
valueOperator: f.valueOperator,
|
||||
datePreset: this.datePresetSelected.get(f.id) || null
|
||||
}));
|
||||
sessionStorage.setItem(this.stateKey, JSON.stringify(state));
|
||||
}
|
||||
|
||||
private clearState(): void {
|
||||
if (!this.stateKey) { return; }
|
||||
sessionStorage.removeItem(this.stateKey);
|
||||
}
|
||||
|
||||
private applyDefaultFilters(): void {
|
||||
if (!this.defaultFilters?.length) { return; }
|
||||
for (const df of this.defaultFilters) {
|
||||
const def = this.filterDefinitions.find(f => f.key === df.key);
|
||||
if (!def) { continue; }
|
||||
const filter = this.createFilter(def, df.value);
|
||||
this.activeFilters.push(filter);
|
||||
if (def.dataType === 'date-preset') {
|
||||
this.datePresetSelected.set(filter.id, df.value);
|
||||
}
|
||||
}
|
||||
this.ensureRequiredFilters();
|
||||
if (this.activeFilters.length) {
|
||||
this.buildAvailableFilters();
|
||||
this.submit();
|
||||
}
|
||||
}
|
||||
|
||||
private restoreState(): void {
|
||||
if (!this.stateKey) { return; }
|
||||
this.stateRestored = true;
|
||||
const raw = sessionStorage.getItem(this.stateKey);
|
||||
if (!raw) {
|
||||
this.applyDefaultFilters();
|
||||
return;
|
||||
}
|
||||
|
||||
let saved: any[];
|
||||
try { saved = JSON.parse(raw); } catch {
|
||||
this.applyDefaultFilters();
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(saved) || !saved.length) {
|
||||
this.applyDefaultFilters();
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of saved) {
|
||||
const def = this.filterDefinitions.find(f => f.key === entry.key);
|
||||
if (!def) { continue; }
|
||||
|
||||
const filter: ActiveFilter = {
|
||||
...this.createFilter(def, this.deserializeValue(entry.value, def.dataType, entry.valueOperator)),
|
||||
operator: entry.operator || 'and',
|
||||
valueOperator: entry.valueOperator || DEFAULT_VALUE_OPERATOR[def.dataType]
|
||||
};
|
||||
this.activeFilters.push(filter);
|
||||
|
||||
if (def.dataType === 'date-preset' && entry.datePreset) {
|
||||
this.datePresetSelected.set(filter.id, entry.datePreset);
|
||||
}
|
||||
}
|
||||
|
||||
this.ensureRequiredFilters();
|
||||
|
||||
if (this.activeFilters.length) {
|
||||
this.buildAvailableFilters();
|
||||
this.submit();
|
||||
}
|
||||
}
|
||||
|
||||
private ensureRequiredFilters(): void {
|
||||
const activeKeys = new Set(this.activeFilters.map((filter: ActiveFilter) => filter.definition.key));
|
||||
|
||||
this.filterDefinitions
|
||||
.filter((definition: FilterDefinition) => definition.removable === false && !activeKeys.has(definition.key))
|
||||
.forEach((definition: FilterDefinition) => {
|
||||
const defaultFilter = this.defaultFilters.find(df => df.key === definition.key);
|
||||
const filter = this.createFilter(definition, defaultFilter ? defaultFilter.value : undefined);
|
||||
|
||||
this.activeFilters.push(filter);
|
||||
|
||||
if (definition.dataType === 'date-preset' && filter.value != null) {
|
||||
this.datePresetSelected.set(filter.id, filter.value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private createFilter(definition: FilterDefinition, value?: any): ActiveFilter {
|
||||
const defaultOp = DEFAULT_VALUE_OPERATOR[definition.dataType];
|
||||
|
||||
return {
|
||||
id: this.nextId++,
|
||||
definition,
|
||||
value: value !== undefined ? value : this.getDefaultValue(definition, defaultOp),
|
||||
operator: 'and',
|
||||
valueOperator: defaultOp
|
||||
};
|
||||
}
|
||||
|
||||
private resetFilter(filter: ActiveFilter): ActiveFilter {
|
||||
const defaultFilter = this.defaultFilters.find(df => df.key === filter.definition.key);
|
||||
const defaultOp = DEFAULT_VALUE_OPERATOR[filter.definition.dataType];
|
||||
|
||||
return {
|
||||
...filter,
|
||||
value: defaultFilter ? defaultFilter.value : this.getDefaultValue(filter.definition, defaultOp),
|
||||
operator: 'and',
|
||||
valueOperator: defaultOp
|
||||
};
|
||||
}
|
||||
|
||||
private deserializeValue(value: any, dataType: FilterDataType, valueOperator: string): any {
|
||||
if (value == null) { return value; }
|
||||
if (dataType === 'date' && valueOperator === 'range' && Array.isArray(value)) {
|
||||
return value.map(v => v ? new Date(v) : null);
|
||||
}
|
||||
if (dataType === 'date' && typeof value === 'string') {
|
||||
return new Date(value);
|
||||
}
|
||||
if (dataType === 'date-preset' && Array.isArray(value)) {
|
||||
return value.map(v => v ? new Date(v) : null);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@ -4,7 +4,7 @@ export enum RoleIds { ADMIN = "0", APP = "1", APP_ADM = "2", CLIENT = "3", OFFIC
|
||||
|
||||
export enum JobStatus { NEW = 0, READY = 1, DOWNLOADED = 2, SPRAYED = 3, ARCHIVED = 9 };
|
||||
export enum Units { OZ = 0, GAL, LB, LIT, KG, /*GR, CC, PT*/ };
|
||||
export enum DRAW { SPRAY, PIVOT, XCL, WAYPOINT, BUFFER, PLACE, OBSTACLE, ABLINE };
|
||||
export enum DRAW { SPRAY, PIVOT, XCL, WAYPOINT, BUFFER, PLACE, OBSTACLE, ABLINE, EDGE_BUFFER };
|
||||
export enum PANE {
|
||||
GeoItems = 'GeoItems', SprayZones = 'SprayZones', XCLZones = 'XCLZones', GridLines = 'GridLines', FlightPaths = 'FlightPaths', SprayData = 'SprayData',
|
||||
Tracks = 'Tracks', ABLine = 'ABLine', Obstacles = 'Obstacles'
|
||||
@ -628,6 +628,7 @@ export const globals = Object.freeze({
|
||||
xclZone: $localize`:@@xclZone:Exclusion Zone`,
|
||||
waypoint: $localize`:@@waypoint:WayPoint`,
|
||||
bufferZone: $localize`:@@bufferZone:Buffer Zone`,
|
||||
edgeBufferZone: $localize`:@@edgeBufferZone:Advanced Buffer Tools`,
|
||||
placeMark: $localize`:@@placeMark:PlaceMark`,
|
||||
obstacle: $localize`:@@obstacle:Obstacle`,
|
||||
userObstacle: $localize`:@@userObstacle:User Obstacles`,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user