feat(election): election duration, director tenure auto-fill, and board UX improvements
- Add durationYears field to election config with Board Active Until display on dashboard - Auto-fill director tenure start (from resultDate) and end (from durationEndDate) when election has duration set; both fields readonly with Auto badge - Hide Status field on new appointment form (only shown on edit) - Change Remove button from hard delete to soft delete (status REMOVED) with audit-safe confirm - Fix isTenureExpired() template call replaced with precomputed isExpired property Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
a44238774f
commit
228df2f32c
@@ -106,7 +106,7 @@
|
||||
<span>{{ d.designation || '—' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="tenure-row">
|
||||
<div class="tenure-row" [class.tenure-expired]="d.isExpired">
|
||||
<div class="card-field">
|
||||
<mat-icon>event_available</mat-icon>
|
||||
<div>
|
||||
@@ -119,7 +119,12 @@
|
||||
<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>
|
||||
<span class="field-val" [class.expired-date]="d.isExpired">
|
||||
{{ d.tenureEndDate ? (d.tenureEndDate | date: 'dd MMM yyyy') : '—' }}
|
||||
@if (d.isExpired) {
|
||||
<span class="expired-tag">Expired</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -141,8 +146,12 @@
|
||||
|
||||
<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>
|
||||
<button
|
||||
class="btn-delete"
|
||||
(click)="confirmRemove(d)"
|
||||
[disabled]="deleting === d.id || d.status === 'REMOVED'"
|
||||
>
|
||||
<mat-icon>person_remove</mat-icon>
|
||||
{{ deleting === d.id ? 'Removing...' : 'Remove' }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -273,15 +282,17 @@
|
||||
<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>
|
||||
@if (editingId) {
|
||||
<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">
|
||||
@@ -302,33 +313,59 @@
|
||||
<!-- 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>
|
||||
<mat-form-field appearance="outline" [class.field-auto-filled]="isTenureStartAutoFilled">
|
||||
<mat-label>
|
||||
Tenure Start Date <span class="req">*</span>
|
||||
@if (isTenureStartAutoFilled) {
|
||||
<span class="auto-badge">Auto</span>
|
||||
}
|
||||
</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[matDatepicker]="tenureStart"
|
||||
[(ngModel)]="form.tenureStartDate"
|
||||
[min]="today"
|
||||
[readonly]="isTenureStartAutoFilled"
|
||||
(ngModelChange)="onTenureStartChange($event)"
|
||||
required
|
||||
/>
|
||||
<mat-datepicker-toggle matSuffix [for]="tenureStart"></mat-datepicker-toggle>
|
||||
<mat-datepicker #tenureStart></mat-datepicker>
|
||||
<mat-datepicker-toggle
|
||||
matSuffix
|
||||
[for]="tenureStart"
|
||||
[disabled]="isTenureStartAutoFilled"
|
||||
></mat-datepicker-toggle>
|
||||
<mat-datepicker #tenureStart [disabled]="isTenureStartAutoFilled"></mat-datepicker>
|
||||
@if (isTenureStartAutoFilled) {
|
||||
<mat-hint>Derived from election result date</mat-hint>
|
||||
}
|
||||
@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>
|
||||
<mat-form-field appearance="outline" [class.field-auto-filled]="isTenureEndAutoFilled">
|
||||
<mat-label>
|
||||
Tenure End Date <span class="req">*</span>
|
||||
@if (isTenureEndAutoFilled) {
|
||||
<span class="auto-badge">Auto</span>
|
||||
}
|
||||
</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[matDatepicker]="tenureEnd"
|
||||
[(ngModel)]="form.tenureEndDate"
|
||||
[min]="form.tenureStartDate || today"
|
||||
[readonly]="isTenureEndAutoFilled"
|
||||
required
|
||||
/>
|
||||
<mat-datepicker-toggle matSuffix [for]="tenureEnd"></mat-datepicker-toggle>
|
||||
<mat-datepicker #tenureEnd></mat-datepicker>
|
||||
<mat-datepicker-toggle
|
||||
matSuffix
|
||||
[for]="tenureEnd"
|
||||
[disabled]="isTenureEndAutoFilled"
|
||||
></mat-datepicker-toggle>
|
||||
<mat-datepicker #tenureEnd [disabled]="isTenureEndAutoFilled"></mat-datepicker>
|
||||
@if (isTenureEndAutoFilled) {
|
||||
<mat-hint>Derived from election duration ({{ selectedElection?.durationYears }} yr)</mat-hint>
|
||||
}
|
||||
@if (submitted && !form.tenureEndDate) {
|
||||
<mat-error>Tenure end date is required</mat-error>
|
||||
}
|
||||
|
||||
@@ -1032,6 +1032,56 @@ $font:
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.auto-badge {
|
||||
display: inline-block;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
padding: 1px 6px;
|
||||
border-radius: 6px;
|
||||
letter-spacing: 0.3px;
|
||||
text-transform: uppercase;
|
||||
margin-left: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.tenure-expired {
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
.expired-date {
|
||||
color: #dc2626;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.expired-tag {
|
||||
display: inline-block;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
padding: 1px 6px;
|
||||
border-radius: 6px;
|
||||
letter-spacing: 0.3px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.field-auto-filled {
|
||||
::ng-deep .mat-mdc-form-field-flex {
|
||||
background: #f0fdf4 !important;
|
||||
}
|
||||
|
||||
::ng-deep .mdc-notched-outline__leading,
|
||||
::ng-deep .mdc-notched-outline__notch,
|
||||
::ng-deep .mdc-notched-outline__trailing {
|
||||
border-color: #86efac !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 600px) {
|
||||
.board-page {
|
||||
padding: 16px;
|
||||
|
||||
@@ -77,6 +77,7 @@ export class DirectorBoardComponent implements OnInit {
|
||||
saving = false;
|
||||
deleting: number | null = null;
|
||||
submitted = false;
|
||||
selectedElection: any = null;
|
||||
|
||||
// ── Step-1: election + WON candidates ─────────────────────────
|
||||
elections: any[] = [];
|
||||
@@ -115,6 +116,8 @@ export class DirectorBoardComponent implements OnInit {
|
||||
|
||||
pickPending(p: any): void {
|
||||
this.editingId = null;
|
||||
this.selectedElectionId = p.electionId;
|
||||
this.selectedElection = null;
|
||||
this.form = {
|
||||
...this.emptyForm(),
|
||||
electionId: p.electionId,
|
||||
@@ -122,9 +125,17 @@ export class DirectorBoardComponent implements OnInit {
|
||||
candidateName: p.candidateName,
|
||||
postName: p.postName
|
||||
};
|
||||
this.selectedElectionId = p.electionId;
|
||||
this.formStep = 2;
|
||||
this.showForm = true;
|
||||
this.service.getElection(p.electionId).subscribe((election: any) => {
|
||||
this.selectedElection = election;
|
||||
if (election?.durationEndDate) {
|
||||
this.form.tenureEndDate = new Date(election.durationEndDate);
|
||||
if (election?.resultDate) {
|
||||
this.form.tenureStartDate = new Date(election.resultDate);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
load(): void {
|
||||
@@ -133,7 +144,11 @@ export class DirectorBoardComponent implements OnInit {
|
||||
.getDirectors({ status: this.filterStatus || undefined, page: this.page, size: this.pageSize })
|
||||
.subscribe({
|
||||
next: (data: any) => {
|
||||
this.directors = data?.content ?? [];
|
||||
const todayStr = new Date().toISOString().slice(0, 10);
|
||||
this.directors = (data?.content ?? []).map((d: any) => ({
|
||||
...d,
|
||||
isExpired: d.tenureEndDate ? d.tenureEndDate < todayStr : false
|
||||
}));
|
||||
this.totalElements = data?.totalElements ?? 0;
|
||||
this.loading = false;
|
||||
},
|
||||
@@ -154,6 +169,7 @@ export class DirectorBoardComponent implements OnInit {
|
||||
openAppoint(): void {
|
||||
this.submitted = false;
|
||||
this.editingId = null;
|
||||
this.selectedElection = null;
|
||||
this.form = this.emptyForm();
|
||||
this.formStep = 1;
|
||||
this.selectedElectionId = null;
|
||||
@@ -172,6 +188,7 @@ export class DirectorBoardComponent implements OnInit {
|
||||
|
||||
selectElection(electionId: number): void {
|
||||
this.selectedElectionId = electionId;
|
||||
this.selectedElection = this.elections.find((e) => e.id === electionId) ?? null;
|
||||
this.wonCandidates = [];
|
||||
this.wonLoading = true;
|
||||
this.service.getResults(electionId).subscribe((results: any[]) => {
|
||||
@@ -181,12 +198,21 @@ export class DirectorBoardComponent implements OnInit {
|
||||
}
|
||||
|
||||
pickCandidate(candidate: any): void {
|
||||
const tenureEndDate = this.selectedElection?.durationEndDate
|
||||
? new Date(this.selectedElection.durationEndDate)
|
||||
: null;
|
||||
const tenureStartDate =
|
||||
this.selectedElection?.durationEndDate && this.selectedElection?.resultDate
|
||||
? new Date(this.selectedElection.resultDate)
|
||||
: null;
|
||||
this.form = {
|
||||
...this.emptyForm(),
|
||||
electionId: this.selectedElectionId,
|
||||
candidateId: candidate.candidateId,
|
||||
candidateName: candidate.candidateName,
|
||||
postName: candidate.postName
|
||||
postName: candidate.postName,
|
||||
tenureStartDate,
|
||||
tenureEndDate
|
||||
};
|
||||
this.formStep = 2;
|
||||
}
|
||||
@@ -197,15 +223,26 @@ export class DirectorBoardComponent implements OnInit {
|
||||
|
||||
onTenureStartChange(val: Date | null): void {
|
||||
if (!val) return;
|
||||
if (this.isTenureStartAutoFilled) return;
|
||||
const end = new Date(val);
|
||||
end.setFullYear(end.getFullYear() + 5);
|
||||
this.form.tenureEndDate = end;
|
||||
}
|
||||
|
||||
get isTenureStartAutoFilled(): boolean {
|
||||
return !!this.selectedElection?.durationEndDate && !!this.selectedElection?.resultDate;
|
||||
}
|
||||
|
||||
get isTenureEndAutoFilled(): boolean {
|
||||
return !!this.selectedElection?.durationEndDate;
|
||||
}
|
||||
|
||||
// ── Open edit drawer (skip step 1) ────────────────────────────
|
||||
openEdit(d: any): void {
|
||||
this.submitted = false;
|
||||
this.editingId = d.id;
|
||||
this.selectedElectionId = d.electionId;
|
||||
this.selectedElection = null;
|
||||
this.form = {
|
||||
electionId: d.electionId,
|
||||
candidateId: d.candidateId,
|
||||
@@ -221,6 +258,12 @@ export class DirectorBoardComponent implements OnInit {
|
||||
};
|
||||
this.formStep = 2;
|
||||
this.showForm = true;
|
||||
this.service.getElection(d.electionId).subscribe((election: any) => {
|
||||
this.selectedElection = election;
|
||||
if (election?.durationEndDate && !this.form.tenureEndDate) {
|
||||
this.form.tenureEndDate = new Date(election.durationEndDate);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
closeForm(): void {
|
||||
@@ -229,6 +272,7 @@ export class DirectorBoardComponent implements OnInit {
|
||||
this.form = this.emptyForm();
|
||||
this.formStep = 1;
|
||||
this.submitted = false;
|
||||
this.selectedElection = null;
|
||||
}
|
||||
|
||||
save(): void {
|
||||
@@ -273,20 +317,29 @@ export class DirectorBoardComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
});
|
||||
confirmRemove(d: any): void {
|
||||
if (!confirm(`Mark ${d.candidateName} as Removed? This will update their status but keep the record for audit.`))
|
||||
return;
|
||||
this.deleting = d.id;
|
||||
this.service
|
||||
.updateDirector(d.id, {
|
||||
...d,
|
||||
status: 'REMOVED',
|
||||
appointmentDate: d.appointmentDate,
|
||||
tenureStartDate: d.tenureStartDate,
|
||||
tenureEndDate: d.tenureEndDate
|
||||
})
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.deleting = null;
|
||||
this.load();
|
||||
this.snackBar.open('Director marked as Removed.', 'Close', { duration: 3000 });
|
||||
},
|
||||
error: () => {
|
||||
this.deleting = null;
|
||||
this.snackBar.open('Failed to remove director.', 'Close', { duration: 4000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get totalPages(): number {
|
||||
|
||||
@@ -91,6 +91,12 @@
|
||||
<mat-datepicker #resDate></mat-datepicker>
|
||||
<mat-error *ngIf="basicForm.errors?.['resultBeforeElection']">Must be after Election Date</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Duration (Years)</mat-label>
|
||||
<input matInput type="number" formControlName="durationYears" min="1" max="20" placeholder="e.g. 5" />
|
||||
<mat-hint>Elected board tenure. No new election until this period expires.</mat-hint>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="step-actions">
|
||||
@@ -190,6 +196,12 @@
|
||||
{{ basicForm.value.resultDate ? (basicForm.value.resultDate | date: 'dd MMM yyyy') : '—' }}
|
||||
</span>
|
||||
</div>
|
||||
@if (basicForm.value.durationYears) {
|
||||
<div class="review-item">
|
||||
<span class="review-label">Duration</span>
|
||||
<span class="review-value">{{ basicForm.value.durationYears }} year(s)</span>
|
||||
</div>
|
||||
}
|
||||
@if (basicForm.value.description) {
|
||||
<div class="review-item full-span">
|
||||
<span class="review-label">Description</span>
|
||||
|
||||
@@ -94,7 +94,8 @@ export class ElectionConfigComponent implements OnInit {
|
||||
null,
|
||||
Validators.required
|
||||
],
|
||||
resultDate: [null]
|
||||
resultDate: [null],
|
||||
durationYears: [null]
|
||||
},
|
||||
{ validators: this.dateOrderValidator() }
|
||||
);
|
||||
|
||||
@@ -82,6 +82,15 @@
|
||||
<span class="date-value">{{ election.electionDate | date: 'dd MMM yyyy' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@if (election.durationEndDate) {
|
||||
<div class="date-item">
|
||||
<mat-icon>schedule</mat-icon>
|
||||
<div>
|
||||
<span class="date-label">Board Active Until</span>
|
||||
<span class="date-value">{{ election.durationEndDate | date: 'dd MMM yyyy' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (election.description) {
|
||||
|
||||
Reference in New Issue
Block a user