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:
Mahi
2026-02-07 10:23:29 -04:00
commit 80566bf0a2
441 changed files with 102418 additions and 0 deletions

View File

@@ -0,0 +1,119 @@
<!-- Skip to main content - GIGW 3.0 Accessibility -->
<a href="#main-content" class="skip-link">Skip to main content</a>
<div class="auth-layout">
<!-- Animated Background -->
<div class="animated-background">
<!-- Floating Blockchain Nodes -->
<div class="node node-1"></div>
<div class="node node-2"></div>
<div class="node node-3"></div>
<div class="node node-4"></div>
<div class="node node-5"></div>
<div class="node node-6"></div>
<!-- Connection Lines -->
<svg class="connections" viewBox="0 0 100 100" preserveAspectRatio="none">
<line class="connection" x1="20" y1="30" x2="50" y2="50" />
<line class="connection" x1="50" y1="50" x2="80" y2="25" />
<line class="connection" x1="80" y1="25" x2="70" y2="70" />
<line class="connection" x1="70" y1="70" x2="30" y2="80" />
<line class="connection" x1="30" y1="80" x2="20" y2="30" />
<line class="connection" x1="50" y1="50" x2="70" y2="70" />
<line class="connection" x1="50" y1="50" x2="30" y2="80" />
</svg>
<!-- Gradient Overlay -->
<div class="gradient-overlay"></div>
</div>
<!-- Main Content Container -->
<div class="auth-container">
<!-- Left Side - Branding -->
<div class="auth-branding">
<div class="branding-content">
<div class="emblem-wrapper">
<img
src="assets/images/goa-emblem.svg"
alt="Government of Goa Emblem"
class="goa-emblem"
onerror="this.style.display='none'"
/>
</div>
<h1 class="brand-title">
<span class="title-line">Government of Goa</span>
<span class="title-highlight">Blockchain e-Licensing</span>
</h1>
<p class="brand-tagline">
Secure, Transparent, Immutable
</p>
<div class="features-grid">
<div class="feature-item">
<div class="feature-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="24" height="24">
<path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z"/>
</svg>
</div>
<div class="feature-text">
<span class="feature-title">Blockchain Secured</span>
<span class="feature-desc">Tamper-proof license records</span>
</div>
</div>
<div class="feature-item">
<div class="feature-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="24" height="24">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
</svg>
</div>
<div class="feature-text">
<span class="feature-title">Instant Verification</span>
<span class="feature-desc">Real-time license validity</span>
</div>
</div>
<div class="feature-item">
<div class="feature-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="24" height="24">
<path d="M9 11H7v2h2v-2zm4 0h-2v2h2v-2zm4 0h-2v2h2v-2zm2-7h-1V2h-2v2H8V2H6v2H5c-1.11 0-1.99.9-1.99 2L3 20c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 16H5V9h14v11z"/>
</svg>
</div>
<div class="feature-text">
<span class="feature-title">Multi-Dept Workflow</span>
<span class="feature-desc">Streamlined approvals</span>
</div>
</div>
</div>
<!-- Network Status -->
<div class="network-status">
<div class="status-dot"></div>
<span class="status-text">Hyperledger Besu Network</span>
<span class="status-badge">Live</span>
</div>
</div>
</div>
<!-- Right Side - Login Form -->
<div class="auth-content" id="main-content" role="main">
<div class="auth-card">
<router-outlet></router-outlet>
</div>
<!-- Footer -->
<footer class="auth-footer" role="contentinfo">
<p class="copyright">&copy; 2024 Government of Goa. All rights reserved.</p>
<div class="footer-links">
<a href="#">Privacy Policy</a>
<span class="divider">|</span>
<a href="#">Terms of Service</a>
<span class="divider">|</span>
<a href="#">Help</a>
</div>
</footer>
</div>
</div>
</div>

View File

