feat(election): candidate voter analysis — Chart.js charts, cross-dimension insights

- New CandidateAnalysisComponent with Chart.js doughnut charts for all
  demographic/financial/social dimensions (gender, age, area, caste,
  sub-caste, religion, occupation, share-holding, tenure, loan, education,
  marital status, monthly income, savings account depth)
- Section 4: Multi-Dimension Voter Alignment card showing core base
  (caste + area cross-match), caste %, area %, and savings engagement
- Candidate board: added Analyse button linking to analysis page
- Hero header, KPI strip, 4 dashboard sections with hover charts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jaanu423
2026-06-30 11:53:28 +05:30
co-authored by Claude Sonnet 4.6
parent 960e4e181b
commit dac169ae15
8 changed files with 2449 additions and 2 deletions
@@ -0,0 +1,779 @@
<!--
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="va-page">
<div class="backdrop-blobs">
<div class="blob blob-1"></div>
<div class="blob blob-2"></div>
<div class="blob blob-3"></div>
</div>
<!-- ── Hero Header ──────────────────────────────────────────────────────── -->
<div class="hero">
<div class="hero-overlay"></div>
<a [routerLink]="['/election', electionId, 'candidates']" class="hero-back">
<mat-icon>chevron_left</mat-icon> Back to Candidates
</a>
@if (data) {
<div class="hero-body">
<div class="hero-avatar-wrapper">
<div class="hero-avatar">{{ data.candidateName?.charAt(0) }}</div>
<div class="hero-avatar-glow"></div>
</div>
<div class="hero-info">
<div class="hero-badge-row">
<span class="post-badge">{{ data.postName }}</span>
<span class="analysis-tag">Voter Influence Analysis</span>
</div>
<h1 class="hero-name">{{ data.candidateName }}</h1>
@if (hasCriteria(data)) {
<div class="hero-criteria">
<span class="criteria-title">Target Criteria:</span>
@if (data.postGenderType && data.postGenderType !== 'ANY') {
<span class="cp">Gender: {{ data.postGenderType }}</span>
}
@if (data.postMinAge && data.postMaxAge) {
<span class="cp">Age: {{ data.postMinAge }}–{{ data.postMaxAge }} yrs</span>
} @else if (data.postMinAge) {
<span class="cp">Min Age: {{ data.postMinAge }} yrs</span>
} @else if (data.postMaxAge) {
<span class="cp">Max Age: {{ data.postMaxAge }} yrs</span>
}
@if (data.postMinShareHolding) {
<span class="cp">Min Shares: {{ data.postMinShareHolding }}</span>
}
</div>
}
</div>
</div>
}
</div>
@if (loading) {
<div class="state-box">
<div class="ring-spinner"></div>
<p>Building voter analysis...</p>
</div>
} @else if (error || !data) {
<div class="state-box">
<mat-icon class="err-icon">error_outline</mat-icon>
<p class="err-msg">{{ errorMessage || 'Analysis not available.' }}</p>
<a [routerLink]="['/election', electionId, 'candidates']" class="err-btn"> Return to Candidates </a>
</div>
} @else {
<!-- ── KPI Strip ─────────────────────────────────────────────────────────── -->
<div class="kpi-row">
<div class="kpi kpi-featured">
<div class="kpi-icon" style="background: rgba(37, 99, 235, 0.1)">
<mat-icon style="color: #2563eb">how_to_vote</mat-icon>
</div>
<div class="kpi-body">
<div class="kpi-val featured-val">{{ data.actualEligibleVoters }}</div>
<div class="kpi-lbl">Post-Eligible Voters</div>
<div class="kpi-sub">{{ eligiblePct(data) }}% of shareholders</div>
</div>
</div>
<div class="kpi">
<div class="kpi-icon" style="background: rgba(2, 132, 199, 0.1)">
<mat-icon style="color: #0284c7">group</mat-icon>
</div>
<div class="kpi-body">
<div class="kpi-val">{{ data.totalActiveShareholders }}</div>
<div class="kpi-lbl">Total Shareholders</div>
<div class="kpi-sub-text">Active members</div>
</div>
</div>
<div class="kpi">
<div class="kpi-icon" style="background: rgba(22, 163, 74, 0.1)">
<mat-icon style="color: #16a34a">cake</mat-icon>
</div>
<div class="kpi-body">
<div class="kpi-val">{{ data.eligibleByAge }}</div>
<div class="kpi-lbl">
@if (!data.postMinAge && !data.postMaxAge) {
Age-Eligible
} @else {
Age-Eligible ({{ data.postMinAge }}–{{ data.postMaxAge }})
}
</div>
<div class="kpi-sub-text">Fits age threshold</div>
</div>
</div>
<div class="kpi">
<div class="kpi-icon" style="background: rgba(126, 34, 206, 0.1)">
<mat-icon style="color: #7e22ce">diversity_3</mat-icon>
</div>
<div class="kpi-body">
<div class="kpi-val">{{ data.casteMatchCount }}</div>
<div class="kpi-lbl">Same Social Category</div>
<div class="kpi-sub-text">Matches candidate</div>
</div>
</div>
<div class="kpi">
<div class="kpi-icon" style="background: rgba(180, 83, 9, 0.1)">
<mat-icon style="color: #b45309">account_balance</mat-icon>
</div>
<div class="kpi-body">
<div class="kpi-val">{{ data.activeLoanHolders }}</div>
<div class="kpi-lbl">Active Loan Holders</div>
<div class="kpi-sub-text">With active accounts</div>
</div>
</div>
<div class="kpi">
<div class="kpi-icon" style="background: rgba(190, 24, 93, 0.1)">
<mat-icon style="color: #be185d">family_restroom</mat-icon>
</div>
<div class="kpi-body">
<div class="kpi-val">{{ data.shareholdersWithFamily }}</div>
<div class="kpi-lbl">With Family Records</div>
<div class="kpi-sub-text">Household influence</div>
</div>
</div>
</div>
<!-- ── Analysis Layout ───────────────────────────────────────────────────── -->
<div class="dashboard-sections">
<!-- SECTION 1: DEMOGRAPHICS & PROFILE -->
<div class="dashboard-section">
<div class="section-header">
<mat-icon>people_outline</mat-icon>
<h2>Demographic & Professional Profile</h2>
<span class="section-badge">6 Analysis Dimensions</span>
</div>
<div class="a-grid">
<!-- Gender — DONUT -->
<div class="a-card" style="--ac: #ec4899; --ac-bg: rgba(236, 72, 153, 0.08)">
<div class="ac-head">
<div class="ac-icon"><mat-icon>people</mat-icon></div>
<span class="ac-title">Gender Distribution</span>
@if (data.candidateGender) {
<span class="ac-tag">Candidate: {{ data.candidateGender }}</span>
}
</div>
<div class="chart-body flex-col">
<div class="donut-wrap">
<div class="chart-canvas-wrap">
<canvas id="gender-chart"></canvas>
</div>
<div class="donut-center">
<span class="dc-val" id="gender-chart-val">{{ data.totalActiveShareholders }}</span>
<span class="dc-sub" id="gender-chart-sub">total</span>
</div>
</div>
<div class="chart-legend">
@for (item of data.genderBreakdown; track item.label; let i = $index) {
<div class="cl-row" [class.cl-match]="item.match">
<span class="cl-dot" [style.background]="itemColor(item, i, '#ec4899', genderPalette)"></span>
<span class="cl-label">
{{ item.label }}
@if (item.match) {
<span class="mbadge">★ Match</span>
}
</span>
<span class="cl-pct-badge">{{ item.percent }}%</span>
</div>
}
</div>
</div>
</div>
<!-- Age Groups — DONUT -->
<div class="a-card" style="--ac: #3b82f6; --ac-bg: rgba(59, 130, 246, 0.08)">
<div class="ac-head">
<div class="ac-icon"><mat-icon>cake</mat-icon></div>
<span class="ac-title">Age Group Distribution</span>
@if (data.candidateAge) {
<span class="ac-tag">Age: {{ data.candidateAge }} yrs</span>
}
</div>
<div class="chart-body flex-col">
<div class="donut-wrap">
<div class="chart-canvas-wrap">
<canvas id="age-chart"></canvas>
</div>
<div class="donut-center">
<span class="dc-val" id="age-chart-val">{{ data.totalActiveShareholders }}</span>
<span class="dc-sub" id="age-chart-sub">total</span>
</div>
</div>
<div class="chart-legend">
@for (item of data.ageBreakdown; track item.label; let i = $index) {
<div class="cl-row" [class.cl-match]="item.match">
<span class="cl-dot" [style.background]="itemColor(item, i, '#3b82f6', agePalette)"></span>
<span class="cl-label">
{{ item.label }} Group
@if (item.match) {
<span class="mbadge">★ Match</span>
}
</span>
<span class="cl-pct-badge">{{ item.percent }}%</span>
</div>
}
</div>
</div>
</div>
<!-- Religion — DONUT -->
<div class="a-card" style="--ac: #f97316; --ac-bg: rgba(249, 115, 22, 0.08)">
<div class="ac-head">
<div class="ac-icon"><mat-icon>temple_hindu</mat-icon></div>
<span class="ac-title">Religion Profile</span>
</div>
<div class="chart-body flex-col">
@if (data.religionBreakdown?.length > 0) {
<div class="donut-wrap">
<div class="chart-canvas-wrap">
<canvas id="religion-chart"></canvas>
</div>
<div class="donut-center">
<span class="dc-val" id="religion-chart-val">{{ data.totalActiveShareholders }}</span>
<span class="dc-sub" id="religion-chart-sub">total</span>
</div>
</div>
<div class="chart-legend">
@for (item of data.religionBreakdown; track item.label; let i = $index) {
<div class="cl-row">
<span class="cl-dot" [style.background]="itemColor(item, i, '#f97316', religionPalette)"></span>
<span class="cl-label">{{ item.label }}</span>
<span class="cl-pct-badge">{{ item.percent }}%</span>
</div>
}
</div>
} @else {
<p class="no-data">No religion data recorded yet.</p>
}
</div>
</div>
<!-- Occupation — DONUT -->
<div class="a-card" style="--ac: #0891b2; --ac-bg: rgba(8, 145, 178, 0.08)">
<div class="ac-head">
<div class="ac-icon"><mat-icon>work</mat-icon></div>
<span class="ac-title">Occupation Profile</span>
</div>
<div class="chart-body flex-col">
@if (data.occupationBreakdown?.length > 0) {
<div class="donut-wrap">
<div class="chart-canvas-wrap">
<canvas id="occupation-chart"></canvas>
</div>
<div class="donut-center">
<span class="dc-val" id="occupation-chart-val">{{ data.totalActiveShareholders }}</span>
<span class="dc-sub" id="occupation-chart-sub">total</span>
</div>
</div>
<div class="chart-legend">
@for (item of data.occupationBreakdown; track item.label; let i = $index) {
<div class="cl-row">
<span
class="cl-dot"
[style.background]="itemColor(item, i, '#0891b2', ['#22d3ee', '#67e8f9', '#a5f3fc', '#cbd5e1'])"
></span>
<span class="cl-label">{{ item.label }}</span>
<span class="cl-pct-badge">{{ item.percent }}%</span>
</div>
}
</div>
} @else {
<p class="no-data">No occupation data recorded yet.</p>
}
</div>
</div>
<!-- Education Level — DONUT -->
<div class="a-card" style="--ac: #6366f1; --ac-bg: rgba(99, 102, 241, 0.08)">
<div class="ac-head">
<div class="ac-icon"><mat-icon>school</mat-icon></div>
<span class="ac-title">Education Level</span>
</div>
<div class="chart-body flex-col">
@if (data.educationBreakdown?.length > 0) {
<div class="donut-wrap">
<div class="chart-canvas-wrap">
<canvas id="education-chart"></canvas>
</div>
<div class="donut-center">
<span class="dc-val" id="education-chart-val">{{ data.totalActiveShareholders }}</span>
<span class="dc-sub" id="education-chart-sub">total</span>
</div>
</div>
<div class="chart-legend">
@for (item of data.educationBreakdown; track item.label; let i = $index) {
<div class="cl-row">
<span class="cl-dot" [style.background]="itemColor(item, i, '#6366f1', educationPalette)"></span>
<span class="cl-label">{{ item.label }}</span>
<span class="cl-pct-badge">{{ item.percent }}%</span>
</div>
}
</div>
} @else {
<p class="no-data">No education data recorded yet.</p>
}
</div>
</div>
<!-- Marital Status — DONUT -->
<div class="a-card" style="--ac: #f59e0b; --ac-bg: rgba(245, 158, 11, 0.08)">
<div class="ac-head">
<div class="ac-icon"><mat-icon>favorite</mat-icon></div>
<span class="ac-title">Marital Status Profile</span>
</div>
<div class="chart-body flex-col">
@if (data.maritalBreakdown?.length > 0) {
<div class="donut-wrap">
<div class="chart-canvas-wrap">
<canvas id="marital-chart"></canvas>
</div>
<div class="donut-center">
<span class="dc-val" id="marital-chart-val">{{ data.totalActiveShareholders }}</span>
<span class="dc-sub" id="marital-chart-sub">total</span>
</div>
</div>
<div class="chart-legend">
@for (item of data.maritalBreakdown; track item.label; let i = $index) {
<div class="cl-row">
<span class="cl-dot" [style.background]="itemColor(item, i, '#f59e0b', maritalPalette)"></span>
<span class="cl-label">{{ item.label }}</span>
<span class="cl-pct-badge">{{ item.percent }}%</span>
</div>
}
</div>
} @else {
<p class="no-data">No marital status data recorded yet.</p>
}
</div>
</div>
</div>
</div>
<!-- SECTION 2: SOCIAL & GEOGRAPHIC PROFILE -->
<div class="dashboard-section">
<div class="section-header">
<mat-icon>public</mat-icon>
<h2>Social & Geographic Influence</h2>
<span class="section-badge">3 Analysis Dimensions</span>
</div>
<div class="a-grid">
<!-- Area / City — BARS -->
<div class="a-card" style="--ac: #10b981; --ac-bg: rgba(16, 185, 129, 0.08)">
<div class="ac-head">
<div class="ac-icon"><mat-icon>location_on</mat-icon></div>
<span class="ac-title">Area / City Distribution</span>
@if (data.candidateArea) {
<span class="ac-tag">Candidate: {{ data.candidateArea }}</span>
}
</div>
<div class="chart-body flex-col">
<div class="donut-wrap">
<div class="chart-canvas-wrap">
<canvas id="area-chart"></canvas>
</div>
<div class="donut-center">
<span class="dc-val" id="area-chart-val">{{ data.totalActiveShareholders }}</span>
<span class="dc-sub" id="area-chart-sub">total</span>
</div>
</div>
<div class="chart-legend">
@for (item of data.areaBreakdown; track item.label; let i = $index) {
<div class="cl-row" [class.cl-match]="item.match">
<span class="cl-dot" [style.background]="item.match ? '#10b981' : '#cbd5e1'"></span>
<span class="cl-label">
{{ item.label }}
@if (item.match) {
<span class="mbadge">★ Match</span>
}
</span>
<span class="cl-pct-badge">{{ item.percent }}%</span>
</div>
}
</div>
</div>
</div>
<!-- Social Category — DONUT -->
<div class="a-card" style="--ac: #8b5cf6; --ac-bg: rgba(139, 92, 246, 0.08)">
<div class="ac-head">
<div class="ac-icon"><mat-icon>diversity_3</mat-icon></div>
<span class="ac-title">Social Category (Caste)</span>
@if (data.candidateCaste) {
<span class="ac-tag">Candidate: {{ data.candidateCaste }}</span>
}
</div>
<div class="chart-body flex-col">
<div class="donut-wrap">
<div class="chart-canvas-wrap">
<canvas id="caste-chart"></canvas>
</div>
<div class="donut-center">
<span class="dc-val" id="caste-chart-val">{{ data.casteMatchCount }}</span>
<span class="dc-sub" id="caste-chart-sub">match</span>
</div>
</div>
<div class="chart-legend">
@for (item of data.casteBreakdown; track item.label; let i = $index) {
<div class="cl-row" [class.cl-match]="item.match">
<span class="cl-dot" [style.background]="itemColor(item, i, '#8b5cf6', castePalette)"></span>
<span class="cl-label">
{{ item.label }}
@if (item.match) {
<span class="mbadge">★ Match</span>
}
</span>
<span class="cl-pct-badge">{{ item.percent }}%</span>
</div>
}
</div>
</div>
</div>
<!-- Sub-Caste — FULL WIDTH BARS -->
<div class="a-card a-full" style="--ac: #d946ef; --ac-bg: rgba(217, 70, 239, 0.08)">
<div class="ac-head">
<div class="ac-icon"><mat-icon>account_tree</mat-icon></div>
<span class="ac-title">Sub-Caste Breakdown</span>
@if (data.candidateSubCaste) {
<span class="ac-tag">Candidate: {{ data.candidateSubCaste }}</span>
}
</div>
@if (data.subCasteBreakdown?.length > 0) {
<div class="full-card-split">
<div class="split-left flex-col">
<div class="donut-wrap">
<div class="chart-canvas-wrap">
<canvas id="subcaste-chart"></canvas>
</div>
<div class="donut-center">
<span class="dc-val" id="subcaste-chart-val">{{ data.totalActiveShareholders }}</span>
<span class="dc-sub" id="subcaste-chart-sub">total</span>
</div>
</div>
</div>
<div class="split-right">
<div class="caste-details-grid">
@for (item of data.subCasteBreakdown; track item.label; let i = $index) {
<div class="caste-detail-item" [class.caste-match]="item.match">
<div class="cdi-header">
<span class="cdi-label">{{ item.label }}</span>
@if (item.match) {
<span class="mbadge">★ Match</span>
}
</div>
<div class="cdi-footer">
<span class="cdi-count">{{ item.count }} members</span>
<span class="cdi-percent">{{ item.percent }}%</span>
</div>
</div>
}
</div>
</div>
</div>
} @else {
<p class="no-data">No sub-caste data recorded for shareholders yet.</p>
}
</div>
</div>
</div>
<!-- SECTION 3: FINANCIAL & FAMILY ENGAGEMENT -->
<div class="dashboard-section">
<div class="section-header">
<mat-icon>savings</mat-icon>
<h2>Financial & Family Engagement</h2>
<span class="section-badge">6 Analysis Dimensions</span>
</div>
<div class="a-grid">
<!-- Share Holdings — BARS -->
<div class="a-card" style="--ac: #6366f1; --ac-bg: rgba(99, 102, 241, 0.08)">
<div class="ac-head">
<div class="ac-icon"><mat-icon>account_balance_wallet</mat-icon></div>
<span class="ac-title">Share Holding Distribution</span>
</div>
<div class="chart-body flex-col">
<div class="donut-wrap">
<div class="chart-canvas-wrap">
<canvas id="shareholding-chart"></canvas>
</div>
<div class="donut-center">
<span class="dc-val" id="shareholding-chart-val">{{ data.totalActiveShareholders }}</span>
<span class="dc-sub" id="shareholding-chart-sub">total</span>
</div>
</div>
<div class="chart-legend">
@for (item of data.shareHoldingBreakdown; track item.label; let i = $index) {
<div class="cl-row">
<span class="cl-dot" style="background: #6366f1"></span>
<span class="cl-label">{{ item.label }}</span>
<span class="cl-pct-badge">{{ item.percent }}%</span>
</div>
}
</div>
</div>
</div>
<!-- Member Tenure — BARS -->
<div class="a-card" style="--ac: #06b6d4; --ac-bg: rgba(6, 182, 212, 0.08)">
<div class="ac-head">
<div class="ac-icon"><mat-icon>history</mat-icon></div>
<span class="ac-title">Member Tenure</span>
</div>
<div class="chart-body flex-col">
<div class="donut-wrap">
<div class="chart-canvas-wrap">
<canvas id="tenure-chart"></canvas>
</div>
<div class="donut-center">
<span class="dc-val" id="tenure-chart-val">{{ data.totalActiveShareholders }}</span>
<span class="dc-sub" id="tenure-chart-sub">total</span>
</div>
</div>
<div class="chart-legend">
@for (item of data.tenureBreakdown; track item.label; let i = $index) {
<div class="cl-row">
<span class="cl-dot" style="background: #06b6d4"></span>
<span class="cl-label">{{ item.label }}</span>
<span class="cl-pct-badge">{{ item.percent }}%</span>
</div>
}
</div>
</div>
</div>
<!-- Monthly Income — DONUT -->
<div class="a-card" style="--ac: #16a34a; --ac-bg: rgba(22, 163, 74, 0.08)">
<div class="ac-head">
<div class="ac-icon"><mat-icon>currency_rupee</mat-icon></div>
<span class="ac-title">Monthly Income Distribution</span>
</div>
<div class="chart-body flex-col">
@if (data.monthlyIncomeBreakdown?.length > 0) {
<div class="donut-wrap">
<div class="chart-canvas-wrap">
<canvas id="income-chart"></canvas>
</div>
<div class="donut-center">
<span class="dc-val" id="income-chart-val">{{ data.totalActiveShareholders }}</span>
<span class="dc-sub" id="income-chart-sub">total</span>
</div>
</div>
<div class="chart-legend">
@for (item of data.monthlyIncomeBreakdown; track item.label; let i = $index) {
<div class="cl-row">
<span class="cl-dot" [style.background]="itemColor(item, i, '#16a34a', incomePalette)"></span>
<span class="cl-label">{{ item.label }}</span>
<span class="cl-pct-badge">{{ item.percent }}%</span>
</div>
}
</div>
} @else {
<p class="no-data">No monthly income data recorded yet.</p>
}
</div>
</div>
<!-- Savings Account Depth — DONUT -->
<div class="a-card" style="--ac: #0ea5e9; --ac-bg: rgba(14, 165, 233, 0.08)">
<div class="ac-head">
<div class="ac-icon"><mat-icon>savings</mat-icon></div>
<span class="ac-title">Savings Account Depth</span>
</div>
<div class="chart-body flex-col">
@if (data.savingsBreakdown?.length > 0) {
<div class="donut-wrap">
<div class="chart-canvas-wrap">
<canvas id="savings-chart"></canvas>
</div>
<div class="donut-center">
<span class="dc-val" id="savings-chart-val">{{ data.activeSavingsHolders }}</span>
<span class="dc-sub" id="savings-chart-sub">active savings</span>
</div>
</div>
<div class="chart-legend">
@for (item of data.savingsBreakdown; track item.label) {
<div class="cl-row" [class.cl-match]="item.match">
<span class="cl-dot" [style.background]="item.match ? '#0ea5e9' : '#cbd5e1'"></span>
<span class="cl-label">{{ item.label }}</span>
<span class="cl-pct-badge">{{ item.percent }}%</span>
</div>
}
</div>
} @else {
<p class="no-data">No savings data available.</p>
}
</div>
</div>
<!-- Financial Engagement — DONUT + STATS (full width) -->
<div class="a-card a-full" style="--ac: #f59e0b; --ac-bg: rgba(245, 158, 11, 0.08)">
<div class="ac-head">
<div class="ac-icon"><mat-icon>account_balance</mat-icon></div>
<span class="ac-title">Financial Engagement — Active Loans</span>
</div>
<div class="loan-layout">
<div class="loan-chart-section">
<div class="loan-donut-wrap">
<div class="chart-canvas-wrap">
<canvas id="loan-chart"></canvas>
</div>
<div class="gauge-center">
<span class="ldc-val" id="loan-chart-val">{{ activeLoanPct(data) }}%</span>
<span class="ldc-sub" id="loan-chart-sub">active</span>
</div>
</div>
</div>
<div class="loan-stats-section">
<div class="loan-stats">
@for (item of data.loanHolderBreakdown; track item.label) {
<div class="loan-stat" [class.loan-stat-active]="item.label === 'Active Loan Holders'">
<div class="ls-header">
<span
class="ls-dot"
[style.background]="item.label === 'Active Loan Holders' ? '#f59e0b' : '#cbd5e1'"
></span>
<span class="ls-label">{{ item.label }}</span>
</div>
<div class="ls-num">{{ item.count }}</div>
<div class="ls-pct">{{ item.percent }}% of shareholders</div>
</div>
}
</div>
<div class="loan-note">
<mat-icon>info_outline</mat-icon>
<span
>Members with active loans have an ongoing financial relationship, indicating higher engagement and
interest in institutional decisions.</span
>
</div>
</div>
</div>
</div>
<!-- Family Member Network (full width) -->
<div class="a-card a-full" style="--ac: #0d9488; --ac-bg: rgba(13, 148, 136, 0.08)">
<div class="ac-head">
<div class="ac-icon"><mat-icon>family_restroom</mat-icon></div>
<span class="ac-title">Family Member Network Influence</span>
</div>
<div class="fam-metrics-layout">
<div class="fam-metrics">
<div class="fam-metric">
<div class="fm-icon-badge" style="color: #0d9488; background: rgba(13, 148, 136, 0.08)">
<mat-icon>group</mat-icon>
</div>
<div class="fm-details">
<div class="fm-val">{{ data.shareholdersWithFamily }}</div>
<div class="fm-lbl">Shareholders with Family Records</div>
</div>
</div>
<div class="fam-sep"></div>
<div class="fam-metric">
<div class="fm-icon-badge" style="color: #0f766e; background: rgba(15, 118, 110, 0.08)">
<mat-icon>diversity_1</mat-icon>
</div>
<div class="fm-details">
<div class="fm-val">{{ data.totalFamilyMembers }}</div>
<div class="fm-lbl">Total Family Members Registered</div>
</div>
</div>
<div class="fam-sep"></div>
<div class="fam-metric">
<div class="fm-icon-badge" style="color: #115e59; background: rgba(17, 94, 89, 0.08)">
<mat-icon>calculate</mat-icon>
</div>
<div class="fm-details">
<div class="fm-val">
{{
data.shareholdersWithFamily > 0
? (data.totalFamilyMembers / data.shareholdersWithFamily | number: '1.1-1')
: '—'
}}
</div>
<div class="fm-lbl">Avg Registered Members / Household</div>
</div>
</div>
</div>
<div class="insight-note">
<mat-icon>insights</mat-icon>
<span
>Family records indicate broader community networks. A single voting shareholder with multiple family
records acts as a proxy shapeholder, often scaling voting decisions across their entire
household.</span
>
</div>
</div>
</div>
</div>
</div>
<!-- SECTION 4: VOTER INFLUENCE INSIGHTS -->
<div class="dashboard-section">
<div class="section-header">
<mat-icon>auto_graph</mat-icon>
<h2>Voter Influence Insights</h2>
<span class="section-badge">Multi-Dimension Analysis</span>
</div>
<div class="a-grid">
<!-- Cross-Dimension Alignment (full width) -->
<div class="a-card a-full" style="--ac: #8b5cf6; --ac-bg: rgba(139, 92, 246, 0.08)">
<div class="ac-head">
<div class="ac-icon"><mat-icon>hub</mat-icon></div>
<span class="ac-title">Multi-Dimension Voter Alignment</span>
<span class="ac-tag">Candidate's core voter base overlap</span>
</div>
<div class="influence-insight">
<div class="influence-metric im-highlight">
<div class="im-icon" style="background: rgba(139, 92, 246, 0.12)">
<mat-icon style="color: #8b5cf6">hub</mat-icon>
</div>
<div class="im-val">{{ data.crossMatchCount }}</div>
<div class="im-pct" style="color: #8b5cf6">{{ crossMatchPct(data) }}%</div>
<div class="im-label">Core Base — Same Caste & Same Area</div>
</div>
<div class="influence-metric">
<div class="im-icon" style="background: rgba(139, 92, 246, 0.08)">
<mat-icon style="color: #a78bfa">diversity_3</mat-icon>
</div>
<div class="im-val">{{ data.casteMatchCount }}</div>
<div class="im-pct" style="color: #a78bfa">{{ casteMatchPct(data) }}%</div>
<div class="im-label">Same Social Category (Caste Alignment)</div>
</div>
<div class="influence-metric">
<div class="im-icon" style="background: rgba(16, 185, 129, 0.08)">
<mat-icon style="color: #10b981">location_on</mat-icon>
</div>
<div class="im-val">{{ data.areaMatchCount }}</div>
<div class="im-pct" style="color: #10b981">{{ areaMatchPct(data) }}%</div>
<div class="im-label">Same Area / City (Geographic Proximity)</div>
</div>
<div class="influence-metric">
<div class="im-icon" style="background: rgba(14, 165, 233, 0.08)">
<mat-icon style="color: #0ea5e9">savings</mat-icon>
</div>
<div class="im-val">{{ data.activeSavingsHolders }}</div>
<div class="im-pct" style="color: #0ea5e9">{{ activeSavingsPct(data) }}%</div>
<div class="im-label">Active Savings Holders (Financially Engaged)</div>
</div>
</div>
<div class="insight-note" style="margin-top: 20px">
<mat-icon>lightbulb</mat-icon>
<span
>The <strong>Core Base</strong> — voters matching both the candidate's social category AND geographic
area — represents the strongest aligned voter segment. Caste, area, and financial engagement are the
three strongest predictors of voter support in cooperative elections.</span
>
</div>
</div>
</div>
</div>
</div>
}
</div>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,403 @@
/**
* 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, OnDestroy, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule, ActivatedRoute } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { ElectionService } from '../../election.service';
import { Chart, registerables } from 'chart.js';
Chart.register(...registerables);
@Component({
selector: 'mifosx-candidate-analysis',
standalone: true,
imports: [
CommonModule,
RouterModule,
MatIconModule
],
templateUrl: './candidate-analysis.component.html',
styleUrls: ['./candidate-analysis.component.scss']
})
export class CandidateAnalysisComponent implements OnInit, OnDestroy {
private service = inject(ElectionService);
private route = inject(ActivatedRoute);
electionId!: number;
candidateId!: number;
data: any = null;
loading = true;
error = false;
errorMessage = '';
private chartInstances: any[] = [];
// ── Color palettes for donut charts ───────────────────────────────────────
readonly genderPalette = [
'#93c5fd',
'#f472b6',
'#cbd5e1'
];
readonly castePalette = [
'#c084fc',
'#67e8f9',
'#6ee7b7',
'#fca5a5',
'#cbd5e1'
];
readonly religionPalette = [
'#fdb574',
'#fca5a5',
'#fde047',
'#93c5fd',
'#c084fc',
'#86efac',
'#cbd5e1'
];
readonly agePalette = [
'#a5b4fc',
'#c084fc',
'#d8b4fe',
'#f9a8d4',
'#fda4af',
'#cbd5e1'
];
readonly loanPalette = ['#cbd5e1'];
readonly educationPalette = [
'#818cf8',
'#a5b4fc',
'#c7d2fe',
'#e0e7ff',
'#cbd5e1'
];
readonly maritalPalette = [
'#fbbf24',
'#fb923c',
'#a3e635',
'#34d399',
'#94a3b8'
];
readonly incomePalette = [
'#4ade80',
'#86efac',
'#6ee7b7',
'#5eead4',
'#a5f3fc',
'#cbd5e1'
];
ngOnInit(): void {
this.electionId = +this.route.snapshot.params['electionId'];
this.candidateId = +this.route.snapshot.params['candidateId'];
this.service.getCandidateVoterAnalysis(this.electionId, this.candidateId).subscribe({
next: (d) => {
this.data = d;
this.loading = false;
setTimeout(() => this.initCharts(), 0);
},
error: (err) => {
this.loading = false;
this.error = true;
this.errorMessage = err?.error?.defaultUserMessage || err?.error?.message || 'Analysis not available.';
}
});
}
ngOnDestroy(): void {
this.chartInstances.forEach((chart) => {
if (chart) {
chart.destroy();
}
});
this.chartInstances = [];
}
initCharts() {
if (!this.data) return;
if (this.data.genderBreakdown?.length) {
this.createDoughnutChart(
'gender-chart',
this.data.genderBreakdown,
'#ec4899',
this.genderPalette,
this.data.totalActiveShareholders,
'total'
);
}
if (this.data.ageBreakdown?.length) {
this.createDoughnutChart(
'age-chart',
this.data.ageBreakdown,
'#3b82f6',
this.agePalette,
this.data.totalActiveShareholders,
'total'
);
}
if (this.data.areaBreakdown?.length) {
this.createDoughnutChart(
'area-chart',
this.data.areaBreakdown,
'#10b981',
[
'#34d399',
'#6ee7b7',
'#a7f3d0',
'#cbd5e1'
],
this.data.totalActiveShareholders,
'total'
);
}
if (this.data.casteBreakdown?.length) {
this.createDoughnutChart(
'caste-chart',
this.data.casteBreakdown,
'#8b5cf6',
this.castePalette,
this.data.casteMatchCount,
'match'
);
}
if (this.data.subCasteBreakdown?.length) {
this.createDoughnutChart(
'subcaste-chart',
this.data.subCasteBreakdown,
'#d946ef',
[
'#e879f9',
'#f0abfc',
'#f5d0fe',
'#cbd5e1'
],
this.data.totalActiveShareholders,
'total'
);
}
if (this.data.religionBreakdown?.length) {
this.createDoughnutChart(
'religion-chart',
this.data.religionBreakdown,
'#f97316',
this.religionPalette,
this.data.totalActiveShareholders,
'total'
);
}
if (this.data.occupationBreakdown?.length) {
this.createDoughnutChart(
'occupation-chart',
this.data.occupationBreakdown,
'#0891b2',
[
'#22d3ee',
'#67e8f9',
'#a5f3fc',
'#cbd5e1'
],
this.data.totalActiveShareholders,
'total'
);
}
if (this.data.shareHoldingBreakdown?.length) {
this.createDoughnutChart(
'shareholding-chart',
this.data.shareHoldingBreakdown,
'#6366f1',
[
'#818cf8',
'#a5b4fc',
'#c7d2fe',
'#cbd5e1'
],
this.data.totalActiveShareholders,
'total'
);
}
if (this.data.tenureBreakdown?.length) {
this.createDoughnutChart(
'tenure-chart',
this.data.tenureBreakdown,
'#06b6d4',
[
'#22d3ee',
'#67e8f9',
'#cbd5e1'
],
this.data.totalActiveShareholders,
'total'
);
}
if (this.data.loanHolderBreakdown?.length) {
this.createDoughnutChart(
'loan-chart',
this.data.loanHolderBreakdown,
'#f59e0b',
this.loanPalette,
this.activeLoanPct(this.data),
'active',
true
);
}
if (this.data.educationBreakdown?.length) {
this.createDoughnutChart(
'education-chart',
this.data.educationBreakdown,
'#6366f1',
this.educationPalette,
this.data.totalActiveShareholders,
'total'
);
}
if (this.data.maritalBreakdown?.length) {
this.createDoughnutChart(
'marital-chart',
this.data.maritalBreakdown,
'#f59e0b',
this.maritalPalette,
this.data.totalActiveShareholders,
'total'
);
}
if (this.data.monthlyIncomeBreakdown?.length) {
this.createDoughnutChart(
'income-chart',
this.data.monthlyIncomeBreakdown,
'#16a34a',
this.incomePalette,
this.data.totalActiveShareholders,
'total'
);
}
if (this.data.savingsBreakdown?.length) {
this.createDoughnutChart(
'savings-chart',
this.data.savingsBreakdown,
'#0ea5e9',
['#cbd5e1'],
this.data.activeSavingsHolders,
'active savings'
);
}
}
private createDoughnutChart(
canvasId: string,
breakdown: any[],
accent: string,
palette: string[],
defaultVal: any,
defaultSub: string,
gauge: boolean = false
) {
const el = document.getElementById(canvasId) as HTMLCanvasElement;
if (!el) return;
const labels = breakdown.map((item) => item.label);
const data = breakdown.map((item) => item.percent);
const colors = breakdown.map((item, idx) => (item.match ? accent : palette[idx % palette.length]));
const chart = new Chart(el, {
type: 'doughnut',
data: {
labels: labels,
datasets: [
{
data: data,
backgroundColor: colors,
borderWidth: 3,
borderColor: '#ffffff',
borderRadius: 5, // Rounded corners on doughnut segments for clean graphic style
hoverOffset: 6
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '70%',
circumference: gauge ? 180 : 360,
rotation: gauge ? -90 : 0,
plugins: {
legend: { display: false },
tooltip: { enabled: false } // Disable native tooltip to prevent center overlay clipping
},
onHover: (_event: any, activeElements: any[]) => {
const valEl = document.getElementById(canvasId + '-val');
const subEl = document.getElementById(canvasId + '-sub');
if (!valEl || !subEl) return;
if (activeElements && activeElements.length > 0) {
const index = activeElements[0].index;
const label = labels[index];
const val = data[index];
valEl.innerText = val + '%';
subEl.innerText = label;
subEl.style.color = colors[index];
subEl.style.fontWeight = '700';
} else {
valEl.innerText = typeof defaultVal === 'number' ? defaultVal + (gauge ? '%' : '') : String(defaultVal);
subEl.innerText = defaultSub;
subEl.style.color = '';
subEl.style.fontWeight = '';
}
}
}
});
this.chartInstances.push(chart);
}
barWidth(pct: number): string {
return Math.min(pct, 100) + '%';
}
barHeight(pct: number): string {
return Math.min(pct, 100) + '%';
}
hasCriteria(d: any): boolean {
return (d.postGenderType && d.postGenderType !== 'ANY') || d.postMinAge || d.postMaxAge || d.postMinShareHolding;
}
eligiblePct(d: any): number {
if (!d?.totalActiveShareholders) return 0;
return Math.round((d.actualEligibleVoters / d.totalActiveShareholders) * 100);
}
activeLoanPct(d: any): number {
if (!d?.totalActiveShareholders) return 0;
return Math.round((d.activeLoanHolders / d.totalActiveShareholders) * 100);
}
activeSavingsPct(d: any): number {
if (!d?.totalActiveShareholders) return 0;
return Math.round((d.activeSavingsHolders / d.totalActiveShareholders) * 100);
}
crossMatchPct(d: any): number {
if (!d?.totalActiveShareholders) return 0;
return Math.round((d.crossMatchCount / d.totalActiveShareholders) * 100);
}
casteMatchPct(d: any): number {
if (!d?.totalActiveShareholders) return 0;
return Math.round((d.casteMatchCount / d.totalActiveShareholders) * 100);
}
areaMatchPct(d: any): number {
if (!d?.totalActiveShareholders) return 0;
return Math.round((d.areaMatchCount / d.totalActiveShareholders) * 100);
}
itemColor(item: any, idx: number, accent: string, palette: string[]): string {
if (item.match) return accent;
return palette[idx % palette.length];
}
}
@@ -111,6 +111,9 @@
</div>
<div class="candidate-actions" (click)="$event.stopPropagation()">
<a [routerLink]="['/election', electionId, 'candidates', candidate.id, 'analysis']" class="btn-analyse">
<mat-icon>analytics</mat-icon> Analyse
</a>
@if (!candidate.isActive) {
<span class="removed-badge">Removed</span>
} @else {
@@ -303,8 +303,33 @@
.candidate-actions {
display: flex;
justify-content: center;
gap: 6px;
margin-top: 6px;
.btn-analyse {
display: inline-flex;
align-items: center;
gap: 3px;
background: #eff6ff;
color: #2563eb;
border: 1px solid #bfdbfe;
padding: 4px 10px;
border-radius: 6px;
font-size: 11px;
text-decoration: none;
cursor: pointer;
mat-icon {
font-size: 13px;
height: 13px;
width: 13px;
}
&:hover {
background: #dbeafe;
}
}
.btn-remove {
display: inline-flex;
align-items: center;
@@ -81,6 +81,14 @@ const routes: Routes = [
(m) => m.ElectionAuditLogComponent
),
data: { title: 'Election Audit Log' }
},
{
path: ':electionId/candidates/:candidateId/analysis',
loadComponent: () =>
import('./components/candidate-analysis/candidate-analysis.component').then(
(m) => m.CandidateAnalysisComponent
),
data: { title: 'Candidate Voter Analysis' }
}
]
}
+6
View File
@@ -157,6 +157,12 @@ export class ElectionService {
return this.http.get<any>(`/election/${electionId}/vote-summary`).pipe(catchError(() => of(null)));
}
getCandidateVoterAnalysis(electionId: number, candidateId: number): Observable<any> {
return this.http
.get<any>(`/election/${electionId}/candidates/${candidateId}/voter-analysis`)
.pipe(catchError(() => of(null)));
}
getAuditLog(
electionId: number,
params?: { entityType?: string; action?: string; search?: string; page?: number; size?: number }
+2 -2
View File
@@ -9,8 +9,8 @@
/* tslint:disable */
export default {
'mifos_x': {
'version': '260623',
'hash': '3d9b0480'
'version': '260626',
'hash': '9e33392b'
},
'allow_switching_backend_instance': true
};