diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.ts b/src/app/cashier/transaction-entry/transaction-entry.component.ts
index 4195071e1..9e5feb219 100644
--- a/src/app/cashier/transaction-entry/transaction-entry.component.ts
+++ b/src/app/cashier/transaction-entry/transaction-entry.component.ts
@@ -420,16 +420,15 @@ export class JointHolderDialogComponent implements OnInit {
selector: 'mifosx-image-preview-dialog',
template: `
-
`,
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;
}
`
],
diff --git a/src/app/election/components/committee-board/committee-board.component.html b/src/app/election/components/committee-board/committee-board.component.html
new file mode 100644
index 000000000..6da5566b2
--- /dev/null
+++ b/src/app/election/components/committee-board/committee-board.component.html
@@ -0,0 +1,241 @@
+
+
+
+
+
+
+
+ @if (loading) {
+
+
+
Loading committees...
+
+ } @else if (committees.length === 0) {
+
+
groups
+
No committees yet
+
Create a committee by selecting directors from the current election.
+
+
+ } @else {
+
+ @for (c of committees; track c.id) {
+
+
+
+ @if (c.description) {
+
{{ c.description }}
+ }
+
+
+ person
+ {{ c.members?.length ?? 0 }} member{{ (c.members?.length ?? 0) !== 1 ? 's' : '' }}
+
+
+
+ @if (c.members?.length > 0) {
+
+ @for (m of c.members.slice(0, 5); track m.id) {
+
+ @if (getMemberPhoto(m.memberId)) {
+
![]()
+ } @else {
+ {{ m.candidateName?.charAt(0) }}
+ }
+
+ }
+ @if (c.members.length > 5) {
+
+{{ c.members.length - 5 }}
+ }
+
+
+
+
+ @for (m of c.members; track m.id) {
+
+
+ @if (getMemberPhoto(m.memberId)) {
+
![]()
+ } @else {
+ {{ m.candidateName?.charAt(0) }}
+ }
+
+
+ {{ m.candidateName }}
+ {{ m.designation || m.postName }}
+
+
+ }
+
+ }
+
+
+ @if (c.status === 'ACTIVE') {
+
+
+ } @else {
+ block Dissolved
+ }
+
+
+ }
+
+ }
+
+
+
+@if (showForm) {
+
+
+}
diff --git a/src/app/election/components/committee-board/committee-board.component.scss b/src/app/election/components/committee-board/committee-board.component.scss
new file mode 100644
index 000000000..9a772f529
--- /dev/null
+++ b/src/app/election/components/committee-board/committee-board.component.scss
@@ -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;
+ }
+}
diff --git a/src/app/election/components/committee-board/committee-board.component.ts b/src/app/election/components/committee-board/committee-board.component.ts
new file mode 100644
index 000000000..325338a9d
--- /dev/null
+++ b/src/app/election/components/committee-board/committee-board.component.ts
@@ -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
= {};
+ memberPhotoRaw: Record = {};
+
+ // 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: [] };
+ }
+}
diff --git a/src/app/election/components/election-dashboard/election-dashboard.component.html b/src/app/election/components/election-dashboard/election-dashboard.component.html
index 55f36739a..c47f53058 100644
--- a/src/app/election/components/election-dashboard/election-dashboard.component.html
+++ b/src/app/election/components/election-dashboard/election-dashboard.component.html
@@ -10,6 +10,10 @@