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: `
-
+
{{ data.title }} -
- - {{ zoomPct }}% - - - -
+
+
-
Scroll = Zoom  ·  Drag = Pan
+ +
`, 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.image) { + + } @else { + groups + } +
+
+

{{ c.name }}

+ {{ c.status }} +
+
+ + @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) { +
+
+
+

{{ editingId ? 'Edit Committee' : 'New Committee' }}

+ +
+ +
+ +
+
+ @if (form.image) { + Committee photo +
+ edit + zoom_in +
+ } @else { + add_photo_alternate + Add Photo + } +
+ @if (form.image) { + + } + +
+ + +
+ + + @if (submitted && !form.name?.trim()) { + Name is required + } +
+ +
+ + +
+ + +
Select Members (Current Election Directors)
+ + @if (directorsLoading) { +
+
+ Loading directors... +
+ } @else if (availableDirectors.length === 0) { +
+ info + No active directors found for the current election. +
+ } @else { +
+ @for (d of availableDirectors; track d.id) { +
+
+ @if (getMemberPhoto(d.memberId)) { + + } @else { + {{ d.candidateName?.charAt(0) }} + } +
+
+ {{ d.candidateName }} + {{ d.designation || d.postName }} +
+
+ @if (isSelected(d.id)) { + check_circle + } @else { + radio_button_unchecked + } +
+
+ } +
+
+ {{ form.selectedIds.length }} member{{ form.selectedIds.length !== 1 ? 's' : '' }} selected +
+ } +
+ + +
+} 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 @@