@@ -0,0 +1,476 @@
// =============================================================================
// AUTH LAYOUT - World-Class Blockchain Login Experience
// DBIM v3.0 Compliant | GIGW 3.0 Accessible
// =============================================================================
// Skip Link (GIGW 3.0)
.skip-link {
position: absolute;
top: -40px;
left: 0;
background: var(--dbim-blue-dark);
color: white;
padding: 8px 16px;
text-decoration: none;
z-index: 1000;
font-size: 14px;
&:focus {
top: 0;
}
}
// =============================================================================
// LAYOUT
// =============================================================================
.auth-layout {
min-height: 100vh;
display: flex;
position: relative;
overflow: hidden;
background: linear-gradient(135deg, #0a0520 0%, #1D0A69 50%, #130640 100%);
}
// =============================================================================
// ANIMATED BACKGROUND
// =============================================================================
.animated-background {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
z-index: 0;
}
// Floating Blockchain Nodes
.node {
position: absolute;
width: 12px;
height: 12px;
background: linear-gradient(135deg, #6366F1 0%, #8B5CF6 100%);
border-radius: 50%;
box-shadow: 0 0 20px rgba(99, 102, 241, 0.5), 0 0 40px rgba(99, 102, 241, 0.3);
animation: float 6s ease-in-out infinite;
&::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 24px;
height: 24px;
border: 1px solid rgba(99, 102, 241, 0.3);
border-radius: 50%;
animation: pulse-ring 2s ease-out infinite;
}
}
.node-1 {
top: 20%;
left: 15%;
animation-delay: 0s;
}
.node-2 {
top: 30%;
left: 45%;
animation-delay: 1s;
width: 16px;
height: 16px;
}
.node-3 {
top: 15%;
right: 20%;
animation-delay: 2s;
}
.node-4 {
bottom: 30%;
right: 25%;
animation-delay: 1.5s;
width: 10px;
height: 10px;
}
.node-5 {
bottom: 25%;
left: 25%;
animation-delay: 0.5s;
}
.node-6 {
top: 50%;
left: 35%;
animation-delay: 2.5s;
width: 8px;
height: 8px;
}
// Connection Lines
.connections {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0.3;
}
.connection {
stroke: rgba(99, 102, 241, 0.4);
stroke-width: 0.1;
stroke-dasharray: 2 2;
animation: dash 20s linear infinite;
}
// Gradient Overlay
.gradient-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background:
radial-gradient(ellipse at 20% 30%, rgba(99, 102, 241, 0.15) 0%, transparent 50%),
radial-gradient(ellipse at 80% 70%, rgba(139, 92, 246, 0.15) 0%, transparent 50%),
radial-gradient(ellipse at 50% 50%, rgba(59, 130, 246, 0.1) 0%, transparent 60%);
}
// =============================================================================
// MAIN CONTAINER
// =============================================================================
.auth-container {
display: flex;
width: 100%;
min-height: 100vh;
position: relative;
z-index: 1;
}
// =============================================================================
// LEFT SIDE - BRANDING
// =============================================================================
.auth-branding {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 48px;
position: relative;
@media (max-width: 1024px) {
display: none;
}
}
.branding-content {
max-width: 480px;
color: white;
animation: fadeInUp 0.6s ease-out;
}
.emblem-wrapper {
width: 80px;
height: 80px;
background: rgba(255, 255, 255, 0.1);
border-radius: 20px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 32px;
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
.goa-emblem {
width: 56px;
height: 56px;
filter: brightness(0) invert(1);
}
}
.brand-title {
margin: 0 0 16px;
.title-line {
display: block;
font-size: 16px;
font-weight: 500;
color: rgba(255, 255, 255, 0.7);
letter-spacing: 0.05em;
text-transform: uppercase;
margin-bottom: 8px;
}
.title-highlight {
display: block;
font-size: 36px;
font-weight: 700;
background: linear-gradient(135deg, #FFFFFF 0%, #A5B4FC 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
line-height: 1.2;
}
}
.brand-tagline {
font-size: 18px;
color: rgba(255, 255, 255, 0.6);
margin: 0 0 48px;
font-weight: 400;
}
// Features Grid
.features-grid {
display: flex;
flex-direction: column;
gap: 20px;
margin-bottom: 48px;
}
.feature-item {
display: flex;
align-items: flex-start;
gap: 16px;
padding: 16px;
background: rgba(255, 255, 255, 0.05);
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
backdrop-filter: blur(5px);
transition: all 0.2s ease;
&:hover {
background: rgba(255, 255, 255, 0.08);
transform: translateX(4px);
}
}
.feature-icon {
width: 44px;
height: 44px;
background: linear-gradient(135deg, rgba(99, 102, 241, 0.2) 0%, rgba(139, 92, 246, 0.2) 100%);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
svg {
width: 24px;
height: 24px;
color: #A5B4FC;
}
}
.feature-text {
display: flex;
flex-direction: column;
gap: 4px;
.feature-title {
font-size: 15px;
font-weight: 600;
color: white;
}
.feature-desc {
font-size: 13px;
color: rgba(255, 255, 255, 0.5);
}
}
// Network Status
.network-status {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: rgba(16, 185, 129, 0.1);
border-radius: 50px;
border: 1px solid rgba(16, 185, 129, 0.2);
width: fit-content;
}
.status-dot {
width: 10px;
height: 10px;
background: #10B981;
border-radius: 50%;
box-shadow: 0 0 8px #10B981;
animation: pulse 2s infinite;
}
.status-text {
font-size: 13px;
color: rgba(255, 255, 255, 0.8);
font-weight: 500;
}
.status-badge {
padding: 2px 8px;
background: rgba(16, 185, 129, 0.2);
border-radius: 4px;
font-size: 11px;
font-weight: 600;
color: #10B981;
text-transform: uppercase;
letter-spacing: 0.05em;
}
// =============================================================================
// RIGHT SIDE - AUTH CONTENT
// =============================================================================
.auth-content {
width: 480px;
min-width: 480px;
display: flex;
flex-direction: column;
justify-content: center;
padding: 48px;
background: white;
position: relative;
@media (max-width: 1024px) {
width: 100%;
min-width: auto;
max-width: 480px;
margin: auto;
border-radius: 24px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
}
@media (max-width: 520px) {
margin: 16px;
width: calc(100% - 32px);
padding: 32px 24px;
border-radius: 20px;
}
}
.auth-card {
animation: fadeIn 0.4s ease-out;
}
// =============================================================================
// FOOTER
// =============================================================================
.auth-footer {
margin-top: auto;
padding-top: 24px;
text-align: center;
.copyright {
font-size: 12px;
color: var(--dbim-grey-2);
margin: 0 0 8px;
}
.footer-links {
display: flex;
justify-content: center;
align-items: center;
gap: 12px;
a {
font-size: 12px;
color: var(--dbim-grey-3);
text-decoration: none;
transition: color 0.2s ease;
&:hover {
color: var(--dbim-blue-dark);
}
}
.divider {
color: var(--dbim-grey-1);
font-size: 10px;
}
}
}
// =============================================================================
// ANIMATIONS
// =============================================================================
@keyframes float {
0%, 100% {
transform: translateY(0) translateX(0);
}
25% {
transform: translateY(-20px) translateX(10px);
}
50% {
transform: translateY(0) translateX(20px);
}
75% {
transform: translateY(20px) translateX(10px);
}
}
@keyframes pulse-ring {
0% {
transform: translate(-50%, -50%) scale(1);
opacity: 1;
}
100% {
transform: translate(-50%, -50%) scale(2);
opacity: 0;
}
}
@keyframes dash {
to {
stroke-dashoffset: 100;
}
}
@keyframes pulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.7;
transform: scale(1.1);
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
// =============================================================================
// RESPONSIVE
// =============================================================================
@media (max-width: 1024px) {
.auth-layout {
align-items: center;
justify-content: center;
}
.auth-container {
min-height: auto;
width: auto;
}
}

View File

@@ -0,0 +1,11 @@
import { Component } from '@angular/core';
import { RouterModule } from '@angular/router';
@Component({
selector: 'app-auth-layout',
standalone: true,
imports: [RouterModule],
templateUrl: './auth-layout.component.html',
styleUrl: './auth-layout.component.scss',
})
export class AuthLayoutComponent {}

View File

@@ -0,0 +1,3 @@
<claude-mem-context>
</claude-mem-context>

View File

@@ -0,0 +1,260 @@
<!-- Skip to main content - GIGW 3.0 Accessibility -->
<a href="#main-content" class="skip-link">Skip to main content</a>
<div class="app-shell">
<!-- Sidebar -->
<aside
class="sidebar"
[class.sidebar-collapsed]="!sidenavOpened()"
role="navigation"
aria-label="Main navigation"
>
<!-- Logo Section -->
<div class="sidebar-header">
<div class="logo-section">
<div class="emblem-container">
<img
src="assets/images/goa-emblem.svg"
alt="Government of Goa Emblem"
class="goa-emblem"
onerror="this.style.display='none'"
/>
<div class="emblem-fallback" *ngIf="!emblemLoaded">
<mat-icon>account_balance</mat-icon>
</div>
</div>
@if (sidenavOpened()) {
<div class="logo-text">
<span class="govt-text">Government of Goa</span>
<span class="platform-text">Blockchain e-Licensing</span>
</div>
}
</div>
</div>
<!-- Navigation Links -->
<nav class="sidebar-nav">
<div class="nav-section">
@if (sidenavOpened()) {
<span class="nav-section-title">Main Menu</span>
}
@for (item of visibleNavItems(); track item.route) {
<a
class="nav-item"
[routerLink]="item.route"
routerLinkActive="active"
[routerLinkActiveOptions]="{ exact: item.route === '/dashboard' }"
[attr.aria-label]="item.label"
[matTooltip]="!sidenavOpened() ? item.label : ''"
matTooltipPosition="right"
>
<div class="nav-icon">
<mat-icon>{{ item.icon }}</mat-icon>
</div>
@if (sidenavOpened()) {
<span class="nav-label">{{ item.label }}</span>
}
@if (item.badge && item.badge() > 0) {
<span class="nav-badge" [class.pulse]="item.badge() > 0">
{{ item.badge() }}
</span>
}
</a>
}
</div>
@if (userType() === 'ADMIN') {
<div class="nav-section">
@if (sidenavOpened()) {
<span class="nav-section-title">Administration</span>
}
<a
class="nav-item"
routerLink="/admin"
routerLinkActive="active"
[matTooltip]="!sidenavOpened() ? 'Admin Portal' : ''"
matTooltipPosition="right"
>
<div class="nav-icon">
<mat-icon>admin_panel_settings</mat-icon>
</div>
@if (sidenavOpened()) {
<span class="nav-label">Admin Portal</span>
}
</a>
</div>
}
</nav>
<!-- Sidebar Footer -->
<div class="sidebar-footer">
@if (sidenavOpened()) {
<div class="blockchain-status">
<div class="status-indicator online"></div>
<div class="status-text">
<span class="status-label">Blockchain</span>
<span class="status-value">Connected</span>
</div>
</div>
} @else {
<div class="blockchain-status-compact">
<div class="status-indicator online"></div>
</div>
}
</div>
</aside>
<!-- Main Content Area -->
<div class="main-wrapper">
<!-- Top Header -->
<header class="top-header" role="banner">
<div class="header-left">
<button
mat-icon-button
(click)="toggleSidenav()"
aria-label="Toggle navigation menu"
class="menu-toggle"
>
<mat-icon>{{ sidenavOpened() ? 'menu_open' : 'menu' }}</mat-icon>
</button>
<!-- Breadcrumb (optional) -->
<nav class="breadcrumb hide-mobile" aria-label="Breadcrumb">
<span class="breadcrumb-item">Dashboard</span>
</nav>
</div>
<div class="header-right">
<!-- Search (optional) -->
<button mat-icon-button class="header-action hide-mobile" aria-label="Search">
<mat-icon>search</mat-icon>
</button>
<!-- Notifications -->
<button
mat-icon-button
class="header-action"
[matMenuTriggerFor]="notificationMenu"
aria-label="Notifications"
>
<mat-icon [matBadge]="unreadNotifications()" matBadgeColor="warn" matBadgeSize="small">
notifications
</mat-icon>
</button>
<mat-menu #notificationMenu="matMenu" class="notification-menu">
<div class="notification-header">
<span class="notification-title">Notifications</span>
<button mat-button color="primary" class="mark-read-btn">Mark all read</button>
</div>
<mat-divider></mat-divider>
<div class="notification-list">
<div class="notification-item unread">
<div class="notification-icon success">
<mat-icon>check_circle</mat-icon>
</div>
<div class="notification-content">
<span class="notification-text">Request #1234 approved</span>
<span class="notification-time">2 minutes ago</span>
</div>
</div>
<div class="notification-item">
<div class="notification-icon info">
<mat-icon>info</mat-icon>
</div>
<div class="notification-content">
<span class="notification-text">New document uploaded</span>
<span class="notification-time">1 hour ago</span>
</div>
</div>
</div>
<mat-divider></mat-divider>
<button mat-menu-item class="view-all-btn">
View all notifications
</button>
</mat-menu>
<!-- User Profile -->
<div class="user-profile">
<button
mat-button
[matMenuTriggerFor]="userMenu"
class="user-button"
aria-label="User menu"
>
<div class="user-avatar" [style.background]="getAvatarColor()">
{{ getUserInitials() }}
</div>
@if (currentUser | async; as user) {
<div class="user-info hide-mobile">
<span class="user-name">{{ user.name }}</span>
<span class="user-role">{{ formatRole(user.type) }}</span>
</div>
}
<mat-icon class="dropdown-arrow hide-mobile">expand_more</mat-icon>
</button>
<mat-menu #userMenu="matMenu" class="user-menu">
@if (currentUser | async; as user) {
<div class="user-menu-header">
<div class="user-avatar-large" [style.background]="getAvatarColor()">
{{ getUserInitials() }}
</div>
<div class="user-details">
<span class="user-name">{{ user.name }}</span>
<span class="user-email">{{ user.email || (user.departmentCode ? user.departmentCode + '@goa.gov.in' : 'user@goa.gov.in') }}</span>
<span class="user-badge">{{ formatRole(user.type) }}</span>
</div>
</div>
<mat-divider></mat-divider>
}
<button mat-menu-item>
<mat-icon>person</mat-icon>
<span>My Profile</span>
</button>
<button mat-menu-item>
<mat-icon>settings</mat-icon>
<span>Settings</span>
</button>
@if (userType() === 'DEPARTMENT') {
<button mat-menu-item routerLink="/department/wallet">
<mat-icon>account_balance_wallet</mat-icon>
<span>My Wallet</span>
</button>
}
<mat-divider></mat-divider>
<button mat-menu-item (click)="logout()" class="logout-btn">
<mat-icon>logout</mat-icon>
<span>Logout</span>
</button>
</mat-menu>
</div>
</div>
</header>
<!-- Main Content -->
<main id="main-content" class="main-content" role="main">
<router-outlet></router-outlet>
</main>
<!-- Footer - DBIM Compliant -->
<footer class="main-footer" role="contentinfo">
<div class="footer-content">
<div class="footer-left">
<span class="footer-text">
This platform belongs to Government of Goa, India
</span>
<span class="footer-divider hide-mobile">|</span>
<span class="footer-text hide-mobile">
Last Updated: {{ lastUpdated }}
</span>
</div>
<div class="footer-right">
<a href="#" class="footer-link">Website Policies</a>
<a href="#" class="footer-link">Help</a>
<a href="#" class="footer-link">Feedback</a>
</div>
</div>
</footer>
</div>
</div>

View File

@@ -0,0 +1,751 @@
// =============================================================================
// MAIN LAYOUT - World-Class Government Blockchain Platform
// DBIM v3.0 Compliant | GIGW 3.0 Accessible
// =============================================================================
// Variables
$sidebar-width: 280px;
$sidebar-collapsed-width: 72px;
$header-height: 64px;
$footer-height: 48px;
$transition-speed: 250ms;
// =============================================================================
// APP SHELL
// =============================================================================
.app-shell {
display: flex;
min-height: 100vh;
background-color: var(--dbim-white);
}
// =============================================================================
// SIDEBAR
// =============================================================================
.sidebar {
position: fixed;
top: 0;
left: 0;
height: 100vh;
width: $sidebar-width;
background: linear-gradient(180deg, var(--dbim-blue-dark) 0%, #130640 100%);
display: flex;
flex-direction: column;
z-index: 100;
transition: width $transition-speed ease;
box-shadow: 4px 0 24px rgba(29, 10, 105, 0.15);
&.sidebar-collapsed {
width: $sidebar-collapsed-width;
.sidebar-header {
padding: 16px 12px;
}
.logo-section {
justify-content: center;
}
.emblem-container {
width: 40px;
height: 40px;
}
.nav-item {
padding: 12px;
justify-content: center;
.nav-icon {
margin-right: 0;
}
}
.nav-section-title {
display: none;
}
}
}
// Sidebar Header
.sidebar-header {
padding: 20px 20px 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.logo-section {
display: flex;
align-items: center;
gap: 12px;
}
.emblem-container {
width: 48px;
height: 48px;
background: rgba(255, 255, 255, 0.1);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all $transition-speed ease;
.goa-emblem {
width: 36px;
height: 36px;
object-fit: contain;
filter: brightness(0) invert(1);
}
.emblem-fallback {
mat-icon {
font-size: 28px;
width: 28px;
height: 28px;
color: white;
}
}
}
.logo-text {
display: flex;
flex-direction: column;
overflow: hidden;
.govt-text {
font-size: 14px;
font-weight: 600;
color: white;
white-space: nowrap;
}
.platform-text {
font-size: 11px;
color: rgba(255, 255, 255, 0.7);
white-space: nowrap;
}
}
// Sidebar Navigation
.sidebar-nav {
flex: 1;
padding: 16px 12px;
overflow-y: auto;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 2px;
}
}
.nav-section {
margin-bottom: 24px;
&:last-child {
margin-bottom: 0;
}
}
.nav-section-title {
display: block;
padding: 8px 16px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: rgba(255, 255, 255, 0.4);
}
.nav-item {
display: flex;
align-items: center;
padding: 12px 16px;
margin-bottom: 4px;
border-radius: 12px;
color: rgba(255, 255, 255, 0.8);
text-decoration: none;
transition: all 150ms ease;
cursor: pointer;
&:hover {
background: rgba(255, 255, 255, 0.08);
color: white;
.nav-icon {
transform: scale(1.05);
}
}
&.active {
background: linear-gradient(135deg, rgba(99, 102, 241, 0.3) 0%, rgba(139, 92, 246, 0.2) 100%);
color: white;
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.25);
.nav-icon {
background: linear-gradient(135deg, var(--crypto-indigo) 0%, var(--crypto-purple) 100%);
mat-icon {
color: white;
}
}
}
.nav-icon {
width: 36px;
height: 36px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.08);
display: flex;
align-items: center;
justify-content: center;
margin-right: 12px;
transition: all 150ms ease;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
color: rgba(255, 255, 255, 0.9);
}
}
.nav-label {
flex: 1;
font-size: 14px;
font-weight: 500;
white-space: nowrap;
}
.nav-badge {
min-width: 20px;
height: 20px;
padding: 0 6px;
border-radius: 10px;
background: var(--dbim-error);
color: white;
font-size: 11px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
&.pulse {
animation: pulse 2s infinite;
}
}
}
// Sidebar Footer
.sidebar-footer {
padding: 16px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.blockchain-status {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: rgba(255, 255, 255, 0.05);
border-radius: 12px;
}
.blockchain-status-compact {
display: flex;
justify-content: center;
padding: 12px;
}
.status-indicator {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
&.online {
background: var(--dbim-success);
box-shadow: 0 0 8px var(--dbim-success);
animation: pulse 2s infinite;
}
&.offline {
background: var(--dbim-error);
}
}
.status-text {
display: flex;
flex-direction: column;
.status-label {
font-size: 11px;
color: rgba(255, 255, 255, 0.5);
}
.status-value {
font-size: 13px;
font-weight: 500;
color: white;
}
}
// =============================================================================
// MAIN WRAPPER
// =============================================================================
.main-wrapper {
flex: 1;
margin-left: $sidebar-width;
display: flex;
flex-direction: column;
min-height: 100vh;
transition: margin-left $transition-speed ease;
.sidebar-collapsed + & {
margin-left: $sidebar-collapsed-width;
}
}
// =============================================================================
// TOP HEADER
// =============================================================================
.top-header {
height: $header-height;
background: var(--dbim-white);
border-bottom: 1px solid rgba(29, 10, 105, 0.08);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
position: sticky;
top: 0;
z-index: 50;
backdrop-filter: blur(8px);
background: rgba(255, 255, 255, 0.95);
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.menu-toggle {
color: var(--dbim-grey-3);
&:hover {
color: var(--dbim-blue-dark);
}
}
.breadcrumb {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: var(--dbim-grey-2);
}
.header-right {
display: flex;
align-items: center;
gap: 8px;
}
.header-action {
color: var(--dbim-grey-2);
&:hover {
color: var(--dbim-blue-dark);
background: rgba(29, 10, 105, 0.05);
}
}
// User Profile
.user-profile {
margin-left: 8px;
}
.user-button {
display: flex;
align-items: center;
gap: 12px;
padding: 6px 12px 6px 6px;
border-radius: 50px;
background: transparent;
&:hover {
background: rgba(29, 10, 105, 0.05);
}
}
.user-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 600;
color: white;
flex-shrink: 0;
}
.user-avatar-large {
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: 600;
color: white;
flex-shrink: 0;
}
.user-info {
display: flex;
flex-direction: column;
align-items: flex-start;
text-align: left;
.user-name {
font-size: 14px;
font-weight: 500;
color: var(--dbim-brown);
line-height: 1.2;
}
.user-role {
font-size: 11px;
color: var(--dbim-grey-2);
}
}
.dropdown-arrow {
font-size: 18px;
color: var(--dbim-grey-2);
}
// User Menu
.user-menu-header {
display: flex;
align-items: center;
gap: 16px;
padding: 16px;
}
.user-details {
display: flex;
flex-direction: column;
.user-name {
font-size: 15px;
font-weight: 600;
color: var(--dbim-brown);
}
.user-email {
font-size: 12px;
color: var(--dbim-grey-2);
margin-top: 2px;
}
.user-badge {
display: inline-block;
padding: 2px 8px;
margin-top: 6px;
background: rgba(29, 10, 105, 0.1);
color: var(--dbim-blue-dark);
font-size: 10px;
font-weight: 600;
border-radius: 4px;
text-transform: uppercase;
letter-spacing: 0.02em;
width: fit-content;
}
}
.logout-btn {
color: var(--dbim-error) !important;
}
// Notification Menu
.notification-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
}
.notification-title {
font-size: 15px;
font-weight: 600;
color: var(--dbim-brown);
}
.mark-read-btn {
font-size: 12px;
}
.notification-list {
max-height: 300px;
overflow-y: auto;
}
.notification-item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px 16px;
cursor: pointer;
transition: background 150ms ease;
&:hover {
background: rgba(29, 10, 105, 0.03);
}
&.unread {
background: rgba(13, 110, 253, 0.05);
}
}
.notification-icon {
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
&.success {
background: rgba(25, 135, 84, 0.1);
color: var(--dbim-success);
}
&.info {
background: rgba(13, 110, 253, 0.1);
color: var(--dbim-info);
}
&.warning {
background: rgba(255, 193, 7, 0.15);
color: #B8860B;
}
&.error {
background: rgba(220, 53, 69, 0.1);
color: var(--dbim-error);
}
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
}
.notification-content {
flex: 1;
display: flex;
flex-direction: column;
.notification-text {
font-size: 13px;
color: var(--dbim-brown);
line-height: 1.4;
}
.notification-time {
font-size: 11px;
color: var(--dbim-grey-2);
margin-top: 4px;
}
}
.view-all-btn {
text-align: center;
font-size: 13px;
color: var(--dbim-info) !important;
}
// =============================================================================
// MAIN CONTENT
// =============================================================================
.main-content {
flex: 1;
padding: 24px;
background: #f8f9fc;
min-height: calc(100vh - #{$header-height} - #{$footer-height});
}
// =============================================================================
// FOOTER - DBIM Compliant
// =============================================================================
.main-footer {
height: $footer-height;
background: var(--dbim-blue-dark);
color: white;
display: flex;
align-items: center;
}
.footer-content {
width: 100%;
padding: 0 24px;
display: flex;
align-items: center;
justify-content: space-between;
}
.footer-left {
display: flex;
align-items: center;
gap: 8px;
}
.footer-text {
font-size: 12px;
color: rgba(255, 255, 255, 0.8);
}
.footer-divider {
color: rgba(255, 255, 255, 0.3);
}
.footer-right {
display: flex;
align-items: center;
gap: 16px;
}
.footer-link {
font-size: 12px;
color: rgba(255, 255, 255, 0.8);
text-decoration: none;
transition: color 150ms ease;
&:hover {
color: white;
}
}
// =============================================================================
// ANIMATIONS
// =============================================================================
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
}
// =============================================================================
// RESPONSIVE
// =============================================================================
@media (max-width: 1024px) {
.sidebar {
width: $sidebar-collapsed-width;
.sidebar-header {
padding: 16px 12px;
}
.logo-section {
justify-content: center;
}
.emblem-container {
width: 40px;
height: 40px;
}
.logo-text,
.nav-label,
.nav-section-title {
display: none;
}
.nav-item {
padding: 12px;
justify-content: center;
.nav-icon {
margin-right: 0;
}
}
.blockchain-status {
padding: 12px;
justify-content: center;
.status-text {
display: none;
}
}
}
.main-wrapper {
margin-left: $sidebar-collapsed-width;
}
}
@media (max-width: 768px) {
.sidebar {
transform: translateX(-100%);
width: $sidebar-width;
&:not(.sidebar-collapsed) {
transform: translateX(0);
}
.logo-text,
.nav-label,
.nav-section-title {
display: block;
}
.nav-item {
padding: 12px 16px;
justify-content: flex-start;
.nav-icon {
margin-right: 12px;
}
}
}
.main-wrapper {
margin-left: 0;
}
.top-header {
padding: 0 16px;
}
.main-content {
padding: 16px;
}
.footer-content {
flex-direction: column;
gap: 8px;
padding: 8px 16px;
text-align: center;
}
.footer-right {
gap: 12px;
}
}

