feat(election): committee management module with image upload, zoom viewer, and member profiles

- Add Committee Board component: create/edit committees with profile image upload
- Image viewer: redesigned zoom dialog with frosted glass toolbar, SVG icons, dark theme
- Committee profile image: circular upload ring, full-size zoom via ImagePreviewDialogComponent
- Member photos: clickable avatars open zoom viewer in both stacked and list views
- Backend: committee API service methods (CRUD, members, dissolve, current directors)
- Route and navigation link added to election dashboard

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jaanu423
2026-07-03 16:38:42 +05:30
co-authored by Claude Sonnet 4.6
parent c4387c5c79
commit 83e9fc9e68
8 changed files with 1597 additions and 67 deletions
@@ -420,16 +420,15 @@ export class JointHolderDialogComponent implements OnInit {
selector: 'mifosx-image-preview-dialog',
template: `
<div class="ipd-wrap">
<div class="ipd-header">
<div class="ipd-topbar">
<span class="ipd-title">{{ data.title }}</span>
<div class="ipd-controls">
<button class="ipd-btn" (click)="zoomOut()" title="Zoom Out">−</button>
<span class="ipd-zoom-label">{{ zoomPct }}%</span>
<button class="ipd-btn" (click)="zoomIn()" title="Zoom In">+</button>
<button class="ipd-btn" (click)="reset()" title="Reset">↺</button>
<button class="ipd-btn ipd-btn--close" (click)="close()">✕</button>
</div>
<button class="ipd-close-btn" (click)="close()" title="Close">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
<path d="M1 1l10 10M11 1L1 11" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
</svg>
</button>
</div>
<div
class="ipd-body"
(wheel)="onWheel($event)"
@@ -437,7 +436,7 @@ export class JointHolderDialogComponent implements OnInit {
(mousemove)="onMouseMove($event)"
(mouseup)="onMouseUp()"
(mouseleave)="onMouseUp()"
[style.cursor]="dragging ? 'grabbing' : zoom > 1 ? 'grab' : 'zoom-in'"
[style.cursor]="dragging ? 'grabbing' : zoom > 1 ? 'grab' : 'default'"
>
<img
[src]="safeUrl"
@@ -447,7 +446,37 @@ export class JointHolderDialogComponent implements OnInit {
(dragstart)="$event.preventDefault()"
/>
</div>
<div class="ipd-hint">Scroll = Zoom &nbsp;·&nbsp; Drag = Pan</div>
<div class="ipd-footer">
<div class="ipd-toolbar">
<button class="ipd-tool" (click)="zoomOut()" [disabled]="zoom <= min" title="Zoom out">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<circle cx="6" cy="6" r="5" stroke="currentColor" stroke-width="1.5" />
<path d="M4 6h4M11 11l-2.5-2.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
</button>
<span class="ipd-zoom-val">{{ zoomPct }}%</span>
<button class="ipd-tool" (click)="zoomIn()" [disabled]="zoom >= max" title="Zoom in">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<circle cx="6" cy="6" r="5" stroke="currentColor" stroke-width="1.5" />
<path d="M6 4v4M4 6h4M11 11l-2.5-2.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
</button>
<div class="ipd-sep"></div>
<button class="ipd-tool" (click)="reset()" title="Reset zoom">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path
d="M2 7a5 5 0 1 0 1.2-3.2M2 3.5V7h3.5"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
</div>
<span class="ipd-hint">Scroll to zoom &nbsp;·&nbsp; Drag to pan</span>
</div>
</div>
`,
styles: [
@@ -455,91 +484,141 @@ export class JointHolderDialogComponent implements OnInit {
.ipd-wrap {
display: flex;
flex-direction: column;
width: 85vw;
max-width: 960px;
border-radius: 10px;
width: min(88vw, 840px);
border-radius: 20px;
overflow: hidden;
background: #07080f;
box-shadow:
0 40px 100px rgba(0, 0, 0, 0.75),
0 0 0 1px rgba(255, 255, 255, 0.06);
}
.ipd-header {
/* ── Top bar ─────────────────────────── */
.ipd-topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
background: #1e293b;
color: #e2e8f0;
gap: 12px;
padding: 14px 18px;
background: rgba(255, 255, 255, 0.03);
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
flex-shrink: 0;
}
.ipd-title {
font-size: 12px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.07em;
letter-spacing: 0.1em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.55);
}
.ipd-controls {
display: flex;
align-items: center;
gap: 6px;
}
.ipd-btn {
width: 32px;
height: 32px;
border-radius: 7px;
border: 1px solid #334155;
background: #0f172a;
color: #e2e8f0;
font-size: 17px;
font-weight: 700;
.ipd-close-btn {
width: 28px;
height: 28px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.5);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.12s;
transition: all 0.15s;
padding: 0;
}
.ipd-btn:hover {
background: #1e40af;
border-color: #3b82f6;
}
.ipd-btn--close {
background: #450a0a;
border-color: #dc2626;
}
.ipd-btn--close:hover {
background: #dc2626;
}
.ipd-zoom-label {
font-size: 12px;
font-weight: 700;
color: #94a3b8;
min-width: 44px;
text-align: center;
.ipd-close-btn:hover {
background: rgba(239, 68, 68, 0.18);
border-color: rgba(239, 68, 68, 0.45);
color: #fca5a5;
}
/* ── Image area ──────────────────────── */
.ipd-body {
background: #0f172a;
background: #040508;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
height: 75vh;
height: 68vh;
user-select: none;
position: relative;
}
.ipd-img {
max-width: 80vw;
max-height: 70vh;
max-width: 82%;
max-height: 64vh;
object-fit: contain;
border-radius: 6px;
border-radius: 10px;
transform-origin: center center;
transition: transform 0.08s ease;
display: block;
transition: transform 0.07s ease;
pointer-events: none;
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.55);
}
.ipd-hint {
background: #0f172a;
color: #475569;
font-size: 11px;
/* ── Footer ──────────────────────────── */
.ipd-footer {
background: rgba(255, 255, 255, 0.02);
border-top: 1px solid rgba(255, 255, 255, 0.06);
padding: 12px 20px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
/* ── Toolbar pill ────────────────────── */
.ipd-toolbar {
display: flex;
align-items: center;
gap: 2px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.09);
border-radius: 50px;
padding: 4px 6px;
}
.ipd-tool {
width: 36px;
height: 36px;
border-radius: 50%;
border: none;
background: transparent;
color: rgba(255, 255, 255, 0.6);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
padding: 0;
flex-shrink: 0;
}
.ipd-tool:hover:not(:disabled) {
background: rgba(99, 102, 241, 0.22);
color: #a5b4fc;
}
.ipd-tool:disabled {
opacity: 0.2;
cursor: not-allowed;
}
.ipd-zoom-val {
font-size: 12px;
font-weight: 700;
color: rgba(255, 255, 255, 0.85);
min-width: 44px;
text-align: center;
padding: 5px;
border-top: 1px solid #1e293b;
font-variant-numeric: tabular-nums;
letter-spacing: -0.01em;
padding: 0 2px;
}
.ipd-sep {
width: 1px;
height: 18px;
background: rgba(255, 255, 255, 0.1);
margin: 0 4px;
flex-shrink: 0;
}
/* ── Hint ────────────────────────────── */
.ipd-hint {
font-size: 10.5px;
color: rgba(255, 255, 255, 0.2);
letter-spacing: 0.03em;
}
`
],
@@ -0,0 +1,241 @@
<!--
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="committee-page">
<!-- Header -->
<div class="page-header">
<div class="header-left">
<a routerLink="/election" class="back-btn"><mat-icon>arrow_back</mat-icon></a>
<div>
<h2>Committees</h2>
<p class="header-sub">Manage committees formed from elected directors</p>
</div>
</div>
<button class="btn-create" (click)="openCreate()"><mat-icon>group_add</mat-icon> New Committee</button>
</div>
<!-- Loading -->
@if (loading) {
<div class="loading-state">
<div class="spinner"></div>
<p>Loading committees...</p>
</div>
} @else if (committees.length === 0) {
<div class="empty-state">
<mat-icon class="empty-icon">groups</mat-icon>
<h3>No committees yet</h3>
<p>Create a committee by selecting directors from the current election.</p>
<button class="btn-create" (click)="openCreate()"><mat-icon>group_add</mat-icon> Create First Committee</button>
</div>
} @else {
<div class="committees-grid">
@for (c of committees; track c.id) {
<div class="committee-card" [class.dissolved]="c.status === 'DISSOLVED'">
<div class="card-header">
<div class="committee-icon">
@if (c.image) {
<img
[src]="c.image"
[alt]="c.name"
class="committee-img clickable-photo"
(click)="openImageViewer(c.image, c.name, $event)"
/>
} @else {
<mat-icon>groups</mat-icon>
}
</div>
<div class="committee-meta">
<h3>{{ c.name }}</h3>
<span
class="status-chip"
[style.background]="statusColor(c.status) + '20'"
[style.color]="statusColor(c.status)"
>{{ c.status }}</span
>
</div>
</div>
@if (c.description) {
<p class="committee-desc">{{ c.description }}</p>
}
<div class="member-count-row">
<mat-icon>person</mat-icon>
<span>{{ c.members?.length ?? 0 }} member{{ (c.members?.length ?? 0) !== 1 ? 's' : '' }}</span>
</div>
<!-- Member Avatars -->
@if (c.members?.length > 0) {
<div class="member-avatars">
@for (m of c.members.slice(0, 5); track m.id) {
<div class="member-avatar" [title]="m.candidateName">
@if (getMemberPhoto(m.memberId)) {
<img
[src]="getMemberPhoto(m.memberId)"
[alt]="m.candidateName"
class="clickable-photo"
(click)="openMemberPhotoViewer(m.memberId, m.candidateName, $event)"
/>
} @else {
{{ m.candidateName?.charAt(0) }}
}
</div>
}
@if (c.members.length > 5) {
<div class="member-avatar more-avatar">+{{ c.members.length - 5 }}</div>
}
</div>
<!-- Member List -->
<div class="member-list">
@for (m of c.members; track m.id) {
<div class="member-row">
<div class="member-avatar-sm">
@if (getMemberPhoto(m.memberId)) {
<img
[src]="getMemberPhoto(m.memberId)"
[alt]="m.candidateName"
class="clickable-photo"
(click)="openMemberPhotoViewer(m.memberId, m.candidateName, $event)"
/>
} @else {
{{ m.candidateName?.charAt(0) }}
}
</div>
<div class="member-info">
<span class="member-name">{{ m.candidateName }}</span>
<span class="member-desig">{{ m.designation || m.postName }}</span>
</div>
</div>
}
</div>
}
<div class="card-actions">
@if (c.status === 'ACTIVE') {
<button class="btn-edit" (click)="openEdit(c)"><mat-icon>edit</mat-icon> Edit</button>
<button class="btn-dissolve" (click)="confirmDissolve(c)"><mat-icon>cancel</mat-icon> Dissolve</button>
} @else {
<span class="dissolved-label"><mat-icon>block</mat-icon> Dissolved</span>
}
</div>
</div>
}
</div>
}
</div>
<!-- Drawer -->
@if (showForm) {
<div class="overlay" (click)="closeForm()"></div>
<div class="form-drawer">
<div class="drawer-header">
<h3>{{ editingId ? 'Edit Committee' : 'New Committee' }}</h3>
<button class="close-btn" (click)="closeForm()"><mat-icon>close</mat-icon></button>
</div>
<div class="drawer-body">
<!-- Image Upload -->
<div class="image-upload-section">
<div class="image-upload-ring" (click)="imageInput.click()">
@if (form.image) {
<img [src]="form.image" alt="Committee photo" class="upload-preview" />
<div class="upload-overlay">
<mat-icon>edit</mat-icon>
<mat-icon
class="view-icon"
title="View full size"
(click)="openImageViewer(form.image, form.name || 'Committee Photo', $event)"
>zoom_in</mat-icon
>
</div>
} @else {
<mat-icon class="upload-placeholder-icon">add_photo_alternate</mat-icon>
<span class="upload-placeholder-text">Add Photo</span>
}
</div>
@if (form.image) {
<button type="button" class="btn-remove-image" (click)="removeImage()">
<mat-icon>delete_outline</mat-icon> Remove
</button>
}
<input #imageInput type="file" accept="image/*" style="display: none" (change)="onImageSelect($event)" />
</div>
<!-- Name -->
<div class="form-group">
<label>Committee Name <span class="req">*</span></label>
<input
type="text"
[(ngModel)]="form.name"
placeholder="e.g. Finance Committee"
[class.input-error]="submitted && !form.name?.trim()"
/>
@if (submitted && !form.name?.trim()) {
<span class="error-msg">Name is required</span>
}
</div>
<div class="form-group">
<label>Description</label>
<textarea [(ngModel)]="form.description" rows="2" placeholder="Optional description..."></textarea>
</div>
<!-- Director Selection -->
<div class="section-title">Select Members (Current Election Directors)</div>
@if (directorsLoading) {
<div class="loading-inline">
<div class="spinner-sm"></div>
<span>Loading directors...</span>
</div>
} @else if (availableDirectors.length === 0) {
<div class="empty-directors">
<mat-icon>info</mat-icon>
<span>No active directors found for the current election.</span>
</div>
} @else {
<div class="director-select-list">
@for (d of availableDirectors; track d.id) {
<div class="director-option" [class.selected]="isSelected(d.id)" (click)="toggleDirector(d.id)">
<div class="dir-avatar">
@if (getMemberPhoto(d.memberId)) {
<img [src]="getMemberPhoto(d.memberId)" [alt]="d.candidateName" />
} @else {
{{ d.candidateName?.charAt(0) }}
}
</div>
<div class="dir-info">
<span class="dir-name">{{ d.candidateName }}</span>
<span class="dir-desig">{{ d.designation || d.postName }}</span>
</div>
<div class="dir-check">
@if (isSelected(d.id)) {
<mat-icon class="check-icon">check_circle</mat-icon>
} @else {
<mat-icon class="uncheck-icon">radio_button_unchecked</mat-icon>
}
</div>
</div>
}
</div>
<div class="selected-count">
{{ form.selectedIds.length }} member{{ form.selectedIds.length !== 1 ? 's' : '' }} selected
</div>
}
</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' : 'Create' }}
</button>
</div>
</div>
}
@@ -0,0 +1,898 @@
/**
* 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/.
*/
$primary: #6366f1;
$primary-dark: #4f46e5;
$text: #0f172a;
$sub: #64748b;
$bg: #f8fafc;
$border: 1px solid rgb(226 232 240 / 80%);
$radius: 16px;
$shadow: 0 4px 20px rgb(15 23 42 / 8%);
$font:
'Inter',
-apple-system,
blinkmacsystemfont,
'Segoe UI',
sans-serif;
.committee-page {
padding: 32px;
background: $bg;
min-height: 100vh;
font-family: $font;
color: $text;
box-sizing: border-box;
}
// ── Header ────────────────────────────────────────────────────────
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 28px;
flex-wrap: wrap;
.header-left {
display: flex;
align-items: center;
gap: 14px;
}
h2 {
font-size: 22px;
font-weight: 800;
margin: 0 0 3px;
letter-spacing: -0.4px;
}
.header-sub {
font-size: 13px;
color: $sub;
margin: 0;
}
}
.back-btn {
width: 38px;
height: 38px;
border-radius: 50%;
background: #fff;
border: $border;
display: flex;
align-items: center;
justify-content: center;
text-decoration: none;
color: $sub;
transition: all 0.2s;
flex-shrink: 0;
mat-icon {
font-size: 18px;
height: 18px;
width: 18px;
}
&:hover {
color: $text;
transform: translateX(-2px);
}
}
.btn-create {
display: inline-flex;
align-items: center;
gap: 7px;
background: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
color: #fff;
border: none;
border-radius: 24px;
padding: 10px 20px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
box-shadow: 0 4px 12px rgb(99 102 241 / 30%);
transition: all 0.2s;
mat-icon {
font-size: 18px;
height: 18px;
width: 18px;
}
&:hover {
transform: translateY(-1px);
box-shadow: 0 6px 16px rgb(99 102 241 / 40%);
}
}
// ── States ────────────────────────────────────────────────────────
.loading-state,
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
padding: 80px 20px;
text-align: center;
color: $sub;
h3 {
font-size: 18px;
font-weight: 700;
color: $text;
margin: 0;
}
p {
font-size: 14px;
max-width: 360px;
margin: 0;
}
}
.empty-icon {
font-size: 56px;
height: 56px;
width: 56px;
color: #cbd5e1;
}
.spinner {
width: 36px;
height: 36px;
border: 3px solid #e2e8f0;
border-top-color: $primary;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
// ── Grid ──────────────────────────────────────────────────────────
.committees-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 20px;
}
// ── Committee Card ────────────────────────────────────────────────
.committee-card {
background: #fff;
border-radius: $radius;
border: $border;
box-shadow: $shadow;
padding: 20px;
display: flex;
flex-direction: column;
gap: 14px;
transition: all 0.25s;
&:hover {
box-shadow: 0 8px 28px rgb(15 23 42 / 12%);
transform: translateY(-2px);
}
&.dissolved {
opacity: 0.65;
}
}
.card-header {
display: flex;
align-items: center;
gap: 12px;
}
.committee-icon {
width: 46px;
height: 46px;
border-radius: 12px;
background: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
overflow: hidden;
mat-icon {
font-size: 22px;
height: 22px;
width: 22px;
color: #fff;
}
}
.committee-img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 12px;
}
.clickable-photo {
cursor: zoom-in;
transition: opacity 0.15s;
&:hover {
opacity: 0.85;
}
}
.committee-meta {
flex: 1;
min-width: 0;
h3 {
font-size: 16px;
font-weight: 700;
margin: 0 0 5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.status-chip {
font-size: 10px;
font-weight: 700;
padding: 2px 9px;
border-radius: 10px;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.committee-desc {
font-size: 13px;
color: $sub;
margin: 0;
line-height: 1.5;
}
.member-count-row {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
color: $sub;
mat-icon {
font-size: 16px;
height: 16px;
width: 16px;
}
}
// ── Member Avatars (stacked) ──────────────────────────────────────
.member-avatars {
display: flex;
flex-direction: row;
}
.member-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
color: #fff;
font-size: 14px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid #fff;
margin-left: -8px;
overflow: hidden;
flex-shrink: 0;
&:first-child {
margin-left: 0;
}
img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 50%;
}
}
.more-avatar {
background: #e2e8f0;
color: $sub;
font-size: 11px;
font-weight: 700;
}
// ── Member List ───────────────────────────────────────────────────
.member-list {
display: flex;
flex-direction: column;
gap: 8px;
border-top: $border;
padding-top: 12px;
}
.member-row {
display: flex;
align-items: center;
gap: 10px;
}
.member-avatar-sm {
width: 32px;
height: 32px;
border-radius: 50%;
background: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
color: #fff;
font-size: 13px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 50%;
}
}
.member-info {
flex: 1;
min-width: 0;
.member-name {
display: block;
font-size: 13px;
font-weight: 600;
color: $text;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.member-desig {
display: block;
font-size: 11px;
color: $sub;
margin-top: 1px;
}
}
// ── Card Actions ──────────────────────────────────────────────────
.card-actions {
display: flex;
gap: 8px;
margin-top: auto;
}
.btn-edit {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 8px 0;
border: $border;
border-radius: 8px;
background: #fff;
font-size: 13px;
font-weight: 600;
color: $text;
cursor: pointer;
transition: background 0.15s;
mat-icon {
font-size: 15px;
height: 15px;
width: 15px;
}
&:hover {
background: #f1f5f9;
}
}
.btn-dissolve {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 8px 0;
border: 1px solid #fecaca;
border-radius: 8px;
background: #fff;
font-size: 13px;
font-weight: 600;
color: #ef4444;
cursor: pointer;
transition: background 0.15s;
mat-icon {
font-size: 15px;
height: 15px;
width: 15px;
}
&:hover {
background: #fef2f2;
}
}
.dissolved-label {
display: flex;
align-items: center;
gap: 5px;
font-size: 13px;
font-weight: 600;
color: #94a3b8;
mat-icon {
font-size: 16px;
height: 16px;
width: 16px;
}
}
// ── Drawer ────────────────────────────────────────────────────────
.overlay {
position: fixed;
inset: 0;
background: rgb(0 0 0 / 30%);
z-index: 100;
backdrop-filter: blur(2px);
}
.form-drawer {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: 460px;
max-width: 100vw;
background: #fff;
z-index: 101;
display: flex;
flex-direction: column;
box-shadow: -8px 0 32px rgb(0 0 0 / 15%);
}
.drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px;
border-bottom: $border;
flex-shrink: 0;
h3 {
font-size: 18px;
font-weight: 700;
margin: 0;
}
}
.close-btn {
width: 32px;
height: 32px;
border-radius: 50%;
border: none;
background: #f1f5f9;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: $sub;
mat-icon {
font-size: 18px;
height: 18px;
width: 18px;
}
&:hover {
background: #e2e8f0;
color: $text;
}
}
.drawer-body {
flex: 1;
overflow-y: auto;
padding: 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
// ── Image Upload ──────────────────────────────────────────────────
.image-upload-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 16px 0 8px;
}
.image-upload-ring {
width: 90px;
height: 90px;
border-radius: 50%;
border: 2px dashed #cbd5e1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
position: relative;
overflow: hidden;
transition:
border-color 0.2s,
background 0.2s;
background: #f8fafc;
&:hover {
border-color: $primary;
background: rgb(99 102 241 / 4%);
.upload-overlay {
opacity: 1;
}
}
}
.upload-preview {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 50%;
}
.upload-overlay {
position: absolute;
inset: 0;
border-radius: 50%;
background: rgb(0 0 0 / 45%);
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
opacity: 0;
transition: opacity 0.2s;
mat-icon {
color: #fff;
font-size: 22px;
height: 22px;
width: 22px;
}
.view-icon {
font-size: 20px;
height: 20px;
width: 20px;
opacity: 0.85;
cursor: zoom-in;
&:hover {
opacity: 1;
transform: scale(1.1);
}
}
}
.upload-placeholder-icon {
font-size: 28px;
height: 28px;
width: 28px;
color: #94a3b8;
margin-bottom: 2px;
}
.upload-placeholder-text {
font-size: 10px;
font-weight: 600;
color: #94a3b8;
letter-spacing: 0.3px;
}
.btn-remove-image {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 12px;
border: 1px solid #fecaca;
border-radius: 20px;
background: #fff;
font-size: 12px;
font-weight: 600;
color: #ef4444;
cursor: pointer;
transition: background 0.15s;
mat-icon {
font-size: 14px;
height: 14px;
width: 14px;
}
&:hover {
background: #fef2f2;
}
}
.form-group {
display: flex;
flex-direction: column;
gap: 5px;
label {
font-size: 12px;
font-weight: 600;
color: $sub;
text-transform: uppercase;
letter-spacing: 0.4px;
}
input,
textarea {
padding: 10px 12px;
border: $border;
border-radius: 8px;
font-size: 14px;
color: $text;
font-family: $font;
outline: none;
transition: border-color 0.15s;
resize: vertical;
&:focus {
border-color: $primary;
box-shadow: 0 0 0 3px rgb(99 102 241 / 10%);
}
&.input-error {
border-color: #ef4444;
}
}
}
.error-msg {
font-size: 11px;
color: #ef4444;
margin-top: 2px;
}
.req {
color: #ef4444;
}
.section-title {
font-size: 11px;
font-weight: 700;
color: $sub;
text-transform: uppercase;
letter-spacing: 0.5px;
padding-bottom: 6px;
border-bottom: $border;
}
.loading-inline {
display: flex;
align-items: center;
gap: 10px;
color: $sub;
font-size: 13px;
}
.spinner-sm {
width: 20px;
height: 20px;
border: 2px solid #e2e8f0;
border-top-color: $primary;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.empty-directors {
display: flex;
align-items: center;
gap: 8px;
padding: 14px;
background: #f8fafc;
border-radius: 10px;
font-size: 13px;
color: $sub;
mat-icon {
font-size: 18px;
height: 18px;
width: 18px;
}
}
// ── Director Selection List ───────────────────────────────────────
.director-select-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.director-option {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border: $border;
border-radius: 10px;
cursor: pointer;
transition: all 0.15s;
background: #fff;
&:hover {
border-color: $primary;
background: rgb(99 102 241 / 2%);
}
&.selected {
border-color: $primary;
background: rgb(99 102 241 / 5%);
}
}
.dir-avatar {
width: 38px;
height: 38px;
border-radius: 50%;
background: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
color: #fff;
font-size: 15px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 50%;
}
}
.dir-info {
flex: 1;
min-width: 0;
.dir-name {
display: block;
font-size: 14px;
font-weight: 600;
color: $text;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dir-desig {
display: block;
font-size: 11px;
color: $sub;
margin-top: 1px;
}
}
.dir-check {
flex-shrink: 0;
.check-icon {
font-size: 20px;
height: 20px;
width: 20px;
color: $primary;
}
.uncheck-icon {
font-size: 20px;
height: 20px;
width: 20px;
color: #cbd5e1;
}
}
.selected-count {
font-size: 12px;
font-weight: 600;
color: $primary;
text-align: right;
}
// ── Footer ────────────────────────────────────────────────────────
.drawer-footer {
display: flex;
gap: 10px;
padding: 16px 24px;
border-top: $border;
flex-shrink: 0;
}
.btn-cancel {
flex: 1;
padding: 10px;
border: $border;
border-radius: 10px;
background: #fff;
font-size: 14px;
font-weight: 600;
color: $sub;
cursor: pointer;
&:hover {
background: #f1f5f9;
}
}
.btn-save {
flex: 2;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 10px;
border: none;
border-radius: 10px;
background: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
color: #fff;
font-size: 14px;
font-weight: 600;
cursor: pointer;
box-shadow: 0 4px 10px rgb(99 102 241 / 25%);
mat-icon {
font-size: 16px;
height: 16px;
width: 16px;
}
&:hover:not(:disabled) {
transform: translateY(-1px);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
@media (width <= 600px) {
.committee-page {
padding: 16px;
}
.committees-grid {
grid-template-columns: 1fr;
}
.form-drawer {
width: 100vw;
}
}
@@ -0,0 +1,260 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { RouterModule } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
import { ElectionService } from '../../election.service';
import { ImagePreviewDialogComponent } from '../../../cashier/transaction-entry/transaction-entry.component';
@Component({
selector: 'mifosx-committee-board',
standalone: true,
imports: [
CommonModule,
FormsModule,
RouterModule,
MatIconModule,
MatSnackBarModule,
MatDialogModule
],
templateUrl: './committee-board.component.html',
styleUrls: ['./committee-board.component.scss']
})
export class CommitteeBoardComponent implements OnInit {
private service = inject(ElectionService);
private snackBar = inject(MatSnackBar);
private sanitizer = inject(DomSanitizer);
private dialog = inject(MatDialog);
committees: any[] = [];
loading = true;
// available directors from current election
availableDirectors: any[] = [];
directorsLoading = false;
memberPhotoMap: Record<number, SafeResourceUrl | null> = {};
memberPhotoRaw: Record<number, string> = {};
// drawer state
showForm = false;
editingId: number | null = null;
saving = false;
submitted = false;
form: any = this.emptyForm();
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.service.getCommittees().subscribe({
next: (data) => {
this.committees = data ?? [];
this.loading = false;
this.loadMemberPhotos();
},
error: () => {
this.committees = [];
this.loading = false;
}
});
}
private loadMemberPhotos(): void {
for (const c of this.committees) {
for (const m of c.members ?? []) {
if (m.memberId && this.memberPhotoMap[m.memberId] === undefined) {
this.service.getClientImage(m.memberId).subscribe((base64) => {
if (base64) {
this.memberPhotoRaw[m.memberId] = base64;
this.memberPhotoMap[m.memberId] = this.sanitizer.bypassSecurityTrustResourceUrl(base64);
} else {
this.memberPhotoMap[m.memberId] = null;
}
});
}
}
}
}
getMemberPhoto(memberId: number): SafeResourceUrl | null {
return this.memberPhotoMap[memberId] ?? null;
}
openCreate(): void {
this.editingId = null;
this.submitted = false;
this.form = this.emptyForm();
this.loadAvailableDirectors();
this.showForm = true;
}
openEdit(committee: any): void {
this.editingId = committee.id;
this.submitted = false;
this.form = {
name: committee.name,
description: committee.description ?? '',
image: committee.image ?? null,
selectedIds: (committee.members ?? []).map((m: any) => m.directorAppointmentId)
};
this.loadAvailableDirectors();
this.showForm = true;
}
private loadAvailableDirectors(): void {
this.directorsLoading = true;
this.service.getCurrentElectionDirectors().subscribe((data) => {
this.availableDirectors = data ?? [];
this.directorsLoading = false;
// load photos for selection list
for (const d of this.availableDirectors) {
if (d.memberId && this.memberPhotoMap[d.memberId] === undefined) {
this.service.getClientImage(d.memberId).subscribe((base64) => {
if (base64) {
this.memberPhotoRaw[d.memberId] = base64;
this.memberPhotoMap[d.memberId] = this.sanitizer.bypassSecurityTrustResourceUrl(base64);
} else {
this.memberPhotoMap[d.memberId] = null;
}
});
}
}
});
}
onImageSelect(event: Event): void {
const file = (event.target as HTMLInputElement).files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
this.form.image = reader.result as string;
};
reader.readAsDataURL(file);
}
removeImage(): void {
this.form.image = null;
}
openImageViewer(url: string, title: string, event: MouseEvent): void {
event.stopPropagation();
if (!url) return;
this.dialog.open(ImagePreviewDialogComponent, {
data: { url, title },
panelClass: 'ipd-dialog',
backdropClass: 'ipd-backdrop'
});
}
openMemberPhotoViewer(memberId: number, name: string, event: MouseEvent): void {
event.stopPropagation();
const url = this.memberPhotoRaw[memberId];
if (!url) return;
this.dialog.open(ImagePreviewDialogComponent, {
data: { url, title: name },
panelClass: 'ipd-dialog',
backdropClass: 'ipd-backdrop'
});
}
toggleDirector(dirId: number): void {
const idx = this.form.selectedIds.indexOf(dirId);
if (idx >= 0) {
this.form.selectedIds.splice(idx, 1);
} else {
this.form.selectedIds.push(dirId);
}
}
isSelected(dirId: number): boolean {
return this.form.selectedIds.includes(dirId);
}
closeForm(): void {
this.showForm = false;
this.editingId = null;
this.form = this.emptyForm();
this.submitted = false;
}
save(): void {
this.submitted = true;
if (!this.form.name?.trim()) return;
this.saving = true;
const payload = {
name: this.form.name.trim(),
description: this.form.description?.trim() || null,
image: this.form.image || null,
directorAppointmentIds: this.form.selectedIds
};
const call =
this.editingId != null
? this.service.updateCommittee(this.editingId, payload)
: this.service.createCommittee(payload);
call.subscribe({
next: (result) => {
if (this.editingId != null) {
// sync members separately if editing
const existing = this.committees.find((c) => c.id === this.editingId);
const existingIds: number[] = (existing?.members ?? []).map((m: any) => m.directorAppointmentId);
const toAdd = this.form.selectedIds.filter((id: number) => !existingIds.includes(id));
const toRemove = existingIds.filter((id: number) => !this.form.selectedIds.includes(id));
const removes = toRemove.map((id: number) =>
this.service.removeCommitteeMember(this.editingId!, id).subscribe()
);
if (toAdd.length > 0) {
this.service.addCommitteeMembers(this.editingId!, toAdd).subscribe();
}
}
this.saving = false;
this.closeForm();
setTimeout(() => this.load(), 300);
this.snackBar.open(this.editingId != null ? 'Committee updated!' : 'Committee created!', 'Close', {
duration: 3000
});
},
error: (err) => {
this.saving = false;
const msg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Operation failed';
this.snackBar.open(msg, 'Close', { duration: 5000 });
}
});
}
confirmDissolve(committee: any): void {
if (!confirm(`Dissolve committee "${committee.name}"? This cannot be undone.`)) return;
this.service.dissolveCommittee(committee.id).subscribe({
next: () => {
this.load();
this.snackBar.open('Committee dissolved.', 'Close', { duration: 3000 });
},
error: () => this.snackBar.open('Failed to dissolve.', 'Close', { duration: 3000 })
});
}
statusColor(status: string): string {
return status === 'ACTIVE' ? '#10b981' : '#94a3b8';
}
private emptyForm(): any {
return { name: '', description: '', image: null, selectedIds: [] };
}
}
@@ -10,6 +10,10 @@
<div class="page-header">
<h2 class="page-title">Election Management</h2>
<div class="header-actions">
<a routerLink="/election/committees" class="btn-directors">
<mat-icon>group_work</mat-icon>
<span>Committees</span>
</a>
<a routerLink="/election/directors" class="btn-directors">
<mat-icon>groups</mat-icon>
<span>Board of Directors</span>
@@ -97,6 +97,12 @@ const routes: Routes = [
loadComponent: () =>
import('./components/director-board/director-board.component').then((m) => m.DirectorBoardComponent),
data: { title: 'Board of Directors' }
},
{
path: 'committees',
loadComponent: () =>
import('./components/committee-board/committee-board.component').then((m) => m.CommitteeBoardComponent),
data: { title: 'Committees' }
}
]
}
+33
View File
@@ -211,4 +211,37 @@ export class ElectionService {
getPendingDirectors(): Observable<any[]> {
return this.http.get<any[]>('/election/directors/pending').pipe(catchError(() => of([])));
}
// ── Committees ─────────────────────────────────────────────────
getCommittees(): Observable<any[]> {
return this.http.get<any[]>('/election/committees').pipe(catchError(() => of([])));
}
getCommittee(id: number): Observable<any> {
return this.http.get<any>(`/election/committees/${id}`);
}
getCurrentElectionDirectors(): Observable<any[]> {
return this.http.get<any[]>('/election/committees/current-directors').pipe(catchError(() => of([])));
}
createCommittee(data: any): Observable<any> {
return this.http.post('/election/committees', data);
}
updateCommittee(id: number, data: any): Observable<any> {
return this.http.put(`/election/committees/${id}`, data);
}
addCommitteeMembers(id: number, directorAppointmentIds: number[]): Observable<any> {
return this.http.post(`/election/committees/${id}/members`, { directorAppointmentIds });
}
removeCommitteeMember(id: number, directorAppointmentId: number): Observable<any> {
return this.http.delete(`/election/committees/${id}/members/${directorAppointmentId}`);
}
dissolveCommittee(id: number): Observable<any> {
return this.http.post(`/election/committees/${id}/dissolve`, {});
}
}
+11 -2
View File
@@ -49,12 +49,21 @@
// ===== Image Preview Dialog (election candidate photo/symbol viewer) =====
.ipd-dialog .mat-mdc-dialog-container {
padding: 0;
border-radius: 10px;
border-radius: 20px;
overflow: hidden;
background: transparent;
box-shadow: none;
}
.ipd-dialog .mdc-dialog__surface {
border-radius: 20px !important;
background: transparent !important;
box-shadow: none !important;
}
.ipd-backdrop {
background: rgb(0 0 0 / 75%);
background: rgb(0 0 0 / 82%);
backdrop-filter: blur(6px);
}
// ===== Sub-menu Mega Panels (rendered in CDK overlay) =====