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:
co-authored by
Claude Sonnet 4.6
parent
822fa4c68d
commit
10b301c30c
@@ -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 || '';
|
||||
}
|
||||
|
||||
|
||||
@@ -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' }}
|
||||
→
|
||||
{{ 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"> · {{ 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
|
||||
> · Share A/C: <strong>{{ selectedShareAccountNo }}</strong></span
|
||||
>
|
||||
<span
|
||||
> · Shares: <strong>{{ selectedShareCount }}</strong></span
|
||||
>
|
||||
} @else {
|
||||
<span style="color: #c53030"> · No active share account found</span>
|
||||
}
|
||||
<span> · 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) {
|
||||
({{ fmExp.daysLeft }} days left)
|
||||
}
|
||||
@if (fmExp.isExpired) {
|
||||
({{ 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) {
|
||||
({{ expInfo.daysLeft }} days left)
|
||||
}
|
||||
@if (expInfo.isExpired) {
|
||||
({{ 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 & 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 }} ({{ 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 }} · {{ 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 {}
|
||||
@@ -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 {}
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,8 @@
|
||||
/* tslint:disable */
|
||||
export default {
|
||||
'mifos_x': {
|
||||
'version': '260617',
|
||||
'hash': 'cbb2bd7c'
|
||||
'version': '260619',
|
||||
'hash': '822fa4c6'
|
||||
},
|
||||
'allow_switching_backend_instance': true
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user