feat(election): Chairman/VC auto-resolution, member vote panel, read-only committee summary
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
93f4b9b03f
commit
3d65900fc4
@@ -619,42 +619,29 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="resolution-row">
|
<div class="resolution-row">
|
||||||
<label class="res-label">Final Resolution:</label>
|
<label class="res-label">Resolution:</label>
|
||||||
<div class="res-buttons">
|
@if (item.resolution) {
|
||||||
<button
|
<span
|
||||||
class="btn-res approve"
|
class="res-badge"
|
||||||
[class.active]="resolutionForm[item.agendaId]?.resolution === 'APPROVED'"
|
[class]="
|
||||||
(click)="resolutionForm[item.agendaId].resolution = 'APPROVED'"
|
item.resolution === 'APPROVED'
|
||||||
type="button"
|
? 'res-badge-approved'
|
||||||
|
: item.resolution === 'REJECTED'
|
||||||
|
? 'res-badge-rejected'
|
||||||
|
: 'res-badge-deferred'
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<mat-icon>check_circle</mat-icon> Approved
|
{{
|
||||||
</button>
|
item.resolution === 'APPROVED'
|
||||||
<button
|
? '✅ Approved'
|
||||||
class="btn-res reject"
|
: item.resolution === 'REJECTED'
|
||||||
[class.active]="resolutionForm[item.agendaId]?.resolution === 'REJECTED'"
|
? '❌ Rejected'
|
||||||
(click)="resolutionForm[item.agendaId].resolution = 'REJECTED'"
|
: '⏸ Deferred'
|
||||||
type="button"
|
}}
|
||||||
>
|
</span>
|
||||||
<mat-icon>cancel</mat-icon> Rejected
|
} @else {
|
||||||
</button>
|
<span class="res-badge res-badge-pending">Awaiting Chairman / Vice Chairman vote</span>
|
||||||
<button
|
}
|
||||||
class="btn-res defer"
|
|
||||||
[class.active]="resolutionForm[item.agendaId]?.resolution === 'DEFERRED'"
|
|
||||||
(click)="resolutionForm[item.agendaId].resolution = 'DEFERRED'"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<mat-icon>pause_circle</mat-icon> Deferred
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="notes-row">
|
|
||||||
<textarea
|
|
||||||
class="notes-input"
|
|
||||||
placeholder="Discussion notes (optional)…"
|
|
||||||
rows="2"
|
|
||||||
[(ngModel)]="resolutionForm[item.agendaId].discussionNotes"
|
|
||||||
></textarea>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -663,15 +650,7 @@
|
|||||||
|
|
||||||
<!-- Modal footer -->
|
<!-- Modal footer -->
|
||||||
<div class="vm-footer">
|
<div class="vm-footer">
|
||||||
<button class="btn-cancel" (click)="closeVotePanel()">Cancel</button>
|
<button class="btn-cancel" (click)="closeVotePanel()">Close</button>
|
||||||
<button
|
|
||||||
class="btn-save purple-btn"
|
|
||||||
(click)="saveResolutions()"
|
|
||||||
[disabled]="savingResolutions || voteSummaryLoading"
|
|
||||||
>
|
|
||||||
<mat-icon>{{ savingResolutions ? 'hourglass_empty' : 'send' }}</mat-icon>
|
|
||||||
{{ savingResolutions ? 'Saving...' : 'Save & Send Minutes' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1829,10 +1829,34 @@ $font-family:
|
|||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.res-buttons {
|
.res-badge {
|
||||||
display: flex;
|
display: inline-block;
|
||||||
gap: 6px;
|
padding: 4px 12px;
|
||||||
flex-wrap: wrap;
|
border-radius: 20px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
|
||||||
|
&.res-badge-approved {
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #15803d;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.res-badge-rejected {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.res-badge-deferred {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.res-badge-pending {
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -77,8 +77,6 @@ export class CommitteeBoardComponent implements OnInit {
|
|||||||
votePanelMeeting: any = null;
|
votePanelMeeting: any = null;
|
||||||
voteSummary: any[] = [];
|
voteSummary: any[] = [];
|
||||||
voteSummaryLoading = false;
|
voteSummaryLoading = false;
|
||||||
resolutionForm: Record<number, { resolution: string; discussionNotes: string }> = {};
|
|
||||||
savingResolutions = false;
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.load();
|
this.load();
|
||||||
@@ -653,18 +651,10 @@ export class CommitteeBoardComponent implements OnInit {
|
|||||||
this.votePanelMeeting = meeting;
|
this.votePanelMeeting = meeting;
|
||||||
this.showVotePanel = true;
|
this.showVotePanel = true;
|
||||||
this.voteSummary = [];
|
this.voteSummary = [];
|
||||||
this.resolutionForm = {};
|
|
||||||
this.voteSummaryLoading = true;
|
this.voteSummaryLoading = true;
|
||||||
this.service.getVoteSummary(meeting.id).subscribe({
|
this.service.getVoteSummary(meeting.id).subscribe({
|
||||||
next: (data) => {
|
next: (data) => {
|
||||||
this.voteSummary = data ?? [];
|
this.voteSummary = data ?? [];
|
||||||
this.resolutionForm = {};
|
|
||||||
for (const item of this.voteSummary) {
|
|
||||||
this.resolutionForm[item.agendaId] = {
|
|
||||||
resolution: item.resolution ?? '',
|
|
||||||
discussionNotes: item.discussionNotes ?? ''
|
|
||||||
};
|
|
||||||
}
|
|
||||||
this.voteSummaryLoading = false;
|
this.voteSummaryLoading = false;
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
@@ -680,48 +670,6 @@ export class CommitteeBoardComponent implements OnInit {
|
|||||||
this.voteSummary = [];
|
this.voteSummary = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
saveResolutions(): void {
|
|
||||||
if (this.savingResolutions) return;
|
|
||||||
const meetingId = this.votePanelMeeting?.id;
|
|
||||||
if (!meetingId) return;
|
|
||||||
|
|
||||||
const payload = Object.entries(this.resolutionForm)
|
|
||||||
.filter(
|
|
||||||
([
|
|
||||||
,
|
|
||||||
v
|
|
||||||
]) => v.resolution
|
|
||||||
)
|
|
||||||
.map(
|
|
||||||
([
|
|
||||||
agendaId,
|
|
||||||
v
|
|
||||||
]) => ({
|
|
||||||
agendaId: Number(agendaId),
|
|
||||||
resolution: v.resolution,
|
|
||||||
discussionNotes: v.discussionNotes
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!payload.length) {
|
|
||||||
this.snackBar.open('Please set at least one resolution.', 'Close', { duration: 3000 });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.savingResolutions = true;
|
|
||||||
this.service.saveResolutions(meetingId, payload).subscribe({
|
|
||||||
next: () => {
|
|
||||||
this.savingResolutions = false;
|
|
||||||
this.snackBar.open('Resolutions saved and meeting minutes sent to all members.', 'Close', { duration: 4000 });
|
|
||||||
this.closeVotePanel();
|
|
||||||
},
|
|
||||||
error: () => {
|
|
||||||
this.savingResolutions = false;
|
|
||||||
this.snackBar.open('Failed to save resolutions.', 'Close', { duration: 3000 });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
resolutionIcon(r: string): string {
|
resolutionIcon(r: string): string {
|
||||||
return r === 'APPROVED' ? 'check_circle' : r === 'REJECTED' ? 'cancel' : 'pause_circle';
|
return r === 'APPROVED' ? 'check_circle' : r === 'REJECTED' ? 'cancel' : 'pause_circle';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,7 +126,36 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Remark row — shown only for Approve / Reject -->
|
<!-- Member votes panel — always visible -->
|
||||||
|
@if (item.memberVotes?.length) {
|
||||||
|
<div class="member-votes-panel">
|
||||||
|
<button class="mv-toggle" type="button" (click)="toggleMemberVotes(item.agendaId)">
|
||||||
|
<mat-icon>{{ showMemberVotes[item.agendaId] ? 'expand_less' : 'people' }}</mat-icon>
|
||||||
|
Committee Votes ({{ countVoted(item) }}/{{ item.memberVotes.length }})
|
||||||
|
</button>
|
||||||
|
@if (showMemberVotes[item.agendaId]) {
|
||||||
|
<div class="mv-list">
|
||||||
|
@for (mv of item.memberVotes; track mv.memberName) {
|
||||||
|
<div class="mv-row">
|
||||||
|
<span class="mv-name">{{ mv.memberName }}</span>
|
||||||
|
@if (mv.designation) {
|
||||||
|
<span class="mv-desig">{{ mv.designation }}</span>
|
||||||
|
}
|
||||||
|
@if (mv.vote) {
|
||||||
|
<span class="mv-badge" [class]="'badge-' + mv.vote.toLowerCase()">
|
||||||
|
{{ mv.vote === 'APPROVED' ? '✅' : mv.vote === 'REJECTED' ? '❌' : '—' }}
|
||||||
|
{{ mv.vote }}
|
||||||
|
</span>
|
||||||
|
} @else {
|
||||||
|
<span class="mv-badge badge-pending">Pending</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
@if (selections[item.agendaId] === 'APPROVED' || selections[item.agendaId] === 'REJECTED') {
|
@if (selections[item.agendaId] === 'APPROVED' || selections[item.agendaId] === 'REJECTED') {
|
||||||
<div class="remark-row">
|
<div class="remark-row">
|
||||||
<textarea
|
<textarea
|
||||||
|
|||||||
@@ -196,6 +196,102 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Member votes panel ────────────────────────────────────────────────────────
|
||||||
|
.member-votes-panel {
|
||||||
|
padding: 6px 0 0 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mv-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
background: none;
|
||||||
|
border: 1.5px solid #e2e8f0;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #64748b;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.12s;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #f1f5f9;
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mv-list {
|
||||||
|
margin-top: 6px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mv-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-bottom: 1px solid #f1f5f9;
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mv-name {
|
||||||
|
flex: 1;
|
||||||
|
color: #1e293b;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mv-desig {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #7c3aed;
|
||||||
|
background: #f5f3ff;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mv-badge {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&.badge-approved {
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #15803d;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.badge-rejected {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.badge-abstain {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.badge-pending {
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Remark row ────────────────────────────────────────────────────────────────
|
// ── Remark row ────────────────────────────────────────────────────────────────
|
||||||
.remark-row {
|
.remark-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ export class MeetingVoteComponent implements OnInit, OnDestroy {
|
|||||||
// agendaId → remark text
|
// agendaId → remark text
|
||||||
remarks: Record<number, string> = {};
|
remarks: Record<number, string> = {};
|
||||||
|
|
||||||
|
// agendaId → whether member votes panel is expanded
|
||||||
|
showMemberVotes: Record<number, boolean> = {};
|
||||||
|
|
||||||
// Voice recording state
|
// Voice recording state
|
||||||
recording: number | null = null; // agendaId currently recording
|
recording: number | null = null; // agendaId currently recording
|
||||||
recordingLang: 'hi-IN' | 'en-IN' = 'hi-IN';
|
recordingLang: 'hi-IN' | 'en-IN' = 'hi-IN';
|
||||||
@@ -206,4 +209,12 @@ export class MeetingVoteComponent implements OnInit, OnDestroy {
|
|||||||
voteLabel(vote: string): string {
|
voteLabel(vote: string): string {
|
||||||
return vote === 'APPROVED' ? '✅ Approved' : vote === 'REJECTED' ? '❌ Rejected' : '— Abstain';
|
return vote === 'APPROVED' ? '✅ Approved' : vote === 'REJECTED' ? '❌ Rejected' : '— Abstain';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toggleMemberVotes(agendaId: number): void {
|
||||||
|
this.showMemberVotes[agendaId] = !this.showMemberVotes[agendaId];
|
||||||
|
}
|
||||||
|
|
||||||
|
countVoted(item: any): number {
|
||||||
|
return ((item.memberVotes ?? []) as any[]).filter((mv) => !!mv.vote).length;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user