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:
co-authored by
Claude Sonnet 4.6
parent
c4387c5c79
commit
83e9fc9e68
@@ -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 · 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 · 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' }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) =====
|
||||
|
||||
Reference in New Issue
Block a user