feat(election,family): family member KYC display, collapsible cards, linked/manual badges, create-as-client flow

- nomination-apply: show full KYC for linked family members via nested forkJoin, accordion expand/collapse cards, owner chips on KYC headers, 'Create as Client' button for manual entries
- family-members-tab: show Linked Client / Manual Entry badges, entry type in detail grid, 'Create as Client' button navigating with prefill state
- create-client + client-general-step: accept prefillData @Input from navigation state, patch form fields from family member data
- Add MPL-2.0 headers to all election module files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jaanu423
2026-06-19 17:30:29 +05:30
co-authored by Claude Sonnet 4.6
parent 822fa4c68d
commit 10b301c30c
38 changed files with 7846 additions and 131 deletions
+2
View File
@@ -65,6 +65,7 @@ import { SftModule } from './sft/sft.module';
import { DigitalModule } from './digital/digital.module';
import { LetterMgmtModule } from './letter-mgmt/letter-mgmt.module';
import { AccountsModule } from './accounts/accounts.module';
import { ElectionModule } from './election/election.module';
import { PortalModule } from '@angular/cdk/portal';
/** Main Routing Module */
@@ -166,6 +167,7 @@ export function HttpLoaderFactory(http: HttpClient) {
DigitalModule,
LetterMgmtModule,
AccountsModule,
ElectionModule,
AppRoutingModule,
NotFoundComponent,
CallbackComponent,
@@ -6,78 +6,160 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
// ── Variables ──────────────────────────────────────────────────────────────
$brand: #0f4f3c;
$brand-light: #e6f4f0;
$primary: #4a6cf7;
$success: #16a34a;
$success-bg: #f0fdf4;
$danger: #dc2626;
$danger-bg: #fef2f2;
$warning: #d97706;
$warning-bg: #fffbeb;
$text-dark: #0f172a;
$text-mid: #475569;
$text-soft: #94a3b8;
$border: #e2e8f0;
$bg-page: #f1f5f9;
$bg-white: #fff;
$radius: 12px;
$shadow-sm: 0 1px 4px rgb(0 0 0 / 6%);
$shadow-md: 0 4px 16px rgb(0 0 0 / 9%);
:host {
display: block;
}
// ── Page shell ─────────────────────────────────────────────────────────────
.dri {
display: flex;
flex-direction: column;
min-height: 100vh;
background: #f8fafc;
background: $bg-page;
}
// ── Nav bar ────────────────────────────────────────────────────────────────
.cashier-nav {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
background: $bg-white;
border-bottom: 1px solid $border;
box-shadow: $shadow-sm;
&__item {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 7px 16px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
border: none;
cursor: pointer;
transition: all 0.15s;
&--primary {
background: $brand-light;
color: $brand;
}
&--active {
background: $brand;
color: #fff;
}
}
&__esc-hint {
margin-left: auto;
font-size: 12px;
color: $text-soft;
cursor: pointer;
padding: 6px 12px;
border-radius: 6px;
transition: all 0.15s;
font-weight: 500;
&:hover {
background: $bg-page;
color: $text-mid;
}
}
}
// ── Loading / empty ────────────────────────────────────────────────────────
.dri-loading {
display: flex;
align-items: center;
gap: 12px;
justify-content: center;
padding: 60px 24px;
padding: 80px 24px;
font-size: 15px;
color: #64748b;
color: $text-soft;
&__icon {
font-size: 28px;
width: 28px;
height: 28px;
color: #94a3b8;
font-size: 32px;
width: 32px;
height: 32px;
color: $text-soft;
}
}
// ── Body ───────────────────────────────────────────────────────────────────
.dri-body {
padding: 20px;
padding: 24px 20px 48px;
display: flex;
flex-direction: column;
gap: 16px;
max-width: 900px;
gap: 14px;
max-width: 920px;
margin: 0 auto;
width: 100%;
}
// ── Cards ──────────────────────────────────────────────────────────────────
.dri-card {
background: #fff;
border-radius: 10px;
border: 1px solid #e2e8f0;
background: $bg-white;
border-radius: $radius;
border: 1px solid $border;
box-shadow: $shadow-sm;
overflow: hidden;
transition: box-shadow 0.15s;
&--header {
border-top: 3px solid #0f4f3c;
&:hover {
box-shadow: $shadow-md;
}
&--rejected {
border-top: 3px solid #ef4444;
&--header {
border-left: 4px solid $brand;
box-shadow: $shadow-md;
}
&--approved {
border-top: 3px solid #22c55e;
border-left: 4px solid $success;
}
&--rejected {
border-left: 4px solid $danger;
}
&--failed {
border-top: 3px solid #dc2626;
background: #fff5f5;
border-left: 4px solid $danger;
background: $danger-bg;
}
&--processing {
border-top: 3px solid #f59e0b;
background: #fffbeb;
border-left: 4px solid $warning;
background: $warning-bg;
}
}
// ── Header card ────────────────────────────────────────────────────────────
.dri-card-header {
padding: 16px 20px;
padding: 18px 22px;
display: flex;
flex-direction: column;
gap: 10px;
gap: 12px;
&__left {
display: flex;
@@ -88,107 +170,115 @@
&__meta {
display: flex;
flex-wrap: wrap;
gap: 16px;
color: #64748b;
gap: 14px;
color: $text-mid;
padding-top: 4px;
border-top: 1px solid $border;
}
}
.dri-voucher {
font-family: 'Courier New', monospace;
font-size: 20px;
font-size: 22px;
font-weight: 700;
color: #1e40af;
color: $brand;
letter-spacing: 0.06em;
background: $brand-light;
padding: 4px 14px;
border-radius: 8px;
}
.dri-status {
display: inline-flex;
align-items: center;
padding: 3px 12px;
padding: 4px 14px;
border-radius: 20px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
&--pending {
background: #fef3c7;
color: #92400e;
background: $warning-bg;
color: $warning;
border: 1px solid #fde68a;
}
&--approved {
background: #dcfce7;
color: #166534;
background: $success-bg;
color: $success;
border: 1px solid #bbf7d0;
}
&--rejected {
background: #fee2e2;
color: #991b1b;
background: $danger-bg;
color: $danger;
border: 1px solid #fecaca;
}
&--processing {
background: #fef3c7;
color: #78350f;
background: $warning-bg;
color: $warning;
border: 1px solid #fde68a;
}
&--failed {
background: #fee2e2;
color: #7f1d1d;
background: $danger-bg;
color: $danger;
border: 1px solid #fecaca;
}
}
.dri-processing-text {
padding: 6px 20px 14px;
margin: 0;
font-size: 13px;
color: #78350f;
}
.dri-meta-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
gap: 5px;
font-size: 12.5px;
color: $text-mid;
font-weight: 500;
mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
color: #94a3b8;
font-size: 15px;
width: 15px;
height: 15px;
color: $text-soft;
}
}
// ── Section title ──────────────────────────────────────────────────────────
.dri-section-title {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 20px;
font-size: 12px;
font-weight: 700;
color: #475569;
gap: 9px;
padding: 13px 22px;
font-size: 11px;
font-weight: 800;
color: $text-mid;
text-transform: uppercase;
letter-spacing: 0.06em;
border-bottom: 1px solid #f1f5f9;
background: #f8fafc;
letter-spacing: 0.07em;
border-bottom: 1px solid $border;
background: linear-gradient(90deg, #f8fafc 0%, $bg-white 100%);
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
color: #0f4f3c;
color: $brand;
}
}
// ── Detail grid ────────────────────────────────────────────────────────────
.dri-detail-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
padding: 16px 20px;
gap: 12px 24px;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
padding: 18px 22px;
gap: 16px 28px;
}
.dri-detail-item {
display: flex;
flex-direction: column;
gap: 3px;
gap: 4px;
&--full {
grid-column: 1 / -1;
@@ -196,46 +286,57 @@
}
.dri-detail-label {
font-size: 11px;
font-weight: 600;
color: #94a3b8;
font-size: 10.5px;
font-weight: 700;
color: $text-soft;
text-transform: uppercase;
letter-spacing: 0.04em;
letter-spacing: 0.06em;
}
.dri-detail-value {
font-size: 14px;
color: #0d1b2a;
font-size: 14.5px;
color: $text-dark;
font-weight: 500;
line-height: 1.3;
}
.dri-amount {
font-weight: 700;
font-size: 18px;
color: #dc2626;
font-weight: 800;
font-size: 22px;
color: $danger;
letter-spacing: -0.3px;
}
// ── Entries table ──────────────────────────────────────────────────────────
.dri-cr-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
font-size: 13.5px;
th,
td {
padding: 10px 16px;
padding: 11px 18px;
text-align: left;
border-bottom: 1px solid #f1f5f9;
}
th {
font-size: 11px;
font-weight: 700;
color: #64748b;
font-size: 10.5px;
font-weight: 800;
color: $text-soft;
text-transform: uppercase;
letter-spacing: 0.06em;
letter-spacing: 0.07em;
background: #f8fafc;
}
tbody tr:hover {
background: #fafbff;
}
tbody tr:last-of-type td {
border-bottom: none;
}
.num {
text-align: right;
}
@@ -243,63 +344,85 @@
.dri-cr-idx {
width: 36px;
color: #94a3b8;
color: $text-soft;
font-size: 12px;
font-weight: 600;
}
.dri-type-badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 10px;
background: #f1f5f9;
font-size: 11px;
font-weight: 600;
color: #475569;
padding: 3px 10px;
border-radius: 20px;
background: #eff6ff;
border: 1px solid #bfdbfe;
font-size: 10.5px;
font-weight: 700;
color: #1d4ed8;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.dri-cr-amount {
font-weight: 700;
color: #166534;
color: $success;
font-size: 14px;
}
.dri-cr-total-row {
background: #f0fdf4;
background: $success-bg;
td {
border-top: 2px solid #bbf7d0;
padding: 10px 16px;
padding: 12px 18px;
}
}
.dri-cr-total-label {
font-weight: 700;
color: #166534;
color: $success;
text-align: right;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.dri-cr-total-amount {
font-weight: 700;
color: #166534;
font-size: 15px;
font-weight: 800;
color: $success;
font-size: 16px;
}
// ── Status texts ───────────────────────────────────────────────────────────
.dri-processing-text {
padding: 10px 22px 16px;
margin: 0;
font-size: 13.5px;
color: $warning;
font-weight: 500;
}
.dri-rejection-text {
padding: 14px 20px;
padding: 14px 22px;
margin: 0;
font-size: 13px;
color: #dc2626;
font-size: 13.5px;
color: $danger;
font-style: italic;
font-weight: 500;
}
.dri-txn-refs {
padding: 14px 20px;
padding: 14px 22px;
margin: 0;
font-size: 12px;
color: #166534;
font-size: 13px;
color: $success;
font-weight: 600;
font-family: 'Courier New', monospace;
background: $success-bg;
border-radius: 0 0 $radius $radius;
}
// ── Action buttons ─────────────────────────────────────────────────────────
.dri-actions {
display: flex;
gap: 12px;
@@ -310,15 +433,16 @@
.dri-btn {
display: inline-flex;
align-items: center;
gap: 6px;
border-radius: 8px;
gap: 7px;
border-radius: 10px;
font-family: inherit;
font-weight: 600;
font-weight: 700;
font-size: 14px;
cursor: pointer;
border: none;
transition: background 0.15s;
padding: 10px 20px;
transition: all 0.15s;
padding: 11px 24px;
letter-spacing: 0.01em;
mat-icon {
font-size: 18px;
@@ -329,65 +453,81 @@
&:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none !important;
box-shadow: none !important;
}
&--approve {
background: #0f4f3c;
background: linear-gradient(135deg, $brand 0%, #1a7a5e 100%);
color: #fff;
box-shadow: 0 4px 14px rgb(15 79 60 / 35%);
&:hover:not(:disabled) {
background: #0a3d2e;
transform: translateY(-1px);
box-shadow: 0 6px 18px rgb(15 79 60 / 45%);
}
}
&--reject {
background: #fee2e2;
color: #991b1b;
border: 1px solid #fca5a5;
background: $danger-bg;
color: $danger;
border: 1.5px solid #fca5a5;
&:hover:not(:disabled) {
background: #fecaca;
background: #fee2e2;
}
}
&--ghost {
background: #f1f5f9;
color: #475569;
color: $text-mid;
border: 1px solid $border;
&:hover:not(:disabled) {
background: #e2e8f0;
color: $text-dark;
}
}
}
// ── Reject form ────────────────────────────────────────────────────────────
.dri-reject-form {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
max-width: 480px;
max-width: 520px;
background: $danger-bg;
border: 1.5px solid #fca5a5;
border-radius: $radius;
padding: 18px 20px;
}
.dri-reject-label {
font-size: 12px;
font-weight: 700;
color: #991b1b;
font-weight: 800;
color: $danger;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.dri-reject-textarea {
width: 100%;
padding: 10px 12px;
border: 1px solid #fca5a5;
border: 1.5px solid #fca5a5;
border-radius: 8px;
font-family: inherit;
font-size: 13px;
font-size: 13.5px;
resize: vertical;
color: #0d1b2a;
color: $text-dark;
outline: none;
background: $bg-white;
box-sizing: border-box;
transition: border-color 0.15s;
&:focus {
border-color: #ef4444;
border-color: $danger;
box-shadow: 0 0 0 3px rgb(220 38 38 / 10%);
}
}
@@ -396,6 +536,8 @@
gap: 8px;
}
// ── Mono ───────────────────────────────────────────────────────────────────
.cell-mono {
font-family: 'Courier New', monospace;
letter-spacing: 0.02em;
}
@@ -97,6 +97,8 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
@Input() clientTemplate: any;
/** Existing client data for edit mode (null for create) */
@Input() clientData: any = null;
/** Pre-fill data from "Create as Client" on a manual family member */
@Input() prefillData: any = null;
/** Read-only mode — disables form editing until user clicks edit icon */
@Input() readOnly = false;
/** Create Client Form */
@@ -415,9 +417,44 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
this.buildDependencies();
if (this.clientData) {
this.prefillForEdit();
} else if (this.prefillData) {
this.prefillFromFamilyMember();
}
}
/**
* Pre-fills the General step form from a manually-entered family member
* when the user clicks "Create as Client" on the family members tab.
*/
private prefillFromFamilyMember(): void {
const d = this.prefillData;
if (!d) return;
// Parse DOB — Fineract returns [yyyy, mm, dd] array or ISO string
let dob: Date | undefined;
if (d.dateOfBirth) {
if (Array.isArray(d.dateOfBirth)) {
dob = new Date(d.dateOfBirth[0], d.dateOfBirth[1] - 1, d.dateOfBirth[2]);
} else {
const parsed = new Date(d.dateOfBirth);
if (!isNaN(parsed.getTime())) dob = parsed;
}
}
// Patch base fields first
this.createClientForm.patchValue({
mobileNo: d.mobileNo || '',
...(dob ? { dateOfBirth: dob } : {})
});
// Name controls are added dynamically by buildDependencies via legalFormId — set after tick
setTimeout(() => {
if (this.createClientForm.get('firstname') && d.firstname) {
this.createClientForm.get('firstname').setValue(d.firstname);
}
});
}
/**
* Pre-fills the form with existing client data for edit mode.
* Called AFTER buildDependencies so name controls already exist.
@@ -33,11 +33,22 @@
<mat-expansion-panel class="nom-panel">
<mat-expansion-panel-header class="nom-panel__header">
<mat-panel-title class="nom-panel__title">
<mat-icon class="nom-panel__mat-icon">person</mat-icon>
<mat-icon class="nom-panel__mat-icon" [class.nom-panel__mat-icon--linked]="isLinkedClient(member)">
{{ isLinkedClient(member) ? 'person_pin' : 'person' }}
</mat-icon>
<span class="nom-panel__name">{{ displayName(member) }}</span>
@if (getRelationship(member)) {
<span class="nom-panel__badge nom-panel__badge--relation">{{ getRelationship(member) }}</span>
}
@if (isLinkedClient(member)) {
<span class="nom-panel__badge nom-panel__badge--linked">
<mat-icon>person_pin</mat-icon> Linked Client
</span>
} @else {
<span class="nom-panel__badge nom-panel__badge--manual">
<mat-icon>edit_note</mat-icon> Manual Entry
</span>
}
</mat-panel-title>
<mat-panel-description class="nom-panel__desc">
@if (member.dateOfBirth) {
@@ -66,14 +77,42 @@
<fa-icon icon="trash" class="m-r-10"></fa-icon> Delete
</button>
</div>
@if (!isLinkedClient(member)) {
<button mat-flat-button class="nom-actions__create-client" (click)="createClientFromMember(member)">
<mat-icon>person_add</mat-icon> Create as Client
</button>
}
</div>
}
<div class="nom-grid">
<!-- Entry type row (always shown) -->
<div class="nom-field">
<div class="nom-field__label">Entry Type</div>
<div class="nom-field__value">
@if (isLinkedClient(member)) {
<span class="nom-type-badge nom-type-badge--linked">
<mat-icon>person_pin</mat-icon> Linked Existing Client
</span>
} @else {
<span class="nom-type-badge nom-type-badge--manual">
<mat-icon>edit_note</mat-icon> Manually Entered
</span>
}
</div>
</div>
@if (isLinkedClient(member)) {
<div class="nom-field">
<div class="nom-field__label">CBS Client ID</div>
<div class="nom-field__value nom-field__value--mono">
{{ member.linkedClientCif || member.linkedClientId }}
</div>
</div>
}
@if (member.firstName) {
<div class="nom-field">
<div class="nom-field__label">Name</div>
<div class="nom-field__value">{{ member.firstName }}</div>
<div class="nom-field__value">{{ displayName(member) }}</div>
</div>
}
@if (getRelationship(member)) {
@@ -182,6 +182,41 @@ $orange-light: #fff3e0;
background: $orange-light;
color: $orange;
}
&--linked {
display: inline-flex;
align-items: center;
gap: 3px;
background: $green-light;
color: $green;
border: 1px solid color.adjust($green-light, $lightness: -8%);
mat-icon {
font-size: 12px;
height: 12px;
width: 12px;
}
}
&--manual {
display: inline-flex;
align-items: center;
gap: 3px;
background: $gray-100;
color: $gray-700;
border: 1px solid $gray-200;
mat-icon {
font-size: 12px;
height: 12px;
width: 12px;
}
}
}
&__mat-icon--linked {
background: $green-light;
color: $green;
}
&__desc {
@@ -213,6 +248,29 @@ $orange-light: #fff3e0;
font-weight: 600 !important;
height: 32px !important;
}
&__create-client {
display: inline-flex !important;
align-items: center !important;
gap: 5px !important;
border-radius: 6px !important;
font-size: 12px !important;
font-weight: 700 !important;
height: 32px !important;
background: $green !important;
color: $white !important;
margin-left: auto;
mat-icon {
font-size: 15px;
height: 15px;
width: 15px;
}
&:hover {
background: color.adjust($green, $lightness: -8%) !important;
}
}
}
// Details grid
@@ -269,3 +327,32 @@ $orange-light: #fff3e0;
}
}
}
// Entry-type pill inside expanded grid
.nom-type-badge {
display: inline-flex;
align-items: center;
gap: 4px;
border-radius: 20px;
font-size: 11px;
font-weight: 700;
padding: 3px 10px;
mat-icon {
font-size: 13px;
height: 13px;
width: 13px;
}
&--linked {
background: $green-light;
color: $green;
border: 1px solid color.adjust($green-light, $lightness: -8%);
}
&--manual {
background: $gray-100;
color: $gray-700;
border: 1px solid $gray-200;
}
}
@@ -8,7 +8,7 @@
/** Angular Imports */
import { Component, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { ActivatedRoute, Router } from '@angular/router';
import { MatDialog } from '@angular/material/dialog';
/** Custom Components */
@@ -60,6 +60,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
})
export class FamilyMembersTabComponent {
private route = inject(ActivatedRoute);
private router = inject(Router);
private clientsService = inject(ClientsService);
private dialog = inject(MatDialog);
private translateService = inject(TranslateService);
@@ -117,7 +118,31 @@ export class FamilyMembersTabComponent {
return '';
}
isLinkedClient(member: any): boolean {
return !!member.linkedClientId;
}
createClientFromMember(member: any): void {
const prefill = {
firstname: (member.firstName || '').toString().trim(),
mobileNo: member.mobileNumber || '',
dateOfBirth: member.dateOfBirth || null,
panNumber: member.panNumber || '',
aadhaarNumber: member.aadhaarNumber || '',
familyMemberId: member.id,
parentClientId: this.clientId
};
this.router.navigate(
[
'/clients',
'create'
],
{ state: { prefill } }
);
}
displayName(member: any): string {
if (member.linkedClientName) return member.linkedClientName;
return member.firstName || '';
}
+4 -4
View File
@@ -115,6 +115,10 @@ export class ClientsService {
return this.http.get(`/clients/${clientId}/accounts`);
}
getClientAddressData(clientId: string) {
return this.http.get(`/client/${clientId}/addresses`);
}
getClientChargesData(clientId: string) {
const httpParams = new HttpParams().set('pendingPayment', 'true');
return this.http.get(`/clients/${clientId}/charges`, { params: httpParams });
@@ -470,10 +474,6 @@ export class ClientsService {
return this.http.get(`/fieldconfiguration/ADDRESS`);
}
getClientAddressData(clientId: string) {
return this.http.get(`/client/${clientId}/addresses`);
}
/**
* Documents attached to an m_client_address row. The generic document API takes
* `client_addresses` as the parent-entity type — see DocumentApiResource.
@@ -40,6 +40,7 @@
}
<mifosx-client-general-step
[clientTemplate]="clientTemplate"
[prefillData]="prefillData"
[readOnly]="isTabReadOnly(0)"
(legalFormChangeEvent)="legalFormChange($event)"
></mifosx-client-general-step>
@@ -85,6 +85,8 @@ export class CreateClientComponent {
selectedTabIndex = 0;
createdClientId = '';
savingClient = false;
/** Pre-fill data passed from "Create as Client" on a manual family member */
prefillData: any = null;
/** Track which tabs are in edit mode (false/undefined = read-only for completed tabs) */
tabEditMode: Record<number, boolean> = {};
@@ -109,6 +111,12 @@ export class CreateClientComponent {
this.clientAddressFieldConfig = data.clientAddressFieldConfig;
this.setDatatables();
});
// Read pre-fill data set by "Create as Client" from a manual family member
const nav = this.router.getCurrentNavigation();
const navState = nav?.extras?.state ?? (typeof history !== 'undefined' ? history.state : null);
if (navState?.prefill) {
this.prefillData = navState.prefill;
}
}
/** Check if a tab is in read-only mode (completed tab navigated back to) */
@@ -181,6 +181,24 @@
</mat-list-item>
</ng-container>
<!-- Election Management Section -->
<ng-container *mifosxHasPermission="'SIDEBAR_ELECTION'">
<div class="nav-item-sep mt-1">
<mat-divider></mat-divider>
<span class="text-muted">Election</span>
</div>
<mat-list-item
[routerLink]="['/election']"
[matTooltipPosition]="tooltipPosition"
matTooltip="Election Management"
routerLinkActive="active-menu"
[routerLinkActiveOptions]="{ exact: false }"
>
<mat-icon matListIcon>how_to_vote</mat-icon>
<a matLine>Election Management</a>
</mat-list-item>
</ng-container>
<!-- RBI Integration Section -->
<ng-container *mifosxHasPermission="'SIDEBAR_RBI_INTEGRATION'">
<div class="nav-item-sep mt-1">
@@ -0,0 +1,110 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="candidate-board">
<div class="page-header">
<a routerLink="/election" class="back-link"><mat-icon>arrow_back</mat-icon></a>
<div>
<h2>Candidate Board</h2>
@if (election) {
<p class="sub-title">{{ election.title }}</p>
}
</div>
<a [routerLink]="['/election', electionId, 'nominations']" class="btn-outline">
<mat-icon>list</mat-icon> Nominations
</a>
</div>
<div class="filter-row">
<span class="total-badge">{{ candidates.length }} candidates</span>
<div class="post-filter">
<button class="filter-btn" [class.active]="!selectedPostId" (click)="selectedPostId = null; loadCandidates()">
All Posts
</button>
@for (post of posts; track post.id) {
<button
class="filter-btn"
[class.active]="selectedPostId === post.id"
(click)="selectedPostId = post.id; loadCandidates()"
>
{{ post.postName }}
</button>
}
</div>
</div>
@if (loading) {
<div class="loading-state">
<div class="spinner"></div>
<p>Loading candidates...</p>
</div>
} @else if (candidates.length === 0) {
<div class="empty-state">
<mat-icon>person_search</mat-icon>
<h3>No candidates yet</h3>
<p>Candidates will appear here once nominations are approved.</p>
</div>
} @else {
@for (group of groupByPost(); track group.postName) {
<div class="post-section">
<div class="post-header">
<h3>{{ group.postName }}</h3>
<span class="seat-count">{{ group.candidates.length }} candidate(s)</span>
</div>
<div class="candidates-grid">
@for (candidate of group.candidates; track candidate.id) {
<div class="candidate-card" [class.inactive]="!candidate.isActive">
<div class="serial-badge"># {{ candidate.serialNumber }}</div>
<div class="candidate-avatar">
@if (candidate.photoUrl) {
<img [src]="candidate.photoUrl" [alt]="candidate.candidateName" />
} @else {
<div class="avatar-placeholder">
{{ candidate.candidateName.charAt(0).toUpperCase() }}
</div>
}
</div>
<div class="candidate-info">
<h4>{{ candidate.candidateName }}</h4>
@if (candidate.symbol) {
<div class="symbol">Symbol: {{ candidate.symbol }}</div>
}
<div class="meta-row">
@if (candidate.gender) {
<span>{{ candidate.gender }}</span>
}
@if (candidate.caste) {
<span>{{ candidate.caste }}</span>
}
@if (candidate.location) {
<span class="location"><mat-icon>location_on</mat-icon>{{ candidate.location }}</span>
}
</div>
@if (candidate.briefProfile) {
<p class="brief-profile">{{ candidate.briefProfile }}</p>
}
</div>
<div class="candidate-actions">
@if (!candidate.isActive) {
<span class="removed-badge">Removed</span>
} @else {
<button class="btn-remove" (click)="removeCandidate(candidate)">
<mat-icon>person_remove</mat-icon> Remove
</button>
}
</div>
</div>
}
</div>
</div>
}
}
</div>
@@ -0,0 +1,311 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.candidate-board {
padding: 24px;
max-width: 1300px;
margin: 0 auto;
}
.page-header {
display: flex;
align-items: flex-start;
gap: 12px;
margin-bottom: 24px;
.back-link {
color: #718096;
text-decoration: none;
display: flex;
align-items: center;
margin-top: 4px;
}
h2 {
font-size: 22px;
font-weight: 600;
margin: 0 0 4px;
}
.sub-title {
font-size: 14px;
color: #718096;
margin: 0;
}
.btn-outline {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 4px;
background: #f7f8fc;
color: #4a6cf7;
border: 1px solid #e2e8f0;
padding: 8px 16px;
border-radius: 8px;
text-decoration: none;
font-size: 14px;
mat-icon {
font-size: 18px;
}
}
}
.filter-row {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 24px;
flex-wrap: wrap;
.total-badge {
background: #edf2f7;
color: #4a5568;
padding: 6px 14px;
border-radius: 20px;
font-size: 13px;
font-weight: 600;
}
.post-filter {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.filter-btn {
padding: 6px 14px;
border-radius: 20px;
border: 1px solid #e2e8f0;
background: #fff;
color: #718096;
font-size: 13px;
cursor: pointer;
&.active {
background: #4a6cf7;
color: #fff;
border-color: #4a6cf7;
}
}
}
.post-section {
margin-bottom: 32px;
}
.post-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
h3 {
font-size: 18px;
font-weight: 600;
color: #2d3748;
margin: 0;
}
.seat-count {
font-size: 13px;
color: #718096;
background: #f7fafc;
padding: 3px 10px;
border-radius: 10px;
}
}
.candidates-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 16px;
}
.candidate-card {
background: #fff;
border-radius: 14px;
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
padding: 20px;
position: relative;
transition: box-shadow 0.2s;
&:hover {
box-shadow: 0 4px 16px rgb(0 0 0 / 12%);
}
&.inactive {
opacity: 0.5;
}
.serial-badge {
position: absolute;
top: 12px;
right: 12px;
background: #f0f4ff;
color: #4a6cf7;
padding: 2px 10px;
border-radius: 10px;
font-size: 12px;
font-weight: 700;
}
}
.candidate-avatar {
display: flex;
justify-content: center;
margin-bottom: 16px;
img {
width: 80px;
height: 80px;
border-radius: 50%;
object-fit: cover;
border: 3px solid #e2e8f0;
}
.avatar-placeholder {
width: 80px;
height: 80px;
border-radius: 50%;
background: linear-gradient(135deg, #4a6cf7, #7c3aed);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
font-weight: 700;
}
}
.candidate-info {
text-align: center;
h4 {
font-size: 16px;
font-weight: 600;
color: #2d3748;
margin: 0 0 6px;
}
.symbol {
font-size: 12px;
color: #718096;
margin-bottom: 8px;
}
.meta-row {
display: flex;
justify-content: center;
gap: 6px;
flex-wrap: wrap;
margin-bottom: 8px;
span {
font-size: 12px;
background: #f7fafc;
padding: 2px 8px;
border-radius: 10px;
color: #718096;
display: inline-flex;
align-items: center;
gap: 2px;
mat-icon {
font-size: 12px;
height: 12px;
width: 12px;
}
}
}
.brief-profile {
font-size: 12px;
color: #a0aec0;
line-height: 1.5;
text-align: center;
}
}
.candidate-actions {
display: flex;
justify-content: center;
margin-top: 14px;
.btn-remove {
display: inline-flex;
align-items: center;
gap: 4px;
background: #fff5f5;
color: #e53e3e;
border: 1px solid #fed7d7;
padding: 5px 12px;
border-radius: 6px;
font-size: 12px;
cursor: pointer;
mat-icon {
font-size: 14px;
height: 14px;
width: 14px;
}
&:hover {
background: #fed7d7;
}
}
.removed-badge {
background: #edf2f7;
color: #a0aec0;
padding: 5px 12px;
border-radius: 6px;
font-size: 12px;
}
}
.loading-state,
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 60px;
color: #a0aec0;
mat-icon {
font-size: 56px;
height: 56px;
width: 56px;
margin-bottom: 12px;
}
h3 {
margin: 0 0 8px;
}
p {
margin: 0;
}
}
.spinner {
width: 36px;
height: 36px;
border: 4px solid #e2e8f0;
border-top-color: #4a6cf7;
border-radius: 50%;
animation: spin 0.7s linear infinite;
margin-bottom: 12px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@@ -0,0 +1,98 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule, ActivatedRoute } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { catchError, of } from 'rxjs';
import { ElectionService } from '../../election.service';
@Component({
selector: 'mifosx-candidate-board',
standalone: true,
imports: [
CommonModule,
RouterModule,
FormsModule,
MatIconModule,
MatSnackBarModule
],
templateUrl: './candidate-board.component.html',
styleUrls: ['./candidate-board.component.scss']
})
export class CandidateBoardComponent implements OnInit {
private service = inject(ElectionService);
private route = inject(ActivatedRoute);
private snackBar = inject(MatSnackBar);
electionId!: number;
election: any = null;
candidates: any[] = [];
loading = true;
selectedPostId: number | null = null;
ngOnInit(): void {
this.electionId = +this.route.snapshot.params['electionId'];
this.loadElection();
this.loadCandidates();
}
loadElection(): void {
this.service.getElection(this.electionId).subscribe((e) => (this.election = e));
}
loadCandidates(): void {
this.loading = true;
this.service
.getCandidates(this.electionId, this.selectedPostId ?? undefined)
.pipe(
catchError(() => {
this.loading = false;
return of([]);
})
)
.subscribe((data: any[]) => {
this.candidates = data;
this.loading = false;
});
}
removeCandidate(candidate: any): void {
if (!confirm(`Remove ${candidate.candidateName} from the candidate list?`)) return;
this.service.removeCandidate(this.electionId, candidate.id, 'Removed by admin').subscribe(() => {
this.snackBar.open('Candidate removed', 'Close', { duration: 3000 });
this.loadCandidates();
});
}
get posts(): any[] {
return this.election?.posts ?? [];
}
get filteredCandidates(): any[] {
if (!this.selectedPostId) return this.candidates;
return this.candidates.filter((c) => c.postId === this.selectedPostId);
}
groupByPost(): { postName: string; candidates: any[] }[] {
const grouped: Record<string, any[]> = {};
for (const c of this.candidates) {
if (!grouped[c.postName]) grouped[c.postName] = [];
grouped[c.postName].push(c);
}
return Object.entries(grouped).map(
([
postName,
candidates
]) => ({ postName, candidates })
);
}
}
@@ -0,0 +1,242 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="config-container">
<div class="config-inner">
<!-- Page Header -->
<div class="page-header">
<a routerLink="/election" class="back-btn">
<mat-icon>arrow_back</mat-icon>
</a>
<div class="header-text">
<h2>{{ isEditMode ? 'Edit Election' : 'Create New Election' }}</h2>
<p>{{ isEditMode ? 'Update election details and posts' : 'Set up a new election with posts and schedule' }}</p>
</div>
</div>
<!-- Stepper Card -->
<div class="stepper-card">
<mat-stepper orientation="horizontal" #stepper linear>
<!-- ── Step 1: Basic Info ── -->
<mat-step [stepControl]="basicForm" label="Basic Info">
<form [formGroup]="basicForm" class="step-form">
<div class="section-label"><mat-icon>info</mat-icon> Election Details</div>
<div class="form-grid">
<mat-form-field appearance="outline" class="full-width">
<mat-label>Election Title *</mat-label>
<input matInput formControlName="title" placeholder="e.g., Board of Directors Election 2026" />
<mat-error *ngIf="basicForm.get('title')?.hasError('required')">Title is required</mat-error>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Election Type *</mat-label>
<mat-select formControlName="electionType">
@for (type of electionTypes; track type) {
<mat-option [value]="type">{{ type }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Description</mat-label>
<textarea
matInput
formControlName="description"
rows="2"
placeholder="Brief description of this election"
></textarea>
</mat-form-field>
</div>
<div class="section-label" style="margin-top: 8px"><mat-icon>event</mat-icon> Schedule</div>
<div class="form-grid">
<mat-form-field appearance="outline">
<mat-label>Nomination Start *</mat-label>
<input matInput [matDatepicker]="nomStart" [min]="today" formControlName="nominationStartDate" />
<mat-datepicker-toggle matSuffix [for]="nomStart"></mat-datepicker-toggle>
<mat-datepicker #nomStart></mat-datepicker>
<mat-error *ngIf="basicForm.get('nominationStartDate')?.hasError('required')">Required</mat-error>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Nomination End *</mat-label>
<input
matInput
[matDatepicker]="nomEnd"
[min]="nomStartVal || today"
formControlName="nominationEndDate"
/>
<mat-datepicker-toggle matSuffix [for]="nomEnd"></mat-datepicker-toggle>
<mat-datepicker #nomEnd></mat-datepicker>
<mat-error *ngIf="basicForm.get('nominationEndDate')?.hasError('required')">Required</mat-error>
<mat-error *ngIf="basicForm.errors?.['nomEndBeforeStart']">Must be after Nomination Start</mat-error>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Election Date *</mat-label>
<input matInput [matDatepicker]="elDate" [min]="nomEndVal || today" formControlName="electionDate" />
<mat-datepicker-toggle matSuffix [for]="elDate"></mat-datepicker-toggle>
<mat-datepicker #elDate></mat-datepicker>
<mat-error *ngIf="basicForm.get('electionDate')?.hasError('required')">Required</mat-error>
<mat-error *ngIf="basicForm.errors?.['electionBeforeNomEnd']">Must be after Nomination End</mat-error>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Result Declaration Date</mat-label>
<input
matInput
[matDatepicker]="resDate"
[min]="electionDateVal || today"
formControlName="resultDate"
/>
<mat-datepicker-toggle matSuffix [for]="resDate"></mat-datepicker-toggle>
<mat-datepicker #resDate></mat-datepicker>
<mat-error *ngIf="basicForm.errors?.['resultBeforeElection']">Must be after Election Date</mat-error>
</mat-form-field>
</div>
<div class="step-actions">
<a routerLink="/election" class="btn-ghost"> <mat-icon>close</mat-icon> Cancel </a>
<button
class="btn-next"
matStepperNext
type="button"
[disabled]="basicForm.invalid || basicFormHasDateError"
>
Next <mat-icon>arrow_forward</mat-icon>
</button>
</div>
</form>
</mat-step>
<!-- ── Step 2: Posts ── -->
<mat-step [stepControl]="postsForm" label="Election Posts">
<form [formGroup]="postsForm" class="step-form">
<div class="posts-header">
<h3>Define Election Posts</h3>
<button class="btn-add-post" type="button" (click)="addPost()"><mat-icon>add</mat-icon> Add Post</button>
</div>
<div formArrayName="posts">
@for (post of postsArray.controls; track $index) {
<div class="post-card" [formGroupName]="$index">
<div class="post-card-header">
<span class="post-num">
<mat-icon style="font-size: 13px; height: 13px; width: 13px">how_to_vote</mat-icon>
Post {{ $index + 1 }}
</span>
@if (postsArray.length > 1) {
<button class="btn-remove" type="button" (click)="removePost($index)">
<mat-icon>delete_outline</mat-icon>
</button>
}
</div>
<div class="form-grid">
<mat-form-field appearance="outline">
<mat-label>Post Name *</mat-label>
<mat-select formControlName="postName">
@for (p of postNames; track p) {
<mat-option [value]="p">{{ p }}</mat-option>
}
</mat-select>
<mat-error *ngIf="post.get('postName')?.hasError('required')">Required</mat-error>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Number of Seats *</mat-label>
<input matInput type="number" formControlName="totalSeats" min="1" />
<mat-error *ngIf="post.get('totalSeats')?.hasError('min')">Minimum 1 seat</mat-error>
</mat-form-field>
</div>
</div>
}
</div>
<div class="step-actions">
<button class="btn-ghost" matStepperPrevious type="button"><mat-icon>arrow_back</mat-icon> Back</button>
<button class="btn-next" matStepperNext type="button" [disabled]="postsForm.invalid">
Review <mat-icon>arrow_forward</mat-icon>
</button>
</div>
</form>
</mat-step>
<!-- ── Step 3: Review & Submit ── -->
<mat-step label="Review & Submit">
<div class="review-wrap">
<p class="review-heading">Review Before Submitting</p>
<!-- Basic Info -->
<div class="review-block">
<div class="review-block-title"><mat-icon>info</mat-icon> Basic Information</div>
<div class="review-grid">
<div class="review-item full-span">
<span class="review-label">Title</span>
<span class="review-value">{{ basicForm.value.title || '—' }}</span>
</div>
<div class="review-item">
<span class="review-label">Type</span>
<span class="review-value">{{ basicForm.value.electionType }}</span>
</div>
<div class="review-item">
<span class="review-label">Nomination Period</span>
<span class="review-value">
{{ basicForm.value.nominationStartDate | date: 'dd MMM yyyy' }}
&nbsp;→&nbsp;
{{ basicForm.value.nominationEndDate | date: 'dd MMM yyyy' }}
</span>
</div>
<div class="review-item">
<span class="review-label">Election Date</span>
<span class="review-value">{{ basicForm.value.electionDate | date: 'dd MMM yyyy' }}</span>
</div>
<div class="review-item">
<span class="review-label">Result Date</span>
<span class="review-value">
{{ basicForm.value.resultDate ? (basicForm.value.resultDate | date: 'dd MMM yyyy') : '—' }}
</span>
</div>
@if (basicForm.value.description) {
<div class="review-item full-span">
<span class="review-label">Description</span>
<span class="review-value">{{ basicForm.value.description }}</span>
</div>
}
</div>
</div>
<!-- Posts -->
<div class="review-block">
<div class="review-block-title"><mat-icon>how_to_vote</mat-icon> Posts ({{ postsArray.length }})</div>
@for (post of postsArray.controls; track $index) {
<div class="review-post-card">
<div class="review-post-header">
<span class="post-badge">Post {{ $index + 1 }}</span>
<strong>{{ post.value.postName || '—' }}</strong>
<span class="seats-badge">{{ post.value.totalSeats }} seat(s)</span>
</div>
</div>
}
</div>
</div>
<div class="step-actions">
<button class="btn-ghost" matStepperPrevious type="button"><mat-icon>arrow_back</mat-icon> Back</button>
<button class="btn-submit" type="button" (click)="submit()" [disabled]="submitting">
<mat-icon>{{ submitting ? 'hourglass_empty' : 'check_circle' }}</mat-icon>
{{ submitting ? 'Saving...' : isEditMode ? 'Update Election' : 'Create Election' }}
</button>
</div>
</mat-step>
</mat-stepper>
</div>
</div>
</div>
@@ -0,0 +1,532 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
// ── Variables ──────────────────────────────────────────────────────────────
$primary: #4a6cf7;
$primary-d: #3a5ce7;
$success: #38a169;
$danger: #e53e3e;
$text-dark: #1a202c;
$text-mid: #4a5568;
$text-soft: #718096;
$border: #e2e8f0;
$bg-page: #f0f4ff;
$bg-white: #fff;
$radius-lg: 16px;
$radius-md: 10px;
$shadow-sm: 0 2px 8px rgb(74 108 247 / 8%);
$shadow-md: 0 4px 20px rgb(74 108 247 / 13%);
// ── Page wrapper ───────────────────────────────────────────────────────────
.config-container {
min-height: 100vh;
background: $bg-page;
padding: 32px 24px 60px;
}
.config-inner {
max-width: 860px;
margin: 0 auto;
}
// ── Page header ────────────────────────────────────────────────────────────
.page-header {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 28px;
.back-btn {
display: flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
border-radius: 10px;
background: $bg-white;
border: 1px solid $border;
color: $text-mid;
text-decoration: none;
box-shadow: $shadow-sm;
transition: all 0.15s;
flex-shrink: 0;
&:hover {
background: #ebf0ff;
color: $primary;
border-color: #c3d0fb;
}
mat-icon {
font-size: 20px;
}
}
.header-text {
h2 {
font-size: 22px;
font-weight: 700;
color: $text-dark;
margin: 0 0 2px;
letter-spacing: -0.3px;
}
p {
font-size: 13px;
color: $text-soft;
margin: 0;
}
}
}
// ── Stepper card ───────────────────────────────────────────────────────────
.stepper-card {
background: $bg-white;
border-radius: $radius-lg;
box-shadow: $shadow-md;
overflow: hidden;
// Override Angular Material stepper header
::ng-deep .mat-stepper-horizontal-line {
border-color: $border;
}
::ng-deep .mat-step-header {
padding: 20px 24px;
&:hover {
background: #f7f9ff;
}
.mat-step-icon {
background: $border;
color: $text-soft;
font-weight: 700;
}
.mat-step-icon-selected,
.mat-step-icon-state-done,
.mat-step-icon-state-edit {
background: $primary !important;
color: #fff !important;
}
.mat-step-label {
color: $text-soft;
font-size: 13px;
font-weight: 500;
}
.mat-step-label-active {
color: $text-dark;
font-weight: 600;
}
.mat-step-label-selected {
color: $primary;
font-weight: 700;
}
}
::ng-deep .mat-horizontal-content-container {
padding: 0;
}
}
// ── Step form ──────────────────────────────────────────────────────────────
.step-form {
padding: 28px 32px 0;
}
.section-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
font-weight: 700;
color: $primary;
text-transform: uppercase;
letter-spacing: 0.8px;
margin-bottom: 16px;
mat-icon {
font-size: 16px;
height: 16px;
width: 16px;
}
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 4px;
.full-width {
grid-column: 1 / -1;
}
}
// Material field overrides
::ng-deep .config-container {
.mat-mdc-form-field-subscript-wrapper {
margin-top: 2px;
}
.mdc-notched-outline__leading,
.mdc-notched-outline__notch,
.mdc-notched-outline__trailing {
border-color: $border !important;
}
.mdc-notched-outline:hover .mdc-notched-outline__leading,
.mdc-notched-outline:hover .mdc-notched-outline__notch,
.mdc-notched-outline:hover .mdc-notched-outline__trailing {
border-color: #b0bfff !important;
}
.mdc-text-field--focused .mdc-notched-outline__leading,
.mdc-text-field--focused .mdc-notched-outline__notch,
.mdc-text-field--focused .mdc-notched-outline__trailing {
border-color: $primary !important;
border-width: 2px !important;
}
}
// ── Step actions ───────────────────────────────────────────────────────────
.step-actions {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 10px;
padding: 20px 32px 24px;
margin-top: 12px;
border-top: 1px solid #f0f4f8;
.btn-ghost {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 9px 18px;
border-radius: 10px;
font-size: 13px;
font-weight: 600;
color: $text-mid;
background: transparent;
border: 1px solid $border;
cursor: pointer;
transition: all 0.15s;
text-decoration: none;
&:hover {
background: #f4f6fb;
color: $text-dark;
}
mat-icon {
font-size: 16px;
height: 16px;
width: 16px;
}
}
.btn-next {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 10px 22px;
border-radius: 10px;
font-size: 13px;
font-weight: 700;
color: #fff;
background: linear-gradient(135deg, #4a6cf7 0%, #6a3de8 100%);
border: none;
cursor: pointer;
box-shadow: 0 4px 12px rgb(74 108 247 / 35%);
transition: all 0.15s;
mat-icon {
font-size: 16px;
height: 16px;
width: 16px;
}
&:hover:not(:disabled) {
background: linear-gradient(135deg, #3a5ce7 0%, #5a2dd8 100%);
box-shadow: 0 6px 16px rgb(74 108 247 / 45%);
transform: translateY(-1px);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
}
.btn-submit {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 11px 28px;
border-radius: 10px;
font-size: 14px;
font-weight: 700;
color: #fff;
background: linear-gradient(135deg, #38a169 0%, #2f855a 100%);
border: none;
cursor: pointer;
box-shadow: 0 4px 14px rgb(56 161 105 / 40%);
transition: all 0.15s;
mat-icon {
font-size: 18px;
height: 18px;
width: 18px;
}
&:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 6px 18px rgb(56 161 105 / 50%);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
}
}
// ── Posts header ───────────────────────────────────────────────────────────
.posts-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
h3 {
font-size: 15px;
font-weight: 700;
color: $text-dark;
margin: 0;
}
.btn-add-post {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 16px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
color: $primary;
background: #ebf0ff;
border: 1px solid #c3d0fb;
cursor: pointer;
transition: all 0.15s;
&:hover {
background: #dce6ff;
}
mat-icon {
font-size: 16px;
height: 16px;
width: 16px;
}
}
}
// ── Post card ──────────────────────────────────────────────────────────────
.post-card {
background: #fafbff;
border: 1.5px solid $border;
border-radius: $radius-md;
padding: 20px 24px;
margin-bottom: 14px;
transition: border-color 0.15s;
&:hover {
border-color: #c3d0fb;
}
.post-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
.post-num {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 700;
color: $primary;
background: #ebf0ff;
padding: 4px 12px;
border-radius: 20px;
letter-spacing: 0.3px;
}
.btn-remove {
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: 8px;
background: #fff5f5;
border: 1px solid #fed7d7;
color: $danger;
cursor: pointer;
transition: all 0.15s;
&:hover {
background: #ffe4e4;
}
mat-icon {
font-size: 16px;
}
}
}
}
// ── Review section ─────────────────────────────────────────────────────────
.review-wrap {
padding: 28px 32px 8px;
}
.review-heading {
font-size: 16px;
font-weight: 700;
color: $text-dark;
margin: 0 0 20px;
display: flex;
align-items: center;
gap: 8px;
&::before {
content: '';
display: inline-block;
width: 4px;
height: 18px;
background: linear-gradient(180deg, $primary, #6a3de8);
border-radius: 4px;
}
}
.review-block {
border: 1px solid $border;
border-radius: $radius-md;
margin-bottom: 16px;
overflow: hidden;
background: $bg-white;
.review-block-title {
display: flex;
align-items: center;
gap: 8px;
padding: 11px 16px;
background: linear-gradient(90deg, #f0f4ff 0%, #f7f9fc 100%);
border-bottom: 1px solid $border;
font-size: 12px;
font-weight: 700;
color: $primary;
text-transform: uppercase;
letter-spacing: 0.5px;
mat-icon {
font-size: 15px;
height: 15px;
width: 15px;
}
}
}
.review-grid {
display: grid;
grid-template-columns: 1fr 1fr;
.review-item {
padding: 12px 16px;
border-bottom: 1px solid #f5f7fa;
display: flex;
flex-direction: column;
gap: 3px;
&.full-span {
grid-column: 1 / -1;
}
&:last-child,
&:nth-last-child(2):nth-child(odd) {
border-bottom: none;
}
}
.review-label {
font-size: 10px;
color: $text-soft;
text-transform: uppercase;
letter-spacing: 0.7px;
font-weight: 700;
}
.review-value {
font-size: 14px;
font-weight: 500;
color: $text-dark;
line-height: 1.4;
}
}
.review-post-card {
padding: 14px 16px;
border-bottom: 1px solid #f0f4f8;
&:last-child {
border-bottom: none;
}
.review-post-header {
display: flex;
align-items: center;
gap: 8px;
strong {
font-size: 14px;
font-weight: 600;
color: $text-dark;
flex: 1;
}
.post-badge {
font-size: 10px;
font-weight: 700;
color: $primary;
background: #ebf0ff;
padding: 2px 10px;
border-radius: 20px;
}
.seats-badge {
font-size: 10px;
font-weight: 600;
color: $success;
background: #f0fff4;
border: 1px solid #c6f6d5;
padding: 2px 10px;
border-radius: 20px;
}
}
}
@@ -0,0 +1,264 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import {
AbstractControl,
FormArray,
FormBuilder,
FormGroup,
ReactiveFormsModule,
ValidationErrors,
ValidatorFn,
Validators
} from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
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 { MatDatepickerModule } from '@angular/material/datepicker';
import { MatNativeDateModule } from '@angular/material/core';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { catchError, of } from 'rxjs';
import { ElectionService } from '../../election.service';
@Component({
selector: 'mifosx-election-config',
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,
RouterModule,
MatStepperModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
MatDatepickerModule,
MatNativeDateModule,
MatIconModule,
MatSnackBarModule
],
templateUrl: './election-config.component.html',
styleUrls: ['./election-config.component.scss']
})
export class ElectionConfigComponent implements OnInit {
private fb = inject(FormBuilder);
private service = inject(ElectionService);
private router = inject(Router);
private route = inject(ActivatedRoute);
private snackBar = inject(MatSnackBar);
electionId: number | null = null;
isEditMode = false;
submitting = false;
basicForm!: FormGroup;
postsForm!: FormGroup;
electionTypes = [
'DIRECTOR',
'GENERAL',
'BY_ELECTION'
];
postNames = [
'Director',
'Chairman',
'Vice Chairman',
'Secretary',
'Treasurer',
'President',
'Managing Director'
];
today = new Date();
ngOnInit(): void {
this.electionId = this.route.snapshot.params['electionId'] ? +this.route.snapshot.params['electionId'] : null;
this.isEditMode = !!this.electionId;
this.buildForms();
if (this.isEditMode) this.loadElection();
}
buildForms(): void {
this.basicForm = this.fb.group(
{
title: [
'',
Validators.required
],
description: [''],
electionType: [
'DIRECTOR',
Validators.required
],
nominationStartDate: [
null,
Validators.required
],
nominationEndDate: [
null,
Validators.required
],
electionDate: [
null,
Validators.required
],
resultDate: [null]
},
{ validators: this.dateOrderValidator() }
);
this.postsForm = this.fb.group({
posts: this.fb.array([this.createPostGroup()])
});
}
createPostGroup(): FormGroup {
return this.fb.group({
postName: [
'',
Validators.required
],
totalSeats: [
1,
[
Validators.required,
Validators.min(1)
]
]
});
}
get postsArray(): FormArray {
return this.postsForm.get('posts') as FormArray;
}
addPost(): void {
this.postsArray.push(this.createPostGroup());
}
removePost(i: number): void {
if (this.postsArray.length > 1) this.postsArray.removeAt(i);
}
loadElection(): void {
this.service.getElection(this.electionId!).subscribe((election: any) => {
this.basicForm.patchValue(election);
if (election.posts?.length) {
this.postsForm.setControl(
'posts',
this.fb.array(
election.posts.map((p: any) =>
this.fb.group({
postName: [
p.postName,
Validators.required
],
totalSeats: [
p.totalSeats,
[
Validators.required,
Validators.min(1)
]
]
})
)
)
);
}
});
}
get nomStartVal(): Date | null {
return this.basicForm?.get('nominationStartDate')?.value ?? null;
}
get nomEndVal(): Date | null {
return this.basicForm?.get('nominationEndDate')?.value ?? null;
}
get electionDateVal(): Date | null {
return this.basicForm?.get('electionDate')?.value ?? null;
}
private dateOrderValidator(): ValidatorFn {
return (group: AbstractControl): ValidationErrors | null => {
const nomStart = group.get('nominationStartDate')?.value as Date | null;
const nomEnd = group.get('nominationEndDate')?.value as Date | null;
const elDate = group.get('electionDate')?.value as Date | null;
const resDate = group.get('resultDate')?.value as Date | null;
const errors: ValidationErrors = {};
if (nomStart && nomEnd && nomEnd <= nomStart) {
errors['nomEndBeforeStart'] = true;
}
if (nomEnd && elDate && elDate <= nomEnd) {
errors['electionBeforeNomEnd'] = true;
}
if (elDate && resDate && resDate <= elDate) {
errors['resultBeforeElection'] = true;
}
return Object.keys(errors).length ? errors : null;
};
}
private toDateString(d: Date | null): string | null {
if (!d) return null;
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
get basicFormHasDateError(): boolean {
return !!(
this.basicForm?.errors?.['nomEndBeforeStart'] ||
this.basicForm?.errors?.['electionBeforeNomEnd'] ||
this.basicForm?.errors?.['resultBeforeElection']
);
}
submit(): void {
if (this.basicForm.invalid || this.postsForm.invalid) {
this.basicForm.markAllAsTouched();
this.postsForm.markAllAsTouched();
return;
}
this.submitting = true;
const bv = this.basicForm.value;
const payload = {
...bv,
nominationStartDate: this.toDateString(bv.nominationStartDate),
nominationEndDate: this.toDateString(bv.nominationEndDate),
electionDate: this.toDateString(bv.electionDate),
resultDate: this.toDateString(bv.resultDate),
posts: this.postsForm.value.posts
};
const obs = this.isEditMode
? this.service.updateElection(this.electionId!, payload)
: this.service.createElection(payload);
obs
.pipe(
catchError((err) => {
this.snackBar.open(err?.error?.errors?.[0]?.defaultUserMessage ?? 'Failed to save election', 'Close', {
duration: 4000
});
this.submitting = false;
return of(null);
})
)
.subscribe((res: any) => {
if (!res) return;
this.snackBar.open('Election saved successfully', 'Close', { duration: 3000 });
this.router.navigate(['/election']);
});
}
}
@@ -0,0 +1,130 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="election-dashboard">
<div class="page-header">
<h2 class="page-title">Election Management</h2>
<a routerLink="/election/config/new" class="btn-primary"> <mat-icon>add</mat-icon> Create Election </a>
</div>
@if (loading) {
<div class="loading-state">
<div class="spinner"></div>
<p>Loading elections...</p>
</div>
} @else if (elections.length === 0) {
<div class="empty-state">
<mat-icon class="empty-icon">how_to_vote</mat-icon>
<h3>No elections found</h3>
<p>Create a new election to get started.</p>
<a routerLink="/election/config/new" class="btn-primary">Create Election</a>
</div>
} @else {
<div class="stats-row">
<div class="stat-card">
<span class="stat-value">{{ elections.length }}</span>
<span class="stat-label">Total Elections</span>
</div>
<div class="stat-card">
<span class="stat-value">{{ countByStatus('ACTIVE') }}</span>
<span class="stat-label">Active</span>
</div>
<div class="stat-card">
<span class="stat-value">{{ countByStatus('VOTING_OPEN') }}</span>
<span class="stat-label">Voting Open</span>
</div>
<div class="stat-card">
<span class="stat-value">{{ countByStatus('RESULT_DECLARED') }}</span>
<span class="stat-label">Completed</span>
</div>
</div>
<div class="elections-grid">
@for (election of elections; track election.id) {
<div class="election-card">
<div class="card-header">
<div>
<h3 class="election-title">{{ election.title }}</h3>
<span class="election-type">{{ election.electionType }}</span>
</div>
<span class="status-chip" [ngClass]="statusClass(election.status)">
{{ statusLabel(election.status) }}
</span>
</div>
<div class="card-body">
<div class="date-row">
<div class="date-item">
<mat-icon>event_note</mat-icon>
<div>
<span class="date-label">Nominations</span>
<span class="date-value">
{{ election.nominationStartDate | date: 'dd MMM' }} –
{{ election.nominationEndDate | date: 'dd MMM yyyy' }}
</span>
</div>
</div>
<div class="date-item">
<mat-icon>how_to_vote</mat-icon>
<div>
<span class="date-label">Election Date</span>
<span class="date-value">{{ election.electionDate | date: 'dd MMM yyyy' }}</span>
</div>
</div>
</div>
@if (election.description) {
<p class="description">{{ election.description }}</p>
}
</div>
<div class="card-actions">
<a [routerLink]="['/election', election.id, 'nominations']" class="btn-outline">
<mat-icon>people</mat-icon> Nominations
</a>
<a [routerLink]="['/election', election.id, 'candidates']" class="btn-outline">
<mat-icon>person_pin</mat-icon> Candidates
</a>
@if (election.status === 'DRAFT') {
<button class="btn-activate" [disabled]="actionLoading[election.id]" (click)="activate(election)">
<mat-icon>play_circle</mat-icon>
{{ actionLoading[election.id] ? 'Activating...' : 'Activate' }}
</button>
}
@if (canNominate(election)) {
<a [routerLink]="['/election', election.id, 'nomination', 'apply']" class="btn-primary">
<mat-icon>person_add</mat-icon> Nominate Member
</a>
}
@if (election.status === 'ACTIVE') {
<button class="btn-warn" [disabled]="actionLoading[election.id]" (click)="closeNominations(election)">
<mat-icon>lock</mat-icon> Close Nominations
</button>
}
@if (election.status === 'NOMINATION_CLOSED') {
<button class="btn-activate" [disabled]="actionLoading[election.id]" (click)="openVoting(election)">
<mat-icon>how_to_vote</mat-icon> Open Voting
</button>
}
@if (canVote(election)) {
<a [routerLink]="['/election', election.id, 'vote']" class="btn-vote">
<mat-icon>ballot</mat-icon> Vote Now
</a>
}
@if (canViewResults(election)) {
<a [routerLink]="['/election', election.id, 'results']" class="btn-results">
<mat-icon>bar_chart</mat-icon> Results
</a>
}
</div>
</div>
}
</div>
}
</div>
@@ -0,0 +1,326 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.election-dashboard {
padding: 24px;
max-width: 1400px;
margin: 0 auto;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 28px;
.page-title {
font-size: 24px;
font-weight: 600;
color: #1a202c;
margin: 0;
}
}
.stats-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 28px;
.stat-card {
background: #fff;
border-radius: 12px;
padding: 20px;
box-shadow: 0 1px 4px rgb(0 0 0 / 8%);
display: flex;
flex-direction: column;
align-items: center;
.stat-value {
font-size: 32px;
font-weight: 700;
color: #4a6cf7;
}
.stat-label {
font-size: 13px;
color: #718096;
margin-top: 4px;
}
}
}
.elections-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
gap: 20px;
}
.election-card {
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
overflow: hidden;
transition: box-shadow 0.2s;
&:hover {
box-shadow: 0 4px 16px rgb(0 0 0 / 12%);
}
.card-header {
padding: 20px 20px 16px;
display: flex;
justify-content: space-between;
align-items: flex-start;
border-bottom: 1px solid #f0f0f0;
.election-title {
font-size: 16px;
font-weight: 600;
margin: 0 0 4px;
color: #1a202c;
}
.election-type {
font-size: 12px;
color: #718096;
text-transform: uppercase;
letter-spacing: 0.5px;
}
}
.card-body {
padding: 16px 20px;
.date-row {
display: flex;
gap: 20px;
.date-item {
display: flex;
align-items: flex-start;
gap: 8px;
mat-icon {
color: #a0aec0;
font-size: 18px;
margin-top: 2px;
}
.date-label {
display: block;
font-size: 11px;
color: #a0aec0;
text-transform: uppercase;
}
.date-value {
display: block;
font-size: 13px;
color: #4a5568;
font-weight: 500;
}
}
}
.description {
margin-top: 12px;
font-size: 13px;
color: #718096;
line-height: 1.5;
}
}
.card-actions {
padding: 12px 20px 16px;
display: flex;
gap: 8px;
flex-wrap: wrap;
border-top: 1px solid #f7f7f7;
}
}
.status-chip {
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
&.status--draft {
background: #edf2f7;
color: #718096;
}
&.status--active {
background: #c6f6d5;
color: #276749;
}
&.status--closed {
background: #fff3cd;
color: #856404;
}
&.status--voting {
background: #bee3f8;
color: #2a69ac;
}
&.status--voting-closed {
background: #e2d9f3;
color: #6b46c1;
}
&.status--declared {
background: #fefcbf;
color: #7b341e;
}
&.status--cancelled {
background: #fed7d7;
color: #9b2c2c;
}
}
.btn-primary,
.btn-outline,
.btn-vote,
.btn-results {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 6px 14px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
text-decoration: none;
cursor: pointer;
border: none;
transition: background 0.15s;
mat-icon {
font-size: 16px;
height: 16px;
width: 16px;
}
}
.btn-primary {
background: #4a6cf7;
color: #fff;
&:hover {
background: #3a5ce7;
}
}
.btn-outline {
background: #f7f8fc;
color: #4a6cf7;
border: 1px solid #e2e8f0;
&:hover {
background: #edf0ff;
}
}
.btn-vote {
background: #38a169;
color: #fff;
&:hover {
background: #2f855a;
}
}
.btn-results {
background: #ed8936;
color: #fff;
&:hover {
background: #dd6b20;
}
}
.btn-activate {
background: #2b6cb0;
color: #fff;
&:hover:not(:disabled) {
background: #2c5282;
}
}
.btn-warn {
background: #c05621;
color: #fff;
&:hover:not(:disabled) {
background: #9c4221;
}
}
.btn-activate,
.btn-warn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 6px 14px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
border: none;
transition: background 0.15s;
mat-icon {
font-size: 16px;
height: 16px;
width: 16px;
}
&:disabled {
opacity: 0.55;
cursor: not-allowed;
}
}
.loading-state,
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 20px;
color: #a0aec0;
text-align: center;
.empty-icon {
font-size: 64px;
height: 64px;
width: 64px;
margin-bottom: 16px;
}
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid #e2e8f0;
border-top-color: #4a6cf7;
border-radius: 50%;
animation: spin 0.7s linear infinite;
margin-bottom: 16px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@@ -0,0 +1,151 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { catchError, of } from 'rxjs';
import { ElectionService } from '../../election.service';
@Component({
selector: 'mifosx-election-dashboard',
standalone: true,
imports: [
CommonModule,
RouterModule,
MatIconModule,
MatSnackBarModule
],
templateUrl: './election-dashboard.component.html',
styleUrls: ['./election-dashboard.component.scss']
})
export class ElectionDashboardComponent implements OnInit {
private service = inject(ElectionService);
private snackBar = inject(MatSnackBar);
elections: any[] = [];
loading = true;
readonly statusConfig: Record<string, { label: string; cssClass: string }> = {
DRAFT: { label: 'Draft', cssClass: 'status--draft' },
ACTIVE: { label: 'Active', cssClass: 'status--active' },
NOMINATION_CLOSED: { label: 'Nominations Closed', cssClass: 'status--closed' },
VOTING_OPEN: { label: 'Voting Open', cssClass: 'status--voting' },
VOTING_CLOSED: { label: 'Voting Closed', cssClass: 'status--voting-closed' },
RESULT_DECLARED: { label: 'Results Declared', cssClass: 'status--declared' },
CANCELLED: { label: 'Cancelled', cssClass: 'status--cancelled' }
};
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.service
.getAllElections()
.pipe(
catchError(() => {
this.loading = false;
return of([]);
})
)
.subscribe((data: any[]) => {
this.elections = data;
this.loading = false;
});
}
statusLabel(status: string): string {
return this.statusConfig[status]?.label ?? status;
}
statusClass(status: string): string {
return this.statusConfig[status]?.cssClass ?? '';
}
canNominate(election: any): boolean {
return election.status === 'ACTIVE';
}
canVote(election: any): boolean {
return election.status === 'VOTING_OPEN';
}
canViewResults(election: any): boolean {
return election.status === 'RESULT_DECLARED';
}
countByStatus(status: string): number {
return this.elections.filter((e) => e.status === status).length;
}
actionLoading: Record<number, boolean> = {};
activate(election: any): void {
this.actionLoading[election.id] = true;
this.service
.activateElection(election.id)
.pipe(
catchError((err) => {
const msg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Failed to activate';
this.snackBar.open(msg, 'Close', { duration: 4000 });
this.actionLoading[election.id] = false;
return of(null);
})
)
.subscribe((res) => {
this.actionLoading[election.id] = false;
if (!res) return;
this.snackBar.open('Election activated — nominations are now open!', 'Close', { duration: 3000 });
this.load();
});
}
closeNominations(election: any): void {
this.actionLoading[election.id] = true;
this.service
.closeNominations(election.id)
.pipe(
catchError((err) => {
this.snackBar.open(err?.error?.errors?.[0]?.defaultUserMessage ?? 'Failed', 'Close', { duration: 4000 });
this.actionLoading[election.id] = false;
return of(null);
})
)
.subscribe((res) => {
this.actionLoading[election.id] = false;
if (res) {
this.snackBar.open('Nominations closed', 'Close', { duration: 3000 });
this.load();
}
});
}
openVoting(election: any): void {
this.actionLoading[election.id] = true;
this.service
.openVoting(election.id)
.pipe(
catchError((err) => {
this.snackBar.open(err?.error?.errors?.[0]?.defaultUserMessage ?? 'Failed', 'Close', { duration: 4000 });
this.actionLoading[election.id] = false;
return of(null);
})
)
.subscribe((res) => {
this.actionLoading[election.id] = false;
if (res) {
this.snackBar.open('Voting is now open!', 'Close', { duration: 3000 });
this.load();
}
});
}
}
@@ -0,0 +1,104 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="election-results">
<div class="page-header">
<a routerLink="/election" class="back-link"><mat-icon>arrow_back</mat-icon></a>
<div>
<h2>Election Results</h2>
@if (election) {
<p class="sub-title">{{ election.title }}</p>
}
</div>
@if (canDeclare) {
<button class="btn-declare" (click)="declareResults()" [disabled]="declaring">
<mat-icon>gavel</mat-icon>
{{ declaring ? 'Declaring...' : 'Declare Results' }}
</button>
}
</div>
@if (loading) {
<div class="loading-state">
<div class="spinner"></div>
<p>Loading results...</p>
</div>
} @else if (results.length === 0) {
<div class="empty-state">
<mat-icon>bar_chart</mat-icon>
<h3>Results not yet declared</h3>
@if (canDeclare) {
<p>Voting is closed. Click "Declare Results" to compute and publish the results.</p>
} @else {
<p>Results will appear here once the election is complete and results are declared.</p>
}
</div>
} @else {
<div class="results-meta">
<div class="meta-chip">
<mat-icon>how_to_vote</mat-icon>
Election: {{ election?.electionDate | date: 'dd MMM yyyy' }}
</div>
@if (results[0]?.declaredDate) {
<div class="meta-chip">
<mat-icon>gavel</mat-icon>
Declared: {{ results[0].declaredDate | date: 'dd MMM yyyy HH:mm' }}
</div>
}
</div>
@for (group of groupByPost(); track group.postName) {
<div class="post-result">
<div class="post-result-header">
<h3>{{ group.postName }}</h3>
</div>
<!-- Winner Spotlight -->
@for (result of group.results; track result.id) {
@if (result.rankPosition === 1 && result.result === 'WON') {
<div class="winner-banner">
<div class="trophy-icon"><mat-icon>emoji_events</mat-icon></div>
<div class="winner-info">
<span class="winner-label">Winner</span>
<h4>{{ result.candidateName }}</h4>
<span class="winner-votes">{{ result.voteCount | number }} votes</span>
</div>
</div>
}
}
<!-- Results Table with Bar Chart -->
<div class="results-table">
@for (result of group.results; track result.id) {
<div class="result-row" [class.winner]="result.result === 'WON'">
<div class="rank">
@if (result.result === 'WON') {
<mat-icon class="won-icon">emoji_events</mat-icon>
} @else {
<span># {{ result.rankPosition }}</span>
}
</div>
<div class="candidate-name">{{ result.candidateName }}</div>
<div class="vote-bar">
<div
class="bar-fill"
[class.bar-won]="result.result === 'WON'"
[style.width.%]="barWidth(result.voteCount, maxVotes(group.results))"
></div>
<span class="vote-count">{{ result.voteCount | number }} votes</span>
</div>
<div class="result-badge" [class.won]="result.result === 'WON'" [class.lost]="result.result === 'LOST'">
{{ result.result }}
</div>
</div>
}
</div>
</div>
}
}
</div>
@@ -0,0 +1,289 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.election-results {
padding: 24px;
max-width: 1000px;
margin: 0 auto;
}
.page-header {
display: flex;
align-items: flex-start;
gap: 12px;
margin-bottom: 24px;
.back-link {
color: #718096;
text-decoration: none;
display: flex;
align-items: center;
margin-top: 4px;
}
h2 {
font-size: 22px;
font-weight: 600;
margin: 0 0 4px;
}
.sub-title {
font-size: 14px;
color: #718096;
margin: 0;
}
.btn-declare {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 6px;
background: #744210;
color: #fff;
padding: 10px 20px;
border-radius: 8px;
border: none;
font-size: 14px;
font-weight: 600;
cursor: pointer;
mat-icon {
font-size: 18px;
}
&:hover:not(:disabled) {
background: #5a3309;
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
}
.results-meta {
display: flex;
gap: 12px;
margin-bottom: 24px;
flex-wrap: wrap;
.meta-chip {
display: inline-flex;
align-items: center;
gap: 6px;
background: #f8fafc;
border: 1px solid #e2e8f0;
padding: 6px 14px;
border-radius: 20px;
font-size: 13px;
color: #4a5568;
mat-icon {
font-size: 16px;
height: 16px;
width: 16px;
color: #a0aec0;
}
}
}
.post-result {
background: #fff;
border-radius: 14px;
box-shadow: 0 2px 10px rgb(0 0 0 / 8%);
margin-bottom: 28px;
overflow: hidden;
.post-result-header {
padding: 16px 24px;
background: #f8fafc;
border-bottom: 1px solid #f0f0f0;
h3 {
font-size: 18px;
font-weight: 600;
color: #2d3748;
margin: 0;
}
}
}
.winner-banner {
display: flex;
align-items: center;
gap: 20px;
padding: 20px 24px;
background: linear-gradient(135deg, #fefcbf, #fbd38d);
border-bottom: 1px solid #f6e05e;
.trophy-icon {
width: 56px;
height: 56px;
background: #744210;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
mat-icon {
color: #fbd38d;
font-size: 28px;
}
}
.winner-label {
display: block;
font-size: 11px;
color: #975a16;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 4px;
}
h4 {
font-size: 20px;
font-weight: 700;
color: #744210;
margin: 0 0 4px;
}
.winner-votes {
font-size: 14px;
color: #975a16;
font-weight: 600;
}
}
.results-table {
padding: 16px 24px 8px;
}
.result-row {
display: flex;
align-items: center;
gap: 14px;
padding: 12px 0;
border-bottom: 1px solid #f7fafc;
&:last-child {
border-bottom: none;
}
&.winner .candidate-name {
font-weight: 700;
color: #744210;
}
.rank {
width: 40px;
text-align: center;
font-size: 14px;
font-weight: 600;
color: #a0aec0;
.won-icon {
color: #d69e2e;
font-size: 22px;
height: 22px;
width: 22px;
}
}
.candidate-name {
width: 200px;
font-size: 15px;
color: #2d3748;
}
.vote-bar {
flex: 1;
display: flex;
align-items: center;
gap: 12px;
.bar-fill {
height: 10px;
border-radius: 5px;
background: #bee3f8;
transition: width 0.5s ease;
&.bar-won {
background: #f6ad55;
}
}
.vote-count {
font-size: 13px;
color: #718096;
white-space: nowrap;
}
}
.result-badge {
padding: 3px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 700;
width: 50px;
text-align: center;
&.won {
background: #fefcbf;
color: #744210;
}
&.lost {
background: #edf2f7;
color: #a0aec0;
}
}
}
.loading-state,
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 60px 20px;
color: #a0aec0;
text-align: center;
mat-icon {
font-size: 56px;
height: 56px;
width: 56px;
margin-bottom: 12px;
}
h3 {
margin: 0 0 8px;
color: #4a5568;
}
p {
margin: 0;
font-size: 14px;
}
}
.spinner {
width: 36px;
height: 36px;
border: 4px solid #e2e8f0;
border-top-color: #4a6cf7;
border-radius: 50%;
animation: spin 0.7s linear infinite;
margin-bottom: 12px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@@ -0,0 +1,102 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule, ActivatedRoute } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { catchError, of } from 'rxjs';
import { ElectionService } from '../../election.service';
@Component({
selector: 'mifosx-election-results',
standalone: true,
imports: [
CommonModule,
RouterModule,
MatIconModule,
MatSnackBarModule
],
templateUrl: './election-results.component.html',
styleUrls: ['./election-results.component.scss']
})
export class ElectionResultsComponent implements OnInit {
private service = inject(ElectionService);
private route = inject(ActivatedRoute);
private snackBar = inject(MatSnackBar);
electionId!: number;
election: any = null;
results: any[] = [];
loading = true;
declaring = false;
ngOnInit(): void {
this.electionId = +this.route.snapshot.params['electionId'];
this.loadElection();
this.loadResults();
}
loadElection(): void {
this.service.getElection(this.electionId).subscribe((e) => (this.election = e));
}
loadResults(): void {
this.loading = true;
this.service
.getResults(this.electionId)
.pipe(
catchError(() => {
this.loading = false;
return of([]);
})
)
.subscribe((data: any[]) => {
this.results = data;
this.loading = false;
});
}
declareResults(): void {
if (!confirm('Are you sure you want to declare the final results?')) return;
this.declaring = true;
this.service.declareResults(this.electionId).subscribe((data: any[]) => {
this.results = data;
this.declaring = false;
this.snackBar.open('Results declared successfully!', 'Close', { duration: 4000 });
this.loadElection();
});
}
groupByPost(): { postName: string; results: any[] }[] {
const grouped: Record<string, any[]> = {};
for (const r of this.results) {
if (!grouped[r.postName]) grouped[r.postName] = [];
grouped[r.postName].push(r);
}
return Object.entries(grouped).map(
([
postName,
results
]) => ({ postName, results })
);
}
maxVotes(postResults: any[]): number {
return Math.max(...postResults.map((r) => r.voteCount ?? 0), 1);
}
barWidth(voteCount: number, maxVotes: number): number {
return Math.round((voteCount / maxVotes) * 100);
}
get canDeclare(): boolean {
return this.election?.status === 'VOTING_CLOSED' && this.results.length === 0;
}
}
@@ -0,0 +1,858 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="apply-container">
<div class="apply-inner">
<!-- Page Header -->
<div class="page-header">
<a [routerLink]="['/election', electionId, 'nominations']" class="back-btn">
<mat-icon>arrow_back</mat-icon>
</a>
<div class="header-text">
<h2>Nomination Application</h2>
@if (election) {
<p>{{ election.title }}</p>
} @else {
<p>Fill in nominee details and select a post</p>
}
</div>
</div>
<!-- Stepper Card -->
<div class="stepper-card">
<mat-stepper orientation="horizontal" #stepper linear>
<!-- ── Step 1: Personal Profile ── -->
<mat-step [stepControl]="profileForm" label="Personal Profile">
<form [formGroup]="profileForm" class="step-form">
<!-- Member Search -->
<div class="member-search-box">
<div class="section-label"><mat-icon>manage_search</mat-icon> Search Account Holder</div>
<div class="search-row">
<mat-form-field appearance="outline" class="search-field">
<mat-label>Type member name or account number</mat-label>
<input
matInput
[formControl]="memberSearchCtrl"
[matAutocomplete]="memberAuto"
placeholder="e.g. Ramesh Kumar or account no."
/>
<mat-icon matSuffix>search</mat-icon>
<mat-autocomplete
#memberAuto="matAutocomplete"
[displayWith]="displayClientFn"
(optionSelected)="onMemberSelect($event.option.value)"
>
@if (searchLoading) {
<mat-option disabled>
<mat-spinner diameter="18" style="display: inline-block; margin-right: 8px"></mat-spinner>
Searching...
</mat-option>
}
@for (client of filteredClients; track client.id) {
<mat-option [value]="client">
<mat-icon style="font-size: 15px; vertical-align: middle; color: #4a6cf7; margin-right: 4px"
>person</mat-icon
>
<strong>{{ client.displayName }}</strong>
<span class="client-id-badge">ID: {{ client.id }}</span>
@if (client.mobileNo) {
<span class="client-mobile">&nbsp;· {{ client.mobileNo }}</span>
}
</mat-option>
}
@if (
!searchLoading &&
filteredClients.length === 0 &&
memberSearchCtrl.value &&
memberSearchCtrl.value.length >= 2
) {
<mat-option disabled>No members found</mat-option>
}
</mat-autocomplete>
</mat-form-field>
</div>
@if (selectedMemberId) {
<div class="member-selected-badge">
<mat-icon>check_circle</mat-icon>
<span
>Member ID: <strong>{{ selectedMemberId }}</strong></span
>
@if (selectedShareAccountNo) {
<span
>&nbsp;·&nbsp; Share A/C: <strong>{{ selectedShareAccountNo }}</strong></span
>
<span
>&nbsp;·&nbsp; Shares: <strong>{{ selectedShareCount }}</strong></span
>
} @else {
<span style="color: #c53030">&nbsp;·&nbsp; No active share account found</span>
}
<span>&nbsp;·&nbsp; Details auto-filled below</span>
</div>
} @else {
<div class="member-hint">
<mat-icon>info</mat-icon>
Search and select a member to auto-fill their personal details below.
</div>
}
</div>
<!-- Family Members (shown after a member is selected) -->
@if (selectedMemberId) {
<div class="family-section">
<div class="section-label"><mat-icon>group</mat-icon> Family Members</div>
@if (familyLoading) {
<div class="family-loading">
<mat-spinner diameter="18"></mat-spinner>
Loading family members...
</div>
} @else if (familyMembers.length === 0) {
<div class="family-empty">
<mat-icon>info_outline</mat-icon>
No family members on record for this client.
</div>
} @else {
<div class="family-list">
@for (member of familyMembers; track member.id) {
<div
class="family-card"
[class.family-card--linked]="isLinkedClient(member)"
[class.family-card--expanded]="expandedFamilyCards.has(member.id)"
>
<!-- Top row (click to expand) -->
<div class="family-card-top" (click)="toggleFamilyCard(member.id)">
<div class="family-card-left">
<span class="family-name">{{ familyMemberDisplayName(member) }}</span>
@if (member.relationship || member.relationshipId?.name) {
<span class="family-rel">{{ member.relationship || member.relationshipId?.name }}</span>
}
@if (isLinkedClient(member)) {
<span class="family-cbs-id">
<mat-icon>person_pin</mat-icon>
{{ member.linkedClientCif || 'CBS ID: ' + member.linkedClientId }}
</span>
}
@if (member.dateOfBirth) {
<span class="family-dob">
<mat-icon>cake</mat-icon>
{{ parseDob(member.dateOfBirth) | date: 'dd MMM yyyy' }}
</span>
}
</div>
<div class="family-card-right">
@if (isLinkedClient(member)) {
<span class="fm-badge fm-badge--linked">
<mat-icon>person_pin</mat-icon>
Linked Client #{{ member.linkedClientId }}
</span>
} @else {
<span class="fm-badge fm-badge--manual">
<mat-icon>edit_note</mat-icon>
Manual Entry
</span>
}
<mat-icon
class="family-expand-icon"
[class.family-expand-icon--open]="expandedFamilyCards.has(member.id)"
>
expand_more
</mat-icon>
</div>
</div>
<!-- Expanded: detail row + KYC -->
@if (expandedFamilyCards.has(member.id)) {
<!-- Detail row: PAN, Aadhaar, Nationality, Mobile -->
@if (member.panNumber || member.aadhaarNumber || member.nationality || member.mobileNumber) {
<div class="family-detail-row">
@if (member.panNumber) {
<div class="family-detail-item">
<span class="family-detail-label">PAN</span>
<span class="family-detail-value mono">{{ member.panNumber }}</span>
</div>
}
@if (member.aadhaarNumber) {
<div class="family-detail-item">
<span class="family-detail-label">Aadhaar</span>
<span class="family-detail-value mono">{{ member.aadhaarNumber }}</span>
</div>
}
@if (member.nationality) {
<div class="family-detail-item">
<span class="family-detail-label">Nationality</span>
<span class="family-detail-value">{{ member.nationality }}</span>
</div>
}
@if (member.mobileNumber) {
<div class="family-detail-item">
<span class="family-detail-label">Mobile</span>
<span class="family-detail-value mono">{{ member.mobileNumber }}</span>
</div>
}
@if (member.qualification) {
<div class="family-detail-item">
<span class="family-detail-label">Qualification</span>
<span class="family-detail-value">{{ member.qualification }}</span>
</div>
}
</div>
}
<!-- Create as Client (manual entries only) -->
@if (!isLinkedClient(member)) {
<div class="fm-create-client-bar">
<span class="fm-create-client-hint">
<mat-icon>info_outline</mat-icon>
This member is not a CBS client yet.
</span>
<button class="fm-create-client-btn" (click)="createClientFromMember(member)">
<mat-icon>person_add</mat-icon> Create as Client
</button>
</div>
}
<!-- KYC details for linked CBS clients -->
@if (isLinkedClient(member)) {
<div class="fm-kyc-panel">
<div class="fm-kyc-title">
<mat-icon>verified_user</mat-icon>
KYC Details
<span class="fm-kyc-owner-chip">{{ familyMemberDisplayName(member) }}</span>
</div>
@if (familyKycLoading) {
<div class="fm-kyc-loading">
<mat-spinner diameter="14"></mat-spinner> Loading KYC...
</div>
} @else {
@let fmKyc = familyMemberKycMap['c' + member.linkedClientId];
@if (!fmKyc) {
<div class="fm-kyc-empty">
<mat-icon>warning</mat-icon> KYC not submitted for this client.
</div>
} @else {
<div
class="kyc-card"
[class.kyc-card--verified]="fmKyc.kycStatus === 300"
[class.kyc-card--rejected]="fmKyc.kycStatus === 400"
[class.kyc-card--expired]="fmKyc.kycStatus === 500"
>
<div class="kyc-card-header">
<span
class="kyc-overall-badge"
[class]="'kyc-overall-badge--' + kycStatusClass(fmKyc.kycStatus)"
>
<mat-icon>{{
fmKyc.kycStatus === 300
? 'verified'
: fmKyc.kycStatus === 400
? 'cancel'
: fmKyc.kycStatus === 500
? 'schedule'
: 'pending'
}}</mat-icon>
{{ kycStatusLabel(fmKyc.kycStatus) }}
</span>
<span class="kyc-type-badge">{{
fmKyc.kycType === 2 ? 'Non-Individual' : 'Individual'
}}</span>
@if (fmKyc.ckycNumber) {
<span class="ckyc-badge"
><mat-icon>task_alt</mat-icon> CKYC: {{ fmKyc.ckycNumber }}</span
>
}
@if (fmKyc.verifiedOn) {
<span class="kyc-date-chip">
<mat-icon>event_available</mat-icon>
Verified: {{ parseDob(fmKyc.verifiedOn) | date: 'dd MMM yyyy' }}
</span>
}
</div>
<div class="kyc-risk-row">
<div
class="kyc-risk-block"
[class]="'kyc-risk-block--' + riskClass(fmKyc.riskCategory)"
>
<mat-icon>{{
fmKyc.riskCategory === 3
? 'priority_high'
: fmKyc.riskCategory === 2
? 'warning_amber'
: 'shield'
}}</mat-icon>
<div>
<div class="kyc-risk-label">{{ riskLabel(fmKyc.riskCategory) }}</div>
<div class="kyc-risk-sub">{{ riskRekyc(fmKyc.riskCategory) }}</div>
</div>
</div>
@if (fmKyc.expiryDate) {
@let fmExp = kycExpiryInfo(fmKyc.expiryDate);
<div
class="kyc-expiry-block"
[class.kyc-expiry-block--expired]="fmExp.isExpired"
[class.kyc-expiry-block--soon]="fmExp.isExpiringSoon && !fmExp.isExpired"
>
<mat-icon>{{ fmExp.isExpired ? 'event_busy' : 'event' }}</mat-icon>
<div>
<div class="kyc-risk-label">
{{ fmExp.isExpired ? 'KYC Expired' : 'KYC Expires' }}
</div>
<div class="kyc-risk-sub">
{{ fmExp.date | date: 'dd MMM yyyy' }}
@if (!fmExp.isExpired && fmExp.isExpiringSoon) {
&nbsp;({{ fmExp.daysLeft }} days left)
}
@if (fmExp.isExpired) {
&nbsp;({{ fmExp.daysLeft * -1 }} days ago)
}
</div>
</div>
</div>
}
</div>
<div class="kyc-sub-title">
<mat-icon>fingerprint</mat-icon> Identity Verification
</div>
<div class="kyc-grid">
<div class="kyc-item">
<div class="kyc-item-label">PAN Number</div>
<div class="kyc-item-value">{{ fmKyc.panNumber || '—' }}</div>
@if (fmKyc.panFullName) {
<div class="kyc-item-sub">{{ fmKyc.panFullName }}</div>
}
@if (fmKyc.panNumber) {
<span
class="kyc-vbadge"
[class.kyc-vbadge--ok]="fmKyc.panVerified"
[class.kyc-vbadge--no]="!fmKyc.panVerified"
>
<mat-icon>{{ fmKyc.panVerified ? 'check_circle' : 'cancel' }}</mat-icon>
{{ fmKyc.panVerified ? 'Verified' : 'Not Verified' }}
</span>
}
@if (fmKyc.panVerifiedOn) {
<div class="kyc-item-sub">
on {{ parseDob(fmKyc.panVerifiedOn) | date: 'dd MMM yyyy' }}
</div>
}
</div>
<div class="kyc-item">
<div class="kyc-item-label">Aadhaar</div>
<div class="kyc-item-value">{{ fmKyc.aadhaarNumberMasked || '—' }}</div>
@if (fmKyc.aadhaarNumberMasked) {
<span
class="kyc-vbadge"
[class.kyc-vbadge--ok]="fmKyc.aadhaarVerified"
[class.kyc-vbadge--no]="!fmKyc.aadhaarVerified"
>
<mat-icon>{{ fmKyc.aadhaarVerified ? 'check_circle' : 'cancel' }}</mat-icon>
{{ fmKyc.aadhaarVerified ? 'Verified' : 'Not Verified' }}
</span>
}
@if (fmKyc.aadhaarVerifiedOn) {
<div class="kyc-item-sub">
on {{ parseDob(fmKyc.aadhaarVerifiedOn) | date: 'dd MMM yyyy' }}
</div>
}
</div>
<div class="kyc-item">
<div class="kyc-item-label">PAN–Aadhaar Link</div>
@if (fmKyc.panAadhaarSeedingStatus === 'Y') {
<span class="kyc-vbadge kyc-vbadge--ok"
><mat-icon>link</mat-icon> Linked</span
>
} @else if (fmKyc.panAadhaarSeedingStatus === 'N') {
<span class="kyc-vbadge kyc-vbadge--no"
><mat-icon>link_off</mat-icon> Not Linked</span
>
} @else if (fmKyc.panAadhaarSeedingStatus === 'E') {
<span class="kyc-vbadge kyc-vbadge--na"
><mat-icon>block</mat-icon> Exempt</span
>
} @else {
<div class="kyc-item-value">—</div>
}
</div>
@if (fmKyc.kycDocumentName) {
<div class="kyc-item">
<div class="kyc-item-label">KYC Document</div>
<div class="kyc-item-value">{{ fmKyc.kycDocumentName }}</div>
@if (fmKyc.kycDocumentNumber) {
<div class="kyc-item-sub">No. {{ fmKyc.kycDocumentNumber }}</div>
}
@if (fmKyc.kycDocExpiryDate) {
<div class="kyc-item-sub">
Expires: {{ parseDob(fmKyc.kycDocExpiryDate) | date: 'dd MMM yyyy' }}
</div>
}
</div>
}
</div>
</div>
}
}
</div>
}
}
</div>
}
</div>
}
</div>
}
<!-- KYC Status -->
@if (selectedMemberId) {
<div class="kyc-section">
<div class="section-label">
<mat-icon>verified_user</mat-icon>
KYC Details
@if (profileForm.value.applicantName) {
<span class="kyc-owner-chip">{{ profileForm.value.applicantName }}</span>
}
</div>
@if (familyLoading) {
<div class="family-loading"><mat-spinner diameter="18"></mat-spinner> Loading KYC details...</div>
} @else if (!kycData) {
<div class="kyc-not-done">
<mat-icon>warning</mat-icon>
KYC not submitted for this client.
</div>
} @else {
<div
class="kyc-card"
[class.kyc-card--verified]="kycData.kycStatus === 300"
[class.kyc-card--rejected]="kycData.kycStatus === 400"
[class.kyc-card--expired]="kycData.kycStatus === 500"
>
<!-- ── Header row ── -->
<div class="kyc-card-header">
<span
class="kyc-overall-badge"
[class]="'kyc-overall-badge--' + kycStatusClass(kycData.kycStatus)"
>
<mat-icon>{{
kycData.kycStatus === 300
? 'verified'
: kycData.kycStatus === 400
? 'cancel'
: kycData.kycStatus === 500
? 'schedule'
: 'pending'
}}</mat-icon>
{{ kycStatusLabel(kycData.kycStatus) }}
</span>
<span class="kyc-type-badge">
{{ kycData.kycType === 2 ? 'Non-Individual' : 'Individual' }}
</span>
@if (kycData.ckycNumber) {
<span class="ckyc-badge"> <mat-icon>task_alt</mat-icon> CKYC: {{ kycData.ckycNumber }} </span>
}
@if (kycData.verifiedOn) {
<span class="kyc-date-chip">
<mat-icon>event_available</mat-icon>
Verified: {{ parseDob(kycData.verifiedOn) | date: 'dd MMM yyyy' }}
</span>
}
</div>
<!-- ── Risk + Expiry banner ── -->
<div class="kyc-risk-row">
<div class="kyc-risk-block" [class]="'kyc-risk-block--' + riskClass(kycData.riskCategory)">
<mat-icon>{{
kycData.riskCategory === 3
? 'priority_high'
: kycData.riskCategory === 2
? 'warning_amber'
: 'shield'
}}</mat-icon>
<div>
<div class="kyc-risk-label">{{ riskLabel(kycData.riskCategory) }}</div>
<div class="kyc-risk-sub">{{ riskRekyc(kycData.riskCategory) }}</div>
</div>
</div>
@if (kycData.expiryDate) {
@let expInfo = kycExpiryInfo(kycData.expiryDate);
<div
class="kyc-expiry-block"
[class.kyc-expiry-block--expired]="expInfo.isExpired"
[class.kyc-expiry-block--soon]="expInfo.isExpiringSoon && !expInfo.isExpired"
>
<mat-icon>{{ expInfo.isExpired ? 'event_busy' : 'event' }}</mat-icon>
<div>
<div class="kyc-risk-label">
{{ expInfo.isExpired ? 'KYC Expired' : 'KYC Expires' }}
</div>
<div class="kyc-risk-sub">
{{ expInfo.date | date: 'dd MMM yyyy' }}
@if (!expInfo.isExpired && expInfo.isExpiringSoon) {
&nbsp;({{ expInfo.daysLeft }} days left)
}
@if (expInfo.isExpired) {
&nbsp;({{ expInfo.daysLeft * -1 }} days ago)
}
</div>
</div>
</div>
}
</div>
<!-- ── Identity documents grid ── -->
<div class="kyc-sub-title"><mat-icon>fingerprint</mat-icon> Identity Verification</div>
<div class="kyc-grid">
<!-- PAN -->
<div class="kyc-item">
<div class="kyc-item-label">PAN Number</div>
<div class="kyc-item-value">{{ kycData.panNumber || '—' }}</div>
@if (kycData.panFullName) {
<div class="kyc-item-sub">{{ kycData.panFullName }}</div>
}
@if (kycData.panNumber) {
<span
class="kyc-vbadge"
[class.kyc-vbadge--ok]="kycData.panVerified"
[class.kyc-vbadge--no]="!kycData.panVerified"
>
<mat-icon>{{ kycData.panVerified ? 'check_circle' : 'cancel' }}</mat-icon>
{{ kycData.panVerified ? 'Verified' : 'Not Verified' }}
</span>
}
@if (kycData.panVerifiedOn) {
<div class="kyc-item-sub">on {{ parseDob(kycData.panVerifiedOn) | date: 'dd MMM yyyy' }}</div>
}
</div>
<!-- Aadhaar -->
<div class="kyc-item">
<div class="kyc-item-label">Aadhaar</div>
<div class="kyc-item-value">{{ kycData.aadhaarNumberMasked || '—' }}</div>
@if (kycData.aadhaarNumberMasked) {
<span
class="kyc-vbadge"
[class.kyc-vbadge--ok]="kycData.aadhaarVerified"
[class.kyc-vbadge--no]="!kycData.aadhaarVerified"
>
<mat-icon>{{ kycData.aadhaarVerified ? 'check_circle' : 'cancel' }}</mat-icon>
{{ kycData.aadhaarVerified ? 'Verified' : 'Not Verified' }}
</span>
}
@if (kycData.aadhaarVerifiedOn) {
<div class="kyc-item-sub">
on {{ parseDob(kycData.aadhaarVerifiedOn) | date: 'dd MMM yyyy' }}
</div>
}
</div>
<!-- PAN–Aadhaar Link -->
<div class="kyc-item">
<div class="kyc-item-label">PAN–Aadhaar Link</div>
@if (kycData.panAadhaarSeedingStatus === 'Y') {
<span class="kyc-vbadge kyc-vbadge--ok"><mat-icon>link</mat-icon> Linked</span>
} @else if (kycData.panAadhaarSeedingStatus === 'N') {
<span class="kyc-vbadge kyc-vbadge--no"><mat-icon>link_off</mat-icon> Not Linked</span>
} @else if (kycData.panAadhaarSeedingStatus === 'E') {
<span class="kyc-vbadge kyc-vbadge--na"><mat-icon>block</mat-icon> Exempt</span>
} @else {
<div class="kyc-item-value">—</div>
}
</div>
<!-- KYC Document -->
@if (kycData.kycDocumentName) {
<div class="kyc-item">
<div class="kyc-item-label">KYC Document</div>
<div class="kyc-item-value">{{ kycData.kycDocumentName }}</div>
@if (kycData.kycDocumentNumber) {
<div class="kyc-item-sub">No. {{ kycData.kycDocumentNumber }}</div>
}
@if (kycData.kycDocExpiryDate) {
<div class="kyc-item-sub">
Expires: {{ parseDob(kycData.kycDocExpiryDate) | date: 'dd MMM yyyy' }}
</div>
}
</div>
}
<!-- GSTIN (non-individual) -->
@if (kycData.gstinNumber) {
<div class="kyc-item">
<div class="kyc-item-label">GSTIN</div>
<div class="kyc-item-value">{{ kycData.gstinNumber }}</div>
<span
class="kyc-vbadge"
[class.kyc-vbadge--ok]="kycData.gstinVerified"
[class.kyc-vbadge--no]="!kycData.gstinVerified"
>
<mat-icon>{{ kycData.gstinVerified ? 'check_circle' : 'cancel' }}</mat-icon>
{{ kycData.gstinVerified ? 'Verified' : 'Not Verified' }}
</span>
</div>
}
</div>
<!-- ── AML / Compliance flags (only if any set) ── -->
@if (
kycData.amlCheckStatus ||
kycData.bannedEntityMatch ||
kycData.willfulDefaulter ||
kycData.accountStatus
) {
<div class="kyc-sub-title"><mat-icon>gavel</mat-icon> Compliance & Account</div>
<div class="kyc-grid">
@if (kycData.amlCheckStatus) {
<div class="kyc-item">
<div class="kyc-item-label">AML Check</div>
<div class="kyc-item-value">{{ kycData.amlCheckStatus }}</div>
</div>
}
@if (kycData.bannedEntityMatch) {
<div class="kyc-item">
<div class="kyc-item-label">Banned Entity</div>
<div class="kyc-item-value">{{ kycData.bannedEntityMatch }}</div>
</div>
}
@if (kycData.willfulDefaulter) {
<div class="kyc-item">
<div class="kyc-item-label">Willful Defaulter</div>
<div class="kyc-item-value">{{ kycData.willfulDefaulter }}</div>
</div>
}
@if (kycData.accountStatus) {
<div class="kyc-item">
<div class="kyc-item-label">Account Status</div>
<div class="kyc-item-value">{{ kycData.accountStatus }}</div>
@if (kycData.freezeReason) {
<div class="kyc-item-sub">{{ kycData.freezeReason }}</div>
}
</div>
}
</div>
}
</div>
}
</div>
}
<!-- Applicant Profile -->
<div class="section-label"><mat-icon>person</mat-icon> Applicant Profile</div>
<div class="form-grid">
<mat-form-field appearance="outline" class="full-width">
<mat-label>Full Name *</mat-label>
<input matInput formControlName="applicantName" />
<mat-error>Required</mat-error>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Date of Birth *</mat-label>
<input matInput [matDatepicker]="dob" formControlName="dateOfBirth" />
<mat-datepicker-toggle matSuffix [for]="dob"></mat-datepicker-toggle>
<mat-datepicker #dob></mat-datepicker>
<mat-error>Required</mat-error>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Gender *</mat-label>
<mat-select formControlName="gender">
@for (g of genders; track g) {
<mat-option [value]="g">{{ g }}</mat-option>
}
</mat-select>
<mat-error>Required</mat-error>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Mobile Number *</mat-label>
<input matInput formControlName="mobileNumber" maxlength="10" />
<mat-error>Valid 10-digit number required</mat-error>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Email</mat-label>
<input matInput type="email" formControlName="email" />
<mat-error>Invalid email</mat-error>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Occupation</mat-label>
<input matInput formControlName="occupation" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Caste</mat-label>
<input matInput formControlName="caste" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Category</mat-label>
<mat-select formControlName="casteCategory">
@for (c of categories; track c) {
<mat-option [value]="c">{{ c }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Share Count *</mat-label>
<input matInput type="number" formControlName="shareCount" min="1" />
<mat-error>Required — min 1 share</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Address *</mat-label>
<textarea matInput formControlName="address" rows="2"></textarea>
<mat-error>Required</mat-error>
</mat-form-field>
</div>
<div class="step-actions">
<a routerLink="/election" class="btn-ghost"> <mat-icon>close</mat-icon> Cancel </a>
<button class="btn-next" type="button" matStepperNext [disabled]="profileForm.invalid">
Next <mat-icon>arrow_forward</mat-icon>
</button>
</div>
</form>
</mat-step>
<!-- ── Step 2: Post Selection ── -->
<mat-step [stepControl]="postForm" label="Post Selection">
<form [formGroup]="postForm" class="step-form">
<div class="section-label"><mat-icon>how_to_vote</mat-icon> Post &amp; Location</div>
<div class="form-grid">
<mat-form-field appearance="outline" class="full-width">
<mat-label>Select Post *</mat-label>
<mat-select formControlName="postId">
@for (post of posts; track post.id) {
<mat-option [value]="post.id">
{{ post.postName }} &nbsp;({{ post.totalSeats }} seat(s))
</mat-option>
}
</mat-select>
<mat-error>Please select a post</mat-error>
</mat-form-field>
@if (selectedPost()) {
<div class="post-criteria full-width">
<h4>Eligibility for this post</h4>
<div class="criteria-grid">
@if (selectedPost().minAge || selectedPost().maxAge) {
<div class="criteria-item">
<mat-icon>cake</mat-icon>
Age: {{ selectedPost().minAge ?? 'Any' }} – {{ selectedPost().maxAge ?? 'Any' }}
</div>
}
@if (selectedPost().casteCategory) {
<div class="criteria-item">
<mat-icon>group</mat-icon>
Category: {{ selectedPost().casteCategory }}
</div>
}
@if (selectedPost().genderType && selectedPost().genderType !== 'ANY') {
<div class="criteria-item">
<mat-icon>wc</mat-icon>
Gender: {{ selectedPost().genderType }}
</div>
}
@if (selectedPost().minShareHolding > 0) {
<div class="criteria-item">
<mat-icon>account_balance_wallet</mat-icon>
Min Shares: {{ selectedPost().minShareHolding }}
</div>
}
</div>
</div>
}
<mat-form-field appearance="outline" class="full-width">
<mat-label>Location / Branch *</mat-label>
<input matInput formControlName="location" placeholder="Your area or branch" />
<mat-error>Required</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Service Details</mat-label>
<textarea
matInput
formControlName="serviceDetails"
rows="3"
placeholder="Brief description of your service or contributions"
></textarea>
</mat-form-field>
</div>
<div class="step-actions">
<button class="btn-ghost" type="button" matStepperPrevious><mat-icon>arrow_back</mat-icon> Back</button>
<button class="btn-next" type="button" matStepperNext [disabled]="postForm.invalid">
Next <mat-icon>arrow_forward</mat-icon>
</button>
</div>
</form>
</mat-step>
<!-- ── Step 3: Declaration ── -->
<mat-step [stepControl]="declarationForm" label="Declaration">
<form [formGroup]="declarationForm" class="step-form">
<div class="declaration-box">
<div class="decl-header">
<mat-icon>gavel</mat-icon>
<h3>Declaration by Applicant</h3>
</div>
<p>
I, <strong>{{ profileForm.value.applicantName || '______' }}</strong
>, hereby declare that:
</p>
<ul>
<li>
All information provided in this nomination form is true and correct to the best of my knowledge.
</li>
<li>I am a valid share account holder of this cooperative society.</li>
<li>I have no outstanding loans or dues against my account.</li>
<li>I do not have any family members who are currently serving as Directors.</li>
<li>
I meet all the eligibility criteria for the post of
<strong>{{ selectedPost()?.postName || '______' }}</strong
>.
</li>
<li>I understand that providing false information may disqualify my nomination.</li>
</ul>
</div>
<div class="declaration-check">
<mat-checkbox formControlName="declarationAccepted" color="primary">
I accept the above declaration and confirm all information provided is accurate.
</mat-checkbox>
</div>
<div class="step-actions">
<button class="btn-ghost" type="button" matStepperPrevious><mat-icon>arrow_back</mat-icon> Back</button>
<button
class="btn-submit"
type="button"
(click)="submit()"
[disabled]="declarationForm.invalid || submitting"
>
<mat-icon>{{ submitting ? 'hourglass_empty' : 'check_circle' }}</mat-icon>
{{ submitting ? 'Submitting...' : 'Submit Nomination' }}
</button>
</div>
</form>
</mat-step>
</mat-stepper>
</div>
</div>
</div>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,448 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormBuilder, FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
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 { MatCheckboxModule } from '@angular/material/checkbox';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatNativeDateModule } from '@angular/material/core';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { catchError, debounceTime, distinctUntilChanged, forkJoin, of, switchMap } from 'rxjs';
import { ElectionService } from '../../election.service';
import { ClientsService } from '../../../clients/clients.service';
@Component({
selector: 'mifosx-nomination-apply',
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,
RouterModule,
MatStepperModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
MatCheckboxModule,
MatDatepickerModule,
MatNativeDateModule,
MatIconModule,
MatSnackBarModule,
MatAutocompleteModule,
MatProgressSpinnerModule
],
templateUrl: './nomination-apply.component.html',
styleUrls: ['./nomination-apply.component.scss']
})
export class NominationApplyComponent implements OnInit {
private fb = inject(FormBuilder);
private service = inject(ElectionService);
private clientsService = inject(ClientsService);
private router = inject(Router);
private route = inject(ActivatedRoute);
private snackBar = inject(MatSnackBar);
electionId!: number;
election: any = null;
posts: any[] = [];
submitting = false;
memberSearchCtrl = new FormControl('');
filteredClients: any[] = [];
selectedMemberId: number | null = null;
selectedShareAccountNo: string | null = null;
selectedShareCount: number | null = null;
searchLoading = false;
familyMembers: any[] = [];
familyLoading = false;
kycData: any = null;
familyMemberKycMap: Record<string, any> = {};
familyKycLoading = false;
expandedFamilyCards = new Set<number>();
profileForm!: FormGroup;
postForm!: FormGroup;
declarationForm!: FormGroup;
genders = [
'MALE',
'FEMALE',
'OTHER'
];
categories = [
'General',
'SC',
'ST',
'OBC',
'EWS'
];
ngOnInit(): void {
this.electionId = +this.route.snapshot.params['electionId'];
this.buildForms();
this.loadElection();
this.setupMemberSearch();
}
setupMemberSearch(): void {
this.memberSearchCtrl.valueChanges
.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap((val) => {
if (!val || typeof val !== 'string' || val.length < 2) {
this.filteredClients = [];
return of(null);
}
this.searchLoading = true;
return this.clientsService.searchByText(val, 0, 20).pipe(catchError(() => of(null)));
})
)
.subscribe((res: any) => {
this.searchLoading = false;
this.filteredClients = res?.content ?? res?.pageItems ?? [];
});
}
isLinkedClient(member: any): boolean {
return !!member.linkedClientId;
}
toggleFamilyCard(memberId: number): void {
if (this.expandedFamilyCards.has(memberId)) {
this.expandedFamilyCards.delete(memberId);
} else {
this.expandedFamilyCards.add(memberId);
}
}
createClientFromMember(member: any): void {
const prefill = {
firstname: (member.firstName || '').toString().trim(),
mobileNo: member.mobileNumber || '',
dateOfBirth: member.dateOfBirth || null,
panNumber: member.panNumber || '',
aadhaarNumber: member.aadhaarNumber || ''
};
this.router.navigate(
[
'/clients',
'create'
],
{ state: { prefill } }
);
}
familyMemberDisplayName(member: any): string {
if (member.linkedClientName) return member.linkedClientName;
const raw = (member.firstName ?? '').toString().trim();
return raw || '—';
}
parseDob(dob: any): Date | null {
if (!dob) return null;
if (Array.isArray(dob)) return new Date(dob[0], dob[1] - 1, dob[2]);
return new Date(dob);
}
kycStatusLabel(status: number): string {
const map: Record<number, string> = {
100: 'Pending',
200: 'In Progress',
300: 'Verified',
400: 'Rejected',
500: 'Expired'
};
return map[status] ?? 'Unknown';
}
kycStatusClass(status: number): string {
const map: Record<number, string> = {
100: 'pending',
200: 'inprogress',
300: 'verified',
400: 'rejected',
500: 'expired'
};
return map[status] ?? 'pending';
}
riskLabel(risk: number): string {
const map: Record<number, string> = { 1: 'Low Risk', 2: 'Medium Risk', 3: 'High Risk' };
return map[risk] ?? '—';
}
riskRekyc(risk: number): string {
const map: Record<number, string> = {
1: 'Re-KYC every 10 years',
2: 'Re-KYC every 8 years',
3: 'Re-KYC every 2 years'
};
return map[risk] ?? '';
}
riskClass(risk: number): string {
return risk === 1 ? 'low' : risk === 2 ? 'medium' : risk === 3 ? 'high' : '';
}
kycExpiryInfo(expiryDate: any): { date: Date | null; isExpired: boolean; isExpiringSoon: boolean; daysLeft: number } {
const date = this.parseDob(expiryDate);
if (!date) return { date: null, isExpired: false, isExpiringSoon: false, daysLeft: 0 };
const today = new Date();
const daysLeft = Math.floor((date.getTime() - today.getTime()) / 86400000);
return { date, isExpired: daysLeft < 0, isExpiringSoon: daysLeft >= 0 && daysLeft <= 180, daysLeft };
}
onMemberSelect(client: any): void {
this.selectedMemberId = client.id;
this.memberSearchCtrl.setValue(client.displayName, { emitEvent: false });
this.filteredClients = [];
this.familyMembers = [];
this.kycData = null;
this.familyLoading = true;
this.familyMemberKycMap = {};
this.familyKycLoading = false;
this.expandedFamilyCards = new Set<number>();
forkJoin({
detail: this.clientsService.getClientData(client.id.toString()),
accounts: this.clientsService.getClientAccountData(client.id.toString()),
address: this.clientsService.getClientAddressData(client.id.toString()).pipe(catchError(() => of([]))),
family: this.clientsService.getClientFamilyMembers(client.id.toString()).pipe(catchError(() => of([]))),
kyc: this.clientsService.getClientKyc(client.id.toString()).pipe(catchError(() => of(null)))
}).subscribe(({ detail, accounts, address, family, kyc }: any) => {
// --- DOB: Fineract returns [yyyy, mm, dd] array ---
const dob = Array.isArray(detail.dateOfBirth)
? new Date(detail.dateOfBirth[0], detail.dateOfBirth[1] - 1, detail.dateOfBirth[2])
: detail.dateOfBirth
? new Date(detail.dateOfBirth)
: null;
// --- Gender: Fineract returns { id, name: "MALE" } ---
const genderRaw = (detail.gender?.name ?? detail.gender?.value ?? '').toString().toUpperCase();
const gender = [
'MALE',
'FEMALE',
'OTHER'
].includes(genderRaw) ? genderRaw : '';
// --- Occupation: Fineract returns { id, name: "Private Sector Employee" } ---
const occupation = detail.occupation?.name ?? detail.occupation ?? '';
// --- Address from /clients/{id}/addresses ---
const addrList: any[] = Array.isArray(address) ? address : (address?.content ?? []);
const addr = addrList[0];
const addressStr = addr
? [
addr.addressLine1,
addr.addressLine2,
addr.city,
addr.stateName
]
.filter(Boolean)
.join(', ')
: '';
// --- Share count from active share account ---
const shareAccounts: any[] = accounts?.shareAccounts ?? [];
const activeShare = shareAccounts.find((s: any) => s.status?.id === 300 || s.status?.value === 'Active');
const shareCount = activeShare?.totalApprovedShares ?? null;
const shareAccountNo = activeShare?.accountNo ?? null;
this.profileForm.patchValue({
applicantName: detail.displayName ?? '',
dateOfBirth: dob,
gender,
mobileNumber: detail.mobileNo ?? '',
email: detail.emailAddress ?? '',
address: addressStr,
occupation,
shareCount
});
this.selectedShareAccountNo = shareAccountNo;
this.selectedShareCount = shareCount;
this.familyLoading = false;
this.familyMembers = Array.isArray(family) ? family : [];
this.kycData = kyc;
const linkedMembers = this.familyMembers.filter((m) => !!m.linkedClientId);
if (linkedMembers.length > 0) {
this.familyKycLoading = true;
const kycCalls: { [k: string]: any } = {};
linkedMembers.forEach((m) => {
kycCalls['c' + m.linkedClientId] = this.clientsService
.getClientKyc(m.linkedClientId.toString())
.pipe(catchError(() => of(null)));
});
forkJoin(kycCalls).subscribe((kycMap: any) => {
this.familyKycLoading = false;
this.familyMemberKycMap = kycMap;
});
}
});
}
displayClientFn(client: any): string {
return client?.displayName ?? '';
}
buildForms(): void {
this.profileForm = this.fb.group({
applicantName: [
'',
Validators.required
],
dateOfBirth: [
null,
Validators.required
],
gender: [
'',
Validators.required
],
caste: [''],
casteCategory: [''],
mobileNumber: [
'',
[
Validators.required,
Validators.pattern(/^[0-9]{10}$/)
]
],
email: [
'',
[Validators.email]
],
address: [
'',
Validators.required
],
occupation: [''],
shareCount: [
null,
[
Validators.required,
Validators.min(1)
]
]
});
this.postForm = this.fb.group({
postId: [
null,
Validators.required
],
location: [
'',
Validators.required
],
serviceDetails: ['']
});
this.declarationForm = this.fb.group({
declarationAccepted: [
false,
Validators.requiredTrue
]
});
}
loadElection(): void {
this.service.getElection(this.electionId).subscribe((election: any) => {
this.election = election;
this.posts = election.posts ?? [];
});
}
submit(): void {
if (!this.selectedMemberId) {
this.snackBar.open('Please search and select a member first', 'Close', { duration: 3000 });
return;
}
if (this.profileForm.invalid || this.postForm.invalid || this.declarationForm.invalid) {
this.profileForm.markAllAsTouched();
this.postForm.markAllAsTouched();
this.declarationForm.markAllAsTouched();
return;
}
this.submitting = true;
const bv = this.profileForm.value;
const payload = {
...bv,
dateOfBirth: this.toDateString(bv.dateOfBirth),
...this.postForm.value,
electionId: this.electionId,
declarationAccepted: this.declarationForm.value.declarationAccepted,
memberId: this.selectedMemberId
};
this.service
.submitNomination(this.electionId, payload)
.pipe(
catchError((err) => {
const code = err?.error?.errors?.[0]?.userMessageGlobalisationCode ?? '';
if (code === 'error.msg.election.nomination.already.exists') {
this.snackBar
.open('A nomination already exists for this member and post.', 'View', { duration: 6000 })
.onAction()
.subscribe(() =>
this.router.navigate([
'/election',
this.electionId,
'nominations'
])
);
this.router.navigate([
'/election',
this.electionId,
'nominations'
]);
} else {
const msg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Failed to submit nomination';
this.snackBar.open(msg, 'Close', { duration: 5000 });
}
this.submitting = false;
return of(null);
})
)
.subscribe((res: any) => {
if (!res) return;
this.snackBar.open('Nomination submitted successfully! Application: ' + res.resourceId, 'Close', {
duration: 5000
});
this.router.navigate([
'/election',
this.electionId,
'nominations'
]);
});
}
selectedPost(): any {
return this.posts.find((p) => p.id === this.postForm.value.postId);
}
private toDateString(d: Date | null): string | null {
if (!d) return null;
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
}
@@ -0,0 +1,270 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="nomination-detail">
<div class="page-header">
<a [routerLink]="['/election', electionId, 'nominations']" class="back-link">
<mat-icon>arrow_back</mat-icon>
</a>
<div class="header-info">
<h2>Nomination Detail</h2>
<span class="app-number">{{ nomination?.applicationNumber }}</span>
</div>
@if (nomination) {
<span class="status-chip" [ngClass]="statusClass(nomination.status)">
{{ statusLabel(nomination.status) }}
</span>
}
</div>
@if (loading) {
<div class="loading-state">
<div class="spinner"></div>
<p>Loading...</p>
</div>
} @else if (!nomination) {
<div class="empty-state">
<mat-icon>error_outline</mat-icon>
<p>Nomination not found.</p>
</div>
} @else {
<!-- Action Bar -->
<div class="action-bar">
@if (canRunEligibility) {
<button class="btn-action btn-check" (click)="runEligibilityCheck()" [disabled]="actionLoading">
<mat-icon>fact_check</mat-icon> Run Eligibility Check
</button>
}
@if (canRunCeoCheck) {
<button class="btn-action btn-check" (click)="runCeoPreCheck()" [disabled]="actionLoading">
<mat-icon>admin_panel_settings</mat-icon> CEO Pre-Check
</button>
}
@if (canGenerateNDC) {
<button class="btn-action btn-ndc" (click)="generateNDC()"><mat-icon>verified</mat-icon> Generate NDC</button>
}
@if (canApproveStep) {
<button class="btn-action btn-approve" (click)="approveStep('APPROVE')">
<mat-icon>thumb_up</mat-icon> Approve Step
</button>
<button class="btn-action btn-reject" (click)="approveStep('REJECT')">
<mat-icon>thumb_down</mat-icon> Reject
</button>
}
@if (canApproveCandidate) {
<button class="btn-action btn-candidate" (click)="approveCandidate()">
<mat-icon>how_to_reg</mat-icon> Add to Candidates
</button>
}
</div>
<!-- Profile Section -->
<div class="detail-card">
<div class="card-title"><mat-icon>person</mat-icon> Applicant Profile</div>
<div class="detail-grid">
<div class="detail-item">
<span class="label">Name</span><span class="value">{{ nomination.applicantName }}</span>
</div>
<div class="detail-item">
<span class="label">Date of Birth</span
><span class="value">{{ nomination.dateOfBirth | date: 'dd MMM yyyy' }} (Age: {{ nomination.age }})</span>
</div>
<div class="detail-item">
<span class="label">Gender</span><span class="value">{{ nomination.gender }}</span>
</div>
<div class="detail-item">
<span class="label">Caste / Category</span
><span class="value">{{ nomination.caste }} / {{ nomination.casteCategory }}</span>
</div>
<div class="detail-item">
<span class="label">Mobile</span><span class="value">{{ nomination.mobileNumber }}</span>
</div>
<div class="detail-item">
<span class="label">Email</span><span class="value">{{ nomination.email ?? '–' }}</span>
</div>
<div class="detail-item">
<span class="label">Occupation</span><span class="value">{{ nomination.occupation ?? '–' }}</span>
</div>
<div class="detail-item">
<span class="label">Share Count</span><span class="value">{{ nomination.shareCount }}</span>
</div>
<div class="detail-item full-span">
<span class="label">Address</span><span class="value">{{ nomination.address }}</span>
</div>
<div class="detail-item">
<span class="label">Location</span><span class="value">{{ nomination.location }}</span>
</div>
<div class="detail-item">
<span class="label">Service Details</span><span class="value">{{ nomination.serviceDetails ?? '–' }}</span>
</div>
<div class="detail-item">
<span class="label">Post Applied</span><span class="value">{{ nomination.postName }}</span>
</div>
</div>
</div>
<!-- Eligibility Check -->
@if (nomination.eligibilityCheck) {
<div class="detail-card">
<div class="card-title">
<mat-icon>fact_check</mat-icon> Eligibility Check
<span class="result-badge" [ngClass]="checkResultClass(nomination.eligibilityCheck.overallResult)">
{{ nomination.eligibilityCheck.overallResult }}
</span>
</div>
<div class="check-grid">
<div class="check-item" [ngClass]="nomination.eligibilityCheck.ageEligible ? 'check--pass' : 'check--fail'">
<mat-icon>{{ nomination.eligibilityCheck.ageEligible ? 'check_circle' : 'cancel' }}</mat-icon>
<div>
<span class="check-label">Age Check</span>
<span class="check-remark">{{ nomination.eligibilityCheck.ageCheckRemarks }}</span>
</div>
</div>
<div
class="check-item"
[ngClass]="nomination.eligibilityCheck.genderEligible ? 'check--pass' : 'check--fail'"
>
<mat-icon>{{ nomination.eligibilityCheck.genderEligible ? 'check_circle' : 'cancel' }}</mat-icon>
<div>
<span class="check-label">Gender Check</span>
<span class="check-remark">{{ nomination.eligibilityCheck.genderCheckRemarks }}</span>
</div>
</div>
<div class="check-item" [ngClass]="nomination.eligibilityCheck.casteEligible ? 'check--pass' : 'check--fail'">
<mat-icon>{{ nomination.eligibilityCheck.casteEligible ? 'check_circle' : 'cancel' }}</mat-icon>
<div>
<span class="check-label">Caste Check</span>
<span class="check-remark">{{ nomination.eligibilityCheck.casteCheckRemarks }}</span>
</div>
</div>
<div
class="check-item"
[ngClass]="nomination.eligibilityCheck.shareHoldingEligible ? 'check--pass' : 'check--fail'"
>
<mat-icon>{{ nomination.eligibilityCheck.shareHoldingEligible ? 'check_circle' : 'cancel' }}</mat-icon>
<div>
<span class="check-label">Share Holding Check</span>
<span class="check-remark">{{ nomination.eligibilityCheck.shareCheckRemarks }}</span>
</div>
</div>
<div class="check-item" [ngClass]="nomination.eligibilityCheck.areaEligible ? 'check--pass' : 'check--fail'">
<mat-icon>{{ nomination.eligibilityCheck.areaEligible ? 'check_circle' : 'cancel' }}</mat-icon>
<div>
<span class="check-label">Area Check</span>
<span class="check-remark">{{ nomination.eligibilityCheck.areaCheckRemarks }}</span>
</div>
</div>
</div>
</div>
}
<!-- CEO Pre-Check -->
@if (nomination.ceoPreCheck) {
<div class="detail-card">
<div class="card-title">
<mat-icon>admin_panel_settings</mat-icon> CEO Pre-Check
<span class="result-badge" [ngClass]="checkResultClass(nomination.ceoPreCheck.overallResult)">
{{ nomination.ceoPreCheck.overallResult }}
</span>
</div>
<div class="check-grid">
<div
class="check-item"
[ngClass]="nomination.ceoPreCheck.directorLoanCheckResult === 'PASS' ? 'check--pass' : 'check--fail'"
>
<mat-icon>{{
nomination.ceoPreCheck.directorLoanCheckResult === 'PASS' ? 'check_circle' : 'cancel'
}}</mat-icon>
<div>
<span class="check-label">Director Loan Check</span>
<span class="check-remark"
>Outstanding: ₹{{ nomination.ceoPreCheck.directorLoanOutstanding | number: '1.2-2' }}</span
>
<span class="check-remark">{{ nomination.ceoPreCheck.directorLoanRemarks }}</span>
</div>
</div>
<div
class="check-item"
[ngClass]="nomination.ceoPreCheck.relationAcCheckResult === 'PASS' ? 'check--pass' : 'check--fail'"
>
<mat-icon>{{
nomination.ceoPreCheck.relationAcCheckResult === 'PASS' ? 'check_circle' : 'cancel'
}}</mat-icon>
<div>
<span class="check-label">Relation Account Check</span>
<span class="check-remark">{{ nomination.ceoPreCheck.relationAcRemarks }}</span>
</div>
</div>
<div
class="check-item"
[ngClass]="nomination.ceoPreCheck.noDueCheckResult === 'PASS' ? 'check--pass' : 'check--fail'"
>
<mat-icon>{{ nomination.ceoPreCheck.noDueCheckResult === 'PASS' ? 'check_circle' : 'pending' }}</mat-icon>
<div>
<span class="check-label">No Due Certificate</span>
@if (nomination.ceoPreCheck.noDueCertificateNumber) {
<span class="ndc-number">{{ nomination.ceoPreCheck.noDueCertificateNumber }}</span>
<span class="check-remark"
>Generated: {{ nomination.ceoPreCheck.noDueGeneratedDate | date: 'dd MMM yyyy HH:mm' }}</span
>
} @else {
<span class="check-remark">Not yet generated</span>
}
</div>
</div>
</div>
</div>
}
<!-- Approval Steps -->
@if (nomination.approvalSteps?.length > 0) {
<div class="detail-card">
<div class="card-title"><mat-icon>approval</mat-icon> Approval Workflow</div>
<div class="steps-timeline">
@for (step of nomination.approvalSteps; track step.id) {
<div class="timeline-step" [ngClass]="stepStatusClass(step.status)">
<div class="step-connector"></div>
<div class="step-dot">
@if (step.status === 'APPROVED') {
<mat-icon>check</mat-icon>
} @else if (step.status === 'REJECTED') {
<mat-icon>close</mat-icon>
} @else {
<span>{{ step.stepNumber }}</span>
}
</div>
<div class="step-info">
<span class="step-name">{{ step.stepName }}</span>
<span class="step-role">{{ step.approverRole }}</span>
@if (step.actionDate) {
<span class="step-date">{{ step.actionDate | date: 'dd MMM yyyy HH:mm' }}</span>
}
@if (step.remarks) {
<span class="step-remarks">{{ step.remarks }}</span>
}
@if (step.familyDetailsVerified) {
<span class="family-verified"><mat-icon>family_restroom</mat-icon> Family details verified</span>
}
</div>
</div>
}
</div>
</div>
}
@if (nomination.rejectionReason) {
<div class="rejection-banner">
<mat-icon>error_outline</mat-icon>
<div>
<strong>Rejection Reason</strong>
<p>{{ nomination.rejectionReason }}</p>
</div>
</div>
}
}
</div>
@@ -0,0 +1,480 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.nomination-detail {
padding: 24px;
max-width: 1100px;
margin: 0 auto;
}
.page-header {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 24px;
.back-link {
color: #718096;
text-decoration: none;
display: flex;
}
.header-info h2 {
font-size: 22px;
font-weight: 600;
margin: 0 0 4px;
}
.app-number {
font-size: 13px;
color: #4a6cf7;
font-family: monospace;
}
.status-chip {
margin-left: auto;
padding: 6px 14px;
border-radius: 20px;
font-size: 13px;
font-weight: 600;
}
}
.action-bar {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 24px;
padding: 16px;
background: #f8fafc;
border-radius: 10px;
border: 1px solid #e2e8f0;
}
.btn-action {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
border: none;
cursor: pointer;
transition: background 0.15s;
mat-icon {
font-size: 18px;
height: 18px;
width: 18px;
}
&.btn-check {
background: #bee3f8;
color: #2a69ac;
&:hover {
background: #90cdf4;
}
}
&.btn-ndc {
background: #c6f6d5;
color: #276749;
&:hover {
background: #9ae6b4;
}
}
&.btn-approve {
background: #c6f6d5;
color: #276749;
&:hover {
background: #9ae6b4;
}
}
&.btn-reject {
background: #fed7d7;
color: #9b2c2c;
&:hover {
background: #feb2b2;
}
}
&.btn-candidate {
background: #4a6cf7;
color: #fff;
&:hover {
background: #3a5ce7;
}
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.detail-card {
background: #fff;
border-radius: 12px;
box-shadow: 0 1px 4px rgb(0 0 0 / 8%);
margin-bottom: 20px;
overflow: hidden;
.card-title {
display: flex;
align-items: center;
gap: 8px;
padding: 16px 20px;
font-size: 15px;
font-weight: 600;
color: #2d3748;
background: #f8fafc;
border-bottom: 1px solid #f0f0f0;
mat-icon {
color: #4a6cf7;
}
}
}
.detail-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0;
padding: 8px 0;
.detail-item {
display: flex;
flex-direction: column;
gap: 3px;
padding: 12px 20px;
border-bottom: 1px solid #fafafa;
&.full-span {
grid-column: 1 / -1;
}
.label {
font-size: 11px;
color: #a0aec0;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.value {
font-size: 14px;
color: #2d3748;
font-weight: 500;
}
}
}
.check-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
padding: 16px 20px;
}
.check-item {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px;
border-radius: 8px;
mat-icon {
font-size: 20px;
height: 20px;
width: 20px;
flex-shrink: 0;
margin-top: 2px;
}
.check-label {
display: block;
font-size: 13px;
font-weight: 600;
}
.check-remark {
display: block;
font-size: 12px;
margin-top: 2px;
}
.ndc-number {
display: block;
font-size: 13px;
font-weight: 600;
font-family: monospace;
}
&.check--pass {
background: #f0fff4;
mat-icon {
color: #38a169;
}
.check-label {
color: #276749;
}
.check-remark {
color: #48bb78;
}
}
&.check--fail {
background: #fff5f5;
mat-icon {
color: #e53e3e;
}
.check-label {
color: #9b2c2c;
}
.check-remark {
color: #fc8181;
}
}
}
.result-badge {
margin-left: auto;
padding: 3px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 700;
&.result--pass {
background: #c6f6d5;
color: #276749;
}
&.result--fail {
background: #fed7d7;
color: #9b2c2c;
}
&.result--pending {
background: #fefcbf;
color: #744210;
}
}
.steps-timeline {
padding: 20px;
display: flex;
flex-direction: column;
gap: 12px;
}
.timeline-step {
display: flex;
align-items: flex-start;
gap: 14px;
position: relative;
.step-dot {
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 14px;
font-weight: 600;
mat-icon {
font-size: 18px;
height: 18px;
width: 18px;
}
}
.step-info {
display: flex;
flex-direction: column;
gap: 3px;
padding-top: 6px;
.step-name {
font-size: 14px;
font-weight: 600;
}
.step-role {
font-size: 12px;
color: #a0aec0;
}
.step-date {
font-size: 12px;
color: #718096;
}
.step-remarks {
font-size: 12px;
color: #4a5568;
font-style: italic;
}
.family-verified {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #276749;
background: #f0fff4;
padding: 2px 8px;
border-radius: 10px;
width: fit-content;
mat-icon {
font-size: 14px;
height: 14px;
width: 14px;
}
}
}
&.step--approved .step-dot {
background: #c6f6d5;
color: #276749;
}
&.step--rejected .step-dot {
background: #fed7d7;
color: #9b2c2c;
}
&.step--pending .step-dot {
background: #edf2f7;
color: #a0aec0;
}
}
.rejection-banner {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 16px 20px;
background: #fff5f5;
border: 1px solid #fed7d7;
border-radius: 10px;
margin-top: 16px;
mat-icon {
color: #e53e3e;
margin-top: 2px;
}
strong {
display: block;
font-size: 14px;
color: #9b2c2c;
margin-bottom: 4px;
}
p {
margin: 0;
font-size: 14px;
color: #4a5568;
}
}
.status-chip {
&.chip--submitted {
background: #bee3f8;
color: #2a69ac;
}
&.chip--review {
background: #fefcbf;
color: #744210;
}
&.chip--ceo {
background: #e2d9f3;
color: #6b46c1;
}
&.chip--ceo-ok {
background: #c6f6d5;
color: #276749;
}
&.chip--pending {
background: #fed7a0;
color: #7b341e;
}
&.chip--approved {
background: #c6f6d5;
color: #276749;
}
&.chip--rejected {
background: #fed7d7;
color: #9b2c2c;
}
&.chip--withdrawn {
background: #edf2f7;
color: #a0aec0;
}
&.chip--draft {
background: #edf2f7;
color: #718096;
}
}
.loading-state,
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 60px;
color: #a0aec0;
mat-icon {
font-size: 48px;
height: 48px;
width: 48px;
margin-bottom: 12px;
}
}
.spinner {
width: 36px;
height: 36px;
border: 4px solid #e2e8f0;
border-top-color: #4a6cf7;
border-radius: 50%;
animation: spin 0.7s linear infinite;
margin-bottom: 12px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@@ -0,0 +1,183 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule, ActivatedRoute } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { catchError, of } from 'rxjs';
import { ElectionService } from '../../election.service';
@Component({
selector: 'mifosx-nomination-detail',
standalone: true,
imports: [
CommonModule,
RouterModule,
MatIconModule,
MatSnackBarModule
],
templateUrl: './nomination-detail.component.html',
styleUrls: ['./nomination-detail.component.scss']
})
export class NominationDetailComponent implements OnInit {
private service = inject(ElectionService);
private route = inject(ActivatedRoute);
private snackBar = inject(MatSnackBar);
electionId!: number;
nominationId!: number;
nomination: any = null;
loading = true;
actionLoading = false;
readonly statusConfig: Record<string, { label: string; cssClass: string }> = {
SUBMITTED: { label: 'Submitted', cssClass: 'chip--submitted' },
UNDER_REVIEW: { label: 'Under Review', cssClass: 'chip--review' },
CEO_CHECK_PENDING: { label: 'CEO Check Pending', cssClass: 'chip--ceo' },
CEO_APPROVED: { label: 'CEO Approved', cssClass: 'chip--ceo-ok' },
CEO_REJECTED: { label: 'CEO Rejected', cssClass: 'chip--rejected' },
APPROVAL_PENDING: { label: 'Approval Pending', cssClass: 'chip--pending' },
APPROVED: { label: 'Approved', cssClass: 'chip--approved' },
REJECTED: { label: 'Rejected', cssClass: 'chip--rejected' },
WITHDRAWN: { label: 'Withdrawn', cssClass: 'chip--withdrawn' },
DRAFT: { label: 'Draft', cssClass: 'chip--draft' }
};
ngOnInit(): void {
this.electionId = +this.route.snapshot.params['electionId'];
this.nominationId = +this.route.snapshot.params['nominationId'];
this.load();
}
load(): void {
this.loading = true;
this.service
.getNomination(this.electionId, this.nominationId)
.pipe(
catchError(() => {
this.loading = false;
return of(null);
})
)
.subscribe((data: any) => {
this.nomination = data;
this.loading = false;
});
}
runEligibilityCheck(): void {
this.actionLoading = true;
this.service
.runEligibilityCheck(this.electionId, this.nominationId)
.pipe(
catchError((err) => {
this.snackBar.open('Eligibility check failed', 'Close', { duration: 3000 });
this.actionLoading = false;
return of(null);
})
)
.subscribe((res: any) => {
if (!res) return;
this.nomination = res;
this.actionLoading = false;
this.snackBar.open('Eligibility check completed', 'Close', { duration: 3000 });
});
}
runCeoPreCheck(): void {
this.actionLoading = true;
this.service
.runCeoPreCheck(this.electionId, this.nominationId)
.pipe(
catchError((err) => {
this.snackBar.open('CEO pre-check failed', 'Close', { duration: 3000 });
this.actionLoading = false;
return of(null);
})
)
.subscribe((res: any) => {
if (!res) return;
this.nomination = res;
this.actionLoading = false;
this.snackBar.open('CEO pre-check completed', 'Close', { duration: 3000 });
});
}
generateNDC(): void {
this.service.generateNoDueCertificate(this.electionId, this.nominationId).subscribe((res: any) => {
this.snackBar.open('NDC Generated: ' + res.certificateNumber, 'Close', { duration: 5000 });
this.load();
});
}
approveStep(action: string): void {
this.service
.processApprovalStep(this.electionId, this.nominationId, {
action,
remarks: 'Approved',
familyDetailsVerified: action === 'APPROVE'
})
.subscribe(() => {
this.load();
this.snackBar.open('Action completed', 'Close', { duration: 3000 });
});
}
approveCandidate(): void {
this.service.approveCandidate(this.electionId, this.nominationId).subscribe(() => {
this.load();
this.snackBar.open('Candidate approved!', 'Close', { duration: 3000 });
});
}
statusLabel(s: string): string {
return this.statusConfig[s]?.label ?? s;
}
statusClass(s: string): string {
return this.statusConfig[s]?.cssClass ?? '';
}
checkResultClass(r: string): string {
return r === 'PASS' ? 'result--pass' : r === 'FAIL' ? 'result--fail' : 'result--pending';
}
stepStatusClass(s: string): string {
return s === 'APPROVED' ? 'step--approved' : s === 'REJECTED' ? 'step--rejected' : 'step--pending';
}
get canRunEligibility(): boolean {
return [
'SUBMITTED',
'UNDER_REVIEW'
].includes(this.nomination?.status);
}
get canRunCeoCheck(): boolean {
return [
'UNDER_REVIEW',
'CEO_CHECK_PENDING'
].includes(this.nomination?.status);
}
get canGenerateNDC(): boolean {
return this.nomination?.status === 'CEO_CHECK_PENDING' && !this.nomination?.ceoPreCheck?.noDueCertificateNumber;
}
get canApproveStep(): boolean {
return [
'CEO_APPROVED',
'APPROVAL_PENDING'
].includes(this.nomination?.status);
}
get canApproveCandidate(): boolean {
return this.nomination?.status === 'APPROVED';
}
}
@@ -0,0 +1,94 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="nomination-list">
<div class="page-header">
<a routerLink="/election" class="back-link"><mat-icon>arrow_back</mat-icon></a>
<div>
<h2>Nominations</h2>
@if (election) {
<p class="sub-title">{{ election.title }}</p>
}
</div>
<a [routerLink]="['/election', electionId, 'nomination', 'apply']" class="btn-primary">
<mat-icon>add</mat-icon> Apply
</a>
</div>
<div class="summary-row">
<div class="summary-chip total">Total: {{ summary.total }}</div>
<div class="summary-chip approved">Approved: {{ summary.approved }}</div>
<div class="summary-chip pending">Pending: {{ summary.pending }}</div>
<div class="summary-chip rejected">Rejected: {{ summary.rejected }}</div>
</div>
<div class="filter-row">
<label>Status:</label>
<select [(ngModel)]="filterStatus" (change)="load()">
@for (opt of statusOptions; track opt.value) {
<option [value]="opt.value">{{ opt.label }}</option>
}
</select>
</div>
@if (loading) {
<div class="loading-state">
<div class="spinner"></div>
<p>Loading...</p>
</div>
} @else if (nominations.length === 0) {
<div class="empty-state">
<mat-icon>inbox</mat-icon>
<p>No nominations found.</p>
</div>
} @else {
<div class="nominations-table">
<table>
<thead>
<tr>
<th>App No.</th>
<th>Applicant</th>
<th>Post</th>
<th>Gender</th>
<th>Category</th>
<th>Submitted</th>
<th>Status</th>
<th>Action</th>
</tr>
</thead>
<tbody>
@for (nom of nominations; track nom.id) {
<tr>
<td class="app-no">{{ nom.applicationNumber ?? '–' }}</td>
<td>
<div class="name-cell">
<span class="name">{{ nom.applicantName }}</span>
@if (nom.age) {
<span class="age-badge">Age: {{ nom.age }}</span>
}
</div>
</td>
<td>{{ nom.postName }}</td>
<td>{{ nom.gender ?? '–' }}</td>
<td>{{ nom.casteCategory ?? '–' }}</td>
<td>{{ nom.submittedDate | date: 'dd MMM yyyy' }}</td>
<td>
<span class="status-chip" [ngClass]="statusClass(nom.status)">
{{ statusLabel(nom.status) }}
</span>
</td>
<td>
<a [routerLink]="['/election', electionId, 'nomination', nom.id]" class="btn-view">View</a>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
@@ -0,0 +1,281 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.nomination-list {
padding: 24px;
max-width: 1300px;
margin: 0 auto;
}
.page-header {
display: flex;
align-items: flex-start;
gap: 12px;
margin-bottom: 24px;
.back-link {
color: #718096;
text-decoration: none;
display: flex;
align-items: center;
margin-top: 4px;
}
h2 {
font-size: 22px;
font-weight: 600;
margin: 0 0 4px;
}
.sub-title {
font-size: 14px;
color: #718096;
margin: 0;
}
.btn-primary {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 4px;
background: #4a6cf7;
color: #fff;
padding: 8px 18px;
border-radius: 8px;
text-decoration: none;
font-size: 14px;
font-weight: 500;
mat-icon {
font-size: 18px;
}
}
}
.summary-row {
display: flex;
gap: 12px;
margin-bottom: 20px;
.summary-chip {
padding: 6px 16px;
border-radius: 20px;
font-size: 13px;
font-weight: 600;
&.total {
background: #edf2f7;
color: #4a5568;
}
&.approved {
background: #c6f6d5;
color: #276749;
}
&.pending {
background: #fefcbf;
color: #744210;
}
&.rejected {
background: #fed7d7;
color: #9b2c2c;
}
}
}
.filter-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 20px;
label {
font-size: 14px;
color: #718096;
}
select {
padding: 7px 12px;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 14px;
color: #4a5568;
outline: none;
cursor: pointer;
&:focus {
border-color: #4a6cf7;
}
}
}
.nominations-table {
overflow-x: auto;
border-radius: 12px;
box-shadow: 0 1px 4px rgb(0 0 0 / 8%);
table {
width: 100%;
border-collapse: collapse;
background: #fff;
th {
background: #f8fafc;
padding: 12px 16px;
font-size: 12px;
font-weight: 600;
color: #a0aec0;
text-transform: uppercase;
letter-spacing: 0.5px;
text-align: left;
}
td {
padding: 14px 16px;
font-size: 14px;
color: #4a5568;
border-bottom: 1px solid #f7fafc;
}
tr:last-child td {
border-bottom: none;
}
tr:hover td {
background: #fafbff;
}
}
}
.app-no {
font-family: monospace;
font-size: 13px;
color: #4a6cf7;
}
.name-cell {
display: flex;
flex-direction: column;
gap: 2px;
.name {
font-weight: 500;
color: #2d3748;
}
.age-badge {
font-size: 11px;
background: #edf2f7;
color: #718096;
padding: 1px 6px;
border-radius: 10px;
width: fit-content;
}
}
.status-chip {
padding: 3px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
&.chip--draft {
background: #edf2f7;
color: #718096;
}
&.chip--submitted {
background: #bee3f8;
color: #2a69ac;
}
&.chip--review {
background: #fefcbf;
color: #744210;
}
&.chip--ceo {
background: #e2d9f3;
color: #6b46c1;
}
&.chip--ceo-ok {
background: #c6f6d5;
color: #276749;
}
&.chip--pending {
background: #fed7a0;
color: #7b341e;
}
&.chip--approved {
background: #c6f6d5;
color: #276749;
}
&.chip--rejected {
background: #fed7d7;
color: #9b2c2c;
}
&.chip--withdrawn {
background: #edf2f7;
color: #a0aec0;
}
}
.btn-view {
display: inline-block;
padding: 5px 14px;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
background: #f0f4ff;
color: #4a6cf7;
text-decoration: none;
&:hover {
background: #e0e7ff;
}
}
.loading-state,
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
color: #a0aec0;
mat-icon {
font-size: 48px;
height: 48px;
width: 48px;
margin-bottom: 12px;
}
}
.spinner {
width: 36px;
height: 36px;
border: 4px solid #e2e8f0;
border-top-color: #4a6cf7;
border-radius: 50%;
animation: spin 0.7s linear infinite;
margin-bottom: 12px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@@ -0,0 +1,117 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule, ActivatedRoute } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { catchError, of } from 'rxjs';
import { ElectionService } from '../../election.service';
@Component({
selector: 'mifosx-nomination-list',
standalone: true,
imports: [
CommonModule,
RouterModule,
FormsModule,
MatIconModule,
MatSnackBarModule
],
templateUrl: './nomination-list.component.html',
styleUrls: ['./nomination-list.component.scss']
})
export class NominationListComponent implements OnInit {
private service = inject(ElectionService);
private route = inject(ActivatedRoute);
private snackBar = inject(MatSnackBar);
electionId!: number;
election: any = null;
nominations: any[] = [];
loading = true;
filterStatus = '';
statusOptions = [
{ value: '', label: 'All Statuses' },
{ value: 'SUBMITTED', label: 'Submitted' },
{ value: 'UNDER_REVIEW', label: 'Under Review' },
{ value: 'CEO_CHECK_PENDING', label: 'CEO Check Pending' },
{ value: 'CEO_APPROVED', label: 'CEO Approved' },
{ value: 'APPROVAL_PENDING', label: 'Approval Pending' },
{ value: 'APPROVED', label: 'Approved' },
{ value: 'REJECTED', label: 'Rejected' },
{ value: 'WITHDRAWN', label: 'Withdrawn' }
];
readonly statusConfig: Record<string, { label: string; cssClass: string }> = {
DRAFT: { label: 'Draft', cssClass: 'chip--draft' },
SUBMITTED: { label: 'Submitted', cssClass: 'chip--submitted' },
UNDER_REVIEW: { label: 'Under Review', cssClass: 'chip--review' },
CEO_CHECK_PENDING: { label: 'CEO Check', cssClass: 'chip--ceo' },
CEO_APPROVED: { label: 'CEO Approved', cssClass: 'chip--ceo-ok' },
CEO_REJECTED: { label: 'CEO Rejected', cssClass: 'chip--rejected' },
APPROVAL_PENDING: { label: 'Approval Pending', cssClass: 'chip--pending' },
APPROVED: { label: 'Approved', cssClass: 'chip--approved' },
REJECTED: { label: 'Rejected', cssClass: 'chip--rejected' },
WITHDRAWN: { label: 'Withdrawn', cssClass: 'chip--withdrawn' }
};
ngOnInit(): void {
this.electionId = +this.route.snapshot.params['electionId'];
this.loadElection();
this.load();
}
loadElection(): void {
this.service.getElection(this.electionId).subscribe((e) => (this.election = e));
}
load(): void {
this.loading = true;
this.service
.getNominations(this.electionId, this.filterStatus || undefined)
.pipe(
catchError(() => {
this.loading = false;
return of([]);
})
)
.subscribe((data: any[]) => {
this.nominations = data;
this.loading = false;
});
}
statusLabel(s: string): string {
return this.statusConfig[s]?.label ?? s;
}
statusClass(s: string): string {
return this.statusConfig[s]?.cssClass ?? '';
}
get summary() {
return {
total: this.nominations.length,
approved: this.nominations.filter((n) => n.status === 'APPROVED').length,
pending: this.nominations.filter((n) => [
'SUBMITTED',
'UNDER_REVIEW',
'CEO_CHECK_PENDING',
'CEO_APPROVED',
'APPROVAL_PENDING'
].includes(n.status)).length,
rejected: this.nominations.filter((n) => [
'REJECTED',
'CEO_REJECTED'
].includes(n.status)).length
};
}
}
@@ -0,0 +1,112 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="voting-interface">
<div class="page-header">
<div class="header-icon"><mat-icon>ballot</mat-icon></div>
<div>
<h2>Cast Your Vote</h2>
@if (election) {
<p class="sub-title">{{ election.title }} &nbsp;·&nbsp; {{ election.electionDate | date: 'dd MMM yyyy' }}</p>
}
</div>
</div>
@if (loading) {
<div class="loading-state">
<div class="spinner"></div>
<p>Loading ballot...</p>
</div>
} @else if (candidates.length === 0) {
<div class="empty-state">
<mat-icon>ballot</mat-icon>
<p>No candidates available for voting.</p>
</div>
} @else {
@if (allPostsVoted) {
<div class="success-banner">
<mat-icon>check_circle</mat-icon>
<div>
<strong>Thank you! Your votes have been recorded.</strong>
<p>You have successfully voted for all posts.</p>
</div>
<a routerLink="/election" class="btn-done">Go to Dashboard</a>
</div>
} @else {
<div class="voting-instructions">
<mat-icon>info</mat-icon>
Select one candidate per post and click "Cast Vote". Each post must be voted separately.
</div>
}
@for (group of groupByPost(); track group.postId) {
<div class="post-ballot">
<div class="ballot-header">
<h3>{{ group.postName }}</h3>
@if (votedPosts.has(group.postId)) {
<span class="voted-badge"><mat-icon>check</mat-icon> Voted</span>
} @else {
<span class="pending-badge">Select candidate</span>
}
</div>
<div class="ballot-candidates">
@for (candidate of group.candidates; track candidate.id) {
<div
class="ballot-card"
[class.selected]="isSelected(group.postId, candidate.id)"
[class.voted-out]="votedPosts.has(group.postId) && !isSelected(group.postId, candidate.id)"
(click)="selectCandidate(group.postId, candidate.id)"
>
<div class="selection-indicator">
@if (isSelected(group.postId, candidate.id)) {
<mat-icon>radio_button_checked</mat-icon>
} @else {
<mat-icon>radio_button_unchecked</mat-icon>
}
</div>
<div class="ballot-avatar">
@if (candidate.photoUrl) {
<img [src]="candidate.photoUrl" [alt]="candidate.candidateName" />
} @else {
<div class="avatar-initial">{{ candidate.candidateName.charAt(0) }}</div>
}
</div>
<div class="ballot-info">
<span class="serial">No. {{ candidate.serialNumber }}</span>
<h4>{{ candidate.candidateName }}</h4>
@if (candidate.symbol) {
<span class="symbol-label">{{ candidate.symbol }}</span>
}
</div>
@if (isSelected(group.postId, candidate.id) && votedPosts.has(group.postId)) {
<div class="voted-overlay"><mat-icon>how_to_vote</mat-icon></div>
}
</div>
}
</div>
@if (!votedPosts.has(group.postId)) {
<div class="cast-vote-row">
<button
class="btn-cast-vote"
[disabled]="!selections[group.postId] || submitting"
(click)="castVote(group.postId)"
>
<mat-icon>how_to_vote</mat-icon>
Cast Vote for {{ group.postName }}
</button>
</div>
}
</div>
}
}
</div>
@@ -0,0 +1,336 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.voting-interface {
padding: 24px;
max-width: 900px;
margin: 0 auto;
}
.page-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 28px;
.header-icon {
width: 52px;
height: 52px;
background: linear-gradient(135deg, #4a6cf7, #7c3aed);
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
mat-icon {
color: #fff;
font-size: 28px;
}
}
h2 {
font-size: 24px;
font-weight: 700;
margin: 0 0 4px;
}
.sub-title {
font-size: 14px;
color: #718096;
margin: 0;
}
}
.voting-instructions {
display: flex;
align-items: center;
gap: 10px;
background: #eff6ff;
border: 1px solid #bfdbfe;
padding: 12px 16px;
border-radius: 10px;
margin-bottom: 24px;
font-size: 14px;
color: #1e40af;
mat-icon {
color: #3b82f6;
}
}
.success-banner {
display: flex;
align-items: center;
gap: 16px;
background: #f0fff4;
border: 1px solid #9ae6b4;
border-radius: 14px;
padding: 20px 24px;
margin-bottom: 24px;
mat-icon {
color: #38a169;
font-size: 36px;
height: 36px;
width: 36px;
}
strong {
display: block;
font-size: 16px;
color: #276749;
margin-bottom: 4px;
}
p {
margin: 0;
font-size: 14px;
color: #48bb78;
}
.btn-done {
margin-left: auto;
background: #38a169;
color: #fff;
padding: 10px 20px;
border-radius: 8px;
text-decoration: none;
font-weight: 600;
&:hover {
background: #2f855a;
}
}
}
.post-ballot {
background: #fff;
border-radius: 16px;
box-shadow: 0 2px 10px rgb(0 0 0 / 8%);
margin-bottom: 24px;
overflow: hidden;
}
.ballot-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 18px 24px;
background: #f8fafc;
border-bottom: 1px solid #f0f0f0;
h3 {
font-size: 18px;
font-weight: 600;
color: #2d3748;
margin: 0;
}
.voted-badge {
display: inline-flex;
align-items: center;
gap: 4px;
background: #c6f6d5;
color: #276749;
padding: 4px 12px;
border-radius: 20px;
font-size: 13px;
font-weight: 600;
mat-icon {
font-size: 16px;
height: 16px;
width: 16px;
}
}
.pending-badge {
background: #fefcbf;
color: #744210;
padding: 4px 12px;
border-radius: 20px;
font-size: 13px;
}
}
.ballot-candidates {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 16px;
padding: 20px 24px;
}
.ballot-card {
border: 2px solid #e2e8f0;
border-radius: 12px;
padding: 16px;
cursor: pointer;
position: relative;
transition: all 0.15s;
text-align: center;
&:hover {
border-color: #4a6cf7;
box-shadow: 0 2px 8px rgb(74 108 247 / 15%);
}
&.selected {
border-color: #4a6cf7;
background: #f0f4ff;
box-shadow: 0 0 0 3px rgb(74 108 247 / 20%);
}
&.voted-out {
opacity: 0.4;
cursor: default;
}
}
.selection-indicator {
position: absolute;
top: 10px;
left: 10px;
mat-icon {
font-size: 20px;
height: 20px;
width: 20px;
color: #4a6cf7;
}
}
.ballot-avatar {
display: flex;
justify-content: center;
margin-bottom: 12px;
img {
width: 64px;
height: 64px;
border-radius: 50%;
object-fit: cover;
border: 2px solid #e2e8f0;
}
.avatar-initial {
width: 64px;
height: 64px;
border-radius: 50%;
background: linear-gradient(135deg, #4a6cf7, #7c3aed);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 26px;
font-weight: 700;
}
}
.ballot-info {
.serial {
display: block;
font-size: 11px;
color: #a0aec0;
margin-bottom: 4px;
}
h4 {
font-size: 14px;
font-weight: 600;
color: #2d3748;
margin: 0 0 4px;
}
.symbol-label {
font-size: 12px;
color: #718096;
}
}
.voted-overlay {
position: absolute;
inset: 0;
background: rgb(74 108 247 / 10%);
display: flex;
align-items: center;
justify-content: center;
border-radius: 10px;
mat-icon {
font-size: 40px;
height: 40px;
width: 40px;
color: #4a6cf7;
}
}
.cast-vote-row {
padding: 16px 24px 20px;
display: flex;
justify-content: center;
.btn-cast-vote {
display: inline-flex;
align-items: center;
gap: 8px;
background: #4a6cf7;
color: #fff;
padding: 12px 28px;
border-radius: 10px;
border: none;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
mat-icon {
font-size: 20px;
}
&:hover:not(:disabled) {
background: #3a5ce7;
}
&:disabled {
background: #a0aec0;
cursor: not-allowed;
}
}
}
.loading-state,
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 60px;
color: #a0aec0;
mat-icon {
font-size: 48px;
height: 48px;
width: 48px;
margin-bottom: 12px;
}
}
.spinner {
width: 36px;
height: 36px;
border: 4px solid #e2e8f0;
border-top-color: #4a6cf7;
border-radius: 50%;
animation: spin 0.7s linear infinite;
margin-bottom: 12px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@@ -0,0 +1,125 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule, ActivatedRoute, Router } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { catchError, of } from 'rxjs';
import { ElectionService } from '../../election.service';
@Component({
selector: 'mifosx-voting-interface',
standalone: true,
imports: [
CommonModule,
RouterModule,
MatIconModule,
MatSnackBarModule
],
templateUrl: './voting-interface.component.html',
styleUrls: ['./voting-interface.component.scss']
})
export class VotingInterfaceComponent implements OnInit {
private service = inject(ElectionService);
private route = inject(ActivatedRoute);
private router = inject(Router);
private snackBar = inject(MatSnackBar);
electionId!: number;
election: any = null;
candidates: any[] = [];
loading = true;
submitting = false;
// postId -> selected candidateId
selections: Record<number, number> = {};
votedPosts: Set<number> = new Set();
ngOnInit(): void {
this.electionId = +this.route.snapshot.params['electionId'];
this.loadData();
}
loadData(): void {
this.loading = true;
Promise.all([
this.service.getElection(this.electionId).toPromise(),
this.service.getCandidates(this.electionId).toPromise()
])
.then(
([
election,
candidates
]: any[]) => {
this.election = election;
this.candidates = candidates ?? [];
this.loading = false;
}
)
.catch(() => {
this.loading = false;
});
}
groupByPost(): { postId: number; postName: string; candidates: any[] }[] {
const grouped: Record<string, { postId: number; postName: string; candidates: any[] }> = {};
for (const c of this.candidates) {
if (!grouped[c.postId]) {
grouped[c.postId] = { postId: c.postId, postName: c.postName, candidates: [] };
}
grouped[c.postId].candidates.push(c);
}
return Object.values(grouped);
}
selectCandidate(postId: number, candidateId: number): void {
if (this.votedPosts.has(postId)) return;
this.selections[postId] = candidateId;
}
isSelected(postId: number, candidateId: number): boolean {
return this.selections[postId] === candidateId;
}
castVote(postId: number): void {
const candidateId = this.selections[postId];
if (!candidateId) {
this.snackBar.open('Please select a candidate first', 'Close', { duration: 3000 });
return;
}
this.submitting = true;
this.service
.castVote(this.electionId, {
electionId: this.electionId,
postId,
candidateId,
voterMemberId: 1
})
.pipe(
catchError((err) => {
const msg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Failed to cast vote';
this.snackBar.open(msg, 'Close', { duration: 4000 });
this.submitting = false;
return of(null);
})
)
.subscribe((res: any) => {
if (res === null) return;
this.votedPosts.add(postId);
this.submitting = false;
this.snackBar.open('Vote cast successfully!', 'Close', { duration: 3000 });
});
}
get allPostsVoted(): boolean {
const posts = this.groupByPost();
return posts.length > 0 && posts.every((p) => this.votedPosts.has(p.postId));
}
}
@@ -0,0 +1,86 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { Route } from '../core/route/route.service';
const routes: Routes = [
Route.withShell([
{
path: 'election',
data: { title: 'Election Management' },
children: [
{
path: '',
loadComponent: () =>
import('./components/election-dashboard/election-dashboard.component').then(
(m) => m.ElectionDashboardComponent
),
data: { title: 'Elections Dashboard' }
},
{
path: 'config/new',
loadComponent: () =>
import('./components/election-config/election-config.component').then((m) => m.ElectionConfigComponent),
data: { title: 'Create Election' }
},
{
path: 'config/:electionId/edit',
loadComponent: () =>
import('./components/election-config/election-config.component').then((m) => m.ElectionConfigComponent),
data: { title: 'Edit Election' }
},
{
path: ':electionId/nominations',
loadComponent: () =>
import('./components/nomination-list/nomination-list.component').then((m) => m.NominationListComponent),
data: { title: 'Nominations' }
},
{
path: ':electionId/nomination/apply',
loadComponent: () =>
import('./components/nomination-apply/nomination-apply.component').then((m) => m.NominationApplyComponent),
data: { title: 'Apply for Nomination' }
},
{
path: ':electionId/nomination/:nominationId',
loadComponent: () =>
import('./components/nomination-detail/nomination-detail.component').then(
(m) => m.NominationDetailComponent
),
data: { title: 'Nomination Detail' }
},
{
path: ':electionId/candidates',
loadComponent: () =>
import('./components/candidate-board/candidate-board.component').then((m) => m.CandidateBoardComponent),
data: { title: 'Candidate Board' }
},
{
path: ':electionId/vote',
loadComponent: () =>
import('./components/voting-interface/voting-interface.component').then((m) => m.VotingInterfaceComponent),
data: { title: 'Cast Your Vote' }
},
{
path: ':electionId/results',
loadComponent: () =>
import('./components/election-results/election-results.component').then((m) => m.ElectionResultsComponent),
data: { title: 'Election Results' }
}
]
}
])
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class ElectionRoutingModule {}
+19
View File
@@ -0,0 +1,19 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { NgModule } from '@angular/core';
import { SharedModule } from '../shared/shared.module';
import { ElectionRoutingModule } from './election-routing.module';
@NgModule({
imports: [
SharedModule,
ElectionRoutingModule
]
})
export class ElectionModule {}
+123
View File
@@ -0,0 +1,123 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class ElectionService {
private http = inject(HttpClient);
// ── Election Config ────────────────────────────────────────────
getAllElections(): Observable<any[]> {
return this.http.get<any[]>('/election/config');
}
getElection(id: number): Observable<any> {
return this.http.get<any>(`/election/config/${id}`);
}
createElection(data: any): Observable<any> {
return this.http.post('/election/config', data);
}
updateElection(id: number, data: any): Observable<any> {
return this.http.put(`/election/config/${id}`, data);
}
activateElection(id: number): Observable<any> {
return this.http.post(`/election/config/${id}/activate`, {});
}
closeNominations(id: number): Observable<any> {
return this.http.post(`/election/config/${id}/close-nominations`, {});
}
openVoting(id: number): Observable<any> {
return this.http.post(`/election/config/${id}/open-voting`, {});
}
closeVoting(id: number): Observable<any> {
return this.http.post(`/election/config/${id}/close-voting`, {});
}
cancelElection(id: number, reason: string): Observable<any> {
const params = new HttpParams().set('reason', reason);
return this.http.post(`/election/config/${id}/cancel`, {}, { params });
}
// ── Nominations ───────────────────────────────────────────────
submitNomination(electionId: number, data: any): Observable<any> {
return this.http.post(`/election/${electionId}/nomination`, data);
}
getNominations(electionId: number, status?: string): Observable<any[]> {
let params = new HttpParams();
if (status) params = params.set('status', status);
return this.http.get<any[]>(`/election/${electionId}/nominations`, { params });
}
getNomination(electionId: number, nominationId: number): Observable<any> {
return this.http.get<any>(`/election/${electionId}/nomination/${nominationId}`);
}
withdrawNomination(electionId: number, nominationId: number): Observable<any> {
return this.http.post(`/election/${electionId}/nomination/${nominationId}/withdraw`, {});
}
runEligibilityCheck(electionId: number, nominationId: number): Observable<any> {
return this.http.post(`/election/${electionId}/nomination/${nominationId}/eligibility-check`, {});
}
runCeoPreCheck(electionId: number, nominationId: number): Observable<any> {
return this.http.post(`/election/${electionId}/nomination/${nominationId}/ceo-precheck`, {});
}
generateNoDueCertificate(electionId: number, nominationId: number): Observable<any> {
return this.http.post(`/election/${electionId}/nomination/${nominationId}/no-due-certificate`, {});
}
processApprovalStep(electionId: number, nominationId: number, data: any): Observable<any> {
return this.http.post(`/election/${electionId}/nomination/${nominationId}/approval-action`, data);
}
getNominationsByMember(memberId: number): Observable<any[]> {
return this.http.get<any[]>(`/election/nominations/member/${memberId}`);
}
// ── Candidates ─────────────────────────────────────────────────
getCandidates(electionId: number, postId?: number): Observable<any[]> {
let params = new HttpParams();
if (postId) params = params.set('postId', postId.toString());
return this.http.get<any[]>(`/election/${electionId}/candidates`, { params });
}
approveCandidate(electionId: number, nominationId: number): Observable<any> {
return this.http.post(`/election/${electionId}/nomination/${nominationId}/approve-candidate`, {});
}
removeCandidate(electionId: number, candidateId: number, reason?: string): Observable<any> {
let params = new HttpParams();
if (reason) params = params.set('reason', reason);
return this.http.delete(`/election/${electionId}/candidate/${candidateId}`, { params });
}
// ── Voting & Results ───────────────────────────────────────────
castVote(electionId: number, data: any): Observable<any> {
return this.http.post(`/election/${electionId}/vote`, data);
}
declareResults(electionId: number): Observable<any> {
return this.http.post(`/election/${electionId}/declare-results`, {});
}
getResults(electionId: number): Observable<any[]> {
return this.http.get<any[]>(`/election/${electionId}/results`);
}
}
+2 -2
View File
@@ -9,8 +9,8 @@
/* tslint:disable */
export default {
'mifos_x': {
'version': '260617',
'hash': 'cbb2bd7c'
'version': '260619',
'hash': '822fa4c6'
},
'allow_switching_backend_instance': true
};