feat: Runtime configuration and Docker deployment improvements
Frontend: - Add runtime configuration service for deployment-time API URL injection - Create docker-entrypoint.sh to generate config.json from environment variables - Update ApiService, ApprovalService, and DocumentViewer to use RuntimeConfigService - Add APP_INITIALIZER to load runtime config before app starts Backend: - Fix init-blockchain.js to properly quote mnemonic phrases in .env file - Improve docker-entrypoint.sh with health checks and better error handling Docker: - Add API_BASE_URL environment variable to frontend container - Update docker-compose.yml with clear documentation for remote deployment - Reorganize .env.example with clear categories (REQUIRED FOR REMOTE, PRODUCTION, AUTO-GENERATED) Workflow fixes: - Fix DepartmentApproval interface to match backend schema - Fix stage transformation for 0-indexed stageOrder - Fix workflow list to show correct stage count from definition.stages Cleanup: - Move development artifacts to .trash directory - Remove root-level package.json (was only for utility scripts) - Add .trash/ to .gitignore
This commit is contained in:
@@ -1,3 +1,9 @@
|
||||
# ==============================================================================
|
||||
# Goa GEL Frontend - Multi-stage Docker Build
|
||||
# ==============================================================================
|
||||
# Supports runtime configuration via environment variables
|
||||
# ==============================================================================
|
||||
|
||||
# Stage 1: Build Angular application
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
@@ -21,9 +27,13 @@ FROM nginx:alpine
|
||||
# Copy custom nginx configuration
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
# Copy built application from builder stage (from browser subdirectory)
|
||||
# Copy built application from builder stage
|
||||
COPY --from=builder /app/dist/goa-gel-frontend/browser /usr/share/nginx/html
|
||||
|
||||
# Copy runtime config script
|
||||
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||
RUN chmod +x /docker-entrypoint.sh
|
||||
|
||||
# Expose port 80
|
||||
EXPOSE 80
|
||||
|
||||
@@ -31,5 +41,6 @@ EXPOSE 80
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget --quiet --tries=1 --spider http://localhost/ || exit 1
|
||||
|
||||
# Start nginx
|
||||
# Use entrypoint to inject runtime config
|
||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
34
frontend/docker-entrypoint.sh
Normal file
34
frontend/docker-entrypoint.sh
Normal file
@@ -0,0 +1,34 @@
|
||||
#!/bin/sh
|
||||
# ==============================================================================
|
||||
# Goa GEL Frontend - Docker Entrypoint
|
||||
# ==============================================================================
|
||||
# Injects runtime configuration from environment variables
|
||||
# ==============================================================================
|
||||
|
||||
set -e
|
||||
|
||||
# Configuration directory in nginx html root
|
||||
CONFIG_DIR="/usr/share/nginx/html/assets"
|
||||
CONFIG_FILE="${CONFIG_DIR}/config.json"
|
||||
|
||||
# Default values (same as build-time defaults)
|
||||
API_BASE_URL="${API_BASE_URL:-http://localhost:3001/api/v1}"
|
||||
|
||||
echo "=== Goa GEL Frontend Runtime Configuration ==="
|
||||
echo "API_BASE_URL: ${API_BASE_URL}"
|
||||
echo "=============================================="
|
||||
|
||||
# Ensure config directory exists
|
||||
mkdir -p "${CONFIG_DIR}"
|
||||
|
||||
# Generate runtime configuration JSON
|
||||
cat > "${CONFIG_FILE}" << EOF
|
||||
{
|
||||
"apiBaseUrl": "${API_BASE_URL}"
|
||||
}
|
||||
EOF
|
||||
|
||||
echo "Runtime config written to ${CONFIG_FILE}"
|
||||
|
||||
# Execute the main command (nginx)
|
||||
exec "$@"
|
||||
3
frontend/e2e/CLAUDE.md
Normal file
3
frontend/e2e/CLAUDE.md
Normal file
@@ -0,0 +1,3 @@
|
||||
<claude-mem-context>
|
||||
|
||||
</claude-mem-context>
|
||||
@@ -62,7 +62,7 @@ test.describe('Authentication', () => {
|
||||
await page.goto('/auth/login');
|
||||
|
||||
// Fill credentials
|
||||
await page.getByLabel('Email').fill('citizen@example.com');
|
||||
await page.getByLabel('Email').fill('rajesh.naik@example.com');
|
||||
await page.getByLabel('Password').fill('Citizen@123');
|
||||
|
||||
// Submit
|
||||
|
||||
@@ -3,7 +3,7 @@ import { test, expect, Page } from '@playwright/test';
|
||||
// Helper functions
|
||||
async function loginAsCitizen(page: Page) {
|
||||
await page.goto('/auth/login');
|
||||
await page.getByLabel('Email').fill('citizen@example.com');
|
||||
await page.getByLabel('Email').fill('rajesh.naik@example.com');
|
||||
await page.getByLabel('Password').fill('Citizen@123');
|
||||
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||
await page.waitForURL('**/dashboard**', { timeout: 10000 });
|
||||
|
||||
@@ -3,7 +3,7 @@ import { test, expect, Page } from '@playwright/test';
|
||||
// Helper function to login as citizen
|
||||
async function loginAsCitizen(page: Page) {
|
||||
await page.goto('/auth/login');
|
||||
await page.getByLabel('Email').fill('citizen@example.com');
|
||||
await page.getByLabel('Email').fill('rajesh.naik@example.com');
|
||||
await page.getByLabel('Password').fill('Citizen@123');
|
||||
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||
await page.waitForURL('**/dashboard**', { timeout: 10000 });
|
||||
|
||||
679
frontend/package-lock.json
generated
679
frontend/package-lock.json
generated
@@ -17,6 +17,7 @@
|
||||
"@angular/material": "^21.1.3",
|
||||
"@angular/platform-browser": "^21.1.0",
|
||||
"@angular/router": "^21.1.0",
|
||||
"pdfjs-dist": "^4.0.379",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
@@ -2242,6 +2243,176 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/node-pre-gyp": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz",
|
||||
"integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.0",
|
||||
"https-proxy-agent": "^5.0.0",
|
||||
"make-dir": "^3.1.0",
|
||||
"node-fetch": "^2.6.7",
|
||||
"nopt": "^5.0.0",
|
||||
"npmlog": "^5.0.1",
|
||||
"rimraf": "^3.0.2",
|
||||
"semver": "^7.3.5",
|
||||
"tar": "^6.1.11"
|
||||
},
|
||||
"bin": {
|
||||
"node-pre-gyp": "bin/node-pre-gyp"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/node-pre-gyp/node_modules/abbrev": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
|
||||
"integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
|
||||
"license": "ISC",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@mapbox/node-pre-gyp/node_modules/agent-base": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/node-pre-gyp/node_modules/chownr": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
|
||||
"integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/node-pre-gyp/node_modules/fs-minipass": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz",
|
||||
"integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"minipass": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/node-pre-gyp/node_modules/fs-minipass/node_modules/minipass": {
|
||||
"version": "3.3.6",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
|
||||
"integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"yallist": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/node-pre-gyp/node_modules/https-proxy-agent": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
||||
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"agent-base": "6",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/node-pre-gyp/node_modules/minipass": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz",
|
||||
"integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/node-pre-gyp/node_modules/minizlib": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz",
|
||||
"integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"minipass": "^3.0.0",
|
||||
"yallist": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/node-pre-gyp/node_modules/minizlib/node_modules/minipass": {
|
||||
"version": "3.3.6",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
|
||||
"integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"yallist": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/node-pre-gyp/node_modules/nopt": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",
|
||||
"integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"abbrev": "1"
|
||||
},
|
||||
"bin": {
|
||||
"nopt": "bin/nopt.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/node-pre-gyp/node_modules/tar": {
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz",
|
||||
"integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==",
|
||||
"deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"chownr": "^2.0.0",
|
||||
"fs-minipass": "^2.0.0",
|
||||
"minipass": "^5.0.0",
|
||||
"minizlib": "^2.1.1",
|
||||
"mkdirp": "^1.0.3",
|
||||
"yallist": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/node-pre-gyp/node_modules/yallist": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
||||
"license": "ISC",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk": {
|
||||
"version": "1.25.2",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.2.tgz",
|
||||
@@ -4669,6 +4840,28 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/aproba": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz",
|
||||
"integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==",
|
||||
"license": "ISC",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/are-we-there-yet": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz",
|
||||
"integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==",
|
||||
"deprecated": "This package is no longer supported.",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"delegates": "^1.0.0",
|
||||
"readable-stream": "^3.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/arg": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
||||
@@ -4755,6 +4948,13 @@
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
@@ -4883,6 +5083,17 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/braces": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||
@@ -5260,6 +5471,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/color-support": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
|
||||
"integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
"color-support": "bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/colorette": {
|
||||
"version": "2.0.20",
|
||||
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
|
||||
@@ -5297,6 +5518,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/concurrently": {
|
||||
"version": "9.2.1",
|
||||
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
|
||||
@@ -5495,6 +5723,13 @@
|
||||
"node": "^14.18.0 || >=16.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/console-control-strings": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
|
||||
"integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==",
|
||||
"license": "ISC",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/console.table": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/console.table/-/console.table-0.10.0.tgz",
|
||||
@@ -5713,7 +5948,7 @@
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
@@ -5772,6 +6007,13 @@
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/delegates": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
|
||||
"integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
@@ -5786,7 +6028,6 @@
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
@@ -5926,7 +6167,6 @@
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
|
||||
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -5937,7 +6177,6 @@
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -6591,6 +6830,13 @@
|
||||
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fs.realpath": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
|
||||
"license": "ISC",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -6616,6 +6862,90 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/gauge": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz",
|
||||
"integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==",
|
||||
"deprecated": "This package is no longer supported.",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"aproba": "^1.0.3 || ^2.0.0",
|
||||
"color-support": "^1.1.2",
|
||||
"console-control-strings": "^1.0.0",
|
||||
"has-unicode": "^2.0.1",
|
||||
"object-assign": "^4.1.1",
|
||||
"signal-exit": "^3.0.0",
|
||||
"string-width": "^4.2.3",
|
||||
"strip-ansi": "^6.0.1",
|
||||
"wide-align": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/gauge/node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/gauge/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/gauge/node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/gauge/node_modules/signal-exit": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
|
||||
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
|
||||
"license": "ISC",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/gauge/node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/gauge/node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/gensync": {
|
||||
"version": "1.0.0-beta.2",
|
||||
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
||||
@@ -6800,6 +7130,13 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-unicode": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
|
||||
"integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==",
|
||||
"license": "ISC",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
@@ -7017,11 +7354,23 @@
|
||||
"node": ">=0.8.19"
|
||||
}
|
||||
},
|
||||
"node_modules/inflight": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
||||
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
|
||||
"deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"once": "^1.3.0",
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ini": {
|
||||
@@ -7873,6 +8222,32 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/make-dir": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
|
||||
"integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"semver": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/make-dir/node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/make-fetch-happen": {
|
||||
"version": "15.0.3",
|
||||
"resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-15.0.3.tgz",
|
||||
@@ -8182,6 +8557,19 @@
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/mkdirp": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
|
||||
"integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
"mkdirp": "bin/cmd.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/mrmime": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
|
||||
@@ -8196,7 +8584,7 @@
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/msgpackr": {
|
||||
@@ -8255,6 +8643,13 @@
|
||||
"thenify-all": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nan": {
|
||||
"version": "2.25.0",
|
||||
"resolved": "https://registry.npmjs.org/nan/-/nan-2.25.0.tgz",
|
||||
"integrity": "sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
@@ -8306,7 +8701,7 @@
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
@@ -8327,21 +8722,21 @@
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-fetch/node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/node-fetch/node_modules/whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tr46": "~0.0.3",
|
||||
@@ -8550,6 +8945,20 @@
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/npmlog": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz",
|
||||
"integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==",
|
||||
"deprecated": "This package is no longer supported.",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"are-we-there-yet": "^2.0.0",
|
||||
"console-control-strings": "^1.1.0",
|
||||
"gauge": "^3.0.0",
|
||||
"set-blocking": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nth-check": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
|
||||
@@ -8567,7 +8976,7 @@
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -8624,7 +9033,7 @@
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"wrappy": "1"
|
||||
@@ -8832,6 +9241,16 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-is-absolute": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
@@ -8887,6 +9306,16 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/path2d-polyfill": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path2d-polyfill/-/path2d-polyfill-2.0.1.tgz",
|
||||
"integrity": "sha512-ad/3bsalbbWhmBo0D6FZ4RNMwsLsPpL6gnvhuSaU5Vm7b06Kr5ubSltQQ0T7YKsiJQO+g22zJ4dJKNTXIyOXtA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
@@ -8894,6 +9323,73 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pdfjs-dist": {
|
||||
"version": "4.0.379",
|
||||
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-4.0.379.tgz",
|
||||
"integrity": "sha512-6H0Gv1nna+wmrr3CakaKlZ4rbrL8hvGIFAgg4YcoFuGC0HC4B2DVjXEGTFjJEjLlf8nYi3C3/MYRcM5bNx0elA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"canvas": "^2.11.2",
|
||||
"path2d-polyfill": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/pdfjs-dist/node_modules/canvas": {
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/canvas/-/canvas-2.11.2.tgz",
|
||||
"integrity": "sha512-ItanGBMrmRV7Py2Z+Xhs7cT+FNt5K0vPL4p9EZ/UX/Mu7hFbkxSjKF2KVtPwX7UYWp7dRKnrTvReflgrItJbdw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@mapbox/node-pre-gyp": "^1.0.0",
|
||||
"nan": "^2.17.0",
|
||||
"simple-get": "^3.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/pdfjs-dist/node_modules/decompress-response": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-4.2.1.tgz",
|
||||
"integrity": "sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"mimic-response": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/pdfjs-dist/node_modules/mimic-response": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-2.1.0.tgz",
|
||||
"integrity": "sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/pdfjs-dist/node_modules/simple-get": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-3.1.1.tgz",
|
||||
"integrity": "sha512-CQ5LTKGfCpvE1K0n2us+kuMPbk/q0EKl82s4aheV9oXjFEz6W/Y7oQFVJuU6QG77hRT4Ghb5RURteF5vnWjupA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"decompress-response": "^4.2.0",
|
||||
"once": "^1.3.1",
|
||||
"simple-concat": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -9336,7 +9832,7 @@
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
@@ -9454,6 +9950,58 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rimraf": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
|
||||
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
|
||||
"deprecated": "Rimraf versions prior to v4 are no longer supported",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"glob": "^7.1.3"
|
||||
},
|
||||
"bin": {
|
||||
"rimraf": "bin.js"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/rimraf/node_modules/glob": {
|
||||
"version": "7.2.3",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
|
||||
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
|
||||
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"fs.realpath": "^1.0.0",
|
||||
"inflight": "^1.0.4",
|
||||
"inherits": "2",
|
||||
"minimatch": "^3.1.1",
|
||||
"once": "^1.3.0",
|
||||
"path-is-absolute": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/rimraf/node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.0.0-beta.58",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-beta.58.tgz",
|
||||
@@ -9595,7 +10143,7 @@
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -9616,7 +10164,7 @@
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sass": {
|
||||
@@ -9687,7 +10235,7 @@
|
||||
"version": "7.7.3",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
||||
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
@@ -9743,6 +10291,13 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"license": "ISC",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
@@ -9900,6 +10455,27 @@
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/simple-concat": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
|
||||
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/slice-ansi": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz",
|
||||
@@ -10089,7 +10665,7 @@
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.2.0"
|
||||
@@ -10702,7 +11278,7 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/validate-npm-package-license": {
|
||||
@@ -11001,6 +11577,71 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/wide-align": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz",
|
||||
"integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"string-width": "^1.0.2 || 2 || 3 || 4"
|
||||
}
|
||||
},
|
||||
"node_modules/wide-align/node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/wide-align/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/wide-align/node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/wide-align/node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/wide-align/node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
@@ -11091,7 +11732,7 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"@angular/material": "^21.1.3",
|
||||
"@angular/platform-browser": "^21.1.0",
|
||||
"@angular/router": "^21.1.0",
|
||||
"pdfjs-dist": "^4.0.379",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface ApprovalResponseDto {
|
||||
requestId: string;
|
||||
departmentId: string;
|
||||
departmentName: string;
|
||||
departmentCode?: string;
|
||||
status: ApprovalStatus;
|
||||
approvedBy?: string;
|
||||
remarks?: string;
|
||||
|
||||
@@ -4,16 +4,19 @@
|
||||
*/
|
||||
|
||||
export type DocumentType =
|
||||
| 'FIRE_SAFETY_CERTIFICATE'
|
||||
| 'BUILDING_PLAN'
|
||||
| 'PROPERTY_OWNERSHIP'
|
||||
| 'INSPECTION_REPORT'
|
||||
| 'POLLUTION_CERTIFICATE'
|
||||
| 'ELECTRICAL_SAFETY_CERTIFICATE'
|
||||
| 'STRUCTURAL_STABILITY_CERTIFICATE'
|
||||
| 'IDENTITY_PROOF'
|
||||
| 'FLOOR_PLAN'
|
||||
| 'PHOTOGRAPH'
|
||||
| 'ID_PROOF'
|
||||
| 'ADDRESS_PROOF'
|
||||
| 'OTHER';
|
||||
| 'NOC'
|
||||
| 'LICENSE_COPY'
|
||||
| 'OTHER'
|
||||
| 'FIRE_SAFETY'
|
||||
| 'HEALTH_CERT'
|
||||
| 'TAX_CLEARANCE'
|
||||
| 'SITE_PLAN'
|
||||
| 'BUILDING_PERMIT'
|
||||
| 'BUSINESS_LICENSE';
|
||||
|
||||
export interface UploadDocumentDto {
|
||||
docType: DocumentType;
|
||||
|
||||
@@ -9,10 +9,13 @@ export type RequestType = 'NEW_LICENSE' | 'RENEWAL' | 'AMENDMENT' | 'MODIFICATIO
|
||||
export type RequestStatus = 'DRAFT' | 'SUBMITTED' | 'IN_REVIEW' | 'PENDING_RESUBMISSION' | 'APPROVED' | 'REJECTED' | 'REVOKED' | 'CANCELLED';
|
||||
|
||||
export interface CreateRequestDto {
|
||||
applicantId: string;
|
||||
applicantName: string;
|
||||
applicantPhone?: string;
|
||||
businessName?: string;
|
||||
requestType: RequestType;
|
||||
workflowId: string;
|
||||
metadata: Record<string, any>;
|
||||
workflowId?: string;
|
||||
workflowCode?: string;
|
||||
metadata?: Record<string, any>;
|
||||
tokenId?: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,25 +3,40 @@
|
||||
* Models for workflow configuration and management
|
||||
*/
|
||||
|
||||
export type ExecutionType = 'SEQUENTIAL' | 'PARALLEL';
|
||||
export type CompletionCriteria = 'ALL' | 'ANY' | 'THRESHOLD';
|
||||
export type RejectionHandling = 'FAIL_REQUEST' | 'RETRY_STAGE' | 'ESCALATE';
|
||||
|
||||
export interface DepartmentApproval {
|
||||
departmentCode: string;
|
||||
departmentName: string;
|
||||
canDelegate: boolean;
|
||||
timeoutDays?: number;
|
||||
}
|
||||
|
||||
export interface WorkflowStage {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
departmentId: string;
|
||||
order: number;
|
||||
isRequired: boolean;
|
||||
stageId: string;
|
||||
stageName: string;
|
||||
stageOrder: number;
|
||||
executionType: ExecutionType;
|
||||
requiredApprovals: DepartmentApproval[];
|
||||
completionCriteria: CompletionCriteria;
|
||||
threshold?: number;
|
||||
timeoutDays?: number;
|
||||
rejectionHandling: RejectionHandling;
|
||||
escalationDepartment?: string;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface CreateWorkflowDto {
|
||||
name: string;
|
||||
description?: string;
|
||||
workflowType: string;
|
||||
departmentId: string;
|
||||
description?: string;
|
||||
stages: WorkflowStage[];
|
||||
onSuccessActions?: string[];
|
||||
onFailureActions?: string[];
|
||||
metadata?: Record<string, any>;
|
||||
createdBy?: string;
|
||||
}
|
||||
|
||||
export interface UpdateWorkflowDto {
|
||||
@@ -35,11 +50,17 @@ export interface UpdateWorkflowDto {
|
||||
export interface WorkflowResponseDto {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
workflowType: string;
|
||||
stages: WorkflowStage[];
|
||||
description?: string;
|
||||
definition: {
|
||||
stages: WorkflowStage[];
|
||||
onSuccessActions?: string[];
|
||||
onFailureActions?: string[];
|
||||
};
|
||||
isActive: boolean;
|
||||
version: number;
|
||||
metadata?: Record<string, any>;
|
||||
createdBy?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -54,12 +75,12 @@ export interface WorkflowPreviewDto {
|
||||
}
|
||||
|
||||
export interface WorkflowStagePreviewDto {
|
||||
id: string;
|
||||
name: string;
|
||||
stageId: string;
|
||||
stageName: string;
|
||||
description?: string;
|
||||
departmentCode: string;
|
||||
departmentName: string;
|
||||
order: number;
|
||||
stageOrder: number;
|
||||
isRequired: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||
import { ApplicationConfig, APP_INITIALIZER, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
|
||||
|
||||
import { routes } from './app.routes';
|
||||
import { authInterceptor, errorInterceptor } from './core/interceptors';
|
||||
import { RuntimeConfigService, initializeApp } from './core/services/runtime-config.service';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
{
|
||||
provide: APP_INITIALIZER,
|
||||
useFactory: initializeApp,
|
||||
deps: [RuntimeConfigService],
|
||||
multi: true,
|
||||
},
|
||||
provideRouter(routes),
|
||||
provideHttpClient(withInterceptors([authInterceptor, errorInterceptor])),
|
||||
provideAnimationsAsync(),
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
shareReplay,
|
||||
of,
|
||||
} from 'rxjs';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { RuntimeConfigService } from './runtime-config.service';
|
||||
|
||||
// Configuration constants
|
||||
const DEFAULT_TIMEOUT_MS = 30000; // 30 seconds
|
||||
@@ -110,6 +110,12 @@ function extractData<T>(response: ApiResponse<T> | null | undefined): T {
|
||||
return response as unknown as T;
|
||||
}
|
||||
|
||||
// Handle paginated responses: have 'data' and pagination fields but no 'success'
|
||||
// These should be returned as-is, not unwrapped
|
||||
if ('data' in response && !('success' in response) && ('total' in response || 'page' in response)) {
|
||||
return response as unknown as T;
|
||||
}
|
||||
|
||||
if (response.data === undefined) {
|
||||
// Return null as T if data is explicitly undefined but response exists
|
||||
return null as T;
|
||||
@@ -132,7 +138,14 @@ function isRetryableError(error: HttpErrorResponse): boolean {
|
||||
})
|
||||
export class ApiService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly baseUrl = environment.apiBaseUrl;
|
||||
private readonly configService = inject(RuntimeConfigService);
|
||||
|
||||
/**
|
||||
* Get API base URL from runtime config (supports deployment-time configuration)
|
||||
*/
|
||||
private get baseUrl(): string {
|
||||
return this.configService.apiBaseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache for GET requests that should be shared
|
||||
@@ -331,7 +344,14 @@ export class ApiService {
|
||||
}
|
||||
|
||||
case HttpEventType.Response: {
|
||||
const responseData = event.body?.data;
|
||||
// Handle both wrapped ({data: ...}) and unwrapped responses
|
||||
const body = event.body;
|
||||
let responseData: T | undefined;
|
||||
if (body && typeof body === 'object' && 'data' in body) {
|
||||
responseData = (body as ApiResponse<T>).data;
|
||||
} else {
|
||||
responseData = body as T | undefined;
|
||||
}
|
||||
return {
|
||||
progress: 100,
|
||||
loaded: 1,
|
||||
|
||||
@@ -295,6 +295,7 @@ export class AuthService implements OnDestroy {
|
||||
name: InputSanitizer.sanitizeName(response.department.name || ''),
|
||||
email: InputSanitizer.sanitizeEmail(response.department.contactEmail || '') || '',
|
||||
departmentCode: InputSanitizer.sanitizeAlphanumeric(response.department.code || '', '_'),
|
||||
departmentId: String(response.department.id),
|
||||
};
|
||||
|
||||
this.storage.setUser(user);
|
||||
|
||||
44
frontend/src/app/core/services/runtime-config.service.ts
Normal file
44
frontend/src/app/core/services/runtime-config.service.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { environment } from '../../../environments/environment';
|
||||
|
||||
export interface RuntimeConfig {
|
||||
apiBaseUrl: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class RuntimeConfigService {
|
||||
private config: RuntimeConfig | null = null;
|
||||
|
||||
/**
|
||||
* Loads runtime configuration from assets/config.json
|
||||
* This allows environment-specific config without rebuilding
|
||||
*/
|
||||
async loadConfig(): Promise<void> {
|
||||
try {
|
||||
const response = await fetch('/assets/config.json');
|
||||
if (response.ok) {
|
||||
const config = await response.json();
|
||||
this.config = config;
|
||||
}
|
||||
} catch {
|
||||
// Config file not found or invalid - use environment defaults
|
||||
console.warn('Runtime config not found, using build-time defaults');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get API base URL - prefers runtime config over build-time environment
|
||||
*/
|
||||
get apiBaseUrl(): string {
|
||||
return this.config?.apiBaseUrl || environment.apiBaseUrl;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function for APP_INITIALIZER
|
||||
*/
|
||||
export function initializeApp(configService: RuntimeConfigService): () => Promise<void> {
|
||||
return () => configService.loadConfig();
|
||||
}
|
||||
@@ -36,8 +36,8 @@ export class StorageService {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if token is expired
|
||||
if (token && TokenValidator.isExpired(token)) {
|
||||
// Check if token is expired (with 60 second buffer for clock skew)
|
||||
if (token && TokenValidator.isExpired(token, 60)) {
|
||||
console.warn('Token expired, clearing...');
|
||||
this.removeToken();
|
||||
return null;
|
||||
@@ -61,11 +61,9 @@ export class StorageService {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if token is already expired
|
||||
if (TokenValidator.isExpired(token)) {
|
||||
console.error('Cannot store expired token');
|
||||
return;
|
||||
}
|
||||
// Note: We don't check expiration here because the token just came from the server.
|
||||
// Clock skew between client and server could cause valid tokens to appear expired.
|
||||
// Expiration is checked when retrieving the token instead.
|
||||
|
||||
this.tokenStorage.setItem(environment.tokenStorageKey, token);
|
||||
}
|
||||
|
||||
@@ -112,6 +112,7 @@ export class TokenValidator {
|
||||
|
||||
/**
|
||||
* Validate token is well-formed and not expired
|
||||
* Uses 120 second buffer for clock skew tolerance
|
||||
*/
|
||||
static validate(token: string | null | undefined): {
|
||||
valid: boolean;
|
||||
@@ -131,7 +132,8 @@ export class TokenValidator {
|
||||
return { valid: false, error: 'Failed to decode token' };
|
||||
}
|
||||
|
||||
if (this.isExpired(token)) {
|
||||
// Use 120 second buffer for clock skew between client and server
|
||||
if (this.isExpired(token, 120)) {
|
||||
return { valid: false, error: 'Token has expired', payload };
|
||||
}
|
||||
|
||||
|
||||
@@ -117,24 +117,24 @@ interface PlatformStats {
|
||||
}
|
||||
|
||||
&.primary {
|
||||
background: linear-gradient(135deg, var(--dbim-blue-dark) 0%, var(--dbim-blue-mid) 100%);
|
||||
color: white;
|
||||
background: linear-gradient(135deg, #1D0A69 0%, #2563EB 100%) !important;
|
||||
color: white !important;
|
||||
}
|
||||
&.success {
|
||||
background: linear-gradient(135deg, #059669 0%, #10b981 100%);
|
||||
color: white;
|
||||
background: linear-gradient(135deg, #059669 0%, #10b981 100%) !important;
|
||||
color: white !important;
|
||||
}
|
||||
&.info {
|
||||
background: linear-gradient(135deg, var(--dbim-info) 0%, #60a5fa 100%);
|
||||
color: white;
|
||||
background: linear-gradient(135deg, #0d6efd 0%, #60a5fa 100%) !important;
|
||||
color: white !important;
|
||||
}
|
||||
&.warning {
|
||||
background: linear-gradient(135deg, #d97706 0%, #f59e0b 100%);
|
||||
color: white;
|
||||
background: linear-gradient(135deg, #d97706 0%, #f59e0b 100%) !important;
|
||||
color: white !important;
|
||||
}
|
||||
&.secondary {
|
||||
background: linear-gradient(135deg, var(--crypto-purple) 0%, var(--crypto-indigo) 100%);
|
||||
color: white;
|
||||
background: linear-gradient(135deg, #8B5CF6 0%, #6366F1 100%) !important;
|
||||
color: white !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,12 +147,13 @@ interface PlatformStats {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
font-size: 28px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
.stat-icon {
|
||||
font-size: 28px !important;
|
||||
width: 28px !important;
|
||||
height: 28px !important;
|
||||
color: white !important;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
@@ -166,12 +167,13 @@ interface PlatformStats {
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
letter-spacing: -0.02em;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
opacity: 0.9;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.9) !important;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
@@ -183,10 +185,11 @@ interface PlatformStats {
|
||||
gap: 16px;
|
||||
|
||||
p {
|
||||
color: var(--dbim-grey-2);
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
`,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -143,34 +143,13 @@ import { BlockchainExplorerMiniComponent } from '../../shared/components';
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
background: linear-gradient(135deg, var(--dbim-blue-dark) 0%, var(--dbim-blue-mid) 100%);
|
||||
background: linear-gradient(135deg, #1D0A69 0%, #2563EB 100%);
|
||||
color: white;
|
||||
padding: 32px;
|
||||
box-shadow: var(--shadow-elevated);
|
||||
box-shadow: 0 4px 20px rgba(29, 10, 105, 0.15);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
right: -20%;
|
||||
width: 60%;
|
||||
height: 200%;
|
||||
background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -50%;
|
||||
left: -10%;
|
||||
width: 40%;
|
||||
height: 150%;
|
||||
background: radial-gradient(circle, rgba(99, 102, 241, 0.2) 0%, transparent 50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
border-radius: 0 0 16px 16px;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
@@ -192,40 +171,81 @@ import { BlockchainExplorerMiniComponent } from '../../shared/components';
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(10px);
|
||||
|
||||
.header-icon {
|
||||
font-size: 32px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.header-text {
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.header-icon {
|
||||
font-size: 32px !important;
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 4px 0 0;
|
||||
opacity: 0.9;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
}
|
||||
.header-text h1 {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.header-text .subtitle {
|
||||
margin: 4px 0 0;
|
||||
color: rgba(255, 255, 255, 0.9) !important;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.admin-content {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.tabs-card {
|
||||
margin-top: 24px;
|
||||
border-radius: 16px !important;
|
||||
overflow: hidden;
|
||||
border-radius: 0 !important;
|
||||
box-shadow: none !important;
|
||||
background: transparent !important;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
:host ::ng-deep .tabs-card .mat-mdc-card {
|
||||
box-shadow: none !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
:host ::ng-deep .mat-mdc-tab-header {
|
||||
background: white;
|
||||
border-radius: 12px 12px 0 0;
|
||||
padding: 8px 8px 0 8px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
:host ::ng-deep .mat-mdc-tab {
|
||||
min-width: 120px;
|
||||
padding: 0 24px;
|
||||
height: 48px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
:host ::ng-deep .mat-mdc-tab.mdc-tab--active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
:host ::ng-deep .mat-mdc-tab-labels {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
:host ::ng-deep .mdc-tab__text-label {
|
||||
color: #1D0A69 !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:host ::ng-deep .mdc-tab-indicator__content--underline {
|
||||
border-color: #1D0A69 !important;
|
||||
border-width: 3px !important;
|
||||
border-radius: 3px 3px 0 0;
|
||||
}
|
||||
|
||||
.tab-icon {
|
||||
@@ -233,11 +253,14 @@ import { BlockchainExplorerMiniComponent } from '../../shared/components';
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: #1D0A69;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
padding: 24px;
|
||||
background: var(--dbim-white);
|
||||
background: white;
|
||||
border-radius: 0 0 12px 12px;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.section-divider {
|
||||
@@ -248,10 +271,6 @@ import { BlockchainExplorerMiniComponent } from '../../shared/components';
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 400px;
|
||||
gap: 24px;
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-main {
|
||||
@@ -261,25 +280,6 @@ import { BlockchainExplorerMiniComponent } from '../../shared/components';
|
||||
.dashboard-sidebar {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:host ::ng-deep {
|
||||
.mat-mdc-tab-label {
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.mat-mdc-tab-header {
|
||||
background: var(--dbim-linen);
|
||||
border-bottom: 1px solid rgba(29, 10, 105, 0.08);
|
||||
}
|
||||
|
||||
.mat-mdc-tab:not(.mat-mdc-tab-disabled).mdc-tab--active .mdc-tab__text-label {
|
||||
color: var(--dbim-blue-dark);
|
||||
}
|
||||
|
||||
.mat-mdc-tab-body-wrapper {
|
||||
background: var(--dbim-white);
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -13,6 +13,7 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatDialogModule, MatDialog } from '@angular/material/dialog';
|
||||
import { ApiService } from '../../../core/services/api.service';
|
||||
import { TransactionDetailDialogComponent } from '../../../shared/components/blockchain-explorer-mini/transaction-detail-dialog.component';
|
||||
|
||||
interface BlockchainTransaction {
|
||||
id: string;
|
||||
@@ -295,19 +296,19 @@ interface PaginatedResponse {
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
border-radius: 12px;
|
||||
color: white;
|
||||
|
||||
&.confirmed { background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%); }
|
||||
&.pending { background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%); }
|
||||
&.failed { background: linear-gradient(135deg, #fa709a 0%, #fee140 100%); }
|
||||
&.total { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }
|
||||
&.confirmed { background: linear-gradient(135deg, #059669 0%, #10b981 100%); }
|
||||
&.pending { background: linear-gradient(135deg, #1D0A69 0%, #2563EB 100%); }
|
||||
&.failed { background: linear-gradient(135deg, #DC3545 0%, #e74c3c 100%); }
|
||||
&.total { background: linear-gradient(135deg, #8B5CF6 0%, #6366F1 100%); }
|
||||
|
||||
mat-icon {
|
||||
font-size: 40px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
opacity: 0.9;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,11 +319,12 @@ interface PaginatedResponse {
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.875rem;
|
||||
opacity: 0.9;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
@@ -491,7 +493,29 @@ export class TransactionDashboardComponent implements OnInit {
|
||||
}
|
||||
|
||||
viewTransactionDetails(tx: BlockchainTransaction): void {
|
||||
alert(`Transaction Details:\n\n${JSON.stringify(tx, null, 2)}`);
|
||||
// Convert to BlockchainTransactionDto format for the dialog
|
||||
const dialogData = {
|
||||
id: tx.id,
|
||||
txHash: tx.transactionHash || (tx as any).txHash,
|
||||
type: (tx as any).txType || 'TRANSACTION',
|
||||
status: tx.status,
|
||||
gasUsed: tx.gasUsed ? parseInt(tx.gasUsed, 10) : undefined,
|
||||
blockNumber: tx.blockNumber,
|
||||
timestamp: tx.createdAt,
|
||||
data: {
|
||||
from: tx.from || (tx as any).fromAddress,
|
||||
to: tx.to || (tx as any).toAddress,
|
||||
value: tx.value,
|
||||
requestId: tx.requestId || (tx as any).relatedEntityId,
|
||||
},
|
||||
};
|
||||
|
||||
this.dialog.open(TransactionDetailDialogComponent, {
|
||||
data: dialogData,
|
||||
width: '600px',
|
||||
maxHeight: '90vh',
|
||||
panelClass: 'blockchain-detail-dialog',
|
||||
});
|
||||
}
|
||||
|
||||
getStatusColor(status: string): string {
|
||||
|
||||
@@ -149,14 +149,19 @@ export class ApprovalActionComponent {
|
||||
];
|
||||
|
||||
readonly documentTypes: { value: DocumentType; label: string }[] = [
|
||||
{ value: 'FIRE_SAFETY_CERTIFICATE', label: 'Fire Safety Certificate' },
|
||||
{ value: 'BUILDING_PLAN', label: 'Building Plan' },
|
||||
{ value: 'PROPERTY_OWNERSHIP', label: 'Property Ownership' },
|
||||
{ value: 'INSPECTION_REPORT', label: 'Inspection Report' },
|
||||
{ value: 'POLLUTION_CERTIFICATE', label: 'Pollution Certificate' },
|
||||
{ value: 'ELECTRICAL_SAFETY_CERTIFICATE', label: 'Electrical Safety' },
|
||||
{ value: 'IDENTITY_PROOF', label: 'Identity Proof' },
|
||||
{ value: 'ID_PROOF', label: 'Identity Proof' },
|
||||
{ value: 'ADDRESS_PROOF', label: 'Address Proof' },
|
||||
{ value: 'FIRE_SAFETY', label: 'Fire Safety Certificate' },
|
||||
{ value: 'FLOOR_PLAN', label: 'Floor Plan' },
|
||||
{ value: 'SITE_PLAN', label: 'Site Plan' },
|
||||
{ value: 'BUILDING_PERMIT', label: 'Building Permit' },
|
||||
{ value: 'BUSINESS_LICENSE', label: 'Business License' },
|
||||
{ value: 'PHOTOGRAPH', label: 'Photograph' },
|
||||
{ value: 'NOC', label: 'No Objection Certificate' },
|
||||
{ value: 'LICENSE_COPY', label: 'License Copy' },
|
||||
{ value: 'HEALTH_CERT', label: 'Health Certificate' },
|
||||
{ value: 'TAX_CLEARANCE', label: 'Tax Clearance' },
|
||||
{ value: 'OTHER', label: 'Other Document' },
|
||||
];
|
||||
|
||||
readonly form = this.fb.nonNullable.group({
|
||||
@@ -266,7 +271,9 @@ export class ApprovalActionComponent {
|
||||
},
|
||||
error: (err) => {
|
||||
this.submitting.set(false);
|
||||
this.notification.error(err?.error?.message || 'Failed to process action. Please try again.');
|
||||
// Handle different error formats
|
||||
const errorMessage = err?.error?.message || err?.message || 'Failed to process action. Please try again.';
|
||||
this.notification.error(errorMessage);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -191,7 +191,7 @@ export class ApprovalHistoryComponent implements OnInit {
|
||||
private loadHistory(): void {
|
||||
this.approvalService.getApprovalHistory(this.requestId).subscribe({
|
||||
next: (data) => {
|
||||
this.approvals.set(data);
|
||||
this.approvals.set(data ?? []);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -37,6 +37,7 @@ import { ApprovalResponseDto } from '../../../api/models';
|
||||
template: `
|
||||
<div class="page-container">
|
||||
<app-page-header
|
||||
icon="pending_actions"
|
||||
title="Pending Approvals"
|
||||
subtitle="Review and approve license requests"
|
||||
/>
|
||||
@@ -47,6 +48,17 @@ import { ApprovalResponseDto } from '../../../api/models';
|
||||
<div class="loading-container">
|
||||
<mat-spinner diameter="48"></mat-spinner>
|
||||
</div>
|
||||
} @else if (hasError()) {
|
||||
<app-empty-state
|
||||
icon="error_outline"
|
||||
title="Failed to load approvals"
|
||||
message="There was an error loading the pending approvals. Please try again."
|
||||
>
|
||||
<button mat-raised-button color="primary" (click)="retryLoad()">
|
||||
<mat-icon>refresh</mat-icon>
|
||||
Retry
|
||||
</button>
|
||||
</app-empty-state>
|
||||
} @else if (approvals().length === 0) {
|
||||
<app-empty-state
|
||||
icon="check_circle"
|
||||
@@ -79,30 +91,32 @@ import { ApprovalResponseDto } from '../../../api/models';
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef>Actions</th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
(click)="openApprovalDialog(row, 'approve')"
|
||||
>
|
||||
<mat-icon>check</mat-icon>
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
mat-raised-button
|
||||
color="warn"
|
||||
(click)="openApprovalDialog(row, 'reject')"
|
||||
style="margin-left: 8px"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
Reject
|
||||
</button>
|
||||
<button
|
||||
mat-button
|
||||
(click)="openApprovalDialog(row, 'changes')"
|
||||
style="margin-left: 8px"
|
||||
>
|
||||
Request Changes
|
||||
</button>
|
||||
<div class="action-buttons">
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
(click)="openApprovalDialog(row, 'approve')"
|
||||
>
|
||||
<mat-icon>check</mat-icon>
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
mat-raised-button
|
||||
color="warn"
|
||||
(click)="openApprovalDialog(row, 'reject')"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
Reject
|
||||
</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
color="accent"
|
||||
(click)="openApprovalDialog(row, 'changes')"
|
||||
>
|
||||
<mat-icon>edit_note</mat-icon>
|
||||
Request Changes
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
@@ -146,9 +160,21 @@ import { ApprovalResponseDto } from '../../../api/models';
|
||||
}
|
||||
|
||||
.mat-column-actions {
|
||||
width: 300px;
|
||||
width: 380px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
flex-wrap: nowrap;
|
||||
|
||||
button {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
@@ -184,9 +210,10 @@ export class PendingListComponent implements OnInit, OnDestroy {
|
||||
this.totalItems.set(response?.total ?? 0);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
error: (err) => {
|
||||
this.hasError.set(true);
|
||||
this.loading.set(false);
|
||||
this.notification.error(err?.message || 'Failed to load pending approvals');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable, throwError, map, catchError } from 'rxjs';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable, throwError, map, catchError, timeout } from 'rxjs';
|
||||
import { ApiService, validateId, validatePagination } from '../../../core/services/api.service';
|
||||
import { RuntimeConfigService } from '../../../core/services/runtime-config.service';
|
||||
import {
|
||||
ApprovalResponseDto,
|
||||
PaginatedApprovalsResponse,
|
||||
@@ -22,11 +24,33 @@ export interface RequestChangesDto {
|
||||
requiredDocuments: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Backend paginated response with meta wrapper
|
||||
*/
|
||||
interface BackendPaginatedResponse {
|
||||
data: ApprovalResponseDto[];
|
||||
meta?: {
|
||||
total?: number;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
totalPages?: number;
|
||||
hasNext?: boolean;
|
||||
hasPrev?: boolean;
|
||||
};
|
||||
// Also support flat pagination fields
|
||||
total?: number;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
totalPages?: number;
|
||||
hasNextPage?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures response has valid data array for paginated approvals
|
||||
* Handles both { data, meta: {...} } and { data, total, page, ... } formats
|
||||
*/
|
||||
function ensureValidPaginatedResponse(
|
||||
response: PaginatedApprovalsResponse | null | undefined,
|
||||
response: BackendPaginatedResponse | null | undefined,
|
||||
page: number,
|
||||
limit: number
|
||||
): PaginatedApprovalsResponse {
|
||||
@@ -41,13 +65,21 @@ function ensureValidPaginatedResponse(
|
||||
};
|
||||
}
|
||||
|
||||
// Extract pagination from meta object if present, otherwise use flat fields
|
||||
const meta = response.meta;
|
||||
const total = meta?.total ?? response.total ?? 0;
|
||||
const responsePage = meta?.page ?? response.page ?? page;
|
||||
const responseLimit = meta?.limit ?? response.limit ?? limit;
|
||||
const totalPages = meta?.totalPages ?? response.totalPages ?? 0;
|
||||
const hasNextPage = meta?.hasNext ?? response.hasNextPage ?? false;
|
||||
|
||||
return {
|
||||
data: Array.isArray(response.data) ? response.data : [],
|
||||
total: typeof response.total === 'number' && response.total >= 0 ? response.total : 0,
|
||||
page: typeof response.page === 'number' && response.page >= 1 ? response.page : page,
|
||||
limit: typeof response.limit === 'number' && response.limit >= 1 ? response.limit : limit,
|
||||
totalPages: typeof response.totalPages === 'number' && response.totalPages >= 0 ? response.totalPages : 0,
|
||||
hasNextPage: typeof response.hasNextPage === 'boolean' ? response.hasNextPage : false,
|
||||
total: typeof total === 'number' && total >= 0 ? total : 0,
|
||||
page: typeof responsePage === 'number' && responsePage >= 1 ? responsePage : page,
|
||||
limit: typeof responseLimit === 'number' && responseLimit >= 1 ? responseLimit : limit,
|
||||
totalPages: typeof totalPages === 'number' && totalPages >= 0 ? totalPages : 0,
|
||||
hasNextPage: typeof hasNextPage === 'boolean' ? hasNextPage : false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -102,16 +134,25 @@ function validateDocumentIds(docs: string[] | undefined | null): string[] {
|
||||
})
|
||||
export class ApprovalService {
|
||||
private readonly api = inject(ApiService);
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly configService = inject(RuntimeConfigService);
|
||||
|
||||
private get baseUrl(): string {
|
||||
return this.configService.apiBaseUrl;
|
||||
}
|
||||
|
||||
getPendingApprovals(page = 1, limit = 10): Observable<PaginatedApprovalsResponse> {
|
||||
const validated = validatePagination(page, limit);
|
||||
|
||||
return this.api
|
||||
.get<PaginatedApprovalsResponse>('/approvals/pending', {
|
||||
page: validated.page,
|
||||
limit: validated.limit,
|
||||
})
|
||||
// Use HttpClient directly to avoid extractData unwrapping the paginated response
|
||||
const params = new HttpParams()
|
||||
.set('page', validated.page.toString())
|
||||
.set('limit', validated.limit.toString());
|
||||
|
||||
return this.http
|
||||
.get<BackendPaginatedResponse>(`${this.baseUrl}/approvals/pending`, { params })
|
||||
.pipe(
|
||||
timeout(30000),
|
||||
map((response) => ensureValidPaginatedResponse(response, validated.page, validated.limit)),
|
||||
catchError((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : 'Failed to fetch pending approvals';
|
||||
@@ -124,7 +165,7 @@ export class ApprovalService {
|
||||
try {
|
||||
const validId = validateId(requestId, 'Request ID');
|
||||
|
||||
return this.api.get<ApprovalResponseDto[]>(`/requests/${validId}/approvals`).pipe(
|
||||
return this.api.get<ApprovalResponseDto[]>(`/approvals/requests/${validId}`).pipe(
|
||||
map((response) => ensureValidArray(response)),
|
||||
catchError((error: unknown) => {
|
||||
const message =
|
||||
@@ -178,7 +219,7 @@ export class ApprovalService {
|
||||
reviewedDocuments: validateDocumentIds(dto.reviewedDocuments),
|
||||
};
|
||||
|
||||
return this.api.post<ApprovalResponseDto>(`/requests/${validId}/approve`, sanitizedDto).pipe(
|
||||
return this.api.post<ApprovalResponseDto>(`/approvals/${validId}/approve`, sanitizedDto).pipe(
|
||||
catchError((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : `Failed to approve request: ${requestId}`;
|
||||
return throwError(() => new Error(message));
|
||||
@@ -201,12 +242,12 @@ export class ApprovalService {
|
||||
return throwError(() => new Error('Rejection reason is required'));
|
||||
}
|
||||
|
||||
const sanitizedDto: RejectRequestDto = {
|
||||
const sanitizedDto = {
|
||||
remarks: validateRemarks(dto.remarks),
|
||||
rejectionReason: dto.rejectionReason,
|
||||
reason: dto.rejectionReason, // Backend expects 'reason' not 'rejectionReason'
|
||||
};
|
||||
|
||||
return this.api.post<ApprovalResponseDto>(`/requests/${validId}/reject`, sanitizedDto).pipe(
|
||||
return this.api.post<ApprovalResponseDto>(`/approvals/${validId}/reject`, sanitizedDto).pipe(
|
||||
catchError((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : `Failed to reject request: ${requestId}`;
|
||||
return throwError(() => new Error(message));
|
||||
@@ -236,7 +277,7 @@ export class ApprovalService {
|
||||
};
|
||||
|
||||
return this.api
|
||||
.post<ApprovalResponseDto>(`/requests/${validId}/request-changes`, sanitizedDto)
|
||||
.post<ApprovalResponseDto>(`/approvals/requests/${validId}/request-changes`, sanitizedDto)
|
||||
.pipe(
|
||||
catchError((error: unknown) => {
|
||||
const message =
|
||||
@@ -253,7 +294,8 @@ export class ApprovalService {
|
||||
try {
|
||||
const validId = validateId(requestId, 'Request ID');
|
||||
|
||||
return this.api.get<ApprovalResponseDto[]>(`/requests/${validId}/approval-history`).pipe(
|
||||
// Use the existing approvals-by-request endpoint which returns all approvals for a request
|
||||
return this.api.get<ApprovalResponseDto[]>(`/approvals/requests/${validId}`).pipe(
|
||||
map((response) => ensureValidArray(response)),
|
||||
catchError((error: unknown) => {
|
||||
const message =
|
||||
|
||||
@@ -101,7 +101,13 @@ import { AuditLogDto, AuditAction, ActorType } from '../../../api/models';
|
||||
<table mat-table [dataSource]="logs()">
|
||||
<ng-container matColumnDef="timestamp">
|
||||
<th mat-header-cell *matHeaderCellDef>Timestamp</th>
|
||||
<td mat-cell *matCellDef="let row">{{ row.timestamp | date: 'medium' }}</td>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
@if (row.timestamp || row.createdAt) {
|
||||
<span class="timestamp-value">{{ (row.timestamp || row.createdAt) | date: 'medium' }}</span>
|
||||
} @else {
|
||||
<span class="timestamp-missing">-</span>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="action">
|
||||
@@ -212,6 +218,16 @@ import { AuditLogDto, AuditAction, ActorType } from '../../../api/models';
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.timestamp-value {
|
||||
font-size: 0.875rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.timestamp-missing {
|
||||
color: #999;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.action-create {
|
||||
background-color: #c8e6c9 !important;
|
||||
color: #2e7d32 !important;
|
||||
|
||||
@@ -276,7 +276,7 @@ export class EntityTrailComponent implements OnInit {
|
||||
private loadTrail(): void {
|
||||
this.auditService.getEntityTrail(this.entityType(), this.entityId()).subscribe({
|
||||
next: (trail) => {
|
||||
this.events.set(trail.events);
|
||||
this.events.set(trail?.events ?? []);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
|
||||
@@ -2,14 +2,12 @@ import { Component } from '@angular/core';
|
||||
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
|
||||
import { Router, RouterModule } from '@angular/router';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { MatDividerModule } from '@angular/material/divider';
|
||||
import { AuthService } from '../../../core/services/auth.service';
|
||||
import { InputSanitizer } from '../../../core/utils/input-sanitizer';
|
||||
|
||||
@@ -28,291 +26,422 @@ interface DemoAccount {
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
RouterModule,
|
||||
MatCardModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatSnackBarModule,
|
||||
MatDividerModule,
|
||||
],
|
||||
template: `
|
||||
<div class="email-login-container">
|
||||
<mat-card class="login-card">
|
||||
<mat-card-header>
|
||||
<mat-card-title>
|
||||
<mat-icon class="logo-icon">verified_user</mat-icon>
|
||||
<h2>Goa GEL Platform</h2>
|
||||
</mat-card-title>
|
||||
<p class="subtitle">Government e-License Platform</p>
|
||||
</mat-card-header>
|
||||
<div class="login-form">
|
||||
<!-- Header -->
|
||||
<div class="form-header">
|
||||
<div class="header-glow"></div>
|
||||
<h1 class="form-title">Sign In</h1>
|
||||
<p class="form-subtitle">Enter your credentials to continue</p>
|
||||
</div>
|
||||
|
||||
<mat-card-content>
|
||||
<form [formGroup]="loginForm" (ngSubmit)="onSubmit()">
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Email</mat-label>
|
||||
<input matInput type="email" formControlName="email" placeholder="Enter your email" />
|
||||
<mat-icon matPrefix>email</mat-icon>
|
||||
<mat-error *ngIf="loginForm.get('email')?.hasError('required')">
|
||||
Email is required
|
||||
</mat-error>
|
||||
<mat-error *ngIf="loginForm.get('email')?.hasError('email')">
|
||||
Please enter a valid email
|
||||
</mat-error>
|
||||
<mat-error *ngIf="loginForm.get('email')?.hasError('maxlength')">
|
||||
Email is too long
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
<!-- Login Form -->
|
||||
<form [formGroup]="loginForm" (ngSubmit)="onSubmit()">
|
||||
<div class="form-field">
|
||||
<label class="field-label">Email</label>
|
||||
<div class="input-wrapper">
|
||||
<mat-icon class="input-icon">email</mat-icon>
|
||||
<input
|
||||
type="email"
|
||||
formControlName="email"
|
||||
placeholder="Enter your email"
|
||||
class="form-input"
|
||||
/>
|
||||
</div>
|
||||
<span class="field-error" *ngIf="loginForm.get('email')?.touched && loginForm.get('email')?.hasError('required')">
|
||||
Email is required
|
||||
</span>
|
||||
<span class="field-error" *ngIf="loginForm.get('email')?.touched && loginForm.get('email')?.hasError('email')">
|
||||
Please enter a valid email
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Password</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[type]="hidePassword ? 'password' : 'text'"
|
||||
formControlName="password"
|
||||
placeholder="Enter your password"
|
||||
/>
|
||||
<mat-icon matPrefix>lock</mat-icon>
|
||||
<button
|
||||
mat-icon-button
|
||||
matSuffix
|
||||
type="button"
|
||||
(click)="hidePassword = !hidePassword"
|
||||
>
|
||||
<mat-icon>{{ hidePassword ? 'visibility_off' : 'visibility' }}</mat-icon>
|
||||
</button>
|
||||
<mat-error *ngIf="loginForm.get('password')?.hasError('required')">
|
||||
Password is required
|
||||
</mat-error>
|
||||
<mat-error *ngIf="loginForm.get('password')?.hasError('minlength')">
|
||||
Password must be at least 8 characters
|
||||
</mat-error>
|
||||
<mat-error *ngIf="loginForm.get('password')?.hasError('maxlength')">
|
||||
Password is too long
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
type="submit"
|
||||
class="full-width login-button"
|
||||
[disabled]="loginForm.invalid || loading"
|
||||
>
|
||||
<mat-spinner *ngIf="loading" diameter="20"></mat-spinner>
|
||||
<span *ngIf="!loading">Sign In</span>
|
||||
<div class="form-field">
|
||||
<label class="field-label">Password</label>
|
||||
<div class="input-wrapper">
|
||||
<mat-icon class="input-icon">lock</mat-icon>
|
||||
<input
|
||||
[type]="hidePassword ? 'password' : 'text'"
|
||||
formControlName="password"
|
||||
placeholder="Enter your password"
|
||||
class="form-input"
|
||||
/>
|
||||
<button type="button" class="toggle-password" (click)="hidePassword = !hidePassword">
|
||||
<mat-icon>{{ hidePassword ? 'visibility_off' : 'visibility' }}</mat-icon>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<span class="field-error" *ngIf="loginForm.get('password')?.touched && loginForm.get('password')?.hasError('required')">
|
||||
Password is required
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<mat-divider class="divider"></mat-divider>
|
||||
<button type="submit" class="submit-btn" [disabled]="loginForm.invalid || loading">
|
||||
<mat-spinner *ngIf="loading" diameter="20"></mat-spinner>
|
||||
<span *ngIf="!loading">Sign In</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="demo-accounts">
|
||||
<h3 class="demo-title">
|
||||
<mat-icon>info</mat-icon>
|
||||
Demo Accounts
|
||||
</h3>
|
||||
<p class="demo-subtitle">Click any account to auto-fill credentials</p>
|
||||
<!-- Demo Credentials Toggle -->
|
||||
<button class="demo-toggle" (click)="showDemo = !showDemo">
|
||||
<mat-icon>{{ showDemo ? 'expand_less' : 'science' }}</mat-icon>
|
||||
<span>{{ showDemo ? 'Hide Demo Accounts' : 'View Demo Accounts' }}</span>
|
||||
</button>
|
||||
|
||||
<div class="demo-grid">
|
||||
<div
|
||||
*ngFor="let account of demoAccounts"
|
||||
class="demo-card"
|
||||
(click)="fillDemoCredentials(account)"
|
||||
[class.selected]="selectedDemo === account.email"
|
||||
>
|
||||
<mat-icon [style.color]="getRoleColor(account.role)">{{ account.icon }}</mat-icon>
|
||||
<div class="demo-info">
|
||||
<strong>{{ account.role }}</strong>
|
||||
<span class="demo-email">{{ account.email }}</span>
|
||||
<span class="demo-description">{{ account.description }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="credentials-note">
|
||||
<mat-icon>security</mat-icon>
|
||||
<span>All demo accounts use the same password format: <code>Role@123</code></span>
|
||||
<!-- Collapsible Demo Panel -->
|
||||
<div class="demo-panel" [class.expanded]="showDemo">
|
||||
<div class="demo-panel-content">
|
||||
<div
|
||||
*ngFor="let account of demoAccounts"
|
||||
class="demo-account"
|
||||
(click)="fillDemoCredentials(account)"
|
||||
[class.selected]="selectedDemo === account.email"
|
||||
>
|
||||
<mat-icon [style.color]="getRoleColor(account.role)">{{ account.icon }}</mat-icon>
|
||||
<div class="account-info">
|
||||
<span class="account-role">{{ account.role }}</span>
|
||||
<span class="account-email">{{ account.email }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
<div class="password-hint">
|
||||
<mat-icon>vpn_key</mat-icon>
|
||||
<span>Password: <code>Role@123</code></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Back Link -->
|
||||
<a [routerLink]="['/auth']" class="back-link">
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
<span>Other login options</span>
|
||||
</a>
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.email-login-container {
|
||||
min-height: 100vh;
|
||||
// =============================================================================
|
||||
// EMAIL LOGIN - Dark Glass-Morphism Theme
|
||||
// =============================================================================
|
||||
|
||||
.login-form {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// HEADER
|
||||
// =============================================================================
|
||||
.form-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.header-glow {
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 200px;
|
||||
height: 100px;
|
||||
background: radial-gradient(ellipse, rgba(99, 102, 241, 0.2) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.form-title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, #FFFFFF 0%, #c7d2fe 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.form-subtitle {
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// FORM FIELDS
|
||||
// =============================================================================
|
||||
.form-field {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.input-icon {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 14px 14px 14px 46px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
font-size: 15px;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
transition: all 0.2s ease;
|
||||
outline: none;
|
||||
|
||||
&::placeholder {
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(99, 102, 241, 0.5);
|
||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15);
|
||||
}
|
||||
|
||||
&:hover:not(:focus) {
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.toggle-password {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
transition: color 0.2s ease;
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
.field-error {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #f87171;
|
||||
margin-top: 6px;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SUBMIT BUTTON
|
||||
// =============================================================================
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
background: linear-gradient(135deg, #6366F1 0%, #818cf8 100%);
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.25s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
mat-card-header {
|
||||
display: block;
|
||||
text-align: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
mat-card-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
&:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #4F46E5 0%, #6366F1 100%);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 8px 20px rgba(99, 102, 241, 0.3);
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
font-size: 48px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: #1976d2;
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 500;
|
||||
color: #1976d2;
|
||||
}
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
text-align: center;
|
||||
color: rgba(0, 0, 0, 0.54);
|
||||
margin: 8px 0 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
height: 48px;
|
||||
font-size: 16px;
|
||||
margin-top: 8px;
|
||||
|
||||
mat-spinner {
|
||||
display: inline-block;
|
||||
margin-right: 8px;
|
||||
::ng-deep circle {
|
||||
stroke: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.divider {
|
||||
margin: 32px 0 24px;
|
||||
}
|
||||
|
||||
.demo-accounts {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.demo-title {
|
||||
// =============================================================================
|
||||
// DEMO TOGGLE & PANEL
|
||||
// =============================================================================
|
||||
.demo-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin: 0 0 8px;
|
||||
font-size: 1.125rem;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: transparent;
|
||||
border: 1px dashed rgba(255, 255, 255, 0.15);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #1976d2;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-color: rgba(99, 102, 241, 0.3);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
.demo-subtitle {
|
||||
color: rgba(0, 0, 0, 0.54);
|
||||
font-size: 0.875rem;
|
||||
margin: 0 0 16px;
|
||||
.demo-panel {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
&.expanded {
|
||||
max-height: 350px;
|
||||
}
|
||||
}
|
||||
|
||||
.demo-grid {
|
||||
.demo-panel-content {
|
||||
padding-top: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.demo-card {
|
||||
.demo-account {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
mat-icon {
|
||||
font-size: 24px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: #1976d2;
|
||||
background-color: #f5f5f5;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-color: rgba(99, 102, 241, 0.3);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border-color: #1976d2;
|
||||
background-color: #e3f2fd;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 32px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
border-color: rgba(99, 102, 241, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
.demo-info {
|
||||
.account-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
gap: 2px;
|
||||
|
||||
strong {
|
||||
font-size: 0.875rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.demo-email {
|
||||
font-size: 0.75rem;
|
||||
color: #666;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.demo-description {
|
||||
font-size: 0.75rem;
|
||||
color: rgba(0, 0, 0, 0.54);
|
||||
}
|
||||
}
|
||||
|
||||
.credentials-note {
|
||||
.account-role {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.account-email {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-family: 'SF Mono', monospace;
|
||||
}
|
||||
|
||||
.password-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
background-color: #fff3e0;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
color: #e65100;
|
||||
padding: 10px 14px;
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
border: 1px solid rgba(16, 185, 129, 0.2);
|
||||
border-radius: 10px;
|
||||
margin-top: 4px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: #34d399;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
code {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-family: 'SF Mono', monospace;
|
||||
color: #34d399;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// BACK LINK
|
||||
// =============================================================================
|
||||
.back-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
margin-top: 24px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: #818cf8;
|
||||
}
|
||||
}
|
||||
`,
|
||||
@@ -322,6 +451,7 @@ export class EmailLoginComponent {
|
||||
loginForm: FormGroup;
|
||||
loading = false;
|
||||
hidePassword = true;
|
||||
showDemo = false;
|
||||
selectedDemo: string | null = null;
|
||||
|
||||
demoAccounts: DemoAccount[] = [
|
||||
@@ -329,35 +459,28 @@ export class EmailLoginComponent {
|
||||
role: 'Admin',
|
||||
email: 'admin@goa.gov.in',
|
||||
password: 'Admin@123',
|
||||
description: 'System administrator with full access',
|
||||
description: 'System administrator',
|
||||
icon: 'admin_panel_settings',
|
||||
},
|
||||
{
|
||||
role: 'Fire Department',
|
||||
role: 'Fire Dept',
|
||||
email: 'fire@goa.gov.in',
|
||||
password: 'Fire@123',
|
||||
description: 'Fire safety inspection officer',
|
||||
description: 'Fire safety officer',
|
||||
icon: 'local_fire_department',
|
||||
},
|
||||
{
|
||||
role: 'Tourism',
|
||||
email: 'tourism@goa.gov.in',
|
||||
password: 'Tourism@123',
|
||||
description: 'Tourism license reviewer',
|
||||
description: 'Tourism reviewer',
|
||||
icon: 'luggage',
|
||||
},
|
||||
{
|
||||
role: 'Municipality',
|
||||
email: 'municipality@goa.gov.in',
|
||||
password: 'Municipality@123',
|
||||
description: 'Municipal building permit officer',
|
||||
icon: 'location_city',
|
||||
},
|
||||
{
|
||||
role: 'Citizen',
|
||||
email: 'citizen@example.com',
|
||||
email: 'rajesh.naik@example.com',
|
||||
password: 'Citizen@123',
|
||||
description: 'Citizen applying for licenses',
|
||||
description: 'Citizen applicant',
|
||||
icon: 'person',
|
||||
},
|
||||
];
|
||||
@@ -384,13 +507,12 @@ export class EmailLoginComponent {
|
||||
|
||||
getRoleColor(role: string): string {
|
||||
const colors: { [key: string]: string } = {
|
||||
Admin: '#d32f2f',
|
||||
'Fire Department': '#f57c00',
|
||||
Tourism: '#1976d2',
|
||||
Municipality: '#388e3c',
|
||||
Citizen: '#7b1fa2',
|
||||
Admin: '#f87171',
|
||||
'Fire Dept': '#fb923c',
|
||||
Tourism: '#60a5fa',
|
||||
Citizen: '#a78bfa',
|
||||
};
|
||||
return colors[role] || '#666';
|
||||
return colors[role] || '#818cf8';
|
||||
}
|
||||
|
||||
async onSubmit(): Promise<void> {
|
||||
@@ -399,9 +521,8 @@ export class EmailLoginComponent {
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
// Sanitize inputs to prevent XSS/injection attacks
|
||||
const email = InputSanitizer.sanitizeEmail(this.loginForm.value.email || '');
|
||||
const password = this.loginForm.value.password || ''; // Don't sanitize password, just validate length
|
||||
const password = this.loginForm.value.password || '';
|
||||
|
||||
try {
|
||||
await this.authService.login(email, password);
|
||||
@@ -410,15 +531,8 @@ export class EmailLoginComponent {
|
||||
panelClass: ['success-snackbar'],
|
||||
});
|
||||
|
||||
// Navigate based on user role
|
||||
const user = this.authService.currentUser();
|
||||
if (user?.role === 'ADMIN' || user?.type === 'ADMIN') {
|
||||
this.router.navigate(['/admin']);
|
||||
} else if (user?.role === 'DEPARTMENT' || user?.type === 'DEPARTMENT') {
|
||||
this.router.navigate(['/dashboard']);
|
||||
} else {
|
||||
this.router.navigate(['/dashboard']);
|
||||
}
|
||||
// All users go to dashboard after login
|
||||
this.router.navigate(['/dashboard']);
|
||||
} catch (error: any) {
|
||||
this.snackBar.open(
|
||||
error?.error?.message || 'Invalid email or password',
|
||||
|
||||
@@ -1,67 +1,69 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Component, signal } from '@angular/core';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatRippleModule } from '@angular/material/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
interface DemoCredential {
|
||||
role: string;
|
||||
credential: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-login-select',
|
||||
standalone: true,
|
||||
imports: [RouterModule, MatButtonModule, MatIconModule, MatRippleModule],
|
||||
imports: [CommonModule, RouterModule, MatButtonModule, MatIconModule, MatRippleModule],
|
||||
template: `
|
||||
<div class="login-select">
|
||||
<!-- Header -->
|
||||
<div class="login-header">
|
||||
<h1 class="login-title">Welcome Back</h1>
|
||||
<p class="login-subtitle">Select your login method to continue</p>
|
||||
<div class="header-glow"></div>
|
||||
<h1 class="login-title">Sign In</h1>
|
||||
<p class="login-subtitle">Select your authentication method</p>
|
||||
</div>
|
||||
|
||||
<!-- Login Options -->
|
||||
<div class="login-options">
|
||||
<div class="login-options" role="list" aria-label="Login options">
|
||||
<!-- Department Login -->
|
||||
<a
|
||||
class="login-option department"
|
||||
class="login-option"
|
||||
[routerLink]="['department']"
|
||||
matRipple
|
||||
[matRippleColor]="'rgba(99, 102, 241, 0.1)'"
|
||||
[matRippleColor]="'rgba(99, 102, 241, 0.2)'"
|
||||
role="listitem"
|
||||
>
|
||||
<div class="option-icon-wrapper">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<div class="option-icon">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 7V3H2v18h20V7H12zM6 19H4v-2h2v2zm0-4H4v-2h2v2zm0-4H4V9h2v2zm0-4H4V5h2v2zm4 12H8v-2h2v2zm0-4H8v-2h2v2zm0-4H8V9h2v2zm0-4H8V5h2v2zm10 12h-8v-2h2v-2h-2v-2h2v-2h-2V9h8v10zm-2-8h-2v2h2v-2zm0 4h-2v2h2v-2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="option-content">
|
||||
<h3 class="option-title">Department Login</h3>
|
||||
<p class="option-desc">For government department officials</p>
|
||||
<div class="option-badge">
|
||||
<mat-icon>verified_user</mat-icon>
|
||||
<span>API Key Authentication</span>
|
||||
</div>
|
||||
<div class="option-text">
|
||||
<span class="option-title">Department</span>
|
||||
<span class="option-desc">Government officials</span>
|
||||
</div>
|
||||
<mat-icon class="option-arrow">arrow_forward</mat-icon>
|
||||
<mat-icon class="option-arrow">chevron_right</mat-icon>
|
||||
</a>
|
||||
|
||||
<!-- DigiLocker Login -->
|
||||
<!-- Citizen Login -->
|
||||
<a
|
||||
class="login-option citizen"
|
||||
[routerLink]="['digilocker']"
|
||||
matRipple
|
||||
[matRippleColor]="'rgba(16, 185, 129, 0.1)'"
|
||||
[matRippleColor]="'rgba(16, 185, 129, 0.2)'"
|
||||
role="listitem"
|
||||
>
|
||||
<div class="option-icon-wrapper citizen">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<div class="option-icon">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="option-content">
|
||||
<h3 class="option-title">Citizen Login</h3>
|
||||
<p class="option-desc">For citizens and applicants via DigiLocker</p>
|
||||
<div class="option-badge citizen">
|
||||
<mat-icon>fingerprint</mat-icon>
|
||||
<span>DigiLocker Verified</span>
|
||||
</div>
|
||||
<div class="option-text">
|
||||
<span class="option-title">Citizen</span>
|
||||
<span class="option-desc">DigiLocker verification</span>
|
||||
</div>
|
||||
<mat-icon class="option-arrow">arrow_forward</mat-icon>
|
||||
<mat-icon class="option-arrow">chevron_right</mat-icon>
|
||||
</a>
|
||||
|
||||
<!-- Admin Login -->
|
||||
@@ -69,48 +71,94 @@ import { MatRippleModule } from '@angular/material/core';
|
||||
class="login-option admin"
|
||||
[routerLink]="['email']"
|
||||
matRipple
|
||||
[matRippleColor]="'rgba(139, 92, 246, 0.1)'"
|
||||
[matRippleColor]="'rgba(168, 85, 247, 0.2)'"
|
||||
role="listitem"
|
||||
>
|
||||
<div class="option-icon-wrapper admin">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<div class="option-icon">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M17 11c.34 0 .67.04 1 .09V6.27L10.5 3 3 6.27v4.91c0 4.54 3.2 8.79 7.5 9.82.55-.13 1.08-.32 1.6-.55-.69-.98-1.1-2.17-1.1-3.45 0-3.31 2.69-6 6-6z"/>
|
||||
<path d="M17 13c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4zm0 1.38c.62 0 1.12.51 1.12 1.12s-.51 1.12-1.12 1.12-1.12-.51-1.12-1.12.5-1.12 1.12-1.12zm0 5.37c-.93 0-1.74-.46-2.24-1.17.05-.72 1.51-1.08 2.24-1.08s2.19.36 2.24 1.08c-.5.71-1.31 1.17-2.24 1.17z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="option-content">
|
||||
<h3 class="option-title">Administrator</h3>
|
||||
<p class="option-desc">Platform administrators and super users</p>
|
||||
<div class="option-badge admin">
|
||||
<mat-icon>admin_panel_settings</mat-icon>
|
||||
<span>Privileged Access</span>
|
||||
</div>
|
||||
<div class="option-text">
|
||||
<span class="option-title">Administrator</span>
|
||||
<span class="option-desc">Platform admins</span>
|
||||
</div>
|
||||
<mat-icon class="option-arrow">arrow_forward</mat-icon>
|
||||
<mat-icon class="option-arrow">chevron_right</mat-icon>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Help Section -->
|
||||
<div class="help-section">
|
||||
<p class="help-text">
|
||||
Need help signing in?
|
||||
<a href="#" class="help-link">Contact Support</a>
|
||||
</p>
|
||||
<!-- Demo Credentials Toggle -->
|
||||
<button class="demo-trigger" (click)="showDemoCredentials.set(!showDemoCredentials())">
|
||||
<mat-icon>{{ showDemoCredentials() ? 'expand_less' : 'science' }}</mat-icon>
|
||||
<span>{{ showDemoCredentials() ? 'Hide Demo Credentials' : 'View Demo Credentials' }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Collapsible Demo Credentials Panel -->
|
||||
<div class="demo-panel" [class.expanded]="showDemoCredentials()">
|
||||
<div class="demo-panel-content">
|
||||
<!-- Department Credentials -->
|
||||
<div class="demo-section">
|
||||
<div class="demo-section-header">
|
||||
<span class="demo-section-icon dept"></span>
|
||||
<span class="demo-section-title">Department</span>
|
||||
</div>
|
||||
<div class="demo-creds">
|
||||
<div class="cred-item" *ngFor="let cred of departmentCredentials">
|
||||
<span class="cred-label">{{ cred.role }}</span>
|
||||
<code class="cred-value">{{ cred.credential }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Citizen Credentials -->
|
||||
<div class="demo-section">
|
||||
<div class="demo-section-header">
|
||||
<span class="demo-section-icon citizen"></span>
|
||||
<span class="demo-section-title">Citizen</span>
|
||||
</div>
|
||||
<div class="demo-creds">
|
||||
<div class="cred-item" *ngFor="let cred of citizenCredentials">
|
||||
<span class="cred-label">{{ cred.role }}</span>
|
||||
<code class="cred-value">{{ cred.credential }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Admin Credentials -->
|
||||
<div class="demo-section">
|
||||
<div class="demo-section-header">
|
||||
<span class="demo-section-icon admin"></span>
|
||||
<span class="demo-section-title">Admin</span>
|
||||
</div>
|
||||
<div class="demo-creds">
|
||||
<div class="cred-item" *ngFor="let cred of adminCredentials">
|
||||
<span class="cred-label">{{ cred.role }}</span>
|
||||
<code class="cred-value">{{ cred.credential }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="login-footer">
|
||||
<a href="#" class="help-link">
|
||||
<mat-icon>help_outline</mat-icon>
|
||||
<span>Need help?</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
// =============================================================================
|
||||
// LOGIN SELECT - DBIM Compliant World-Class Design
|
||||
// LOGIN SELECT - Dark Glass-Morphism Theme
|
||||
// Matches the blockchain landing page design
|
||||
// =============================================================================
|
||||
|
||||
.login-select {
|
||||
animation: fadeIn 0.4s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
position: relative;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -119,19 +167,34 @@ import { MatRippleModule } from '@angular/material/core';
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.header-glow {
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 200px;
|
||||
height: 100px;
|
||||
background: radial-gradient(ellipse, rgba(99, 102, 241, 0.2) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--dbim-brown, #150202);
|
||||
background: linear-gradient(135deg, #FFFFFF 0%, #c7d2fe 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin: 0 0 8px;
|
||||
line-height: 1.2;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
font-size: 15px;
|
||||
color: var(--dbim-grey-2, #8E8E8E);
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -149,34 +212,33 @@ import { MatRippleModule } from '@angular/material/core';
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
background: var(--dbim-linen, #EBEAEA);
|
||||
padding: 16px 20px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 16px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border: 2px solid transparent;
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
// Left accent line
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 4px;
|
||||
top: 0;
|
||||
width: 3px;
|
||||
height: 100%;
|
||||
background: linear-gradient(180deg, var(--dbim-blue-dark, #1D0A69) 0%, var(--crypto-indigo, #6366F1) 100%);
|
||||
background: linear-gradient(180deg, #6366F1 0%, #818cf8 100%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: white;
|
||||
border-color: rgba(99, 102, 241, 0.2);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-color: rgba(99, 102, 241, 0.3);
|
||||
transform: translateX(4px);
|
||||
box-shadow: 0 4px 20px rgba(29, 10, 105, 0.1);
|
||||
|
||||
&::before {
|
||||
opacity: 1;
|
||||
@@ -184,153 +246,264 @@ import { MatRippleModule } from '@angular/material/core';
|
||||
|
||||
.option-arrow {
|
||||
transform: translateX(4px);
|
||||
color: var(--dbim-blue-dark, #1D0A69);
|
||||
color: #818cf8;
|
||||
}
|
||||
}
|
||||
|
||||
&.citizen {
|
||||
&::before {
|
||||
background: linear-gradient(180deg, #059669 0%, #10B981 100%);
|
||||
background: linear-gradient(180deg, #10B981 0%, #34d399 100%);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(16, 185, 129, 0.2);
|
||||
border-color: rgba(16, 185, 129, 0.3);
|
||||
|
||||
.option-arrow {
|
||||
color: #059669;
|
||||
color: #34d399;
|
||||
}
|
||||
}
|
||||
|
||||
.option-icon {
|
||||
background: linear-gradient(135deg, rgba(16, 185, 129, 0.2) 0%, rgba(52, 211, 153, 0.1) 100%);
|
||||
border-color: rgba(16, 185, 129, 0.3);
|
||||
|
||||
svg {
|
||||
color: #34d399;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.admin {
|
||||
&::before {
|
||||
background: linear-gradient(180deg, #7C3AED 0%, #8B5CF6 100%);
|
||||
background: linear-gradient(180deg, #A855F7 0%, #c084fc 100%);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(139, 92, 246, 0.2);
|
||||
border-color: rgba(168, 85, 247, 0.3);
|
||||
|
||||
.option-arrow {
|
||||
color: #7C3AED;
|
||||
color: #c084fc;
|
||||
}
|
||||
}
|
||||
|
||||
.option-icon {
|
||||
background: linear-gradient(135deg, rgba(168, 85, 247, 0.2) 0%, rgba(192, 132, 252, 0.1) 100%);
|
||||
border-color: rgba(168, 85, 247, 0.3);
|
||||
|
||||
svg {
|
||||
color: #c084fc;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ICON WRAPPER
|
||||
// =============================================================================
|
||||
.option-icon-wrapper {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
.option-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(135deg, var(--dbim-blue-dark, #1D0A69) 0%, var(--crypto-indigo, #6366F1) 100%);
|
||||
background: linear-gradient(135deg, rgba(99, 102, 241, 0.2) 0%, rgba(129, 140, 248, 0.1) 100%);
|
||||
border: 1px solid rgba(99, 102, 241, 0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
|
||||
|
||||
svg {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.citizen {
|
||||
background: linear-gradient(135deg, #059669 0%, #10B981 100%);
|
||||
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
&.admin {
|
||||
background: linear-gradient(135deg, #7C3AED 0%, #8B5CF6 100%);
|
||||
box-shadow: 0 4px 12px rgba(139, 92, 246, 0.3);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: #818cf8;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CONTENT
|
||||
// =============================================================================
|
||||
.option-content {
|
||||
.option-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.option-title {
|
||||
font-size: 16px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--dbim-brown, #150202);
|
||||
margin: 0 0 4px;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
.option-desc {
|
||||
font-size: 13px;
|
||||
color: var(--dbim-grey-2, #8E8E8E);
|
||||
margin: 0 0 8px;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
.option-badge {
|
||||
display: inline-flex;
|
||||
.option-arrow {
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
transition: all 0.25s ease;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// DEMO CREDENTIALS TRIGGER
|
||||
// =============================================================================
|
||||
.demo-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
background: rgba(99, 102, 241, 0.1);
|
||||
border-radius: 20px;
|
||||
font-size: 11px;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: transparent;
|
||||
border: 1px dashed rgba(255, 255, 255, 0.15);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--dbim-blue-dark, #1D0A69);
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
mat-icon {
|
||||
font-size: 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-color: rgba(99, 102, 241, 0.3);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// COLLAPSIBLE DEMO PANEL
|
||||
// =============================================================================
|
||||
.demo-panel {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
&.expanded {
|
||||
max-height: 400px;
|
||||
}
|
||||
}
|
||||
|
||||
.demo-panel-content {
|
||||
padding-top: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.demo-section {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.demo-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.demo-section-icon {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #818cf8;
|
||||
|
||||
&.citizen {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: #059669;
|
||||
background: #34d399;
|
||||
}
|
||||
|
||||
&.admin {
|
||||
background: rgba(139, 92, 246, 0.1);
|
||||
color: #7C3AED;
|
||||
background: #c084fc;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ARROW
|
||||
// =============================================================================
|
||||
.option-arrow {
|
||||
color: var(--dbim-grey-1, #C6C6C6);
|
||||
transition: all 0.2s ease;
|
||||
.demo-section-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.demo-creds {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.cred-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.cred-label {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// HELP SECTION
|
||||
// =============================================================================
|
||||
.help-section {
|
||||
text-align: center;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--dbim-linen, #EBEAEA);
|
||||
.cred-value {
|
||||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace;
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.help-text {
|
||||
font-size: 13px;
|
||||
color: var(--dbim-grey-2, #8E8E8E);
|
||||
margin: 0;
|
||||
// =============================================================================
|
||||
// FOOTER
|
||||
// =============================================================================
|
||||
.login-footer {
|
||||
margin-top: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.help-link {
|
||||
color: var(--dbim-info, #0D6EFD);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
color: #818cf8;
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class LoginSelectComponent {}
|
||||
export class LoginSelectComponent {
|
||||
showDemoCredentials = signal(false);
|
||||
|
||||
departmentCredentials: DemoCredential[] = [
|
||||
{ role: 'Transport', credential: 'DEPT-TRANS-001', description: '' },
|
||||
{ role: 'Health', credential: 'DEPT-HEALTH-001', description: '' },
|
||||
{ role: 'Revenue', credential: 'DEPT-REV-001', description: '' }
|
||||
];
|
||||
|
||||
citizenCredentials: DemoCredential[] = [
|
||||
{ role: 'Demo', credential: 'Any 12-digit AADHAAR', description: '' }
|
||||
];
|
||||
|
||||
adminCredentials: DemoCredential[] = [
|
||||
{ role: 'Admin', credential: 'admin@goa.gov.in / Admin@123', description: '' }
|
||||
];
|
||||
}
|
||||
|
||||
@@ -141,7 +141,7 @@ import { AdminStatsDto } from '../../../api/models';
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="status-grid">
|
||||
@for (item of stats()!.requestsByStatus; track item.status) {
|
||||
@for (item of (stats()?.requestsByStatus || []); track item.status) {
|
||||
<div class="status-item" [routerLink]="['/requests']" [queryParams]="{ status: item.status }">
|
||||
<app-status-badge [status]="item.status" />
|
||||
<span class="count">{{ item.count }}</span>
|
||||
@@ -205,23 +205,13 @@ import { AdminStatsDto } from '../../../api/models';
|
||||
|
||||
/* Welcome Section */
|
||||
.welcome-section {
|
||||
background: linear-gradient(135deg, var(--dbim-blue-dark, #1D0A69) 0%, var(--dbim-blue-mid, #2563EB) 100%);
|
||||
color: white;
|
||||
background: linear-gradient(135deg, #1D0A69 0%, #2563EB 100%) !important;
|
||||
color: white !important;
|
||||
padding: 32px;
|
||||
margin: -24px -24px 24px -24px;
|
||||
margin: 0 0 24px 0;
|
||||
border-radius: 16px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
right: -10%;
|
||||
width: 50%;
|
||||
height: 200%;
|
||||
background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.welcome-content {
|
||||
@@ -233,32 +223,29 @@ import { AdminStatsDto } from '../../../api/models';
|
||||
gap: 24px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.welcome-text {
|
||||
.greeting {
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.welcome-text .greeting {
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.9;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: rgba(255, 255, 255, 0.9) !important;
|
||||
display: block;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 8px 0;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.welcome-text h1 {
|
||||
margin: 8px 0;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
opacity: 0.85;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.welcome-text .subtitle {
|
||||
margin: 0;
|
||||
opacity: 0.9;
|
||||
font-size: 0.95rem;
|
||||
color: rgba(255, 255, 255, 0.9) !important;
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
@@ -266,24 +253,22 @@ import { AdminStatsDto } from '../../../api/models';
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
&.primary {
|
||||
background: white;
|
||||
color: var(--dbim-blue-dark, #1D0A69);
|
||||
}
|
||||
.action-btn.primary {
|
||||
background: white !important;
|
||||
color: #1D0A69 !important;
|
||||
}
|
||||
|
||||
&:not(.primary) {
|
||||
color: white;
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
.action-btn:not(.primary) {
|
||||
color: white;
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
.action-btn:not(.primary):hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
.action-btn mat-icon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
@@ -293,10 +278,10 @@ import { AdminStatsDto } from '../../../api/models';
|
||||
justify-content: center;
|
||||
padding: 64px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--dbim-grey-2, #8E8E8E);
|
||||
}
|
||||
.loading-container p {
|
||||
color: #8E8E8E;
|
||||
}
|
||||
|
||||
/* Stats Section */
|
||||
@@ -320,54 +305,57 @@ import { AdminStatsDto } from '../../../api/models';
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
color: white;
|
||||
background: white !important;
|
||||
color: #150202;
|
||||
border: 1px solid #EBEAEA;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--shadow-elevated, 0 8px 24px rgba(0, 0, 0, 0.15));
|
||||
}
|
||||
|
||||
&.requests {
|
||||
background: linear-gradient(135deg, var(--dbim-blue-dark, #1D0A69) 0%, var(--dbim-blue-mid, #2563EB) 100%);
|
||||
}
|
||||
|
||||
&.approvals {
|
||||
background: linear-gradient(135deg, #059669 0%, #10b981 100%);
|
||||
}
|
||||
|
||||
&.documents {
|
||||
background: linear-gradient(135deg, #f59e0b 0%, #fbbf24 100%);
|
||||
}
|
||||
|
||||
&.departments {
|
||||
background: linear-gradient(135deg, #7c3aed 0%, #a78bfa 100%);
|
||||
}
|
||||
|
||||
&.applicants {
|
||||
background: linear-gradient(135deg, #0891b2 0%, #22d3ee 100%);
|
||||
}
|
||||
|
||||
&.blockchain {
|
||||
background: linear-gradient(135deg, #475569 0%, #64748b 100%);
|
||||
}
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.stat-icon-wrapper {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
background: rgba(29, 10, 105, 0.08);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(10px);
|
||||
flex-shrink: 0;
|
||||
color: #1D0A69;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 26px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
.stat-icon-wrapper mat-icon {
|
||||
font-size: 24px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.stat-card.approvals .stat-icon-wrapper {
|
||||
background: rgba(5, 150, 105, 0.1);
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.stat-card.documents .stat-icon-wrapper {
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.stat-card.departments .stat-icon-wrapper {
|
||||
background: rgba(124, 58, 237, 0.1);
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
.stat-card.applicants .stat-icon-wrapper {
|
||||
background: rgba(8, 145, 178, 0.1);
|
||||
color: #0891b2;
|
||||
}
|
||||
|
||||
.stat-card.blockchain .stat-icon-wrapper {
|
||||
background: rgba(71, 85, 105, 0.1);
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
@@ -379,22 +367,17 @@ import { AdminStatsDto } from '../../../api/models';
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
color: #150202;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.9;
|
||||
color: #8E8E8E;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.stat-decoration {
|
||||
position: absolute;
|
||||
top: -20px;
|
||||
right: -20px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Content Grid */
|
||||
@@ -402,10 +385,6 @@ import { AdminStatsDto } from '../../../api/models';
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 400px;
|
||||
gap: 24px;
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.content-main {
|
||||
@@ -423,31 +402,31 @@ import { AdminStatsDto } from '../../../api/models';
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid var(--dbim-linen, #EBEAEA);
|
||||
border-bottom: 1px solid #EBEAEA;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
.card-header .header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
color: var(--dbim-blue-mid, #2563EB);
|
||||
}
|
||||
.card-header .header-left mat-icon {
|
||||
color: #2563EB;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--dbim-brown, #150202);
|
||||
}
|
||||
}
|
||||
.card-header .header-left h2 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: #150202;
|
||||
}
|
||||
|
||||
button mat-icon {
|
||||
margin-left: 4px;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
.card-header button mat-icon {
|
||||
margin-left: 4px;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
@@ -465,20 +444,20 @@ import { AdminStatsDto } from '../../../api/models';
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 14px 16px;
|
||||
background: var(--dbim-linen, #EBEAEA);
|
||||
background: #F5F5F5;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
.status-item:hover {
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.count {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--dbim-brown, #150202);
|
||||
}
|
||||
.status-item .count {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: #150202;
|
||||
}
|
||||
|
||||
/* Quick Actions */
|
||||
@@ -486,10 +465,6 @@ import { AdminStatsDto } from '../../../api/models';
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.action-item {
|
||||
@@ -502,16 +477,16 @@ import { AdminStatsDto } from '../../../api/models';
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--dbim-linen, #EBEAEA);
|
||||
}
|
||||
.action-item:hover {
|
||||
background: #F5F5F5;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 0.85rem;
|
||||
color: var(--dbim-brown, #150202);
|
||||
font-weight: 500;
|
||||
}
|
||||
.action-item span {
|
||||
font-size: 0.85rem;
|
||||
color: #150202;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
@@ -521,39 +496,57 @@ import { AdminStatsDto } from '../../../api/models';
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 26px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
.action-icon mat-icon {
|
||||
font-size: 26px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
&.departments {
|
||||
background: linear-gradient(135deg, #7c3aed, #a78bfa);
|
||||
color: white;
|
||||
}
|
||||
.action-icon.departments {
|
||||
background: linear-gradient(135deg, #7c3aed, #a78bfa);
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.workflows {
|
||||
background: linear-gradient(135deg, var(--dbim-blue-dark, #1D0A69), var(--dbim-blue-mid, #2563EB));
|
||||
color: white;
|
||||
}
|
||||
.action-icon.workflows {
|
||||
background: linear-gradient(135deg, #1D0A69, #2563EB);
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.audit {
|
||||
background: linear-gradient(135deg, #f59e0b, #fbbf24);
|
||||
color: white;
|
||||
}
|
||||
.action-icon.audit {
|
||||
background: linear-gradient(135deg, #f59e0b, #fbbf24);
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.webhooks {
|
||||
background: linear-gradient(135deg, #059669, #10b981);
|
||||
color: white;
|
||||
}
|
||||
.action-icon.webhooks {
|
||||
background: linear-gradient(135deg, #059669, #10b981);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.content-sidebar {
|
||||
@media (max-width: 1200px) {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 1200px) {
|
||||
.content-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.content-sidebar {
|
||||
order: -1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.welcome-content {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.actions-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class AdminDashboardComponent implements OnInit {
|
||||
|
||||
@@ -9,7 +9,6 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { StatusBadgeComponent } from '../../../shared/components/status-badge/status-badge.component';
|
||||
import { BlockchainExplorerMiniComponent } from '../../../shared/components/blockchain-explorer-mini/blockchain-explorer-mini.component';
|
||||
import { ApiService } from '../../../core/services/api.service';
|
||||
import { AuthService } from '../../../core/services/auth.service';
|
||||
import { NotificationService } from '../../../core/services/notification.service';
|
||||
@@ -36,7 +35,6 @@ interface ApplicantStats {
|
||||
MatTooltipModule,
|
||||
MatChipsModule,
|
||||
StatusBadgeComponent,
|
||||
BlockchainExplorerMiniComponent,
|
||||
],
|
||||
template: `
|
||||
<div class="page-container">
|
||||
@@ -205,10 +203,6 @@ interface ApplicantStats {
|
||||
</mat-card>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Blockchain Activity -->
|
||||
<div class="content-sidebar">
|
||||
<app-blockchain-explorer-mini [showViewAll]="false" [refreshInterval]="30000"></app-blockchain-explorer-mini>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
@@ -219,23 +213,23 @@ interface ApplicantStats {
|
||||
|
||||
/* Welcome Section */
|
||||
.welcome-section {
|
||||
background: linear-gradient(135deg, var(--dbim-blue-dark, #1D0A69) 0%, var(--dbim-blue-mid, #2563EB) 100%);
|
||||
background: linear-gradient(135deg, #1D0A69 0%, #2563EB 100%);
|
||||
color: white;
|
||||
padding: 32px;
|
||||
margin: -24px -24px 24px -24px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
right: -10%;
|
||||
width: 50%;
|
||||
height: 200%;
|
||||
background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.welcome-section::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
right: -10%;
|
||||
width: 50%;
|
||||
height: 200%;
|
||||
background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.welcome-content {
|
||||
@@ -247,61 +241,48 @@ interface ApplicantStats {
|
||||
gap: 24px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.welcome-text {
|
||||
.greeting {
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.welcome-text .greeting {
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 8px 0;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.welcome-text h1 {
|
||||
margin: 8px 0;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
opacity: 0.85;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.welcome-text .subtitle {
|
||||
margin: 0;
|
||||
opacity: 0.85;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
&.primary {
|
||||
background: white;
|
||||
color: var(--dbim-blue-dark, #1D0A69);
|
||||
}
|
||||
.action-btn.primary {
|
||||
background: white !important;
|
||||
color: #1D0A69 !important;
|
||||
}
|
||||
|
||||
&:not(.primary) {
|
||||
color: white;
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
.action-btn:not(.primary) {
|
||||
color: white;
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
.action-btn:not(.primary):hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
.action-btn mat-icon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
/* Stats Section */
|
||||
@@ -326,27 +307,27 @@ interface ApplicantStats {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
color: white;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--shadow-elevated, 0 8px 24px rgba(0, 0, 0, 0.15));
|
||||
}
|
||||
.stat-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
&.pending {
|
||||
background: linear-gradient(135deg, #f59e0b 0%, #fbbf24 100%);
|
||||
}
|
||||
.stat-card.pending {
|
||||
background: linear-gradient(135deg, #f59e0b 0%, #fbbf24 100%) !important;
|
||||
}
|
||||
|
||||
&.approved {
|
||||
background: linear-gradient(135deg, #059669 0%, #10b981 100%);
|
||||
}
|
||||
.stat-card.approved {
|
||||
background: linear-gradient(135deg, #059669 0%, #10b981 100%) !important;
|
||||
}
|
||||
|
||||
&.documents {
|
||||
background: linear-gradient(135deg, var(--dbim-blue-dark, #1D0A69) 0%, var(--dbim-blue-mid, #2563EB) 100%);
|
||||
}
|
||||
.stat-card.documents {
|
||||
background: linear-gradient(135deg, #1D0A69 0%, #2563EB 100%) !important;
|
||||
}
|
||||
|
||||
&.blockchain {
|
||||
background: linear-gradient(135deg, #7c3aed 0%, #a78bfa 100%);
|
||||
}
|
||||
.stat-card.blockchain {
|
||||
background: linear-gradient(135deg, #7c3aed 0%, #a78bfa 100%) !important;
|
||||
}
|
||||
|
||||
.stat-icon-wrapper {
|
||||
@@ -359,12 +340,12 @@ interface ApplicantStats {
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(10px);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 28px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
.stat-icon-wrapper mat-icon {
|
||||
font-size: 28px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
@@ -397,12 +378,8 @@ interface ApplicantStats {
|
||||
/* Content Grid */
|
||||
.content-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 400px;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 24px;
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.content-main {
|
||||
@@ -420,31 +397,31 @@ interface ApplicantStats {
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid var(--dbim-linen, #EBEAEA);
|
||||
border-bottom: 1px solid #EBEAEA;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
.card-header .header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
color: var(--dbim-blue-mid, #2563EB);
|
||||
}
|
||||
.card-header .header-left mat-icon {
|
||||
color: #2563EB;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--dbim-brown, #150202);
|
||||
}
|
||||
}
|
||||
.card-header .header-left h2 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: #150202;
|
||||
}
|
||||
|
||||
button mat-icon {
|
||||
margin-left: 4px;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
.card-header button mat-icon {
|
||||
margin-left: 4px;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
@@ -462,19 +439,19 @@ interface ApplicantStats {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 48px 24px;
|
||||
color: var(--dbim-grey-2, #8E8E8E);
|
||||
color: #8E8E8E;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 48px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.empty-state-inline mat-icon {
|
||||
font-size: 48px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
.empty-state-inline p {
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
/* Requests List */
|
||||
@@ -490,17 +467,17 @@ interface ApplicantStats {
|
||||
padding: 16px 0;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
border-bottom: 1px solid var(--dbim-linen, #EBEAEA);
|
||||
border-bottom: 1px solid #EBEAEA;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.request-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
margin: 0 -24px;
|
||||
padding: 16px 24px;
|
||||
}
|
||||
.request-item:hover {
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
margin: 0 -24px;
|
||||
padding: 16px 24px;
|
||||
}
|
||||
|
||||
.request-left {
|
||||
@@ -516,37 +493,37 @@ interface ApplicantStats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 22px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
.request-icon mat-icon {
|
||||
font-size: 22px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
&.draft {
|
||||
background: rgba(158, 158, 158, 0.1);
|
||||
color: #9e9e9e;
|
||||
}
|
||||
.request-icon.draft {
|
||||
background: rgba(158, 158, 158, 0.1);
|
||||
color: #9e9e9e;
|
||||
}
|
||||
|
||||
&.submitted {
|
||||
background: rgba(33, 150, 243, 0.1);
|
||||
color: #2196f3;
|
||||
}
|
||||
.request-icon.submitted {
|
||||
background: rgba(33, 150, 243, 0.1);
|
||||
color: #2196f3;
|
||||
}
|
||||
|
||||
&.in-review {
|
||||
background: rgba(255, 152, 0, 0.1);
|
||||
color: #ff9800;
|
||||
}
|
||||
.request-icon.in-review {
|
||||
background: rgba(255, 152, 0, 0.1);
|
||||
color: #ff9800;
|
||||
}
|
||||
|
||||
&.approved {
|
||||
background: rgba(76, 175, 80, 0.1);
|
||||
color: #4caf50;
|
||||
}
|
||||
.request-icon.approved {
|
||||
background: rgba(76, 175, 80, 0.1);
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
&.rejected {
|
||||
background: rgba(244, 67, 54, 0.1);
|
||||
color: #f44336;
|
||||
}
|
||||
.request-icon.rejected {
|
||||
background: rgba(244, 67, 54, 0.1);
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
.request-info {
|
||||
@@ -557,12 +534,12 @@ interface ApplicantStats {
|
||||
|
||||
.request-number {
|
||||
font-weight: 600;
|
||||
color: var(--dbim-brown, #150202);
|
||||
color: #150202;
|
||||
}
|
||||
|
||||
.request-type {
|
||||
font-size: 0.8rem;
|
||||
color: var(--dbim-grey-2, #8E8E8E);
|
||||
color: #8E8E8E;
|
||||
}
|
||||
|
||||
.request-right {
|
||||
@@ -573,11 +550,11 @@ interface ApplicantStats {
|
||||
|
||||
.request-date {
|
||||
font-size: 0.8rem;
|
||||
color: var(--dbim-grey-2, #8E8E8E);
|
||||
color: #8E8E8E;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
color: var(--dbim-grey-1, #C6C6C6);
|
||||
color: #C6C6C6;
|
||||
}
|
||||
|
||||
/* Quick Actions Card */
|
||||
@@ -585,10 +562,6 @@ interface ApplicantStats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.action-item {
|
||||
@@ -601,16 +574,16 @@ interface ApplicantStats {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--dbim-linen, #EBEAEA);
|
||||
}
|
||||
.action-item:hover {
|
||||
background: #F5F5F5;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 0.85rem;
|
||||
color: var(--dbim-brown, #150202);
|
||||
font-weight: 500;
|
||||
}
|
||||
.action-item span {
|
||||
font-size: 0.85rem;
|
||||
color: #150202;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
@@ -620,37 +593,45 @@ interface ApplicantStats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
mat-icon {
|
||||
font-size: 26px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
&.license {
|
||||
background: linear-gradient(135deg, var(--dbim-blue-dark, #1D0A69), var(--dbim-blue-mid, #2563EB));
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.renewal {
|
||||
background: linear-gradient(135deg, #059669, #10b981);
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.track {
|
||||
background: linear-gradient(135deg, #f59e0b, #fbbf24);
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.help {
|
||||
background: linear-gradient(135deg, #7c3aed, #a78bfa);
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.content-sidebar {
|
||||
@media (max-width: 1200px) {
|
||||
order: -1;
|
||||
.action-icon mat-icon {
|
||||
font-size: 26px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.action-icon.license {
|
||||
background: linear-gradient(135deg, #1D0A69, #2563EB);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.action-icon.renewal {
|
||||
background: linear-gradient(135deg, #059669, #10b981);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.action-icon.track {
|
||||
background: linear-gradient(135deg, #f59e0b, #fbbf24);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.action-icon.help {
|
||||
background: linear-gradient(135deg, #7c3aed, #a78bfa);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.welcome-content {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.quick-actions {
|
||||
width: 100%;
|
||||
}
|
||||
.actions-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
`],
|
||||
|
||||
@@ -59,7 +59,7 @@ export class DepartmentService {
|
||||
return throwError(() => new Error('Limit must be between 1 and 100'));
|
||||
}
|
||||
|
||||
return this.api.get<ApiPaginatedResponse>('/departments', { page, limit }).pipe(
|
||||
return this.api.getRaw<ApiPaginatedResponse>('/departments', { page, limit }).pipe(
|
||||
map((response: ApiPaginatedResponse) => {
|
||||
const data = response?.data ?? [];
|
||||
const meta = response?.meta;
|
||||
@@ -86,7 +86,7 @@ export class DepartmentService {
|
||||
return throwError(() => error);
|
||||
}
|
||||
|
||||
return this.api.get<DepartmentResponseDto>(`/departments/${id.trim()}`).pipe(
|
||||
return this.api.getRaw<DepartmentResponseDto>(`/departments/${id.trim()}`).pipe(
|
||||
catchError((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : `Failed to fetch department with ID: ${id}`;
|
||||
return throwError(() => new Error(message));
|
||||
@@ -101,7 +101,7 @@ export class DepartmentService {
|
||||
return throwError(() => error);
|
||||
}
|
||||
|
||||
return this.api.get<DepartmentResponseDto>(`/departments/code/${encodeURIComponent(code.trim())}`).pipe(
|
||||
return this.api.getRaw<DepartmentResponseDto>(`/departments/code/${encodeURIComponent(code.trim())}`).pipe(
|
||||
catchError((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : `Failed to fetch department with code: ${code}`;
|
||||
return throwError(() => new Error(message));
|
||||
@@ -114,7 +114,7 @@ export class DepartmentService {
|
||||
return throwError(() => new Error('Department data is required'));
|
||||
}
|
||||
|
||||
return this.api.post<CreateDepartmentWithCredentialsResponse>('/departments', dto).pipe(
|
||||
return this.api.postRaw<CreateDepartmentWithCredentialsResponse>('/departments', dto).pipe(
|
||||
catchError((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : 'Failed to create department';
|
||||
return throwError(() => new Error(message));
|
||||
|
||||
@@ -853,15 +853,18 @@ export class DocumentUploadComponent {
|
||||
readonly hashCopied = signal(false);
|
||||
|
||||
readonly documentTypes: { value: DocumentType; label: string; icon: string }[] = [
|
||||
{ value: 'FIRE_SAFETY_CERTIFICATE', label: 'Fire Safety Certificate', icon: 'local_fire_department' },
|
||||
{ value: 'BUILDING_PLAN', label: 'Building Plan', icon: 'apartment' },
|
||||
{ value: 'PROPERTY_OWNERSHIP', label: 'Property Ownership', icon: 'home' },
|
||||
{ value: 'INSPECTION_REPORT', label: 'Inspection Report', icon: 'fact_check' },
|
||||
{ value: 'POLLUTION_CERTIFICATE', label: 'Pollution Certificate', icon: 'eco' },
|
||||
{ value: 'ELECTRICAL_SAFETY_CERTIFICATE', label: 'Electrical Safety Certificate', icon: 'electrical_services' },
|
||||
{ value: 'STRUCTURAL_STABILITY_CERTIFICATE', label: 'Structural Stability Certificate', icon: 'foundation' },
|
||||
{ value: 'IDENTITY_PROOF', label: 'Identity Proof', icon: 'badge' },
|
||||
{ value: 'ID_PROOF', label: 'Identity Proof', icon: 'badge' },
|
||||
{ value: 'ADDRESS_PROOF', label: 'Address Proof', icon: 'location_on' },
|
||||
{ value: 'FIRE_SAFETY', label: 'Fire Safety Certificate', icon: 'local_fire_department' },
|
||||
{ value: 'FLOOR_PLAN', label: 'Floor Plan', icon: 'apartment' },
|
||||
{ value: 'SITE_PLAN', label: 'Site Plan', icon: 'map' },
|
||||
{ value: 'BUILDING_PERMIT', label: 'Building Permit', icon: 'home_work' },
|
||||
{ value: 'BUSINESS_LICENSE', label: 'Business License', icon: 'storefront' },
|
||||
{ value: 'PHOTOGRAPH', label: 'Photograph', icon: 'photo_camera' },
|
||||
{ value: 'NOC', label: 'No Objection Certificate', icon: 'verified' },
|
||||
{ value: 'LICENSE_COPY', label: 'License Copy', icon: 'file_copy' },
|
||||
{ value: 'HEALTH_CERT', label: 'Health Certificate', icon: 'health_and_safety' },
|
||||
{ value: 'TAX_CLEARANCE', label: 'Tax Clearance', icon: 'receipt_long' },
|
||||
{ value: 'OTHER', label: 'Other Document', icon: 'description' },
|
||||
];
|
||||
|
||||
|
||||
@@ -89,16 +89,19 @@ function validateDocType(docType: DocumentType | string | undefined | null): Doc
|
||||
}
|
||||
|
||||
const validDocTypes: DocumentType[] = [
|
||||
'FIRE_SAFETY_CERTIFICATE',
|
||||
'BUILDING_PLAN',
|
||||
'PROPERTY_OWNERSHIP',
|
||||
'INSPECTION_REPORT',
|
||||
'POLLUTION_CERTIFICATE',
|
||||
'ELECTRICAL_SAFETY_CERTIFICATE',
|
||||
'STRUCTURAL_STABILITY_CERTIFICATE',
|
||||
'IDENTITY_PROOF',
|
||||
'FLOOR_PLAN',
|
||||
'PHOTOGRAPH',
|
||||
'ID_PROOF',
|
||||
'ADDRESS_PROOF',
|
||||
'NOC',
|
||||
'LICENSE_COPY',
|
||||
'OTHER',
|
||||
'FIRE_SAFETY',
|
||||
'HEALTH_CERT',
|
||||
'TAX_CLEARANCE',
|
||||
'SITE_PLAN',
|
||||
'BUILDING_PERMIT',
|
||||
'BUSINESS_LICENSE',
|
||||
];
|
||||
|
||||
if (!validDocTypes.includes(docType as DocumentType)) {
|
||||
|
||||
@@ -473,10 +473,16 @@ export class RequestCreateComponent implements OnInit {
|
||||
|
||||
this.requestService
|
||||
.createRequest({
|
||||
applicantId: user.id,
|
||||
applicantName: metadata.ownerName,
|
||||
applicantPhone: metadata.ownerPhone,
|
||||
businessName: metadata.businessName,
|
||||
requestType: basic.requestType,
|
||||
workflowId: basic.workflowId,
|
||||
metadata,
|
||||
metadata: {
|
||||
businessAddress: metadata.businessAddress,
|
||||
ownerEmail: metadata.ownerEmail,
|
||||
description: metadata.description,
|
||||
},
|
||||
})
|
||||
.subscribe({
|
||||
next: (result) => {
|
||||
|
||||
@@ -160,6 +160,12 @@
|
||||
<span style="margin-left: 8px">Documents ({{ detailedDocuments().length || 0 }})</span>
|
||||
</ng-template>
|
||||
<div class="tab-content">
|
||||
<div class="documents-header" style="display: flex; justify-content: flex-end; margin-bottom: 16px;">
|
||||
<button mat-raised-button color="primary" (click)="openUploadDialog()">
|
||||
<mat-icon>cloud_upload</mat-icon>
|
||||
Upload Document
|
||||
</button>
|
||||
</div>
|
||||
@if (loadingDocuments()) {
|
||||
<div class="loading-container">
|
||||
<mat-spinner diameter="40"></mat-spinner>
|
||||
@@ -175,41 +181,14 @@
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<mat-icon>how_to_reg</mat-icon>
|
||||
<span style="margin-left: 8px">Approvals ({{ req.approvals.length || 0 }})</span>
|
||||
<span style="margin-left: 8px">Approvals ({{ req.approvals?.length || 0 }})</span>
|
||||
</ng-template>
|
||||
<div class="tab-content">
|
||||
@if (req.approvals && req.approvals.length > 0) {
|
||||
<div class="approvals-timeline">
|
||||
@for (approval of req.approvals; track approval.id) {
|
||||
<div class="timeline-item">
|
||||
<div class="timeline-marker" [ngClass]="{
|
||||
'approved': approval.status === 'APPROVED',
|
||||
'pending': approval.status === 'REVIEW_REQUIRED' || approval.status === 'CHANGES_REQUESTED',
|
||||
'rejected': approval.status === 'REJECTED'
|
||||
}">
|
||||
@if (approval.status === 'APPROVED') {
|
||||
<mat-icon>check</mat-icon>
|
||||
} @else if (approval.status === 'REJECTED') {
|
||||
<mat-icon>close</mat-icon>
|
||||
} @else {
|
||||
<mat-icon>schedule</mat-icon>
|
||||
}
|
||||
</div>
|
||||
<div class="timeline-content">
|
||||
<div class="timeline-header">
|
||||
<span class="dept-name">{{ formatDepartmentId(approval.departmentId) }}</span>
|
||||
<span class="timeline-time">{{ approval.createdAt | date: 'medium' }}</span>
|
||||
</div>
|
||||
<app-status-badge [status]="approval.status" />
|
||||
@if (approval.remarks) {
|
||||
<div class="timeline-remarks">
|
||||
<strong>Remarks:</strong> {{ approval.remarks }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<app-approval-workflow-timeline
|
||||
[requestId]="req.id"
|
||||
[txHash]="req.blockchainTxHash"
|
||||
[tokenId]="req.tokenId" />
|
||||
} @else {
|
||||
<div class="empty-state-card">
|
||||
<mat-icon>pending_actions</mat-icon>
|
||||
|
||||
@@ -14,11 +14,13 @@ import { StatusBadgeComponent } from '../../../shared/components/status-badge/st
|
||||
import { ConfirmDialogComponent } from '../../../shared/components/confirm-dialog/confirm-dialog.component';
|
||||
import { BlockchainInfoComponent } from '../../../shared/components/blockchain-info/blockchain-info.component';
|
||||
import { DocumentViewerComponent } from '../../../shared/components/document-viewer/document-viewer.component';
|
||||
import { ApprovalWorkflowTimelineComponent } from '../../approvals/approval-workflow-timeline/approval-workflow-timeline.component';
|
||||
import { RequestService } from '../services/request.service';
|
||||
import { AuthService } from '../../../core/services/auth.service';
|
||||
import { NotificationService } from '../../../core/services/notification.service';
|
||||
import { ApiService } from '../../../core/services/api.service';
|
||||
import { RequestDetailResponseDto } from '../../../api/models';
|
||||
import { DocumentUploadComponent, DocumentUploadDialogData } from '../../documents/document-upload/document-upload.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-request-detail',
|
||||
@@ -37,6 +39,7 @@ import { RequestDetailResponseDto } from '../../../api/models';
|
||||
StatusBadgeComponent,
|
||||
BlockchainInfoComponent,
|
||||
DocumentViewerComponent,
|
||||
ApprovalWorkflowTimelineComponent,
|
||||
],
|
||||
templateUrl: './request-detail.component.html',
|
||||
styles: [
|
||||
@@ -88,7 +91,7 @@ import { RequestDetailResponseDto } from '../../../api/models';
|
||||
.request-number {
|
||||
font-family: 'Roboto Mono', monospace;
|
||||
font-size: 14px;
|
||||
opacity: 0.8;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
@@ -96,6 +99,7 @@ import { RequestDetailResponseDto } from '../../../api/models';
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 8px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.request-meta {
|
||||
@@ -109,12 +113,13 @@ import { RequestDetailResponseDto } from '../../../api/models';
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -133,6 +138,7 @@ import { RequestDetailResponseDto } from '../../../api/models';
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: white;
|
||||
|
||||
&.status-draft {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
@@ -152,6 +158,10 @@ import { RequestDetailResponseDto } from '../../../api/models';
|
||||
background: rgba(220, 53, 69, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.actions button {
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -487,11 +497,23 @@ export class RequestDetailComponent implements OnInit, OnDestroy {
|
||||
|
||||
private loadDetailedDocuments(requestId: string): void {
|
||||
this.loadingDocuments.set(true);
|
||||
this.api.get<any[]>(`/admin/documents/${requestId}`)
|
||||
this.api.get<any[]>(`/requests/${requestId}/documents`)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (documents) => {
|
||||
this.detailedDocuments.set(documents ?? []);
|
||||
// Map API response fields to what DocumentViewerComponent expects
|
||||
const mapped = (documents ?? []).map(doc => ({
|
||||
id: doc.id,
|
||||
name: doc.originalFilename || doc.name || 'Unknown',
|
||||
type: doc.docType || doc.type || 'OTHER',
|
||||
size: doc.fileSize || doc.size || 0,
|
||||
fileHash: doc.currentHash || doc.fileHash || '',
|
||||
url: doc.url || '',
|
||||
uploadedAt: doc.createdAt || doc.uploadedAt || new Date().toISOString(),
|
||||
uploadedBy: doc.uploadedBy || '',
|
||||
currentVersion: doc.currentVersion || 1,
|
||||
}));
|
||||
this.detailedDocuments.set(mapped);
|
||||
this.loadingDocuments.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
@@ -502,6 +524,25 @@ export class RequestDetailComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
openUploadDialog(): void {
|
||||
const req = this.request();
|
||||
if (!req) return;
|
||||
|
||||
const dialogRef = this.dialog.open(DocumentUploadComponent, {
|
||||
width: '560px',
|
||||
data: { requestId: req.id } as DocumentUploadDialogData,
|
||||
});
|
||||
|
||||
dialogRef.afterClosed()
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe((result) => {
|
||||
if (result) {
|
||||
// Reload documents after successful upload
|
||||
this.loadDetailedDocuments(req.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
submitRequest(): void {
|
||||
const req = this.request();
|
||||
if (!req) return;
|
||||
|
||||
@@ -166,22 +166,27 @@ export class RequestService {
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if (!dto.applicantId || typeof dto.applicantId !== 'string' || dto.applicantId.trim().length === 0) {
|
||||
return throwError(() => new Error('Applicant ID is required'));
|
||||
if (!dto.applicantName || typeof dto.applicantName !== 'string' || dto.applicantName.trim().length < 2) {
|
||||
return throwError(() => new Error('Applicant name must be at least 2 characters'));
|
||||
}
|
||||
|
||||
if (!dto.requestType) {
|
||||
return throwError(() => new Error('Request type is required'));
|
||||
}
|
||||
|
||||
if (!dto.workflowId || typeof dto.workflowId !== 'string' || dto.workflowId.trim().length === 0) {
|
||||
return throwError(() => new Error('Workflow ID is required'));
|
||||
// Either workflowId or workflowCode must be provided
|
||||
if ((!dto.workflowId || dto.workflowId.trim().length === 0) &&
|
||||
(!dto.workflowCode || dto.workflowCode.trim().length === 0)) {
|
||||
return throwError(() => new Error('Workflow ID or code is required'));
|
||||
}
|
||||
|
||||
const sanitizedDto: CreateRequestDto = {
|
||||
applicantId: dto.applicantId.trim(),
|
||||
applicantName: dto.applicantName.trim(),
|
||||
applicantPhone: dto.applicantPhone?.trim(),
|
||||
businessName: dto.businessName?.trim(),
|
||||
requestType: dto.requestType,
|
||||
workflowId: dto.workflowId.trim(),
|
||||
workflowId: dto.workflowId?.trim(),
|
||||
workflowCode: dto.workflowCode?.trim(),
|
||||
metadata: dto.metadata ?? {},
|
||||
tokenId: dto.tokenId,
|
||||
};
|
||||
|
||||
@@ -75,11 +75,11 @@ import { WebhookResponseDto } from '../../../api/models';
|
||||
<th mat-header-cell *matHeaderCellDef>Events</th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
<div class="events-chips">
|
||||
@for (event of row.events.slice(0, 2); track event) {
|
||||
@for (event of (row.events || []).slice(0, 2); track event) {
|
||||
<mat-chip>{{ formatEvent(event) }}</mat-chip>
|
||||
}
|
||||
@if (row.events.length > 2) {
|
||||
<mat-chip>+{{ row.events.length - 2 }}</mat-chip>
|
||||
@if ((row.events?.length || 0) > 2) {
|
||||
<mat-chip>+{{ (row.events?.length || 0) - 2 }}</mat-chip>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -54,8 +54,8 @@ function validateCreateWorkflowDto(dto: CreateWorkflowDto | null | undefined): C
|
||||
throw new Error('Workflow name cannot exceed 200 characters');
|
||||
}
|
||||
|
||||
if (!dto.departmentId || typeof dto.departmentId !== 'string' || dto.departmentId.trim().length === 0) {
|
||||
throw new Error('Department ID is required');
|
||||
if (!dto.workflowType || typeof dto.workflowType !== 'string') {
|
||||
throw new Error('Workflow type is required');
|
||||
}
|
||||
|
||||
if (!dto.stages || !Array.isArray(dto.stages) || dto.stages.length === 0) {
|
||||
@@ -63,11 +63,11 @@ function validateCreateWorkflowDto(dto: CreateWorkflowDto | null | undefined): C
|
||||
}
|
||||
|
||||
// Validate each stage
|
||||
dto.stages.forEach((stage, index) => {
|
||||
if (!stage.name || typeof stage.name !== 'string' || stage.name.trim().length === 0) {
|
||||
dto.stages.forEach((stage: any, index: number) => {
|
||||
if (!stage.stageName || typeof stage.stageName !== 'string' || stage.stageName.trim().length === 0) {
|
||||
throw new Error(`Stage ${index + 1}: Name is required`);
|
||||
}
|
||||
if (!stage.order || typeof stage.order !== 'number' || stage.order < 1) {
|
||||
if (typeof stage.stageOrder !== 'number' || stage.stageOrder < 1) {
|
||||
throw new Error(`Stage ${index + 1}: Valid order is required`);
|
||||
}
|
||||
});
|
||||
@@ -76,7 +76,6 @@ function validateCreateWorkflowDto(dto: CreateWorkflowDto | null | undefined): C
|
||||
...dto,
|
||||
name: dto.name.trim(),
|
||||
description: dto.description?.trim() || undefined,
|
||||
departmentId: dto.departmentId.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -118,6 +117,13 @@ function validateUpdateWorkflowDto(dto: UpdateWorkflowDto | null | undefined): U
|
||||
sanitized.stages = dto.stages;
|
||||
}
|
||||
|
||||
if (dto.metadata !== undefined) {
|
||||
if (typeof dto.metadata !== 'object' || dto.metadata === null) {
|
||||
throw new Error('Metadata must be an object');
|
||||
}
|
||||
sanitized.metadata = dto.metadata;
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
@@ -131,7 +137,7 @@ export class WorkflowService {
|
||||
const validated = validatePagination(page, limit);
|
||||
|
||||
return this.api
|
||||
.get<PaginatedWorkflowsResponse>('/workflows', {
|
||||
.getRaw<PaginatedWorkflowsResponse>('/workflows', {
|
||||
page: validated.page,
|
||||
limit: validated.limit,
|
||||
})
|
||||
@@ -148,16 +154,13 @@ export class WorkflowService {
|
||||
try {
|
||||
const validId = validateId(id, 'Workflow ID');
|
||||
|
||||
return this.api.get<WorkflowResponseDto>(`/workflows/${validId}`).pipe(
|
||||
return this.api.getRaw<WorkflowResponseDto>(`/workflows/${validId}`).pipe(
|
||||
map((response) => {
|
||||
if (!response) {
|
||||
throw new Error('Workflow not found');
|
||||
}
|
||||
// Ensure nested arrays are valid
|
||||
return {
|
||||
...response,
|
||||
stages: Array.isArray(response.stages) ? response.stages : [],
|
||||
};
|
||||
// Response structure has stages inside definition
|
||||
return response;
|
||||
}),
|
||||
catchError((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : `Failed to fetch workflow: ${id}`;
|
||||
@@ -173,7 +176,7 @@ export class WorkflowService {
|
||||
try {
|
||||
const sanitizedDto = validateCreateWorkflowDto(dto);
|
||||
|
||||
return this.api.post<WorkflowResponseDto>('/workflows', sanitizedDto).pipe(
|
||||
return this.api.postRaw<WorkflowResponseDto>('/workflows', sanitizedDto).pipe(
|
||||
catchError((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : 'Failed to create workflow';
|
||||
return throwError(() => new Error(message));
|
||||
|
||||
@@ -21,7 +21,7 @@ import { WorkflowService } from '../services/workflow.service';
|
||||
import { DepartmentService } from '../../departments/services/department.service';
|
||||
import { NotificationService } from '../../../core/services/notification.service';
|
||||
import { AuthService } from '../../../core/services/auth.service';
|
||||
import { WorkflowResponseDto, WorkflowStage, DepartmentResponseDto } from '../../../api/models';
|
||||
import { WorkflowResponseDto, DepartmentResponseDto } from '../../../api/models';
|
||||
|
||||
// Node position interface for canvas positioning
|
||||
interface NodePosition {
|
||||
@@ -29,13 +29,20 @@ interface NodePosition {
|
||||
y: number;
|
||||
}
|
||||
|
||||
// Extended stage with visual properties
|
||||
interface VisualStage extends WorkflowStage {
|
||||
// Visual stage interface for canvas display (decoupled from backend model)
|
||||
interface VisualStage {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
departmentId: string;
|
||||
order: number;
|
||||
isRequired: boolean;
|
||||
position: NodePosition;
|
||||
isSelected: boolean;
|
||||
isStartNode?: boolean;
|
||||
isEndNode?: boolean;
|
||||
connections: string[]; // IDs of connected stages (outgoing)
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
// Connection between stages
|
||||
@@ -152,7 +159,7 @@ export class WorkflowBuilderComponent implements OnInit {
|
||||
private loadDepartments(): void {
|
||||
this.departmentService.getDepartments(1, 100).subscribe({
|
||||
next: (response) => {
|
||||
this.departments.set(response.data);
|
||||
this.departments.set(response?.data ?? []);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -168,15 +175,31 @@ export class WorkflowBuilderComponent implements OnInit {
|
||||
isActive: workflow.isActive,
|
||||
});
|
||||
|
||||
// Convert stages to visual stages with positions
|
||||
const visualStages = workflow.stages.map((stage, index) => ({
|
||||
...stage,
|
||||
position: this.calculateStagePosition(index, workflow.stages.length),
|
||||
isSelected: false,
|
||||
isStartNode: index === 0,
|
||||
isEndNode: index === workflow.stages.length - 1,
|
||||
connections: index < workflow.stages.length - 1 ? [workflow.stages[index + 1].id] : [],
|
||||
}));
|
||||
// Get stages from definition (backend format)
|
||||
const backendStages = workflow.definition?.stages || [];
|
||||
|
||||
// Convert backend stages to visual stages with positions
|
||||
const visualStages: VisualStage[] = backendStages.map((stage, index) => {
|
||||
// Find department by code to get departmentId
|
||||
const dept = this.departments().find(d =>
|
||||
d.code === stage.requiredApprovals?.[0]?.departmentCode
|
||||
);
|
||||
|
||||
return {
|
||||
id: stage.stageId,
|
||||
name: stage.stageName,
|
||||
description: stage.metadata?.['description'] || '',
|
||||
departmentId: dept?.id || '',
|
||||
order: stage.stageOrder,
|
||||
isRequired: stage.metadata?.['isRequired'] ?? true,
|
||||
position: stage.metadata?.['position'] || this.calculateStagePosition(index, backendStages.length),
|
||||
isSelected: false,
|
||||
isStartNode: index === 0,
|
||||
isEndNode: index === backendStages.length - 1,
|
||||
connections: index < backendStages.length - 1 ? [backendStages[index + 1].stageId] : [],
|
||||
metadata: stage.metadata,
|
||||
};
|
||||
});
|
||||
|
||||
this.stages.set(visualStages);
|
||||
this.rebuildConnections();
|
||||
@@ -513,24 +536,34 @@ export class WorkflowBuilderComponent implements OnInit {
|
||||
const departmentId = currentUser?.departmentId ||
|
||||
this.stages().find(s => s.departmentId)?.departmentId || '';
|
||||
|
||||
const dto = {
|
||||
// Transform visual stages to backend DTO format
|
||||
const dto: any = {
|
||||
name: workflowData.name,
|
||||
description: workflowData.description || undefined,
|
||||
workflowType: workflowData.workflowType,
|
||||
departmentId: departmentId,
|
||||
stages: this.stages().map((s, index) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
description: s.description,
|
||||
departmentId: s.departmentId,
|
||||
order: index + 1,
|
||||
isRequired: s.isRequired,
|
||||
metadata: {
|
||||
...s.metadata,
|
||||
position: s.position,
|
||||
connections: s.connections,
|
||||
},
|
||||
})),
|
||||
stages: this.stages().map((s, index) => {
|
||||
const department = this.departments().find(d => d.id === s.departmentId);
|
||||
return {
|
||||
stageId: s.id,
|
||||
stageName: s.name,
|
||||
stageOrder: index, // Backend expects 0-indexed
|
||||
executionType: s.metadata?.['executionType'] || 'SEQUENTIAL',
|
||||
requiredApprovals: [{
|
||||
departmentCode: department?.code || '',
|
||||
departmentName: department?.name || 'Unknown',
|
||||
canDelegate: false,
|
||||
}],
|
||||
completionCriteria: s.metadata?.['completionCriteria'] || 'ALL',
|
||||
rejectionHandling: 'FAIL_REQUEST',
|
||||
metadata: {
|
||||
description: s.description,
|
||||
position: s.position,
|
||||
connections: s.connections,
|
||||
timeoutHours: s.metadata?.['timeoutHours'],
|
||||
isRequired: s.isRequired,
|
||||
},
|
||||
};
|
||||
}),
|
||||
metadata: {
|
||||
visualLayout: {
|
||||
stages: this.stages().map(s => ({
|
||||
|
||||
@@ -304,7 +304,7 @@ export class WorkflowFormComponent implements OnInit {
|
||||
private loadDepartments(): void {
|
||||
this.departmentService.getDepartments(1, 100).subscribe({
|
||||
next: (response) => {
|
||||
this.departments.set(response.data);
|
||||
this.departments.set(response?.data ?? []);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -322,14 +322,19 @@ export class WorkflowFormComponent implements OnInit {
|
||||
});
|
||||
|
||||
this.stagesArray.clear();
|
||||
workflow.stages.forEach((stage) => {
|
||||
const stages = workflow.definition?.stages || [];
|
||||
stages.forEach((stage) => {
|
||||
// Find department by code to get departmentId
|
||||
const dept = this.departments().find(d =>
|
||||
d.code === stage.requiredApprovals?.[0]?.departmentCode
|
||||
);
|
||||
this.stagesArray.push(
|
||||
this.fb.group({
|
||||
id: [stage.id],
|
||||
name: [stage.name, Validators.required],
|
||||
departmentId: [stage.departmentId, Validators.required],
|
||||
order: [stage.order],
|
||||
isRequired: [stage.isRequired],
|
||||
id: [stage.stageId],
|
||||
name: [stage.stageName, Validators.required],
|
||||
departmentId: [dept?.id || '', Validators.required],
|
||||
order: [stage.stageOrder],
|
||||
isRequired: [stage.metadata?.['isRequired'] ?? true],
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -414,18 +419,30 @@ export class WorkflowFormComponent implements OnInit {
|
||||
const departmentId = currentUser?.departmentId ||
|
||||
(values.stages[0]?.departmentId) || '';
|
||||
|
||||
const dto = {
|
||||
// Transform to backend DTO format
|
||||
const dto: any = {
|
||||
name: normalizeWhitespace(values.name),
|
||||
description: normalizeWhitespace(values.description) || undefined,
|
||||
workflowType: values.workflowType!,
|
||||
departmentId: departmentId,
|
||||
stages: values.stages.map((s, i) => ({
|
||||
id: s.id || `stage-${i + 1}`,
|
||||
name: normalizeWhitespace(s.name) || `Stage ${i + 1}`,
|
||||
departmentId: s.departmentId || '',
|
||||
isRequired: s.isRequired ?? true,
|
||||
order: i + 1,
|
||||
})),
|
||||
stages: values.stages.map((s, i) => {
|
||||
const department = this.departments().find(d => d.id === s.departmentId);
|
||||
return {
|
||||
stageId: s.id || `stage-${i + 1}`,
|
||||
stageName: normalizeWhitespace(s.name) || `Stage ${i + 1}`,
|
||||
stageOrder: i, // Backend expects 0-indexed
|
||||
executionType: 'SEQUENTIAL' as const,
|
||||
requiredApprovals: [{
|
||||
departmentCode: department?.code || '',
|
||||
departmentName: department?.name || 'Unknown',
|
||||
canDelegate: false,
|
||||
}],
|
||||
completionCriteria: 'ALL' as const,
|
||||
rejectionHandling: 'FAIL_REQUEST' as const,
|
||||
metadata: {
|
||||
isRequired: s.isRequired ?? true,
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
const action$ = this.isEditMode()
|
||||
|
||||
@@ -81,7 +81,7 @@ import { WorkflowResponseDto } from '../../../api/models';
|
||||
<ng-container matColumnDef="stages">
|
||||
<th mat-header-cell *matHeaderCellDef>Stages</th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
<mat-chip>{{ row.stages?.length || 0 }} stages</mat-chip>
|
||||
<mat-chip>{{ row.definition?.stages?.length || 0 }} stages</mat-chip>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ import { WorkflowResponseDto } from '../../../api/models';
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">Total Stages</span>
|
||||
<span class="value">{{ wf.stages.length || 0 }}</span>
|
||||
<span class="value">{{ wf.definition?.stages?.length || 0 }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">Created</span>
|
||||
@@ -75,14 +75,14 @@ import { WorkflowResponseDto } from '../../../api/models';
|
||||
<div class="stages-section">
|
||||
<h3>Approval Stages</h3>
|
||||
<div class="stages-flow">
|
||||
@for (stage of wf.stages; track stage.id; let i = $index; let last = $last) {
|
||||
@for (stage of (wf.definition?.stages || []); track stage.stageId; let i = $index; let last = $last) {
|
||||
<div class="stage-item">
|
||||
<div class="stage-number">{{ i + 1 }}</div>
|
||||
<mat-card class="stage-card">
|
||||
<div class="stage-content">
|
||||
<div class="stage-name">{{ stage.name }}</div>
|
||||
<div class="stage-dept">{{ stage.departmentId }}</div>
|
||||
@if (stage.isRequired) {
|
||||
<div class="stage-name">{{ stage.stageName }}</div>
|
||||
<div class="stage-dept">{{ stage.requiredApprovals?.[0]?.departmentCode || 'N/A' }}</div>
|
||||
@if (stage.metadata?.['isRequired'] !== false) {
|
||||
<mat-chip>Required</mat-chip>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -1,38 +1,93 @@
|
||||
<!-- Skip to main content - GIGW 3.0 Accessibility -->
|
||||
<a href="#main-content" class="skip-link">Skip to main content</a>
|
||||
|
||||
<div class="auth-layout">
|
||||
<!-- Animated Background -->
|
||||
<div class="animated-background">
|
||||
<!-- Floating Blockchain Nodes -->
|
||||
<div class="node node-1"></div>
|
||||
<div class="node node-2"></div>
|
||||
<div class="node node-3"></div>
|
||||
<div class="node node-4"></div>
|
||||
<div class="node node-5"></div>
|
||||
<div class="node node-6"></div>
|
||||
<div class="landing-page">
|
||||
<!-- Immersive Background with Blockchain Visualization -->
|
||||
<div class="hero-background">
|
||||
<!-- Animated Gradient Mesh -->
|
||||
<div class="gradient-mesh"></div>
|
||||
|
||||
<!-- Connection Lines -->
|
||||
<svg class="connections" viewBox="0 0 100 100" preserveAspectRatio="none">
|
||||
<line class="connection" x1="20" y1="30" x2="50" y2="50" />
|
||||
<line class="connection" x1="50" y1="50" x2="80" y2="25" />
|
||||
<line class="connection" x1="80" y1="25" x2="70" y2="70" />
|
||||
<line class="connection" x1="70" y1="70" x2="30" y2="80" />
|
||||
<line class="connection" x1="30" y1="80" x2="20" y2="30" />
|
||||
<line class="connection" x1="50" y1="50" x2="70" y2="70" />
|
||||
<line class="connection" x1="50" y1="50" x2="30" y2="80" />
|
||||
</svg>
|
||||
<!-- Floating Blockchain Network -->
|
||||
<div class="blockchain-network">
|
||||
<!-- Primary Nodes -->
|
||||
<div class="bc-node primary" style="--x: 15%; --y: 20%; --delay: 0s; --size: 20px;"></div>
|
||||
<div class="bc-node primary" style="--x: 75%; --y: 15%; --delay: 1.5s; --size: 24px;"></div>
|
||||
<div class="bc-node primary" style="--x: 85%; --y: 60%; --delay: 3s; --size: 18px;"></div>
|
||||
<div class="bc-node primary" style="--x: 25%; --y: 70%; --delay: 2s; --size: 22px;"></div>
|
||||
<div class="bc-node primary" style="--x: 50%; --y: 45%; --delay: 0.5s; --size: 28px;"></div>
|
||||
|
||||
<!-- Gradient Overlay -->
|
||||
<div class="gradient-overlay"></div>
|
||||
<!-- Secondary Nodes -->
|
||||
<div class="bc-node secondary" style="--x: 35%; --y: 30%; --delay: 0.8s; --size: 12px;"></div>
|
||||
<div class="bc-node secondary" style="--x: 65%; --y: 35%; --delay: 2.3s; --size: 10px;"></div>
|
||||
<div class="bc-node secondary" style="--x: 45%; --y: 75%; --delay: 1.2s; --size: 14px;"></div>
|
||||
<div class="bc-node secondary" style="--x: 90%; --y: 30%; --delay: 3.5s; --size: 8px;"></div>
|
||||
<div class="bc-node secondary" style="--x: 10%; --y: 50%; --delay: 1.8s; --size: 10px;"></div>
|
||||
|
||||
<!-- Connection Lines SVG -->
|
||||
<svg class="network-connections" viewBox="0 0 100 100" preserveAspectRatio="none">
|
||||
<defs>
|
||||
<linearGradient id="lineGrad1" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="rgba(99, 102, 241, 0.8)" />
|
||||
<stop offset="100%" stop-color="rgba(168, 85, 247, 0.4)" />
|
||||
</linearGradient>
|
||||
<linearGradient id="lineGrad2" x1="100%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="rgba(16, 185, 129, 0.6)" />
|
||||
<stop offset="100%" stop-color="rgba(99, 102, 241, 0.3)" />
|
||||
</linearGradient>
|
||||
<filter id="glow">
|
||||
<feGaussianBlur stdDeviation="1" result="coloredBlur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="coloredBlur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
<!-- Network Connections -->
|
||||
<path class="connection-line" d="M15,20 Q30,35 50,45" filter="url(#glow)"/>
|
||||
<path class="connection-line" d="M50,45 Q65,30 75,15" filter="url(#glow)"/>
|
||||
<path class="connection-line" d="M75,15 Q85,35 85,60" filter="url(#glow)"/>
|
||||
<path class="connection-line" d="M85,60 Q60,65 50,45" filter="url(#glow)"/>
|
||||
<path class="connection-line" d="M50,45 Q35,60 25,70" filter="url(#glow)"/>
|
||||
<path class="connection-line" d="M25,70 Q15,45 15,20" filter="url(#glow)"/>
|
||||
<path class="connection-line secondary" d="M35,30 Q42,38 50,45" filter="url(#glow)"/>
|
||||
<path class="connection-line secondary" d="M65,35 Q58,40 50,45" filter="url(#glow)"/>
|
||||
<path class="connection-line secondary" d="M45,75 Q47,60 50,45" filter="url(#glow)"/>
|
||||
|
||||
<!-- Data Packets Animation -->
|
||||
<circle class="data-packet" r="1">
|
||||
<animateMotion dur="4s" repeatCount="indefinite" path="M15,20 Q30,35 50,45 Q65,30 75,15"/>
|
||||
</circle>
|
||||
<circle class="data-packet" r="1">
|
||||
<animateMotion dur="5s" repeatCount="indefinite" path="M75,15 Q85,35 85,60 Q60,65 50,45 Q35,60 25,70"/>
|
||||
</circle>
|
||||
<circle class="data-packet" r="0.8">
|
||||
<animateMotion dur="3s" repeatCount="indefinite" path="M25,70 Q15,45 15,20 Q30,35 50,45"/>
|
||||
</circle>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Floating Particles -->
|
||||
<div class="particles">
|
||||
<div class="particle" style="--x: 10%; --y: 15%; --duration: 20s;"></div>
|
||||
<div class="particle" style="--x: 20%; --y: 80%; --duration: 25s;"></div>
|
||||
<div class="particle" style="--x: 80%; --y: 25%; --duration: 22s;"></div>
|
||||
<div class="particle" style="--x: 90%; --y: 85%; --duration: 28s;"></div>
|
||||
<div class="particle" style="--x: 40%; --y: 10%; --duration: 18s;"></div>
|
||||
<div class="particle" style="--x: 60%; --y: 90%; --duration: 24s;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Ambient Light Effects -->
|
||||
<div class="ambient-light light-1"></div>
|
||||
<div class="ambient-light light-2"></div>
|
||||
<div class="ambient-light light-3"></div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Container -->
|
||||
<div class="auth-container">
|
||||
<!-- Left Side - Branding -->
|
||||
<div class="auth-branding">
|
||||
<div class="branding-content">
|
||||
<div class="emblem-wrapper">
|
||||
<!-- Main Content Area -->
|
||||
<div class="landing-content">
|
||||
<!-- Top Navigation Bar -->
|
||||
<header class="top-nav">
|
||||
<div class="nav-brand">
|
||||
<div class="emblem-container">
|
||||
<img
|
||||
src="assets/images/goa-emblem.svg"
|
||||
alt="Government of Goa Emblem"
|
||||
@@ -40,80 +95,105 @@
|
||||
onerror="this.style.display='none'"
|
||||
/>
|
||||
</div>
|
||||
<div class="brand-text">
|
||||
<span class="brand-name">Government of Goa</span>
|
||||
<span class="brand-subtitle">Blockchain e-Licensing Platform</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="nav-status">
|
||||
<div class="status-indicator">
|
||||
<span class="status-dot"></span>
|
||||
<span class="status-label">Network Active</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<h1 class="brand-title">
|
||||
<span class="title-line">Government of Goa</span>
|
||||
<span class="title-highlight">Blockchain e-Licensing</span>
|
||||
<!-- Hero Section with Login -->
|
||||
<main class="hero-section" id="main-content" role="main">
|
||||
<!-- Left Column - Hero Content -->
|
||||
<div class="hero-content">
|
||||
<div class="hero-badge">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4z"/>
|
||||
</svg>
|
||||
<span>Powered by Hyperledger Besu</span>
|
||||
</div>
|
||||
|
||||
<h1 class="hero-title">
|
||||
<span class="title-line-1">Secure & Transparent</span>
|
||||
<span class="title-line-2">License Management</span>
|
||||
</h1>
|
||||
|
||||
<p class="brand-tagline">
|
||||
Secure, Transparent, Immutable
|
||||
<p class="hero-description">
|
||||
Experience the future of government services with blockchain-backed
|
||||
license applications, instant verification, and tamper-proof records.
|
||||
</p>
|
||||
|
||||
<div class="features-grid">
|
||||
<div class="feature-item">
|
||||
<div class="feature-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="24" height="24">
|
||||
<path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="feature-text">
|
||||
<span class="feature-title">Blockchain Secured</span>
|
||||
<span class="feature-desc">Tamper-proof license records</span>
|
||||
</div>
|
||||
<!-- Feature Pills -->
|
||||
<div class="feature-pills">
|
||||
<div class="feature-pill">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
|
||||
</svg>
|
||||
<span>Instant Verification</span>
|
||||
</div>
|
||||
|
||||
<div class="feature-item">
|
||||
<div class="feature-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="24" height="24">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="feature-text">
|
||||
<span class="feature-title">Instant Verification</span>
|
||||
<span class="feature-desc">Real-time license validity</span>
|
||||
</div>
|
||||
<div class="feature-pill">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z"/>
|
||||
</svg>
|
||||
<span>Tamper-Proof</span>
|
||||
</div>
|
||||
|
||||
<div class="feature-item">
|
||||
<div class="feature-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="24" height="24">
|
||||
<path d="M9 11H7v2h2v-2zm4 0h-2v2h2v-2zm4 0h-2v2h2v-2zm2-7h-1V2h-2v2H8V2H6v2H5c-1.11 0-1.99.9-1.99 2L3 20c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 16H5V9h14v11z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="feature-text">
|
||||
<span class="feature-title">Multi-Dept Workflow</span>
|
||||
<span class="feature-desc">Streamlined approvals</span>
|
||||
</div>
|
||||
<div class="feature-pill">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/>
|
||||
</svg>
|
||||
<span>Multi-Department</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Network Status -->
|
||||
<div class="network-status">
|
||||
<div class="status-dot"></div>
|
||||
<span class="status-text">Hyperledger Besu Network</span>
|
||||
<span class="status-badge">Live</span>
|
||||
<!-- Benefits List -->
|
||||
<div class="benefits-list">
|
||||
<div class="benefit-item">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-5 14H7v-2h7v2zm3-4H7v-2h10v2zm0-4H7V7h10v2z"/>
|
||||
</svg>
|
||||
<span>100% Paperless Process</span>
|
||||
</div>
|
||||
<div class="benefit-item">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z"/>
|
||||
</svg>
|
||||
<span>Apply Anytime, Anywhere</span>
|
||||
</div>
|
||||
<div class="benefit-item">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/>
|
||||
</svg>
|
||||
<span>Real-time Status Tracking</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Side - Login Form -->
|
||||
<div class="auth-content" id="main-content" role="main">
|
||||
<div class="auth-card">
|
||||
<router-outlet></router-outlet>
|
||||
<!-- Right Column - Login Card -->
|
||||
<div class="login-container">
|
||||
<div class="login-card">
|
||||
<div class="card-glow"></div>
|
||||
<router-outlet></router-outlet>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="auth-footer" role="contentinfo">
|
||||
<p class="copyright">© 2024 Government of Goa. All rights reserved.</p>
|
||||
<!-- Footer -->
|
||||
<footer class="landing-footer" role="contentinfo">
|
||||
<div class="footer-content">
|
||||
<p class="copyright">© {{ currentYear }} Government of Goa, India. All rights reserved.</p>
|
||||
<div class="footer-links">
|
||||
<a href="#">Privacy Policy</a>
|
||||
<span class="divider">|</span>
|
||||
<a href="#">Terms of Service</a>
|
||||
<span class="divider">|</span>
|
||||
<a href="#">Help</a>
|
||||
<a href="/policies" aria-label="Website Policies">Policies</a>
|
||||
<a href="/terms" aria-label="Terms and Conditions">Terms</a>
|
||||
<a href="/accessibility" aria-label="Accessibility Statement">Accessibility</a>
|
||||
<a href="/contact" aria-label="Contact Information">Contact</a>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,4 +8,6 @@ import { RouterModule } from '@angular/router';
|
||||
templateUrl: './auth-layout.component.html',
|
||||
styleUrl: './auth-layout.component.scss',
|
||||
})
|
||||
export class AuthLayoutComponent {}
|
||||
export class AuthLayoutComponent {
|
||||
readonly currentYear = new Date().getFullYear();
|
||||
}
|
||||
|
||||
@@ -32,6 +32,25 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Department Info Banner (for department users) -->
|
||||
@if (userType() === 'DEPARTMENT' && (currentUser | async); as user) {
|
||||
<div class="department-info-banner" [class.collapsed]="!sidenavOpened()">
|
||||
@if (sidenavOpened()) {
|
||||
<div class="dept-icon">
|
||||
<mat-icon>business</mat-icon>
|
||||
</div>
|
||||
<div class="dept-details">
|
||||
<span class="dept-label">Department Portal</span>
|
||||
<span class="dept-name">{{ departmentName() || user.name }}</span>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="dept-icon-compact" [matTooltip]="departmentName() || user.name" matTooltipPosition="right">
|
||||
<mat-icon>business</mat-icon>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Navigation Links -->
|
||||
<nav class="sidebar-nav">
|
||||
<div class="nav-section">
|
||||
@@ -72,11 +91,12 @@
|
||||
class="nav-item"
|
||||
routerLink="/admin"
|
||||
routerLinkActive="active"
|
||||
aria-label="Admin Portal - Platform administration"
|
||||
[matTooltip]="!sidenavOpened() ? 'Admin Portal' : ''"
|
||||
matTooltipPosition="right"
|
||||
>
|
||||
<div class="nav-icon">
|
||||
<mat-icon>admin_panel_settings</mat-icon>
|
||||
<mat-icon aria-hidden="true">admin_panel_settings</mat-icon>
|
||||
</div>
|
||||
@if (sidenavOpened()) {
|
||||
<span class="nav-label">Admin Portal</span>
|
||||
@@ -89,16 +109,16 @@
|
||||
<!-- Sidebar Footer -->
|
||||
<div class="sidebar-footer">
|
||||
@if (sidenavOpened()) {
|
||||
<div class="blockchain-status">
|
||||
<div class="status-indicator online"></div>
|
||||
<div class="blockchain-status" role="status" aria-live="polite" aria-label="Blockchain network status: Connected">
|
||||
<div class="status-indicator online" aria-hidden="true"></div>
|
||||
<div class="status-text">
|
||||
<span class="status-label">Blockchain</span>
|
||||
<span class="status-value">Connected</span>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="blockchain-status-compact">
|
||||
<div class="status-indicator online"></div>
|
||||
<div class="blockchain-status-compact" role="status" aria-label="Blockchain connected">
|
||||
<div class="status-indicator online" aria-hidden="true"></div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -106,7 +126,7 @@
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<div class="main-wrapper">
|
||||
<!-- Top Header -->
|
||||
<!-- Top Header - DBIM Compliant -->
|
||||
<header class="top-header" role="banner">
|
||||
<div class="header-left">
|
||||
<button
|
||||
@@ -118,15 +138,47 @@
|
||||
<mat-icon>{{ sidenavOpened() ? 'menu_open' : 'menu' }}</mat-icon>
|
||||
</button>
|
||||
|
||||
<!-- Breadcrumb (optional) -->
|
||||
<nav class="breadcrumb hide-mobile" aria-label="Breadcrumb">
|
||||
<span class="breadcrumb-item">Dashboard</span>
|
||||
<!-- Breadcrumb - GIGW 3.0 Required -->
|
||||
<nav class="breadcrumb hide-mobile" aria-label="Breadcrumb navigation">
|
||||
<ol class="breadcrumb-list" role="list">
|
||||
<li class="breadcrumb-item">
|
||||
<a routerLink="/dashboard" aria-label="Go to Dashboard">Home</a>
|
||||
</li>
|
||||
<!-- Additional breadcrumb items would be dynamically added based on route -->
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<!-- Search (optional) -->
|
||||
<button mat-icon-button class="header-action hide-mobile" aria-label="Search">
|
||||
<!-- Language Toggle - DBIM Required -->
|
||||
<div class="language-toggle" role="group" aria-label="Language selection">
|
||||
<button
|
||||
mat-button
|
||||
class="lang-btn"
|
||||
[class.active]="currentLanguage() === 'en'"
|
||||
(click)="setLanguage('en')"
|
||||
aria-label="Switch to English"
|
||||
>
|
||||
A
|
||||
</button>
|
||||
<span class="lang-divider">|</span>
|
||||
<button
|
||||
mat-button
|
||||
class="lang-btn"
|
||||
[class.active]="currentLanguage() === 'hi'"
|
||||
(click)="setLanguage('hi')"
|
||||
aria-label="Switch to Hindi"
|
||||
>
|
||||
अ
|
||||
</button>
|
||||
</div>
|
||||
<!-- Search - GIGW 3.0 Required -->
|
||||
<button
|
||||
mat-icon-button
|
||||
class="header-action hide-mobile"
|
||||
aria-label="Search the platform"
|
||||
title="Search"
|
||||
>
|
||||
<mat-icon>search</mat-icon>
|
||||
</button>
|
||||
|
||||
@@ -135,9 +187,15 @@
|
||||
mat-icon-button
|
||||
class="header-action"
|
||||
[matMenuTriggerFor]="notificationMenu"
|
||||
aria-label="Notifications"
|
||||
[attr.aria-label]="'Notifications, ' + unreadNotifications() + ' unread'"
|
||||
title="Notifications"
|
||||
>
|
||||
<mat-icon [matBadge]="unreadNotifications()" matBadgeColor="warn" matBadgeSize="small">
|
||||
<mat-icon
|
||||
[matBadge]="unreadNotifications()"
|
||||
matBadgeColor="warn"
|
||||
matBadgeSize="small"
|
||||
[attr.aria-hidden]="true"
|
||||
>
|
||||
notifications
|
||||
</mat-icon>
|
||||
</button>
|
||||
@@ -237,12 +295,12 @@
|
||||
<router-outlet></router-outlet>
|
||||
</main>
|
||||
|
||||
<!-- Footer - DBIM Compliant -->
|
||||
<!-- Footer - DBIM & GIGW 3.0 Compliant -->
|
||||
<footer class="main-footer" role="contentinfo">
|
||||
<div class="footer-content">
|
||||
<div class="footer-left">
|
||||
<span class="footer-text">
|
||||
This platform belongs to Government of Goa, India
|
||||
© {{ currentYear }} Government of Goa, India. All rights reserved.
|
||||
</span>
|
||||
<span class="footer-divider hide-mobile">|</span>
|
||||
<span class="footer-text hide-mobile">
|
||||
@@ -250,9 +308,11 @@
|
||||
</span>
|
||||
</div>
|
||||
<div class="footer-right">
|
||||
<a href="#" class="footer-link">Website Policies</a>
|
||||
<a href="#" class="footer-link">Help</a>
|
||||
<a href="#" class="footer-link">Feedback</a>
|
||||
<a href="/policies" class="footer-link" aria-label="Website Policies">Website Policies</a>
|
||||
<a href="/terms" class="footer-link" aria-label="Terms and Conditions">Terms & Conditions</a>
|
||||
<a href="/accessibility" class="footer-link" aria-label="Accessibility Statement">Accessibility</a>
|
||||
<a href="/contact" class="footer-link" aria-label="Contact Information">Contact</a>
|
||||
<a href="/help" class="footer-link" aria-label="Help and Support">Help</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -125,6 +125,92 @@ $transition-speed: 250ms;
|
||||
}
|
||||
}
|
||||
|
||||
// Department Info Banner
|
||||
.department-info-banner {
|
||||
margin: 12px 12px 0;
|
||||
padding: 14px 16px;
|
||||
background: linear-gradient(135deg, rgba(99, 102, 241, 0.25) 0%, rgba(139, 92, 246, 0.15) 100%);
|
||||
border: 1px solid rgba(139, 92, 246, 0.3);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
transition: all $transition-speed ease;
|
||||
|
||||
&.collapsed {
|
||||
padding: 10px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.dept-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, #8B5CF6 0%, #6366F1 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.35);
|
||||
|
||||
mat-icon {
|
||||
font-size: 22px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.dept-icon-compact {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, #8B5CF6 0%, #6366F1 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: help;
|
||||
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.35);
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.dept-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
overflow: hidden;
|
||||
|
||||
.dept-label {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.dept-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.dept-code {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sidebar Navigation
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
@@ -344,6 +430,7 @@ $transition-speed: 250ms;
|
||||
}
|
||||
}
|
||||
|
||||
// Breadcrumb - GIGW 3.0 Required
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -352,6 +439,98 @@ $transition-speed: 250ms;
|
||||
color: var(--dbim-grey-2);
|
||||
}
|
||||
|
||||
.breadcrumb-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.breadcrumb-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
a {
|
||||
color: var(--dbim-grey-3);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
transition: color 150ms ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--dbim-blue-dark);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--dbim-info);
|
||||
outline-offset: 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '/';
|
||||
margin-left: 8px;
|
||||
color: var(--dbim-grey-1);
|
||||
}
|
||||
|
||||
&:last-child::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:last-child a {
|
||||
color: var(--dbim-brown);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Language Toggle - DBIM Required
|
||||
.language-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-right: 8px;
|
||||
padding: 4px 8px;
|
||||
background: rgba(29, 10, 105, 0.04);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.lang-btn {
|
||||
min-width: 32px !important;
|
||||
padding: 4px 8px !important;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--dbim-grey-2);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
color: var(--dbim-blue-dark);
|
||||
background: rgba(29, 10, 105, 0.08);
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: var(--dbim-blue-dark);
|
||||
background: white;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--dbim-info);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.lang-divider {
|
||||
color: var(--dbim-grey-1);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -373,16 +552,24 @@ $transition-speed: 250ms;
|
||||
}
|
||||
|
||||
.user-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 6px 12px 6px 6px;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
gap: 10px;
|
||||
padding: 4px 12px 4px 4px !important;
|
||||
border-radius: 50px;
|
||||
background: transparent;
|
||||
min-height: 44px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(29, 10, 105, 0.05);
|
||||
}
|
||||
|
||||
// Ensure proper vertical alignment
|
||||
::ng-deep .mdc-button__label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
@@ -396,6 +583,8 @@ $transition-speed: 250ms;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
flex-shrink: 0;
|
||||
margin: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.user-avatar-large {
|
||||
@@ -409,24 +598,30 @@ $transition-speed: 250ms;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
flex-shrink: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
text-align: left;
|
||||
min-height: 36px;
|
||||
|
||||
.user-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--dbim-brown);
|
||||
line-height: 1.2;
|
||||
line-height: 1.3;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.user-role {
|
||||
font-size: 11px;
|
||||
color: var(--dbim-grey-2);
|
||||
line-height: 1.2;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -694,6 +889,28 @@ $transition-speed: 250ms;
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.department-info-banner {
|
||||
padding: 10px;
|
||||
justify-content: center;
|
||||
margin: 8px 8px 0;
|
||||
|
||||
.dept-details {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dept-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.main-wrapper {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, inject, signal, computed } from '@angular/core';
|
||||
import { Component, inject, signal, computed, OnInit, DestroyRef } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterModule, Router } from '@angular/router';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
@@ -8,6 +9,7 @@ import { MatDividerModule } from '@angular/material/divider';
|
||||
import { MatBadgeModule } from '@angular/material/badge';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { AuthService } from '../../core/services/auth.service';
|
||||
import { DepartmentService } from '../../features/departments/services/department.service';
|
||||
|
||||
interface NavItem {
|
||||
label: string;
|
||||
@@ -33,15 +35,23 @@ interface NavItem {
|
||||
templateUrl: './main-layout.component.html',
|
||||
styleUrl: './main-layout.component.scss',
|
||||
})
|
||||
export class MainLayoutComponent {
|
||||
export class MainLayoutComponent implements OnInit {
|
||||
private readonly authService = inject(AuthService);
|
||||
private readonly departmentService = inject(DepartmentService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
readonly sidenavOpened = signal(true);
|
||||
readonly currentUser = this.authService.currentUser$;
|
||||
readonly userType = this.authService.userType;
|
||||
|
||||
// Enhanced department info fetched from API
|
||||
readonly departmentCode = signal<string | null>(null);
|
||||
readonly departmentName = signal<string | null>(null);
|
||||
readonly emblemLoaded = signal(true);
|
||||
readonly unreadNotifications = signal(3);
|
||||
readonly currentLanguage = signal<'en' | 'hi'>('en');
|
||||
readonly currentYear = new Date().getFullYear();
|
||||
readonly lastUpdated = new Date().toLocaleDateString('en-GB', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
@@ -76,10 +86,50 @@ export class MainLayoutComponent {
|
||||
});
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadDepartmentDetails();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch department details if user is a department and code is missing
|
||||
*/
|
||||
private loadDepartmentDetails(): void {
|
||||
const user = this.authService.getCurrentUser();
|
||||
if (!user || user.type !== 'DEPARTMENT') return;
|
||||
|
||||
// If we already have department code from login, use it
|
||||
if (user.departmentCode) {
|
||||
this.departmentCode.set(user.departmentCode);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch department details using the user's ID (which is the department ID)
|
||||
const deptId = user.departmentId || user.id;
|
||||
if (!deptId) return;
|
||||
|
||||
this.departmentService.getDepartment(deptId)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (dept) => {
|
||||
this.departmentCode.set(dept.code);
|
||||
this.departmentName.set(dept.name);
|
||||
},
|
||||
error: () => {
|
||||
// Silently fail - we'll just not show the code
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
toggleSidenav(): void {
|
||||
this.sidenavOpened.update((v) => !v);
|
||||
}
|
||||
|
||||
setLanguage(lang: 'en' | 'hi'): void {
|
||||
this.currentLanguage.set(lang);
|
||||
// In production, this would trigger i18n service to change language
|
||||
document.documentElement.lang = lang;
|
||||
}
|
||||
|
||||
logout(): void {
|
||||
this.authService.logout();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatDialogModule, MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatDividerModule } from '@angular/material/divider';
|
||||
import { Clipboard } from '@angular/cdk/clipboard';
|
||||
import { BlockDto } from '../../../api/models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-block-detail-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatDialogModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatDividerModule,
|
||||
],
|
||||
template: `
|
||||
<div class="dialog-container">
|
||||
<div class="dialog-header">
|
||||
<div class="header-icon">
|
||||
<mat-icon>view_module</mat-icon>
|
||||
</div>
|
||||
<div class="header-text">
|
||||
<h2>Block #{{ data.blockNumber | number }}</h2>
|
||||
<p class="subtitle">Block Details</p>
|
||||
</div>
|
||||
<button mat-icon-button (click)="close()" class="close-btn">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<mat-divider></mat-divider>
|
||||
|
||||
<div class="dialog-content">
|
||||
<div class="detail-grid">
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">Block Number</div>
|
||||
<div class="detail-value highlight">{{ data.blockNumber | number }}</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-item full-width">
|
||||
<div class="detail-label">Block Hash</div>
|
||||
<div class="detail-value hash" (click)="copyToClipboard(data.hash)">
|
||||
<code>{{ data.hash }}</code>
|
||||
<mat-icon class="copy-icon">content_copy</mat-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-item full-width">
|
||||
<div class="detail-label">Parent Hash</div>
|
||||
<div class="detail-value hash" (click)="copyToClipboard(data.parentHash)">
|
||||
<code>{{ data.parentHash }}</code>
|
||||
<mat-icon class="copy-icon">content_copy</mat-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">Timestamp</div>
|
||||
<div class="detail-value">{{ data.timestamp | date:'medium' }}</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">Transactions</div>
|
||||
<div class="detail-value">
|
||||
<span class="tx-count">{{ data.transactionCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">Gas Used</div>
|
||||
<div class="detail-value">{{ data.gasUsed | number }}</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">Gas Limit</div>
|
||||
<div class="detail-value">{{ data.gasLimit | number }}</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">Size</div>
|
||||
<div class="detail-value">{{ formatSize(data.size) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<mat-divider></mat-divider>
|
||||
|
||||
<div class="dialog-actions">
|
||||
<button mat-button (click)="close()">Close</button>
|
||||
<button mat-flat-button color="primary" (click)="copyAllDetails()">
|
||||
<mat-icon>content_copy</mat-icon>
|
||||
Copy Details
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.dialog-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px 24px;
|
||||
background: linear-gradient(135deg, #1D0A69 0%, #2563EB 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
mat-icon {
|
||||
font-size: 28px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
.header-text {
|
||||
flex: 1;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
padding: 24px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
&.full-width {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-size: 0.95rem;
|
||||
color: #1f2937;
|
||||
font-weight: 500;
|
||||
|
||||
&.highlight {
|
||||
color: #2563EB;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&.hash {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
padding: 8px 12px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #eee;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
font-size: 0.8rem;
|
||||
word-break: break-all;
|
||||
flex: 1;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.copy-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: #6b7280;
|
||||
}
|
||||
}
|
||||
|
||||
.tx-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 32px;
|
||||
padding: 4px 12px;
|
||||
background: rgba(37, 99, 235, 0.1);
|
||||
color: #2563EB;
|
||||
border-radius: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding: 16px 24px;
|
||||
background: #fafafa;
|
||||
|
||||
button mat-icon {
|
||||
margin-right: 4px;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class BlockDetailDialogComponent {
|
||||
readonly data = inject<BlockDto>(MAT_DIALOG_DATA);
|
||||
private readonly dialogRef = inject(MatDialogRef<BlockDetailDialogComponent>);
|
||||
private readonly clipboard = inject(Clipboard);
|
||||
|
||||
close(): void {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
copyToClipboard(text: string): void {
|
||||
this.clipboard.copy(text);
|
||||
}
|
||||
|
||||
copyAllDetails(): void {
|
||||
const details = `Block #${this.data.blockNumber}
|
||||
Hash: ${this.data.hash}
|
||||
Parent Hash: ${this.data.parentHash}
|
||||
Timestamp: ${this.data.timestamp}
|
||||
Transactions: ${this.data.transactionCount}
|
||||
Gas Used: ${this.data.gasUsed}
|
||||
Gas Limit: ${this.data.gasLimit}
|
||||
Size: ${this.data.size} bytes`;
|
||||
|
||||
this.clipboard.copy(details);
|
||||
}
|
||||
|
||||
formatSize(bytes: number): string {
|
||||
if (!bytes) return '0 B';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,14 @@ import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatDialogModule, MatDialog } from '@angular/material/dialog';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { Clipboard } from '@angular/cdk/clipboard';
|
||||
import { interval, Subscription } from 'rxjs';
|
||||
import { ApiService } from '../../../core/services/api.service';
|
||||
import { BlockDto, BlockchainTransactionDto } from '../../../api/models';
|
||||
import { BlockDetailDialogComponent } from './block-detail-dialog.component';
|
||||
import { TransactionDetailDialogComponent } from './transaction-detail-dialog.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-blockchain-explorer-mini',
|
||||
@@ -25,6 +28,7 @@ import { BlockDto, BlockchainTransactionDto } from '../../../api/models';
|
||||
MatProgressSpinnerModule,
|
||||
MatTabsModule,
|
||||
MatChipsModule,
|
||||
MatDialogModule,
|
||||
RouterModule,
|
||||
],
|
||||
template: `
|
||||
@@ -240,18 +244,17 @@ import { BlockDto, BlockchainTransactionDto } from '../../../api/models';
|
||||
}
|
||||
}
|
||||
|
||||
.header-text {
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.header-text h3 {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.header-text .subtitle {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: rgba(255, 255, 255, 0.9) !important;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
@@ -598,6 +601,7 @@ import { BlockDto, BlockchainTransactionDto } from '../../../api/models';
|
||||
export class BlockchainExplorerMiniComponent implements OnInit, OnDestroy {
|
||||
private readonly api = inject(ApiService);
|
||||
private readonly clipboard = inject(Clipboard);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private refreshSubscription?: Subscription;
|
||||
|
||||
@Input() showViewAll = true;
|
||||
@@ -655,11 +659,12 @@ export class BlockchainExplorerMiniComponent implements OnInit, OnDestroy {
|
||||
{ limit: 5 }
|
||||
).toPromise();
|
||||
|
||||
if (blocksResponse?.data) {
|
||||
if (blocksResponse?.data && blocksResponse.data.length > 0) {
|
||||
this.blocks.set(blocksResponse.data);
|
||||
if (blocksResponse.data.length > 0) {
|
||||
this.latestBlock.set(blocksResponse.data[0].blockNumber);
|
||||
}
|
||||
this.latestBlock.set(blocksResponse.data[0].blockNumber);
|
||||
} else {
|
||||
// No real blocks - use mock data for demo
|
||||
this.loadMockBlocks();
|
||||
}
|
||||
|
||||
// Fetch transactions
|
||||
@@ -687,18 +692,24 @@ export class BlockchainExplorerMiniComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
private loadMockData(): void {
|
||||
// Mock blocks
|
||||
private loadMockBlocks(): void {
|
||||
const mockBlocks: BlockDto[] = Array.from({ length: 5 }, (_, i) => ({
|
||||
blockNumber: 12345 - i,
|
||||
blockNumber: 1628260 - i,
|
||||
hash: `0x${this.generateRandomHash()}`,
|
||||
parentHash: `0x${this.generateRandomHash()}`,
|
||||
timestamp: new Date(Date.now() - i * 15000).toISOString(),
|
||||
transactionCount: Math.floor(Math.random() * 20) + 1,
|
||||
gasUsed: Math.floor(Math.random() * 8000000),
|
||||
transactionCount: Math.floor(Math.random() * 5) + 1,
|
||||
gasUsed: Math.floor(Math.random() * 500000) + 100000,
|
||||
gasLimit: 15000000,
|
||||
size: Math.floor(Math.random() * 50000) + 10000,
|
||||
}));
|
||||
this.blocks.set(mockBlocks);
|
||||
this.latestBlock.set(mockBlocks[0].blockNumber);
|
||||
}
|
||||
|
||||
private loadMockData(): void {
|
||||
// Mock blocks
|
||||
this.loadMockBlocks();
|
||||
|
||||
const mockTx: BlockchainTransactionDto[] = [
|
||||
{
|
||||
@@ -746,9 +757,7 @@ export class BlockchainExplorerMiniComponent implements OnInit, OnDestroy {
|
||||
},
|
||||
];
|
||||
|
||||
this.blocks.set(mockBlocks);
|
||||
this.transactions.set(mockTx);
|
||||
this.latestBlock.set(mockBlocks[0].blockNumber);
|
||||
this.totalTransactions.set(1234);
|
||||
this.pendingTransactions.set(3);
|
||||
this.networkStatus.set('HEALTHY');
|
||||
@@ -761,11 +770,12 @@ export class BlockchainExplorerMiniComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
truncateHash(hash: string): string {
|
||||
if (!hash || hash.length <= 18) return hash;
|
||||
if (!hash || hash.length <= 18) return hash || '';
|
||||
return `${hash.substring(0, 10)}...${hash.substring(hash.length - 6)}`;
|
||||
}
|
||||
|
||||
getRelativeTime(timestamp: string): string {
|
||||
if (!timestamp) return '';
|
||||
const now = new Date();
|
||||
const time = new Date(timestamp);
|
||||
const diffSeconds = Math.floor((now.getTime() - time.getTime()) / 1000);
|
||||
@@ -785,6 +795,7 @@ export class BlockchainExplorerMiniComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
getTxTypeIcon(type: string): string {
|
||||
if (!type) return 'receipt_long';
|
||||
const icons: Record<string, string> = {
|
||||
LICENSE_MINT: 'verified',
|
||||
DOCUMENT_HASH: 'fingerprint',
|
||||
@@ -796,6 +807,7 @@ export class BlockchainExplorerMiniComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
formatTxType(type: string): string {
|
||||
if (!type) return 'Unknown';
|
||||
return type.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
|
||||
}
|
||||
|
||||
@@ -805,12 +817,20 @@ export class BlockchainExplorerMiniComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
viewBlock(block: BlockDto): void {
|
||||
// Could open a dialog or navigate
|
||||
console.log('View block:', block);
|
||||
this.dialog.open(BlockDetailDialogComponent, {
|
||||
data: block,
|
||||
width: '600px',
|
||||
maxHeight: '90vh',
|
||||
panelClass: 'blockchain-detail-dialog',
|
||||
});
|
||||
}
|
||||
|
||||
viewTransaction(tx: BlockchainTransactionDto): void {
|
||||
// Could open a dialog or navigate
|
||||
console.log('View transaction:', tx);
|
||||
this.dialog.open(TransactionDetailDialogComponent, {
|
||||
data: tx,
|
||||
width: '600px',
|
||||
maxHeight: '90vh',
|
||||
panelClass: 'blockchain-detail-dialog',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatDialogModule, MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatDividerModule } from '@angular/material/divider';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { Clipboard } from '@angular/cdk/clipboard';
|
||||
import { BlockchainTransactionDto } from '../../../api/models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-transaction-detail-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatDialogModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatDividerModule,
|
||||
MatChipsModule,
|
||||
],
|
||||
template: `
|
||||
<div class="dialog-container">
|
||||
<div class="dialog-header" [class]="'status-' + data.status.toLowerCase()">
|
||||
<div class="header-icon">
|
||||
<mat-icon>{{ getStatusIcon() }}</mat-icon>
|
||||
</div>
|
||||
<div class="header-text">
|
||||
<h2>Transaction Details</h2>
|
||||
<p class="subtitle">{{ formatTxType(data.type) }}</p>
|
||||
</div>
|
||||
<button mat-icon-button (click)="close()" class="close-btn">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<mat-divider></mat-divider>
|
||||
|
||||
<div class="dialog-content">
|
||||
<div class="status-banner" [class]="data.status.toLowerCase()">
|
||||
<mat-icon>{{ getStatusIcon() }}</mat-icon>
|
||||
<span>{{ data.status }}</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-grid">
|
||||
<div class="detail-item full-width">
|
||||
<div class="detail-label">Transaction Hash</div>
|
||||
<div class="detail-value hash" (click)="copyToClipboard(data.txHash)">
|
||||
<code>{{ data.txHash }}</code>
|
||||
<mat-icon class="copy-icon">content_copy</mat-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">Type</div>
|
||||
<div class="detail-value">
|
||||
<mat-icon class="type-icon">{{ getTxTypeIcon() }}</mat-icon>
|
||||
{{ formatTxType(data.type) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">Status</div>
|
||||
<div class="detail-value">
|
||||
<mat-chip [class]="data.status.toLowerCase()">
|
||||
{{ data.status }}
|
||||
</mat-chip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (data.blockNumber) {
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">Block Number</div>
|
||||
<div class="detail-value highlight">{{ data.blockNumber | number }}</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">Timestamp</div>
|
||||
<div class="detail-value">{{ data.timestamp | date:'medium' }}</div>
|
||||
</div>
|
||||
|
||||
@if (data.gasUsed) {
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">Gas Used</div>
|
||||
<div class="detail-value">{{ data.gasUsed | number }}</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (data.data && hasDataKeys()) {
|
||||
<div class="detail-item full-width">
|
||||
<div class="detail-label">Additional Data</div>
|
||||
<div class="detail-value">
|
||||
<pre class="data-json">{{ data.data | json }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<mat-divider></mat-divider>
|
||||
|
||||
<div class="dialog-actions">
|
||||
<button mat-button (click)="close()">Close</button>
|
||||
<button mat-flat-button color="primary" (click)="copyAllDetails()">
|
||||
<mat-icon>content_copy</mat-icon>
|
||||
Copy Details
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.dialog-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px 24px;
|
||||
color: white;
|
||||
|
||||
&.status-confirmed {
|
||||
background: linear-gradient(135deg, #198754 0%, #28a745 100%);
|
||||
}
|
||||
|
||||
&.status-pending {
|
||||
background: linear-gradient(135deg, #1D0A69 0%, #2563EB 100%);
|
||||
}
|
||||
|
||||
&.status-failed {
|
||||
background: linear-gradient(135deg, #DC3545 0%, #e74c3c 100%);
|
||||
}
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
mat-icon {
|
||||
font-size: 28px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
.header-text {
|
||||
flex: 1;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
padding: 24px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.status-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 24px;
|
||||
font-weight: 600;
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
&.confirmed {
|
||||
background: rgba(25, 135, 84, 0.1);
|
||||
color: #198754;
|
||||
}
|
||||
|
||||
&.pending {
|
||||
background: rgba(37, 99, 235, 0.1);
|
||||
color: #2563EB;
|
||||
}
|
||||
|
||||
&.failed {
|
||||
background: rgba(220, 53, 69, 0.1);
|
||||
color: #DC3545;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
&.full-width {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-size: 0.95rem;
|
||||
color: #1f2937;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
&.highlight {
|
||||
color: #2563EB;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&.hash {
|
||||
cursor: pointer;
|
||||
padding: 8px 12px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #eee;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
font-size: 0.8rem;
|
||||
word-break: break-all;
|
||||
flex: 1;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.copy-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: #6b7280;
|
||||
}
|
||||
}
|
||||
|
||||
&.link {
|
||||
color: #2563EB;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.type-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: #1D0A69;
|
||||
}
|
||||
|
||||
mat-chip {
|
||||
font-size: 0.75rem;
|
||||
|
||||
&.confirmed {
|
||||
background: rgba(25, 135, 84, 0.1) !important;
|
||||
color: #198754 !important;
|
||||
}
|
||||
|
||||
&.pending {
|
||||
background: rgba(37, 99, 235, 0.1) !important;
|
||||
color: #2563EB !important;
|
||||
}
|
||||
|
||||
&.failed {
|
||||
background: rgba(220, 53, 69, 0.1) !important;
|
||||
color: #DC3545 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding: 16px 24px;
|
||||
background: #fafafa;
|
||||
|
||||
button mat-icon {
|
||||
margin-right: 4px;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.data-json {
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
font-size: 0.8rem;
|
||||
background: #f5f5f5;
|
||||
color: #1f2937;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class TransactionDetailDialogComponent {
|
||||
readonly data = inject<BlockchainTransactionDto>(MAT_DIALOG_DATA);
|
||||
private readonly dialogRef = inject(MatDialogRef<TransactionDetailDialogComponent>);
|
||||
private readonly clipboard = inject(Clipboard);
|
||||
|
||||
close(): void {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
copyToClipboard(text: string): void {
|
||||
this.clipboard.copy(text);
|
||||
}
|
||||
|
||||
copyAllDetails(): void {
|
||||
const details = `Transaction Hash: ${this.data.txHash}
|
||||
Type: ${this.formatTxType(this.data.type)}
|
||||
Status: ${this.data.status}
|
||||
Block Number: ${this.data.blockNumber || 'Pending'}
|
||||
Timestamp: ${this.data.timestamp}
|
||||
${this.data.gasUsed ? `Gas Used: ${this.data.gasUsed}` : ''}`;
|
||||
|
||||
this.clipboard.copy(details);
|
||||
}
|
||||
|
||||
hasDataKeys(): boolean {
|
||||
return this.data.data && Object.keys(this.data.data).length > 0;
|
||||
}
|
||||
|
||||
getStatusIcon(): string {
|
||||
switch (this.data.status) {
|
||||
case 'CONFIRMED': return 'check_circle';
|
||||
case 'PENDING': return 'schedule';
|
||||
case 'FAILED': return 'error';
|
||||
default: return 'receipt_long';
|
||||
}
|
||||
}
|
||||
|
||||
getTxTypeIcon(): string {
|
||||
const icons: Record<string, string> = {
|
||||
LICENSE_MINT: 'verified',
|
||||
DOCUMENT_HASH: 'fingerprint',
|
||||
APPROVAL_RECORD: 'approval',
|
||||
LICENSE_TRANSFER: 'swap_horiz',
|
||||
REVOCATION: 'block',
|
||||
};
|
||||
return icons[this.data.type] || 'receipt_long';
|
||||
}
|
||||
|
||||
formatTxType(type: string): string {
|
||||
if (!type) return 'Blockchain Transaction';
|
||||
const typeMap: Record<string, string> = {
|
||||
'LICENSE_MINT': 'License Minting',
|
||||
'DOCUMENT_HASH': 'Document Hash',
|
||||
'APPROVAL_RECORD': 'Approval Record',
|
||||
'LICENSE_TRANSFER': 'License Transfer',
|
||||
'REVOCATION': 'License Revocation',
|
||||
'TRANSACTION': 'Blockchain Transaction',
|
||||
};
|
||||
return typeMap[type] || type.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, Input, OnInit } from '@angular/core';
|
||||
import { Component, Input, OnInit, OnDestroy, OnChanges, SimpleChanges, AfterViewInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
@@ -9,6 +9,10 @@ import { MatExpansionModule } from '@angular/material/expansion';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatDialogModule, MatDialog } from '@angular/material/dialog';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { Subject } from 'rxjs';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import { StorageService } from '../../../core/services/storage.service';
|
||||
import { RuntimeConfigService } from '../../../core/services/runtime-config.service';
|
||||
|
||||
interface DocumentVersion {
|
||||
id: string;
|
||||
@@ -37,6 +41,10 @@ interface Document {
|
||||
ipfsHash?: string;
|
||||
url: string;
|
||||
thumbnailUrl?: string;
|
||||
previewDataUrl?: string; // Generated preview data URL
|
||||
previewLoading?: boolean;
|
||||
previewError?: boolean;
|
||||
mimeType?: string;
|
||||
uploadedAt: string;
|
||||
uploadedBy: string;
|
||||
currentVersion: number;
|
||||
@@ -69,13 +77,27 @@ interface Document {
|
||||
<div class="document-viewer">
|
||||
<div class="documents-grid" *ngIf="documents && documents.length > 0">
|
||||
<mat-card *ngFor="let doc of documents" class="document-card">
|
||||
<!-- Document Thumbnail/Icon -->
|
||||
<div class="document-thumbnail" [class.has-thumbnail]="doc.thumbnailUrl" (click)="previewDocument(doc)">
|
||||
<img *ngIf="doc.thumbnailUrl" [src]="doc.thumbnailUrl" [alt]="doc.name" />
|
||||
<div *ngIf="!doc.thumbnailUrl" class="document-icon">
|
||||
<!-- Document Thumbnail/Preview -->
|
||||
<div class="document-thumbnail"
|
||||
[class.has-thumbnail]="doc.previewDataUrl || doc.thumbnailUrl"
|
||||
[class.is-loading]="doc.previewLoading"
|
||||
(click)="previewDocument(doc)">
|
||||
<!-- Loading spinner -->
|
||||
<div *ngIf="doc.previewLoading" class="preview-loading">
|
||||
<mat-spinner diameter="40"></mat-spinner>
|
||||
<span>Loading preview...</span>
|
||||
</div>
|
||||
<!-- Actual preview image -->
|
||||
<img *ngIf="(doc.previewDataUrl || doc.thumbnailUrl) && !doc.previewLoading"
|
||||
[src]="doc.previewDataUrl || doc.thumbnailUrl"
|
||||
[alt]="doc.name"
|
||||
class="preview-image" />
|
||||
<!-- Fallback icon when no preview available -->
|
||||
<div *ngIf="!doc.previewDataUrl && !doc.thumbnailUrl && !doc.previewLoading" class="document-icon">
|
||||
<mat-icon>{{ getFileIcon(doc.type) }}</mat-icon>
|
||||
<span class="file-extension">{{ getFileExtension(doc.name) }}</span>
|
||||
</div>
|
||||
<!-- Hover overlay -->
|
||||
<div class="preview-overlay">
|
||||
<mat-icon>visibility</mat-icon>
|
||||
<span>Preview</span>
|
||||
@@ -252,22 +274,41 @@ interface Document {
|
||||
.document-thumbnail {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 180px;
|
||||
height: 200px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
border-radius: 8px 8px 0 0;
|
||||
|
||||
&.has-thumbnail {
|
||||
background: #f5f5f5;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
img {
|
||||
&.is-loading {
|
||||
background: #e9ecef;
|
||||
}
|
||||
|
||||
.preview-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: #666;
|
||||
|
||||
span {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-fit: contain;
|
||||
background: white;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.document-icon {
|
||||
@@ -502,17 +543,194 @@ interface Document {
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class DocumentViewerComponent implements OnInit {
|
||||
export class DocumentViewerComponent implements OnInit, OnDestroy, AfterViewInit, OnChanges {
|
||||
@Input() documents: Document[] = [];
|
||||
@Input() showVersionHistory = true;
|
||||
@Input() showDepartmentReviews = true;
|
||||
@Input() apiBaseUrl?: string;
|
||||
|
||||
versionColumns = ['version', 'uploadedAt', 'uploadedBy', 'fileHash', 'actions'];
|
||||
|
||||
constructor(private dialog: MatDialog) {}
|
||||
private destroy$ = new Subject<void>();
|
||||
private storage = inject(StorageService);
|
||||
private configService = inject(RuntimeConfigService);
|
||||
|
||||
/**
|
||||
* Get effective API base URL (input override or runtime config)
|
||||
*/
|
||||
private get effectiveApiBaseUrl(): string {
|
||||
return this.apiBaseUrl || this.configService.apiBaseUrl;
|
||||
}
|
||||
|
||||
constructor(private dialog: MatDialog) {
|
||||
// Configure PDF.js worker - use local asset
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = '/assets/pdf.worker.min.js';
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
// Initialize component
|
||||
// Load previews after documents are set
|
||||
this.loadDocumentPreviews();
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
// Ensure previews are loaded after view is ready
|
||||
setTimeout(() => this.loadDocumentPreviews(), 100);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
private loadDocumentPreviews(): void {
|
||||
if (!this.documents?.length) return;
|
||||
|
||||
this.documents.forEach(doc => {
|
||||
if (!doc.previewDataUrl && !doc.previewLoading) {
|
||||
this.generatePreview(doc);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private generatePreview(doc: Document): void {
|
||||
const mimeType = doc.mimeType || doc.metadata?.mimeType || this.getMimeTypeFromFilename(doc.name);
|
||||
|
||||
if (this.isImage(mimeType)) {
|
||||
this.loadImagePreview(doc);
|
||||
} else if (this.isPdf(mimeType)) {
|
||||
this.loadPdfPreview(doc);
|
||||
}
|
||||
// Other file types will show the default icon
|
||||
}
|
||||
|
||||
private getMimeTypeFromFilename(filename: string): string {
|
||||
const ext = filename?.split('.').pop()?.toLowerCase() || '';
|
||||
const mimeMap: { [key: string]: string } = {
|
||||
'pdf': 'application/pdf',
|
||||
'jpg': 'image/jpeg',
|
||||
'jpeg': 'image/jpeg',
|
||||
'png': 'image/png',
|
||||
'gif': 'image/gif',
|
||||
'webp': 'image/webp',
|
||||
'bmp': 'image/bmp',
|
||||
'svg': 'image/svg+xml',
|
||||
};
|
||||
return mimeMap[ext] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
private isImage(mimeType: string): boolean {
|
||||
return mimeType?.startsWith('image/') || false;
|
||||
}
|
||||
|
||||
private isPdf(mimeType: string): boolean {
|
||||
return mimeType === 'application/pdf';
|
||||
}
|
||||
|
||||
private async loadImagePreview(doc: Document): Promise<void> {
|
||||
doc.previewLoading = true;
|
||||
const downloadUrl = `${this.effectiveApiBaseUrl}/documents/${doc.id}/download?inline=true`;
|
||||
|
||||
try {
|
||||
// Get authorization token
|
||||
const token = this.storage.getToken();
|
||||
const headers: HeadersInit = {};
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
// Use fetch with authorization header
|
||||
const response = await fetch(downloadUrl, {
|
||||
credentials: 'include',
|
||||
headers
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
|
||||
// Convert blob to data URL
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
doc.previewDataUrl = reader.result as string;
|
||||
doc.previewLoading = false;
|
||||
};
|
||||
reader.onerror = () => {
|
||||
console.warn('Failed to read image blob:', reader.error);
|
||||
doc.previewLoading = false;
|
||||
doc.previewError = true;
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
} catch (err) {
|
||||
console.warn('Image preview failed, using fallback icon:', err);
|
||||
doc.previewLoading = false;
|
||||
doc.previewError = true;
|
||||
}
|
||||
}
|
||||
|
||||
private async loadPdfPreview(doc: Document): Promise<void> {
|
||||
doc.previewLoading = true;
|
||||
const downloadUrl = `${this.effectiveApiBaseUrl}/documents/${doc.id}/download`;
|
||||
|
||||
try {
|
||||
// Get authorization token
|
||||
const token = this.storage.getToken();
|
||||
const headers: HeadersInit = {};
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
// Fetch the PDF as an array buffer
|
||||
const response = await fetch(downloadUrl, {
|
||||
credentials: 'include',
|
||||
headers
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
|
||||
// Load PDF and render first page
|
||||
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
|
||||
const page = await pdf.getPage(1);
|
||||
|
||||
// Create a canvas for the thumbnail - scale to fit 320x200
|
||||
const originalViewport = page.getViewport({ scale: 1 });
|
||||
const scale = Math.min(320 / originalViewport.width, 200 / originalViewport.height);
|
||||
const viewport = page.getViewport({ scale });
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
throw new Error('Could not get canvas context');
|
||||
}
|
||||
|
||||
// Fill white background
|
||||
ctx.fillStyle = 'white';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Render the page
|
||||
await page.render({
|
||||
canvasContext: ctx,
|
||||
viewport
|
||||
}).promise;
|
||||
|
||||
// Convert to data URL
|
||||
doc.previewDataUrl = canvas.toDataURL('image/png');
|
||||
doc.previewLoading = false;
|
||||
} catch (err) {
|
||||
// If PDF.js fails, still mark as done but use fallback icon
|
||||
console.warn('PDF preview failed, using fallback icon:', err);
|
||||
doc.previewLoading = false;
|
||||
doc.previewError = true;
|
||||
// Don't set previewDataUrl - will show default icon
|
||||
}
|
||||
}
|
||||
|
||||
getFileIcon(type: string): string {
|
||||
@@ -529,7 +747,8 @@ export class DocumentViewerComponent implements OnInit {
|
||||
return iconMap[type] || 'insert_drive_file';
|
||||
}
|
||||
|
||||
getFileExtension(filename: string): string {
|
||||
getFileExtension(filename: string | undefined | null): string {
|
||||
if (!filename) return 'FILE';
|
||||
const parts = filename.split('.');
|
||||
return parts.length > 1 ? parts[parts.length - 1].toUpperCase() : 'FILE';
|
||||
}
|
||||
@@ -558,24 +777,37 @@ export class DocumentViewerComponent implements OnInit {
|
||||
}
|
||||
|
||||
downloadDocument(doc: Document): void {
|
||||
// Create a temporary link and trigger download
|
||||
const link = document.createElement('a');
|
||||
link.href = doc.url;
|
||||
link.download = doc.name;
|
||||
link.click();
|
||||
const downloadUrl = `${this.effectiveApiBaseUrl}/documents/${doc.id}/download`;
|
||||
// Open in new tab to trigger download
|
||||
window.open(downloadUrl, '_blank');
|
||||
}
|
||||
|
||||
downloadVersion(doc: Document, version: DocumentVersion): void {
|
||||
alert(`Downloading version ${version.version} of ${doc.name}`);
|
||||
// In real implementation, fetch version-specific URL and download
|
||||
const downloadUrl = `${this.effectiveApiBaseUrl}/documents/${doc.id}/download?version=${version.version}`;
|
||||
window.open(downloadUrl, '_blank');
|
||||
}
|
||||
|
||||
previewDocument(doc: Document): void {
|
||||
// Open preview dialog or new window
|
||||
window.open(doc.url, '_blank');
|
||||
const mimeType = doc.mimeType || doc.metadata?.mimeType || this.getMimeTypeFromFilename(doc.name);
|
||||
const previewUrl = `${this.effectiveApiBaseUrl}/documents/${doc.id}/download?inline=true`;
|
||||
|
||||
if (this.isImage(mimeType) || this.isPdf(mimeType)) {
|
||||
// Open in new window with inline display
|
||||
window.open(previewUrl, '_blank', 'width=900,height=700,scrollbars=yes');
|
||||
} else {
|
||||
// For other types, trigger download
|
||||
this.downloadDocument(doc);
|
||||
}
|
||||
}
|
||||
|
||||
viewVersionHistory(doc: Document): void {
|
||||
alert(`Version History for ${doc.name}\n\nTotal versions: ${doc.versions?.length}`);
|
||||
}
|
||||
|
||||
// Called when documents input changes
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['documents'] && !changes['documents'].firstChange) {
|
||||
this.loadDocumentPreviews();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,30 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
|
||||
@Component({
|
||||
selector: 'app-page-header',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
imports: [CommonModule, MatIconModule],
|
||||
template: `
|
||||
<div class="page-header">
|
||||
<header class="page-header" role="banner">
|
||||
<div class="page-header-content">
|
||||
<h1 class="page-title">{{ title }}</h1>
|
||||
@if (subtitle) {
|
||||
<p class="page-subtitle">{{ subtitle }}</p>
|
||||
@if (icon) {
|
||||
<div class="page-header-icon">
|
||||
<mat-icon>{{ icon }}</mat-icon>
|
||||
</div>
|
||||
}
|
||||
<div class="page-header-text">
|
||||
<h1 class="page-title" [id]="titleId">{{ title }}</h1>
|
||||
@if (subtitle) {
|
||||
<p class="page-subtitle" [attr.aria-describedby]="titleId">{{ subtitle }}</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<div class="page-actions" role="group" aria-label="Page actions">
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
@@ -30,21 +38,44 @@ import { CommonModule } from '@angular/common';
|
||||
}
|
||||
|
||||
.page-header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.page-header-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #1D0A69 0%, #2563EB 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
mat-icon {
|
||||
color: white;
|
||||
font-size: 24px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 500;
|
||||
color: rgba(0, 0, 0, 0.87);
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--dbim-brown, #150202);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin: 4px 0 0;
|
||||
margin: 8px 0 0;
|
||||
font-size: 0.875rem;
|
||||
color: rgba(0, 0, 0, 0.54);
|
||||
color: var(--dbim-grey-2, #8E8E8E);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.page-actions {
|
||||
@@ -58,6 +89,10 @@ import { CommonModule } from '@angular/common';
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
@@ -65,4 +100,8 @@ import { CommonModule } from '@angular/common';
|
||||
export class PageHeaderComponent {
|
||||
@Input({ required: true }) title!: string;
|
||||
@Input() subtitle?: string;
|
||||
@Input() icon?: string;
|
||||
|
||||
// Generate unique ID for accessibility
|
||||
readonly titleId = `page-title-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
@@ -15,12 +15,15 @@ import { CommonModule } from '@angular/common';
|
||||
span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 12px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 16px;
|
||||
font-size: 0.75rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
max-width: 160px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.status-draft {
|
||||
|
||||
3
frontend/src/assets/config.json
Normal file
3
frontend/src/assets/config.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"apiBaseUrl": "http://localhost:3001/api/v1"
|
||||
}
|
||||
21
frontend/src/assets/pdf.worker.min.js
vendored
Normal file
21
frontend/src/assets/pdf.worker.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -1,12 +1,21 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="en" dir="ltr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Goa GEL - Blockchain e-Licensing Platform</title>
|
||||
<title>Goa GEL - Government e-Licensing Platform | Government of Goa</title>
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="description" content="Government of Goa Blockchain-based Document Verification and e-Licensing Platform">
|
||||
<meta name="description" content="Official Government of Goa Blockchain-based Document Verification and e-Licensing Platform. Apply for licenses, track applications, and verify documents securely.">
|
||||
<meta name="keywords" content="Government of Goa, e-Licensing, Blockchain, License, Permit, Government Services, Digital India">
|
||||
<meta name="author" content="Government of Goa">
|
||||
<meta name="robots" content="index, follow">
|
||||
<meta name="theme-color" content="#1D0A69">
|
||||
|
||||
<!-- GIGW 3.0 Accessibility Meta -->
|
||||
<meta name="accessibility" content="WCAG 2.1 Level AA">
|
||||
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||
<link rel="canonical" href="https://gel.goa.gov.in">
|
||||
|
||||
<!-- Google Fonts: Noto Sans (DBIM Mandatory) + Material Icons -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
@@ -15,6 +24,13 @@
|
||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Noscript fallback for accessibility -->
|
||||
<noscript>
|
||||
<div style="padding: 20px; text-align: center; background: #FFC107; color: #150202;">
|
||||
JavaScript is required to use the Government of Goa e-Licensing Platform.
|
||||
Please enable JavaScript in your browser settings.
|
||||
</div>
|
||||
</noscript>
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -101,34 +101,40 @@ $goa-gel-theme: mat.m2-define-light-theme(
|
||||
|
||||
// =============================================================================
|
||||
// CSS VARIABLES - DBIM Colour Tokens
|
||||
// India Government Digital Brand Identity Manual (DBIM) Compliant
|
||||
// =============================================================================
|
||||
:root {
|
||||
// Primary colours (Blue group)
|
||||
--dbim-blue-dark: #1D0A69;
|
||||
// DBIM Official India Government Blue (Primary)
|
||||
--dbim-govt-blue: #0066B3; // Official India Government Blue
|
||||
--dbim-govt-blue-dark: #004B8D; // Darker variant for hover states
|
||||
--dbim-govt-blue-light: #1A7FC1; // Lighter variant
|
||||
|
||||
// Primary colours (Blue group - for Goa state customization)
|
||||
--dbim-blue-dark: #1D0A69; // Goa state accent
|
||||
--dbim-blue-mid: #2563EB;
|
||||
--dbim-blue-light: #3B82F6;
|
||||
--dbim-blue-lighter: #60A5FA;
|
||||
--dbim-blue-subtle: #DBEAFE;
|
||||
|
||||
// Functional colours
|
||||
--dbim-white: #FFFFFF;
|
||||
--dbim-linen: #EBEAEA;
|
||||
--dbim-brown: #150202;
|
||||
--dbim-black: #000000;
|
||||
--dbim-deep-blue: #1D0A69;
|
||||
// Functional colours (DBIM Mandatory)
|
||||
--dbim-white: #FFFFFF; // Inclusive White - page backgrounds
|
||||
--dbim-linen: #EBEAEA; // Background secondary - cards, quotes
|
||||
--dbim-brown: #150202; // Deep Earthy Brown - text on light bg
|
||||
--dbim-black: #000000; // State Emblem on light bg
|
||||
--dbim-deep-blue: #1D0A69; // Gov.In identity colour
|
||||
|
||||
// Status colours
|
||||
--dbim-success: #198754;
|
||||
--dbim-warning: #FFC107;
|
||||
--dbim-error: #DC3545;
|
||||
--dbim-info: #0D6EFD;
|
||||
// Status colours (DBIM Fixed)
|
||||
--dbim-success: #198754; // Liberty Green - approved, confirmed
|
||||
--dbim-warning: #FFC107; // Mustard Yellow - pending, in-review
|
||||
--dbim-error: #DC3545; // Coral Red - rejected, failed
|
||||
--dbim-info: #0D6EFD; // Blue - information, hyperlinks
|
||||
|
||||
// Grey palette
|
||||
// Grey palette (DBIM Compliant)
|
||||
--dbim-grey-1: #C6C6C6;
|
||||
--dbim-grey-2: #8E8E8E;
|
||||
--dbim-grey-3: #606060;
|
||||
|
||||
// Crypto accents
|
||||
// Crypto accents (within DBIM compliance)
|
||||
--crypto-purple: #8B5CF6;
|
||||
--crypto-indigo: #6366F1;
|
||||
--crypto-cyan: #06B6D4;
|
||||
@@ -143,6 +149,11 @@ $goa-gel-theme: mat.m2-define-light-theme(
|
||||
--transition-fast: 150ms ease;
|
||||
--transition-normal: 250ms ease;
|
||||
--transition-slow: 350ms ease;
|
||||
|
||||
// Focus ring (WCAG AA compliant - 3:1 contrast)
|
||||
--focus-ring-color: #0066B3;
|
||||
--focus-ring-width: 3px;
|
||||
--focus-ring-offset: 2px;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -936,8 +947,10 @@ a {
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ACCESSIBILITY
|
||||
// ACCESSIBILITY - GIGW 3.0 & WCAG 2.1 AA Compliant
|
||||
// =============================================================================
|
||||
|
||||
// Screen reader only content
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
@@ -950,23 +963,110 @@ a {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.skip-link {
|
||||
// Visually hidden but focusable (for skip links)
|
||||
.visually-hidden-focusable {
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
left: 0;
|
||||
background: var(--dbim-blue-dark);
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
z-index: 100;
|
||||
transition: top var(--transition-fast);
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
|
||||
&:focus {
|
||||
top: 0;
|
||||
&:focus,
|
||||
&:active {
|
||||
position: static;
|
||||
width: auto;
|
||||
height: auto;
|
||||
padding: 8px 16px;
|
||||
margin: 0;
|
||||
overflow: visible;
|
||||
clip: auto;
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
// Focus styles for keyboard navigation
|
||||
*:focus-visible {
|
||||
outline: 2px solid var(--dbim-info);
|
||||
outline-offset: 2px;
|
||||
// Skip to main content link - GIGW 3.0 Required
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
top: -50px;
|
||||
left: 8px;
|
||||
background: var(--dbim-govt-blue);
|
||||
color: white;
|
||||
padding: 12px 24px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
border-radius: 0 0 8px 8px;
|
||||
z-index: 10000;
|
||||
transition: top var(--transition-fast);
|
||||
box-shadow: var(--shadow-elevated);
|
||||
|
||||
&:focus {
|
||||
top: 0;
|
||||
outline: 3px solid var(--dbim-warning);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
// Focus styles for keyboard navigation - WCAG 2.1 AA (3:1 contrast ratio)
|
||||
*:focus-visible {
|
||||
outline: var(--focus-ring-width) solid var(--focus-ring-color);
|
||||
outline-offset: var(--focus-ring-offset);
|
||||
}
|
||||
|
||||
// Enhanced focus for interactive elements
|
||||
a:focus-visible,
|
||||
button:focus-visible,
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
textarea:focus-visible,
|
||||
[tabindex]:focus-visible {
|
||||
outline: var(--focus-ring-width) solid var(--focus-ring-color);
|
||||
outline-offset: var(--focus-ring-offset);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
// High contrast focus for buttons
|
||||
button:focus-visible,
|
||||
.mat-mdc-button:focus-visible,
|
||||
.mat-mdc-raised-button:focus-visible,
|
||||
.mat-mdc-outlined-button:focus-visible {
|
||||
outline: var(--focus-ring-width) solid var(--focus-ring-color);
|
||||
outline-offset: var(--focus-ring-offset);
|
||||
box-shadow: 0 0 0 4px rgba(0, 102, 179, 0.25);
|
||||
}
|
||||
|
||||
// Remove default focus outline when using focus-visible
|
||||
*:focus:not(:focus-visible) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
// Reduced motion preference support - WCAG 2.1
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
// High contrast mode support
|
||||
@media (prefers-contrast: high) {
|
||||
:root {
|
||||
--dbim-govt-blue: #0052CC;
|
||||
--dbim-success: #006644;
|
||||
--dbim-error: #CC0000;
|
||||
--dbim-warning: #CC8800;
|
||||
--focus-ring-width: 4px;
|
||||
}
|
||||
|
||||
*:focus-visible {
|
||||
outline-width: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,29 +6,34 @@ module.exports = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// DBIM Primary Colour Group - Blue (selected for blockchain/tech platform)
|
||||
// DBIM Primary Colour Group - India Government Official Colours
|
||||
'dbim': {
|
||||
// Blue colour group variants
|
||||
// Official India Government Blue (DBIM Primary)
|
||||
'govt-blue': '#0066B3', // Official India Government Blue
|
||||
'govt-blue-dark': '#004B8D', // Darker variant for hover
|
||||
'govt-blue-light': '#1A7FC1', // Lighter variant
|
||||
|
||||
// Blue colour group variants (State customization)
|
||||
'blue-dark': '#1D0A69', // Key colour - footer, primary headers, sidebar
|
||||
'blue-mid': '#2563EB', // Primary buttons, active states
|
||||
'blue-light': '#3B82F6', // Hover states, links
|
||||
'blue-lighter': '#60A5FA', // Card accents
|
||||
'blue-subtle': '#DBEAFE', // Subtle backgrounds
|
||||
|
||||
// Functional palette (mandatory)
|
||||
// Functional palette (DBIM mandatory)
|
||||
'white': '#FFFFFF', // Inclusive White - page backgrounds
|
||||
'linen': '#EBEAEA', // Background secondary - cards, quotes
|
||||
'brown': '#150202', // Deep Earthy Brown - text on light bg
|
||||
'black': '#000000', // State Emblem on light bg
|
||||
'deep-blue': '#1D0A69', // Gov.In identity colour
|
||||
|
||||
// Status colours (fixed by DBIM)
|
||||
// Status colours (DBIM fixed)
|
||||
'success': '#198754', // Liberty Green - approved, confirmed
|
||||
'warning': '#FFC107', // Mustard Yellow - pending, in-review
|
||||
'error': '#DC3545', // Coral Red - rejected, failed
|
||||
'info': '#0D6EFD', // Blue - information, hyperlinks
|
||||
|
||||
// Grey palette
|
||||
// Grey palette (DBIM compliant)
|
||||
'grey-1': '#C6C6C6',
|
||||
'grey-2': '#8E8E8E',
|
||||
'grey-3': '#606060',
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
}
|
||||
Reference in New Issue
Block a user