View File

@@ -0,0 +1,124 @@
import { Component, inject, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule, Router } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { MatDividerModule } from '@angular/material/divider';
import { MatBadgeModule } from '@angular/material/badge';
import { MatTooltipModule } from '@angular/material/tooltip';
import { AuthService } from '../../core/services/auth.service';
interface NavItem {
label: string;
icon: string;
route: string;
roles?: ('APPLICANT' | 'DEPARTMENT' | 'ADMIN')[];
badge?: () => number;
}
@Component({
selector: 'app-main-layout',
standalone: true,
imports: [
CommonModule,
RouterModule,
MatButtonModule,
MatIconModule,
MatMenuModule,
MatDividerModule,
MatBadgeModule,
MatTooltipModule,
],
templateUrl: './main-layout.component.html',
styleUrl: './main-layout.component.scss',
})
export class MainLayoutComponent {
private readonly authService = inject(AuthService);
private readonly router = inject(Router);
readonly sidenavOpened = signal(true);
readonly currentUser = this.authService.currentUser$;
readonly userType = this.authService.userType;
readonly emblemLoaded = signal(true);
readonly unreadNotifications = signal(3);
readonly lastUpdated = new Date().toLocaleDateString('en-GB', {
day: '2-digit',
month: 'short',
year: 'numeric',
});
// Pending approvals count for department badge
private readonly pendingCount = signal(5);
readonly navItems: NavItem[] = [
{ label: 'Dashboard', icon: 'dashboard', route: '/dashboard' },
{ label: 'My Requests', icon: 'description', route: '/requests', roles: ['APPLICANT'] },
{
label: 'Pending Approvals',
icon: 'pending_actions',
route: '/approvals',
roles: ['DEPARTMENT'],
badge: () => this.pendingCount(),
},
{ label: 'All Requests', icon: 'list_alt', route: '/requests', roles: ['DEPARTMENT', 'ADMIN'] },
{ label: 'Departments', icon: 'business', route: '/departments', roles: ['ADMIN'] },
{ label: 'Workflows', icon: 'account_tree', route: '/workflows', roles: ['ADMIN'] },
{ label: 'Webhooks', icon: 'webhook', route: '/webhooks', roles: ['DEPARTMENT', 'ADMIN'] },
{ label: 'Audit Logs', icon: 'history', route: '/audit', roles: ['ADMIN'] },
];
readonly visibleNavItems = computed(() => {
const type = this.userType();
return this.navItems.filter((item) => {
if (!item.roles) return true;
return type && item.roles.includes(type);
});
});
toggleSidenav(): void {
this.sidenavOpened.update((v) => !v);
}
logout(): void {
this.authService.logout();
}
getUserInitials(): string {
const user = this.authService.getCurrentUser();
if (!user?.name) return '?';
const parts = user.name.split(' ');
return parts
.map((p) => p[0])
.join('')
.toUpperCase()
.slice(0, 2);
}
getAvatarColor(): string {
const type = this.userType();
switch (type) {
case 'ADMIN':
return 'linear-gradient(135deg, #8B5CF6 0%, #6366F1 100%)';
case 'DEPARTMENT':
return 'linear-gradient(135deg, #1D0A69 0%, #2563EB 100%)';
case 'APPLICANT':
return 'linear-gradient(135deg, #10B981 0%, #06B6D4 100%)';
default:
return 'linear-gradient(135deg, #6B7280 0%, #9CA3AF 100%)';
}
}
formatRole(role: string): string {
switch (role) {
case 'ADMIN':
return 'Administrator';
case 'DEPARTMENT':
return 'Department Officer';
case 'APPLICANT':
return 'Citizen';
default:
return role;
}
}
}