feat(election): Board of Directors module — appoint, manage, and track elected directors
- New director-board component: 2-step drawer (pick WON candidate → fill appointment details) - Pending appointments section: highlights WON candidates not yet given a designation - mat-datepicker for all date fields; tenure end auto-fills to +5 years from start - Frontend validation: required field indicators + inline mat-error on submit attempt - Past dates disabled via [min]="today" on all date pickers - mat-select for Designation and Status (CBS outline style) - Election service: appointDirector, updateDirector, deleteDirector, getDirectors, getPendingDirectors - Route added: /election/directors; nav link in election dashboard - Error handler: suppress 404 toast for /election/directors (backend not yet deployed guard) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
bc513e7c89
commit
a44238774f
@@ -53,7 +53,8 @@ export class ErrorHandlerInterceptor implements HttpInterceptor {
|
||||
|
||||
const isClientImageRequest = request.url.includes('/clients/') && request.url.includes('/images');
|
||||
const isClientKycRequest = request.url.match(/\/clients\/\d+\/kyc$/);
|
||||
const isSilentRequest = isClientImageRequest || isClientKycRequest;
|
||||
const isDirectorRequest = request.url.includes('/election/directors');
|
||||
const isSilentRequest = isClientImageRequest || isClientKycRequest || isDirectorRequest;
|
||||
|
||||
if (!environment.production && !isSilentRequest) {
|
||||
log.error(`Request Error: ${errorMessage}`);
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
<!--
|
||||
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="board-page">
|
||||
<!-- Header -->
|
||||
<div class="board-header">
|
||||
<div class="header-left">
|
||||
<a routerLink="/election" class="back-btn">
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
</a>
|
||||
<div>
|
||||
<h2>Board of Directors</h2>
|
||||
<p class="header-sub">Registry of elected directors, their designations and tenures</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-appoint" (click)="openAppoint()">
|
||||
<mat-icon>person_add</mat-icon>
|
||||
<span>Appoint Director</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Pending Appointments -->
|
||||
@if (!pendingLoading && pendingCandidates.length > 0) {
|
||||
<div class="pending-section">
|
||||
<div class="pending-header">
|
||||
<mat-icon class="pending-icon">pending_actions</mat-icon>
|
||||
<div>
|
||||
<h4>Pending Appointments</h4>
|
||||
<p>{{ pendingCandidates.length }} won candidate(s) awaiting designation</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pending-list">
|
||||
@for (p of pendingCandidates; track p.candidateId) {
|
||||
<div class="pending-card">
|
||||
<div class="pending-avatar">{{ p.candidateName?.charAt(0) }}</div>
|
||||
<div class="pending-info">
|
||||
<span class="pending-name">{{ p.candidateName }}</span>
|
||||
<span class="pending-meta">{{ p.postName }} · {{ p.electionTitle }}</span>
|
||||
</div>
|
||||
<div class="pending-votes">
|
||||
<mat-icon>emoji_events</mat-icon>
|
||||
<span>{{ p.voteCount }} votes</span>
|
||||
</div>
|
||||
<button class="btn-appoint-now" (click)="pickPending(p)">
|
||||
<mat-icon>person_add</mat-icon>
|
||||
Appoint
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="filter-bar">
|
||||
@for (s of statusOptions; track s) {
|
||||
<button class="filter-chip" [class.active]="filterStatus === s" (click)="filterStatus = s; applyFilter()">
|
||||
{{ statusLabels[s] }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
@if (loading) {
|
||||
<div class="loading-state">
|
||||
<div class="spinner"></div>
|
||||
<p>Loading board registry...</p>
|
||||
</div>
|
||||
} @else if (directors.length === 0) {
|
||||
<div class="empty-state">
|
||||
<mat-icon class="empty-icon">groups</mat-icon>
|
||||
<h3>No directors appointed yet</h3>
|
||||
<p>Select a won candidate from election results to appoint as director.</p>
|
||||
<button class="btn-appoint" (click)="openAppoint()">
|
||||
<mat-icon>person_add</mat-icon>
|
||||
<span>Appoint First Director</span>
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<!-- Director Cards Grid -->
|
||||
<div class="directors-grid">
|
||||
@for (d of directors; track d.id) {
|
||||
<div class="director-card">
|
||||
<div class="card-top">
|
||||
<div class="director-avatar">{{ d.candidateName?.charAt(0) }}</div>
|
||||
<div class="director-info">
|
||||
<h4>{{ d.candidateName }}</h4>
|
||||
<span class="post-badge">{{ d.postName }}</span>
|
||||
</div>
|
||||
<span
|
||||
class="status-chip"
|
||||
[style.background-color]="statusColor(d.status) + '20'"
|
||||
[style.color]="statusColor(d.status)"
|
||||
>
|
||||
{{ d.status }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="card-field designation-field">
|
||||
<mat-icon>badge</mat-icon>
|
||||
<span>{{ d.designation || '—' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="tenure-row">
|
||||
<div class="card-field">
|
||||
<mat-icon>event_available</mat-icon>
|
||||
<div>
|
||||
<span class="field-label">Tenure Start</span>
|
||||
<span class="field-val">{{ d.tenureStartDate ? (d.tenureStartDate | date: 'dd MMM yyyy') : '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tenure-divider">→</div>
|
||||
<div class="card-field">
|
||||
<mat-icon>event_busy</mat-icon>
|
||||
<div>
|
||||
<span class="field-label">Tenure End</span>
|
||||
<span class="field-val">{{ d.tenureEndDate ? (d.tenureEndDate | date: 'dd MMM yyyy') : '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="meta-row">
|
||||
@if (d.resolutionNo) {
|
||||
<span class="meta-tag"><mat-icon>description</mat-icon>{{ d.resolutionNo }}</span>
|
||||
}
|
||||
@if (d.appointmentDate) {
|
||||
<span class="meta-tag"
|
||||
><mat-icon>calendar_today</mat-icon>{{ d.appointmentDate | date: 'dd MMM yyyy' }}</span
|
||||
>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (d.remarks) {
|
||||
<p class="remarks-text">{{ d.remarks }}</p>
|
||||
}
|
||||
|
||||
<div class="card-actions">
|
||||
<button class="btn-edit" (click)="openEdit(d)"><mat-icon>edit</mat-icon> Edit</button>
|
||||
<button class="btn-delete" (click)="confirmDelete(d.id)" [disabled]="deleting === d.id">
|
||||
<mat-icon>delete_outline</mat-icon>
|
||||
{{ deleting === d.id ? 'Removing...' : 'Remove' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (totalPages > 1) {
|
||||
<div class="pagination">
|
||||
<button class="page-btn" (click)="prevPage()" [disabled]="page === 0">
|
||||
<mat-icon>chevron_left</mat-icon>
|
||||
</button>
|
||||
<span class="page-info">Page {{ page + 1 }} of {{ totalPages }} · {{ totalElements }} records</span>
|
||||
<button class="page-btn" (click)="nextPage()" [disabled]="page >= totalPages - 1">
|
||||
<mat-icon>chevron_right</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- ── Drawer Overlay ── -->
|
||||
@if (showForm) {
|
||||
<div class="overlay" (click)="closeForm()"></div>
|
||||
|
||||
<div class="form-drawer">
|
||||
<!-- Drawer Header -->
|
||||
<div class="drawer-header">
|
||||
<div class="drawer-title-row">
|
||||
@if (formStep === 2 && !editingId) {
|
||||
<button class="icon-btn" (click)="backToStep1()">
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
</button>
|
||||
}
|
||||
<div>
|
||||
<h3>{{ editingId ? 'Edit Director' : formStep === 1 ? 'Select Won Candidate' : 'Appointment Details' }}</h3>
|
||||
@if (!editingId) {
|
||||
<p class="step-hint">Step {{ formStep }} of 2</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<button class="close-btn" (click)="closeForm()"><mat-icon>close</mat-icon></button>
|
||||
</div>
|
||||
|
||||
<!-- ── STEP 1: Select Won Candidate ── -->
|
||||
@if (formStep === 1) {
|
||||
<div class="drawer-body">
|
||||
<!-- Election Selector -->
|
||||
@if (electionsLoading) {
|
||||
<div class="loading-state small">
|
||||
<div class="spinner"></div>
|
||||
<p>Loading elections...</p>
|
||||
</div>
|
||||
} @else {
|
||||
<mat-form-field appearance="outline" class="full-field">
|
||||
<mat-label>Select Election</mat-label>
|
||||
<mat-select [(ngModel)]="selectedElectionId" (ngModelChange)="selectElection($event)">
|
||||
<mat-option [value]="null">— Choose an election —</mat-option>
|
||||
@for (e of elections; track e.id) {
|
||||
<mat-option [value]="e.id">{{ e.title }} ({{ e.status }})</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
<!-- WON Candidates List -->
|
||||
@if (wonLoading) {
|
||||
<div class="loading-state small">
|
||||
<div class="spinner"></div>
|
||||
<p>Loading results...</p>
|
||||
</div>
|
||||
} @else if (selectedElectionId && wonCandidates.length === 0) {
|
||||
<div class="empty-won">
|
||||
<mat-icon>info</mat-icon>
|
||||
<span>No won candidates found for this election.</span>
|
||||
</div>
|
||||
} @else if (wonCandidates.length > 0) {
|
||||
<div class="form-section-title">Won Candidates — click to select</div>
|
||||
<div class="won-list">
|
||||
@for (c of wonCandidates; track c.candidateId) {
|
||||
<button class="won-card" (click)="pickCandidate(c)">
|
||||
<div class="won-avatar">{{ c.candidateName?.charAt(0) }}</div>
|
||||
<div class="won-info">
|
||||
<span class="won-name">{{ c.candidateName }}</span>
|
||||
<span class="won-post">{{ c.postName }}</span>
|
||||
</div>
|
||||
<div class="won-votes">
|
||||
<mat-icon class="trophy">emoji_events</mat-icon>
|
||||
<span>{{ c.voteCount }} votes</span>
|
||||
</div>
|
||||
<mat-icon class="pick-arrow">chevron_right</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="drawer-footer">
|
||||
<button class="btn-cancel" (click)="closeForm()">Cancel</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ── STEP 2: Appointment Details ── -->
|
||||
@if (formStep === 2) {
|
||||
<div class="drawer-body">
|
||||
<!-- Auto-filled candidate info banner -->
|
||||
<div class="selected-candidate-banner">
|
||||
<div class="selected-avatar">{{ form.candidateName?.charAt(0) }}</div>
|
||||
<div>
|
||||
<span class="selected-name">{{ form.candidateName }}</span>
|
||||
<span class="selected-post">{{ form.postName }}</span>
|
||||
</div>
|
||||
<mat-icon class="check-icon">verified</mat-icon>
|
||||
</div>
|
||||
|
||||
<!-- Appointment Details -->
|
||||
<div class="form-section-title">Appointment Details</div>
|
||||
<div class="form-row">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Designation <span class="req">*</span></mat-label>
|
||||
<mat-select [(ngModel)]="form.designation" #desig="ngModel" required>
|
||||
<mat-option value="">— Select —</mat-option>
|
||||
@for (opt of designationOptions; track opt) {
|
||||
<mat-option [value]="opt">{{ opt }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
@if (submitted && !form.designation) {
|
||||
<mat-error>Designation is required</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Status</mat-label>
|
||||
<mat-select [(ngModel)]="form.status">
|
||||
<mat-option value="ACTIVE">Active</mat-option>
|
||||
<mat-option value="RESIGNED">Resigned</mat-option>
|
||||
<mat-option value="REMOVED">Removed</mat-option>
|
||||
<mat-option value="TENURE_ENDED">Tenure Ended</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Appointment Date <span class="req">*</span></mat-label>
|
||||
<input matInput [matDatepicker]="apptDate" [(ngModel)]="form.appointmentDate" [min]="today" required />
|
||||
<mat-datepicker-toggle matSuffix [for]="apptDate"></mat-datepicker-toggle>
|
||||
<mat-datepicker #apptDate></mat-datepicker>
|
||||
@if (submitted && !form.appointmentDate) {
|
||||
<mat-error>Appointment date is required</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Resolution No.</mat-label>
|
||||
<input matInput [(ngModel)]="form.resolutionNo" placeholder="e.g. AGM/2026/001" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<!-- Tenure -->
|
||||
<div class="form-section-title">Tenure Period</div>
|
||||
<div class="form-row">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Tenure Start Date <span class="req">*</span></mat-label>
|
||||
<input
|
||||
matInput
|
||||
[matDatepicker]="tenureStart"
|
||||
[(ngModel)]="form.tenureStartDate"
|
||||
[min]="today"
|
||||
(ngModelChange)="onTenureStartChange($event)"
|
||||
required
|
||||
/>
|
||||
<mat-datepicker-toggle matSuffix [for]="tenureStart"></mat-datepicker-toggle>
|
||||
<mat-datepicker #tenureStart></mat-datepicker>
|
||||
@if (submitted && !form.tenureStartDate) {
|
||||
<mat-error>Tenure start date is required</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Tenure End Date <span class="req">*</span></mat-label>
|
||||
<input
|
||||
matInput
|
||||
[matDatepicker]="tenureEnd"
|
||||
[(ngModel)]="form.tenureEndDate"
|
||||
[min]="form.tenureStartDate || today"
|
||||
required
|
||||
/>
|
||||
<mat-datepicker-toggle matSuffix [for]="tenureEnd"></mat-datepicker-toggle>
|
||||
<mat-datepicker #tenureEnd></mat-datepicker>
|
||||
@if (submitted && !form.tenureEndDate) {
|
||||
<mat-error>Tenure end date is required</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<mat-form-field appearance="outline" class="full-field">
|
||||
<mat-label>Remarks</mat-label>
|
||||
<textarea matInput [(ngModel)]="form.remarks" rows="3" placeholder="Additional notes..."></textarea>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="drawer-footer">
|
||||
<button class="btn-cancel" (click)="closeForm()">Cancel</button>
|
||||
<button class="btn-save" (click)="save()" [disabled]="saving">
|
||||
<mat-icon>{{ saving ? 'hourglass_empty' : 'save' }}</mat-icon>
|
||||
{{ saving ? 'Saving...' : editingId ? 'Update' : 'Appoint' }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,343 @@
|
||||
/**
|
||||
* 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, DatePipe } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||
import { MatNativeDateModule } from '@angular/material/core';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { ElectionService } from '../../election.service';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-director-board',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
RouterModule,
|
||||
MatIconModule,
|
||||
MatSnackBarModule,
|
||||
MatDatepickerModule,
|
||||
MatNativeDateModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
DatePipe
|
||||
],
|
||||
templateUrl: './director-board.component.html',
|
||||
styleUrls: ['./director-board.component.scss']
|
||||
})
|
||||
export class DirectorBoardComponent implements OnInit {
|
||||
private service = inject(ElectionService);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
|
||||
// ── Pending appointments ───────────────────────────────────────
|
||||
pendingCandidates: any[] = [];
|
||||
pendingLoading = true;
|
||||
|
||||
// ── Director list ──────────────────────────────────────────────
|
||||
directors: any[] = [];
|
||||
loading = true;
|
||||
totalElements = 0;
|
||||
page = 0;
|
||||
pageSize = 20;
|
||||
|
||||
filterStatus = '';
|
||||
statusOptions = [
|
||||
'',
|
||||
'ACTIVE',
|
||||
'RESIGNED',
|
||||
'REMOVED',
|
||||
'TENURE_ENDED'
|
||||
];
|
||||
statusLabels: Record<string, string> = {
|
||||
'': 'All',
|
||||
ACTIVE: 'Active',
|
||||
RESIGNED: 'Resigned',
|
||||
REMOVED: 'Removed',
|
||||
TENURE_ENDED: 'Tenure Ended'
|
||||
};
|
||||
|
||||
// ── Drawer state ───────────────────────────────────────────────
|
||||
showForm = false;
|
||||
/** 1 = pick candidate, 2 = fill details */
|
||||
formStep: 1 | 2 = 1;
|
||||
editingId: number | null = null;
|
||||
saving = false;
|
||||
deleting: number | null = null;
|
||||
submitted = false;
|
||||
|
||||
// ── Step-1: election + WON candidates ─────────────────────────
|
||||
elections: any[] = [];
|
||||
electionsLoading = false;
|
||||
selectedElectionId: number | null = null;
|
||||
wonCandidates: any[] = [];
|
||||
wonLoading = false;
|
||||
|
||||
// ── Step-2: form ───────────────────────────────────────────────
|
||||
form: any = this.emptyForm();
|
||||
|
||||
readonly today: Date = new Date();
|
||||
|
||||
designationOptions = [
|
||||
'Chairman',
|
||||
'Vice Chairman',
|
||||
'Secretary',
|
||||
'Treasurer',
|
||||
'Director',
|
||||
'Managing Director',
|
||||
'Chief Executive Officer'
|
||||
];
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
this.loadPending();
|
||||
}
|
||||
|
||||
loadPending(): void {
|
||||
this.pendingLoading = true;
|
||||
this.service.getPendingDirectors().subscribe((data: any[]) => {
|
||||
this.pendingCandidates = data ?? [];
|
||||
this.pendingLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
pickPending(p: any): void {
|
||||
this.editingId = null;
|
||||
this.form = {
|
||||
...this.emptyForm(),
|
||||
electionId: p.electionId,
|
||||
candidateId: p.candidateId,
|
||||
candidateName: p.candidateName,
|
||||
postName: p.postName
|
||||
};
|
||||
this.selectedElectionId = p.electionId;
|
||||
this.formStep = 2;
|
||||
this.showForm = true;
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.service
|
||||
.getDirectors({ status: this.filterStatus || undefined, page: this.page, size: this.pageSize })
|
||||
.subscribe({
|
||||
next: (data: any) => {
|
||||
this.directors = data?.content ?? [];
|
||||
this.totalElements = data?.totalElements ?? 0;
|
||||
this.loading = false;
|
||||
},
|
||||
error: () => {
|
||||
this.directors = [];
|
||||
this.totalElements = 0;
|
||||
this.loading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
applyFilter(): void {
|
||||
this.page = 0;
|
||||
this.load();
|
||||
}
|
||||
|
||||
// ── Open appoint drawer (step 1) ───────────────────────────────
|
||||
openAppoint(): void {
|
||||
this.submitted = false;
|
||||
this.editingId = null;
|
||||
this.form = this.emptyForm();
|
||||
this.formStep = 1;
|
||||
this.selectedElectionId = null;
|
||||
this.wonCandidates = [];
|
||||
this.showForm = true;
|
||||
this.loadElections();
|
||||
}
|
||||
|
||||
loadElections(): void {
|
||||
this.electionsLoading = true;
|
||||
this.service.getAllElections(0, 50).subscribe((data: any) => {
|
||||
this.elections = data?.content ?? [];
|
||||
this.electionsLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
selectElection(electionId: number): void {
|
||||
this.selectedElectionId = electionId;
|
||||
this.wonCandidates = [];
|
||||
this.wonLoading = true;
|
||||
this.service.getResults(electionId).subscribe((results: any[]) => {
|
||||
this.wonCandidates = (results ?? []).filter((r) => r.result === 'WON');
|
||||
this.wonLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
pickCandidate(candidate: any): void {
|
||||
this.form = {
|
||||
...this.emptyForm(),
|
||||
electionId: this.selectedElectionId,
|
||||
candidateId: candidate.candidateId,
|
||||
candidateName: candidate.candidateName,
|
||||
postName: candidate.postName
|
||||
};
|
||||
this.formStep = 2;
|
||||
}
|
||||
|
||||
backToStep1(): void {
|
||||
this.formStep = 1;
|
||||
}
|
||||
|
||||
onTenureStartChange(val: Date | null): void {
|
||||
if (!val) return;
|
||||
const end = new Date(val);
|
||||
end.setFullYear(end.getFullYear() + 5);
|
||||
this.form.tenureEndDate = end;
|
||||
}
|
||||
|
||||
// ── Open edit drawer (skip step 1) ────────────────────────────
|
||||
openEdit(d: any): void {
|
||||
this.submitted = false;
|
||||
this.editingId = d.id;
|
||||
this.form = {
|
||||
electionId: d.electionId,
|
||||
candidateId: d.candidateId,
|
||||
candidateName: d.candidateName,
|
||||
postName: d.postName,
|
||||
designation: d.designation ?? '',
|
||||
tenureStartDate: d.tenureStartDate ? new Date(d.tenureStartDate) : null,
|
||||
tenureEndDate: d.tenureEndDate ? new Date(d.tenureEndDate) : null,
|
||||
resolutionNo: d.resolutionNo ?? '',
|
||||
appointmentDate: d.appointmentDate ? new Date(d.appointmentDate) : null,
|
||||
status: d.status ?? 'ACTIVE',
|
||||
remarks: d.remarks ?? ''
|
||||
};
|
||||
this.formStep = 2;
|
||||
this.showForm = true;
|
||||
}
|
||||
|
||||
closeForm(): void {
|
||||
this.showForm = false;
|
||||
this.editingId = null;
|
||||
this.form = this.emptyForm();
|
||||
this.formStep = 1;
|
||||
this.submitted = false;
|
||||
}
|
||||
|
||||
save(): void {
|
||||
this.submitted = true;
|
||||
if (
|
||||
!this.form.designation ||
|
||||
!this.form.appointmentDate ||
|
||||
!this.form.tenureStartDate ||
|
||||
!this.form.tenureEndDate
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.saving = true;
|
||||
const payload = {
|
||||
...this.form,
|
||||
appointmentDate: this.formatDate(this.form.appointmentDate),
|
||||
tenureStartDate: this.formatDate(this.form.tenureStartDate),
|
||||
tenureEndDate: this.formatDate(this.form.tenureEndDate)
|
||||
};
|
||||
const call =
|
||||
this.editingId != null
|
||||
? this.service.updateDirector(this.editingId, payload)
|
||||
: this.service.appointDirector(payload);
|
||||
|
||||
call.subscribe({
|
||||
next: () => {
|
||||
this.saving = false;
|
||||
this.closeForm();
|
||||
this.load();
|
||||
this.loadPending();
|
||||
this.snackBar.open(
|
||||
this.editingId != null ? 'Director updated successfully!' : 'Director appointed successfully!',
|
||||
'Close',
|
||||
{ duration: 3000 }
|
||||
);
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.saving = false;
|
||||
const msg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Operation failed';
|
||||
this.snackBar.open(msg, 'Close', { duration: 5000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
confirmDelete(id: number): void {
|
||||
if (!confirm('Remove this director appointment?')) return;
|
||||
this.deleting = id;
|
||||
this.service.deleteDirector(id).subscribe({
|
||||
next: () => {
|
||||
this.deleting = null;
|
||||
this.load();
|
||||
this.snackBar.open('Appointment removed.', 'Close', { duration: 3000 });
|
||||
},
|
||||
error: () => {
|
||||
this.deleting = null;
|
||||
this.snackBar.open('Failed to remove appointment.', 'Close', { duration: 4000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get totalPages(): number {
|
||||
return Math.ceil(this.totalElements / this.pageSize);
|
||||
}
|
||||
|
||||
prevPage(): void {
|
||||
if (this.page > 0) {
|
||||
this.page--;
|
||||
this.load();
|
||||
}
|
||||
}
|
||||
|
||||
nextPage(): void {
|
||||
if (this.page < this.totalPages - 1) {
|
||||
this.page++;
|
||||
this.load();
|
||||
}
|
||||
}
|
||||
|
||||
statusColor(status: string): string {
|
||||
const map: Record<string, string> = {
|
||||
ACTIVE: '#10b981',
|
||||
RESIGNED: '#f59e0b',
|
||||
REMOVED: '#ef4444',
|
||||
TENURE_ENDED: '#94a3b8'
|
||||
};
|
||||
return map[status] ?? '#94a3b8';
|
||||
}
|
||||
|
||||
private formatDate(date: Date | null): string | null {
|
||||
if (!date) return null;
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
private emptyForm(): any {
|
||||
return {
|
||||
electionId: null,
|
||||
candidateId: null,
|
||||
candidateName: '',
|
||||
postName: '',
|
||||
designation: '',
|
||||
tenureStartDate: null,
|
||||
tenureEndDate: null,
|
||||
resolutionNo: '',
|
||||
appointmentDate: null,
|
||||
status: 'ACTIVE',
|
||||
remarks: ''
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,13 @@
|
||||
<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 class="header-actions">
|
||||
<a routerLink="/election/directors" class="btn-directors">
|
||||
<mat-icon>groups</mat-icon>
|
||||
<span>Board of Directors</span>
|
||||
</a>
|
||||
<a routerLink="/election/config/new" class="btn-primary"> <mat-icon>add</mat-icon> Create Election </a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (loading) {
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
|
||||
.page-title {
|
||||
font-size: 18px;
|
||||
@@ -24,6 +26,40 @@
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-directors {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #fff;
|
||||
color: #475569;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #f1f5f9;
|
||||
color: #0f172a;
|
||||
border-color: #cbd5e1;
|
||||
}
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
|
||||
@@ -91,6 +91,12 @@ const routes: Routes = [
|
||||
(m) => m.ShareholderAnalysisComponent
|
||||
),
|
||||
data: { title: 'Overall Shareholder Analysis' }
|
||||
},
|
||||
{
|
||||
path: 'directors',
|
||||
loadComponent: () =>
|
||||
import('./components/director-board/director-board.component').then((m) => m.DirectorBoardComponent),
|
||||
data: { title: 'Board of Directors' }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -181,4 +181,34 @@ export class ElectionService {
|
||||
.get<any>(`/election/config/${electionId}/audit-log`, { params: qp })
|
||||
.pipe(catchError(() => of({ content: [], totalElements: 0, totalPages: 0, currentPage: 0, pageSize: 20 })));
|
||||
}
|
||||
|
||||
// ── Board of Directors ─────────────────────────────────────────
|
||||
getDirectors(params?: { status?: string; electionId?: number; page?: number; size?: number }): Observable<any> {
|
||||
const qp: Record<string, string> = {};
|
||||
if (params?.status) qp['status'] = params.status;
|
||||
if (params?.electionId) qp['electionId'] = String(params.electionId);
|
||||
if (params?.page !== undefined) qp['page'] = String(params.page);
|
||||
if (params?.size !== undefined) qp['size'] = String(params.size);
|
||||
return this.http.get<any>('/election/directors', { params: qp }).pipe(catchError(() => of({ content: [] })));
|
||||
}
|
||||
|
||||
getDirector(id: number): Observable<any> {
|
||||
return this.http.get<any>(`/election/directors/${id}`);
|
||||
}
|
||||
|
||||
appointDirector(data: any): Observable<any> {
|
||||
return this.http.post('/election/directors', data);
|
||||
}
|
||||
|
||||
updateDirector(id: number, data: any): Observable<any> {
|
||||
return this.http.put(`/election/directors/${id}`, data);
|
||||
}
|
||||
|
||||
deleteDirector(id: number): Observable<any> {
|
||||
return this.http.delete(`/election/directors/${id}`);
|
||||
}
|
||||
|
||||
getPendingDirectors(): Observable<any[]> {
|
||||
return this.http.get<any[]>('/election/directors/pending').pipe(catchError(() => of([])));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
/* tslint:disable */
|
||||
export default {
|
||||
'mifos_x': {
|
||||
'version': '260626',
|
||||
'hash': '9e33392b'
|
||||
'version': '260701',
|
||||
'hash': 'f2f2338c'
|
||||
},
|
||||
'allow_switching_backend_instance': true
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user