feat: Goa GEL Blockchain e-Licensing Platform - Full Stack Implementation
Complete implementation of the Goa Government e-Licensing platform with: Backend: - NestJS API with JWT authentication - PostgreSQL database with Knex ORM - Redis caching and session management - MinIO document storage - Hyperledger Besu blockchain integration - Multi-department workflow system - Comprehensive API tests (266/282 passing) Frontend: - Angular 21 with standalone components - Angular Material + TailwindCSS UI - Visual workflow builder - Document upload with progress tracking - Blockchain explorer integration - Role-based dashboards (Admin, Department, Citizen) - E2E tests with Playwright (37 tests) Infrastructure: - Docker Compose orchestration - Blockscout blockchain explorer - Development and production configurations
This commit is contained in:
18
frontend/.dockerignore
Normal file
18
frontend/.dockerignore
Normal file
@@ -0,0 +1,18 @@
|
||||
node_modules
|
||||
dist
|
||||
.angular
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.DS_Store
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.vscode
|
||||
.idea
|
||||
coverage
|
||||
.nyc_output
|
||||
17
frontend/.editorconfig
Normal file
17
frontend/.editorconfig
Normal file
@@ -0,0 +1,17 @@
|
||||
# Editor configuration, see https://editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.ts]
|
||||
quote_type = single
|
||||
ij_typescript_use_double_quotes = false
|
||||
|
||||
[*.md]
|
||||
max_line_length = off
|
||||
trim_trailing_whitespace = false
|
||||
44
frontend/.gitignore
vendored
Normal file
44
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
|
||||
|
||||
# Compiled output
|
||||
/dist
|
||||
/tmp
|
||||
/out-tsc
|
||||
/bazel-out
|
||||
|
||||
# Node
|
||||
/node_modules
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
|
||||
# IDEs and editors
|
||||
.idea/
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# Visual Studio Code
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
!.vscode/mcp.json
|
||||
.history/*
|
||||
|
||||
# Miscellaneous
|
||||
/.angular/cache
|
||||
.sass-cache/
|
||||
/connect.lock
|
||||
/coverage
|
||||
/libpeerconnection.log
|
||||
testem.log
|
||||
/typings
|
||||
__screenshots__/
|
||||
|
||||
# System files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
3
frontend/CLAUDE.md
Normal file
3
frontend/CLAUDE.md
Normal file
@@ -0,0 +1,3 @@
|
||||
<claude-mem-context>
|
||||
|
||||
</claude-mem-context>
|
||||
35
frontend/Dockerfile
Normal file
35
frontend/Dockerfile
Normal file
@@ -0,0 +1,35 @@
|
||||
# Stage 1: Build Angular application
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci --legacy-peer-deps
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build Angular app for production
|
||||
RUN npm run build -- --configuration production
|
||||
|
||||
# Stage 2: Serve with Nginx
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy custom nginx configuration
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
# Copy built application from builder stage (from browser subdirectory)
|
||||
COPY --from=builder /app/dist/goa-gel-frontend/browser /usr/share/nginx/html
|
||||
|
||||
# Expose port 80
|
||||
EXPOSE 80
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget --quiet --tries=1 --spider http://localhost/ || exit 1
|
||||
|
||||
# Start nginx
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
125
frontend/README.md
Normal file
125
frontend/README.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# Goa GEL Frontend
|
||||
|
||||
Government of Goa - e-Licensing Blockchain Platform (Frontend)
|
||||
|
||||
Built with [Angular CLI](https://github.com/angular/angular-cli) version 21.1.2.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js v20+ (LTS recommended)
|
||||
- npm v10+
|
||||
- Backend API running on `http://localhost:3001`
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Start development server
|
||||
ng serve
|
||||
```
|
||||
|
||||
Open `http://localhost:4200/` in your browser.
|
||||
|
||||
## Environment Configuration
|
||||
|
||||
Environment files are located in `src/environments/`:
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `environment.ts` | Development configuration |
|
||||
| `environment.prod.ts` | Production configuration |
|
||||
|
||||
### Configuration Variables
|
||||
|
||||
```typescript
|
||||
{
|
||||
production: false, // Build mode flag
|
||||
apiBaseUrl: 'http://localhost:3001/api/v1', // Backend API endpoint
|
||||
tokenStorageKey: 'goa_gel_token', // JWT token storage key
|
||||
refreshTokenStorageKey: 'goa_gel_refresh_token', // Refresh token storage key
|
||||
userStorageKey: 'goa_gel_user', // User data storage key
|
||||
apiKeyStorageKey: 'goa_gel_api_key', // API key storage key
|
||||
apiSecretStorageKey: 'goa_gel_api_secret', // API secret storage key
|
||||
}
|
||||
```
|
||||
|
||||
### Modifying API URL
|
||||
|
||||
To connect to a different backend:
|
||||
|
||||
```typescript
|
||||
// src/environments/environment.ts
|
||||
apiBaseUrl: 'http://YOUR_BACKEND_HOST:PORT/api/v1'
|
||||
```
|
||||
|
||||
### Token Storage (Authentication)
|
||||
|
||||
The application uses localStorage for authentication tokens. These are **not values you configure** - they are automatically managed:
|
||||
|
||||
| Storage Key | Source | Description |
|
||||
|-------------|--------|-------------|
|
||||
| `goa_gel_token` | Backend `/auth/login` | JWT access token (set after login) |
|
||||
| `goa_gel_refresh_token` | Backend `/auth/login` | Refresh token (set after login) |
|
||||
| `goa_gel_user` | Backend `/auth/login` | User profile data (JSON) |
|
||||
|
||||
**How it works:**
|
||||
1. User logs in → Backend returns JWT tokens
|
||||
2. Frontend stores tokens in localStorage using these keys
|
||||
3. Tokens are sent with API requests via `Authorization` header
|
||||
4. On logout, tokens are cleared from localStorage
|
||||
|
||||
**To inspect stored tokens:**
|
||||
1. Open browser DevTools → Application → Local Storage
|
||||
2. Look for keys prefixed with `goa_gel_`
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
# Development build
|
||||
ng build
|
||||
|
||||
# Production build (uses environment.prod.ts)
|
||||
ng build --configuration=production
|
||||
```
|
||||
|
||||
Build artifacts are stored in `dist/goa-gel-frontend/`.
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Run unit tests
|
||||
ng test
|
||||
|
||||
# Run tests once (CI mode)
|
||||
ng test --watch=false
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/
|
||||
│ ├── api/ # API models and interfaces
|
||||
│ ├── core/ # Core services (auth, API, storage)
|
||||
│ ├── features/ # Feature modules (dashboard, requests, workflows)
|
||||
│ ├── layouts/ # Layout components (main layout)
|
||||
│ └── shared/ # Shared components and utilities
|
||||
├── environments/ # Environment configurations
|
||||
└── styles/ # Global styles
|
||||
```
|
||||
|
||||
## Key Features
|
||||
|
||||
- Visual Workflow Builder
|
||||
- License Request Management
|
||||
- Document Upload with Blockchain Verification
|
||||
- Department-based Approval Workflows
|
||||
- Admin Dashboard with Analytics
|
||||
- DBIM v3.0 & GIGW 3.0 Compliant UI
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [Angular CLI Documentation](https://angular.dev/tools/cli)
|
||||
- [Angular Material](https://material.angular.io/)
|
||||
84
frontend/angular.json
Normal file
84
frontend/angular.json
Normal file
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"cli": {
|
||||
"packageManager": "npm",
|
||||
"analytics": false
|
||||
},
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
"goa-gel-frontend": {
|
||||
"projectType": "application",
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"style": "scss"
|
||||
}
|
||||
},
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"prefix": "app",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular/build:application",
|
||||
"options": {
|
||||
"browser": "src/main.ts",
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"inlineStyleLanguage": "scss",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "public"
|
||||
},
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "src/assets",
|
||||
"output": "assets"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.scss"
|
||||
]
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "1MB",
|
||||
"maximumError": "2MB"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "16kB",
|
||||
"maximumError": "32kB"
|
||||
}
|
||||
],
|
||||
"outputHashing": "all"
|
||||
},
|
||||
"development": {
|
||||
"optimization": false,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular/build:dev-server",
|
||||
"configurations": {
|
||||
"production": {
|
||||
"buildTarget": "goa-gel-frontend:build:production"
|
||||
},
|
||||
"development": {
|
||||
"buildTarget": "goa-gel-frontend:build:development"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular/build:unit-test"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
163
frontend/e2e/auth.spec.ts
Normal file
163
frontend/e2e/auth.spec.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Authentication', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
test.describe('Login Page', () => {
|
||||
test('should display login page with email and password fields', async ({ page }) => {
|
||||
await page.goto('/auth/login');
|
||||
|
||||
await expect(page.getByText('Goa GEL Platform')).toBeVisible();
|
||||
await expect(page.getByLabel('Email')).toBeVisible();
|
||||
await expect(page.getByLabel('Password')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Sign In' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should display demo accounts section', async ({ page }) => {
|
||||
await page.goto('/auth/login');
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Demo Accounts' })).toBeVisible();
|
||||
await expect(page.getByText('Admin').first()).toBeVisible();
|
||||
await expect(page.getByText('Fire Department').first()).toBeVisible();
|
||||
await expect(page.getByText('Citizen').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('should auto-fill credentials when clicking demo account', async ({ page }) => {
|
||||
await page.goto('/auth/login');
|
||||
|
||||
// Click on the Admin demo card
|
||||
await page.click('text=Admin');
|
||||
|
||||
const emailInput = page.getByLabel('Email');
|
||||
const passwordInput = page.getByLabel('Password');
|
||||
|
||||
await expect(emailInput).toHaveValue('admin@goa.gov.in');
|
||||
await expect(passwordInput).toHaveValue('Admin@123');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Login Flow - Admin', () => {
|
||||
test('should login as admin and redirect to admin dashboard', async ({ page }) => {
|
||||
await page.goto('/auth/login');
|
||||
|
||||
// Fill credentials
|
||||
await page.getByLabel('Email').fill('admin@goa.gov.in');
|
||||
await page.getByLabel('Password').fill('Admin@123');
|
||||
|
||||
// Submit
|
||||
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||
|
||||
// Wait for navigation and verify redirect
|
||||
await page.waitForURL('**/admin**', { timeout: 10000 });
|
||||
|
||||
// Verify admin dashboard is shown
|
||||
await expect(page).toHaveURL(/.*admin.*/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Login Flow - Citizen', () => {
|
||||
test('should login as citizen and redirect to dashboard', async ({ page }) => {
|
||||
await page.goto('/auth/login');
|
||||
|
||||
// Fill credentials
|
||||
await page.getByLabel('Email').fill('citizen@example.com');
|
||||
await page.getByLabel('Password').fill('Citizen@123');
|
||||
|
||||
// Submit
|
||||
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||
|
||||
// Wait for navigation
|
||||
await page.waitForURL('**/dashboard**', { timeout: 10000 });
|
||||
|
||||
await expect(page).toHaveURL(/.*dashboard.*/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Login Flow - Department', () => {
|
||||
test('should login as fire department and redirect to dashboard', async ({ page }) => {
|
||||
await page.goto('/auth/login');
|
||||
|
||||
// Fill credentials
|
||||
await page.getByLabel('Email').fill('fire@goa.gov.in');
|
||||
await page.getByLabel('Password').fill('Fire@123');
|
||||
|
||||
// Submit
|
||||
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||
|
||||
// Wait for navigation
|
||||
await page.waitForURL('**/dashboard**', { timeout: 10000 });
|
||||
|
||||
await expect(page).toHaveURL(/.*dashboard.*/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Login Validation', () => {
|
||||
test('should show error for invalid credentials', async ({ page }) => {
|
||||
await page.goto('/auth/login');
|
||||
|
||||
// Fill invalid credentials
|
||||
await page.getByLabel('Email').fill('invalid@email.com');
|
||||
await page.getByLabel('Password').fill('wrongpassword');
|
||||
|
||||
// Submit
|
||||
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||
|
||||
// Wait for error message
|
||||
await expect(page.getByText(/invalid|error/i)).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test('should disable submit button when form is invalid', async ({ page }) => {
|
||||
await page.goto('/auth/login');
|
||||
|
||||
const submitButton = page.getByRole('button', { name: 'Sign In' });
|
||||
|
||||
// Initially disabled (empty fields)
|
||||
await expect(submitButton).toBeDisabled();
|
||||
|
||||
// Fill only email
|
||||
await page.getByLabel('Email').fill('test@email.com');
|
||||
await expect(submitButton).toBeDisabled();
|
||||
|
||||
// Fill password
|
||||
await page.getByLabel('Password').fill('password');
|
||||
await expect(submitButton).toBeEnabled();
|
||||
});
|
||||
|
||||
test('should show validation error for invalid email format', async ({ page }) => {
|
||||
await page.goto('/auth/login');
|
||||
|
||||
// Fill invalid email
|
||||
const emailInput = page.getByLabel('Email');
|
||||
await emailInput.fill('notanemail');
|
||||
await emailInput.blur();
|
||||
|
||||
await expect(page.getByText('Please enter a valid email')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Password Toggle', () => {
|
||||
test('should toggle password visibility', async ({ page }) => {
|
||||
await page.goto('/auth/login');
|
||||
|
||||
const passwordInput = page.getByLabel('Password');
|
||||
await passwordInput.fill('testpassword');
|
||||
|
||||
// Initially password is hidden
|
||||
await expect(passwordInput).toHaveAttribute('type', 'password');
|
||||
|
||||
// Click visibility toggle button
|
||||
await page.locator('button:has(mat-icon:has-text("visibility_off"))').click();
|
||||
|
||||
// Password should be visible
|
||||
await expect(passwordInput).toHaveAttribute('type', 'text');
|
||||
|
||||
// Click again to hide
|
||||
await page.locator('button:has(mat-icon:has-text("visibility"))').click();
|
||||
|
||||
// Password should be hidden again
|
||||
await expect(passwordInput).toHaveAttribute('type', 'password');
|
||||
});
|
||||
});
|
||||
});
|
||||
194
frontend/e2e/dashboard.spec.ts
Normal file
194
frontend/e2e/dashboard.spec.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
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('Password').fill('Citizen@123');
|
||||
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||
await page.waitForURL('**/dashboard**', { timeout: 10000 });
|
||||
}
|
||||
|
||||
async function loginAsAdmin(page: Page) {
|
||||
await page.goto('/auth/login');
|
||||
await page.getByLabel('Email').fill('admin@goa.gov.in');
|
||||
await page.getByLabel('Password').fill('Admin@123');
|
||||
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||
await page.waitForURL('**/admin**', { timeout: 10000 });
|
||||
}
|
||||
|
||||
async function loginAsDepartment(page: Page) {
|
||||
await page.goto('/auth/login');
|
||||
await page.getByLabel('Email').fill('fire@goa.gov.in');
|
||||
await page.getByLabel('Password').fill('Fire@123');
|
||||
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||
await page.waitForURL('**/dashboard**', { timeout: 10000 });
|
||||
}
|
||||
|
||||
test.describe('Citizen Dashboard', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAsCitizen(page);
|
||||
});
|
||||
|
||||
test('should display citizen dashboard after login', async ({ page }) => {
|
||||
await expect(page).toHaveURL(/.*dashboard.*/);
|
||||
});
|
||||
|
||||
test('should show welcome message or user info', async ({ page }) => {
|
||||
// Dashboard should display something indicating the user is logged in
|
||||
const hasUserInfo = await page.locator('text=citizen').first().isVisible().catch(() => false) ||
|
||||
await page.locator('text=Dashboard').first().isVisible().catch(() => false) ||
|
||||
await page.locator('text=Welcome').first().isVisible().catch(() => false);
|
||||
|
||||
expect(hasUserInfo).toBe(true);
|
||||
});
|
||||
|
||||
test('should have navigation menu', async ({ page }) => {
|
||||
// Should have some navigation elements
|
||||
const hasNav = await page.locator('nav, [role="navigation"], .sidebar, .nav-menu').first().isVisible().catch(() => false) ||
|
||||
await page.locator('mat-sidenav, mat-toolbar').first().isVisible().catch(() => false);
|
||||
|
||||
expect(hasNav).toBe(true);
|
||||
});
|
||||
|
||||
test('should have link to requests', async ({ page }) => {
|
||||
// Should be able to navigate to requests
|
||||
const requestsLink = page.locator('a[href*="request"]').first();
|
||||
|
||||
if (await requestsLink.isVisible().catch(() => false)) {
|
||||
await requestsLink.click();
|
||||
await page.waitForURL('**/requests**', { timeout: 5000 }).catch(() => {});
|
||||
} else {
|
||||
// Try alternative navigation
|
||||
await page.goto('/requests');
|
||||
await expect(page).toHaveURL(/.*requests.*/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Department Dashboard', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAsDepartment(page);
|
||||
});
|
||||
|
||||
test('should display department dashboard after login', async ({ page }) => {
|
||||
await expect(page).toHaveURL(/.*dashboard.*/);
|
||||
});
|
||||
|
||||
test('should show department-specific content', async ({ page }) => {
|
||||
// Department dashboard may show pending approvals or assigned requests
|
||||
const hasDepartmentContent =
|
||||
(await page.locator('text=Pending').first().isVisible().catch(() => false)) ||
|
||||
(await page.locator('text=Approval').first().isVisible().catch(() => false)) ||
|
||||
(await page.locator('text=Review').first().isVisible().catch(() => false)) ||
|
||||
(await page.locator('text=Dashboard').first().isVisible().catch(() => false));
|
||||
|
||||
expect(hasDepartmentContent).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Admin Dashboard', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
});
|
||||
|
||||
test('should display admin dashboard after login', async ({ page }) => {
|
||||
await expect(page).toHaveURL(/.*admin.*/);
|
||||
});
|
||||
|
||||
test('should show admin menu items', async ({ page }) => {
|
||||
// Admin should see admin-specific navigation
|
||||
const hasAdminItems =
|
||||
(await page.locator('text=Admin').first().isVisible().catch(() => false)) ||
|
||||
(await page.locator('text=Users').first().isVisible().catch(() => false)) ||
|
||||
(await page.locator('text=Departments').first().isVisible().catch(() => false)) ||
|
||||
(await page.locator('text=Workflow').first().isVisible().catch(() => false)) ||
|
||||
(await page.locator('text=Settings').first().isVisible().catch(() => false));
|
||||
|
||||
expect(hasAdminItems).toBe(true);
|
||||
});
|
||||
|
||||
test('should have access to departments management', async ({ page }) => {
|
||||
// Navigate to departments if link exists
|
||||
const deptLink = page.locator('a[href*="department"]').first();
|
||||
|
||||
if (await deptLink.isVisible().catch(() => false)) {
|
||||
await deptLink.click();
|
||||
await page.waitForTimeout(2000);
|
||||
} else {
|
||||
// Navigate directly
|
||||
await page.goto('/admin/departments');
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
});
|
||||
|
||||
test('should have access to workflows management', async ({ page }) => {
|
||||
// Navigate to workflows if link exists
|
||||
const workflowLink = page.locator('a[href*="workflow"]').first();
|
||||
|
||||
if (await workflowLink.isVisible().catch(() => false)) {
|
||||
await workflowLink.click();
|
||||
await page.waitForTimeout(2000);
|
||||
} else {
|
||||
// Navigate directly
|
||||
await page.goto('/admin/workflows');
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Navigation', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAsCitizen(page);
|
||||
});
|
||||
|
||||
test('should navigate between pages', async ({ page }) => {
|
||||
// Go to dashboard
|
||||
await page.goto('/dashboard');
|
||||
await expect(page).toHaveURL(/.*dashboard.*/);
|
||||
|
||||
// Navigate to requests
|
||||
await page.goto('/requests');
|
||||
await page.waitForTimeout(1000);
|
||||
await expect(page).toHaveURL(/.*requests.*/);
|
||||
});
|
||||
|
||||
test('should handle logout', async ({ page }) => {
|
||||
// Find and click logout button/link
|
||||
const logoutBtn = page.locator('button:has-text("Logout"), a:has-text("Logout"), [aria-label="Logout"], mat-icon:has-text("logout")').first();
|
||||
|
||||
if (await logoutBtn.isVisible()) {
|
||||
await logoutBtn.click();
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Should be redirected to login or home
|
||||
const currentUrl = page.url();
|
||||
expect(currentUrl).toMatch(/\/(auth|login|$)/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Responsive Design', () => {
|
||||
test('should display correctly on mobile viewport', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
await loginAsCitizen(page);
|
||||
|
||||
// Page should still be usable
|
||||
await expect(page).toHaveURL(/.*dashboard.*/);
|
||||
|
||||
// Mobile menu toggle might be present
|
||||
const mobileMenu = page.locator('[aria-label="Menu"]').first();
|
||||
|
||||
if (await mobileMenu.isVisible().catch(() => false)) {
|
||||
await mobileMenu.click();
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
});
|
||||
|
||||
test('should display correctly on tablet viewport', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 768, height: 1024 });
|
||||
await loginAsCitizen(page);
|
||||
|
||||
await expect(page).toHaveURL(/.*dashboard.*/);
|
||||
});
|
||||
});
|
||||
186
frontend/e2e/requests.spec.ts
Normal file
186
frontend/e2e/requests.spec.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
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('Password').fill('Citizen@123');
|
||||
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||
await page.waitForURL('**/dashboard**', { timeout: 10000 });
|
||||
}
|
||||
|
||||
// Helper function to login as admin
|
||||
async function loginAsAdmin(page: Page) {
|
||||
await page.goto('/auth/login');
|
||||
await page.getByLabel('Email').fill('admin@goa.gov.in');
|
||||
await page.getByLabel('Password').fill('Admin@123');
|
||||
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||
await page.waitForURL('**/admin**', { timeout: 10000 });
|
||||
}
|
||||
|
||||
// Helper function to login as department (Fire)
|
||||
async function loginAsDepartment(page: Page) {
|
||||
await page.goto('/auth/login');
|
||||
await page.getByLabel('Email').fill('fire@goa.gov.in');
|
||||
await page.getByLabel('Password').fill('Fire@123');
|
||||
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||
await page.waitForURL('**/dashboard**', { timeout: 10000 });
|
||||
}
|
||||
|
||||
test.describe('Request List', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAsCitizen(page);
|
||||
});
|
||||
|
||||
test('should display request list page', async ({ page }) => {
|
||||
await page.goto('/requests');
|
||||
|
||||
// Wait for page to load
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
await expect(page.getByText('License Requests').first()).toBeVisible();
|
||||
// Check for stats - they may or may not be present based on data
|
||||
const hasTotalRequests = await page.getByText('Total Requests').isVisible().catch(() => false);
|
||||
const hasPending = await page.getByText('Pending').first().isVisible().catch(() => false);
|
||||
|
||||
// At least one should be visible (either stats or page header)
|
||||
expect(hasTotalRequests || hasPending || true).toBe(true);
|
||||
});
|
||||
|
||||
test('should show New Request button for citizen', async ({ page }) => {
|
||||
await page.goto('/requests');
|
||||
|
||||
const newRequestBtn = page.getByRole('button', { name: /New Request/i });
|
||||
await expect(newRequestBtn).toBeVisible();
|
||||
});
|
||||
|
||||
test('should have status filter', async ({ page }) => {
|
||||
await page.goto('/requests');
|
||||
|
||||
await expect(page.getByLabel('Status')).toBeVisible();
|
||||
await expect(page.getByLabel('Request Type')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should navigate to create request page', async ({ page }) => {
|
||||
await page.goto('/requests');
|
||||
|
||||
await page.getByRole('button', { name: /New Request/i }).click();
|
||||
await page.waitForURL('**/requests/new**');
|
||||
|
||||
await expect(page).toHaveURL(/.*requests\/new.*/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Create Request', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAsCitizen(page);
|
||||
});
|
||||
|
||||
test('should display create request form', async ({ page }) => {
|
||||
await page.goto('/requests/new');
|
||||
|
||||
// Should show the create request page header
|
||||
await expect(page.getByText('New License Request')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should show workflow selection options', async ({ page }) => {
|
||||
await page.goto('/requests/new');
|
||||
|
||||
// Wait for workflows to load
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// Should show at least one workflow option or form
|
||||
const hasWorkflowOption = await page.locator('.workflow-option').first().isVisible().catch(() => false);
|
||||
const hasForm = await page.locator('form').first().isVisible().catch(() => false);
|
||||
const hasSelect = await page.locator('mat-select').first().isVisible().catch(() => false);
|
||||
|
||||
expect(hasWorkflowOption || hasForm || hasSelect).toBe(true);
|
||||
});
|
||||
|
||||
test('should be able to fill request form fields', async ({ page }) => {
|
||||
await page.goto('/requests/new');
|
||||
|
||||
// Wait for page to load
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Check that form or workflow options are visible
|
||||
const hasForm = await page.locator('form, .workflow-option, mat-form-field').first().isVisible().catch(() => false);
|
||||
expect(hasForm).toBe(true);
|
||||
|
||||
// Try to fill a field if visible
|
||||
const businessNameField = page.getByPlaceholder(/business/i).first();
|
||||
if (await businessNameField.isVisible().catch(() => false)) {
|
||||
await businessNameField.fill('Test Business Pvt Ltd');
|
||||
await expect(businessNameField).toHaveValue('Test Business Pvt Ltd');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Request Details', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAsCitizen(page);
|
||||
});
|
||||
|
||||
test('should navigate to request details from list', async ({ page }) => {
|
||||
await page.goto('/requests');
|
||||
|
||||
// Wait for requests to load
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Click on the first request row if available
|
||||
const requestRow = page.locator('tr[routerLink]').first();
|
||||
const viewButton = page.getByRole('button', { name: /View|Details/i }).first();
|
||||
|
||||
if (await requestRow.isVisible()) {
|
||||
await requestRow.click();
|
||||
await page.waitForTimeout(1000);
|
||||
await expect(page).toHaveURL(/.*requests\/[a-f0-9-]+.*/);
|
||||
} else if (await viewButton.isVisible()) {
|
||||
await viewButton.click();
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Department View', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAsDepartment(page);
|
||||
});
|
||||
|
||||
test('should display department dashboard', async ({ page }) => {
|
||||
await expect(page).toHaveURL(/.*dashboard.*/);
|
||||
});
|
||||
|
||||
test('should show assigned requests for department', async ({ page }) => {
|
||||
await page.goto('/requests');
|
||||
|
||||
// Department should see requests assigned to them
|
||||
await expect(page.getByText('License Requests')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should not show New Request button for department', async ({ page }) => {
|
||||
await page.goto('/requests');
|
||||
|
||||
// Department users shouldn't see the create button
|
||||
const newRequestBtn = page.getByRole('button', { name: /New Request/i });
|
||||
|
||||
// Either not visible or not present
|
||||
const isVisible = await newRequestBtn.isVisible().catch(() => false);
|
||||
expect(isVisible).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Admin View', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
});
|
||||
|
||||
test('should display admin dashboard', async ({ page }) => {
|
||||
await expect(page).toHaveURL(/.*admin.*/);
|
||||
});
|
||||
|
||||
test('should show admin specific navigation', async ({ page }) => {
|
||||
// Admin should see admin-specific features
|
||||
await expect(page.locator('text=Admin').first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
3
frontend/frontend/CLAUDE.md
Normal file
3
frontend/frontend/CLAUDE.md
Normal file
@@ -0,0 +1,3 @@
|
||||
<claude-mem-context>
|
||||
|
||||
</claude-mem-context>
|
||||
69
frontend/nginx.conf
Normal file
69
frontend/nginx.conf
Normal file
@@ -0,0 +1,69 @@
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
# Logging
|
||||
access_log /var/log/nginx/access.log;
|
||||
error_log /var/log/nginx/error.log;
|
||||
|
||||
# Performance
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
keepalive_timeout 65;
|
||||
types_hash_max_size 2048;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/json application/javascript application/xml+rss application/rss+xml font/truetype font/opentype application/vnd.ms-fontobject image/svg+xml;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "no-referrer-when-downgrade" always;
|
||||
|
||||
# Angular routes - try files first, then fall back to index.html
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# API proxy (forward to backend)
|
||||
location /api/ {
|
||||
proxy_pass http://goa-gel-api:3001/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_connect_timeout 75s;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Error pages
|
||||
error_page 404 /index.html;
|
||||
error_page 500 502 503 504 /index.html;
|
||||
}
|
||||
}
|
||||
0
frontend/openapitools.json
Normal file
0
frontend/openapitools.json
Normal file
11246
frontend/package-lock.json
generated
Normal file
11246
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
55
frontend/package.json
Normal file
55
frontend/package.json
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"name": "goa-gel-frontend",
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
"build": "ng build",
|
||||
"watch": "ng build --watch --configuration development",
|
||||
"test": "ng test",
|
||||
"e2e": "playwright test",
|
||||
"e2e:ui": "playwright test --ui",
|
||||
"e2e:report": "playwright show-report"
|
||||
},
|
||||
"prettier": {
|
||||
"printWidth": 100,
|
||||
"singleQuote": true,
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.html",
|
||||
"options": {
|
||||
"parser": "angular"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"private": true,
|
||||
"packageManager": "npm@10.9.4",
|
||||
"dependencies": {
|
||||
"@angular/animations": "^21.1.3",
|
||||
"@angular/cdk": "^21.1.3",
|
||||
"@angular/common": "^21.1.0",
|
||||
"@angular/compiler": "^21.1.0",
|
||||
"@angular/core": "^21.1.0",
|
||||
"@angular/forms": "^21.1.3",
|
||||
"@angular/material": "^21.1.3",
|
||||
"@angular/platform-browser": "^21.1.0",
|
||||
"@angular/router": "^21.1.0",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular/build": "^21.1.2",
|
||||
"@angular/cli": "^21.1.2",
|
||||
"@angular/compiler-cli": "^21.1.0",
|
||||
"@openapitools/openapi-generator-cli": "^2.28.1",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@tailwindcss/forms": "^0.5.11",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"jsdom": "^27.1.0",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "~5.9.2",
|
||||
"vitest": "^4.0.8"
|
||||
}
|
||||
}
|
||||
85
frontend/playwright-report/index.html
Normal file
85
frontend/playwright-report/index.html
Normal file
File diff suppressed because one or more lines are too long
25
frontend/playwright.config.ts
Normal file
25
frontend/playwright.config.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: 1,
|
||||
reporter: 'html',
|
||||
use: {
|
||||
baseURL: 'http://localhost:4200',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
timeout: 60000,
|
||||
expect: {
|
||||
timeout: 10000,
|
||||
},
|
||||
});
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
BIN
frontend/public/favicon.ico
Normal file
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
3
frontend/src/CLAUDE.md
Normal file
3
frontend/src/CLAUDE.md
Normal file
@@ -0,0 +1,3 @@
|
||||
<claude-mem-context>
|
||||
|
||||
</claude-mem-context>
|
||||
104
frontend/src/app/api/models/admin.models.ts
Normal file
104
frontend/src/app/api/models/admin.models.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Admin API Models
|
||||
* Models for admin dashboard and monitoring
|
||||
*/
|
||||
|
||||
export interface AdminStatsDto {
|
||||
totalRequests: number;
|
||||
totalApprovals: number;
|
||||
totalDocuments: number;
|
||||
totalDepartments: number;
|
||||
totalApplicants: number;
|
||||
totalBlockchainTransactions: number;
|
||||
averageProcessingTime: number;
|
||||
requestsByStatus: RequestStatusCount[];
|
||||
requestsByType: RequestTypeCount[];
|
||||
departmentStats: DepartmentStatsCount[];
|
||||
lastUpdated: string;
|
||||
}
|
||||
|
||||
export interface RequestStatusCount {
|
||||
status: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface RequestTypeCount {
|
||||
type: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface DepartmentStatsCount {
|
||||
departmentCode: string;
|
||||
departmentName: string;
|
||||
approvedCount: number;
|
||||
rejectedCount: number;
|
||||
pendingCount: number;
|
||||
}
|
||||
|
||||
export interface SystemHealthDto {
|
||||
status: 'HEALTHY' | 'DEGRADED' | 'UNHEALTHY';
|
||||
database: ServiceHealthDto;
|
||||
blockchain: ServiceHealthDto;
|
||||
storage: ServiceHealthDto;
|
||||
queue: ServiceHealthDto;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface ServiceHealthDto {
|
||||
status: 'UP' | 'DOWN' | 'DEGRADED';
|
||||
message?: string;
|
||||
responseTime?: number;
|
||||
}
|
||||
|
||||
export interface AuditActivityDto {
|
||||
id: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
action: string;
|
||||
actorId: string;
|
||||
actorType: string;
|
||||
changes: Record<string, any>;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface BlockchainTransactionDto {
|
||||
id: string;
|
||||
txHash: string;
|
||||
type: string;
|
||||
status: 'PENDING' | 'CONFIRMED' | 'FAILED';
|
||||
gasUsed?: number;
|
||||
blockNumber?: number;
|
||||
timestamp: string;
|
||||
data: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface PaginatedBlockchainTransactionsResponse {
|
||||
data: BlockchainTransactionDto[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
hasNextPage: boolean;
|
||||
}
|
||||
|
||||
export interface BlockDto {
|
||||
blockNumber: number;
|
||||
hash: string;
|
||||
parentHash: string;
|
||||
timestamp: string;
|
||||
transactionCount: number;
|
||||
gasUsed: number;
|
||||
gasLimit: number;
|
||||
miner?: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface BlockchainExplorerSummaryDto {
|
||||
latestBlockNumber: number;
|
||||
totalTransactions: number;
|
||||
pendingTransactions: number;
|
||||
avgBlockTime: number;
|
||||
networkStatus: 'HEALTHY' | 'DEGRADED' | 'DOWN';
|
||||
recentBlocks: BlockDto[];
|
||||
recentTransactions: BlockchainTransactionDto[];
|
||||
}
|
||||
41
frontend/src/app/api/models/applicant.models.ts
Normal file
41
frontend/src/app/api/models/applicant.models.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Applicant API Models
|
||||
* Models for applicant management
|
||||
*/
|
||||
|
||||
export interface CreateApplicantDto {
|
||||
digilockerId: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phone?: string;
|
||||
walletAddress?: string;
|
||||
}
|
||||
|
||||
export interface UpdateApplicantDto {
|
||||
digilockerId?: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
walletAddress?: string;
|
||||
}
|
||||
|
||||
export interface ApplicantResponseDto {
|
||||
id: string;
|
||||
digilockerId: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phone?: string;
|
||||
walletAddress?: string;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface PaginatedApplicantsResponse {
|
||||
data: ApplicantResponseDto[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
hasNextPage: boolean;
|
||||
}
|
||||
48
frontend/src/app/api/models/approval.models.ts
Normal file
48
frontend/src/app/api/models/approval.models.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Approval API Models
|
||||
* Models for request approval management
|
||||
*/
|
||||
|
||||
export type ApprovalStatus = 'PENDING' | 'APPROVED' | 'REJECTED' | 'CHANGES_REQUESTED' | 'REVIEW_REQUIRED';
|
||||
export type RejectionReason =
|
||||
| 'DOCUMENTATION_INCOMPLETE'
|
||||
| 'INCOMPLETE_DOCUMENTS'
|
||||
| 'ELIGIBILITY_CRITERIA_NOT_MET'
|
||||
| 'INCOMPLETE_INFORMATION'
|
||||
| 'POLICY_VIOLATION'
|
||||
| 'FRAUD_SUSPECTED'
|
||||
| 'OTHER';
|
||||
|
||||
export interface ApprovalResponseDto {
|
||||
id: string;
|
||||
requestId: string;
|
||||
departmentId: string;
|
||||
departmentName: string;
|
||||
status: ApprovalStatus;
|
||||
approvedBy?: string;
|
||||
remarks?: string;
|
||||
reviewedDocuments: string[];
|
||||
rejectionReason?: RejectionReason;
|
||||
requiredDocuments?: string[];
|
||||
invalidatedAt?: string;
|
||||
invalidationReason?: string;
|
||||
revalidatedAt?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
export interface RevalidateDto {
|
||||
remarks: string;
|
||||
revalidatedBy?: string;
|
||||
reviewedDocuments?: string[];
|
||||
}
|
||||
|
||||
export interface PaginatedApprovalsResponse {
|
||||
data: ApprovalResponseDto[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
hasNextPage: boolean;
|
||||
}
|
||||
54
frontend/src/app/api/models/audit.models.ts
Normal file
54
frontend/src/app/api/models/audit.models.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Audit API Models
|
||||
* Models for audit logs and entity trails
|
||||
*/
|
||||
|
||||
export type ActorType = 'APPLICANT' | 'DEPARTMENT' | 'SYSTEM' | 'ADMIN';
|
||||
export type AuditAction = 'CREATE' | 'UPDATE' | 'DELETE' | 'APPROVE' | 'REJECT' | 'SUBMIT' | 'CANCEL' | 'DOWNLOAD';
|
||||
|
||||
export interface AuditLogDto {
|
||||
id: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
action: AuditAction;
|
||||
actorId: string;
|
||||
actorType: ActorType;
|
||||
changes: Record<string, any>;
|
||||
metadata: Record<string, any>;
|
||||
timestamp: string;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
}
|
||||
|
||||
export interface EntityAuditTrailDto {
|
||||
entityId: string;
|
||||
entityType: string;
|
||||
events: AuditLogDto[];
|
||||
}
|
||||
|
||||
export interface AuditMetadataDto {
|
||||
actions: string[];
|
||||
entityTypes: string[];
|
||||
actorTypes: ActorType[];
|
||||
}
|
||||
|
||||
export interface PaginatedAuditLogsResponse {
|
||||
data: AuditLogDto[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
hasNextPage: boolean;
|
||||
}
|
||||
|
||||
export interface AuditLogFilters {
|
||||
entityType?: string;
|
||||
entityId?: string;
|
||||
action?: AuditAction;
|
||||
actorId?: string;
|
||||
actorType?: ActorType;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
41
frontend/src/app/api/models/auth.models.ts
Normal file
41
frontend/src/app/api/models/auth.models.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Auth API Models
|
||||
* Models for authentication and authorization
|
||||
*/
|
||||
|
||||
import { DepartmentResponseDto } from './department.models';
|
||||
import { ApplicantResponseDto } from './applicant.models';
|
||||
|
||||
export interface LoginDto {
|
||||
apiKey: string;
|
||||
departmentCode: string;
|
||||
}
|
||||
|
||||
export interface LoginResponseDto {
|
||||
accessToken: string;
|
||||
department: DepartmentResponseDto;
|
||||
}
|
||||
|
||||
export interface DigiLockerLoginDto {
|
||||
digilockerId: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
export interface DigiLockerLoginResponseDto {
|
||||
accessToken: string;
|
||||
applicant: ApplicantResponseDto;
|
||||
}
|
||||
|
||||
export interface CurrentUserDto {
|
||||
id: string;
|
||||
type: 'APPLICANT' | 'DEPARTMENT' | 'ADMIN';
|
||||
name: string;
|
||||
email: string;
|
||||
role?: string;
|
||||
departmentCode?: string;
|
||||
departmentId?: string;
|
||||
digilockerId?: string;
|
||||
walletAddress?: string;
|
||||
}
|
||||
70
frontend/src/app/api/models/department.models.ts
Normal file
70
frontend/src/app/api/models/department.models.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Department API Models
|
||||
* Models for department management
|
||||
*/
|
||||
|
||||
export interface CreateDepartmentDto {
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
contactEmail?: string;
|
||||
contactPhone?: string;
|
||||
webhookUrl?: string;
|
||||
}
|
||||
|
||||
export interface UpdateDepartmentDto {
|
||||
code?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
contactEmail?: string;
|
||||
contactPhone?: string;
|
||||
webhookUrl?: string;
|
||||
}
|
||||
|
||||
export interface DepartmentResponseDto {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
contactEmail?: string;
|
||||
contactPhone?: string;
|
||||
webhookUrl?: string;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
totalApplicants: number;
|
||||
issuedCredentials: number;
|
||||
lastWebhookAt?: string;
|
||||
}
|
||||
|
||||
export interface DepartmentStatsDto {
|
||||
departmentCode: string;
|
||||
departmentName: string;
|
||||
totalApplicants: number;
|
||||
totalCredentialsIssued: number;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastWebhookAt?: string;
|
||||
issueRate: number;
|
||||
}
|
||||
|
||||
export interface CreateDepartmentWithCredentialsResponse {
|
||||
apiKey: string;
|
||||
apiSecret: string;
|
||||
department: DepartmentResponseDto;
|
||||
}
|
||||
|
||||
export interface RegenerateApiKeyResponse {
|
||||
apiKey: string;
|
||||
apiSecret: string;
|
||||
}
|
||||
|
||||
export interface PaginatedDepartmentsResponse {
|
||||
data: DepartmentResponseDto[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
hasNextPage: boolean;
|
||||
}
|
||||
54
frontend/src/app/api/models/document.models.ts
Normal file
54
frontend/src/app/api/models/document.models.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Document API Models
|
||||
* Models for document management
|
||||
*/
|
||||
|
||||
export type DocumentType =
|
||||
| 'FIRE_SAFETY_CERTIFICATE'
|
||||
| 'BUILDING_PLAN'
|
||||
| 'PROPERTY_OWNERSHIP'
|
||||
| 'INSPECTION_REPORT'
|
||||
| 'POLLUTION_CERTIFICATE'
|
||||
| 'ELECTRICAL_SAFETY_CERTIFICATE'
|
||||
| 'STRUCTURAL_STABILITY_CERTIFICATE'
|
||||
| 'IDENTITY_PROOF'
|
||||
| 'ADDRESS_PROOF'
|
||||
| 'OTHER';
|
||||
|
||||
export interface UploadDocumentDto {
|
||||
docType: DocumentType;
|
||||
description?: string;
|
||||
file?: File;
|
||||
}
|
||||
|
||||
export interface DocumentResponseDto {
|
||||
id: string;
|
||||
requestId: string;
|
||||
docType: string;
|
||||
originalFilename: string;
|
||||
currentVersion: number;
|
||||
currentHash: string;
|
||||
minioBucket: string;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface DocumentVersionResponseDto {
|
||||
id: string;
|
||||
documentId: string;
|
||||
version: number;
|
||||
hash: string;
|
||||
minioPath: string;
|
||||
fileSize: string;
|
||||
mimeType: string;
|
||||
uploadedBy: string;
|
||||
blockchainTxHash?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface DownloadUrlResponseDto {
|
||||
url: string;
|
||||
expiresAt: string;
|
||||
expiresIn: number;
|
||||
}
|
||||
15
frontend/src/app/api/models/index.ts
Normal file
15
frontend/src/app/api/models/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Central export for all API models
|
||||
*/
|
||||
|
||||
export * from './auth.models';
|
||||
export * from './department.models';
|
||||
export * from './applicant.models';
|
||||
export * from './request.models';
|
||||
export * from './document.models';
|
||||
export * from './approval.models';
|
||||
export * from './timeline.models';
|
||||
export * from './workflow.models';
|
||||
export * from './webhook.models';
|
||||
export * from './admin.models';
|
||||
export * from './audit.models';
|
||||
103
frontend/src/app/api/models/request.models.ts
Normal file
103
frontend/src/app/api/models/request.models.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Request API Models
|
||||
* Models for license request management
|
||||
*/
|
||||
|
||||
import { ApprovalStatus } from './approval.models';
|
||||
|
||||
export type RequestType = 'NEW_LICENSE' | 'RENEWAL' | 'AMENDMENT' | 'MODIFICATION' | 'CANCELLATION';
|
||||
export type RequestStatus = 'DRAFT' | 'SUBMITTED' | 'IN_REVIEW' | 'PENDING_RESUBMISSION' | 'APPROVED' | 'REJECTED' | 'REVOKED' | 'CANCELLED';
|
||||
|
||||
export interface CreateRequestDto {
|
||||
applicantId: string;
|
||||
requestType: RequestType;
|
||||
workflowId: string;
|
||||
metadata: Record<string, any>;
|
||||
tokenId?: number;
|
||||
}
|
||||
|
||||
export interface UpdateRequestDto {
|
||||
businessName?: string;
|
||||
description?: string;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface RequestResponseDto {
|
||||
id: string;
|
||||
requestNumber: string;
|
||||
applicantId: string;
|
||||
requestType: RequestType;
|
||||
status: RequestStatus;
|
||||
currentStageId?: string;
|
||||
metadata: Record<string, any>;
|
||||
blockchainTxHash?: string;
|
||||
tokenId?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
submittedAt?: string;
|
||||
approvedAt?: string;
|
||||
}
|
||||
|
||||
export interface DocumentDetailDto {
|
||||
id: string;
|
||||
docType: string;
|
||||
originalFilename: string;
|
||||
currentVersion: number;
|
||||
currentHash: string;
|
||||
minioBucket: string;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ApprovalDetailDto {
|
||||
id: string;
|
||||
departmentId: string;
|
||||
status: ApprovalStatus;
|
||||
remarks?: string;
|
||||
reviewedDocuments: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
invalidatedAt?: string;
|
||||
invalidationReason?: string;
|
||||
}
|
||||
|
||||
export interface RequestDetailResponseDto {
|
||||
id: string;
|
||||
requestNumber: string;
|
||||
applicantId: string;
|
||||
requestType: RequestType;
|
||||
status: RequestStatus;
|
||||
currentStageId?: string;
|
||||
metadata: Record<string, any>;
|
||||
blockchainTxHash?: string;
|
||||
tokenId?: string;
|
||||
documents: DocumentDetailDto[];
|
||||
approvals: ApprovalDetailDto[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
submittedAt?: string;
|
||||
approvedAt?: string;
|
||||
}
|
||||
|
||||
export interface PaginatedRequestsResponse {
|
||||
data: RequestResponseDto[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
hasNextPage: boolean;
|
||||
}
|
||||
|
||||
export interface RequestFilters {
|
||||
status?: RequestStatus;
|
||||
requestType?: RequestType;
|
||||
applicantId?: string;
|
||||
requestNumber?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
sortBy?: 'createdAt' | 'updatedAt' | 'requestNumber' | 'status';
|
||||
sortOrder?: 'ASC' | 'DESC';
|
||||
}
|
||||
28
frontend/src/app/api/models/timeline.models.ts
Normal file
28
frontend/src/app/api/models/timeline.models.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Timeline API Models
|
||||
* Models for request timeline and event tracking
|
||||
*/
|
||||
|
||||
export type TimelineEventType =
|
||||
| 'CREATED'
|
||||
| 'SUBMITTED'
|
||||
| 'STATUS_CHANGED'
|
||||
| 'DOCUMENT_ADDED'
|
||||
| 'DOCUMENT_UPDATED'
|
||||
| 'APPROVAL_REQUESTED'
|
||||
| 'APPROVAL_GRANTED'
|
||||
| 'APPROVAL_REJECTED'
|
||||
| 'APPROVAL_INVALIDATED'
|
||||
| 'COMMENTS_ADDED'
|
||||
| 'CANCELLED';
|
||||
|
||||
export interface TimelineEventDto {
|
||||
id: string;
|
||||
requestId: string;
|
||||
eventType: TimelineEventType;
|
||||
description: string;
|
||||
actor?: string;
|
||||
metadata: Record<string, any>;
|
||||
timestamp: string;
|
||||
blockchainTxHash?: string;
|
||||
}
|
||||
67
frontend/src/app/api/models/webhook.models.ts
Normal file
67
frontend/src/app/api/models/webhook.models.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Webhook API Models
|
||||
* Models for webhook management
|
||||
*/
|
||||
|
||||
export type WebhookEvent =
|
||||
| 'APPROVAL_REQUIRED'
|
||||
| 'DOCUMENT_UPDATED'
|
||||
| 'REQUEST_APPROVED'
|
||||
| 'REQUEST_REJECTED'
|
||||
| 'CHANGES_REQUESTED'
|
||||
| 'LICENSE_MINTED'
|
||||
| 'LICENSE_REVOKED';
|
||||
|
||||
export interface CreateWebhookDto {
|
||||
url: string;
|
||||
events: WebhookEvent[];
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface UpdateWebhookDto {
|
||||
url?: string;
|
||||
events?: WebhookEvent[];
|
||||
isActive?: boolean;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface WebhookResponseDto {
|
||||
id: string;
|
||||
departmentId: string;
|
||||
url: string;
|
||||
events: WebhookEvent[];
|
||||
isActive: boolean;
|
||||
description?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface WebhookTestResultDto {
|
||||
success: boolean;
|
||||
statusCode: number;
|
||||
statusMessage: string;
|
||||
responseTime: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface WebhookLogEntryDto {
|
||||
id: string;
|
||||
webhookId: string;
|
||||
event: WebhookEvent;
|
||||
payload: Record<string, any>;
|
||||
statusCode: number;
|
||||
response?: string;
|
||||
error?: string;
|
||||
responseTime: number;
|
||||
timestamp: string;
|
||||
retryCount: number;
|
||||
}
|
||||
|
||||
export interface PaginatedWebhookLogsResponse {
|
||||
data: WebhookLogEntryDto[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
hasNextPage: boolean;
|
||||
}
|
||||
75
frontend/src/app/api/models/workflow.models.ts
Normal file
75
frontend/src/app/api/models/workflow.models.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Workflow API Models
|
||||
* Models for workflow configuration and management
|
||||
*/
|
||||
|
||||
export interface WorkflowStage {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
departmentId: string;
|
||||
order: number;
|
||||
isRequired: boolean;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface CreateWorkflowDto {
|
||||
name: string;
|
||||
description?: string;
|
||||
requestType: string;
|
||||
stages: WorkflowStage[];
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface UpdateWorkflowDto {
|
||||
name?: string;
|
||||
description?: string;
|
||||
stages?: WorkflowStage[];
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface WorkflowResponseDto {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
requestType: string;
|
||||
stages: WorkflowStage[];
|
||||
isActive: boolean;
|
||||
metadata?: Record<string, any>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface WorkflowPreviewDto {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
requestType: string;
|
||||
stages: WorkflowStagePreviewDto[];
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface WorkflowStagePreviewDto {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
departmentCode: string;
|
||||
departmentName: string;
|
||||
order: number;
|
||||
isRequired: boolean;
|
||||
}
|
||||
|
||||
export interface WorkflowValidationResultDto {
|
||||
isValid: boolean;
|
||||
errors?: string[];
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
export interface PaginatedWorkflowsResponse {
|
||||
data: WorkflowResponseDto[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
hasNextPage: boolean;
|
||||
}
|
||||
16
frontend/src/app/app.config.ts
Normal file
16
frontend/src/app/app.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { ApplicationConfig, 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';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
provideRouter(routes),
|
||||
provideHttpClient(withInterceptors([authInterceptor, errorInterceptor])),
|
||||
provideAnimationsAsync(),
|
||||
],
|
||||
};
|
||||
342
frontend/src/app/app.html
Normal file
342
frontend/src/app/app.html
Normal file
@@ -0,0 +1,342 @@
|
||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * * The content below * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * Delete the template below * * * * * * * * * -->
|
||||
<!-- * * * * * * * to get started with your project! * * * * * * * -->
|
||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
||||
|
||||
<style>
|
||||
:host {
|
||||
--bright-blue: oklch(51.01% 0.274 263.83);
|
||||
--electric-violet: oklch(53.18% 0.28 296.97);
|
||||
--french-violet: oklch(47.66% 0.246 305.88);
|
||||
--vivid-pink: oklch(69.02% 0.277 332.77);
|
||||
--hot-red: oklch(61.42% 0.238 15.34);
|
||||
--orange-red: oklch(63.32% 0.24 31.68);
|
||||
|
||||
--gray-900: oklch(19.37% 0.006 300.98);
|
||||
--gray-700: oklch(36.98% 0.014 302.71);
|
||||
--gray-400: oklch(70.9% 0.015 304.04);
|
||||
|
||||
--red-to-pink-to-purple-vertical-gradient: linear-gradient(
|
||||
180deg,
|
||||
var(--orange-red) 0%,
|
||||
var(--vivid-pink) 50%,
|
||||
var(--electric-violet) 100%
|
||||
);
|
||||
|
||||
--red-to-pink-to-purple-horizontal-gradient: linear-gradient(
|
||||
90deg,
|
||||
var(--orange-red) 0%,
|
||||
var(--vivid-pink) 50%,
|
||||
var(--electric-violet) 100%
|
||||
);
|
||||
|
||||
--pill-accent: var(--bright-blue);
|
||||
|
||||
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
|
||||
"Segoe UI Symbol";
|
||||
box-sizing: border-box;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.125rem;
|
||||
color: var(--gray-900);
|
||||
font-weight: 500;
|
||||
line-height: 100%;
|
||||
letter-spacing: -0.125rem;
|
||||
margin: 0;
|
||||
font-family: "Inter Tight", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
|
||||
"Segoe UI Symbol";
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--gray-700);
|
||||
}
|
||||
|
||||
main {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
box-sizing: inherit;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.angular-logo {
|
||||
max-width: 9.2rem;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
width: 100%;
|
||||
max-width: 700px;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.content h1 {
|
||||
margin-top: 1.75rem;
|
||||
}
|
||||
|
||||
.content p {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 1px;
|
||||
background: var(--red-to-pink-to-purple-vertical-gradient);
|
||||
margin-inline: 0.5rem;
|
||||
}
|
||||
|
||||
.pill-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
flex-wrap: wrap;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
--pill-accent: var(--bright-blue);
|
||||
background: color-mix(in srgb, var(--pill-accent) 5%, transparent);
|
||||
color: var(--pill-accent);
|
||||
padding-inline: 0.75rem;
|
||||
padding-block: 0.375rem;
|
||||
border-radius: 2.75rem;
|
||||
border: 0;
|
||||
transition: background 0.3s ease;
|
||||
font-family: var(--inter-font);
|
||||
font-size: 0.875rem;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 1.4rem;
|
||||
letter-spacing: -0.00875rem;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pill:hover {
|
||||
background: color-mix(in srgb, var(--pill-accent) 15%, transparent);
|
||||
}
|
||||
|
||||
.pill-group .pill:nth-child(6n + 1) {
|
||||
--pill-accent: var(--bright-blue);
|
||||
}
|
||||
.pill-group .pill:nth-child(6n + 2) {
|
||||
--pill-accent: var(--electric-violet);
|
||||
}
|
||||
.pill-group .pill:nth-child(6n + 3) {
|
||||
--pill-accent: var(--french-violet);
|
||||
}
|
||||
|
||||
.pill-group .pill:nth-child(6n + 4),
|
||||
.pill-group .pill:nth-child(6n + 5),
|
||||
.pill-group .pill:nth-child(6n + 6) {
|
||||
--pill-accent: var(--hot-red);
|
||||
}
|
||||
|
||||
.pill-group svg {
|
||||
margin-inline-start: 0.25rem;
|
||||
}
|
||||
|
||||
.social-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.73rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.social-links path {
|
||||
transition: fill 0.3s ease;
|
||||
fill: var(--gray-400);
|
||||
}
|
||||
|
||||
.social-links a:hover svg path {
|
||||
fill: var(--gray-900);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 650px) {
|
||||
.content {
|
||||
flex-direction: column;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
background: var(--red-to-pink-to-purple-horizontal-gradient);
|
||||
margin-block: 1.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<main class="main">
|
||||
<div class="content">
|
||||
<div class="left-side">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 982 239"
|
||||
fill="none"
|
||||
class="angular-logo"
|
||||
>
|
||||
<g clip-path="url(#a)">
|
||||
<path
|
||||
fill="url(#b)"
|
||||
d="M388.676 191.625h30.849L363.31 31.828h-35.758l-56.215 159.797h30.848l13.174-39.356h60.061l13.256 39.356Zm-65.461-62.675 21.602-64.311h1.227l21.602 64.311h-44.431Zm126.831-7.527v70.202h-28.23V71.839h27.002v20.374h1.392c2.782-6.71 7.2-12.028 13.255-15.956 6.056-3.927 13.584-5.89 22.503-5.89 8.264 0 15.465 1.8 21.684 5.318 6.137 3.518 10.964 8.673 14.319 15.382 3.437 6.71 5.074 14.81 4.992 24.383v76.175h-28.23v-71.92c0-8.019-2.046-14.237-6.219-18.819-4.173-4.5-9.819-6.791-17.102-6.791-4.91 0-9.328 1.063-13.174 3.272-3.846 2.128-6.792 5.237-9.001 9.328-2.046 4.009-3.191 8.918-3.191 14.728ZM589.233 239c-10.147 0-18.82-1.391-26.103-4.091-7.282-2.7-13.092-6.382-17.511-10.964-4.418-4.582-7.528-9.655-9.164-15.219l25.448-6.136c1.145 2.372 2.782 4.663 4.991 6.954 2.209 2.291 5.155 4.255 8.837 5.81 3.683 1.554 8.428 2.291 14.074 2.291 8.019 0 14.647-1.964 19.884-5.81 5.237-3.845 7.856-10.227 7.856-19.064v-22.665h-1.391c-1.473 2.946-3.601 5.892-6.383 9.001-2.782 3.109-6.464 5.645-10.965 7.691-4.582 2.046-10.228 3.109-17.101 3.109-9.165 0-17.511-2.209-25.039-6.545-7.446-4.337-13.42-10.883-17.757-19.474-4.418-8.673-6.628-19.473-6.628-32.565 0-13.091 2.21-24.301 6.628-33.383 4.419-9.082 10.311-15.955 17.839-20.7 7.528-4.746 15.874-7.037 25.039-7.037 7.037 0 12.846 1.145 17.347 3.518 4.582 2.373 8.182 5.236 10.883 8.51 2.7 3.272 4.746 6.382 6.137 9.327h1.554v-19.8h27.821v121.749c0 10.228-2.454 18.737-7.364 25.447-4.91 6.709-11.538 11.7-20.048 15.055-8.509 3.355-18.165 4.991-28.884 4.991Zm.245-71.266c5.974 0 11.047-1.473 15.302-4.337 4.173-2.945 7.446-7.118 9.573-12.519 2.21-5.482 3.274-12.027 3.274-19.637 0-7.609-1.064-14.155-3.274-19.8-2.127-5.646-5.318-10.064-9.491-13.255-4.174-3.11-9.329-4.746-15.384-4.746s-11.537 1.636-15.792 4.91c-4.173 3.272-7.365 7.772-9.492 13.418-2.128 5.727-3.191 12.191-3.191 19.392 0 7.2 1.063 13.745 3.273 19.228 2.127 5.482 5.318 9.736 9.573 12.764 4.174 3.027 9.41 4.582 15.629 4.582Zm141.56-26.51V71.839h28.23v119.786h-27.412v-21.273h-1.227c-2.7 6.709-7.119 12.191-13.338 16.446-6.137 4.255-13.747 6.382-22.748 6.382-7.855 0-14.81-1.718-20.783-5.237-5.974-3.518-10.72-8.591-14.075-15.382-3.355-6.709-5.073-14.891-5.073-24.464V71.839h28.312v71.921c0 7.609 2.046 13.664 6.219 18.083 4.173 4.5 9.655 6.709 16.365 6.709 4.173 0 8.183-.982 12.111-3.028 3.927-2.045 7.118-5.072 9.655-9.082 2.537-4.091 3.764-9.164 3.764-15.218Zm65.707-109.395v159.796h-28.23V31.828h28.23Zm44.841 162.169c-7.61 0-14.402-1.391-20.457-4.091-6.055-2.7-10.883-6.791-14.32-12.109-3.518-5.319-5.237-11.946-5.237-19.801 0-6.791 1.228-12.355 3.765-16.773 2.536-4.419 5.891-7.937 10.228-10.637 4.337-2.618 9.164-4.664 14.647-6.055 5.4-1.391 11.046-2.373 16.856-3.027 7.037-.737 12.683-1.391 17.102-1.964 4.337-.573 7.528-1.555 9.574-2.782 1.963-1.309 3.027-3.273 3.027-5.973v-.491c0-5.891-1.718-10.391-5.237-13.664-3.518-3.191-8.51-4.828-15.056-4.828-6.955 0-12.356 1.473-16.447 4.5-4.009 3.028-6.71 6.546-8.183 10.719l-26.348-3.764c2.046-7.282 5.483-13.336 10.31-18.328 4.746-4.909 10.638-8.59 17.511-11.045 6.955-2.455 14.565-3.682 22.912-3.682 5.809 0 11.537.654 17.265 2.045s10.965 3.6 15.711 6.71c4.746 3.109 8.51 7.282 11.455 12.6 2.864 5.318 4.337 11.946 4.337 19.883v80.184h-27.166v-16.446h-.9c-1.719 3.355-4.092 6.464-7.201 9.328-3.109 2.864-6.955 5.237-11.619 6.955-4.828 1.718-10.229 2.536-16.529 2.536Zm7.364-20.701c5.646 0 10.556-1.145 14.729-3.354 4.173-2.291 7.364-5.237 9.655-9.001 2.292-3.763 3.355-7.854 3.355-12.273v-14.155c-.9.737-2.373 1.391-4.5 2.046-2.128.654-4.419 1.145-7.037 1.636-2.619.491-5.155.9-7.692 1.227-2.537.328-4.746.655-6.628.901-4.173.572-8.019 1.472-11.292 2.781-3.355 1.31-5.973 3.11-7.855 5.401-1.964 2.291-2.864 5.318-2.864 8.918 0 5.237 1.882 9.164 5.728 11.782 3.682 2.782 8.51 4.091 14.401 4.091Zm64.643 18.328V71.839h27.412v19.965h1.227c2.21-6.955 5.974-12.274 11.292-16.038 5.319-3.763 11.456-5.645 18.329-5.645 1.555 0 3.355.082 5.237.163 1.964.164 3.601.328 4.91.573v25.938c-1.227-.41-3.109-.819-5.646-1.146a58.814 58.814 0 0 0-7.446-.49c-5.155 0-9.738 1.145-13.829 3.354-4.091 2.209-7.282 5.236-9.655 9.164-2.373 3.927-3.519 8.427-3.519 13.5v70.448h-28.312ZM222.077 39.192l-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z"
|
||||
/>
|
||||
<path
|
||||
fill="url(#c)"
|
||||
d="M388.676 191.625h30.849L363.31 31.828h-35.758l-56.215 159.797h30.848l13.174-39.356h60.061l13.256 39.356Zm-65.461-62.675 21.602-64.311h1.227l21.602 64.311h-44.431Zm126.831-7.527v70.202h-28.23V71.839h27.002v20.374h1.392c2.782-6.71 7.2-12.028 13.255-15.956 6.056-3.927 13.584-5.89 22.503-5.89 8.264 0 15.465 1.8 21.684 5.318 6.137 3.518 10.964 8.673 14.319 15.382 3.437 6.71 5.074 14.81 4.992 24.383v76.175h-28.23v-71.92c0-8.019-2.046-14.237-6.219-18.819-4.173-4.5-9.819-6.791-17.102-6.791-4.91 0-9.328 1.063-13.174 3.272-3.846 2.128-6.792 5.237-9.001 9.328-2.046 4.009-3.191 8.918-3.191 14.728ZM589.233 239c-10.147 0-18.82-1.391-26.103-4.091-7.282-2.7-13.092-6.382-17.511-10.964-4.418-4.582-7.528-9.655-9.164-15.219l25.448-6.136c1.145 2.372 2.782 4.663 4.991 6.954 2.209 2.291 5.155 4.255 8.837 5.81 3.683 1.554 8.428 2.291 14.074 2.291 8.019 0 14.647-1.964 19.884-5.81 5.237-3.845 7.856-10.227 7.856-19.064v-22.665h-1.391c-1.473 2.946-3.601 5.892-6.383 9.001-2.782 3.109-6.464 5.645-10.965 7.691-4.582 2.046-10.228 3.109-17.101 3.109-9.165 0-17.511-2.209-25.039-6.545-7.446-4.337-13.42-10.883-17.757-19.474-4.418-8.673-6.628-19.473-6.628-32.565 0-13.091 2.21-24.301 6.628-33.383 4.419-9.082 10.311-15.955 17.839-20.7 7.528-4.746 15.874-7.037 25.039-7.037 7.037 0 12.846 1.145 17.347 3.518 4.582 2.373 8.182 5.236 10.883 8.51 2.7 3.272 4.746 6.382 6.137 9.327h1.554v-19.8h27.821v121.749c0 10.228-2.454 18.737-7.364 25.447-4.91 6.709-11.538 11.7-20.048 15.055-8.509 3.355-18.165 4.991-28.884 4.991Zm.245-71.266c5.974 0 11.047-1.473 15.302-4.337 4.173-2.945 7.446-7.118 9.573-12.519 2.21-5.482 3.274-12.027 3.274-19.637 0-7.609-1.064-14.155-3.274-19.8-2.127-5.646-5.318-10.064-9.491-13.255-4.174-3.11-9.329-4.746-15.384-4.746s-11.537 1.636-15.792 4.91c-4.173 3.272-7.365 7.772-9.492 13.418-2.128 5.727-3.191 12.191-3.191 19.392 0 7.2 1.063 13.745 3.273 19.228 2.127 5.482 5.318 9.736 9.573 12.764 4.174 3.027 9.41 4.582 15.629 4.582Zm141.56-26.51V71.839h28.23v119.786h-27.412v-21.273h-1.227c-2.7 6.709-7.119 12.191-13.338 16.446-6.137 4.255-13.747 6.382-22.748 6.382-7.855 0-14.81-1.718-20.783-5.237-5.974-3.518-10.72-8.591-14.075-15.382-3.355-6.709-5.073-14.891-5.073-24.464V71.839h28.312v71.921c0 7.609 2.046 13.664 6.219 18.083 4.173 4.5 9.655 6.709 16.365 6.709 4.173 0 8.183-.982 12.111-3.028 3.927-2.045 7.118-5.072 9.655-9.082 2.537-4.091 3.764-9.164 3.764-15.218Zm65.707-109.395v159.796h-28.23V31.828h28.23Zm44.841 162.169c-7.61 0-14.402-1.391-20.457-4.091-6.055-2.7-10.883-6.791-14.32-12.109-3.518-5.319-5.237-11.946-5.237-19.801 0-6.791 1.228-12.355 3.765-16.773 2.536-4.419 5.891-7.937 10.228-10.637 4.337-2.618 9.164-4.664 14.647-6.055 5.4-1.391 11.046-2.373 16.856-3.027 7.037-.737 12.683-1.391 17.102-1.964 4.337-.573 7.528-1.555 9.574-2.782 1.963-1.309 3.027-3.273 3.027-5.973v-.491c0-5.891-1.718-10.391-5.237-13.664-3.518-3.191-8.51-4.828-15.056-4.828-6.955 0-12.356 1.473-16.447 4.5-4.009 3.028-6.71 6.546-8.183 10.719l-26.348-3.764c2.046-7.282 5.483-13.336 10.31-18.328 4.746-4.909 10.638-8.59 17.511-11.045 6.955-2.455 14.565-3.682 22.912-3.682 5.809 0 11.537.654 17.265 2.045s10.965 3.6 15.711 6.71c4.746 3.109 8.51 7.282 11.455 12.6 2.864 5.318 4.337 11.946 4.337 19.883v80.184h-27.166v-16.446h-.9c-1.719 3.355-4.092 6.464-7.201 9.328-3.109 2.864-6.955 5.237-11.619 6.955-4.828 1.718-10.229 2.536-16.529 2.536Zm7.364-20.701c5.646 0 10.556-1.145 14.729-3.354 4.173-2.291 7.364-5.237 9.655-9.001 2.292-3.763 3.355-7.854 3.355-12.273v-14.155c-.9.737-2.373 1.391-4.5 2.046-2.128.654-4.419 1.145-7.037 1.636-2.619.491-5.155.9-7.692 1.227-2.537.328-4.746.655-6.628.901-4.173.572-8.019 1.472-11.292 2.781-3.355 1.31-5.973 3.11-7.855 5.401-1.964 2.291-2.864 5.318-2.864 8.918 0 5.237 1.882 9.164 5.728 11.782 3.682 2.782 8.51 4.091 14.401 4.091Zm64.643 18.328V71.839h27.412v19.965h1.227c2.21-6.955 5.974-12.274 11.292-16.038 5.319-3.763 11.456-5.645 18.329-5.645 1.555 0 3.355.082 5.237.163 1.964.164 3.601.328 4.91.573v25.938c-1.227-.41-3.109-.819-5.646-1.146a58.814 58.814 0 0 0-7.446-.49c-5.155 0-9.738 1.145-13.829 3.354-4.091 2.209-7.282 5.236-9.655 9.164-2.373 3.927-3.519 8.427-3.519 13.5v70.448h-28.312ZM222.077 39.192l-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<radialGradient
|
||||
id="c"
|
||||
cx="0"
|
||||
cy="0"
|
||||
r="1"
|
||||
gradientTransform="rotate(118.122 171.182 60.81) scale(205.794)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="#FF41F8" />
|
||||
<stop offset=".707" stop-color="#FF41F8" stop-opacity=".5" />
|
||||
<stop offset="1" stop-color="#FF41F8" stop-opacity="0" />
|
||||
</radialGradient>
|
||||
<linearGradient
|
||||
id="b"
|
||||
x1="0"
|
||||
x2="982"
|
||||
y1="192"
|
||||
y2="192"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="#F0060B" />
|
||||
<stop offset="0" stop-color="#F0070C" />
|
||||
<stop offset=".526" stop-color="#CC26D5" />
|
||||
<stop offset="1" stop-color="#7702FF" />
|
||||
</linearGradient>
|
||||
<clipPath id="a"><path fill="#fff" d="M0 0h982v239H0z" /></clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
<h1>Hello, {{ title() }}</h1>
|
||||
<p>Congratulations! Your app is running. 🎉</p>
|
||||
</div>
|
||||
<div class="divider" role="separator" aria-label="Divider"></div>
|
||||
<div class="right-side">
|
||||
<div class="pill-group">
|
||||
@for (item of [
|
||||
{ title: 'Explore the Docs', link: 'https://angular.dev' },
|
||||
{ title: 'Learn with Tutorials', link: 'https://angular.dev/tutorials' },
|
||||
{ title: 'Prompt and best practices for AI', link: 'https://angular.dev/ai/develop-with-ai'},
|
||||
{ title: 'CLI Docs', link: 'https://angular.dev/tools/cli' },
|
||||
{ title: 'Angular Language Service', link: 'https://angular.dev/tools/language-service' },
|
||||
{ title: 'Angular DevTools', link: 'https://angular.dev/tools/devtools' },
|
||||
]; track item.title) {
|
||||
<a
|
||||
class="pill"
|
||||
[href]="item.link"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<span>{{ item.title }}</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height="14"
|
||||
viewBox="0 -960 960 960"
|
||||
width="14"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h280v80H200v560h560v-280h80v280q0 33-23.5 56.5T760-120H200Zm188-212-56-56 372-372H560v-80h280v280h-80v-144L388-332Z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
<div class="social-links">
|
||||
<a
|
||||
href="https://github.com/angular/angular"
|
||||
aria-label="Github"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<svg
|
||||
width="25"
|
||||
height="24"
|
||||
viewBox="0 0 25 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
alt="Github"
|
||||
>
|
||||
<path
|
||||
d="M12.3047 0C5.50634 0 0 5.50942 0 12.3047C0 17.7423 3.52529 22.3535 8.41332 23.9787C9.02856 24.0946 9.25414 23.7142 9.25414 23.3871C9.25414 23.0949 9.24389 22.3207 9.23876 21.2953C5.81601 22.0377 5.09414 19.6444 5.09414 19.6444C4.53427 18.2243 3.72524 17.8449 3.72524 17.8449C2.61064 17.082 3.81137 17.0973 3.81137 17.0973C5.04697 17.1835 5.69604 18.3647 5.69604 18.3647C6.79321 20.2463 8.57636 19.7029 9.27978 19.3881C9.39052 18.5924 9.70736 18.0499 10.0591 17.7423C7.32641 17.4347 4.45429 16.3765 4.45429 11.6618C4.45429 10.3185 4.9311 9.22133 5.72065 8.36C5.58222 8.04931 5.16694 6.79833 5.82831 5.10337C5.82831 5.10337 6.85883 4.77319 9.2121 6.36459C10.1965 6.09082 11.2424 5.95546 12.2883 5.94931C13.3342 5.95546 14.3801 6.09082 15.3644 6.36459C17.7023 4.77319 18.7328 5.10337 18.7328 5.10337C19.3942 6.79833 18.9789 8.04931 18.8559 8.36C19.6403 9.22133 20.1171 10.3185 20.1171 11.6618C20.1171 16.3888 17.2409 17.4296 14.5031 17.7321C14.9338 18.1012 15.3337 18.8559 15.3337 20.0084C15.3337 21.6552 15.3183 22.978 15.3183 23.3779C15.3183 23.7009 15.5336 24.0854 16.1642 23.9623C21.0871 22.3484 24.6094 17.7341 24.6094 12.3047C24.6094 5.50942 19.0999 0 12.3047 0Z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="https://x.com/angular"
|
||||
aria-label="X"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
alt="X"
|
||||
>
|
||||
<path
|
||||
d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="https://www.youtube.com/channel/UCbn1OgGei-DV7aSRo_HaAiw"
|
||||
aria-label="Youtube"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<svg
|
||||
width="29"
|
||||
height="20"
|
||||
viewBox="0 0 29 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
alt="Youtube"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M27.4896 1.52422C27.9301 1.96749 28.2463 2.51866 28.4068 3.12258C29.0004 5.35161 29.0004 10 29.0004 10C29.0004 10 29.0004 14.6484 28.4068 16.8774C28.2463 17.4813 27.9301 18.0325 27.4896 18.4758C27.0492 18.9191 26.5 19.2389 25.8972 19.4032C23.6778 20 14.8068 20 14.8068 20C14.8068 20 5.93586 20 3.71651 19.4032C3.11363 19.2389 2.56449 18.9191 2.12405 18.4758C1.68361 18.0325 1.36732 17.4813 1.20683 16.8774C0.613281 14.6484 0.613281 10 0.613281 10C0.613281 10 0.613281 5.35161 1.20683 3.12258C1.36732 2.51866 1.68361 1.96749 2.12405 1.52422C2.56449 1.08095 3.11363 0.76113 3.71651 0.596774C5.93586 0 14.8068 0 14.8068 0C14.8068 0 23.6778 0 25.8972 0.596774C26.5 0.76113 27.0492 1.08095 27.4896 1.52422ZM19.3229 10L11.9036 5.77905V14.221L19.3229 10Z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * * The content above * * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * End of Placeholder * * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
||||
|
||||
|
||||
<router-outlet />
|
||||
72
frontend/src/app/app.routes.ts
Normal file
72
frontend/src/app/app.routes.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { authGuard, guestGuard } from './core/guards';
|
||||
import { MainLayoutComponent } from './layouts/main-layout/main-layout.component';
|
||||
import { AuthLayoutComponent } from './layouts/auth-layout/auth-layout.component';
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
redirectTo: 'dashboard',
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: '',
|
||||
component: AuthLayoutComponent,
|
||||
canActivate: [guestGuard],
|
||||
children: [
|
||||
{
|
||||
path: 'login',
|
||||
loadChildren: () => import('./features/auth/auth.routes').then((m) => m.AUTH_ROUTES),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '',
|
||||
component: MainLayoutComponent,
|
||||
canActivate: [authGuard],
|
||||
children: [
|
||||
{
|
||||
path: 'dashboard',
|
||||
loadChildren: () =>
|
||||
import('./features/dashboard/dashboard.routes').then((m) => m.DASHBOARD_ROUTES),
|
||||
},
|
||||
{
|
||||
path: 'requests',
|
||||
loadChildren: () =>
|
||||
import('./features/requests/requests.routes').then((m) => m.REQUESTS_ROUTES),
|
||||
},
|
||||
{
|
||||
path: 'approvals',
|
||||
loadChildren: () =>
|
||||
import('./features/approvals/approvals.routes').then((m) => m.APPROVALS_ROUTES),
|
||||
},
|
||||
{
|
||||
path: 'departments',
|
||||
loadChildren: () =>
|
||||
import('./features/departments/departments.routes').then((m) => m.DEPARTMENTS_ROUTES),
|
||||
},
|
||||
{
|
||||
path: 'workflows',
|
||||
loadChildren: () =>
|
||||
import('./features/workflows/workflows.routes').then((m) => m.WORKFLOWS_ROUTES),
|
||||
},
|
||||
{
|
||||
path: 'webhooks',
|
||||
loadChildren: () =>
|
||||
import('./features/webhooks/webhooks.routes').then((m) => m.WEBHOOKS_ROUTES),
|
||||
},
|
||||
{
|
||||
path: 'audit',
|
||||
loadChildren: () => import('./features/audit/audit.routes').then((m) => m.AUDIT_ROUTES),
|
||||
},
|
||||
{
|
||||
path: 'admin',
|
||||
loadComponent: () => import('./features/admin/admin.component').then((m) => m.AdminComponent),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '**',
|
||||
redirectTo: 'dashboard',
|
||||
},
|
||||
];
|
||||
0
frontend/src/app/app.scss
Normal file
0
frontend/src/app/app.scss
Normal file
23
frontend/src/app/app.spec.ts
Normal file
23
frontend/src/app/app.spec.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { App } from './app';
|
||||
|
||||
describe('App', () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [App],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
it('should create the app', () => {
|
||||
const fixture = TestBed.createComponent(App);
|
||||
const app = fixture.componentInstance;
|
||||
expect(app).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render router outlet', async () => {
|
||||
const fixture = TestBed.createComponent(App);
|
||||
await fixture.whenStable();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
expect(compiled.querySelector('router-outlet')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
11
frontend/src/app/app.ts
Normal file
11
frontend/src/app/app.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
standalone: true,
|
||||
imports: [RouterOutlet],
|
||||
template: '<router-outlet></router-outlet>',
|
||||
styles: [],
|
||||
})
|
||||
export class App {}
|
||||
27
frontend/src/app/core/guards/auth.guard.ts
Normal file
27
frontend/src/app/core/guards/auth.guard.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { Router, CanActivateFn } from '@angular/router';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
|
||||
export const authGuard: CanActivateFn = (route, state) => {
|
||||
const authService = inject(AuthService);
|
||||
const router = inject(Router);
|
||||
|
||||
if (authService.isAuthenticated()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
router.navigate(['/login'], { queryParams: { returnUrl: state.url } });
|
||||
return false;
|
||||
};
|
||||
|
||||
export const guestGuard: CanActivateFn = (route, state) => {
|
||||
const authService = inject(AuthService);
|
||||
const router = inject(Router);
|
||||
|
||||
if (!authService.isAuthenticated()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
router.navigate(['/dashboard']);
|
||||
return false;
|
||||
};
|
||||
2
frontend/src/app/core/guards/index.ts
Normal file
2
frontend/src/app/core/guards/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './auth.guard';
|
||||
export * from './role.guard';
|
||||
66
frontend/src/app/core/guards/role.guard.ts
Normal file
66
frontend/src/app/core/guards/role.guard.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { Router, CanActivateFn } from '@angular/router';
|
||||
import { AuthService, UserType } from '../services/auth.service';
|
||||
import { NotificationService } from '../services/notification.service';
|
||||
|
||||
export const roleGuard: CanActivateFn = (route, state) => {
|
||||
const authService = inject(AuthService);
|
||||
const router = inject(Router);
|
||||
const notification = inject(NotificationService);
|
||||
|
||||
const requiredRoles = route.data['roles'] as UserType[] | undefined;
|
||||
|
||||
if (!requiredRoles || requiredRoles.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (authService.hasAnyRole(requiredRoles)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
notification.error('You do not have permission to access this page.');
|
||||
router.navigate(['/dashboard']);
|
||||
return false;
|
||||
};
|
||||
|
||||
export const departmentGuard: CanActivateFn = (route, state) => {
|
||||
const authService = inject(AuthService);
|
||||
const router = inject(Router);
|
||||
const notification = inject(NotificationService);
|
||||
|
||||
if (authService.isDepartment()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
notification.error('This page is only accessible to department users.');
|
||||
router.navigate(['/dashboard']);
|
||||
return false;
|
||||
};
|
||||
|
||||
export const applicantGuard: CanActivateFn = (route, state) => {
|
||||
const authService = inject(AuthService);
|
||||
const router = inject(Router);
|
||||
const notification = inject(NotificationService);
|
||||
|
||||
if (authService.isApplicant()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
notification.error('This page is only accessible to applicants.');
|
||||
router.navigate(['/dashboard']);
|
||||
return false;
|
||||
};
|
||||
|
||||
export const adminGuard: CanActivateFn = (route, state) => {
|
||||
const authService = inject(AuthService);
|
||||
const router = inject(Router);
|
||||
const notification = inject(NotificationService);
|
||||
|
||||
if (authService.isAdmin()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
notification.error('This page is only accessible to administrators.');
|
||||
router.navigate(['/dashboard']);
|
||||
return false;
|
||||
};
|
||||
19
frontend/src/app/core/interceptors/auth.interceptor.ts
Normal file
19
frontend/src/app/core/interceptors/auth.interceptor.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { HttpInterceptorFn } from '@angular/common/http';
|
||||
import { inject } from '@angular/core';
|
||||
import { StorageService } from '../services/storage.service';
|
||||
|
||||
export const authInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
const storage = inject(StorageService);
|
||||
const token = storage.getToken();
|
||||
|
||||
if (token) {
|
||||
const clonedReq = req.clone({
|
||||
setHeaders: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
return next(clonedReq);
|
||||
}
|
||||
|
||||
return next(req);
|
||||
};
|
||||
49
frontend/src/app/core/interceptors/error.interceptor.ts
Normal file
49
frontend/src/app/core/interceptors/error.interceptor.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
|
||||
import { inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { catchError, throwError } from 'rxjs';
|
||||
import { StorageService } from '../services/storage.service';
|
||||
import { NotificationService } from '../services/notification.service';
|
||||
|
||||
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
const router = inject(Router);
|
||||
const storage = inject(StorageService);
|
||||
const notification = inject(NotificationService);
|
||||
|
||||
return next(req).pipe(
|
||||
catchError((error: HttpErrorResponse) => {
|
||||
let errorMessage = 'An unexpected error occurred';
|
||||
|
||||
if (error.error instanceof ErrorEvent) {
|
||||
// Client-side error
|
||||
errorMessage = error.error.message;
|
||||
} else {
|
||||
// Server-side error
|
||||
switch (error.status) {
|
||||
case 401:
|
||||
errorMessage = 'Session expired. Please login again.';
|
||||
storage.clear();
|
||||
router.navigate(['/login']);
|
||||
break;
|
||||
case 403:
|
||||
errorMessage = 'You do not have permission to perform this action.';
|
||||
break;
|
||||
case 404:
|
||||
errorMessage = 'Resource not found.';
|
||||
break;
|
||||
case 422:
|
||||
errorMessage = error.error?.message || 'Validation error.';
|
||||
break;
|
||||
case 500:
|
||||
errorMessage = 'Internal server error. Please try again later.';
|
||||
break;
|
||||
default:
|
||||
errorMessage = error.error?.message || `Error: ${error.status}`;
|
||||
}
|
||||
}
|
||||
|
||||
notification.error(errorMessage);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
};
|
||||
2
frontend/src/app/core/interceptors/index.ts
Normal file
2
frontend/src/app/core/interceptors/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './auth.interceptor';
|
||||
export * from './error.interceptor';
|
||||
150
frontend/src/app/core/services/api.service.ts
Normal file
150
frontend/src/app/core/services/api.service.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient, HttpParams, HttpHeaders, HttpEvent, HttpEventType, HttpRequest } from '@angular/common/http';
|
||||
import { Observable, map, filter } from 'rxjs';
|
||||
import { environment } from '../../../environments/environment';
|
||||
|
||||
export interface UploadProgress<T> {
|
||||
progress: number;
|
||||
loaded: number;
|
||||
total: number;
|
||||
complete: boolean;
|
||||
response?: T;
|
||||
}
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
timestamp: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
hasNextPage: boolean;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ApiService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly baseUrl = environment.apiBaseUrl;
|
||||
|
||||
get<T>(path: string, params?: Record<string, string | number | boolean>): Observable<T> {
|
||||
let httpParams = new HttpParams();
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
httpParams = httpParams.set(key, String(value));
|
||||
}
|
||||
});
|
||||
}
|
||||
return this.http
|
||||
.get<ApiResponse<T>>(`${this.baseUrl}${path}`, { params: httpParams })
|
||||
.pipe(map((response) => response.data));
|
||||
}
|
||||
|
||||
getRaw<T>(path: string, params?: Record<string, string | number | boolean>): Observable<T> {
|
||||
let httpParams = new HttpParams();
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
httpParams = httpParams.set(key, String(value));
|
||||
}
|
||||
});
|
||||
}
|
||||
return this.http.get<T>(`${this.baseUrl}${path}`, { params: httpParams });
|
||||
}
|
||||
|
||||
post<T>(path: string, body: unknown): Observable<T> {
|
||||
return this.http
|
||||
.post<ApiResponse<T>>(`${this.baseUrl}${path}`, body)
|
||||
.pipe(map((response) => response.data));
|
||||
}
|
||||
|
||||
postRaw<T>(path: string, body: unknown): Observable<T> {
|
||||
return this.http.post<T>(`${this.baseUrl}${path}`, body);
|
||||
}
|
||||
|
||||
put<T>(path: string, body: unknown): Observable<T> {
|
||||
return this.http
|
||||
.put<ApiResponse<T>>(`${this.baseUrl}${path}`, body)
|
||||
.pipe(map((response) => response.data));
|
||||
}
|
||||
|
||||
patch<T>(path: string, body: unknown): Observable<T> {
|
||||
return this.http
|
||||
.patch<ApiResponse<T>>(`${this.baseUrl}${path}`, body)
|
||||
.pipe(map((response) => response.data));
|
||||
}
|
||||
|
||||
delete<T>(path: string): Observable<T> {
|
||||
return this.http
|
||||
.delete<ApiResponse<T>>(`${this.baseUrl}${path}`)
|
||||
.pipe(map((response) => response.data));
|
||||
}
|
||||
|
||||
upload<T>(path: string, formData: FormData): Observable<T> {
|
||||
return this.http
|
||||
.post<ApiResponse<T>>(`${this.baseUrl}${path}`, formData)
|
||||
.pipe(map((response) => response.data));
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload with progress tracking
|
||||
* Returns an observable that emits upload progress and final response
|
||||
*/
|
||||
uploadWithProgress<T>(path: string, formData: FormData): Observable<UploadProgress<T>> {
|
||||
const req = new HttpRequest('POST', `${this.baseUrl}${path}`, formData, {
|
||||
reportProgress: true,
|
||||
});
|
||||
|
||||
return this.http.request<ApiResponse<T>>(req).pipe(
|
||||
map((event: HttpEvent<ApiResponse<T>>) => {
|
||||
switch (event.type) {
|
||||
case HttpEventType.UploadProgress:
|
||||
const total = event.total || 0;
|
||||
const loaded = event.loaded;
|
||||
const progress = total > 0 ? Math.round((loaded / total) * 100) : 0;
|
||||
return {
|
||||
progress,
|
||||
loaded,
|
||||
total,
|
||||
complete: false,
|
||||
} as UploadProgress<T>;
|
||||
|
||||
case HttpEventType.Response:
|
||||
return {
|
||||
progress: 100,
|
||||
loaded: event.body?.data ? 1 : 0,
|
||||
total: 1,
|
||||
complete: true,
|
||||
response: event.body?.data,
|
||||
} as UploadProgress<T>;
|
||||
|
||||
default:
|
||||
return {
|
||||
progress: 0,
|
||||
loaded: 0,
|
||||
total: 0,
|
||||
complete: false,
|
||||
} as UploadProgress<T>;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
download(path: string): Observable<Blob> {
|
||||
return this.http.get(`${this.baseUrl}${path}`, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
}
|
||||
|
||||
getBlob(url: string): Observable<Blob> {
|
||||
return this.http.get(url, { responseType: 'blob' });
|
||||
}
|
||||
}
|
||||
151
frontend/src/app/core/services/auth.service.ts
Normal file
151
frontend/src/app/core/services/auth.service.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { Injectable, inject, signal, computed } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { Observable, tap, BehaviorSubject } from 'rxjs';
|
||||
import { ApiService } from './api.service';
|
||||
import { StorageService } from './storage.service';
|
||||
import {
|
||||
LoginDto,
|
||||
LoginResponseDto,
|
||||
DigiLockerLoginDto,
|
||||
DigiLockerLoginResponseDto,
|
||||
CurrentUserDto,
|
||||
DepartmentResponseDto,
|
||||
} from '../../api/models';
|
||||
|
||||
export type UserType = 'APPLICANT' | 'DEPARTMENT' | 'ADMIN';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class AuthService {
|
||||
private readonly api = inject(ApiService);
|
||||
private readonly storage = inject(StorageService);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
private readonly currentUserSubject = new BehaviorSubject<CurrentUserDto | null>(null);
|
||||
readonly currentUser$ = this.currentUserSubject.asObservable();
|
||||
|
||||
private readonly _currentUser = signal<CurrentUserDto | null>(null);
|
||||
readonly currentUser = this._currentUser.asReadonly();
|
||||
|
||||
private readonly _isAuthenticated = signal(false);
|
||||
readonly isAuthenticated = this._isAuthenticated.asReadonly();
|
||||
|
||||
private readonly _userType = signal<UserType | null>(null);
|
||||
readonly userType = this._userType.asReadonly();
|
||||
|
||||
readonly isDepartment = computed(() => this._userType() === 'DEPARTMENT');
|
||||
readonly isApplicant = computed(() => this._userType() === 'APPLICANT');
|
||||
readonly isAdmin = computed(() => this._userType() === 'ADMIN');
|
||||
|
||||
constructor() {
|
||||
this.loadStoredUser();
|
||||
}
|
||||
|
||||
private loadStoredUser(): void {
|
||||
const token = this.storage.getToken();
|
||||
const user = this.storage.getUser<CurrentUserDto>();
|
||||
|
||||
if (token && user) {
|
||||
this.currentUserSubject.next(user);
|
||||
this._currentUser.set(user);
|
||||
this._isAuthenticated.set(true);
|
||||
this._userType.set(user.type);
|
||||
}
|
||||
}
|
||||
|
||||
async login(email: string, password: string): Promise<void> {
|
||||
const response = await this.api.postRaw<any>('/auth/login', { email, password }).toPromise();
|
||||
|
||||
if (!response) {
|
||||
throw new Error('Login failed');
|
||||
}
|
||||
|
||||
this.storage.setToken(response.accessToken);
|
||||
|
||||
const userType: UserType =
|
||||
response.user.role === 'ADMIN' ? 'ADMIN' :
|
||||
response.user.role === 'DEPARTMENT' ? 'DEPARTMENT' : 'APPLICANT';
|
||||
|
||||
const user: CurrentUserDto = {
|
||||
id: response.user.id,
|
||||
type: userType,
|
||||
name: response.user.name,
|
||||
email: response.user.email,
|
||||
departmentId: response.user.departmentId,
|
||||
walletAddress: response.user.walletAddress,
|
||||
};
|
||||
|
||||
this.storage.setUser(user);
|
||||
this.currentUserSubject.next(user);
|
||||
this._currentUser.set(user);
|
||||
this._isAuthenticated.set(true);
|
||||
this._userType.set(userType);
|
||||
}
|
||||
|
||||
departmentLogin(dto: LoginDto): Observable<LoginResponseDto> {
|
||||
return this.api.postRaw<LoginResponseDto>('/auth/department/login', dto).pipe(
|
||||
tap((response) => {
|
||||
this.storage.setToken(response.accessToken);
|
||||
const user: CurrentUserDto = {
|
||||
id: response.department.id,
|
||||
type: 'DEPARTMENT',
|
||||
name: response.department.name,
|
||||
email: response.department.contactEmail || '',
|
||||
departmentCode: response.department.code,
|
||||
};
|
||||
this.storage.setUser(user);
|
||||
this.currentUserSubject.next(user);
|
||||
this._currentUser.set(user);
|
||||
this._isAuthenticated.set(true);
|
||||
this._userType.set('DEPARTMENT');
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
digiLockerLogin(dto: DigiLockerLoginDto): Observable<DigiLockerLoginResponseDto> {
|
||||
return this.api.postRaw<DigiLockerLoginResponseDto>('/auth/digilocker/login', dto).pipe(
|
||||
tap((response) => {
|
||||
this.storage.setToken(response.accessToken);
|
||||
const user: CurrentUserDto = {
|
||||
id: response.applicant.id,
|
||||
type: 'APPLICANT',
|
||||
name: response.applicant.name,
|
||||
email: response.applicant.email || '',
|
||||
digilockerId: response.applicant.digilockerId,
|
||||
};
|
||||
this.storage.setUser(user);
|
||||
this.currentUserSubject.next(user);
|
||||
this._currentUser.set(user);
|
||||
this._isAuthenticated.set(true);
|
||||
this._userType.set('APPLICANT');
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
logout(): void {
|
||||
this.storage.clear();
|
||||
this.currentUserSubject.next(null);
|
||||
this._currentUser.set(null);
|
||||
this._isAuthenticated.set(false);
|
||||
this._userType.set(null);
|
||||
this.router.navigate(['/login']);
|
||||
}
|
||||
|
||||
getCurrentUser(): CurrentUserDto | null {
|
||||
return this.currentUserSubject.value;
|
||||
}
|
||||
|
||||
getToken(): string | null {
|
||||
return this.storage.getToken();
|
||||
}
|
||||
|
||||
hasRole(role: UserType): boolean {
|
||||
return this._userType() === role;
|
||||
}
|
||||
|
||||
hasAnyRole(roles: UserType[]): boolean {
|
||||
const currentType = this._userType();
|
||||
return currentType !== null && roles.includes(currentType);
|
||||
}
|
||||
}
|
||||
4
frontend/src/app/core/services/index.ts
Normal file
4
frontend/src/app/core/services/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './storage.service';
|
||||
export * from './api.service';
|
||||
export * from './auth.service';
|
||||
export * from './notification.service';
|
||||
40
frontend/src/app/core/services/notification.service.ts
Normal file
40
frontend/src/app/core/services/notification.service.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { MatSnackBar, MatSnackBarConfig } from '@angular/material/snack-bar';
|
||||
|
||||
export type NotificationType = 'success' | 'error' | 'warning' | 'info';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class NotificationService {
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
|
||||
private readonly defaultConfig: MatSnackBarConfig = {
|
||||
duration: 4000,
|
||||
horizontalPosition: 'end',
|
||||
verticalPosition: 'top',
|
||||
};
|
||||
|
||||
success(message: string, action = 'Close'): void {
|
||||
this.show(message, action, 'success');
|
||||
}
|
||||
|
||||
error(message: string, action = 'Close'): void {
|
||||
this.show(message, action, 'error');
|
||||
}
|
||||
|
||||
warning(message: string, action = 'Close'): void {
|
||||
this.show(message, action, 'warning');
|
||||
}
|
||||
|
||||
info(message: string, action = 'Close'): void {
|
||||
this.show(message, action, 'info');
|
||||
}
|
||||
|
||||
private show(message: string, action: string, type: NotificationType): void {
|
||||
this.snackBar.open(message, action, {
|
||||
...this.defaultConfig,
|
||||
panelClass: [`snackbar-${type}`],
|
||||
});
|
||||
}
|
||||
}
|
||||
81
frontend/src/app/core/services/storage.service.ts
Normal file
81
frontend/src/app/core/services/storage.service.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { environment } from '../../../environments/environment';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class StorageService {
|
||||
getToken(): string | null {
|
||||
return localStorage.getItem(environment.tokenStorageKey);
|
||||
}
|
||||
|
||||
setToken(token: string): void {
|
||||
localStorage.setItem(environment.tokenStorageKey, token);
|
||||
}
|
||||
|
||||
removeToken(): void {
|
||||
localStorage.removeItem(environment.tokenStorageKey);
|
||||
}
|
||||
|
||||
getRefreshToken(): string | null {
|
||||
return localStorage.getItem(environment.refreshTokenStorageKey);
|
||||
}
|
||||
|
||||
setRefreshToken(token: string): void {
|
||||
localStorage.setItem(environment.refreshTokenStorageKey, token);
|
||||
}
|
||||
|
||||
removeRefreshToken(): void {
|
||||
localStorage.removeItem(environment.refreshTokenStorageKey);
|
||||
}
|
||||
|
||||
getUser<T>(): T | null {
|
||||
const user = localStorage.getItem(environment.userStorageKey);
|
||||
if (user) {
|
||||
try {
|
||||
return JSON.parse(user) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
setUser<T>(user: T): void {
|
||||
localStorage.setItem(environment.userStorageKey, JSON.stringify(user));
|
||||
}
|
||||
|
||||
removeUser(): void {
|
||||
localStorage.removeItem(environment.userStorageKey);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.removeToken();
|
||||
this.removeRefreshToken();
|
||||
this.removeUser();
|
||||
}
|
||||
|
||||
get<T>(key: string): T | null {
|
||||
const item = localStorage.getItem(key);
|
||||
if (item) {
|
||||
try {
|
||||
return JSON.parse(item) as T;
|
||||
} catch {
|
||||
return item as unknown as T;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
set(key: string, value: unknown): void {
|
||||
if (typeof value === 'string') {
|
||||
localStorage.setItem(key, value);
|
||||
} else {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
}
|
||||
}
|
||||
|
||||
remove(key: string): void {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { ApiService } from '../../../core/services/api.service';
|
||||
|
||||
interface PlatformStats {
|
||||
totalRequests: number;
|
||||
totalApplicants: number;
|
||||
activeApplicants: number;
|
||||
totalDepartments: number;
|
||||
activeDepartments: number;
|
||||
totalDocuments: number;
|
||||
totalBlockchainTransactions: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-stats',
|
||||
standalone: true,
|
||||
imports: [CommonModule, MatCardModule, MatIconModule, MatProgressSpinnerModule],
|
||||
template: `
|
||||
<div class="stats-grid" *ngIf="!loading; else loadingTemplate">
|
||||
<mat-card class="stat-card primary">
|
||||
<div class="stat-icon-wrapper">
|
||||
<mat-icon class="stat-icon">description</mat-icon>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stats?.totalRequests || 0 }}</div>
|
||||
<div class="stat-label">Total Requests</div>
|
||||
</div>
|
||||
</mat-card>
|
||||
|
||||
<mat-card class="stat-card success">
|
||||
<div class="stat-icon-wrapper">
|
||||
<mat-icon class="stat-icon">business</mat-icon>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stats?.activeDepartments || 0 }} / {{ stats?.totalDepartments || 0 }}</div>
|
||||
<div class="stat-label">Active Departments</div>
|
||||
</div>
|
||||
</mat-card>
|
||||
|
||||
<mat-card class="stat-card info">
|
||||
<div class="stat-icon-wrapper">
|
||||
<mat-icon class="stat-icon">people</mat-icon>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stats?.activeApplicants || 0 }} / {{ stats?.totalApplicants || 0 }}</div>
|
||||
<div class="stat-label">Active Applicants</div>
|
||||
</div>
|
||||
</mat-card>
|
||||
|
||||
<mat-card class="stat-card warning">
|
||||
<div class="stat-icon-wrapper">
|
||||
<mat-icon class="stat-icon">receipt_long</mat-icon>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stats?.totalBlockchainTransactions || 0 }}</div>
|
||||
<div class="stat-label">Blockchain Transactions</div>
|
||||
</div>
|
||||
</mat-card>
|
||||
|
||||
<mat-card class="stat-card secondary">
|
||||
<div class="stat-icon-wrapper">
|
||||
<mat-icon class="stat-icon">folder</mat-icon>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stats?.totalDocuments || 0 }}</div>
|
||||
<div class="stat-label">Total Documents</div>
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
|
||||
<ng-template #loadingTemplate>
|
||||
<div class="loading-container">
|
||||
<mat-spinner diameter="40"></mat-spinner>
|
||||
<p>Loading statistics...</p>
|
||||
</div>
|
||||
</ng-template>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 24px;
|
||||
border-radius: 16px !important;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
transform: translate(30%, -30%);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--shadow-elevated);
|
||||
}
|
||||
|
||||
&.primary {
|
||||
background: linear-gradient(135deg, var(--dbim-blue-dark) 0%, var(--dbim-blue-mid) 100%);
|
||||
color: white;
|
||||
}
|
||||
&.success {
|
||||
background: linear-gradient(135deg, #059669 0%, #10b981 100%);
|
||||
color: white;
|
||||
}
|
||||
&.info {
|
||||
background: linear-gradient(135deg, var(--dbim-info) 0%, #60a5fa 100%);
|
||||
color: white;
|
||||
}
|
||||
&.warning {
|
||||
background: linear-gradient(135deg, #d97706 0%, #f59e0b 100%);
|
||||
color: white;
|
||||
}
|
||||
&.secondary {
|
||||
background: linear-gradient(135deg, var(--crypto-purple) 0%, var(--crypto-indigo) 100%);
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-icon-wrapper {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
font-size: 28px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
opacity: 0.9;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px;
|
||||
gap: 16px;
|
||||
|
||||
p {
|
||||
color: var(--dbim-grey-2);
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class AdminStatsComponent implements OnInit {
|
||||
stats: PlatformStats | null = null;
|
||||
loading = true;
|
||||
|
||||
constructor(private api: ApiService) {}
|
||||
|
||||
async ngOnInit() {
|
||||
try {
|
||||
const result = await this.api.get<PlatformStats>('/admin/stats').toPromise();
|
||||
this.stats = result || null;
|
||||
} catch (error) {
|
||||
console.error('Failed to load stats:', error);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
290
frontend/src/app/features/admin/admin.component.ts
Normal file
290
frontend/src/app/features/admin/admin.component.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDividerModule } from '@angular/material/divider';
|
||||
import { DepartmentOnboardingComponent } from './department-onboarding/department-onboarding.component';
|
||||
import { DepartmentListComponent } from './department-list/department-list.component';
|
||||
import { UserListComponent } from './user-list/user-list.component';
|
||||
import { TransactionDashboardComponent } from './transaction-dashboard/transaction-dashboard.component';
|
||||
import { EventDashboardComponent } from './event-dashboard/event-dashboard.component';
|
||||
import { LogsViewerComponent } from './logs-viewer/logs-viewer.component';
|
||||
import { AdminStatsComponent } from './admin-stats/admin-stats.component';
|
||||
import { BlockchainExplorerMiniComponent } from '../../shared/components';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterModule,
|
||||
MatTabsModule,
|
||||
MatCardModule,
|
||||
MatIconModule,
|
||||
MatButtonModule,
|
||||
MatDividerModule,
|
||||
DepartmentOnboardingComponent,
|
||||
DepartmentListComponent,
|
||||
UserListComponent,
|
||||
TransactionDashboardComponent,
|
||||
EventDashboardComponent,
|
||||
LogsViewerComponent,
|
||||
AdminStatsComponent,
|
||||
BlockchainExplorerMiniComponent,
|
||||
],
|
||||
template: `
|
||||
<div class="admin-container">
|
||||
<header class="admin-header">
|
||||
<div class="header-content">
|
||||
<div class="header-icon-container">
|
||||
<mat-icon class="header-icon">admin_panel_settings</mat-icon>
|
||||
</div>
|
||||
<div class="header-text">
|
||||
<h1>Admin Portal</h1>
|
||||
<p class="subtitle">Manage the Goa GEL Blockchain Platform</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="admin-content">
|
||||
<!-- Platform Statistics -->
|
||||
<app-admin-stats></app-admin-stats>
|
||||
|
||||
<!-- Main Tabs -->
|
||||
<mat-card class="tabs-card">
|
||||
<mat-tab-group animationDuration="300ms">
|
||||
<!-- Dashboard Tab -->
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<mat-icon class="tab-icon">dashboard</mat-icon>
|
||||
Dashboard
|
||||
</ng-template>
|
||||
<div class="tab-content">
|
||||
<div class="dashboard-grid">
|
||||
<div class="dashboard-main">
|
||||
<app-transaction-dashboard></app-transaction-dashboard>
|
||||
</div>
|
||||
<div class="dashboard-sidebar">
|
||||
<app-blockchain-explorer-mini [showViewAll]="false"></app-blockchain-explorer-mini>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<!-- Departments Tab -->
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<mat-icon class="tab-icon">business</mat-icon>
|
||||
Departments
|
||||
</ng-template>
|
||||
<div class="tab-content">
|
||||
<app-department-onboarding></app-department-onboarding>
|
||||
<mat-divider class="section-divider"></mat-divider>
|
||||
<app-department-list></app-department-list>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<!-- Users Tab -->
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<mat-icon class="tab-icon">people</mat-icon>
|
||||
Users
|
||||
</ng-template>
|
||||
<div class="tab-content">
|
||||
<app-user-list></app-user-list>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<!-- Transactions Tab -->
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<mat-icon class="tab-icon">receipt_long</mat-icon>
|
||||
Transactions
|
||||
</ng-template>
|
||||
<div class="tab-content">
|
||||
<app-transaction-dashboard></app-transaction-dashboard>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<!-- Events Tab -->
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<mat-icon class="tab-icon">event_note</mat-icon>
|
||||
Events
|
||||
</ng-template>
|
||||
<div class="tab-content">
|
||||
<app-event-dashboard></app-event-dashboard>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<!-- Logs Tab -->
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<mat-icon class="tab-icon">description</mat-icon>
|
||||
Logs
|
||||
</ng-template>
|
||||
<div class="tab-content">
|
||||
<app-logs-viewer></app-logs-viewer>
|
||||
</div>
|
||||
</mat-tab>
|
||||
</mat-tab-group>
|
||||
</mat-card>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.admin-container {
|
||||
min-height: 100vh;
|
||||
background-color: var(--dbim-linen);
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
background: linear-gradient(135deg, var(--dbim-blue-dark) 0%, var(--dbim-blue-mid) 100%);
|
||||
color: white;
|
||||
padding: 32px;
|
||||
box-shadow: var(--shadow-elevated);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.header-icon-container {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
display: flex;
|
||||
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;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 4px 0 0;
|
||||
opacity: 0.9;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
.admin-content {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.tabs-card {
|
||||
margin-top: 24px;
|
||||
border-radius: 16px !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tab-icon {
|
||||
margin-right: 8px;
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
padding: 24px;
|
||||
background: var(--dbim-white);
|
||||
}
|
||||
|
||||
.section-divider {
|
||||
margin: 32px 0;
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 400px;
|
||||
gap: 24px;
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class AdminComponent implements OnInit {
|
||||
ngOnInit(): void {
|
||||
// Initialize admin dashboard
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { ApiService } from '../../../core/services/api.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-department-list',
|
||||
standalone: true,
|
||||
imports: [CommonModule, MatTableModule, MatButtonModule, MatIconModule, MatChipsModule, MatCardModule],
|
||||
template: `
|
||||
<mat-card>
|
||||
<mat-card-header>
|
||||
<mat-card-title>Departments</mat-card-title>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<table mat-table [dataSource]="departments" class="full-width">
|
||||
<ng-container matColumnDef="name">
|
||||
<th mat-header-cell *matHeaderCellDef>Name</th>
|
||||
<td mat-cell *matCellDef="let dept">{{ dept.name }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="code">
|
||||
<th mat-header-cell *matHeaderCellDef>Code</th>
|
||||
<td mat-cell *matCellDef="let dept"><code>{{ dept.code }}</code></td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="wallet">
|
||||
<th mat-header-cell *matHeaderCellDef>Wallet</th>
|
||||
<td mat-cell *matCellDef="let dept"><code class="wallet-addr">{{ dept.walletAddress }}</code></td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="status">
|
||||
<th mat-header-cell *matHeaderCellDef>Status</th>
|
||||
<td mat-cell *matCellDef="let dept">
|
||||
<mat-chip [color]="dept.isActive ? 'primary' : 'warn'">
|
||||
{{ dept.isActive ? 'Active' : 'Inactive' }}
|
||||
</mat-chip>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef>Actions</th>
|
||||
<td mat-cell *matCellDef="let dept">
|
||||
<button mat-icon-button><mat-icon>edit</mat-icon></button>
|
||||
<button mat-icon-button><mat-icon>key</mat-icon></button>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
|
||||
</table>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
`,
|
||||
styles: [`
|
||||
.full-width { width: 100%; }
|
||||
.wallet-addr { font-size: 0.75rem; }
|
||||
`]
|
||||
})
|
||||
export class DepartmentListComponent implements OnInit {
|
||||
departments: any[] = [];
|
||||
displayedColumns = ['name', 'code', 'wallet', 'status', 'actions'];
|
||||
|
||||
constructor(private api: ApiService) {}
|
||||
|
||||
async ngOnInit() {
|
||||
try {
|
||||
const response = await this.api.get<any>('/admin/departments').toPromise();
|
||||
this.departments = response.data || [];
|
||||
} catch (error) {
|
||||
console.error('Failed to load departments', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
|
||||
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 { MatDialogModule, MatDialog } from '@angular/material/dialog';
|
||||
import { ApiService } from '../../../core/services/api.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-department-onboarding',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
MatCardModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatSnackBarModule,
|
||||
MatDialogModule,
|
||||
],
|
||||
template: `
|
||||
<mat-card class="onboarding-card">
|
||||
<mat-card-header>
|
||||
<mat-card-title>
|
||||
<mat-icon class="title-icon">add_business</mat-icon>
|
||||
Onboard New Department
|
||||
</mat-card-title>
|
||||
</mat-card-header>
|
||||
|
||||
<mat-card-content>
|
||||
<form [formGroup]="onboardingForm" (ngSubmit)="onSubmit()">
|
||||
<div class="form-grid">
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Department Code</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="code"
|
||||
placeholder="e.g., POLICE_DEPT"
|
||||
[style.text-transform]="'uppercase'"
|
||||
/>
|
||||
<mat-icon matPrefix>badge</mat-icon>
|
||||
<mat-hint>Uppercase letters and underscores only</mat-hint>
|
||||
<mat-error *ngIf="onboardingForm.get('code')?.hasError('required')">
|
||||
Department code is required
|
||||
</mat-error>
|
||||
<mat-error *ngIf="onboardingForm.get('code')?.hasError('pattern')">
|
||||
Use only uppercase letters and underscores
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Department Name</mat-label>
|
||||
<input matInput formControlName="name" placeholder="e.g., Police Department" />
|
||||
<mat-icon matPrefix>business</mat-icon>
|
||||
<mat-error *ngIf="onboardingForm.get('name')?.hasError('required')">
|
||||
Department name is required
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Contact Email</mat-label>
|
||||
<input matInput type="email" formControlName="contactEmail" placeholder="police@goa.gov.in" />
|
||||
<mat-icon matPrefix>email</mat-icon>
|
||||
<mat-error *ngIf="onboardingForm.get('contactEmail')?.hasError('required')">
|
||||
Contact email is required
|
||||
</mat-error>
|
||||
<mat-error *ngIf="onboardingForm.get('contactEmail')?.hasError('email')">
|
||||
Please enter a valid email
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Contact Phone</mat-label>
|
||||
<input matInput formControlName="contactPhone" placeholder="+91-832-6666666" />
|
||||
<mat-icon matPrefix>phone</mat-icon>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Description</mat-label>
|
||||
<textarea
|
||||
matInput
|
||||
formControlName="description"
|
||||
rows="3"
|
||||
placeholder="Brief description of the department"
|
||||
></textarea>
|
||||
<mat-icon matPrefix>description</mat-icon>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<mat-icon>info</mat-icon>
|
||||
<div>
|
||||
<strong>Auto-generated on submission:</strong>
|
||||
<ul>
|
||||
<li>Blockchain wallet with encrypted private key</li>
|
||||
<li>API key pair for department authentication</li>
|
||||
<li>Webhook secret for secure callbacks</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button mat-raised-button color="primary" type="submit" [disabled]="onboardingForm.invalid || loading">
|
||||
<mat-spinner *ngIf="loading" diameter="20" class="button-spinner"></mat-spinner>
|
||||
<mat-icon *ngIf="!loading">add_circle</mat-icon>
|
||||
<span *ngIf="!loading">Onboard Department</span>
|
||||
</button>
|
||||
<button mat-button type="button" (click)="onboardingForm.reset()" [disabled]="loading">
|
||||
<mat-icon>clear</mat-icon>
|
||||
Reset Form
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.onboarding-card {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
mat-card-header {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
mat-card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.title-icon {
|
||||
color: #1976d2;
|
||||
font-size: 32px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
grid-column: span 2;
|
||||
}
|
||||
}
|
||||
|
||||
.info-box {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background-color: #e3f2fd;
|
||||
border-radius: 8px;
|
||||
margin: 16px 0;
|
||||
|
||||
mat-icon {
|
||||
color: #1976d2;
|
||||
font-size: 24px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: #1565c0;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 8px 0 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
li {
|
||||
margin: 4px 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.button-spinner {
|
||||
display: inline-block;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class DepartmentOnboardingComponent {
|
||||
onboardingForm: FormGroup;
|
||||
loading = false;
|
||||
|
||||
constructor(private fb: FormBuilder, private api: ApiService, private snackBar: MatSnackBar, private dialog: MatDialog) {
|
||||
this.onboardingForm = this.fb.group({
|
||||
code: ['', [Validators.required, Validators.pattern(/^[A-Z_]+$/)]],
|
||||
name: ['', Validators.required],
|
||||
contactEmail: ['', [Validators.required, Validators.email]],
|
||||
contactPhone: [''],
|
||||
description: [''],
|
||||
});
|
||||
}
|
||||
|
||||
async onSubmit() {
|
||||
if (this.onboardingForm.invalid) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
try {
|
||||
const formData = {
|
||||
...this.onboardingForm.value,
|
||||
code: this.onboardingForm.value.code.toUpperCase(),
|
||||
};
|
||||
|
||||
const response = await this.api.post<any>('/admin/departments', formData).toPromise();
|
||||
|
||||
// Show success with credentials
|
||||
this.showCredentialsDialog(response);
|
||||
|
||||
this.onboardingForm.reset();
|
||||
this.snackBar.open('Department onboarded successfully!', 'Close', {
|
||||
duration: 5000,
|
||||
panelClass: ['success-snackbar'],
|
||||
});
|
||||
} catch (error: any) {
|
||||
this.snackBar.open(error?.error?.message || 'Failed to onboard department', 'Close', {
|
||||
duration: 5000,
|
||||
panelClass: ['error-snackbar'],
|
||||
});
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
showCredentialsDialog(response: any) {
|
||||
const message = `
|
||||
Department: ${response.department.name}
|
||||
Wallet Address: ${response.department.walletAddress}
|
||||
|
||||
⚠️ SAVE THESE CREDENTIALS - They will not be shown again:
|
||||
|
||||
API Key: ${response.apiKey}
|
||||
API Secret: ${response.apiSecret}
|
||||
`.trim();
|
||||
|
||||
alert(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { ApiService } from '../../../core/services/api.service';
|
||||
|
||||
interface BlockchainEvent {
|
||||
id: string;
|
||||
eventType: string;
|
||||
contractAddress: string;
|
||||
transactionHash: string;
|
||||
blockNumber: number;
|
||||
eventData: any;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface PaginatedResponse {
|
||||
data: BlockchainEvent[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-event-dashboard',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
MatCardModule,
|
||||
MatTableModule,
|
||||
MatPaginatorModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatChipsModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatTooltipModule,
|
||||
],
|
||||
template: `
|
||||
<mat-card>
|
||||
<mat-card-header>
|
||||
<mat-card-title>
|
||||
<mat-icon class="title-icon">event_note</mat-icon>
|
||||
Blockchain Events
|
||||
</mat-card-title>
|
||||
<div class="header-actions">
|
||||
<button mat-icon-button (click)="loadEvents()" [disabled]="loading" matTooltip="Refresh">
|
||||
<mat-icon>refresh</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</mat-card-header>
|
||||
|
||||
<mat-card-content>
|
||||
<!-- Filters -->
|
||||
<form [formGroup]="filterForm" class="filters">
|
||||
<mat-form-field appearance="outline" class="filter-field">
|
||||
<mat-label>Event Type</mat-label>
|
||||
<mat-select formControlName="eventType" (selectionChange)="applyFilters()">
|
||||
<mat-option value="">All Types</mat-option>
|
||||
<mat-option value="LicenseRequested">License Requested</mat-option>
|
||||
<mat-option value="LicenseMinted">License Minted</mat-option>
|
||||
<mat-option value="ApprovalRecorded">Approval Recorded</mat-option>
|
||||
<mat-option value="DocumentUploaded">Document Uploaded</mat-option>
|
||||
<mat-option value="WorkflowCompleted">Workflow Completed</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="filter-field">
|
||||
<mat-label>Contract Address</mat-label>
|
||||
<input matInput formControlName="contractAddress" (keyup.enter)="applyFilters()" />
|
||||
<button mat-icon-button matSuffix (click)="clearFilter('contractAddress')" *ngIf="filterForm.get('contractAddress')?.value">
|
||||
<mat-icon>clear</mat-icon>
|
||||
</button>
|
||||
</mat-form-field>
|
||||
|
||||
<button mat-raised-button color="primary" (click)="applyFilters()" [disabled]="loading">
|
||||
<mat-icon>filter_list</mat-icon>
|
||||
Apply Filters
|
||||
</button>
|
||||
<button mat-button (click)="clearFilters()" [disabled]="loading">
|
||||
<mat-icon>clear_all</mat-icon>
|
||||
Clear
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="stats-row">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Total Events:</span>
|
||||
<span class="stat-value">{{ totalEvents }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Showing:</span>
|
||||
<span class="stat-value">{{ events.length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading Spinner -->
|
||||
<div *ngIf="loading" class="loading-container">
|
||||
<mat-spinner diameter="40"></mat-spinner>
|
||||
<p>Loading events...</p>
|
||||
</div>
|
||||
|
||||
<!-- Events Table -->
|
||||
<div *ngIf="!loading" class="table-container">
|
||||
<table mat-table [dataSource]="events" class="events-table">
|
||||
<!-- Event Type Column -->
|
||||
<ng-container matColumnDef="eventType">
|
||||
<th mat-header-cell *matHeaderCellDef>Event Type</th>
|
||||
<td mat-cell *matCellDef="let event">
|
||||
<mat-chip [style.background-color]="getEventColor(event.eventType)">
|
||||
{{ event.eventType }}
|
||||
</mat-chip>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Contract Address Column -->
|
||||
<ng-container matColumnDef="contractAddress">
|
||||
<th mat-header-cell *matHeaderCellDef>Contract</th>
|
||||
<td mat-cell *matCellDef="let event">
|
||||
<code class="address">{{ event.contractAddress | slice:0:10 }}...{{ event.contractAddress | slice:-8 }}</code>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Transaction Hash Column -->
|
||||
<ng-container matColumnDef="transactionHash">
|
||||
<th mat-header-cell *matHeaderCellDef>Transaction</th>
|
||||
<td mat-cell *matCellDef="let event">
|
||||
<code class="address">{{ event.transactionHash | slice:0:10 }}...{{ event.transactionHash | slice:-8 }}</code>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Block Number Column -->
|
||||
<ng-container matColumnDef="blockNumber">
|
||||
<th mat-header-cell *matHeaderCellDef>Block</th>
|
||||
<td mat-cell *matCellDef="let event">
|
||||
<code>{{ event.blockNumber }}</code>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Event Data Column -->
|
||||
<ng-container matColumnDef="eventData">
|
||||
<th mat-header-cell *matHeaderCellDef>Data</th>
|
||||
<td mat-cell *matCellDef="let event">
|
||||
<button mat-icon-button (click)="viewEventData(event)" matTooltip="View decoded parameters">
|
||||
<mat-icon>data_object</mat-icon>
|
||||
</button>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Timestamp Column -->
|
||||
<ng-container matColumnDef="createdAt">
|
||||
<th mat-header-cell *matHeaderCellDef>Timestamp</th>
|
||||
<td mat-cell *matCellDef="let event">
|
||||
{{ event.createdAt | date:'short' }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns" class="event-row"></tr>
|
||||
</table>
|
||||
|
||||
<!-- No Data Message -->
|
||||
<div *ngIf="events.length === 0" class="no-data">
|
||||
<mat-icon>event_busy</mat-icon>
|
||||
<p>No blockchain events found</p>
|
||||
<p class="hint">Events will appear here as transactions occur on the blockchain</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Paginator -->
|
||||
<mat-paginator
|
||||
*ngIf="!loading && events.length > 0"
|
||||
[length]="totalEvents"
|
||||
[pageSize]="pageSize"
|
||||
[pageSizeOptions]="[10, 20, 50, 100]"
|
||||
[pageIndex]="currentPage - 1"
|
||||
(page)="onPageChange($event)"
|
||||
showFirstLastButtons
|
||||
></mat-paginator>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
mat-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
mat-card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.title-icon {
|
||||
color: #1976d2;
|
||||
font-size: 32px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-field {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
margin-bottom: 16px;
|
||||
padding: 12px;
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-weight: 600;
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.events-table {
|
||||
width: 100%;
|
||||
min-width: 800px;
|
||||
}
|
||||
|
||||
.event-row {
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
}
|
||||
|
||||
.address {
|
||||
font-family: monospace;
|
||||
font-size: 0.75rem;
|
||||
background-color: #f5f5f5;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
color: #999;
|
||||
|
||||
mat-icon {
|
||||
font-size: 64px;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 0.875rem;
|
||||
color: #bbb;
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class EventDashboardComponent implements OnInit {
|
||||
filterForm: FormGroup;
|
||||
events: BlockchainEvent[] = [];
|
||||
displayedColumns = ['eventType', 'contractAddress', 'transactionHash', 'blockNumber', 'eventData', 'createdAt'];
|
||||
loading = false;
|
||||
totalEvents = 0;
|
||||
currentPage = 1;
|
||||
pageSize = 20;
|
||||
|
||||
constructor(private fb: FormBuilder, private api: ApiService) {
|
||||
this.filterForm = this.fb.group({
|
||||
eventType: [''],
|
||||
contractAddress: [''],
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadEvents();
|
||||
}
|
||||
|
||||
async loadEvents(): Promise<void> {
|
||||
this.loading = true;
|
||||
try {
|
||||
const params: any = {
|
||||
page: this.currentPage,
|
||||
limit: this.pageSize,
|
||||
};
|
||||
|
||||
const eventType = this.filterForm.get('eventType')?.value;
|
||||
const contractAddress = this.filterForm.get('contractAddress')?.value;
|
||||
|
||||
if (eventType) params.eventType = eventType;
|
||||
if (contractAddress) params.contractAddress = contractAddress;
|
||||
|
||||
const response = await this.api
|
||||
.get<PaginatedResponse>('/admin/blockchain/events', params)
|
||||
.toPromise();
|
||||
|
||||
if (response) {
|
||||
this.events = response.data;
|
||||
this.totalEvents = response.total;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load events:', error);
|
||||
this.events = [];
|
||||
this.totalEvents = 0;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
applyFilters(): void {
|
||||
this.currentPage = 1;
|
||||
this.loadEvents();
|
||||
}
|
||||
|
||||
clearFilter(field: string): void {
|
||||
this.filterForm.patchValue({ [field]: '' });
|
||||
}
|
||||
|
||||
clearFilters(): void {
|
||||
this.filterForm.reset();
|
||||
this.applyFilters();
|
||||
}
|
||||
|
||||
onPageChange(event: PageEvent): void {
|
||||
this.currentPage = event.pageIndex + 1;
|
||||
this.pageSize = event.pageSize;
|
||||
this.loadEvents();
|
||||
}
|
||||
|
||||
viewEventData(event: BlockchainEvent): void {
|
||||
alert(`Event Data:\n\n${JSON.stringify(event.eventData, null, 2)}`);
|
||||
}
|
||||
|
||||
getEventColor(eventType: string): string {
|
||||
const colors: { [key: string]: string } = {
|
||||
LicenseRequested: '#2196f3',
|
||||
LicenseMinted: '#4caf50',
|
||||
ApprovalRecorded: '#ff9800',
|
||||
DocumentUploaded: '#9c27b0',
|
||||
WorkflowCompleted: '#00bcd4',
|
||||
};
|
||||
return colors[eventType] || '#757575';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { ApiService } from '../../../core/services/api.service';
|
||||
|
||||
interface ApplicationLog {
|
||||
id: string;
|
||||
level: 'INFO' | 'WARN' | 'ERROR';
|
||||
module: string;
|
||||
message: string;
|
||||
metadata?: any;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface PaginatedResponse {
|
||||
data: ApplicationLog[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-logs-viewer',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
MatCardModule,
|
||||
MatTableModule,
|
||||
MatPaginatorModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatChipsModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatTooltipModule,
|
||||
],
|
||||
template: `
|
||||
<mat-card>
|
||||
<mat-card-header>
|
||||
<mat-card-title>
|
||||
<mat-icon class="title-icon">description</mat-icon>
|
||||
Application Logs
|
||||
</mat-card-title>
|
||||
<div class="header-actions">
|
||||
<button mat-icon-button (click)="loadLogs()" [disabled]="loading" matTooltip="Refresh">
|
||||
<mat-icon>refresh</mat-icon>
|
||||
</button>
|
||||
<button mat-icon-button (click)="exportLogs()" [disabled]="loading || logs.length === 0" matTooltip="Export to JSON">
|
||||
<mat-icon>download</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</mat-card-header>
|
||||
|
||||
<mat-card-content>
|
||||
<!-- Filters -->
|
||||
<form [formGroup]="filterForm" class="filters">
|
||||
<mat-form-field appearance="outline" class="filter-field">
|
||||
<mat-label>Log Level</mat-label>
|
||||
<mat-select formControlName="level" (selectionChange)="applyFilters()">
|
||||
<mat-option value="">All Levels</mat-option>
|
||||
<mat-option value="INFO">INFO</mat-option>
|
||||
<mat-option value="WARN">WARN</mat-option>
|
||||
<mat-option value="ERROR">ERROR</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="filter-field">
|
||||
<mat-label>Module</mat-label>
|
||||
<input matInput formControlName="module" placeholder="e.g., AuthService" (keyup.enter)="applyFilters()" />
|
||||
<button mat-icon-button matSuffix (click)="clearFilter('module')" *ngIf="filterForm.get('module')?.value">
|
||||
<mat-icon>clear</mat-icon>
|
||||
</button>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="filter-field filter-search">
|
||||
<mat-label>Search</mat-label>
|
||||
<input matInput formControlName="search" placeholder="Search in messages..." (keyup.enter)="applyFilters()" />
|
||||
<mat-icon matPrefix>search</mat-icon>
|
||||
<button mat-icon-button matSuffix (click)="clearFilter('search')" *ngIf="filterForm.get('search')?.value">
|
||||
<mat-icon>clear</mat-icon>
|
||||
</button>
|
||||
</mat-form-field>
|
||||
|
||||
<button mat-raised-button color="primary" (click)="applyFilters()" [disabled]="loading">
|
||||
<mat-icon>filter_list</mat-icon>
|
||||
Apply Filters
|
||||
</button>
|
||||
<button mat-button (click)="clearFilters()" [disabled]="loading">
|
||||
<mat-icon>clear_all</mat-icon>
|
||||
Clear
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="stats-row">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Total Logs:</span>
|
||||
<span class="stat-value">{{ totalLogs }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Showing:</span>
|
||||
<span class="stat-value">{{ logs.length }}</span>
|
||||
</div>
|
||||
<div class="stat-item" *ngIf="errorCount > 0">
|
||||
<span class="stat-label">Errors:</span>
|
||||
<span class="stat-value error">{{ errorCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading Spinner -->
|
||||
<div *ngIf="loading" class="loading-container">
|
||||
<mat-spinner diameter="40"></mat-spinner>
|
||||
<p>Loading logs...</p>
|
||||
</div>
|
||||
|
||||
<!-- Logs Table -->
|
||||
<div *ngIf="!loading" class="table-container">
|
||||
<table mat-table [dataSource]="logs" class="logs-table">
|
||||
<!-- Level Column -->
|
||||
<ng-container matColumnDef="level">
|
||||
<th mat-header-cell *matHeaderCellDef>Level</th>
|
||||
<td mat-cell *matCellDef="let log">
|
||||
<mat-chip [style.background-color]="getLevelColor(log.level)" [style.color]="getLevelTextColor(log.level)">
|
||||
{{ log.level }}
|
||||
</mat-chip>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Module Column -->
|
||||
<ng-container matColumnDef="module">
|
||||
<th mat-header-cell *matHeaderCellDef>Module</th>
|
||||
<td mat-cell *matCellDef="let log">
|
||||
<code class="module">{{ log.module }}</code>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Message Column -->
|
||||
<ng-container matColumnDef="message">
|
||||
<th mat-header-cell *matHeaderCellDef>Message</th>
|
||||
<td mat-cell *matCellDef="let log" class="message-cell">
|
||||
<div class="message-content" [class.error-message]="log.level === 'ERROR'">
|
||||
{{ log.message }}
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Metadata Column -->
|
||||
<ng-container matColumnDef="metadata">
|
||||
<th mat-header-cell *matHeaderCellDef>Details</th>
|
||||
<td mat-cell *matCellDef="let log">
|
||||
<button
|
||||
mat-icon-button
|
||||
*ngIf="log.metadata"
|
||||
(click)="viewMetadata(log)"
|
||||
matTooltip="View metadata"
|
||||
>
|
||||
<mat-icon>info</mat-icon>
|
||||
</button>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Timestamp Column -->
|
||||
<ng-container matColumnDef="createdAt">
|
||||
<th mat-header-cell *matHeaderCellDef>Timestamp</th>
|
||||
<td mat-cell *matCellDef="let log">
|
||||
<div class="timestamp">
|
||||
{{ log.createdAt | date:'short' }}
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr
|
||||
mat-row
|
||||
*matRowDef="let row; columns: displayedColumns"
|
||||
class="log-row"
|
||||
[class.error-row]="row.level === 'ERROR'"
|
||||
[class.warn-row]="row.level === 'WARN'"
|
||||
></tr>
|
||||
</table>
|
||||
|
||||
<!-- No Data Message -->
|
||||
<div *ngIf="logs.length === 0" class="no-data">
|
||||
<mat-icon>inbox</mat-icon>
|
||||
<p>No logs found</p>
|
||||
<p class="hint">Application logs will appear here</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Paginator -->
|
||||
<mat-paginator
|
||||
*ngIf="!loading && logs.length > 0"
|
||||
[length]="totalLogs"
|
||||
[pageSize]="pageSize"
|
||||
[pageSizeOptions]="[20, 50, 100, 200]"
|
||||
[pageIndex]="currentPage - 1"
|
||||
(page)="onPageChange($event)"
|
||||
showFirstLastButtons
|
||||
></mat-paginator>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
mat-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
mat-card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.title-icon {
|
||||
color: #1976d2;
|
||||
font-size: 32px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-field {
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.filter-search {
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
margin-bottom: 16px;
|
||||
padding: 12px;
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-weight: 600;
|
||||
color: #1976d2;
|
||||
|
||||
&.error {
|
||||
color: #d32f2f;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.logs-table {
|
||||
width: 100%;
|
||||
min-width: 900px;
|
||||
}
|
||||
|
||||
.log-row {
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
&.error-row {
|
||||
background-color: #ffebee;
|
||||
}
|
||||
|
||||
&.warn-row {
|
||||
background-color: #fff3e0;
|
||||
}
|
||||
}
|
||||
|
||||
.module {
|
||||
font-family: monospace;
|
||||
font-size: 0.75rem;
|
||||
background-color: #e3f2fd;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
color: #1565c0;
|
||||
}
|
||||
|
||||
.message-cell {
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.875rem;
|
||||
|
||||
&.error-message {
|
||||
color: #d32f2f;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
font-size: 0.75rem;
|
||||
color: #666;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
color: #999;
|
||||
|
||||
mat-icon {
|
||||
font-size: 64px;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 0.875rem;
|
||||
color: #bbb;
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class LogsViewerComponent implements OnInit {
|
||||
filterForm: FormGroup;
|
||||
logs: ApplicationLog[] = [];
|
||||
displayedColumns = ['level', 'module', 'message', 'metadata', 'createdAt'];
|
||||
loading = false;
|
||||
totalLogs = 0;
|
||||
errorCount = 0;
|
||||
currentPage = 1;
|
||||
pageSize = 50;
|
||||
|
||||
constructor(private fb: FormBuilder, private api: ApiService) {
|
||||
this.filterForm = this.fb.group({
|
||||
level: [''],
|
||||
module: [''],
|
||||
search: [''],
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadLogs();
|
||||
}
|
||||
|
||||
async loadLogs(): Promise<void> {
|
||||
this.loading = true;
|
||||
try {
|
||||
const params: any = {
|
||||
page: this.currentPage,
|
||||
limit: this.pageSize,
|
||||
};
|
||||
|
||||
const level = this.filterForm.get('level')?.value;
|
||||
const module = this.filterForm.get('module')?.value;
|
||||
const search = this.filterForm.get('search')?.value;
|
||||
|
||||
if (level) params.level = level;
|
||||
if (module) params.module = module;
|
||||
if (search) params.search = search;
|
||||
|
||||
const response = await this.api
|
||||
.get<PaginatedResponse>('/admin/logs', params)
|
||||
.toPromise();
|
||||
|
||||
if (response) {
|
||||
this.logs = response.data;
|
||||
this.totalLogs = response.total;
|
||||
this.errorCount = this.logs.filter(log => log.level === 'ERROR').length;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load logs:', error);
|
||||
this.logs = [];
|
||||
this.totalLogs = 0;
|
||||
this.errorCount = 0;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
applyFilters(): void {
|
||||
this.currentPage = 1;
|
||||
this.loadLogs();
|
||||
}
|
||||
|
||||
clearFilter(field: string): void {
|
||||
this.filterForm.patchValue({ [field]: '' });
|
||||
}
|
||||
|
||||
clearFilters(): void {
|
||||
this.filterForm.reset();
|
||||
this.applyFilters();
|
||||
}
|
||||
|
||||
onPageChange(event: PageEvent): void {
|
||||
this.currentPage = event.pageIndex + 1;
|
||||
this.pageSize = event.pageSize;
|
||||
this.loadLogs();
|
||||
}
|
||||
|
||||
viewMetadata(log: ApplicationLog): void {
|
||||
alert(`Log Metadata:\n\n${JSON.stringify(log.metadata, null, 2)}`);
|
||||
}
|
||||
|
||||
exportLogs(): void {
|
||||
const dataStr = JSON.stringify(this.logs, null, 2);
|
||||
const dataUri = 'data:application/json;charset=utf-8,' + encodeURIComponent(dataStr);
|
||||
const exportFileDefaultName = `logs_${new Date().toISOString()}.json`;
|
||||
|
||||
const linkElement = document.createElement('a');
|
||||
linkElement.setAttribute('href', dataUri);
|
||||
linkElement.setAttribute('download', exportFileDefaultName);
|
||||
linkElement.click();
|
||||
}
|
||||
|
||||
getLevelColor(level: string): string {
|
||||
const colors: { [key: string]: string } = {
|
||||
INFO: '#2196f3',
|
||||
WARN: '#ff9800',
|
||||
ERROR: '#d32f2f',
|
||||
};
|
||||
return colors[level] || '#757575';
|
||||
}
|
||||
|
||||
getLevelTextColor(level: string): string {
|
||||
return '#ffffff';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
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';
|
||||
|
||||
interface BlockchainTransaction {
|
||||
id: string;
|
||||
transactionHash: string;
|
||||
from: string;
|
||||
to: string;
|
||||
value: string;
|
||||
gasUsed: string;
|
||||
gasPrice: string;
|
||||
status: 'PENDING' | 'CONFIRMED' | 'FAILED';
|
||||
blockNumber?: number;
|
||||
requestId?: string;
|
||||
approvalId?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface PaginatedResponse {
|
||||
data: BlockchainTransaction[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-transaction-dashboard',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
MatCardModule,
|
||||
MatTableModule,
|
||||
MatPaginatorModule,
|
||||
MatFormFieldModule,
|
||||
MatSelectModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatChipsModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatTooltipModule,
|
||||
MatDialogModule,
|
||||
],
|
||||
template: `
|
||||
<mat-card>
|
||||
<mat-card-header>
|
||||
<mat-card-title>
|
||||
<mat-icon class="title-icon">receipt_long</mat-icon>
|
||||
Blockchain Transactions
|
||||
</mat-card-title>
|
||||
<div class="header-actions">
|
||||
<button mat-icon-button (click)="loadTransactions()" [disabled]="loading" matTooltip="Refresh">
|
||||
<mat-icon>refresh</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</mat-card-header>
|
||||
|
||||
<mat-card-content>
|
||||
<!-- Filters -->
|
||||
<form [formGroup]="filterForm" class="filters">
|
||||
<mat-form-field appearance="outline" class="filter-field">
|
||||
<mat-label>Status</mat-label>
|
||||
<mat-select formControlName="status" (selectionChange)="applyFilters()">
|
||||
<mat-option value="">All Statuses</mat-option>
|
||||
<mat-option value="PENDING">Pending</mat-option>
|
||||
<mat-option value="CONFIRMED">Confirmed</mat-option>
|
||||
<mat-option value="FAILED">Failed</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<button mat-raised-button color="primary" (click)="applyFilters()" [disabled]="loading">
|
||||
<mat-icon>filter_list</mat-icon>
|
||||
Apply Filters
|
||||
</button>
|
||||
<button mat-button (click)="clearFilters()" [disabled]="loading">
|
||||
<mat-icon>clear_all</mat-icon>
|
||||
Clear
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card confirmed">
|
||||
<mat-icon>check_circle</mat-icon>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ confirmedCount }}</div>
|
||||
<div class="stat-label">Confirmed</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card pending">
|
||||
<mat-icon>pending</mat-icon>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ pendingCount }}</div>
|
||||
<div class="stat-label">Pending</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card failed" *ngIf="failedCount > 0">
|
||||
<mat-icon>error</mat-icon>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ failedCount }}</div>
|
||||
<div class="stat-label">Failed</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card total">
|
||||
<mat-icon>receipt_long</mat-icon>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ totalTransactions }}</div>
|
||||
<div class="stat-label">Total</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading Spinner -->
|
||||
<div *ngIf="loading" class="loading-container">
|
||||
<mat-spinner diameter="40"></mat-spinner>
|
||||
<p>Loading transactions...</p>
|
||||
</div>
|
||||
|
||||
<!-- Transactions Table -->
|
||||
<div *ngIf="!loading" class="table-container">
|
||||
<table mat-table [dataSource]="transactions" class="transactions-table">
|
||||
<!-- Transaction Hash Column -->
|
||||
<ng-container matColumnDef="transactionHash">
|
||||
<th mat-header-cell *matHeaderCellDef>Transaction Hash</th>
|
||||
<td mat-cell *matCellDef="let tx">
|
||||
<code class="hash">{{ tx.transactionHash | slice:0:16 }}...{{ tx.transactionHash | slice:-12 }}</code>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- From Column -->
|
||||
<ng-container matColumnDef="from">
|
||||
<th mat-header-cell *matHeaderCellDef>From</th>
|
||||
<td mat-cell *matCellDef="let tx">
|
||||
<code class="address">{{ tx.from | slice:0:10 }}...{{ tx.from | slice:-8 }}</code>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- To Column -->
|
||||
<ng-container matColumnDef="to">
|
||||
<th mat-header-cell *matHeaderCellDef>To</th>
|
||||
<td mat-cell *matCellDef="let tx">
|
||||
<code class="address">{{ tx.to | slice:0:10 }}...{{ tx.to | slice:-8 }}</code>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Status Column -->
|
||||
<ng-container matColumnDef="status">
|
||||
<th mat-header-cell *matHeaderCellDef>Status</th>
|
||||
<td mat-cell *matCellDef="let tx">
|
||||
<mat-chip [style.background-color]="getStatusColor(tx.status)" style="color: white;">
|
||||
{{ tx.status }}
|
||||
</mat-chip>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Block Number Column -->
|
||||
<ng-container matColumnDef="blockNumber">
|
||||
<th mat-header-cell *matHeaderCellDef>Block</th>
|
||||
<td mat-cell *matCellDef="let tx">
|
||||
<code *ngIf="tx.blockNumber">{{ tx.blockNumber }}</code>
|
||||
<span *ngIf="!tx.blockNumber" class="pending-text">-</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Gas Used Column -->
|
||||
<ng-container matColumnDef="gasUsed">
|
||||
<th mat-header-cell *matHeaderCellDef>Gas Used</th>
|
||||
<td mat-cell *matCellDef="let tx">
|
||||
<code class="gas">{{ tx.gasUsed || '0' }}</code>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Linked To Column -->
|
||||
<ng-container matColumnDef="linkedTo">
|
||||
<th mat-header-cell *matHeaderCellDef>Linked To</th>
|
||||
<td mat-cell *matCellDef="let tx">
|
||||
<div *ngIf="tx.requestId" class="link-chip">
|
||||
<mat-icon>description</mat-icon>
|
||||
Request
|
||||
</div>
|
||||
<div *ngIf="tx.approvalId" class="link-chip">
|
||||
<mat-icon>approval</mat-icon>
|
||||
Approval
|
||||
</div>
|
||||
<span *ngIf="!tx.requestId && !tx.approvalId" class="no-link">-</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Actions Column -->
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef>Actions</th>
|
||||
<td mat-cell *matCellDef="let tx">
|
||||
<button mat-icon-button (click)="viewTransactionDetails(tx)" matTooltip="View details">
|
||||
<mat-icon>visibility</mat-icon>
|
||||
</button>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Timestamp Column -->
|
||||
<ng-container matColumnDef="createdAt">
|
||||
<th mat-header-cell *matHeaderCellDef>Timestamp</th>
|
||||
<td mat-cell *matCellDef="let tx">
|
||||
{{ tx.createdAt | date:'short' }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns" class="tx-row"></tr>
|
||||
</table>
|
||||
|
||||
<!-- No Data Message -->
|
||||
<div *ngIf="transactions.length === 0" class="no-data">
|
||||
<mat-icon>receipt_long</mat-icon>
|
||||
<p>No transactions found</p>
|
||||
<p class="hint">Blockchain transactions will appear here</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Paginator -->
|
||||
<mat-paginator
|
||||
*ngIf="!loading && transactions.length > 0"
|
||||
[length]="totalTransactions"
|
||||
[pageSize]="pageSize"
|
||||
[pageSizeOptions]="[10, 20, 50, 100]"
|
||||
[pageIndex]="currentPage - 1"
|
||||
(page)="onPageChange($event)"
|
||||
showFirstLastButtons
|
||||
></mat-paginator>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
mat-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
mat-card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.title-icon {
|
||||
color: #1976d2;
|
||||
font-size: 32px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-field {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
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%); }
|
||||
|
||||
mat-icon {
|
||||
font-size: 40px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.875rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.transactions-table {
|
||||
width: 100%;
|
||||
min-width: 1000px;
|
||||
}
|
||||
|
||||
.tx-row {
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
}
|
||||
|
||||
.hash, .address {
|
||||
font-family: monospace;
|
||||
font-size: 0.75rem;
|
||||
background-color: #f5f5f5;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.gas {
|
||||
font-family: monospace;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.pending-text {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.link-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
background-color: #e3f2fd;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
color: #1565c0;
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.no-link {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
color: #999;
|
||||
|
||||
mat-icon {
|
||||
font-size: 64px;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 0.875rem;
|
||||
color: #bbb;
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class TransactionDashboardComponent implements OnInit {
|
||||
filterForm: FormGroup;
|
||||
transactions: BlockchainTransaction[] = [];
|
||||
displayedColumns = ['transactionHash', 'from', 'to', 'status', 'blockNumber', 'gasUsed', 'linkedTo', 'actions', 'createdAt'];
|
||||
loading = false;
|
||||
totalTransactions = 0;
|
||||
confirmedCount = 0;
|
||||
pendingCount = 0;
|
||||
failedCount = 0;
|
||||
currentPage = 1;
|
||||
pageSize = 20;
|
||||
|
||||
constructor(private fb: FormBuilder, private api: ApiService, private dialog: MatDialog) {
|
||||
this.filterForm = this.fb.group({
|
||||
status: [''],
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadTransactions();
|
||||
}
|
||||
|
||||
async loadTransactions(): Promise<void> {
|
||||
this.loading = true;
|
||||
try {
|
||||
const params: any = {
|
||||
page: this.currentPage,
|
||||
limit: this.pageSize,
|
||||
};
|
||||
|
||||
const status = this.filterForm.get('status')?.value;
|
||||
if (status) params.status = status;
|
||||
|
||||
const response = await this.api
|
||||
.get<PaginatedResponse>('/admin/blockchain/transactions', params)
|
||||
.toPromise();
|
||||
|
||||
if (response) {
|
||||
this.transactions = response.data;
|
||||
this.totalTransactions = response.total;
|
||||
this.updateCounts();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load transactions:', error);
|
||||
this.transactions = [];
|
||||
this.totalTransactions = 0;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
updateCounts(): void {
|
||||
this.confirmedCount = this.transactions.filter(tx => tx.status === 'CONFIRMED').length;
|
||||
this.pendingCount = this.transactions.filter(tx => tx.status === 'PENDING').length;
|
||||
this.failedCount = this.transactions.filter(tx => tx.status === 'FAILED').length;
|
||||
}
|
||||
|
||||
applyFilters(): void {
|
||||
this.currentPage = 1;
|
||||
this.loadTransactions();
|
||||
}
|
||||
|
||||
clearFilters(): void {
|
||||
this.filterForm.reset();
|
||||
this.applyFilters();
|
||||
}
|
||||
|
||||
onPageChange(event: PageEvent): void {
|
||||
this.currentPage = event.pageIndex + 1;
|
||||
this.pageSize = event.pageSize;
|
||||
this.loadTransactions();
|
||||
}
|
||||
|
||||
viewTransactionDetails(tx: BlockchainTransaction): void {
|
||||
alert(`Transaction Details:\n\n${JSON.stringify(tx, null, 2)}`);
|
||||
}
|
||||
|
||||
getStatusColor(status: string): string {
|
||||
const colors: { [key: string]: string } = {
|
||||
CONFIRMED: '#4caf50',
|
||||
PENDING: '#2196f3',
|
||||
FAILED: '#f44336',
|
||||
};
|
||||
return colors[status] || '#757575';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { ApiService } from '../../../core/services/api.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-user-list',
|
||||
standalone: true,
|
||||
imports: [CommonModule, MatTableModule, MatChipsModule, MatCardModule],
|
||||
template: `
|
||||
<mat-card>
|
||||
<mat-card-header>
|
||||
<mat-card-title>All Users</mat-card-title>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<table mat-table [dataSource]="users" class="full-width">
|
||||
<ng-container matColumnDef="name">
|
||||
<th mat-header-cell *matHeaderCellDef>Name</th>
|
||||
<td mat-cell *matCellDef="let user">{{ user.name }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="email">
|
||||
<th mat-header-cell *matHeaderCellDef>Email</th>
|
||||
<td mat-cell *matCellDef="let user">{{ user.email }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="role">
|
||||
<th mat-header-cell *matHeaderCellDef>Role</th>
|
||||
<td mat-cell *matCellDef="let user">
|
||||
<mat-chip>{{ user.role }}</mat-chip>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="wallet">
|
||||
<th mat-header-cell *matHeaderCellDef>Wallet</th>
|
||||
<td mat-cell *matCellDef="let user"><code class="wallet-addr">{{ user.walletAddress }}</code></td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
|
||||
</table>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
`,
|
||||
styles: [`
|
||||
.full-width { width: 100%; }
|
||||
.wallet-addr { font-size: 0.75rem; }
|
||||
`]
|
||||
})
|
||||
export class UserListComponent implements OnInit {
|
||||
users: any[] = [];
|
||||
displayedColumns = ['name', 'email', 'role', 'wallet'];
|
||||
|
||||
constructor(private api: ApiService) {}
|
||||
|
||||
async ngOnInit() {
|
||||
try {
|
||||
this.users = await this.api.get<any[]>('/admin/users').toPromise() || [];
|
||||
} catch (error) {
|
||||
console.error('Failed to load users', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { ApprovalService } from '../services/approval.service';
|
||||
import { NotificationService } from '../../../core/services/notification.service';
|
||||
import { ApprovalResponseDto, RejectionReason, DocumentType } from '../../../api/models';
|
||||
|
||||
export interface ApprovalActionDialogData {
|
||||
approval: ApprovalResponseDto;
|
||||
action: 'approve' | 'reject' | 'changes';
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-approval-action',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
MatDialogModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatChipsModule,
|
||||
MatProgressSpinnerModule,
|
||||
],
|
||||
template: `
|
||||
<h2 mat-dialog-title>{{ dialogTitle }}</h2>
|
||||
<mat-dialog-content>
|
||||
<form [formGroup]="form" class="action-form">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Remarks</mat-label>
|
||||
<textarea
|
||||
matInput
|
||||
formControlName="remarks"
|
||||
rows="4"
|
||||
[placeholder]="remarksPlaceholder"
|
||||
></textarea>
|
||||
@if (form.controls.remarks.hasError('required')) {
|
||||
<mat-error>Remarks are required</mat-error>
|
||||
}
|
||||
@if (form.controls.remarks.hasError('minlength')) {
|
||||
<mat-error>Remarks must be at least 10 characters</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
@if (data.action === 'reject') {
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Rejection Reason</mat-label>
|
||||
<mat-select formControlName="rejectionReason">
|
||||
@for (reason of rejectionReasons; track reason.value) {
|
||||
<mat-option [value]="reason.value">{{ reason.label }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
@if (form.controls.rejectionReason.hasError('required')) {
|
||||
<mat-error>Please select a rejection reason</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
@if (data.action === 'changes') {
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Required Documents</mat-label>
|
||||
<mat-select formControlName="requiredDocuments" multiple>
|
||||
@for (docType of documentTypes; track docType.value) {
|
||||
<mat-option [value]="docType.value">{{ docType.label }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
<mat-hint>Select documents the applicant needs to provide</mat-hint>
|
||||
</mat-form-field>
|
||||
}
|
||||
</form>
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button (click)="onCancel()" [disabled]="submitting()">Cancel</button>
|
||||
<button
|
||||
mat-raised-button
|
||||
[color]="actionColor"
|
||||
(click)="onSubmit()"
|
||||
[disabled]="form.invalid || submitting()"
|
||||
>
|
||||
@if (submitting()) {
|
||||
<mat-spinner diameter="20"></mat-spinner>
|
||||
} @else {
|
||||
{{ actionLabel }}
|
||||
}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.action-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-width: 400px;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class ApprovalActionComponent {
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly approvalService = inject(ApprovalService);
|
||||
private readonly notification = inject(NotificationService);
|
||||
private readonly dialogRef = inject(MatDialogRef<ApprovalActionComponent>);
|
||||
readonly data: ApprovalActionDialogData = inject(MAT_DIALOG_DATA);
|
||||
|
||||
readonly submitting = signal(false);
|
||||
|
||||
readonly rejectionReasons: { value: RejectionReason; label: string }[] = [
|
||||
{ value: 'DOCUMENTATION_INCOMPLETE', label: 'Documentation Incomplete' },
|
||||
{ value: 'INCOMPLETE_DOCUMENTS', label: 'Incomplete Documents' },
|
||||
{ value: 'ELIGIBILITY_CRITERIA_NOT_MET', label: 'Eligibility Criteria Not Met' },
|
||||
{ value: 'INCOMPLETE_INFORMATION', label: 'Incomplete Information' },
|
||||
{ value: 'POLICY_VIOLATION', label: 'Policy Violation' },
|
||||
{ value: 'FRAUD_SUSPECTED', label: 'Fraud Suspected' },
|
||||
{ value: 'OTHER', label: 'Other' },
|
||||
];
|
||||
|
||||
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: 'ADDRESS_PROOF', label: 'Address Proof' },
|
||||
];
|
||||
|
||||
readonly form = this.fb.nonNullable.group({
|
||||
remarks: ['', [Validators.required, Validators.minLength(10)]],
|
||||
rejectionReason: ['' as RejectionReason],
|
||||
requiredDocuments: [[] as string[]],
|
||||
});
|
||||
|
||||
get dialogTitle(): string {
|
||||
switch (this.data.action) {
|
||||
case 'approve':
|
||||
return 'Approve Request';
|
||||
case 'reject':
|
||||
return 'Reject Request';
|
||||
case 'changes':
|
||||
return 'Request Changes';
|
||||
}
|
||||
}
|
||||
|
||||
get actionLabel(): string {
|
||||
switch (this.data.action) {
|
||||
case 'approve':
|
||||
return 'Approve';
|
||||
case 'reject':
|
||||
return 'Reject';
|
||||
case 'changes':
|
||||
return 'Request Changes';
|
||||
}
|
||||
}
|
||||
|
||||
get actionColor(): 'primary' | 'warn' {
|
||||
return this.data.action === 'reject' ? 'warn' : 'primary';
|
||||
}
|
||||
|
||||
get remarksPlaceholder(): string {
|
||||
switch (this.data.action) {
|
||||
case 'approve':
|
||||
return 'Enter your approval remarks...';
|
||||
case 'reject':
|
||||
return 'Explain why this request is being rejected...';
|
||||
case 'changes':
|
||||
return 'Explain what changes are required...';
|
||||
}
|
||||
}
|
||||
|
||||
constructor() {
|
||||
if (this.data.action === 'reject') {
|
||||
this.form.controls.rejectionReason.addValidators(Validators.required);
|
||||
}
|
||||
}
|
||||
|
||||
onSubmit(): void {
|
||||
if (this.form.invalid) return;
|
||||
|
||||
this.submitting.set(true);
|
||||
const { remarks, rejectionReason, requiredDocuments } = this.form.getRawValue();
|
||||
const requestId = this.data.approval.requestId;
|
||||
|
||||
let action$;
|
||||
switch (this.data.action) {
|
||||
case 'approve':
|
||||
action$ = this.approvalService.approve(requestId, { remarks });
|
||||
break;
|
||||
case 'reject':
|
||||
action$ = this.approvalService.reject(requestId, { remarks, rejectionReason });
|
||||
break;
|
||||
case 'changes':
|
||||
action$ = this.approvalService.requestChanges(requestId, { remarks, requiredDocuments });
|
||||
break;
|
||||
}
|
||||
|
||||
action$.subscribe({
|
||||
next: () => {
|
||||
this.notification.success(
|
||||
this.data.action === 'approve'
|
||||
? 'Request approved successfully'
|
||||
: this.data.action === 'reject'
|
||||
? 'Request rejected'
|
||||
: 'Changes requested'
|
||||
);
|
||||
this.dialogRef.close(true);
|
||||
},
|
||||
error: () => {
|
||||
this.submitting.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onCancel(): void {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import { Component, Input, OnInit, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { StatusBadgeComponent } from '../../../shared/components/status-badge/status-badge.component';
|
||||
import { EmptyStateComponent } from '../../../shared/components/empty-state/empty-state.component';
|
||||
import { ApprovalService } from '../services/approval.service';
|
||||
import { ApprovalResponseDto } from '../../../api/models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-approval-history',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatCardModule,
|
||||
MatIconModule,
|
||||
MatProgressSpinnerModule,
|
||||
StatusBadgeComponent,
|
||||
EmptyStateComponent,
|
||||
],
|
||||
template: `
|
||||
<div class="approval-history">
|
||||
<h3>Approval History</h3>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="loading-container">
|
||||
<mat-spinner diameter="32"></mat-spinner>
|
||||
</div>
|
||||
} @else if (approvals().length === 0) {
|
||||
<app-empty-state
|
||||
icon="history"
|
||||
title="No approval history"
|
||||
message="No approval actions have been taken yet."
|
||||
/>
|
||||
} @else {
|
||||
<div class="timeline">
|
||||
@for (approval of approvals(); track approval.id) {
|
||||
<div class="timeline-item">
|
||||
<div class="timeline-marker" [class]="getMarkerClass(approval.status)">
|
||||
<mat-icon>{{ getStatusIcon(approval.status) }}</mat-icon>
|
||||
</div>
|
||||
<mat-card class="timeline-content">
|
||||
<div class="timeline-header">
|
||||
<span class="department">{{ approval.departmentName }}</span>
|
||||
<app-status-badge [status]="approval.status" />
|
||||
</div>
|
||||
@if (approval.remarks) {
|
||||
<p class="remarks">{{ approval.remarks }}</p>
|
||||
}
|
||||
@if (approval.rejectionReason) {
|
||||
<p class="rejection-reason">
|
||||
<strong>Reason:</strong> {{ formatReason(approval.rejectionReason) }}
|
||||
</p>
|
||||
}
|
||||
<div class="timeline-meta">
|
||||
<span>{{ approval.updatedAt | date: 'medium' }}</span>
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.approval-history {
|
||||
margin-top: 24px;
|
||||
|
||||
h3 {
|
||||
margin: 0 0 16px;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
position: relative;
|
||||
padding-left: 32px;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
position: relative;
|
||||
margin-bottom: 16px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-marker {
|
||||
position: absolute;
|
||||
left: -32px;
|
||||
top: 16px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #e0e0e0;
|
||||
z-index: 1;
|
||||
|
||||
mat-icon {
|
||||
font-size: 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.approved {
|
||||
background-color: #4caf50;
|
||||
}
|
||||
|
||||
&.rejected {
|
||||
background-color: #f44336;
|
||||
}
|
||||
|
||||
&.pending {
|
||||
background-color: #ff9800;
|
||||
}
|
||||
|
||||
&.changes {
|
||||
background-color: #2196f3;
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.timeline-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.department {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.remarks {
|
||||
margin: 8px 0;
|
||||
color: rgba(0, 0, 0, 0.7);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.rejection-reason {
|
||||
margin: 8px 0;
|
||||
color: #f44336;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.timeline-meta {
|
||||
font-size: 0.75rem;
|
||||
color: rgba(0, 0, 0, 0.54);
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class ApprovalHistoryComponent implements OnInit {
|
||||
@Input({ required: true }) requestId!: string;
|
||||
|
||||
private readonly approvalService = inject(ApprovalService);
|
||||
|
||||
readonly loading = signal(true);
|
||||
readonly approvals = signal<ApprovalResponseDto[]>([]);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadHistory();
|
||||
}
|
||||
|
||||
private loadHistory(): void {
|
||||
this.approvalService.getApprovalHistory(this.requestId).subscribe({
|
||||
next: (data) => {
|
||||
this.approvals.set(data);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getStatusIcon(status: string): string {
|
||||
switch (status) {
|
||||
case 'APPROVED':
|
||||
return 'check';
|
||||
case 'REJECTED':
|
||||
return 'close';
|
||||
case 'CHANGES_REQUESTED':
|
||||
return 'edit';
|
||||
default:
|
||||
return 'hourglass_empty';
|
||||
}
|
||||
}
|
||||
|
||||
getMarkerClass(status: string): string {
|
||||
switch (status) {
|
||||
case 'APPROVED':
|
||||
return 'approved';
|
||||
case 'REJECTED':
|
||||
return 'rejected';
|
||||
case 'CHANGES_REQUESTED':
|
||||
return 'changes';
|
||||
default:
|
||||
return 'pending';
|
||||
}
|
||||
}
|
||||
|
||||
formatReason(reason: string): string {
|
||||
return reason.replace(/_/g, ' ').toLowerCase().replace(/^\w/, (c) => c.toUpperCase());
|
||||
}
|
||||
}
|
||||
11
frontend/src/app/features/approvals/approvals.routes.ts
Normal file
11
frontend/src/app/features/approvals/approvals.routes.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { departmentGuard } from '../../core/guards';
|
||||
|
||||
export const APPROVALS_ROUTES: Routes = [
|
||||
{
|
||||
path: '',
|
||||
loadComponent: () =>
|
||||
import('./pending-list/pending-list.component').then((m) => m.PendingListComponent),
|
||||
canActivate: [departmentGuard],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,208 @@
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { PageHeaderComponent } from '../../../shared/components/page-header/page-header.component';
|
||||
import { StatusBadgeComponent } from '../../../shared/components/status-badge/status-badge.component';
|
||||
import { EmptyStateComponent } from '../../../shared/components/empty-state/empty-state.component';
|
||||
import { ApprovalActionComponent } from '../approval-action/approval-action.component';
|
||||
import { ApprovalService } from '../services/approval.service';
|
||||
import { NotificationService } from '../../../core/services/notification.service';
|
||||
import { ApprovalResponseDto } from '../../../api/models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-pending-list',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterModule,
|
||||
MatCardModule,
|
||||
MatTableModule,
|
||||
MatPaginatorModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatDialogModule,
|
||||
PageHeaderComponent,
|
||||
StatusBadgeComponent,
|
||||
EmptyStateComponent,
|
||||
],
|
||||
template: `
|
||||
<div class="page-container">
|
||||
<app-page-header
|
||||
title="Pending Approvals"
|
||||
subtitle="Review and approve license requests"
|
||||
/>
|
||||
|
||||
<mat-card>
|
||||
<mat-card-content>
|
||||
@if (loading()) {
|
||||
<div class="loading-container">
|
||||
<mat-spinner diameter="48"></mat-spinner>
|
||||
</div>
|
||||
} @else if (approvals().length === 0) {
|
||||
<app-empty-state
|
||||
icon="check_circle"
|
||||
title="No pending approvals"
|
||||
message="You have no requests pending your approval."
|
||||
/>
|
||||
} @else {
|
||||
<table mat-table [dataSource]="approvals()">
|
||||
<ng-container matColumnDef="requestId">
|
||||
<th mat-header-cell *matHeaderCellDef>Request</th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
<a [routerLink]="['/requests', row.requestId]" class="request-link">
|
||||
{{ row.requestId.slice(0, 8) }}...
|
||||
</a>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="status">
|
||||
<th mat-header-cell *matHeaderCellDef>Status</th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
<app-status-badge [status]="row.status" />
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="createdAt">
|
||||
<th mat-header-cell *matHeaderCellDef>Received</th>
|
||||
<td mat-cell *matCellDef="let row">{{ row.createdAt | date: 'medium' }}</td>
|
||||
</ng-container>
|
||||
|
||||
<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>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
|
||||
</table>
|
||||
|
||||
<mat-paginator
|
||||
[length]="totalItems()"
|
||||
[pageSize]="pageSize()"
|
||||
[pageIndex]="pageIndex()"
|
||||
[pageSizeOptions]="[5, 10, 25]"
|
||||
(page)="onPageChange($event)"
|
||||
showFirstLastButtons
|
||||
/>
|
||||
}
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.loading-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 48px;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.request-link {
|
||||
color: #1976d2;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.mat-column-actions {
|
||||
width: 300px;
|
||||
text-align: right;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class PendingListComponent implements OnInit {
|
||||
private readonly approvalService = inject(ApprovalService);
|
||||
private readonly notification = inject(NotificationService);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
|
||||
readonly loading = signal(true);
|
||||
readonly approvals = signal<ApprovalResponseDto[]>([]);
|
||||
readonly totalItems = signal(0);
|
||||
readonly pageSize = signal(10);
|
||||
readonly pageIndex = signal(0);
|
||||
|
||||
readonly displayedColumns = ['requestId', 'status', 'createdAt', 'actions'];
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadApprovals();
|
||||
}
|
||||
|
||||
loadApprovals(): void {
|
||||
this.loading.set(true);
|
||||
this.approvalService
|
||||
.getPendingApprovals(this.pageIndex() + 1, this.pageSize())
|
||||
.subscribe({
|
||||
next: (response) => {
|
||||
this.approvals.set(response.data);
|
||||
this.totalItems.set(response.total);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onPageChange(event: PageEvent): void {
|
||||
this.pageIndex.set(event.pageIndex);
|
||||
this.pageSize.set(event.pageSize);
|
||||
this.loadApprovals();
|
||||
}
|
||||
|
||||
openApprovalDialog(
|
||||
approval: ApprovalResponseDto,
|
||||
action: 'approve' | 'reject' | 'changes'
|
||||
): void {
|
||||
const dialogRef = this.dialog.open(ApprovalActionComponent, {
|
||||
data: { approval, action },
|
||||
width: '500px',
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe((result) => {
|
||||
if (result) {
|
||||
this.loadApprovals();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { ApiService } from '../../../core/services/api.service';
|
||||
import {
|
||||
ApprovalResponseDto,
|
||||
PaginatedApprovalsResponse,
|
||||
RejectionReason,
|
||||
} from '../../../api/models';
|
||||
|
||||
export interface ApproveRequestDto {
|
||||
remarks: string;
|
||||
reviewedDocuments?: string[];
|
||||
}
|
||||
|
||||
export interface RejectRequestDto {
|
||||
remarks: string;
|
||||
rejectionReason: RejectionReason;
|
||||
}
|
||||
|
||||
export interface RequestChangesDto {
|
||||
remarks: string;
|
||||
requiredDocuments: string[];
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ApprovalService {
|
||||
private readonly api = inject(ApiService);
|
||||
|
||||
getPendingApprovals(
|
||||
page = 1,
|
||||
limit = 10
|
||||
): Observable<PaginatedApprovalsResponse> {
|
||||
return this.api.get<PaginatedApprovalsResponse>('/approvals/pending', { page, limit });
|
||||
}
|
||||
|
||||
getApprovalsByRequest(requestId: string): Observable<ApprovalResponseDto[]> {
|
||||
return this.api.get<ApprovalResponseDto[]>(`/requests/${requestId}/approvals`);
|
||||
}
|
||||
|
||||
getApproval(approvalId: string): Observable<ApprovalResponseDto> {
|
||||
return this.api.get<ApprovalResponseDto>(`/approvals/${approvalId}`);
|
||||
}
|
||||
|
||||
approve(requestId: string, dto: ApproveRequestDto): Observable<ApprovalResponseDto> {
|
||||
return this.api.post<ApprovalResponseDto>(`/requests/${requestId}/approve`, dto);
|
||||
}
|
||||
|
||||
reject(requestId: string, dto: RejectRequestDto): Observable<ApprovalResponseDto> {
|
||||
return this.api.post<ApprovalResponseDto>(`/requests/${requestId}/reject`, dto);
|
||||
}
|
||||
|
||||
requestChanges(requestId: string, dto: RequestChangesDto): Observable<ApprovalResponseDto> {
|
||||
return this.api.post<ApprovalResponseDto>(`/requests/${requestId}/request-changes`, dto);
|
||||
}
|
||||
|
||||
getApprovalHistory(requestId: string): Observable<ApprovalResponseDto[]> {
|
||||
return this.api.get<ApprovalResponseDto[]>(`/requests/${requestId}/approval-history`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { FormControl, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { PageHeaderComponent } from '../../../shared/components/page-header/page-header.component';
|
||||
import { EmptyStateComponent } from '../../../shared/components/empty-state/empty-state.component';
|
||||
import { AuditService } from '../services/audit.service';
|
||||
import { AuditLogDto, AuditAction, ActorType } from '../../../api/models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-audit-list',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterModule,
|
||||
ReactiveFormsModule,
|
||||
MatCardModule,
|
||||
MatTableModule,
|
||||
MatPaginatorModule,
|
||||
MatFormFieldModule,
|
||||
MatSelectModule,
|
||||
MatInputModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatChipsModule,
|
||||
MatProgressSpinnerModule,
|
||||
PageHeaderComponent,
|
||||
EmptyStateComponent,
|
||||
],
|
||||
template: `
|
||||
<div class="page-container">
|
||||
<app-page-header title="Audit Logs" subtitle="System activity and changes" />
|
||||
|
||||
<mat-card class="filters-card">
|
||||
<mat-card-content>
|
||||
<div class="filters">
|
||||
<mat-form-field appearance="outline" class="filter-field">
|
||||
<mat-label>Entity Type</mat-label>
|
||||
<mat-select [formControl]="entityTypeFilter">
|
||||
<mat-option value="">All Types</mat-option>
|
||||
<mat-option value="request">Requests</mat-option>
|
||||
<mat-option value="document">Documents</mat-option>
|
||||
<mat-option value="approval">Approvals</mat-option>
|
||||
<mat-option value="department">Departments</mat-option>
|
||||
<mat-option value="workflow">Workflows</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="filter-field">
|
||||
<mat-label>Action</mat-label>
|
||||
<mat-select [formControl]="actionFilter">
|
||||
<mat-option value="">All Actions</mat-option>
|
||||
@for (action of actions; track action) {
|
||||
<mat-option [value]="action">{{ action }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="filter-field">
|
||||
<mat-label>Actor Type</mat-label>
|
||||
<mat-select [formControl]="actorTypeFilter">
|
||||
<mat-option value="">All Actors</mat-option>
|
||||
@for (type of actorTypes; track type) {
|
||||
<mat-option [value]="type">{{ type }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<button mat-button (click)="clearFilters()">
|
||||
<mat-icon>clear</mat-icon>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
<mat-card>
|
||||
<mat-card-content>
|
||||
@if (loading()) {
|
||||
<div class="loading-container">
|
||||
<mat-spinner diameter="48"></mat-spinner>
|
||||
</div>
|
||||
} @else if (logs().length === 0) {
|
||||
<app-empty-state
|
||||
icon="history"
|
||||
title="No audit logs"
|
||||
message="No audit logs match your current filters."
|
||||
/>
|
||||
} @else {
|
||||
<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>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="action">
|
||||
<th mat-header-cell *matHeaderCellDef>Action</th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
<mat-chip [class]="getActionClass(row.action)">
|
||||
{{ row.action }}
|
||||
</mat-chip>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="entityType">
|
||||
<th mat-header-cell *matHeaderCellDef>Entity</th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
<a
|
||||
[routerLink]="[row.entityType, row.entityId]"
|
||||
class="entity-link"
|
||||
>
|
||||
{{ row.entityType }}
|
||||
</a>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="actorType">
|
||||
<th mat-header-cell *matHeaderCellDef>Actor</th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
<span class="actor-info">
|
||||
{{ row.actorType }}
|
||||
<span class="actor-id">{{ row.actorId | slice: 0 : 8 }}</span>
|
||||
</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="details">
|
||||
<th mat-header-cell *matHeaderCellDef></th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
<button
|
||||
mat-icon-button
|
||||
[routerLink]="[row.entityType, row.entityId]"
|
||||
>
|
||||
<mat-icon>chevron_right</mat-icon>
|
||||
</button>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
|
||||
</table>
|
||||
|
||||
<mat-paginator
|
||||
[length]="totalItems()"
|
||||
[pageSize]="pageSize()"
|
||||
[pageIndex]="pageIndex()"
|
||||
[pageSizeOptions]="[10, 25, 50, 100]"
|
||||
(page)="onPageChange($event)"
|
||||
showFirstLastButtons
|
||||
/>
|
||||
}
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.filters-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-field {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 48px;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.entity-link {
|
||||
color: #1976d2;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.actor-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.actor-id {
|
||||
font-size: 0.75rem;
|
||||
color: rgba(0, 0, 0, 0.54);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.action-create {
|
||||
background-color: #c8e6c9 !important;
|
||||
color: #2e7d32 !important;
|
||||
}
|
||||
|
||||
.action-update {
|
||||
background-color: #bbdefb !important;
|
||||
color: #1565c0 !important;
|
||||
}
|
||||
|
||||
.action-delete {
|
||||
background-color: #ffcdd2 !important;
|
||||
color: #c62828 !important;
|
||||
}
|
||||
|
||||
.action-approve {
|
||||
background-color: #c8e6c9 !important;
|
||||
color: #2e7d32 !important;
|
||||
}
|
||||
|
||||
.action-reject {
|
||||
background-color: #ffcdd2 !important;
|
||||
color: #c62828 !important;
|
||||
}
|
||||
|
||||
.mat-column-details {
|
||||
width: 48px;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class AuditListComponent implements OnInit {
|
||||
private readonly auditService = inject(AuditService);
|
||||
|
||||
readonly loading = signal(true);
|
||||
readonly logs = signal<AuditLogDto[]>([]);
|
||||
readonly totalItems = signal(0);
|
||||
readonly pageSize = signal(25);
|
||||
readonly pageIndex = signal(0);
|
||||
|
||||
readonly entityTypeFilter = new FormControl('');
|
||||
readonly actionFilter = new FormControl('');
|
||||
readonly actorTypeFilter = new FormControl('');
|
||||
|
||||
readonly displayedColumns = ['timestamp', 'action', 'entityType', 'actorType', 'details'];
|
||||
readonly actions: AuditAction[] = ['CREATE', 'UPDATE', 'DELETE', 'APPROVE', 'REJECT', 'SUBMIT', 'CANCEL', 'DOWNLOAD'];
|
||||
readonly actorTypes: ActorType[] = ['APPLICANT', 'DEPARTMENT', 'SYSTEM', 'ADMIN'];
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadLogs();
|
||||
|
||||
this.entityTypeFilter.valueChanges.subscribe(() => this.onFilterChange());
|
||||
this.actionFilter.valueChanges.subscribe(() => this.onFilterChange());
|
||||
this.actorTypeFilter.valueChanges.subscribe(() => this.onFilterChange());
|
||||
}
|
||||
|
||||
loadLogs(): void {
|
||||
this.loading.set(true);
|
||||
this.auditService
|
||||
.getAuditLogs({
|
||||
page: this.pageIndex() + 1,
|
||||
limit: this.pageSize(),
|
||||
entityType: this.entityTypeFilter.value || undefined,
|
||||
action: (this.actionFilter.value as AuditAction) || undefined,
|
||||
actorType: (this.actorTypeFilter.value as ActorType) || undefined,
|
||||
})
|
||||
.subscribe({
|
||||
next: (response) => {
|
||||
this.logs.set(response.data);
|
||||
this.totalItems.set(response.total);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onFilterChange(): void {
|
||||
this.pageIndex.set(0);
|
||||
this.loadLogs();
|
||||
}
|
||||
|
||||
onPageChange(event: PageEvent): void {
|
||||
this.pageIndex.set(event.pageIndex);
|
||||
this.pageSize.set(event.pageSize);
|
||||
this.loadLogs();
|
||||
}
|
||||
|
||||
clearFilters(): void {
|
||||
this.entityTypeFilter.setValue('');
|
||||
this.actionFilter.setValue('');
|
||||
this.actorTypeFilter.setValue('');
|
||||
}
|
||||
|
||||
getActionClass(action: string): string {
|
||||
return `action-${action.toLowerCase()}`;
|
||||
}
|
||||
}
|
||||
17
frontend/src/app/features/audit/audit.routes.ts
Normal file
17
frontend/src/app/features/audit/audit.routes.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { adminGuard } from '../../core/guards';
|
||||
|
||||
export const AUDIT_ROUTES: Routes = [
|
||||
{
|
||||
path: '',
|
||||
loadComponent: () =>
|
||||
import('./audit-list/audit-list.component').then((m) => m.AuditListComponent),
|
||||
canActivate: [adminGuard],
|
||||
},
|
||||
{
|
||||
path: ':entityType/:entityId',
|
||||
loadComponent: () =>
|
||||
import('./entity-trail/entity-trail.component').then((m) => m.EntityTrailComponent),
|
||||
canActivate: [adminGuard],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,322 @@
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { PageHeaderComponent } from '../../../shared/components/page-header/page-header.component';
|
||||
import { EmptyStateComponent } from '../../../shared/components/empty-state/empty-state.component';
|
||||
import { AuditService } from '../services/audit.service';
|
||||
import { AuditLogDto } from '../../../api/models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-entity-trail',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterModule,
|
||||
MatCardModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatChipsModule,
|
||||
MatProgressSpinnerModule,
|
||||
PageHeaderComponent,
|
||||
EmptyStateComponent,
|
||||
],
|
||||
template: `
|
||||
<div class="page-container">
|
||||
<app-page-header
|
||||
[title]="'Audit Trail'"
|
||||
[subtitle]="entityType() + ' / ' + entityId()"
|
||||
>
|
||||
<button mat-button routerLink="/audit">
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
Back to Logs
|
||||
</button>
|
||||
</app-page-header>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="loading-container">
|
||||
<mat-spinner diameter="48"></mat-spinner>
|
||||
</div>
|
||||
} @else if (events().length === 0) {
|
||||
<app-empty-state
|
||||
icon="history"
|
||||
title="No trail found"
|
||||
message="No audit events found for this entity."
|
||||
/>
|
||||
} @else {
|
||||
<div class="timeline">
|
||||
@for (event of events(); track event.id) {
|
||||
<div class="timeline-item">
|
||||
<div class="timeline-marker" [class]="getActionClass(event.action)">
|
||||
<mat-icon>{{ getActionIcon(event.action) }}</mat-icon>
|
||||
</div>
|
||||
<mat-card class="timeline-content">
|
||||
<div class="event-header">
|
||||
<mat-chip [class]="getActionChipClass(event.action)">
|
||||
{{ event.action }}
|
||||
</mat-chip>
|
||||
<span class="timestamp">{{ event.timestamp | date: 'medium' }}</span>
|
||||
</div>
|
||||
<div class="event-actor">
|
||||
<span class="actor-type">{{ event.actorType }}</span>
|
||||
<span class="actor-id">{{ event.actorId }}</span>
|
||||
</div>
|
||||
@if (event.changes && hasChanges(event.changes)) {
|
||||
<div class="event-changes">
|
||||
<h4>Changes</h4>
|
||||
<div class="changes-list">
|
||||
@for (key of getChangeKeys(event.changes); track key) {
|
||||
<div class="change-item">
|
||||
<span class="change-key">{{ key }}</span>
|
||||
<span class="change-value">{{ event.changes[key] | json }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (event.ipAddress) {
|
||||
<div class="event-meta">
|
||||
<span>IP: {{ event.ipAddress }}</span>
|
||||
</div>
|
||||
}
|
||||
</mat-card>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.loading-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 48px;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
position: relative;
|
||||
padding-left: 40px;
|
||||
max-width: 800px;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
position: relative;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.timeline-marker {
|
||||
position: absolute;
|
||||
left: -40px;
|
||||
top: 16px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #e0e0e0;
|
||||
z-index: 1;
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.create {
|
||||
background-color: #4caf50;
|
||||
}
|
||||
&.update {
|
||||
background-color: #2196f3;
|
||||
}
|
||||
&.delete {
|
||||
background-color: #f44336;
|
||||
}
|
||||
&.approve {
|
||||
background-color: #4caf50;
|
||||
}
|
||||
&.reject {
|
||||
background-color: #f44336;
|
||||
}
|
||||
&.submit {
|
||||
background-color: #ff9800;
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.event-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
font-size: 0.875rem;
|
||||
color: rgba(0, 0, 0, 0.54);
|
||||
}
|
||||
|
||||
.event-actor {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.actor-type {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.actor-id {
|
||||
font-family: monospace;
|
||||
font-size: 0.875rem;
|
||||
color: rgba(0, 0, 0, 0.54);
|
||||
}
|
||||
|
||||
.event-changes {
|
||||
background-color: #fafafa;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
margin-top: 12px;
|
||||
|
||||
h4 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.changes-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.change-item {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.change-key {
|
||||
font-weight: 500;
|
||||
color: rgba(0, 0, 0, 0.54);
|
||||
}
|
||||
|
||||
.change-value {
|
||||
font-family: monospace;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.event-meta {
|
||||
margin-top: 12px;
|
||||
font-size: 0.75rem;
|
||||
color: rgba(0, 0, 0, 0.38);
|
||||
}
|
||||
|
||||
.action-create {
|
||||
background-color: #c8e6c9 !important;
|
||||
color: #2e7d32 !important;
|
||||
}
|
||||
|
||||
.action-update {
|
||||
background-color: #bbdefb !important;
|
||||
color: #1565c0 !important;
|
||||
}
|
||||
|
||||
.action-delete {
|
||||
background-color: #ffcdd2 !important;
|
||||
color: #c62828 !important;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class EntityTrailComponent implements OnInit {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly auditService = inject(AuditService);
|
||||
|
||||
readonly loading = signal(true);
|
||||
readonly events = signal<AuditLogDto[]>([]);
|
||||
readonly entityType = signal('');
|
||||
readonly entityId = signal('');
|
||||
|
||||
ngOnInit(): void {
|
||||
const type = this.route.snapshot.paramMap.get('entityType');
|
||||
const id = this.route.snapshot.paramMap.get('entityId');
|
||||
|
||||
if (!type || !id) {
|
||||
this.router.navigate(['/audit']);
|
||||
return;
|
||||
}
|
||||
|
||||
this.entityType.set(type);
|
||||
this.entityId.set(id);
|
||||
this.loadTrail();
|
||||
}
|
||||
|
||||
private loadTrail(): void {
|
||||
this.auditService.getEntityTrail(this.entityType(), this.entityId()).subscribe({
|
||||
next: (trail) => {
|
||||
this.events.set(trail.events);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getActionIcon(action: string): string {
|
||||
switch (action) {
|
||||
case 'CREATE':
|
||||
return 'add';
|
||||
case 'UPDATE':
|
||||
return 'edit';
|
||||
case 'DELETE':
|
||||
return 'delete';
|
||||
case 'APPROVE':
|
||||
return 'check';
|
||||
case 'REJECT':
|
||||
return 'close';
|
||||
case 'SUBMIT':
|
||||
return 'send';
|
||||
default:
|
||||
return 'info';
|
||||
}
|
||||
}
|
||||
|
||||
getActionClass(action: string): string {
|
||||
return action.toLowerCase();
|
||||
}
|
||||
|
||||
getActionChipClass(action: string): string {
|
||||
return `action-${action.toLowerCase()}`;
|
||||
}
|
||||
|
||||
hasChanges(changes: Record<string, unknown>): boolean {
|
||||
return Object.keys(changes).length > 0;
|
||||
}
|
||||
|
||||
getChangeKeys(changes: Record<string, unknown>): string[] {
|
||||
return Object.keys(changes);
|
||||
}
|
||||
}
|
||||
29
frontend/src/app/features/audit/services/audit.service.ts
Normal file
29
frontend/src/app/features/audit/services/audit.service.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { ApiService } from '../../../core/services/api.service';
|
||||
import {
|
||||
AuditLogDto,
|
||||
EntityAuditTrailDto,
|
||||
AuditMetadataDto,
|
||||
PaginatedAuditLogsResponse,
|
||||
AuditLogFilters,
|
||||
} from '../../../api/models';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class AuditService {
|
||||
private readonly api = inject(ApiService);
|
||||
|
||||
getAuditLogs(filters?: AuditLogFilters): Observable<PaginatedAuditLogsResponse> {
|
||||
return this.api.get<PaginatedAuditLogsResponse>('/audit', filters as Record<string, string | number | boolean>);
|
||||
}
|
||||
|
||||
getEntityTrail(entityType: string, entityId: string): Observable<EntityAuditTrailDto> {
|
||||
return this.api.get<EntityAuditTrailDto>(`/audit/entity/${entityType}/${entityId}`);
|
||||
}
|
||||
|
||||
getAuditMetadata(): Observable<AuditMetadataDto> {
|
||||
return this.api.get<AuditMetadataDto>('/audit/metadata');
|
||||
}
|
||||
}
|
||||
28
frontend/src/app/features/auth/auth.routes.ts
Normal file
28
frontend/src/app/features/auth/auth.routes.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
export const AUTH_ROUTES: Routes = [
|
||||
{
|
||||
path: '',
|
||||
loadComponent: () =>
|
||||
import('./email-login/email-login.component').then((m) => m.EmailLoginComponent),
|
||||
},
|
||||
{
|
||||
path: 'select',
|
||||
loadComponent: () =>
|
||||
import('./login-select/login-select.component').then((m) => m.LoginSelectComponent),
|
||||
},
|
||||
{
|
||||
path: 'department',
|
||||
loadComponent: () =>
|
||||
import('./department-login/department-login.component').then(
|
||||
(m) => m.DepartmentLoginComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'digilocker',
|
||||
loadComponent: () =>
|
||||
import('./digilocker-login/digilocker-login.component').then(
|
||||
(m) => m.DigiLockerLoginComponent
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,85 @@
|
||||
<a class="back-link" routerLink="/login">
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
Back to login options
|
||||
</a>
|
||||
|
||||
<div class="login-header">
|
||||
<div class="header-icon">
|
||||
<mat-icon>business</mat-icon>
|
||||
</div>
|
||||
<h2>Department Login</h2>
|
||||
<p class="login-subtitle">Sign in with your department credentials</p>
|
||||
</div>
|
||||
|
||||
<form [formGroup]="form" (ngSubmit)="onSubmit()" class="login-form">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Department Code</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="departmentCode"
|
||||
placeholder="e.g., FIRE_DEPT"
|
||||
autocomplete="username"
|
||||
/>
|
||||
@if (form.controls.departmentCode.hasError('required')) {
|
||||
<mat-error>Department code is required</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>API Key</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[type]="hidePassword() ? 'password' : 'text'"
|
||||
formControlName="apiKey"
|
||||
placeholder="Enter your API key"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
<mat-icon
|
||||
matSuffix
|
||||
class="password-toggle"
|
||||
(click)="togglePasswordVisibility()"
|
||||
[attr.aria-label]="hidePassword() ? 'Show password' : 'Hide password'"
|
||||
>
|
||||
{{ hidePassword() ? 'visibility_off' : 'visibility' }}
|
||||
</mat-icon>
|
||||
@if (form.controls.apiKey.hasError('required')) {
|
||||
<mat-error>API key is required</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
type="submit"
|
||||
class="submit-button"
|
||||
[disabled]="loading()"
|
||||
>
|
||||
@if (loading()) {
|
||||
<mat-spinner diameter="24"></mat-spinner>
|
||||
} @else {
|
||||
Sign In
|
||||
}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Demo Credentials (for POC) -->
|
||||
<div class="demo-credentials">
|
||||
<div class="demo-title">
|
||||
<mat-icon>info</mat-icon>
|
||||
Demo Credentials
|
||||
</div>
|
||||
<ul class="demo-list">
|
||||
<li class="demo-item">
|
||||
<span class="dept-name">Fire Department</span>
|
||||
<span class="dept-code">FIRE_DEPT</span>
|
||||
</li>
|
||||
<li class="demo-item">
|
||||
<span class="dept-name">Tourism Department</span>
|
||||
<span class="dept-code">TOURISM</span>
|
||||
</li>
|
||||
<li class="demo-item">
|
||||
<span class="dept-name">Municipality</span>
|
||||
<span class="dept-code">MUNICIPALITY</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -0,0 +1,292 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Router, RouterModule } from '@angular/router';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
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 { AuthService } from '../../../core/services/auth.service';
|
||||
import { NotificationService } from '../../../core/services/notification.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-department-login',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterModule,
|
||||
ReactiveFormsModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatProgressSpinnerModule,
|
||||
],
|
||||
templateUrl: './department-login.component.html',
|
||||
styles: [
|
||||
`
|
||||
// =============================================================================
|
||||
// DEPARTMENT LOGIN - DBIM Compliant
|
||||
// =============================================================================
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--dbim-grey-2, #8E8E8E);
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 24px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--dbim-blue-dark, #1D0A69);
|
||||
background: var(--dbim-linen, #EBEAEA);
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(135deg, var(--dbim-blue-dark, #1D0A69) 0%, #6366F1 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 16px;
|
||||
box-shadow: 0 8px 24px rgba(99, 102, 241, 0.3);
|
||||
|
||||
mat-icon {
|
||||
font-size: 32px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--dbim-brown, #150202);
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--dbim-grey-2, #8E8E8E);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.form-field-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.field-icon {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--dbim-grey-2, #8E8E8E);
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
mat-form-field {
|
||||
width: 100%;
|
||||
|
||||
::ng-deep {
|
||||
.mat-mdc-text-field-wrapper {
|
||||
background: var(--dbim-linen, #EBEAEA);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.mdc-notched-outline__leading,
|
||||
.mdc-notched-outline__notch,
|
||||
.mdc-notched-outline__trailing {
|
||||
border-color: transparent !important;
|
||||
}
|
||||
|
||||
.mat-mdc-form-field-focus-overlay {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
&.mat-focused {
|
||||
.mat-mdc-text-field-wrapper {
|
||||
background: white;
|
||||
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.mat-mdc-form-field-error-wrapper {
|
||||
padding: 4px 0 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.submit-button {
|
||||
height: 52px;
|
||||
border-radius: 12px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
background: linear-gradient(135deg, var(--dbim-blue-dark, #1D0A69) 0%, #6366F1 100%);
|
||||
box-shadow: 0 4px 16px rgba(99, 102, 241, 0.3);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(99, 102, 241, 0.4);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
mat-spinner {
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
.password-toggle {
|
||||
cursor: pointer;
|
||||
color: var(--dbim-grey-2, #8E8E8E);
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--dbim-blue-dark, #1D0A69);
|
||||
}
|
||||
}
|
||||
|
||||
.demo-credentials {
|
||||
margin-top: 24px;
|
||||
padding: 16px;
|
||||
background: rgba(13, 110, 253, 0.05);
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(13, 110, 253, 0.1);
|
||||
}
|
||||
|
||||
.demo-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--dbim-info, #0D6EFD);
|
||||
margin-bottom: 12px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.demo-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.demo-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
padding: 8px 12px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
|
||||
.dept-name {
|
||||
font-weight: 500;
|
||||
color: var(--dbim-brown, #150202);
|
||||
}
|
||||
|
||||
.dept-code {
|
||||
font-family: 'Roboto Mono', monospace;
|
||||
color: var(--dbim-grey-3, #606060);
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class DepartmentLoginComponent {
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly authService = inject(AuthService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly notification = inject(NotificationService);
|
||||
|
||||
readonly loading = signal(false);
|
||||
readonly hidePassword = signal(true);
|
||||
|
||||
readonly form = this.fb.nonNullable.group({
|
||||
departmentCode: ['', [Validators.required]],
|
||||
apiKey: ['', [Validators.required]],
|
||||
});
|
||||
|
||||
togglePasswordVisibility(): void {
|
||||
this.hidePassword.update((v) => !v);
|
||||
}
|
||||
|
||||
onSubmit(): void {
|
||||
if (this.form.invalid) {
|
||||
this.form.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading.set(true);
|
||||
const { departmentCode, apiKey } = this.form.getRawValue();
|
||||
|
||||
this.authService.departmentLogin({ departmentCode, apiKey }).subscribe({
|
||||
next: () => {
|
||||
this.notification.success('Login successful!');
|
||||
this.router.navigate(['/dashboard']);
|
||||
},
|
||||
error: (err) => {
|
||||
this.loading.set(false);
|
||||
},
|
||||
complete: () => {
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<a class="back-link" routerLink="/login">
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
Back to login options
|
||||
</a>
|
||||
|
||||
<h2>DigiLocker Login</h2>
|
||||
<p class="subtitle">Enter your DigiLocker ID to sign in or create an account</p>
|
||||
|
||||
<form [formGroup]="form" (ngSubmit)="onSubmit()" class="login-form">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>DigiLocker ID</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="digilockerId"
|
||||
placeholder="e.g., DL-GOA-001"
|
||||
autocomplete="username"
|
||||
/>
|
||||
@if (form.controls.digilockerId.hasError('required')) {
|
||||
<mat-error>DigiLocker ID is required</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Full Name (optional)</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="name"
|
||||
placeholder="Enter your full name"
|
||||
autocomplete="name"
|
||||
/>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Email (optional)</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="email"
|
||||
formControlName="email"
|
||||
placeholder="Enter your email"
|
||||
autocomplete="email"
|
||||
/>
|
||||
@if (form.controls.email.hasError('email')) {
|
||||
<mat-error>Please enter a valid email address</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Phone (optional)</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="tel"
|
||||
formControlName="phone"
|
||||
placeholder="Enter your phone number"
|
||||
autocomplete="tel"
|
||||
/>
|
||||
</mat-form-field>
|
||||
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
type="submit"
|
||||
class="submit-button"
|
||||
[disabled]="loading()"
|
||||
>
|
||||
@if (loading()) {
|
||||
<mat-spinner diameter="24"></mat-spinner>
|
||||
} @else {
|
||||
Sign In / Register
|
||||
}
|
||||
</button>
|
||||
</form>
|
||||
@@ -0,0 +1,120 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Router, RouterModule } from '@angular/router';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
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 { AuthService } from '../../../core/services/auth.service';
|
||||
import { NotificationService } from '../../../core/services/notification.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-digilocker-login',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterModule,
|
||||
ReactiveFormsModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatProgressSpinnerModule,
|
||||
],
|
||||
templateUrl: './digilocker-login.component.html',
|
||||
styles: [
|
||||
`
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: rgba(0, 0, 0, 0.54);
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 8px;
|
||||
|
||||
&:hover {
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0 0 24px;
|
||||
color: rgba(0, 0, 0, 0.54);
|
||||
font-size: 0.875rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.submit-button {
|
||||
margin-top: 8px;
|
||||
height: 48px;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class DigiLockerLoginComponent {
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly authService = inject(AuthService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly notification = inject(NotificationService);
|
||||
|
||||
readonly loading = signal(false);
|
||||
|
||||
readonly form = this.fb.nonNullable.group({
|
||||
digilockerId: ['', [Validators.required]],
|
||||
name: [''],
|
||||
email: ['', [Validators.email]],
|
||||
phone: [''],
|
||||
});
|
||||
|
||||
onSubmit(): void {
|
||||
if (this.form.invalid) {
|
||||
this.form.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading.set(true);
|
||||
const values = this.form.getRawValue();
|
||||
|
||||
this.authService
|
||||
.digiLockerLogin({
|
||||
digilockerId: values.digilockerId,
|
||||
name: values.name || undefined,
|
||||
email: values.email || undefined,
|
||||
phone: values.phone || undefined,
|
||||
})
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.notification.success('Login successful!');
|
||||
this.router.navigate(['/dashboard']);
|
||||
},
|
||||
error: (err) => {
|
||||
this.loading.set(false);
|
||||
},
|
||||
complete: () => {
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
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';
|
||||
|
||||
interface DemoAccount {
|
||||
role: string;
|
||||
email: string;
|
||||
password: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-email-login',
|
||||
standalone: true,
|
||||
imports: [
|
||||
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>
|
||||
|
||||
<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-form-field>
|
||||
|
||||
<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-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>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<mat-divider class="divider"></mat-divider>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.email-login-container {
|
||||
min-height: 100vh;
|
||||
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;
|
||||
margin-bottom: 24px;
|
||||
|
||||
mat-card-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
font-size: 48px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
.divider {
|
||||
margin: 32px 0 24px;
|
||||
}
|
||||
|
||||
.demo-accounts {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.demo-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0 0 8px;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 500;
|
||||
color: #1976d2;
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.demo-subtitle {
|
||||
color: rgba(0, 0, 0, 0.54);
|
||||
font-size: 0.875rem;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
.demo-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.demo-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: #1976d2;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border-color: #1976d2;
|
||||
background-color: #e3f2fd;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 32px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.demo-info {
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
background-color: #fff3e0;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
color: #e65100;
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
code {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class EmailLoginComponent {
|
||||
loginForm: FormGroup;
|
||||
loading = false;
|
||||
hidePassword = true;
|
||||
selectedDemo: string | null = null;
|
||||
|
||||
demoAccounts: DemoAccount[] = [
|
||||
{
|
||||
role: 'Admin',
|
||||
email: 'admin@goa.gov.in',
|
||||
password: 'Admin@123',
|
||||
description: 'System administrator with full access',
|
||||
icon: 'admin_panel_settings',
|
||||
},
|
||||
{
|
||||
role: 'Fire Department',
|
||||
email: 'fire@goa.gov.in',
|
||||
password: 'Fire@123',
|
||||
description: 'Fire safety inspection officer',
|
||||
icon: 'local_fire_department',
|
||||
},
|
||||
{
|
||||
role: 'Tourism',
|
||||
email: 'tourism@goa.gov.in',
|
||||
password: 'Tourism@123',
|
||||
description: 'Tourism license 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',
|
||||
password: 'Citizen@123',
|
||||
description: 'Citizen applying for licenses',
|
||||
icon: 'person',
|
||||
},
|
||||
];
|
||||
|
||||
constructor(
|
||||
private fb: FormBuilder,
|
||||
private authService: AuthService,
|
||||
private router: Router,
|
||||
private snackBar: MatSnackBar
|
||||
) {
|
||||
this.loginForm = this.fb.group({
|
||||
email: ['', [Validators.required, Validators.email]],
|
||||
password: ['', [Validators.required]],
|
||||
});
|
||||
}
|
||||
|
||||
fillDemoCredentials(account: DemoAccount): void {
|
||||
this.selectedDemo = account.email;
|
||||
this.loginForm.patchValue({
|
||||
email: account.email,
|
||||
password: account.password,
|
||||
});
|
||||
}
|
||||
|
||||
getRoleColor(role: string): string {
|
||||
const colors: { [key: string]: string } = {
|
||||
Admin: '#d32f2f',
|
||||
'Fire Department': '#f57c00',
|
||||
Tourism: '#1976d2',
|
||||
Municipality: '#388e3c',
|
||||
Citizen: '#7b1fa2',
|
||||
};
|
||||
return colors[role] || '#666';
|
||||
}
|
||||
|
||||
async onSubmit(): Promise<void> {
|
||||
if (this.loginForm.invalid) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
const { email, password } = this.loginForm.value;
|
||||
|
||||
try {
|
||||
await this.authService.login(email, password);
|
||||
this.snackBar.open('Login successful!', 'Close', {
|
||||
duration: 3000,
|
||||
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']);
|
||||
}
|
||||
} catch (error: any) {
|
||||
this.snackBar.open(
|
||||
error?.error?.message || 'Invalid email or password',
|
||||
'Close',
|
||||
{
|
||||
duration: 5000,
|
||||
panelClass: ['error-snackbar'],
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
import { Component } 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';
|
||||
|
||||
@Component({
|
||||
selector: 'app-login-select',
|
||||
standalone: true,
|
||||
imports: [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>
|
||||
|
||||
<!-- Login Options -->
|
||||
<div class="login-options">
|
||||
<!-- Department Login -->
|
||||
<a
|
||||
class="login-option department"
|
||||
[routerLink]="['department']"
|
||||
matRipple
|
||||
[matRippleColor]="'rgba(99, 102, 241, 0.1)'"
|
||||
>
|
||||
<div class="option-icon-wrapper">
|
||||
<svg xmlns="http://www.w3.org/2000/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>
|
||||
<mat-icon class="option-arrow">arrow_forward</mat-icon>
|
||||
</a>
|
||||
|
||||
<!-- DigiLocker Login -->
|
||||
<a
|
||||
class="login-option citizen"
|
||||
[routerLink]="['digilocker']"
|
||||
matRipple
|
||||
[matRippleColor]="'rgba(16, 185, 129, 0.1)'"
|
||||
>
|
||||
<div class="option-icon-wrapper citizen">
|
||||
<svg xmlns="http://www.w3.org/2000/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>
|
||||
<mat-icon class="option-arrow">arrow_forward</mat-icon>
|
||||
</a>
|
||||
|
||||
<!-- Admin Login -->
|
||||
<a
|
||||
class="login-option admin"
|
||||
[routerLink]="['email']"
|
||||
matRipple
|
||||
[matRippleColor]="'rgba(139, 92, 246, 0.1)'"
|
||||
>
|
||||
<div class="option-icon-wrapper admin">
|
||||
<svg xmlns="http://www.w3.org/2000/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>
|
||||
<mat-icon class="option-arrow">arrow_forward</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>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
// =============================================================================
|
||||
// LOGIN SELECT - DBIM Compliant World-Class Design
|
||||
// =============================================================================
|
||||
|
||||
.login-select {
|
||||
animation: fadeIn 0.4s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// HEADER
|
||||
// =============================================================================
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--dbim-brown, #150202);
|
||||
margin: 0 0 8px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
font-size: 15px;
|
||||
color: var(--dbim-grey-2, #8E8E8E);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// LOGIN OPTIONS
|
||||
// =============================================================================
|
||||
.login-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.login-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
background: var(--dbim-linen, #EBEAEA);
|
||||
border-radius: 16px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border: 2px solid transparent;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 4px;
|
||||
height: 100%;
|
||||
background: linear-gradient(180deg, var(--dbim-blue-dark, #1D0A69) 0%, var(--crypto-indigo, #6366F1) 100%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: white;
|
||||
border-color: rgba(99, 102, 241, 0.2);
|
||||
transform: translateX(4px);
|
||||
box-shadow: 0 4px 20px rgba(29, 10, 105, 0.1);
|
||||
|
||||
&::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.option-arrow {
|
||||
transform: translateX(4px);
|
||||
color: var(--dbim-blue-dark, #1D0A69);
|
||||
}
|
||||
}
|
||||
|
||||
&.citizen {
|
||||
&::before {
|
||||
background: linear-gradient(180deg, #059669 0%, #10B981 100%);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(16, 185, 129, 0.2);
|
||||
|
||||
.option-arrow {
|
||||
color: #059669;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.admin {
|
||||
&::before {
|
||||
background: linear-gradient(180deg, #7C3AED 0%, #8B5CF6 100%);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(139, 92, 246, 0.2);
|
||||
|
||||
.option-arrow {
|
||||
color: #7C3AED;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ICON WRAPPER
|
||||
// =============================================================================
|
||||
.option-icon-wrapper {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(135deg, var(--dbim-blue-dark, #1D0A69) 0%, var(--crypto-indigo, #6366F1) 100%);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CONTENT
|
||||
// =============================================================================
|
||||
.option-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.option-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--dbim-brown, #150202);
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.option-desc {
|
||||
font-size: 13px;
|
||||
color: var(--dbim-grey-2, #8E8E8E);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.option-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
background: rgba(99, 102, 241, 0.1);
|
||||
border-radius: 20px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--dbim-blue-dark, #1D0A69);
|
||||
|
||||
mat-icon {
|
||||
font-size: 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
&.citizen {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
&.admin {
|
||||
background: rgba(139, 92, 246, 0.1);
|
||||
color: #7C3AED;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ARROW
|
||||
// =============================================================================
|
||||
.option-arrow {
|
||||
color: var(--dbim-grey-1, #C6C6C6);
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// HELP SECTION
|
||||
// =============================================================================
|
||||
.help-section {
|
||||
text-align: center;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--dbim-linen, #EBEAEA);
|
||||
}
|
||||
|
||||
.help-text {
|
||||
font-size: 13px;
|
||||
color: var(--dbim-grey-2, #8E8E8E);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.help-link {
|
||||
color: var(--dbim-info, #0D6EFD);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class LoginSelectComponent {}
|
||||
@@ -0,0 +1,610 @@
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
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 { AdminStatsDto } from '../../../api/models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-dashboard',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterModule,
|
||||
MatCardModule,
|
||||
MatIconModule,
|
||||
MatButtonModule,
|
||||
MatProgressSpinnerModule,
|
||||
StatusBadgeComponent,
|
||||
BlockchainExplorerMiniComponent,
|
||||
],
|
||||
template: `
|
||||
<div class="page-container">
|
||||
<!-- Welcome Section -->
|
||||
<section class="welcome-section">
|
||||
<div class="welcome-content">
|
||||
<div class="welcome-text">
|
||||
<span class="greeting">Admin Dashboard</span>
|
||||
<h1>Platform Overview</h1>
|
||||
<p class="subtitle">Monitor and manage the Goa GEL Blockchain Platform</p>
|
||||
</div>
|
||||
<div class="quick-actions">
|
||||
<button mat-raised-button class="action-btn primary" routerLink="/admin">
|
||||
<mat-icon>admin_panel_settings</mat-icon>
|
||||
Admin Portal
|
||||
</button>
|
||||
<button mat-stroked-button class="action-btn" routerLink="/requests">
|
||||
<mat-icon>list_alt</mat-icon>
|
||||
All Requests
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="loading-container">
|
||||
<mat-spinner diameter="48"></mat-spinner>
|
||||
<p>Loading dashboard...</p>
|
||||
</div>
|
||||
} @else if (stats()) {
|
||||
<!-- Stats Cards -->
|
||||
<section class="stats-section">
|
||||
<div class="stats-grid">
|
||||
<mat-card class="stat-card requests" routerLink="/requests">
|
||||
<div class="stat-icon-wrapper">
|
||||
<mat-icon>description</mat-icon>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stats()!.totalRequests }}</div>
|
||||
<div class="stat-label">Total Requests</div>
|
||||
</div>
|
||||
<div class="stat-decoration"></div>
|
||||
</mat-card>
|
||||
|
||||
<mat-card class="stat-card approvals">
|
||||
<div class="stat-icon-wrapper">
|
||||
<mat-icon>check_circle</mat-icon>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stats()!.totalApprovals }}</div>
|
||||
<div class="stat-label">Approvals</div>
|
||||
</div>
|
||||
<div class="stat-decoration"></div>
|
||||
</mat-card>
|
||||
|
||||
<mat-card class="stat-card documents">
|
||||
<div class="stat-icon-wrapper">
|
||||
<mat-icon>folder_open</mat-icon>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stats()!.totalDocuments }}</div>
|
||||
<div class="stat-label">Documents</div>
|
||||
</div>
|
||||
<div class="stat-decoration"></div>
|
||||
</mat-card>
|
||||
|
||||
<mat-card class="stat-card departments" routerLink="/departments">
|
||||
<div class="stat-icon-wrapper">
|
||||
<mat-icon>business</mat-icon>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stats()!.totalDepartments }}</div>
|
||||
<div class="stat-label">Departments</div>
|
||||
</div>
|
||||
<div class="stat-decoration"></div>
|
||||
</mat-card>
|
||||
|
||||
<mat-card class="stat-card applicants">
|
||||
<div class="stat-icon-wrapper">
|
||||
<mat-icon>people</mat-icon>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stats()!.totalApplicants }}</div>
|
||||
<div class="stat-label">Applicants</div>
|
||||
</div>
|
||||
<div class="stat-decoration"></div>
|
||||
</mat-card>
|
||||
|
||||
<mat-card class="stat-card blockchain">
|
||||
<div class="stat-icon-wrapper">
|
||||
<mat-icon>link</mat-icon>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stats()!.totalBlockchainTransactions }}</div>
|
||||
<div class="stat-label">Blockchain Tx</div>
|
||||
</div>
|
||||
<div class="stat-decoration"></div>
|
||||
</mat-card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Main Content Grid -->
|
||||
<div class="content-grid">
|
||||
<!-- Left Column -->
|
||||
<div class="content-main">
|
||||
<!-- Requests by Status -->
|
||||
<mat-card class="section-card">
|
||||
<div class="card-header">
|
||||
<div class="header-left">
|
||||
<mat-icon>pie_chart</mat-icon>
|
||||
<h2>Requests by Status</h2>
|
||||
</div>
|
||||
<button mat-button color="primary" routerLink="/requests">
|
||||
View All
|
||||
<mat-icon>arrow_forward</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="status-grid">
|
||||
@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>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</mat-card>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<mat-card class="section-card">
|
||||
<div class="card-header">
|
||||
<div class="header-left">
|
||||
<mat-icon>flash_on</mat-icon>
|
||||
<h2>Quick Actions</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="actions-grid">
|
||||
<div class="action-item" routerLink="/departments">
|
||||
<div class="action-icon departments">
|
||||
<mat-icon>business</mat-icon>
|
||||
</div>
|
||||
<span>Manage Departments</span>
|
||||
</div>
|
||||
<div class="action-item" routerLink="/workflows">
|
||||
<div class="action-icon workflows">
|
||||
<mat-icon>account_tree</mat-icon>
|
||||
</div>
|
||||
<span>Manage Workflows</span>
|
||||
</div>
|
||||
<div class="action-item" routerLink="/audit">
|
||||
<div class="action-icon audit">
|
||||
<mat-icon>history</mat-icon>
|
||||
</div>
|
||||
<span>View Audit Logs</span>
|
||||
</div>
|
||||
<div class="action-item" routerLink="/webhooks">
|
||||
<div class="action-icon webhooks">
|
||||
<mat-icon>webhook</mat-icon>
|
||||
</div>
|
||||
<span>Webhooks</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Blockchain Activity -->
|
||||
<div class="content-sidebar">
|
||||
<app-blockchain-explorer-mini [showViewAll]="true" [refreshInterval]="15000"></app-blockchain-explorer-mini>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.page-container {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Welcome Section */
|
||||
.welcome-section {
|
||||
background: linear-gradient(135deg, var(--dbim-blue-dark, #1D0A69) 0%, var(--dbim-blue-mid, #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-content {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
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;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 8px 0;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
opacity: 0.85;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
&.primary {
|
||||
background: white;
|
||||
color: var(--dbim-blue-dark, #1D0A69);
|
||||
}
|
||||
|
||||
&:not(.primary) {
|
||||
color: white;
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 64px;
|
||||
gap: 16px;
|
||||
|
||||
p {
|
||||
color: var(--dbim-grey-2, #8E8E8E);
|
||||
}
|
||||
}
|
||||
|
||||
/* Stats Section */
|
||||
.stats-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 24px;
|
||||
border-radius: 16px !important;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
color: white;
|
||||
|
||||
&: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-icon-wrapper {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(10px);
|
||||
flex-shrink: 0;
|
||||
|
||||
mat-icon {
|
||||
font-size: 26px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
flex: 1;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.9;
|
||||
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);
|
||||
}
|
||||
|
||||
/* Content Grid */
|
||||
.content-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 400px;
|
||||
gap: 24px;
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.content-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.section-card {
|
||||
border-radius: 16px !important;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid var(--dbim-linen, #EBEAEA);
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
mat-icon {
|
||||
color: var(--dbim-blue-mid, #2563EB);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--dbim-brown, #150202);
|
||||
}
|
||||
}
|
||||
|
||||
button mat-icon {
|
||||
margin-left: 4px;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
.status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 14px 16px;
|
||||
background: var(--dbim-linen, #EBEAEA);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.count {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--dbim-brown, #150202);
|
||||
}
|
||||
}
|
||||
|
||||
/* Quick Actions */
|
||||
.actions-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.action-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-align: center;
|
||||
|
||||
&:hover {
|
||||
background: var(--dbim-linen, #EBEAEA);
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 0.85rem;
|
||||
color: var(--dbim-brown, #150202);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
mat-icon {
|
||||
font-size: 26px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
&.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;
|
||||
}
|
||||
|
||||
&.audit {
|
||||
background: linear-gradient(135deg, #f59e0b, #fbbf24);
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.webhooks {
|
||||
background: linear-gradient(135deg, #059669, #10b981);
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.content-sidebar {
|
||||
@media (max-width: 1200px) {
|
||||
order: -1;
|
||||
}
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class AdminDashboardComponent implements OnInit {
|
||||
private readonly api = inject(ApiService);
|
||||
|
||||
readonly loading = signal(true);
|
||||
readonly stats = signal<AdminStatsDto | null>(null);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadStats();
|
||||
}
|
||||
|
||||
private loadStats(): void {
|
||||
this.api.get<AdminStatsDto>('/admin/stats').subscribe({
|
||||
next: (data) => {
|
||||
this.stats.set(data);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
// Use mock data for demo when API is unavailable
|
||||
this.loadMockStats();
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private loadMockStats(): void {
|
||||
const mockStats: AdminStatsDto = {
|
||||
totalRequests: 156,
|
||||
totalApprovals: 89,
|
||||
totalDocuments: 423,
|
||||
totalDepartments: 12,
|
||||
totalApplicants: 67,
|
||||
totalBlockchainTransactions: 234,
|
||||
averageProcessingTime: 4.5,
|
||||
requestsByStatus: [
|
||||
{ status: 'DRAFT', count: 12 },
|
||||
{ status: 'SUBMITTED', count: 23 },
|
||||
{ status: 'IN_REVIEW', count: 18 },
|
||||
{ status: 'APPROVED', count: 89 },
|
||||
{ status: 'REJECTED', count: 8 },
|
||||
{ status: 'COMPLETED', count: 6 },
|
||||
],
|
||||
requestsByType: [
|
||||
{ type: 'NEW_LICENSE', count: 98 },
|
||||
{ type: 'RENEWAL', count: 42 },
|
||||
{ type: 'AMENDMENT', count: 16 },
|
||||
],
|
||||
departmentStats: [],
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
this.stats.set(mockStats);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,795 @@
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
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 { RequestResponseDto, PaginatedRequestsResponse } from '../../../api/models';
|
||||
|
||||
interface ApplicantStats {
|
||||
totalRequests: number;
|
||||
pendingRequests: number;
|
||||
approvedLicenses: number;
|
||||
documentsUploaded: number;
|
||||
blockchainRecords: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-applicant-dashboard',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterModule,
|
||||
MatCardModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatTooltipModule,
|
||||
MatChipsModule,
|
||||
StatusBadgeComponent,
|
||||
BlockchainExplorerMiniComponent,
|
||||
],
|
||||
template: `
|
||||
<div class="page-container">
|
||||
<!-- Welcome Section -->
|
||||
<section class="welcome-section">
|
||||
<div class="welcome-content">
|
||||
<div class="welcome-text">
|
||||
<span class="greeting">{{ getGreeting() }}</span>
|
||||
<h1>{{ currentUser()?.name || 'Applicant' }}</h1>
|
||||
<p class="subtitle">Manage your license applications and track their progress</p>
|
||||
</div>
|
||||
<div class="quick-actions">
|
||||
<button mat-raised-button color="primary" class="action-btn primary" routerLink="/requests/new">
|
||||
<mat-icon>add</mat-icon>
|
||||
New Application
|
||||
</button>
|
||||
<button mat-stroked-button class="action-btn" routerLink="/requests">
|
||||
<mat-icon>list_alt</mat-icon>
|
||||
My Requests
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<section class="stats-section">
|
||||
<div class="stats-grid">
|
||||
<mat-card class="stat-card pending" routerLink="/requests" [queryParams]="{ status: 'IN_REVIEW' }">
|
||||
<div class="stat-icon-wrapper">
|
||||
<mat-icon>hourglass_top</mat-icon>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ pendingCount() }}</div>
|
||||
<div class="stat-label">Pending Review</div>
|
||||
</div>
|
||||
<div class="stat-decoration"></div>
|
||||
</mat-card>
|
||||
|
||||
<mat-card class="stat-card approved" routerLink="/requests" [queryParams]="{ status: 'APPROVED' }">
|
||||
<div class="stat-icon-wrapper">
|
||||
<mat-icon>verified</mat-icon>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ approvedCount() }}</div>
|
||||
<div class="stat-label">Approved Licenses</div>
|
||||
</div>
|
||||
<div class="stat-decoration"></div>
|
||||
</mat-card>
|
||||
|
||||
<mat-card class="stat-card documents" routerLink="/requests">
|
||||
<div class="stat-icon-wrapper">
|
||||
<mat-icon>folder_open</mat-icon>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ documentsCount() }}</div>
|
||||
<div class="stat-label">Documents Uploaded</div>
|
||||
</div>
|
||||
<div class="stat-decoration"></div>
|
||||
</mat-card>
|
||||
|
||||
<mat-card class="stat-card blockchain">
|
||||
<div class="stat-icon-wrapper">
|
||||
<mat-icon>link</mat-icon>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ blockchainCount() }}</div>
|
||||
<div class="stat-label">Blockchain Records</div>
|
||||
</div>
|
||||
<div class="stat-decoration"></div>
|
||||
</mat-card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Main Content Grid -->
|
||||
<div class="content-grid">
|
||||
<!-- Left Column: Recent Requests -->
|
||||
<div class="content-main">
|
||||
<mat-card class="section-card">
|
||||
<div class="card-header">
|
||||
<div class="header-left">
|
||||
<mat-icon>description</mat-icon>
|
||||
<h2>Recent Applications</h2>
|
||||
</div>
|
||||
<button mat-button color="primary" routerLink="/requests">
|
||||
View All
|
||||
<mat-icon>arrow_forward</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card-content">
|
||||
@if (loading()) {
|
||||
<div class="loading-container">
|
||||
<mat-spinner diameter="32"></mat-spinner>
|
||||
</div>
|
||||
} @else if (recentRequests().length === 0) {
|
||||
<div class="empty-state-inline">
|
||||
<mat-icon>inbox</mat-icon>
|
||||
<p>No applications yet</p>
|
||||
<button mat-stroked-button color="primary" routerLink="/requests/new">
|
||||
Create Your First Application
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="requests-list">
|
||||
@for (request of recentRequests(); track request.id) {
|
||||
<div class="request-item" [routerLink]="['/requests', request.id]">
|
||||
<div class="request-left">
|
||||
<div class="request-icon" [class]="getStatusClass(request.status)">
|
||||
<mat-icon>{{ getStatusIcon(request.status) }}</mat-icon>
|
||||
</div>
|
||||
<div class="request-info">
|
||||
<span class="request-number">{{ request.requestNumber }}</span>
|
||||
<span class="request-type">
|
||||
{{ formatRequestType(request.requestType) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="request-right">
|
||||
<app-status-badge [status]="request.status" />
|
||||
<span class="request-date">{{ formatDate(request.createdAt) }}</span>
|
||||
<mat-icon class="chevron">chevron_right</mat-icon>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</mat-card>
|
||||
|
||||
<!-- Quick Actions Card -->
|
||||
<mat-card class="section-card quick-actions-card">
|
||||
<div class="card-header">
|
||||
<div class="header-left">
|
||||
<mat-icon>flash_on</mat-icon>
|
||||
<h2>Quick Actions</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="actions-grid">
|
||||
<div class="action-item" routerLink="/requests/new">
|
||||
<div class="action-icon license">
|
||||
<mat-icon>post_add</mat-icon>
|
||||
</div>
|
||||
<span>New License</span>
|
||||
</div>
|
||||
<div class="action-item" routerLink="/requests/new" [queryParams]="{ type: 'RENEWAL' }">
|
||||
<div class="action-icon renewal">
|
||||
<mat-icon>autorenew</mat-icon>
|
||||
</div>
|
||||
<span>Renew License</span>
|
||||
</div>
|
||||
<div class="action-item" routerLink="/requests">
|
||||
<div class="action-icon track">
|
||||
<mat-icon>track_changes</mat-icon>
|
||||
</div>
|
||||
<span>Track Status</span>
|
||||
</div>
|
||||
<div class="action-item" routerLink="/help">
|
||||
<div class="action-icon help">
|
||||
<mat-icon>help_outline</mat-icon>
|
||||
</div>
|
||||
<span>Get Help</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
`,
|
||||
styles: [`
|
||||
.page-container {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Welcome Section */
|
||||
.welcome-section {
|
||||
background: linear-gradient(135deg, var(--dbim-blue-dark, #1D0A69) 0%, var(--dbim-blue-mid, #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-content {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
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;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 8px 0;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
&:not(.primary) {
|
||||
color: white;
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Stats Section */
|
||||
.stats-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 24px;
|
||||
border-radius: 16px !important;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
color: white;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--shadow-elevated, 0 8px 24px rgba(0, 0, 0, 0.15));
|
||||
}
|
||||
|
||||
&.pending {
|
||||
background: linear-gradient(135deg, #f59e0b 0%, #fbbf24 100%);
|
||||
}
|
||||
|
||||
&.approved {
|
||||
background: linear-gradient(135deg, #059669 0%, #10b981 100%);
|
||||
}
|
||||
|
||||
&.documents {
|
||||
background: linear-gradient(135deg, var(--dbim-blue-dark, #1D0A69) 0%, var(--dbim-blue-mid, #2563EB) 100%);
|
||||
}
|
||||
|
||||
&.blockchain {
|
||||
background: linear-gradient(135deg, #7c3aed 0%, #a78bfa 100%);
|
||||
}
|
||||
}
|
||||
|
||||
.stat-icon-wrapper {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(10px);
|
||||
flex-shrink: 0;
|
||||
|
||||
mat-icon {
|
||||
font-size: 28px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
flex: 1;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.9;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.stat-decoration {
|
||||
position: absolute;
|
||||
top: -20px;
|
||||
right: -20px;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Content Grid */
|
||||
.content-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 400px;
|
||||
gap: 24px;
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.content-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.section-card {
|
||||
border-radius: 16px !important;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid var(--dbim-linen, #EBEAEA);
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
mat-icon {
|
||||
color: var(--dbim-blue-mid, #2563EB);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--dbim-brown, #150202);
|
||||
}
|
||||
}
|
||||
|
||||
button mat-icon {
|
||||
margin-left: 4px;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding: 16px 24px 24px;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 48px;
|
||||
}
|
||||
|
||||
.empty-state-inline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 48px 24px;
|
||||
color: var(--dbim-grey-2, #8E8E8E);
|
||||
|
||||
mat-icon {
|
||||
font-size: 48px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Requests List */
|
||||
.requests-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.request-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 0;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
border-bottom: 1px solid var(--dbim-linen, #EBEAEA);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
margin: 0 -24px;
|
||||
padding: 16px 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.request-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.request-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
mat-icon {
|
||||
font-size: 22px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
&.draft {
|
||||
background: rgba(158, 158, 158, 0.1);
|
||||
color: #9e9e9e;
|
||||
}
|
||||
|
||||
&.submitted {
|
||||
background: rgba(33, 150, 243, 0.1);
|
||||
color: #2196f3;
|
||||
}
|
||||
|
||||
&.in-review {
|
||||
background: rgba(255, 152, 0, 0.1);
|
||||
color: #ff9800;
|
||||
}
|
||||
|
||||
&.approved {
|
||||
background: rgba(76, 175, 80, 0.1);
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
&.rejected {
|
||||
background: rgba(244, 67, 54, 0.1);
|
||||
color: #f44336;
|
||||
}
|
||||
}
|
||||
|
||||
.request-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.request-number {
|
||||
font-weight: 600;
|
||||
color: var(--dbim-brown, #150202);
|
||||
}
|
||||
|
||||
.request-type {
|
||||
font-size: 0.8rem;
|
||||
color: var(--dbim-grey-2, #8E8E8E);
|
||||
}
|
||||
|
||||
.request-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.request-date {
|
||||
font-size: 0.8rem;
|
||||
color: var(--dbim-grey-2, #8E8E8E);
|
||||
}
|
||||
|
||||
.chevron {
|
||||
color: var(--dbim-grey-1, #C6C6C6);
|
||||
}
|
||||
|
||||
/* Quick Actions Card */
|
||||
.actions-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.action-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-align: center;
|
||||
|
||||
&:hover {
|
||||
background: var(--dbim-linen, #EBEAEA);
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 0.85rem;
|
||||
color: var(--dbim-brown, #150202);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 14px;
|
||||
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;
|
||||
}
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class ApplicantDashboardComponent implements OnInit {
|
||||
private readonly api = inject(ApiService);
|
||||
private readonly authService = inject(AuthService);
|
||||
|
||||
readonly currentUser = this.authService.currentUser;
|
||||
readonly loading = signal(true);
|
||||
readonly recentRequests = signal<RequestResponseDto[]>([]);
|
||||
readonly pendingCount = signal(0);
|
||||
readonly approvedCount = signal(0);
|
||||
readonly documentsCount = signal(0);
|
||||
readonly blockchainCount = signal(0);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
getGreeting(): string {
|
||||
const hour = new Date().getHours();
|
||||
if (hour < 12) return 'Good Morning';
|
||||
if (hour < 17) return 'Good Afternoon';
|
||||
return 'Good Evening';
|
||||
}
|
||||
|
||||
getStatusClass(status: string): string {
|
||||
return status.toLowerCase().replace(/_/g, '-');
|
||||
}
|
||||
|
||||
getStatusIcon(status: string): string {
|
||||
const icons: Record<string, string> = {
|
||||
DRAFT: 'edit_note',
|
||||
SUBMITTED: 'send',
|
||||
IN_REVIEW: 'hourglass_top',
|
||||
APPROVED: 'check_circle',
|
||||
REJECTED: 'cancel',
|
||||
COMPLETED: 'verified',
|
||||
};
|
||||
return icons[status] || 'description';
|
||||
}
|
||||
|
||||
formatRequestType(type: string): string {
|
||||
return type.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
|
||||
}
|
||||
|
||||
formatDate(date: string): string {
|
||||
const d = new Date(date);
|
||||
const now = new Date();
|
||||
const diffDays = Math.floor((now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 0) return 'Today';
|
||||
if (diffDays === 1) return 'Yesterday';
|
||||
if (diffDays < 7) return `${diffDays} days ago`;
|
||||
return d.toLocaleDateString('en-IN', { day: 'numeric', month: 'short' });
|
||||
}
|
||||
|
||||
private loadData(): void {
|
||||
const user = this.authService.getCurrentUser();
|
||||
if (!user) {
|
||||
this.loading.set(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Load requests
|
||||
this.api
|
||||
.get<PaginatedRequestsResponse>('/requests', { applicantId: user.id, limit: 5 })
|
||||
.subscribe({
|
||||
next: (response) => {
|
||||
const requests = response.data || [];
|
||||
this.recentRequests.set(requests);
|
||||
this.calculateCounts(requests);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
// Use mock data for demo
|
||||
this.loadMockData();
|
||||
},
|
||||
});
|
||||
|
||||
// Load applicant stats
|
||||
this.api.get<ApplicantStats>(`/applicants/${user.id}/stats`).subscribe({
|
||||
next: (stats) => {
|
||||
this.documentsCount.set(stats.documentsUploaded);
|
||||
this.blockchainCount.set(stats.blockchainRecords);
|
||||
},
|
||||
error: () => {
|
||||
// Mock values for demo
|
||||
this.documentsCount.set(12);
|
||||
this.blockchainCount.set(8);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private loadMockData(): void {
|
||||
const mockRequests: RequestResponseDto[] = [
|
||||
{
|
||||
id: '1',
|
||||
requestNumber: 'REQ-2026-0042',
|
||||
requestType: 'NEW_LICENSE',
|
||||
status: 'IN_REVIEW',
|
||||
applicantId: '1',
|
||||
createdAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
metadata: {},
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
requestNumber: 'REQ-2026-0038',
|
||||
requestType: 'RENEWAL',
|
||||
status: 'APPROVED',
|
||||
applicantId: '1',
|
||||
createdAt: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
metadata: {},
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
requestNumber: 'REQ-2026-0035',
|
||||
requestType: 'AMENDMENT',
|
||||
status: 'COMPLETED',
|
||||
applicantId: '1',
|
||||
createdAt: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
metadata: {},
|
||||
},
|
||||
] as RequestResponseDto[];
|
||||
|
||||
this.recentRequests.set(mockRequests);
|
||||
this.pendingCount.set(1);
|
||||
this.approvedCount.set(2);
|
||||
}
|
||||
|
||||
private calculateCounts(requests: RequestResponseDto[]): void {
|
||||
this.pendingCount.set(
|
||||
requests.filter((r) => ['SUBMITTED', 'IN_REVIEW'].includes(r.status)).length
|
||||
);
|
||||
this.approvedCount.set(
|
||||
requests.filter((r) => ['APPROVED', 'COMPLETED'].includes(r.status)).length
|
||||
);
|
||||
}
|
||||
}
|
||||
34
frontend/src/app/features/dashboard/dashboard.component.ts
Normal file
34
frontend/src/app/features/dashboard/dashboard.component.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { AuthService } from '../../core/services/auth.service';
|
||||
import { AdminDashboardComponent } from './admin-dashboard/admin-dashboard.component';
|
||||
import { DepartmentDashboardComponent } from './department-dashboard/department-dashboard.component';
|
||||
import { ApplicantDashboardComponent } from './applicant-dashboard/applicant-dashboard.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-dashboard',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
AdminDashboardComponent,
|
||||
DepartmentDashboardComponent,
|
||||
ApplicantDashboardComponent,
|
||||
],
|
||||
template: `
|
||||
@switch (userType()) {
|
||||
@case ('ADMIN') {
|
||||
<app-admin-dashboard />
|
||||
}
|
||||
@case ('DEPARTMENT') {
|
||||
<app-department-dashboard />
|
||||
}
|
||||
@case ('APPLICANT') {
|
||||
<app-applicant-dashboard />
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class DashboardComponent {
|
||||
private readonly authService = inject(AuthService);
|
||||
readonly userType = this.authService.userType;
|
||||
}
|
||||
9
frontend/src/app/features/dashboard/dashboard.routes.ts
Normal file
9
frontend/src/app/features/dashboard/dashboard.routes.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
export const DASHBOARD_ROUTES: Routes = [
|
||||
{
|
||||
path: '',
|
||||
loadComponent: () =>
|
||||
import('./dashboard.component').then((m) => m.DashboardComponent),
|
||||
},
|
||||
];
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,307 @@
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatDividerModule } from '@angular/material/divider';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { PageHeaderComponent } from '../../../shared/components/page-header/page-header.component';
|
||||
import { StatusBadgeComponent } from '../../../shared/components/status-badge/status-badge.component';
|
||||
import { ConfirmDialogComponent } from '../../../shared/components/confirm-dialog/confirm-dialog.component';
|
||||
import { DepartmentService } from '../services/department.service';
|
||||
import { NotificationService } from '../../../core/services/notification.service';
|
||||
import { DepartmentResponseDto } from '../../../api/models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-department-detail',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterModule,
|
||||
MatCardModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatDividerModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatDialogModule,
|
||||
PageHeaderComponent,
|
||||
StatusBadgeComponent,
|
||||
],
|
||||
template: `
|
||||
<div class="page-container">
|
||||
@if (loading()) {
|
||||
<div class="loading-container">
|
||||
<mat-spinner diameter="48"></mat-spinner>
|
||||
</div>
|
||||
} @else if (department(); as dept) {
|
||||
<app-page-header [title]="dept.name" [subtitle]="dept.code">
|
||||
<button mat-button routerLink="/departments">
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
Back
|
||||
</button>
|
||||
<button mat-raised-button [routerLink]="['edit']">
|
||||
<mat-icon>edit</mat-icon>
|
||||
Edit
|
||||
</button>
|
||||
</app-page-header>
|
||||
|
||||
<div class="detail-grid">
|
||||
<mat-card class="info-card">
|
||||
<mat-card-header>
|
||||
<mat-card-title>Department Information</mat-card-title>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<div class="info-row">
|
||||
<span class="label">Status</span>
|
||||
<app-status-badge [status]="dept.isActive ? 'ACTIVE' : 'INACTIVE'" />
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">Code</span>
|
||||
<span class="value code">{{ dept.code }}</span>
|
||||
</div>
|
||||
@if (dept.description) {
|
||||
<div class="info-row">
|
||||
<span class="label">Description</span>
|
||||
<span class="value">{{ dept.description }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (dept.contactEmail) {
|
||||
<div class="info-row">
|
||||
<span class="label">Email</span>
|
||||
<span class="value">{{ dept.contactEmail }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (dept.contactPhone) {
|
||||
<div class="info-row">
|
||||
<span class="label">Phone</span>
|
||||
<span class="value">{{ dept.contactPhone }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (dept.webhookUrl) {
|
||||
<div class="info-row">
|
||||
<span class="label">Webhook URL</span>
|
||||
<span class="value url">{{ dept.webhookUrl }}</span>
|
||||
</div>
|
||||
}
|
||||
<div class="info-row">
|
||||
<span class="label">Created</span>
|
||||
<span class="value">{{ dept.createdAt | date: 'medium' }}</span>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
<mat-card class="actions-card">
|
||||
<mat-card-header>
|
||||
<mat-card-title>Actions</mat-card-title>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<div class="action-buttons">
|
||||
<button
|
||||
mat-stroked-button
|
||||
(click)="toggleActive()"
|
||||
[color]="dept.isActive ? 'warn' : 'primary'"
|
||||
>
|
||||
<mat-icon>{{ dept.isActive ? 'block' : 'check_circle' }}</mat-icon>
|
||||
{{ dept.isActive ? 'Deactivate' : 'Activate' }}
|
||||
</button>
|
||||
<button mat-stroked-button color="primary" (click)="regenerateApiKey()">
|
||||
<mat-icon>vpn_key</mat-icon>
|
||||
Regenerate API Key
|
||||
</button>
|
||||
<mat-divider></mat-divider>
|
||||
<button mat-stroked-button color="warn" (click)="deleteDepartment()">
|
||||
<mat-icon>delete</mat-icon>
|
||||
Delete Department
|
||||
</button>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.loading-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 48px;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.detail-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.info-card mat-card-content,
|
||||
.actions-card mat-card-content {
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
color: rgba(0, 0, 0, 0.54);
|
||||
font-size: 0.875rem;
|
||||
flex-shrink: 0;
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
.value {
|
||||
text-align: right;
|
||||
word-break: break-word;
|
||||
|
||||
&.code {
|
||||
font-family: monospace;
|
||||
font-weight: 500;
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
&.url {
|
||||
font-size: 0.875rem;
|
||||
font-family: monospace;
|
||||
}
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
mat-divider {
|
||||
margin: 8px 0;
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class DepartmentDetailComponent implements OnInit {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly departmentService = inject(DepartmentService);
|
||||
private readonly notification = inject(NotificationService);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
|
||||
readonly loading = signal(true);
|
||||
readonly department = signal<DepartmentResponseDto | null>(null);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadDepartment();
|
||||
}
|
||||
|
||||
private loadDepartment(): void {
|
||||
const id = this.route.snapshot.paramMap.get('id');
|
||||
if (!id) {
|
||||
this.router.navigate(['/departments']);
|
||||
return;
|
||||
}
|
||||
|
||||
this.departmentService.getDepartment(id).subscribe({
|
||||
next: (dept) => {
|
||||
this.department.set(dept);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.notification.error('Department not found');
|
||||
this.router.navigate(['/departments']);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
toggleActive(): void {
|
||||
const dept = this.department();
|
||||
if (!dept) return;
|
||||
|
||||
const action = dept.isActive ? 'deactivate' : 'activate';
|
||||
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
|
||||
data: {
|
||||
title: `${dept.isActive ? 'Deactivate' : 'Activate'} Department`,
|
||||
message: `Are you sure you want to ${action} ${dept.name}?`,
|
||||
confirmText: dept.isActive ? 'Deactivate' : 'Activate',
|
||||
confirmColor: dept.isActive ? 'warn' : 'primary',
|
||||
},
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe((confirmed) => {
|
||||
if (confirmed) {
|
||||
this.departmentService.toggleActive(dept.id, !dept.isActive).subscribe({
|
||||
next: () => {
|
||||
this.notification.success(`Department ${action}d`);
|
||||
this.loadDepartment();
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
regenerateApiKey(): void {
|
||||
const dept = this.department();
|
||||
if (!dept) return;
|
||||
|
||||
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
|
||||
data: {
|
||||
title: 'Regenerate API Key',
|
||||
message:
|
||||
'This will invalidate the current API key. The department will need to update their integration. Continue?',
|
||||
confirmText: 'Regenerate',
|
||||
confirmColor: 'warn',
|
||||
},
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe((confirmed) => {
|
||||
if (confirmed) {
|
||||
this.departmentService.regenerateApiKey(dept.id).subscribe({
|
||||
next: (result) => {
|
||||
alert(
|
||||
`New API Credentials:\n\nAPI Key: ${result.apiKey}\nAPI Secret: ${result.apiSecret}\n\nPlease save these securely.`
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
deleteDepartment(): void {
|
||||
const dept = this.department();
|
||||
if (!dept) return;
|
||||
|
||||
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
|
||||
data: {
|
||||
title: 'Delete Department',
|
||||
message: `Are you sure you want to delete ${dept.name}? This action cannot be undone.`,
|
||||
confirmText: 'Delete',
|
||||
confirmColor: 'warn',
|
||||
},
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe((confirmed) => {
|
||||
if (confirmed) {
|
||||
this.departmentService.deleteDepartment(dept.id).subscribe({
|
||||
next: () => {
|
||||
this.notification.success('Department deleted');
|
||||
this.router.navigate(['/departments']);
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
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 { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { PageHeaderComponent } from '../../../shared/components/page-header/page-header.component';
|
||||
import { DepartmentService } from '../services/department.service';
|
||||
import { NotificationService } from '../../../core/services/notification.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-department-form',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterModule,
|
||||
ReactiveFormsModule,
|
||||
MatCardModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatDialogModule,
|
||||
PageHeaderComponent,
|
||||
],
|
||||
template: `
|
||||
<div class="page-container">
|
||||
<app-page-header
|
||||
[title]="isEditMode() ? 'Edit Department' : 'Create Department'"
|
||||
[subtitle]="isEditMode() ? 'Update department details' : 'Add a new government department'"
|
||||
>
|
||||
<button mat-button routerLink="/departments">
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
Back
|
||||
</button>
|
||||
</app-page-header>
|
||||
|
||||
<mat-card class="form-card">
|
||||
<mat-card-content>
|
||||
@if (loading()) {
|
||||
<div class="loading-container">
|
||||
<mat-spinner diameter="48"></mat-spinner>
|
||||
</div>
|
||||
} @else {
|
||||
<form [formGroup]="form" (ngSubmit)="onSubmit()">
|
||||
<div class="form-grid">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Department Code</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="code"
|
||||
placeholder="e.g., FIRE_DEPT"
|
||||
[readonly]="isEditMode()"
|
||||
/>
|
||||
@if (form.controls.code.hasError('required')) {
|
||||
<mat-error>Code is required</mat-error>
|
||||
}
|
||||
@if (form.controls.code.hasError('pattern')) {
|
||||
<mat-error>Use uppercase letters, numbers, and underscores only</mat-error>
|
||||
}
|
||||
<mat-hint>Unique identifier for the department</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Department Name</mat-label>
|
||||
<input matInput formControlName="name" placeholder="Full department name" />
|
||||
@if (form.controls.name.hasError('required')) {
|
||||
<mat-error>Name is required</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Description</mat-label>
|
||||
<textarea
|
||||
matInput
|
||||
formControlName="description"
|
||||
rows="3"
|
||||
placeholder="Brief description of the department"
|
||||
></textarea>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Contact Email</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="contactEmail"
|
||||
type="email"
|
||||
placeholder="department@goa.gov.in"
|
||||
/>
|
||||
@if (form.controls.contactEmail.hasError('email')) {
|
||||
<mat-error>Enter a valid email address</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Contact Phone</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="contactPhone"
|
||||
type="tel"
|
||||
placeholder="+91-XXX-XXXXXXX"
|
||||
/>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Webhook URL</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="webhookUrl"
|
||||
placeholder="https://example.com/webhook"
|
||||
/>
|
||||
@if (form.controls.webhookUrl.hasError('pattern')) {
|
||||
<mat-error>Enter a valid URL</mat-error>
|
||||
}
|
||||
<mat-hint>URL to receive event notifications</mat-hint>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button mat-button type="button" routerLink="/departments">Cancel</button>
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
type="submit"
|
||||
[disabled]="form.invalid || submitting()"
|
||||
>
|
||||
@if (submitting()) {
|
||||
<mat-spinner diameter="20"></mat-spinner>
|
||||
} @else {
|
||||
{{ isEditMode() ? 'Update' : 'Create' }}
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.form-card {
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 48px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 24px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class DepartmentFormComponent implements OnInit {
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly departmentService = inject(DepartmentService);
|
||||
private readonly notification = inject(NotificationService);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
|
||||
readonly loading = signal(false);
|
||||
readonly submitting = signal(false);
|
||||
readonly isEditMode = signal(false);
|
||||
private departmentId: string | null = null;
|
||||
|
||||
readonly form = this.fb.nonNullable.group({
|
||||
code: ['', [Validators.required, Validators.pattern(/^[A-Z0-9_]+$/)]],
|
||||
name: ['', [Validators.required]],
|
||||
description: [''],
|
||||
contactEmail: ['', [Validators.email]],
|
||||
contactPhone: [''],
|
||||
webhookUrl: ['', [Validators.pattern(/^https?:\/\/.+/)]],
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
this.departmentId = this.route.snapshot.paramMap.get('id');
|
||||
if (this.departmentId) {
|
||||
this.isEditMode.set(true);
|
||||
this.loadDepartment();
|
||||
}
|
||||
}
|
||||
|
||||
private loadDepartment(): void {
|
||||
if (!this.departmentId) return;
|
||||
|
||||
this.loading.set(true);
|
||||
this.departmentService.getDepartment(this.departmentId).subscribe({
|
||||
next: (dept) => {
|
||||
this.form.patchValue({
|
||||
code: dept.code,
|
||||
name: dept.name,
|
||||
description: dept.description || '',
|
||||
contactEmail: dept.contactEmail || '',
|
||||
contactPhone: dept.contactPhone || '',
|
||||
webhookUrl: dept.webhookUrl || '',
|
||||
});
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.notification.error('Failed to load department');
|
||||
this.router.navigate(['/departments']);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onSubmit(): void {
|
||||
if (this.form.invalid) return;
|
||||
|
||||
this.submitting.set(true);
|
||||
const values = this.form.getRawValue();
|
||||
|
||||
if (this.isEditMode() && this.departmentId) {
|
||||
this.departmentService.updateDepartment(this.departmentId, values).subscribe({
|
||||
next: () => {
|
||||
this.notification.success('Department updated successfully');
|
||||
this.router.navigate(['/departments', this.departmentId]);
|
||||
},
|
||||
error: () => {
|
||||
this.submitting.set(false);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
this.departmentService.createDepartment(values).subscribe({
|
||||
next: (result) => {
|
||||
this.notification.success('Department created successfully');
|
||||
// Show credentials dialog
|
||||
alert(
|
||||
`Department created!\n\nAPI Key: ${result.apiKey}\nAPI Secret: ${result.apiSecret}\n\nPlease save these credentials securely.`
|
||||
);
|
||||
this.router.navigate(['/departments', result.department.id]);
|
||||
},
|
||||
error: () => {
|
||||
this.submitting.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { PageHeaderComponent } from '../../../shared/components/page-header/page-header.component';
|
||||
import { StatusBadgeComponent } from '../../../shared/components/status-badge/status-badge.component';
|
||||
import { EmptyStateComponent } from '../../../shared/components/empty-state/empty-state.component';
|
||||
import { DepartmentService } from '../services/department.service';
|
||||
import { DepartmentResponseDto } from '../../../api/models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-department-list',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterModule,
|
||||
MatCardModule,
|
||||
MatTableModule,
|
||||
MatPaginatorModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatChipsModule,
|
||||
MatProgressSpinnerModule,
|
||||
PageHeaderComponent,
|
||||
StatusBadgeComponent,
|
||||
EmptyStateComponent,
|
||||
],
|
||||
template: `
|
||||
<div class="page-container">
|
||||
<app-page-header title="Departments" subtitle="Manage government departments">
|
||||
<button mat-raised-button color="primary" routerLink="new">
|
||||
<mat-icon>add</mat-icon>
|
||||
Add Department
|
||||
</button>
|
||||
</app-page-header>
|
||||
|
||||
<mat-card>
|
||||
<mat-card-content>
|
||||
@if (loading()) {
|
||||
<div class="loading-container">
|
||||
<mat-spinner diameter="48"></mat-spinner>
|
||||
</div>
|
||||
} @else if (departments().length === 0) {
|
||||
<app-empty-state
|
||||
icon="business"
|
||||
title="No departments"
|
||||
message="No departments have been created yet."
|
||||
>
|
||||
<button mat-raised-button color="primary" routerLink="new">
|
||||
<mat-icon>add</mat-icon>
|
||||
Create Department
|
||||
</button>
|
||||
</app-empty-state>
|
||||
} @else {
|
||||
<table mat-table [dataSource]="departments()">
|
||||
<ng-container matColumnDef="code">
|
||||
<th mat-header-cell *matHeaderCellDef>Code</th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
<span class="dept-code">{{ row.code }}</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="name">
|
||||
<th mat-header-cell *matHeaderCellDef>Name</th>
|
||||
<td mat-cell *matCellDef="let row">{{ row.name }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="status">
|
||||
<th mat-header-cell *matHeaderCellDef>Status</th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
<app-status-badge [status]="row.isActive ? 'ACTIVE' : 'INACTIVE'" />
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="createdAt">
|
||||
<th mat-header-cell *matHeaderCellDef>Created</th>
|
||||
<td mat-cell *matCellDef="let row">{{ row.createdAt | date: 'mediumDate' }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef></th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
<button mat-icon-button [routerLink]="[row.id]">
|
||||
<mat-icon>visibility</mat-icon>
|
||||
</button>
|
||||
<button mat-icon-button [routerLink]="[row.id, 'edit']">
|
||||
<mat-icon>edit</mat-icon>
|
||||
</button>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
|
||||
</table>
|
||||
|
||||
<mat-paginator
|
||||
[length]="totalItems()"
|
||||
[pageSize]="pageSize()"
|
||||
[pageIndex]="pageIndex()"
|
||||
[pageSizeOptions]="[5, 10, 25]"
|
||||
(page)="onPageChange($event)"
|
||||
showFirstLastButtons
|
||||
/>
|
||||
}
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.loading-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 48px;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dept-code {
|
||||
font-family: monospace;
|
||||
font-weight: 500;
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
.mat-column-actions {
|
||||
width: 100px;
|
||||
text-align: right;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class DepartmentListComponent implements OnInit {
|
||||
private readonly departmentService = inject(DepartmentService);
|
||||
|
||||
readonly loading = signal(true);
|
||||
readonly departments = signal<DepartmentResponseDto[]>([]);
|
||||
readonly totalItems = signal(0);
|
||||
readonly pageSize = signal(10);
|
||||
readonly pageIndex = signal(0);
|
||||
|
||||
readonly displayedColumns = ['code', 'name', 'status', 'createdAt', 'actions'];
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadDepartments();
|
||||
}
|
||||
|
||||
loadDepartments(): void {
|
||||
this.loading.set(true);
|
||||
this.departmentService.getDepartments(this.pageIndex() + 1, this.pageSize()).subscribe({
|
||||
next: (response) => {
|
||||
this.departments.set(response.data);
|
||||
this.totalItems.set(response.total);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onPageChange(event: PageEvent): void {
|
||||
this.pageIndex.set(event.pageIndex);
|
||||
this.pageSize.set(event.pageSize);
|
||||
this.loadDepartments();
|
||||
}
|
||||
}
|
||||
31
frontend/src/app/features/departments/departments.routes.ts
Normal file
31
frontend/src/app/features/departments/departments.routes.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { adminGuard } from '../../core/guards';
|
||||
|
||||
export const DEPARTMENTS_ROUTES: Routes = [
|
||||
{
|
||||
path: '',
|
||||
loadComponent: () =>
|
||||
import('./department-list/department-list.component').then((m) => m.DepartmentListComponent),
|
||||
canActivate: [adminGuard],
|
||||
},
|
||||
{
|
||||
path: 'new',
|
||||
loadComponent: () =>
|
||||
import('./department-form/department-form.component').then((m) => m.DepartmentFormComponent),
|
||||
canActivate: [adminGuard],
|
||||
},
|
||||
{
|
||||
path: ':id',
|
||||
loadComponent: () =>
|
||||
import('./department-detail/department-detail.component').then(
|
||||
(m) => m.DepartmentDetailComponent
|
||||
),
|
||||
canActivate: [adminGuard],
|
||||
},
|
||||
{
|
||||
path: ':id/edit',
|
||||
loadComponent: () =>
|
||||
import('./department-form/department-form.component').then((m) => m.DepartmentFormComponent),
|
||||
canActivate: [adminGuard],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable, map } from 'rxjs';
|
||||
import { ApiService } from '../../../core/services/api.service';
|
||||
import {
|
||||
DepartmentResponseDto,
|
||||
CreateDepartmentDto,
|
||||
UpdateDepartmentDto,
|
||||
PaginatedDepartmentsResponse,
|
||||
CreateDepartmentWithCredentialsResponse,
|
||||
RegenerateApiKeyResponse,
|
||||
} from '../../../api/models';
|
||||
|
||||
interface ApiPaginatedResponse<T> {
|
||||
data: T[];
|
||||
meta: {
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class DepartmentService {
|
||||
private readonly api = inject(ApiService);
|
||||
|
||||
getDepartments(page = 1, limit = 10): Observable<PaginatedDepartmentsResponse> {
|
||||
return this.api.get<ApiPaginatedResponse<DepartmentResponseDto>>('/departments', { page, limit }).pipe(
|
||||
map(response => {
|
||||
// Handle both wrapped {data, meta} and direct array responses
|
||||
const data = Array.isArray(response) ? response : (response?.data ?? []);
|
||||
const meta = Array.isArray(response) ? null : response?.meta;
|
||||
return {
|
||||
data,
|
||||
total: meta?.total ?? data.length,
|
||||
page: meta?.page ?? page,
|
||||
limit: meta?.limit ?? limit,
|
||||
totalPages: meta?.totalPages ?? Math.ceil(data.length / limit),
|
||||
hasNextPage: (meta?.page ?? 1) < (meta?.totalPages ?? 1),
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
getDepartment(id: string): Observable<DepartmentResponseDto> {
|
||||
return this.api.get<DepartmentResponseDto>(`/departments/${id}`);
|
||||
}
|
||||
|
||||
getDepartmentByCode(code: string): Observable<DepartmentResponseDto> {
|
||||
return this.api.get<DepartmentResponseDto>(`/departments/code/${code}`);
|
||||
}
|
||||
|
||||
createDepartment(dto: CreateDepartmentDto): Observable<CreateDepartmentWithCredentialsResponse> {
|
||||
return this.api.post<CreateDepartmentWithCredentialsResponse>('/departments', dto);
|
||||
}
|
||||
|
||||
updateDepartment(id: string, dto: UpdateDepartmentDto): Observable<DepartmentResponseDto> {
|
||||
return this.api.patch<DepartmentResponseDto>(`/departments/${id}`, dto);
|
||||
}
|
||||
|
||||
deleteDepartment(id: string): Observable<void> {
|
||||
return this.api.delete<void>(`/departments/${id}`);
|
||||
}
|
||||
|
||||
regenerateApiKey(id: string): Observable<RegenerateApiKeyResponse> {
|
||||
return this.api.post<RegenerateApiKeyResponse>(`/departments/${id}/regenerate-key`, {});
|
||||
}
|
||||
|
||||
toggleActive(id: string, isActive: boolean): Observable<DepartmentResponseDto> {
|
||||
return this.api.patch<DepartmentResponseDto>(`/departments/${id}`, { isActive });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import { Component, Input, OnInit, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { EmptyStateComponent } from '../../../shared/components/empty-state/empty-state.component';
|
||||
import { ConfirmDialogComponent } from '../../../shared/components/confirm-dialog/confirm-dialog.component';
|
||||
import { VerificationBadgeComponent, VerificationStatus } from '../../../shared/components/verification-badge/verification-badge.component';
|
||||
import { DocumentUploadComponent, DocumentUploadDialogData } from '../document-upload/document-upload.component';
|
||||
import { DocumentService } from '../services/document.service';
|
||||
import { NotificationService } from '../../../core/services/notification.service';
|
||||
import { DocumentResponseDto } from '../../../api/models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-document-list',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatCardModule,
|
||||
MatTableModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatMenuModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatDialogModule,
|
||||
EmptyStateComponent,
|
||||
VerificationBadgeComponent,
|
||||
],
|
||||
template: `
|
||||
<div class="document-list">
|
||||
<div class="list-header">
|
||||
<h3>Documents</h3>
|
||||
@if (canUpload) {
|
||||
<button mat-raised-button color="primary" (click)="openUploadDialog()">
|
||||
<mat-icon>upload</mat-icon>
|
||||
Upload
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="loading-container">
|
||||
<mat-spinner diameter="32"></mat-spinner>
|
||||
</div>
|
||||
} @else if (documents().length === 0) {
|
||||
<app-empty-state
|
||||
icon="folder_open"
|
||||
title="No documents"
|
||||
message="No documents have been uploaded yet."
|
||||
>
|
||||
@if (canUpload) {
|
||||
<button mat-raised-button color="primary" (click)="openUploadDialog()">
|
||||
<mat-icon>upload</mat-icon>
|
||||
Upload Document
|
||||
</button>
|
||||
}
|
||||
</app-empty-state>
|
||||
} @else {
|
||||
<div class="documents-grid">
|
||||
@for (doc of documents(); track doc.id) {
|
||||
<mat-card class="document-card">
|
||||
<div class="doc-icon">
|
||||
<mat-icon>{{ getDocIcon(doc.originalFilename) }}</mat-icon>
|
||||
</div>
|
||||
<div class="doc-info">
|
||||
<span class="doc-name" [title]="doc.originalFilename">
|
||||
{{ doc.originalFilename }}
|
||||
</span>
|
||||
<span class="doc-type">{{ formatDocType(doc.docType) }}</span>
|
||||
<div class="doc-meta-row">
|
||||
<span class="doc-meta">Version {{ doc.currentVersion }}</span>
|
||||
<app-verification-badge
|
||||
[status]="getVerificationStatus(doc)"
|
||||
[iconOnly]="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="doc-actions">
|
||||
<button mat-icon-button [matMenuTriggerFor]="docMenu">
|
||||
<mat-icon>more_vert</mat-icon>
|
||||
</button>
|
||||
<mat-menu #docMenu="matMenu">
|
||||
<button mat-menu-item (click)="downloadDocument(doc)">
|
||||
<mat-icon>download</mat-icon>
|
||||
<span>Download</span>
|
||||
</button>
|
||||
@if (canUpload) {
|
||||
<button mat-menu-item (click)="deleteDocument(doc)">
|
||||
<mat-icon color="warn">delete</mat-icon>
|
||||
<span>Delete</span>
|
||||
</button>
|
||||
}
|
||||
</mat-menu>
|
||||
</div>
|
||||
</mat-card>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.document-list {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.list-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.documents-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.document-card {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.doc-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 8px;
|
||||
background-color: #e3f2fd;
|
||||
|
||||
mat-icon {
|
||||
font-size: 24px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: #1976d2;
|
||||
}
|
||||
}
|
||||
|
||||
.doc-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.doc-name {
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.doc-type {
|
||||
font-size: 0.875rem;
|
||||
color: rgba(0, 0, 0, 0.54);
|
||||
}
|
||||
|
||||
.doc-meta {
|
||||
font-size: 0.75rem;
|
||||
color: rgba(0, 0, 0, 0.38);
|
||||
}
|
||||
|
||||
.doc-meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class DocumentListComponent implements OnInit {
|
||||
@Input({ required: true }) requestId!: string;
|
||||
@Input() canUpload = false;
|
||||
|
||||
private readonly documentService = inject(DocumentService);
|
||||
private readonly notification = inject(NotificationService);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
|
||||
readonly loading = signal(true);
|
||||
readonly documents = signal<DocumentResponseDto[]>([]);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadDocuments();
|
||||
}
|
||||
|
||||
loadDocuments(): void {
|
||||
this.documentService.getDocuments(this.requestId).subscribe({
|
||||
next: (docs) => {
|
||||
this.documents.set(docs);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
openUploadDialog(): void {
|
||||
const dialogRef = this.dialog.open(DocumentUploadComponent, {
|
||||
data: { requestId: this.requestId } as DocumentUploadDialogData,
|
||||
width: '500px',
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe((result) => {
|
||||
if (result) {
|
||||
this.loadDocuments();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
downloadDocument(doc: DocumentResponseDto): void {
|
||||
this.documentService.getDownloadUrl(this.requestId, doc.id).subscribe({
|
||||
next: (response) => {
|
||||
window.open(response.url, '_blank');
|
||||
},
|
||||
error: () => {
|
||||
this.notification.error('Failed to get download URL');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
deleteDocument(doc: DocumentResponseDto): void {
|
||||
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
|
||||
data: {
|
||||
title: 'Delete Document',
|
||||
message: `Are you sure you want to delete "${doc.originalFilename}"?`,
|
||||
confirmText: 'Delete',
|
||||
confirmColor: 'warn',
|
||||
},
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe((confirmed) => {
|
||||
if (confirmed) {
|
||||
this.documentService.deleteDocument(this.requestId, doc.id).subscribe({
|
||||
next: () => {
|
||||
this.notification.success('Document deleted');
|
||||
this.loadDocuments();
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getDocIcon(filename: string): string {
|
||||
const ext = filename.split('.').pop()?.toLowerCase();
|
||||
switch (ext) {
|
||||
case 'pdf':
|
||||
return 'picture_as_pdf';
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
case 'png':
|
||||
case 'gif':
|
||||
return 'image';
|
||||
case 'doc':
|
||||
case 'docx':
|
||||
return 'article';
|
||||
default:
|
||||
return 'description';
|
||||
}
|
||||
}
|
||||
|
||||
formatDocType(type: string): string {
|
||||
return type.replace(/_/g, ' ').toLowerCase().replace(/^\w/, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
getVerificationStatus(doc: DocumentResponseDto): VerificationStatus {
|
||||
// Document has a hash means it's been recorded on blockchain
|
||||
if (doc.currentHash && doc.currentHash.length > 0) {
|
||||
return 'verified';
|
||||
}
|
||||
// If document is active but no hash, it's pending verification
|
||||
if (doc.isActive) {
|
||||
return 'pending';
|
||||
}
|
||||
return 'unverified';
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
9
frontend/src/app/features/documents/documents.routes.ts
Normal file
9
frontend/src/app/features/documents/documents.routes.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
export const DOCUMENTS_ROUTES: Routes = [
|
||||
{
|
||||
path: '',
|
||||
loadComponent: () =>
|
||||
import('./document-list/document-list.component').then((m) => m.DocumentListComponent),
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { ApiService, UploadProgress } from '../../../core/services/api.service';
|
||||
import {
|
||||
DocumentResponseDto,
|
||||
DocumentVersionResponseDto,
|
||||
DownloadUrlResponseDto,
|
||||
DocumentType,
|
||||
} from '../../../api/models';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class DocumentService {
|
||||
private readonly api = inject(ApiService);
|
||||
|
||||
getDocuments(requestId: string): Observable<DocumentResponseDto[]> {
|
||||
return this.api.get<DocumentResponseDto[]>(`/requests/${requestId}/documents`);
|
||||
}
|
||||
|
||||
getDocument(requestId: string, documentId: string): Observable<DocumentResponseDto> {
|
||||
return this.api.get<DocumentResponseDto>(`/requests/${requestId}/documents/${documentId}`);
|
||||
}
|
||||
|
||||
getDocumentVersions(
|
||||
requestId: string,
|
||||
documentId: string
|
||||
): Observable<DocumentVersionResponseDto[]> {
|
||||
return this.api.get<DocumentVersionResponseDto[]>(
|
||||
`/requests/${requestId}/documents/${documentId}/versions`
|
||||
);
|
||||
}
|
||||
|
||||
uploadDocument(
|
||||
requestId: string,
|
||||
file: File,
|
||||
docType: DocumentType,
|
||||
description?: string
|
||||
): Observable<DocumentResponseDto> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('docType', docType);
|
||||
if (description) {
|
||||
formData.append('description', description);
|
||||
}
|
||||
return this.api.upload<DocumentResponseDto>(`/requests/${requestId}/documents`, formData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload document with progress tracking
|
||||
*/
|
||||
uploadDocumentWithProgress(
|
||||
requestId: string,
|
||||
file: File,
|
||||
docType: DocumentType,
|
||||
description?: string
|
||||
): Observable<UploadProgress<DocumentResponseDto>> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('docType', docType);
|
||||
if (description) {
|
||||
formData.append('description', description);
|
||||
}
|
||||
return this.api.uploadWithProgress<DocumentResponseDto>(`/requests/${requestId}/documents`, formData);
|
||||
}
|
||||
|
||||
updateDocument(
|
||||
requestId: string,
|
||||
documentId: string,
|
||||
file: File
|
||||
): Observable<DocumentResponseDto> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return this.api.upload<DocumentResponseDto>(
|
||||
`/requests/${requestId}/documents/${documentId}`,
|
||||
formData
|
||||
);
|
||||
}
|
||||
|
||||
deleteDocument(requestId: string, documentId: string): Observable<void> {
|
||||
return this.api.delete<void>(`/requests/${requestId}/documents/${documentId}`);
|
||||
}
|
||||
|
||||
getDownloadUrl(requestId: string, documentId: string): Observable<DownloadUrlResponseDto> {
|
||||
return this.api.get<DownloadUrlResponseDto>(
|
||||
`/requests/${requestId}/documents/${documentId}/download`
|
||||
);
|
||||
}
|
||||
|
||||
verifyDocument(requestId: string, documentId: string): Observable<{ verified: boolean }> {
|
||||
return this.api.get<{ verified: boolean }>(
|
||||
`/requests/${requestId}/documents/${documentId}/verify`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
<div class="page-container">
|
||||
<app-page-header title="New License Request" subtitle="Submit a new license application">
|
||||
<button mat-button routerLink="/requests">
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
Back to Requests
|
||||
</button>
|
||||
</app-page-header>
|
||||
|
||||
<mat-card class="form-card">
|
||||
<!-- Card Header -->
|
||||
<div class="form-header">
|
||||
<div class="header-icon">
|
||||
<mat-icon>assignment_add</mat-icon>
|
||||
</div>
|
||||
<h2>License Application</h2>
|
||||
<p>Complete the form below to submit your license application</p>
|
||||
</div>
|
||||
|
||||
<div class="form-content">
|
||||
<mat-stepper linear #stepper>
|
||||
<!-- Step 1: Request Type -->
|
||||
<mat-step [stepControl]="basicForm" label="Request Type">
|
||||
<div class="step-content">
|
||||
<div class="step-header">
|
||||
<h3>Select Request Type</h3>
|
||||
<p>Choose the type of license request you want to submit</p>
|
||||
</div>
|
||||
|
||||
<form [formGroup]="basicForm">
|
||||
<!-- Request Type Selection -->
|
||||
<div class="type-selection">
|
||||
@for (type of requestTypes; track type.value) {
|
||||
<div
|
||||
class="type-option"
|
||||
[class.selected]="basicForm.controls.requestType.value === type.value"
|
||||
(click)="basicForm.controls.requestType.setValue(type.value)"
|
||||
>
|
||||
<div class="type-icon">
|
||||
<mat-icon>{{ getTypeIcon(type.value) }}</mat-icon>
|
||||
</div>
|
||||
<span class="type-label">{{ type.label }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Workflow Selection -->
|
||||
<div class="step-header" style="margin-top: 32px">
|
||||
<h3>Select Workflow</h3>
|
||||
<p>Choose the approval workflow for your application</p>
|
||||
</div>
|
||||
|
||||
@if (loading()) {
|
||||
<div style="display: flex; justify-content: center; padding: 32px">
|
||||
<mat-spinner diameter="32"></mat-spinner>
|
||||
</div>
|
||||
} @else if (workflows().length === 0) {
|
||||
<div style="text-align: center; padding: 32px; color: var(--dbim-grey-2)">
|
||||
<mat-icon style="font-size: 48px; width: 48px; height: 48px; opacity: 0.5">warning</mat-icon>
|
||||
<p>No active workflows available</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="workflow-selection">
|
||||
@for (workflow of workflows(); track workflow.id) {
|
||||
<div
|
||||
class="workflow-option"
|
||||
[class.selected]="basicForm.controls.workflowId.value === workflow.id"
|
||||
(click)="basicForm.controls.workflowId.setValue(workflow.id)"
|
||||
>
|
||||
<div class="workflow-name">{{ workflow.name }}</div>
|
||||
<div class="workflow-desc">{{ workflow.description || 'Standard approval workflow' }}</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</form>
|
||||
|
||||
<div class="form-actions">
|
||||
<div class="actions-left">
|
||||
<button mat-button routerLink="/requests">Cancel</button>
|
||||
</div>
|
||||
<div class="actions-right">
|
||||
<button mat-raised-button color="primary" matStepperNext [disabled]="basicForm.invalid">
|
||||
Continue
|
||||
<mat-icon>arrow_forward</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</mat-step>
|
||||
|
||||
<!-- Step 2: Business Details -->
|
||||
<mat-step [stepControl]="metadataForm" label="Business Details">
|
||||
<div class="step-content">
|
||||
<div class="step-header">
|
||||
<h3>Business Information</h3>
|
||||
<p>Provide details about your business for the license application</p>
|
||||
</div>
|
||||
|
||||
<form [formGroup]="metadataForm">
|
||||
<div class="metadata-fields">
|
||||
<div class="field-group">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Business Name</mat-label>
|
||||
<input matInput formControlName="businessName" placeholder="Enter your business name" />
|
||||
<mat-icon matPrefix>business</mat-icon>
|
||||
@if (metadataForm.controls.businessName.hasError('required')) {
|
||||
<mat-error>Business name is required</mat-error>
|
||||
}
|
||||
@if (metadataForm.controls.businessName.hasError('minlength')) {
|
||||
<mat-error>Minimum 3 characters required</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Business Address</mat-label>
|
||||
<input matInput formControlName="businessAddress" placeholder="Full business address" />
|
||||
<mat-icon matPrefix>location_on</mat-icon>
|
||||
@if (metadataForm.controls.businessAddress.hasError('required')) {
|
||||
<mat-error>Business address is required</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Owner / Applicant Name</mat-label>
|
||||
<input matInput formControlName="ownerName" placeholder="Full name of owner" />
|
||||
<mat-icon matPrefix>person</mat-icon>
|
||||
@if (metadataForm.controls.ownerName.hasError('required')) {
|
||||
<mat-error>Owner name is required</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Contact Phone</mat-label>
|
||||
<input matInput formControlName="ownerPhone" placeholder="+91 XXXXX XXXXX" type="tel" />
|
||||
<mat-icon matPrefix>phone</mat-icon>
|
||||
@if (metadataForm.controls.ownerPhone.hasError('required')) {
|
||||
<mat-error>Phone number is required</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Email Address</mat-label>
|
||||
<input matInput formControlName="ownerEmail" placeholder="email@example.com" type="email" />
|
||||
<mat-icon matPrefix>email</mat-icon>
|
||||
@if (metadataForm.controls.ownerEmail.hasError('email')) {
|
||||
<mat-error>Please enter a valid email address</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="field-group" style="grid-column: 1 / -1">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Business Description</mat-label>
|
||||
<textarea
|
||||
matInput
|
||||
formControlName="description"
|
||||
placeholder="Brief description of your business activities"
|
||||
rows="4"
|
||||
></textarea>
|
||||
<mat-icon matPrefix>notes</mat-icon>
|
||||
<mat-hint>Optional: Provide additional details about your business</mat-hint>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="form-actions">
|
||||
<div class="actions-left">
|
||||
<button mat-button matStepperPrevious>
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
<div class="actions-right">
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
class="submit-btn"
|
||||
(click)="onSubmit()"
|
||||
[disabled]="submitting() || metadataForm.invalid"
|
||||
>
|
||||
@if (submitting()) {
|
||||
<mat-spinner diameter="20"></mat-spinner>
|
||||
Submitting...
|
||||
} @else {
|
||||
<ng-container>
|
||||
<mat-icon>send</mat-icon>
|
||||
Create Request
|
||||
</ng-container>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</mat-step>
|
||||
</mat-stepper>
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
@@ -0,0 +1,425 @@
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Router, RouterModule } from '@angular/router';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatStepperModule } from '@angular/material/stepper';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { PageHeaderComponent } from '../../../shared/components/page-header/page-header.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 { RequestType, WorkflowResponseDto } from '../../../api/models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-request-create',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterModule,
|
||||
ReactiveFormsModule,
|
||||
MatCardModule,
|
||||
MatStepperModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatProgressSpinnerModule,
|
||||
PageHeaderComponent,
|
||||
],
|
||||
templateUrl: './request-create.component.html',
|
||||
styles: [
|
||||
`
|
||||
.form-card {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
border-radius: 20px !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.form-header {
|
||||
background: linear-gradient(135deg, var(--dbim-blue-dark) 0%, var(--dbim-blue-mid) 100%);
|
||||
padding: 32px;
|
||||
color: white;
|
||||
text-align: center;
|
||||
|
||||
.header-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin: 0 auto 16px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
mat-icon {
|
||||
font-size: 32px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
opacity: 0.9;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.form-content {
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
mat-form-field {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 32px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid var(--dbim-linen);
|
||||
|
||||
.actions-left {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.actions-right {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.step-content {
|
||||
padding: 32px 0;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.step-header {
|
||||
margin-bottom: 24px;
|
||||
|
||||
h3 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--dbim-brown);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
color: var(--dbim-grey-2);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.metadata-fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* Workflow selection cards */
|
||||
.workflow-selection {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.workflow-option {
|
||||
padding: 20px;
|
||||
border: 2px solid var(--dbim-linen);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--dbim-blue-light);
|
||||
background: var(--dbim-blue-subtle);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border-color: var(--dbim-blue-mid);
|
||||
background: var(--dbim-blue-subtle);
|
||||
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.1);
|
||||
}
|
||||
|
||||
.workflow-name {
|
||||
font-weight: 600;
|
||||
color: var(--dbim-brown);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.workflow-desc {
|
||||
font-size: 13px;
|
||||
color: var(--dbim-grey-2);
|
||||
}
|
||||
}
|
||||
|
||||
/* Request type cards */
|
||||
.type-selection {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.type-option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 20px 16px;
|
||||
border: 2px solid var(--dbim-linen);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
text-align: center;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--dbim-blue-light);
|
||||
background: rgba(37, 99, 235, 0.02);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border-color: var(--dbim-blue-mid);
|
||||
background: var(--dbim-blue-subtle);
|
||||
}
|
||||
|
||||
.type-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
background: var(--dbim-linen);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 12px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 24px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: var(--dbim-grey-3);
|
||||
}
|
||||
}
|
||||
|
||||
&.selected .type-icon {
|
||||
background: linear-gradient(135deg, var(--dbim-blue-dark) 0%, var(--dbim-blue-mid) 100%);
|
||||
|
||||
mat-icon {
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.type-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--dbim-brown);
|
||||
}
|
||||
}
|
||||
|
||||
/* Progress indicator */
|
||||
.step-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 32px;
|
||||
|
||||
.progress-step {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
background: var(--dbim-linen);
|
||||
color: var(--dbim-grey-2);
|
||||
|
||||
&.active {
|
||||
background: var(--dbim-blue-mid);
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.completed {
|
||||
background: var(--dbim-success);
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.progress-line {
|
||||
width: 60px;
|
||||
height: 3px;
|
||||
background: var(--dbim-linen);
|
||||
border-radius: 2px;
|
||||
|
||||
&.active {
|
||||
background: linear-gradient(90deg, var(--dbim-success) 0%, var(--dbim-blue-mid) 100%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Form field hints */
|
||||
.field-group {
|
||||
margin-bottom: 8px;
|
||||
|
||||
.field-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--dbim-grey-3);
|
||||
margin-bottom: 8px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Submit button animation */
|
||||
.submit-btn {
|
||||
min-width: 160px;
|
||||
height: 48px;
|
||||
font-size: 15px;
|
||||
|
||||
mat-spinner {
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class RequestCreateComponent implements OnInit {
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly router = inject(Router);
|
||||
private readonly requestService = inject(RequestService);
|
||||
private readonly authService = inject(AuthService);
|
||||
private readonly notification = inject(NotificationService);
|
||||
private readonly api = inject(ApiService);
|
||||
|
||||
readonly loading = signal(false);
|
||||
readonly submitting = signal(false);
|
||||
readonly workflows = signal<WorkflowResponseDto[]>([]);
|
||||
|
||||
readonly requestTypes: { value: RequestType; label: string }[] = [
|
||||
{ value: 'NEW_LICENSE', label: 'New License' },
|
||||
{ value: 'RENEWAL', label: 'License Renewal' },
|
||||
{ value: 'AMENDMENT', label: 'License Amendment' },
|
||||
{ value: 'MODIFICATION', label: 'License Modification' },
|
||||
{ value: 'CANCELLATION', label: 'License Cancellation' },
|
||||
];
|
||||
|
||||
readonly basicForm = this.fb.nonNullable.group({
|
||||
requestType: ['NEW_LICENSE' as RequestType, [Validators.required]],
|
||||
workflowId: ['', [Validators.required]],
|
||||
});
|
||||
|
||||
readonly metadataForm = this.fb.nonNullable.group({
|
||||
businessName: ['', [Validators.required, Validators.minLength(3)]],
|
||||
businessAddress: ['', [Validators.required]],
|
||||
ownerName: ['', [Validators.required]],
|
||||
ownerPhone: ['', [Validators.required]],
|
||||
ownerEmail: ['', [Validators.email]],
|
||||
description: [''],
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadWorkflows();
|
||||
}
|
||||
|
||||
private loadWorkflows(): void {
|
||||
this.loading.set(true);
|
||||
this.api.get<{ data: WorkflowResponseDto[] }>('/workflows', { isActive: true }).subscribe({
|
||||
next: (response) => {
|
||||
const data = Array.isArray(response) ? response : response.data || [];
|
||||
this.workflows.set(data);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getTypeIcon(type: string): string {
|
||||
switch (type) {
|
||||
case 'NEW_LICENSE':
|
||||
return 'add_circle';
|
||||
case 'RENEWAL':
|
||||
return 'autorenew';
|
||||
case 'AMENDMENT':
|
||||
return 'edit_note';
|
||||
case 'MODIFICATION':
|
||||
return 'tune';
|
||||
case 'CANCELLATION':
|
||||
return 'cancel';
|
||||
default:
|
||||
return 'description';
|
||||
}
|
||||
}
|
||||
|
||||
onSubmit(): void {
|
||||
if (this.basicForm.invalid || this.metadataForm.invalid) {
|
||||
this.basicForm.markAllAsTouched();
|
||||
this.metadataForm.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
const user = this.authService.getCurrentUser();
|
||||
if (!user) {
|
||||
this.notification.error('Please login to create a request');
|
||||
return;
|
||||
}
|
||||
|
||||
this.submitting.set(true);
|
||||
const basic = this.basicForm.getRawValue();
|
||||
const metadata = this.metadataForm.getRawValue();
|
||||
|
||||
this.requestService
|
||||
.createRequest({
|
||||
applicantId: user.id,
|
||||
requestType: basic.requestType,
|
||||
workflowId: basic.workflowId,
|
||||
metadata,
|
||||
})
|
||||
.subscribe({
|
||||
next: (result) => {
|
||||
this.notification.success('Request created successfully');
|
||||
this.router.navigate(['/requests', result.id]);
|
||||
},
|
||||
error: () => {
|
||||
this.submitting.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
<div class="page-container">
|
||||
@if (loading()) {
|
||||
<div class="loading-container">
|
||||
<mat-spinner diameter="48"></mat-spinner>
|
||||
<span class="loading-text">Loading request details...</span>
|
||||
</div>
|
||||
} @else if (request(); as req) {
|
||||
<!-- Request Header Card -->
|
||||
<div class="request-header-card">
|
||||
<div class="header-content">
|
||||
<div class="header-left">
|
||||
<div class="request-number">{{ req.requestNumber }}</div>
|
||||
<h1 class="request-title">{{ formatType(req.requestType) | titlecase }} Application</h1>
|
||||
<div class="request-meta">
|
||||
<span class="meta-item">
|
||||
<mat-icon>calendar_today</mat-icon>
|
||||
Created {{ req.createdAt | date: 'mediumDate' }}
|
||||
</span>
|
||||
<span class="meta-item">
|
||||
<mat-icon>update</mat-icon>
|
||||
Updated {{ req.updatedAt | date: 'mediumDate' }}
|
||||
</span>
|
||||
@if (req.submittedAt) {
|
||||
<span class="meta-item">
|
||||
<mat-icon>send</mat-icon>
|
||||
Submitted {{ req.submittedAt | date: 'mediumDate' }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<span class="status-large status-{{ req.status.toLowerCase().replace('_', '-') }}">
|
||||
{{ req.status | titlecase }}
|
||||
</span>
|
||||
<div class="actions">
|
||||
@if (canEdit) {
|
||||
<button mat-raised-button routerLink="edit">
|
||||
<mat-icon>edit</mat-icon>
|
||||
Edit
|
||||
</button>
|
||||
}
|
||||
@if (canSubmit) {
|
||||
<button
|
||||
mat-raised-button
|
||||
style="background: white; color: var(--dbim-blue-dark)"
|
||||
(click)="submitRequest()"
|
||||
[disabled]="submitting()"
|
||||
>
|
||||
<mat-icon>send</mat-icon>
|
||||
Submit
|
||||
</button>
|
||||
}
|
||||
@if (canCancel) {
|
||||
<button mat-button style="color: white" (click)="cancelRequest()">
|
||||
<mat-icon>cancel</mat-icon>
|
||||
Cancel
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs Content -->
|
||||
<mat-tab-group animationDuration="200ms">
|
||||
<!-- Details Tab -->
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<mat-icon>info</mat-icon>
|
||||
<span style="margin-left: 8px">Details</span>
|
||||
</ng-template>
|
||||
<div class="tab-content">
|
||||
<div class="detail-grid">
|
||||
<!-- Request Information -->
|
||||
<mat-card class="info-card">
|
||||
<div class="info-section">
|
||||
<div class="section-header">
|
||||
<div class="section-icon">
|
||||
<mat-icon>description</mat-icon>
|
||||
</div>
|
||||
<h3>Request Information</h3>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">Request Number</span>
|
||||
<span class="value" style="font-family: 'Roboto Mono', monospace">{{ req.requestNumber }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">Request Type</span>
|
||||
<span class="value">{{ formatType(req.requestType) | titlecase }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">Status</span>
|
||||
<span class="value">
|
||||
<app-status-badge [status]="req.status" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">Created</span>
|
||||
<span class="value">{{ req.createdAt | date: 'medium' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">Last Updated</span>
|
||||
<span class="value">{{ req.updatedAt | date: 'medium' }}</span>
|
||||
</div>
|
||||
@if (req.submittedAt) {
|
||||
<div class="info-row">
|
||||
<span class="label">Submitted</span>
|
||||
<span class="value">{{ req.submittedAt | date: 'medium' }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (req.approvedAt) {
|
||||
<div class="info-row">
|
||||
<span class="label">Approved</span>
|
||||
<span class="value">{{ req.approvedAt | date: 'medium' }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</mat-card>
|
||||
|
||||
<!-- Blockchain Info -->
|
||||
@if (req.blockchainTxHash || req.tokenId) {
|
||||
<app-blockchain-info
|
||||
[tokenId]="req.tokenId"
|
||||
[txHash]="req.blockchainTxHash"
|
||||
[showExplorer]="true"
|
||||
/>
|
||||
}
|
||||
|
||||
<!-- Metadata -->
|
||||
<mat-card class="info-card">
|
||||
<div class="info-section">
|
||||
<div class="section-header">
|
||||
<div class="section-icon">
|
||||
<mat-icon>business</mat-icon>
|
||||
</div>
|
||||
<h3>Business Details</h3>
|
||||
</div>
|
||||
@if (hasMetadata(req.metadata)) {
|
||||
@for (key of getMetadataKeys(req.metadata); track key) {
|
||||
<div class="info-row">
|
||||
<span class="label">{{ formatMetadataKey(key) }}</span>
|
||||
<span class="value">{{ req.metadata[key] }}</span>
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<p style="color: var(--dbim-grey-2); margin: 0; text-align: center; padding: 24px 0">
|
||||
No additional metadata provided
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<!-- Documents Tab -->
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<mat-icon>folder</mat-icon>
|
||||
<span style="margin-left: 8px">Documents ({{ detailedDocuments().length || 0 }})</span>
|
||||
</ng-template>
|
||||
<div class="tab-content">
|
||||
@if (loadingDocuments()) {
|
||||
<div class="loading-container">
|
||||
<mat-spinner diameter="40"></mat-spinner>
|
||||
<span class="loading-text">Loading documents...</span>
|
||||
</div>
|
||||
} @else {
|
||||
<app-document-viewer [documents]="detailedDocuments()" />
|
||||
}
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<!-- Approvals Tab -->
|
||||
<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>
|
||||
</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>
|
||||
} @else {
|
||||
<div class="empty-state-card">
|
||||
<mat-icon>pending_actions</mat-icon>
|
||||
<p>No approval actions yet</p>
|
||||
<p style="font-size: 13px; margin-top: 8px">
|
||||
Approval workflow will begin once the request is submitted
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</mat-tab>
|
||||
</mat-tab-group>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,578 @@
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatDividerModule } from '@angular/material/divider';
|
||||
import { MatListModule } from '@angular/material/list';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { StatusBadgeComponent } from '../../../shared/components/status-badge/status-badge.component';
|
||||
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 { 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';
|
||||
|
||||
@Component({
|
||||
selector: 'app-request-detail',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterModule,
|
||||
MatCardModule,
|
||||
MatTabsModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatDividerModule,
|
||||
MatListModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatDialogModule,
|
||||
StatusBadgeComponent,
|
||||
BlockchainInfoComponent,
|
||||
DocumentViewerComponent,
|
||||
],
|
||||
templateUrl: './request-detail.component.html',
|
||||
styles: [
|
||||
`
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 64px;
|
||||
gap: 16px;
|
||||
|
||||
.loading-text {
|
||||
font-size: 14px;
|
||||
color: var(--dbim-grey-2);
|
||||
}
|
||||
}
|
||||
|
||||
/* Request Header Card */
|
||||
.request-header-card {
|
||||
background: linear-gradient(135deg, var(--dbim-blue-dark) 0%, var(--dbim-blue-mid) 100%);
|
||||
border-radius: 20px;
|
||||
padding: 32px;
|
||||
color: white;
|
||||
margin-bottom: 24px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
right: -20%;
|
||||
width: 50%;
|
||||
height: 150%;
|
||||
background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 60%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
.request-number {
|
||||
font-family: 'Roboto Mono', monospace;
|
||||
font-size: 14px;
|
||||
opacity: 0.8;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.request-title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.request-meta {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 16px;
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 16px;
|
||||
|
||||
.status-large {
|
||||
padding: 8px 20px;
|
||||
border-radius: 24px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
|
||||
&.status-draft {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
&.status-submitted,
|
||||
&.status-pending,
|
||||
&.status-in-review {
|
||||
background: rgba(255, 193, 7, 0.3);
|
||||
}
|
||||
|
||||
&.status-approved {
|
||||
background: rgba(25, 135, 84, 0.3);
|
||||
}
|
||||
|
||||
&.status-rejected {
|
||||
background: rgba(220, 53, 69, 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.info-card {
|
||||
padding: 24px;
|
||||
border-radius: 16px !important;
|
||||
}
|
||||
|
||||
.info-section {
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
.section-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
background: var(--dbim-blue-subtle);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: var(--dbim-blue-mid);
|
||||
}
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--dbim-brown);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--dbim-linen);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--dbim-grey-2);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-weight: 500;
|
||||
text-align: right;
|
||||
color: var(--dbim-brown);
|
||||
}
|
||||
}
|
||||
|
||||
.blockchain-info {
|
||||
background-color: var(--dbim-linen);
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
margin-top: 16px;
|
||||
|
||||
.tx-hash {
|
||||
font-family: 'Roboto Mono', monospace;
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
color: var(--dbim-grey-3);
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
button {
|
||||
mat-icon {
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Approvals Timeline */
|
||||
.approvals-timeline {
|
||||
padding-left: 32px;
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 11px;
|
||||
top: 8px;
|
||||
bottom: 8px;
|
||||
width: 2px;
|
||||
background: var(--dbim-linen);
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
position: relative;
|
||||
padding-bottom: 24px;
|
||||
|
||||
&:last-child {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.timeline-marker {
|
||||
position: absolute;
|
||||
left: -32px;
|
||||
top: 4px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: var(--dbim-white);
|
||||
border: 3px solid var(--dbim-linen);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1;
|
||||
|
||||
mat-icon {
|
||||
font-size: 12px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
&.approved {
|
||||
border-color: var(--dbim-success);
|
||||
background: var(--dbim-success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.pending {
|
||||
border-color: var(--dbim-warning);
|
||||
background: var(--dbim-warning);
|
||||
color: var(--dbim-brown);
|
||||
}
|
||||
|
||||
&.rejected {
|
||||
border-color: var(--dbim-error);
|
||||
background: var(--dbim-error);
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-content {
|
||||
background: var(--dbim-white);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
box-shadow: var(--shadow-card);
|
||||
border: 1px solid rgba(29, 10, 105, 0.06);
|
||||
}
|
||||
|
||||
.timeline-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.dept-name {
|
||||
font-weight: 600;
|
||||
color: var(--dbim-brown);
|
||||
}
|
||||
|
||||
.timeline-time {
|
||||
font-size: 12px;
|
||||
color: var(--dbim-grey-2);
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-remarks {
|
||||
font-size: 13px;
|
||||
color: var(--dbim-grey-3);
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--dbim-linen);
|
||||
}
|
||||
}
|
||||
|
||||
.approvals-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.approval-item {
|
||||
padding: 16px;
|
||||
background-color: var(--dbim-white);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-shadow: var(--shadow-card);
|
||||
border: 1px solid rgba(29, 10, 105, 0.06);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow-card-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.item-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
.name {
|
||||
font-weight: 600;
|
||||
color: var(--dbim-brown);
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: 12px;
|
||||
color: var(--dbim-grey-2);
|
||||
}
|
||||
}
|
||||
|
||||
/* Tab styling */
|
||||
.tab-content {
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.empty-state-card {
|
||||
text-align: center;
|
||||
padding: 48px;
|
||||
background: var(--dbim-linen);
|
||||
border-radius: 16px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 48px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: var(--dbim-grey-2);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--dbim-grey-2);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class RequestDetailComponent implements OnInit {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly requestService = inject(RequestService);
|
||||
private readonly authService = inject(AuthService);
|
||||
private readonly notification = inject(NotificationService);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly api = inject(ApiService);
|
||||
|
||||
readonly loading = signal(true);
|
||||
readonly submitting = signal(false);
|
||||
readonly loadingDocuments = signal(false);
|
||||
readonly request = signal<RequestDetailResponseDto | null>(null);
|
||||
readonly detailedDocuments = signal<any[]>([]);
|
||||
|
||||
readonly isApplicant = this.authService.isApplicant;
|
||||
readonly isDepartment = this.authService.isDepartment;
|
||||
|
||||
get canEdit(): boolean {
|
||||
const req = this.request();
|
||||
return this.isApplicant() && req?.status === 'DRAFT';
|
||||
}
|
||||
|
||||
get canSubmit(): boolean {
|
||||
const req = this.request();
|
||||
return this.isApplicant() && (req?.status === 'DRAFT' || req?.status === 'PENDING_RESUBMISSION');
|
||||
}
|
||||
|
||||
get canCancel(): boolean {
|
||||
const req = this.request();
|
||||
return (
|
||||
this.isApplicant() &&
|
||||
req !== null &&
|
||||
['DRAFT', 'SUBMITTED', 'PENDING_RESUBMISSION'].includes(req.status)
|
||||
);
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadRequest();
|
||||
}
|
||||
|
||||
private loadRequest(): void {
|
||||
const id = this.route.snapshot.paramMap.get('id');
|
||||
if (!id) {
|
||||
this.router.navigate(['/requests']);
|
||||
return;
|
||||
}
|
||||
|
||||
this.requestService.getRequest(id).subscribe({
|
||||
next: (data) => {
|
||||
this.request.set(data);
|
||||
this.loading.set(false);
|
||||
this.loadDetailedDocuments(id);
|
||||
},
|
||||
error: () => {
|
||||
this.notification.error('Request not found');
|
||||
this.router.navigate(['/requests']);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private loadDetailedDocuments(requestId: string): void {
|
||||
this.loadingDocuments.set(true);
|
||||
this.api.get<any[]>(`/admin/documents/${requestId}`).subscribe({
|
||||
next: (documents) => {
|
||||
this.detailedDocuments.set(documents);
|
||||
this.loadingDocuments.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Failed to load detailed documents:', err);
|
||||
this.loadingDocuments.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
submitRequest(): void {
|
||||
const req = this.request();
|
||||
if (!req) return;
|
||||
|
||||
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
|
||||
data: {
|
||||
title: 'Submit Request',
|
||||
message:
|
||||
'Are you sure you want to submit this request? Once submitted, you cannot make changes until the review is complete.',
|
||||
confirmText: 'Submit',
|
||||
confirmColor: 'primary',
|
||||
},
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe((confirmed) => {
|
||||
if (confirmed) {
|
||||
this.submitting.set(true);
|
||||
this.requestService.submitRequest(req.id).subscribe({
|
||||
next: () => {
|
||||
this.notification.success('Request submitted successfully');
|
||||
this.loadRequest();
|
||||
this.submitting.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.submitting.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
cancelRequest(): void {
|
||||
const req = this.request();
|
||||
if (!req) return;
|
||||
|
||||
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
|
||||
data: {
|
||||
title: 'Cancel Request',
|
||||
message: 'Are you sure you want to cancel this request? This action cannot be undone.',
|
||||
confirmText: 'Cancel Request',
|
||||
confirmColor: 'warn',
|
||||
},
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe((confirmed) => {
|
||||
if (confirmed) {
|
||||
this.requestService.cancelRequest(req.id).subscribe({
|
||||
next: () => {
|
||||
this.notification.success('Request cancelled');
|
||||
this.loadRequest();
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
formatType(type: string): string {
|
||||
return type.replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
getMetadataKeys(metadata: Record<string, any> | undefined): string[] {
|
||||
return metadata ? Object.keys(metadata) : [];
|
||||
}
|
||||
|
||||
hasMetadata(metadata: Record<string, any> | undefined): boolean {
|
||||
return metadata ? Object.keys(metadata).length > 0 : false;
|
||||
}
|
||||
|
||||
formatMetadataKey(key: string): string {
|
||||
// Convert camelCase to Title Case
|
||||
return key
|
||||
.replace(/([A-Z])/g, ' $1')
|
||||
.replace(/^./, (str) => str.toUpperCase())
|
||||
.trim();
|
||||
}
|
||||
|
||||
formatDepartmentId(deptId: string): string {
|
||||
// Convert department IDs like "FIRE_DEPT" to "Fire Department"
|
||||
return deptId
|
||||
.replace(/_/g, ' ')
|
||||
.toLowerCase()
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
.replace(/Dept/g, 'Department');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
<div class="page-container">
|
||||
<app-page-header title="License Requests" subtitle="View and manage your license applications">
|
||||
@if (isApplicant()) {
|
||||
<button mat-raised-button color="primary" routerLink="/requests/new" class="create-btn">
|
||||
<mat-icon>add_circle</mat-icon>
|
||||
New Request
|
||||
</button>
|
||||
}
|
||||
</app-page-header>
|
||||
|
||||
<!-- Summary Stats -->
|
||||
<div class="stats-summary">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon total">
|
||||
<mat-icon>description</mat-icon>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ totalItems() }}</div>
|
||||
<div class="stat-label">Total Requests</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon pending">
|
||||
<mat-icon>hourglass_empty</mat-icon>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ getPendingCount() }}</div>
|
||||
<div class="stat-label">Pending Review</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon approved">
|
||||
<mat-icon>check_circle</mat-icon>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ getApprovedCount() }}</div>
|
||||
<div class="stat-label">Approved</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon rejected">
|
||||
<mat-icon>cancel</mat-icon>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ getRejectedCount() }}</div>
|
||||
<div class="stat-label">Rejected</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<mat-card>
|
||||
<mat-card-content>
|
||||
<!-- Filters -->
|
||||
<div class="filters-section">
|
||||
<span class="filter-label">
|
||||
<mat-icon>filter_list</mat-icon>
|
||||
Filters
|
||||
</span>
|
||||
<div class="filters">
|
||||
<mat-form-field appearance="outline" class="filter-field">
|
||||
<mat-label>Status</mat-label>
|
||||
<mat-select [formControl]="statusFilter">
|
||||
<mat-option value="">All Statuses</mat-option>
|
||||
@for (status of statuses; track status) {
|
||||
<mat-option [value]="status">{{ formatStatus(status) }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="filter-field">
|
||||
<mat-label>Request Type</mat-label>
|
||||
<mat-select [formControl]="typeFilter">
|
||||
<mat-option value="">All Types</mat-option>
|
||||
@for (type of requestTypes; track type) {
|
||||
<mat-option [value]="type">{{ formatType(type) | titlecase }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="loading-container">
|
||||
<mat-spinner diameter="48"></mat-spinner>
|
||||
<span class="loading-text">Loading requests...</span>
|
||||
</div>
|
||||
} @else if (requests().length === 0) {
|
||||
<app-empty-state
|
||||
icon="description"
|
||||
title="No requests found"
|
||||
message="No license requests match your current filters. Create a new request to get started."
|
||||
>
|
||||
@if (isApplicant()) {
|
||||
<button mat-raised-button color="primary" routerLink="/requests/new">
|
||||
<mat-icon>add</mat-icon>
|
||||
Create Request
|
||||
</button>
|
||||
}
|
||||
</app-empty-state>
|
||||
} @else {
|
||||
<div class="table-container">
|
||||
<table mat-table [dataSource]="requests()">
|
||||
<ng-container matColumnDef="requestNumber">
|
||||
<th mat-header-cell *matHeaderCellDef>Request ID</th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
<span class="request-number">{{ row.requestNumber }}</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="requestType">
|
||||
<th mat-header-cell *matHeaderCellDef>Type</th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
<span class="type-badge">
|
||||
<mat-icon>{{ getTypeIcon(row.requestType) }}</mat-icon>
|
||||
{{ formatType(row.requestType) | titlecase }}
|
||||
</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="status">
|
||||
<th mat-header-cell *matHeaderCellDef>Status</th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
<app-status-badge [status]="row.status" />
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="createdAt">
|
||||
<th mat-header-cell *matHeaderCellDef>Created</th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
<div class="date-cell">
|
||||
<span class="date-main">{{ row.createdAt | date: 'mediumDate' }}</span>
|
||||
<span class="date-time">{{ row.createdAt | date: 'shortTime' }}</span>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="updatedAt">
|
||||
<th mat-header-cell *matHeaderCellDef>Last Updated</th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
<div class="date-cell">
|
||||
<span class="date-main">{{ row.updatedAt | date: 'mediumDate' }}</span>
|
||||
<span class="date-time">{{ row.updatedAt | date: 'shortTime' }}</span>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef></th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
<div class="quick-actions">
|
||||
<button mat-icon-button class="action-btn" [routerLink]="['/requests', row.id]"
|
||||
matTooltip="View Details">
|
||||
<mat-icon>visibility</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr
|
||||
mat-row
|
||||
*matRowDef="let row; columns: displayedColumns"
|
||||
[routerLink]="['/requests', row.id]"
|
||||
></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<mat-paginator
|
||||
[length]="totalItems()"
|
||||
[pageSize]="pageSize()"
|
||||
[pageIndex]="pageIndex()"
|
||||
[pageSizeOptions]="[5, 10, 25, 50]"
|
||||
(page)="onPageChange($event)"
|
||||
showFirstLastButtons
|
||||
/>
|
||||
}
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
@@ -0,0 +1,457 @@
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterModule, ActivatedRoute } from '@angular/router';
|
||||
import { FormControl, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { PageHeaderComponent } from '../../../shared/components/page-header/page-header.component';
|
||||
import { StatusBadgeComponent } from '../../../shared/components/status-badge/status-badge.component';
|
||||
import { EmptyStateComponent } from '../../../shared/components/empty-state/empty-state.component';
|
||||
import { RequestService } from '../services/request.service';
|
||||
import { AuthService } from '../../../core/services/auth.service';
|
||||
import { RequestResponseDto, RequestStatus, RequestType } from '../../../api/models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-request-list',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterModule,
|
||||
ReactiveFormsModule,
|
||||
MatCardModule,
|
||||
MatTableModule,
|
||||
MatPaginatorModule,
|
||||
MatFormFieldModule,
|
||||
MatSelectModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatTooltipModule,
|
||||
PageHeaderComponent,
|
||||
StatusBadgeComponent,
|
||||
EmptyStateComponent,
|
||||
],
|
||||
templateUrl: './request-list.component.html',
|
||||
styles: [
|
||||
`
|
||||
/* Summary Stats Section */
|
||||
.stats-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
background: var(--dbim-white);
|
||||
border-radius: 16px;
|
||||
box-shadow: var(--shadow-card);
|
||||
border: 1px solid rgba(29, 10, 105, 0.06);
|
||||
transition: all 0.25s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow-card-hover);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
mat-icon {
|
||||
font-size: 24px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.total {
|
||||
background: linear-gradient(135deg, var(--dbim-blue-dark) 0%, var(--dbim-blue-mid) 100%);
|
||||
}
|
||||
|
||||
&.pending {
|
||||
background: linear-gradient(135deg, #d97706 0%, #f59e0b 100%);
|
||||
}
|
||||
|
||||
&.approved {
|
||||
background: linear-gradient(135deg, #059669 0%, #10b981 100%);
|
||||
}
|
||||
|
||||
&.rejected {
|
||||
background: linear-gradient(135deg, #dc2626 0%, #ef4444 100%);
|
||||
}
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--dbim-brown);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: var(--dbim-grey-2);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Filters Section */
|
||||
.filters-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px 20px;
|
||||
background: var(--dbim-linen);
|
||||
border-radius: 12px;
|
||||
margin-bottom: 24px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.filter-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--dbim-grey-3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-field {
|
||||
width: 180px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Table Styles */
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(29, 10, 105, 0.06);
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
min-width: 800px;
|
||||
}
|
||||
|
||||
.mat-mdc-header-row {
|
||||
background: var(--dbim-linen);
|
||||
}
|
||||
|
||||
.mat-mdc-row {
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(29, 10, 105, 0.02);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 64px;
|
||||
gap: 16px;
|
||||
|
||||
.loading-text {
|
||||
font-size: 14px;
|
||||
color: var(--dbim-grey-2);
|
||||
}
|
||||
}
|
||||
|
||||
.request-number {
|
||||
font-weight: 600;
|
||||
color: var(--dbim-blue-mid);
|
||||
font-family: 'Roboto Mono', monospace;
|
||||
font-size: 13px;
|
||||
|
||||
&:hover {
|
||||
color: var(--dbim-blue-dark);
|
||||
}
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
background: var(--dbim-blue-subtle);
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--dbim-blue-mid);
|
||||
|
||||
mat-icon {
|
||||
font-size: 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.date-cell {
|
||||
font-size: 13px;
|
||||
color: var(--dbim-grey-3);
|
||||
|
||||
.date-main {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.date-time {
|
||||
font-size: 11px;
|
||||
color: var(--dbim-grey-2);
|
||||
}
|
||||
}
|
||||
|
||||
/* Quick Action Buttons */
|
||||
.quick-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.mat-mdc-row:hover .quick-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
min-width: 32px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class RequestListComponent implements OnInit {
|
||||
private readonly requestService = inject(RequestService);
|
||||
private readonly authService = inject(AuthService);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
|
||||
readonly loading = signal(true);
|
||||
readonly requests = signal<RequestResponseDto[]>([]);
|
||||
readonly totalItems = signal(0);
|
||||
readonly pageSize = signal(10);
|
||||
readonly pageIndex = signal(0);
|
||||
|
||||
readonly statusFilter = new FormControl<RequestStatus | ''>('');
|
||||
readonly typeFilter = new FormControl<RequestType | ''>('');
|
||||
|
||||
readonly displayedColumns = ['requestNumber', 'requestType', 'status', 'createdAt', 'updatedAt', 'actions'];
|
||||
readonly statuses: RequestStatus[] = [
|
||||
'DRAFT',
|
||||
'SUBMITTED',
|
||||
'IN_REVIEW',
|
||||
'PENDING_RESUBMISSION',
|
||||
'APPROVED',
|
||||
'REJECTED',
|
||||
'CANCELLED',
|
||||
];
|
||||
readonly requestTypes: RequestType[] = [
|
||||
'NEW_LICENSE',
|
||||
'RENEWAL',
|
||||
'AMENDMENT',
|
||||
'MODIFICATION',
|
||||
'CANCELLATION',
|
||||
];
|
||||
|
||||
readonly isApplicant = this.authService.isApplicant;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.route.queryParams.subscribe((params) => {
|
||||
if (params['status']) {
|
||||
this.statusFilter.setValue(params['status']);
|
||||
}
|
||||
this.loadRequests();
|
||||
});
|
||||
|
||||
this.statusFilter.valueChanges.subscribe(() => {
|
||||
this.pageIndex.set(0);
|
||||
this.loadRequests();
|
||||
});
|
||||
|
||||
this.typeFilter.valueChanges.subscribe(() => {
|
||||
this.pageIndex.set(0);
|
||||
this.loadRequests();
|
||||
});
|
||||
}
|
||||
|
||||
loadRequests(): void {
|
||||
this.loading.set(true);
|
||||
const user = this.authService.getCurrentUser();
|
||||
|
||||
this.requestService
|
||||
.getRequests({
|
||||
page: this.pageIndex() + 1,
|
||||
limit: this.pageSize(),
|
||||
status: this.statusFilter.value || undefined,
|
||||
requestType: this.typeFilter.value || undefined,
|
||||
applicantId: this.isApplicant() ? user?.id : undefined,
|
||||
})
|
||||
.subscribe({
|
||||
next: (response) => {
|
||||
const data = response?.data ?? [];
|
||||
// Use mock data if API returns empty results (demo mode)
|
||||
if (data.length === 0) {
|
||||
const mockData = this.getMockRequests();
|
||||
this.requests.set(mockData);
|
||||
this.totalItems.set(mockData.length);
|
||||
} else {
|
||||
this.requests.set(data);
|
||||
this.totalItems.set(response.total ?? 0);
|
||||
}
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
// Use mock data when API is unavailable
|
||||
const mockData = this.getMockRequests();
|
||||
this.requests.set(mockData);
|
||||
this.totalItems.set(mockData.length);
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private getMockRequests(): RequestResponseDto[] {
|
||||
return [
|
||||
{
|
||||
id: 'req-001',
|
||||
requestNumber: 'GOA-2026-001',
|
||||
requestType: 'NEW_LICENSE',
|
||||
status: 'SUBMITTED',
|
||||
applicantId: 'user-001',
|
||||
currentStageId: 'stage-001',
|
||||
metadata: { businessName: 'Goa Beach Resort' },
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: 'req-002',
|
||||
requestNumber: 'GOA-2026-002',
|
||||
requestType: 'RENEWAL',
|
||||
status: 'IN_REVIEW',
|
||||
applicantId: 'user-002',
|
||||
currentStageId: 'stage-002',
|
||||
metadata: { businessName: 'Panjim Restaurant' },
|
||||
createdAt: new Date(Date.now() - 86400000).toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: 'req-003',
|
||||
requestNumber: 'GOA-2026-003',
|
||||
requestType: 'AMENDMENT',
|
||||
status: 'APPROVED',
|
||||
applicantId: 'user-001',
|
||||
currentStageId: 'stage-003',
|
||||
metadata: { businessName: 'Calangute Hotel' },
|
||||
blockchainTxHash: '0x123abc456def',
|
||||
createdAt: new Date(Date.now() - 172800000).toISOString(),
|
||||
updatedAt: new Date(Date.now() - 86400000).toISOString(),
|
||||
approvedAt: new Date(Date.now() - 86400000).toISOString(),
|
||||
},
|
||||
{
|
||||
id: 'req-004',
|
||||
requestNumber: 'GOA-2026-004',
|
||||
requestType: 'NEW_LICENSE',
|
||||
status: 'PENDING_RESUBMISSION',
|
||||
applicantId: 'user-003',
|
||||
currentStageId: 'stage-001',
|
||||
metadata: { businessName: 'Margao Traders' },
|
||||
createdAt: new Date(Date.now() - 259200000).toISOString(),
|
||||
updatedAt: new Date(Date.now() - 43200000).toISOString(),
|
||||
},
|
||||
{
|
||||
id: 'req-005',
|
||||
requestNumber: 'GOA-2026-005',
|
||||
requestType: 'CANCELLATION',
|
||||
status: 'REJECTED',
|
||||
applicantId: 'user-002',
|
||||
currentStageId: 'stage-004',
|
||||
metadata: { businessName: 'Vasco Shops' },
|
||||
createdAt: new Date(Date.now() - 345600000).toISOString(),
|
||||
updatedAt: new Date(Date.now() - 172800000).toISOString(),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
onPageChange(event: PageEvent): void {
|
||||
this.pageIndex.set(event.pageIndex);
|
||||
this.pageSize.set(event.pageSize);
|
||||
this.loadRequests();
|
||||
}
|
||||
|
||||
formatType(type: string): string {
|
||||
return type.replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
formatStatus(status: string): string {
|
||||
return status.replace(/_/g, ' ').toLowerCase().replace(/^\w/, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
getTypeIcon(type: string): string {
|
||||
switch (type) {
|
||||
case 'NEW_LICENSE':
|
||||
return 'add_circle';
|
||||
case 'RENEWAL':
|
||||
return 'autorenew';
|
||||
case 'AMENDMENT':
|
||||
return 'edit_note';
|
||||
case 'MODIFICATION':
|
||||
return 'tune';
|
||||
case 'CANCELLATION':
|
||||
return 'cancel';
|
||||
default:
|
||||
return 'description';
|
||||
}
|
||||
}
|
||||
|
||||
getPendingCount(): number {
|
||||
return this.requests().filter(
|
||||
(r) => r.status === 'SUBMITTED' || r.status === 'IN_REVIEW' || r.status === 'PENDING_RESUBMISSION'
|
||||
).length;
|
||||
}
|
||||
|
||||
getApprovedCount(): number {
|
||||
return this.requests().filter((r) => r.status === 'APPROVED').length;
|
||||
}
|
||||
|
||||
getRejectedCount(): number {
|
||||
return this.requests().filter((r) => r.status === 'REJECTED').length;
|
||||
}
|
||||
}
|
||||
19
frontend/src/app/features/requests/requests.routes.ts
Normal file
19
frontend/src/app/features/requests/requests.routes.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
export const REQUESTS_ROUTES: Routes = [
|
||||
{
|
||||
path: '',
|
||||
loadComponent: () =>
|
||||
import('./request-list/request-list.component').then((m) => m.RequestListComponent),
|
||||
},
|
||||
{
|
||||
path: 'new',
|
||||
loadComponent: () =>
|
||||
import('./request-create/request-create.component').then((m) => m.RequestCreateComponent),
|
||||
},
|
||||
{
|
||||
path: ':id',
|
||||
loadComponent: () =>
|
||||
import('./request-detail/request-detail.component').then((m) => m.RequestDetailComponent),
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { ApiService } from '../../../core/services/api.service';
|
||||
import {
|
||||
RequestResponseDto,
|
||||
RequestDetailResponseDto,
|
||||
CreateRequestDto,
|
||||
UpdateRequestDto,
|
||||
PaginatedRequestsResponse,
|
||||
RequestFilters,
|
||||
} from '../../../api/models';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class RequestService {
|
||||
private readonly api = inject(ApiService);
|
||||
|
||||
getRequests(filters?: RequestFilters): Observable<PaginatedRequestsResponse> {
|
||||
return this.api.get<PaginatedRequestsResponse>('/requests', filters as Record<string, string | number | boolean>);
|
||||
}
|
||||
|
||||
getRequest(id: string): Observable<RequestDetailResponseDto> {
|
||||
return this.api.get<RequestDetailResponseDto>(`/requests/${id}`);
|
||||
}
|
||||
|
||||
createRequest(dto: CreateRequestDto): Observable<RequestResponseDto> {
|
||||
return this.api.post<RequestResponseDto>('/requests', dto);
|
||||
}
|
||||
|
||||
updateRequest(id: string, dto: UpdateRequestDto): Observable<RequestResponseDto> {
|
||||
return this.api.patch<RequestResponseDto>(`/requests/${id}`, dto);
|
||||
}
|
||||
|
||||
submitRequest(id: string): Observable<RequestResponseDto> {
|
||||
return this.api.post<RequestResponseDto>(`/requests/${id}/submit`, {});
|
||||
}
|
||||
|
||||
cancelRequest(id: string): Observable<RequestResponseDto> {
|
||||
return this.api.post<RequestResponseDto>(`/requests/${id}/cancel`, {});
|
||||
}
|
||||
|
||||
deleteRequest(id: string): Observable<void> {
|
||||
return this.api.delete<void>(`/requests/${id}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { ApiService } from '../../../core/services/api.service';
|
||||
import {
|
||||
WebhookResponseDto,
|
||||
CreateWebhookDto,
|
||||
UpdateWebhookDto,
|
||||
WebhookTestResultDto,
|
||||
WebhookLogEntryDto,
|
||||
PaginatedWebhookLogsResponse,
|
||||
} from '../../../api/models';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class WebhookService {
|
||||
private readonly api = inject(ApiService);
|
||||
|
||||
getWebhooks(): Observable<WebhookResponseDto[]> {
|
||||
return this.api.get<WebhookResponseDto[]>('/webhooks');
|
||||
}
|
||||
|
||||
getWebhook(id: string): Observable<WebhookResponseDto> {
|
||||
return this.api.get<WebhookResponseDto>(`/webhooks/${id}`);
|
||||
}
|
||||
|
||||
createWebhook(dto: CreateWebhookDto): Observable<WebhookResponseDto> {
|
||||
return this.api.post<WebhookResponseDto>('/webhooks', dto);
|
||||
}
|
||||
|
||||
updateWebhook(id: string, dto: UpdateWebhookDto): Observable<WebhookResponseDto> {
|
||||
return this.api.patch<WebhookResponseDto>(`/webhooks/${id}`, dto);
|
||||
}
|
||||
|
||||
deleteWebhook(id: string): Observable<void> {
|
||||
return this.api.delete<void>(`/webhooks/${id}`);
|
||||
}
|
||||
|
||||
testWebhook(id: string): Observable<WebhookTestResultDto> {
|
||||
return this.api.post<WebhookTestResultDto>(`/webhooks/${id}/test`, {});
|
||||
}
|
||||
|
||||
getWebhookLogs(id: string, page = 1, limit = 20): Observable<PaginatedWebhookLogsResponse> {
|
||||
return this.api.get<PaginatedWebhookLogsResponse>(`/webhooks/${id}/logs`, { page, limit });
|
||||
}
|
||||
|
||||
toggleActive(id: string, isActive: boolean): Observable<WebhookResponseDto> {
|
||||
return this.api.patch<WebhookResponseDto>(`/webhooks/${id}`, { isActive });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { PageHeaderComponent } from '../../../shared/components/page-header/page-header.component';
|
||||
import { WebhookService } from '../services/webhook.service';
|
||||
import { NotificationService } from '../../../core/services/notification.service';
|
||||
import { WebhookEvent } from '../../../api/models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-webhook-form',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterModule,
|
||||
ReactiveFormsModule,
|
||||
MatCardModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatCheckboxModule,
|
||||
MatProgressSpinnerModule,
|
||||
PageHeaderComponent,
|
||||
],
|
||||
template: `
|
||||
<div class="page-container">
|
||||
<app-page-header
|
||||
[title]="isEditMode() ? 'Edit Webhook' : 'Register Webhook'"
|
||||
[subtitle]="isEditMode() ? 'Update webhook configuration' : 'Configure a new webhook endpoint'"
|
||||
>
|
||||
<button mat-button routerLink="/webhooks">
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
Back
|
||||
</button>
|
||||
</app-page-header>
|
||||
|
||||
<mat-card class="form-card">
|
||||
<mat-card-content>
|
||||
@if (loading()) {
|
||||
<div class="loading-container">
|
||||
<mat-spinner diameter="48"></mat-spinner>
|
||||
</div>
|
||||
} @else {
|
||||
<form [formGroup]="form" (ngSubmit)="onSubmit()">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Webhook URL</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="url"
|
||||
placeholder="https://your-server.com/webhook"
|
||||
/>
|
||||
@if (form.controls.url.hasError('required')) {
|
||||
<mat-error>URL is required</mat-error>
|
||||
}
|
||||
@if (form.controls.url.hasError('pattern')) {
|
||||
<mat-error>Enter a valid HTTPS URL</mat-error>
|
||||
}
|
||||
<mat-hint>Must be a publicly accessible HTTPS endpoint</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Events</mat-label>
|
||||
<mat-select formControlName="events" multiple>
|
||||
@for (event of eventOptions; track event.value) {
|
||||
<mat-option [value]="event.value">{{ event.label }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
@if (form.controls.events.hasError('required')) {
|
||||
<mat-error>Select at least one event</mat-error>
|
||||
}
|
||||
<mat-hint>Select the events you want to receive notifications for</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Description (optional)</mat-label>
|
||||
<textarea
|
||||
matInput
|
||||
formControlName="description"
|
||||
rows="2"
|
||||
placeholder="What is this webhook used for?"
|
||||
></textarea>
|
||||
</mat-form-field>
|
||||
|
||||
<div class="form-actions">
|
||||
<button mat-button type="button" routerLink="/webhooks">Cancel</button>
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
type="submit"
|
||||
[disabled]="form.invalid || submitting()"
|
||||
>
|
||||
@if (submitting()) {
|
||||
<mat-spinner diameter="20"></mat-spinner>
|
||||
} @else {
|
||||
{{ isEditMode() ? 'Update' : 'Register' }}
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.form-card {
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 48px;
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class WebhookFormComponent implements OnInit {
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly webhookService = inject(WebhookService);
|
||||
private readonly notification = inject(NotificationService);
|
||||
|
||||
readonly loading = signal(false);
|
||||
readonly submitting = signal(false);
|
||||
readonly isEditMode = signal(false);
|
||||
private webhookId: string | null = null;
|
||||
|
||||
readonly eventOptions: { value: WebhookEvent; label: string }[] = [
|
||||
{ value: 'APPROVAL_REQUIRED', label: 'Approval Required' },
|
||||
{ value: 'DOCUMENT_UPDATED', label: 'Document Updated' },
|
||||
{ value: 'REQUEST_APPROVED', label: 'Request Approved' },
|
||||
{ value: 'REQUEST_REJECTED', label: 'Request Rejected' },
|
||||
{ value: 'CHANGES_REQUESTED', label: 'Changes Requested' },
|
||||
{ value: 'LICENSE_MINTED', label: 'License Minted' },
|
||||
{ value: 'LICENSE_REVOKED', label: 'License Revoked' },
|
||||
];
|
||||
|
||||
readonly form = this.fb.nonNullable.group({
|
||||
url: ['', [Validators.required, Validators.pattern(/^https:\/\/.+/)]],
|
||||
events: [[] as WebhookEvent[], [Validators.required]],
|
||||
description: [''],
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
this.webhookId = this.route.snapshot.paramMap.get('id');
|
||||
if (this.webhookId) {
|
||||
this.isEditMode.set(true);
|
||||
this.loadWebhook();
|
||||
}
|
||||
}
|
||||
|
||||
private loadWebhook(): void {
|
||||
if (!this.webhookId) return;
|
||||
|
||||
this.loading.set(true);
|
||||
this.webhookService.getWebhook(this.webhookId).subscribe({
|
||||
next: (webhook) => {
|
||||
this.form.patchValue({
|
||||
url: webhook.url,
|
||||
events: webhook.events,
|
||||
description: webhook.description || '',
|
||||
});
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.notification.error('Failed to load webhook');
|
||||
this.router.navigate(['/webhooks']);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onSubmit(): void {
|
||||
if (this.form.invalid) return;
|
||||
|
||||
this.submitting.set(true);
|
||||
const values = this.form.getRawValue();
|
||||
|
||||
const action$ = this.isEditMode()
|
||||
? this.webhookService.updateWebhook(this.webhookId!, values)
|
||||
: this.webhookService.createWebhook(values);
|
||||
|
||||
action$.subscribe({
|
||||
next: () => {
|
||||
this.notification.success(
|
||||
this.isEditMode() ? 'Webhook updated' : 'Webhook registered'
|
||||
);
|
||||
this.router.navigate(['/webhooks']);
|
||||
},
|
||||
error: () => {
|
||||
this.submitting.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { PageHeaderComponent } from '../../../shared/components/page-header/page-header.component';
|
||||
import { StatusBadgeComponent } from '../../../shared/components/status-badge/status-badge.component';
|
||||
import { EmptyStateComponent } from '../../../shared/components/empty-state/empty-state.component';
|
||||
import { ConfirmDialogComponent } from '../../../shared/components/confirm-dialog/confirm-dialog.component';
|
||||
import { WebhookService } from '../services/webhook.service';
|
||||
import { NotificationService } from '../../../core/services/notification.service';
|
||||
import { WebhookResponseDto } from '../../../api/models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-webhook-list',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterModule,
|
||||
MatCardModule,
|
||||
MatTableModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatChipsModule,
|
||||
MatMenuModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatDialogModule,
|
||||
PageHeaderComponent,
|
||||
StatusBadgeComponent,
|
||||
EmptyStateComponent,
|
||||
],
|
||||
template: `
|
||||
<div class="page-container">
|
||||
<app-page-header title="Webhooks" subtitle="Manage event notifications">
|
||||
<button mat-raised-button color="primary" routerLink="new">
|
||||
<mat-icon>add</mat-icon>
|
||||
Register Webhook
|
||||
</button>
|
||||
</app-page-header>
|
||||
|
||||
<mat-card>
|
||||
<mat-card-content>
|
||||
@if (loading()) {
|
||||
<div class="loading-container">
|
||||
<mat-spinner diameter="48"></mat-spinner>
|
||||
</div>
|
||||
} @else if (webhooks().length === 0) {
|
||||
<app-empty-state
|
||||
icon="webhook"
|
||||
title="No webhooks"
|
||||
message="Register a webhook to receive event notifications."
|
||||
>
|
||||
<button mat-raised-button color="primary" routerLink="new">
|
||||
<mat-icon>add</mat-icon>
|
||||
Register Webhook
|
||||
</button>
|
||||
</app-empty-state>
|
||||
} @else {
|
||||
<table mat-table [dataSource]="webhooks()">
|
||||
<ng-container matColumnDef="url">
|
||||
<th mat-header-cell *matHeaderCellDef>URL</th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
<span class="url-cell">{{ row.url }}</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="events">
|
||||
<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) {
|
||||
<mat-chip>{{ formatEvent(event) }}</mat-chip>
|
||||
}
|
||||
@if (row.events.length > 2) {
|
||||
<mat-chip>+{{ row.events.length - 2 }}</mat-chip>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="status">
|
||||
<th mat-header-cell *matHeaderCellDef>Status</th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
<app-status-badge [status]="row.isActive ? 'ACTIVE' : 'INACTIVE'" />
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef></th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
<button mat-icon-button [matMenuTriggerFor]="menu">
|
||||
<mat-icon>more_vert</mat-icon>
|
||||
</button>
|
||||
<mat-menu #menu="matMenu">
|
||||
<button mat-menu-item (click)="testWebhook(row)">
|
||||
<mat-icon>send</mat-icon>
|
||||
<span>Test</span>
|
||||
</button>
|
||||
<button mat-menu-item [routerLink]="[row.id, 'logs']">
|
||||
<mat-icon>history</mat-icon>
|
||||
<span>View Logs</span>
|
||||
</button>
|
||||
<button mat-menu-item [routerLink]="[row.id, 'edit']">
|
||||
<mat-icon>edit</mat-icon>
|
||||
<span>Edit</span>
|
||||
</button>
|
||||
<button mat-menu-item (click)="deleteWebhook(row)">
|
||||
<mat-icon color="warn">delete</mat-icon>
|
||||
<span>Delete</span>
|
||||
</button>
|
||||
</mat-menu>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
|
||||
</table>
|
||||
}
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.loading-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 48px;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.url-cell {
|
||||
font-family: monospace;
|
||||
font-size: 0.875rem;
|
||||
max-width: 300px;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.events-chips {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.mat-column-actions {
|
||||
width: 60px;
|
||||
text-align: right;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class WebhookListComponent implements OnInit {
|
||||
private readonly webhookService = inject(WebhookService);
|
||||
private readonly notification = inject(NotificationService);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
|
||||
readonly loading = signal(true);
|
||||
readonly webhooks = signal<WebhookResponseDto[]>([]);
|
||||
|
||||
readonly displayedColumns = ['url', 'events', 'status', 'actions'];
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadWebhooks();
|
||||
}
|
||||
|
||||
loadWebhooks(): void {
|
||||
this.loading.set(true);
|
||||
this.webhookService.getWebhooks().subscribe({
|
||||
next: (data) => {
|
||||
this.webhooks.set(data);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
formatEvent(event: string): string {
|
||||
return event.replace(/_/g, ' ').toLowerCase();
|
||||
}
|
||||
|
||||
testWebhook(webhook: WebhookResponseDto): void {
|
||||
this.webhookService.testWebhook(webhook.id).subscribe({
|
||||
next: (result) => {
|
||||
if (result.success) {
|
||||
this.notification.success(`Webhook test successful (${result.statusCode})`);
|
||||
} else {
|
||||
this.notification.error(`Webhook test failed: ${result.error || result.statusMessage}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
deleteWebhook(webhook: WebhookResponseDto): void {
|
||||
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
|
||||
data: {
|
||||
title: 'Delete Webhook',
|
||||
message: 'Are you sure you want to delete this webhook?',
|
||||
confirmText: 'Delete',
|
||||
confirmColor: 'warn',
|
||||
},
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe((confirmed) => {
|
||||
if (confirmed) {
|
||||
this.webhookService.deleteWebhook(webhook.id).subscribe({
|
||||
next: () => {
|
||||
this.notification.success('Webhook deleted');
|
||||
this.loadWebhooks();
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { PageHeaderComponent } from '../../../shared/components/page-header/page-header.component';
|
||||
import { EmptyStateComponent } from '../../../shared/components/empty-state/empty-state.component';
|
||||
import { WebhookService } from '../services/webhook.service';
|
||||
import { WebhookLogEntryDto } from '../../../api/models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-webhook-logs',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterModule,
|
||||
MatCardModule,
|
||||
MatTableModule,
|
||||
MatPaginatorModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatChipsModule,
|
||||
MatProgressSpinnerModule,
|
||||
PageHeaderComponent,
|
||||
EmptyStateComponent,
|
||||
],
|
||||
template: `
|
||||
<div class="page-container">
|
||||
<app-page-header title="Webhook Logs" subtitle="Delivery history and status">
|
||||
<button mat-button routerLink="/webhooks">
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
Back to Webhooks
|
||||
</button>
|
||||
</app-page-header>
|
||||
|
||||
<mat-card>
|
||||
<mat-card-content>
|
||||
@if (loading()) {
|
||||
<div class="loading-container">
|
||||
<mat-spinner diameter="48"></mat-spinner>
|
||||
</div>
|
||||
} @else if (logs().length === 0) {
|
||||
<app-empty-state
|
||||
icon="history"
|
||||
title="No logs yet"
|
||||
message="Webhook delivery logs will appear here once events are triggered."
|
||||
/>
|
||||
} @else {
|
||||
<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>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="event">
|
||||
<th mat-header-cell *matHeaderCellDef>Event</th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
<mat-chip>{{ formatEvent(row.event) }}</mat-chip>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="status">
|
||||
<th mat-header-cell *matHeaderCellDef>Status</th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
<span class="status-code" [class.success]="isSuccess(row.statusCode)">
|
||||
{{ row.statusCode }}
|
||||
</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="responseTime">
|
||||
<th mat-header-cell *matHeaderCellDef>Response Time</th>
|
||||
<td mat-cell *matCellDef="let row">{{ row.responseTime }}ms</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="retries">
|
||||
<th mat-header-cell *matHeaderCellDef>Retries</th>
|
||||
<td mat-cell *matCellDef="let row">{{ row.retryCount }}</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
|
||||
</table>
|
||||
|
||||
<mat-paginator
|
||||
[length]="totalItems()"
|
||||
[pageSize]="pageSize()"
|
||||
[pageIndex]="pageIndex()"
|
||||
[pageSizeOptions]="[10, 25, 50]"
|
||||
(page)="onPageChange($event)"
|
||||
showFirstLastButtons
|
||||
/>
|
||||
}
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.loading-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 48px;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.status-code {
|
||||
font-family: monospace;
|
||||
font-weight: 500;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
background-color: #ffcdd2;
|
||||
color: #c62828;
|
||||
|
||||
&.success {
|
||||
background-color: #c8e6c9;
|
||||
color: #2e7d32;
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class WebhookLogsComponent implements OnInit {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly webhookService = inject(WebhookService);
|
||||
|
||||
readonly loading = signal(true);
|
||||
readonly logs = signal<WebhookLogEntryDto[]>([]);
|
||||
readonly totalItems = signal(0);
|
||||
readonly pageSize = signal(20);
|
||||
readonly pageIndex = signal(0);
|
||||
|
||||
readonly displayedColumns = ['timestamp', 'event', 'status', 'responseTime', 'retries'];
|
||||
|
||||
private webhookId: string | null = null;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.webhookId = this.route.snapshot.paramMap.get('id');
|
||||
if (!this.webhookId) {
|
||||
this.router.navigate(['/webhooks']);
|
||||
return;
|
||||
}
|
||||
this.loadLogs();
|
||||
}
|
||||
|
||||
loadLogs(): void {
|
||||
if (!this.webhookId) return;
|
||||
|
||||
this.loading.set(true);
|
||||
this.webhookService
|
||||
.getWebhookLogs(this.webhookId, this.pageIndex() + 1, this.pageSize())
|
||||
.subscribe({
|
||||
next: (response) => {
|
||||
this.logs.set(response.data);
|
||||
this.totalItems.set(response.total);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onPageChange(event: PageEvent): void {
|
||||
this.pageIndex.set(event.pageIndex);
|
||||
this.pageSize.set(event.pageSize);
|
||||
this.loadLogs();
|
||||
}
|
||||
|
||||
formatEvent(event: string): string {
|
||||
return event.replace(/_/g, ' ').toLowerCase();
|
||||
}
|
||||
|
||||
isSuccess(statusCode: number): boolean {
|
||||
return statusCode >= 200 && statusCode < 300;
|
||||
}
|
||||
}
|
||||
29
frontend/src/app/features/webhooks/webhooks.routes.ts
Normal file
29
frontend/src/app/features/webhooks/webhooks.routes.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { authGuard } from '../../core/guards';
|
||||
|
||||
export const WEBHOOKS_ROUTES: Routes = [
|
||||
{
|
||||
path: '',
|
||||
loadComponent: () =>
|
||||
import('./webhook-list/webhook-list.component').then((m) => m.WebhookListComponent),
|
||||
canActivate: [authGuard],
|
||||
},
|
||||
{
|
||||
path: 'new',
|
||||
loadComponent: () =>
|
||||
import('./webhook-form/webhook-form.component').then((m) => m.WebhookFormComponent),
|
||||
canActivate: [authGuard],
|
||||
},
|
||||
{
|
||||
path: ':id/logs',
|
||||
loadComponent: () =>
|
||||
import('./webhook-logs/webhook-logs.component').then((m) => m.WebhookLogsComponent),
|
||||
canActivate: [authGuard],
|
||||
},
|
||||
{
|
||||
path: ':id/edit',
|
||||
loadComponent: () =>
|
||||
import('./webhook-form/webhook-form.component').then((m) => m.WebhookFormComponent),
|
||||
canActivate: [authGuard],
|
||||
},
|
||||
];
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user