diff --git a/src/app/core/http/error-handler.interceptor.ts b/src/app/core/http/error-handler.interceptor.ts index d1d3e0db5..e730f03b0 100644 --- a/src/app/core/http/error-handler.interceptor.ts +++ b/src/app/core/http/error-handler.interceptor.ts @@ -53,7 +53,8 @@ export class ErrorHandlerInterceptor implements HttpInterceptor { const isClientImageRequest = request.url.includes('/clients/') && request.url.includes('/images'); const isClientKycRequest = request.url.match(/\/clients\/\d+\/kyc$/); - const isSilentRequest = isClientImageRequest || isClientKycRequest; + const isDirectorRequest = request.url.includes('/election/directors'); + const isSilentRequest = isClientImageRequest || isClientKycRequest || isDirectorRequest; if (!environment.production && !isSilentRequest) { log.error(`Request Error: ${errorMessage}`); diff --git a/src/app/election/components/director-board/director-board.component.html b/src/app/election/components/director-board/director-board.component.html new file mode 100644 index 000000000..7fb2f4b64 --- /dev/null +++ b/src/app/election/components/director-board/director-board.component.html @@ -0,0 +1,353 @@ + + +
+ +
+
+ + arrow_back + +
+

Board of Directors

+

Registry of elected directors, their designations and tenures

+
+
+ +
+ + + @if (!pendingLoading && pendingCandidates.length > 0) { +
+
+ pending_actions +
+

Pending Appointments

+

{{ pendingCandidates.length }} won candidate(s) awaiting designation

+
+
+
+ @for (p of pendingCandidates; track p.candidateId) { +
+
{{ p.candidateName?.charAt(0) }}
+
+ {{ p.candidateName }} + {{ p.postName }}  ·  {{ p.electionTitle }} +
+
+ emoji_events + {{ p.voteCount }} votes +
+ +
+ } +
+
+ } + + +
+ @for (s of statusOptions; track s) { + + } +
+ + + @if (loading) { +
+
+

Loading board registry...

+
+ } @else if (directors.length === 0) { +
+ groups +

No directors appointed yet

+

Select a won candidate from election results to appoint as director.

+ +
+ } @else { + +
+ @for (d of directors; track d.id) { +
+
+
{{ d.candidateName?.charAt(0) }}
+
+

{{ d.candidateName }}

+ {{ d.postName }} +
+ + {{ d.status }} + +
+ +
+ badge + {{ d.designation || '—' }} +
+ +
+
+ event_available +
+ Tenure Start + {{ d.tenureStartDate ? (d.tenureStartDate | date: 'dd MMM yyyy') : '—' }} +
+
+
→
+
+ event_busy +
+ Tenure End + {{ d.tenureEndDate ? (d.tenureEndDate | date: 'dd MMM yyyy') : '—' }} +
+
+
+ +
+ @if (d.resolutionNo) { + description{{ d.resolutionNo }} + } + @if (d.appointmentDate) { + calendar_today{{ d.appointmentDate | date: 'dd MMM yyyy' }} + } +
+ + @if (d.remarks) { +

{{ d.remarks }}

+ } + +
+ + +
+
+ } +
+ + @if (totalPages > 1) { + + } + } +
+ + +@if (showForm) { +
+ +
+ +
+
+ @if (formStep === 2 && !editingId) { + + } +
+

{{ editingId ? 'Edit Director' : formStep === 1 ? 'Select Won Candidate' : 'Appointment Details' }}

+ @if (!editingId) { +

Step {{ formStep }} of 2

+ } +
+
+ +
+ + + @if (formStep === 1) { +
+ + @if (electionsLoading) { +
+
+

Loading elections...

+
+ } @else { + + Select Election + + — Choose an election — + @for (e of elections; track e.id) { + {{ e.title }} ({{ e.status }}) + } + + + } + + + @if (wonLoading) { +
+
+

Loading results...

+
+ } @else if (selectedElectionId && wonCandidates.length === 0) { +
+ info + No won candidates found for this election. +
+ } @else if (wonCandidates.length > 0) { +
Won Candidates — click to select
+
+ @for (c of wonCandidates; track c.candidateId) { + + } +
+ } +
+ + + } + + + @if (formStep === 2) { +
+ +
+
{{ form.candidateName?.charAt(0) }}
+
+ {{ form.candidateName }} + {{ form.postName }} +
+ verified +
+ + +
Appointment Details
+
+ + Designation * + + — Select — + @for (opt of designationOptions; track opt) { + {{ opt }} + } + + @if (submitted && !form.designation) { + Designation is required + } + + + Status + + Active + Resigned + Removed + Tenure Ended + + +
+
+ + Appointment Date * + + + + @if (submitted && !form.appointmentDate) { + Appointment date is required + } + + + Resolution No. + + +
+ + +
Tenure Period
+
+ + Tenure Start Date * + + + + @if (submitted && !form.tenureStartDate) { + Tenure start date is required + } + + + Tenure End Date * + + + + @if (submitted && !form.tenureEndDate) { + Tenure end date is required + } + +
+ + + Remarks + + +
+ + + } +
+} diff --git a/src/app/election/components/director-board/director-board.component.scss b/src/app/election/components/director-board/director-board.component.scss new file mode 100644 index 000000000..44eaa5fd7 --- /dev/null +++ b/src/app/election/components/director-board/director-board.component.scss @@ -0,0 +1,1051 @@ +/** + * 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/. + */ + +$radius: 16px; +$shadow: 0 4px 20px rgb(15 23 42 / 8%); +$border: 1px solid rgb(226 232 240 / 80%); +$text: #0f172a; +$sub: #64748b; +$primary: #6366f1; +$font: + 'Inter', + -apple-system, + blinkmacsystemfont, + 'Segoe UI', + sans-serif; + +.board-page { + padding: 32px; + background: #f8fafc; + min-height: 100vh; + font-family: $font; + color: $text; + box-sizing: border-box; +} + +// ── Header ────────────────────────────────────────────────────── +.board-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 24px; + 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); + } +} + +// ── Buttons ────────────────────────────────────────────────────── +.btn-appoint { + display: inline-flex; + align-items: center; + gap: 8px; + background: linear-gradient(135deg, $primary 0%, #4f46e5 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%); + } +} + +// ── Pending Appointments ────────────────────────────────────────── +.pending-section { + background: linear-gradient(135deg, rgb(245 158 11 / 6%) 0%, rgb(251 191 36 / 4%) 100%); + border: 1px solid rgb(245 158 11 / 25%); + border-radius: $radius; + padding: 20px; + margin-bottom: 24px; +} + +.pending-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 16px; + + h4 { + font-size: 15px; + font-weight: 700; + margin: 0 0 2px; + color: #92400e; + } + + p { + font-size: 12px; + color: #b45309; + margin: 0; + } +} + +.pending-icon { + font-size: 28px; + height: 28px; + width: 28px; + color: #f59e0b; + flex-shrink: 0; +} + +.pending-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.pending-card { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 14px; + background: #fff; + border: 1px solid rgb(245 158 11 / 20%); + border-radius: 10px; + flex-wrap: wrap; +} + +.pending-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); + color: #fff; + font-size: 16px; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.pending-info { + flex: 1; + min-width: 0; + + .pending-name { + display: block; + font-size: 14px; + font-weight: 700; + color: $text; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .pending-meta { + display: block; + font-size: 11px; + color: $sub; + margin-top: 2px; + } +} + +.pending-votes { + display: flex; + align-items: center; + gap: 4px; + font-size: 12px; + font-weight: 600; + color: #f59e0b; + flex-shrink: 0; + + mat-icon { + font-size: 15px; + height: 15px; + width: 15px; + } +} + +.btn-appoint-now { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 7px 14px; + background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); + color: #fff; + border: none; + border-radius: 20px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + flex-shrink: 0; + transition: all 0.2s; + box-shadow: 0 2px 8px rgb(245 158 11 / 30%); + + mat-icon { + font-size: 15px; + height: 15px; + width: 15px; + } + + &:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgb(245 158 11 / 40%); + } +} + +// ── Filter chips ───────────────────────────────────────────────── +.filter-bar { + display: flex; + gap: 8px; + margin-bottom: 24px; + flex-wrap: wrap; +} + +.filter-chip { + padding: 6px 16px; + border-radius: 20px; + border: $border; + background: #fff; + font-size: 13px; + font-weight: 500; + color: $sub; + cursor: pointer; + transition: all 0.15s; + + &.active, + &:hover { + background: $primary; + color: #fff; + border-color: $primary; + } +} + +// ── 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: 400px; + 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); + } +} + +// ── Director Cards Grid ─────────────────────────────────────────── +.directors-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); + gap: 20px; +} + +.director-card { + background: #fff; + border-radius: $radius; + border: $border; + box-shadow: $shadow; + padding: 20px; + display: flex; + flex-direction: column; + gap: 12px; + transition: all 0.25s; + + &:hover { + box-shadow: 0 8px 28px rgb(15 23 42 / 12%); + transform: translateY(-2px); + } +} + +.card-top { + display: flex; + align-items: center; + gap: 12px; +} + +.director-avatar { + width: 48px; + height: 48px; + border-radius: 50%; + background: linear-gradient(135deg, $primary 0%, #4f46e5 100%); + color: #fff; + font-size: 20px; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.director-info { + flex: 1; + min-width: 0; + + h4 { + font-size: 15px; + font-weight: 700; + margin: 0 0 4px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } +} + +.post-badge { + font-size: 11px; + font-weight: 600; + background: rgb(99 102 241 / 10%); + color: $primary; + padding: 2px 8px; + border-radius: 10px; +} + +.status-chip { + font-size: 10px; + font-weight: 700; + padding: 3px 10px; + border-radius: 12px; + text-transform: uppercase; + letter-spacing: 0.3px; + white-space: nowrap; + flex-shrink: 0; +} + +.designation-field { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 600; + color: $text; + + mat-icon { + font-size: 16px; + height: 16px; + width: 16px; + color: $sub; + } +} + +.tenure-row { + display: flex; + align-items: center; + gap: 8px; + background: #f8fafc; + border-radius: 10px; + padding: 10px 12px; +} + +.card-field { + display: flex; + align-items: center; + gap: 6px; + flex: 1; + + mat-icon { + font-size: 15px; + height: 15px; + width: 15px; + color: $sub; + flex-shrink: 0; + } + + .field-label { + display: block; + font-size: 10px; + font-weight: 600; + color: $sub; + text-transform: uppercase; + letter-spacing: 0.3px; + } + + .field-val { + display: block; + font-size: 13px; + font-weight: 600; + color: $text; + } +} + +.tenure-divider { + color: $sub; + font-size: 18px; + flex-shrink: 0; +} + +.meta-row { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.meta-tag { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 12px; + font-weight: 500; + color: $sub; + background: #f1f5f9; + padding: 3px 10px; + border-radius: 10px; + + mat-icon { + font-size: 13px; + height: 13px; + width: 13px; + } +} + +.remarks-text { + font-size: 12px; + color: $sub; + margin: 0; + font-style: italic; + border-left: 3px solid #e2e8f0; + padding-left: 10px; +} + +.card-actions { + display: flex; + gap: 8px; + margin-top: 4px; +} + +.btn-edit { + flex: 1; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + padding: 7px 0; + border: $border; + border-radius: 8px; + background: #fff; + font-size: 13px; + font-weight: 600; + color: $text; + cursor: pointer; + transition: all 0.15s; + + mat-icon { + font-size: 15px; + height: 15px; + width: 15px; + } + + &:hover { + background: #f1f5f9; + } +} + +.btn-delete { + flex: 1; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + padding: 7px 0; + border: 1px solid #fecaca; + border-radius: 8px; + background: #fff; + font-size: 13px; + font-weight: 600; + color: #ef4444; + cursor: pointer; + transition: all 0.15s; + + mat-icon { + font-size: 15px; + height: 15px; + width: 15px; + } + + &:hover:not(:disabled) { + background: #fef2f2; + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } +} + +// ── Pagination ─────────────────────────────────────────────────── +.pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 16px; + margin-top: 32px; +} + +.page-btn { + width: 36px; + height: 36px; + border-radius: 50%; + border: $border; + background: #fff; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: $sub; + transition: all 0.15s; + + mat-icon { + font-size: 18px; + height: 18px; + width: 18px; + } + + &:hover:not(:disabled) { + background: $primary; + color: #fff; + border-color: $primary; + } + + &:disabled { + opacity: 0.4; + cursor: not-allowed; + } +} + +.page-info { + font-size: 13px; + color: $sub; + font-weight: 500; +} + +// ── Form 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: 480px; + max-width: 100vw; + background: #fff; + z-index: 101; + display: flex; + flex-direction: column; + box-shadow: -8px 0 32px rgb(0 0 0 / 15%); + overflow: hidden; +} + +.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 0 2px; + } +} + +.drawer-title-row { + display: flex; + align-items: center; + gap: 10px; +} + +.step-hint { + font-size: 11px; + color: $sub; + margin: 0; + font-weight: 500; +} + +.icon-btn { + width: 32px; + height: 32px; + border-radius: 50%; + border: $border; + background: #fff; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: $sub; + flex-shrink: 0; + transition: all 0.15s; + + mat-icon { + font-size: 18px; + height: 18px; + width: 18px; + } + + &:hover { + background: #f1f5f9; + color: $text; + } +} + +// ── Step-1: Won candidates list ─────────────────────────────────── +.inline-spinner { + width: 20px; + height: 20px; + border: 2px solid #e2e8f0; + border-top-color: $primary; + border-radius: 50%; + animation: spin 0.8s linear infinite; + margin: 8px auto; +} + +.loading-state.small { + padding: 24px; + font-size: 13px; + + .spinner { + width: 24px; + height: 24px; + border-width: 2px; + margin-bottom: 8px; + } +} + +.empty-won { + display: flex; + align-items: center; + gap: 8px; + padding: 16px; + background: #f8fafc; + border-radius: 10px; + font-size: 13px; + color: $sub; + + mat-icon { + font-size: 18px; + height: 18px; + width: 18px; + color: #94a3b8; + } +} + +.won-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.won-card { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 14px; + background: #fff; + border: $border; + border-radius: 12px; + cursor: pointer; + text-align: left; + width: 100%; + transition: all 0.2s; + + &:hover { + border-color: $primary; + background: rgb(99 102 241 / 3%); + box-shadow: 0 2px 8px rgb(99 102 241 / 12%); + + .pick-arrow { + color: $primary; + transform: translateX(2px); + } + } +} + +.won-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + background: linear-gradient(135deg, #10b981 0%, #059669 100%); + color: #fff; + font-size: 16px; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.won-info { + flex: 1; + min-width: 0; + + .won-name { + display: block; + font-size: 14px; + font-weight: 700; + color: $text; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .won-post { + display: block; + font-size: 11px; + color: $sub; + margin-top: 1px; + } +} + +.won-votes { + display: flex; + align-items: center; + gap: 3px; + font-size: 12px; + font-weight: 600; + color: #f59e0b; + flex-shrink: 0; + + .trophy { + font-size: 15px; + height: 15px; + width: 15px; + } +} + +.pick-arrow { + font-size: 18px; + height: 18px; + width: 18px; + color: #cbd5e1; + flex-shrink: 0; + transition: all 0.2s; +} + +// ── Step-2: Selected candidate banner ──────────────────────────── +.selected-candidate-banner { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 16px; + background: linear-gradient(135deg, rgb(99 102 241 / 6%) 0%, rgb(79 70 229 / 4%) 100%); + border: 1px solid rgb(99 102 241 / 20%); + border-radius: 12px; +} + +.selected-avatar { + width: 44px; + height: 44px; + border-radius: 50%; + background: linear-gradient(135deg, $primary 0%, #4f46e5 100%); + color: #fff; + font-size: 18px; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.selected-name { + display: block; + font-size: 15px; + font-weight: 700; + color: $text; +} + +.selected-post { + display: block; + font-size: 12px; + color: $primary; + font-weight: 600; + margin-top: 2px; +} + +.check-icon { + font-size: 22px; + height: 22px; + width: 22px; + color: #10b981; + margin-left: auto; + flex-shrink: 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; + transition: all 0.15s; + + 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; +} + +.form-section-title { + font-size: 11px; + font-weight: 700; + color: $sub; + text-transform: uppercase; + letter-spacing: 0.5px; + padding-bottom: 4px; + border-bottom: $border; + margin-top: 4px; +} + +.form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 5px; + + &.full-width { + grid-column: 1 / -1; + } + + label { + font-size: 12px; + font-weight: 600; + color: $sub; + + .req { + color: #ef4444; + } + } + + input, + select, + textarea { + padding: 9px 12px; + border: $border; + border-radius: 8px; + font-size: 14px; + color: $text; + background: #fff; + outline: none; + font-family: $font; + transition: border-color 0.15s; + + &:focus { + border-color: $primary; + box-shadow: 0 0 0 3px rgb(99 102 241 / 10%); + } + } + + textarea { + resize: vertical; + } +} + +.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; + transition: all 0.15s; + + &: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%, #4f46e5 100%); + color: #fff; + font-size: 14px; + font-weight: 600; + cursor: pointer; + box-shadow: 0 4px 10px rgb(99 102 241 / 25%); + transition: all 0.2s; + + mat-icon { + font-size: 16px; + height: 16px; + width: 16px; + } + + &:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: 0 6px 14px rgb(99 102 241 / 35%); + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } +} + +// mat-form-field inside drawer fits the grid +.form-drawer mat-form-field { + width: 100%; +} + +.form-drawer .full-field { + grid-column: 1 / -1; + width: 100%; +} + +.req { + color: #ef4444; + font-style: normal; +} + +@media (width <= 600px) { + .board-page { + padding: 16px; + } + + .directors-grid { + grid-template-columns: 1fr; + } + + .form-drawer { + width: 100vw; + } + + .form-row { + grid-template-columns: 1fr; + } +} diff --git a/src/app/election/components/director-board/director-board.component.ts b/src/app/election/components/director-board/director-board.component.ts new file mode 100644 index 000000000..37ff51968 --- /dev/null +++ b/src/app/election/components/director-board/director-board.component.ts @@ -0,0 +1,343 @@ +/** + * 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, DatePipe } 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 { MatDatepickerModule } from '@angular/material/datepicker'; +import { MatNativeDateModule } from '@angular/material/core'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { ElectionService } from '../../election.service'; + +@Component({ + selector: 'mifosx-director-board', + standalone: true, + imports: [ + CommonModule, + FormsModule, + RouterModule, + MatIconModule, + MatSnackBarModule, + MatDatepickerModule, + MatNativeDateModule, + MatFormFieldModule, + MatInputModule, + MatSelectModule, + DatePipe + ], + templateUrl: './director-board.component.html', + styleUrls: ['./director-board.component.scss'] +}) +export class DirectorBoardComponent implements OnInit { + private service = inject(ElectionService); + private snackBar = inject(MatSnackBar); + + // ── Pending appointments ─────────────────────────────────────── + pendingCandidates: any[] = []; + pendingLoading = true; + + // ── Director list ────────────────────────────────────────────── + directors: any[] = []; + loading = true; + totalElements = 0; + page = 0; + pageSize = 20; + + filterStatus = ''; + statusOptions = [ + '', + 'ACTIVE', + 'RESIGNED', + 'REMOVED', + 'TENURE_ENDED' + ]; + statusLabels: Record = { + '': 'All', + ACTIVE: 'Active', + RESIGNED: 'Resigned', + REMOVED: 'Removed', + TENURE_ENDED: 'Tenure Ended' + }; + + // ── Drawer state ─────────────────────────────────────────────── + showForm = false; + /** 1 = pick candidate, 2 = fill details */ + formStep: 1 | 2 = 1; + editingId: number | null = null; + saving = false; + deleting: number | null = null; + submitted = false; + + // ── Step-1: election + WON candidates ───────────────────────── + elections: any[] = []; + electionsLoading = false; + selectedElectionId: number | null = null; + wonCandidates: any[] = []; + wonLoading = false; + + // ── Step-2: form ─────────────────────────────────────────────── + form: any = this.emptyForm(); + + readonly today: Date = new Date(); + + designationOptions = [ + 'Chairman', + 'Vice Chairman', + 'Secretary', + 'Treasurer', + 'Director', + 'Managing Director', + 'Chief Executive Officer' + ]; + + ngOnInit(): void { + this.load(); + this.loadPending(); + } + + loadPending(): void { + this.pendingLoading = true; + this.service.getPendingDirectors().subscribe((data: any[]) => { + this.pendingCandidates = data ?? []; + this.pendingLoading = false; + }); + } + + pickPending(p: any): void { + this.editingId = null; + this.form = { + ...this.emptyForm(), + electionId: p.electionId, + candidateId: p.candidateId, + candidateName: p.candidateName, + postName: p.postName + }; + this.selectedElectionId = p.electionId; + this.formStep = 2; + this.showForm = true; + } + + load(): void { + this.loading = true; + this.service + .getDirectors({ status: this.filterStatus || undefined, page: this.page, size: this.pageSize }) + .subscribe({ + next: (data: any) => { + this.directors = data?.content ?? []; + this.totalElements = data?.totalElements ?? 0; + this.loading = false; + }, + error: () => { + this.directors = []; + this.totalElements = 0; + this.loading = false; + } + }); + } + + applyFilter(): void { + this.page = 0; + this.load(); + } + + // ── Open appoint drawer (step 1) ─────────────────────────────── + openAppoint(): void { + this.submitted = false; + this.editingId = null; + this.form = this.emptyForm(); + this.formStep = 1; + this.selectedElectionId = null; + this.wonCandidates = []; + this.showForm = true; + this.loadElections(); + } + + loadElections(): void { + this.electionsLoading = true; + this.service.getAllElections(0, 50).subscribe((data: any) => { + this.elections = data?.content ?? []; + this.electionsLoading = false; + }); + } + + selectElection(electionId: number): void { + this.selectedElectionId = electionId; + this.wonCandidates = []; + this.wonLoading = true; + this.service.getResults(electionId).subscribe((results: any[]) => { + this.wonCandidates = (results ?? []).filter((r) => r.result === 'WON'); + this.wonLoading = false; + }); + } + + pickCandidate(candidate: any): void { + this.form = { + ...this.emptyForm(), + electionId: this.selectedElectionId, + candidateId: candidate.candidateId, + candidateName: candidate.candidateName, + postName: candidate.postName + }; + this.formStep = 2; + } + + backToStep1(): void { + this.formStep = 1; + } + + onTenureStartChange(val: Date | null): void { + if (!val) return; + const end = new Date(val); + end.setFullYear(end.getFullYear() + 5); + this.form.tenureEndDate = end; + } + + // ── Open edit drawer (skip step 1) ──────────────────────────── + openEdit(d: any): void { + this.submitted = false; + this.editingId = d.id; + this.form = { + electionId: d.electionId, + candidateId: d.candidateId, + candidateName: d.candidateName, + postName: d.postName, + designation: d.designation ?? '', + tenureStartDate: d.tenureStartDate ? new Date(d.tenureStartDate) : null, + tenureEndDate: d.tenureEndDate ? new Date(d.tenureEndDate) : null, + resolutionNo: d.resolutionNo ?? '', + appointmentDate: d.appointmentDate ? new Date(d.appointmentDate) : null, + status: d.status ?? 'ACTIVE', + remarks: d.remarks ?? '' + }; + this.formStep = 2; + this.showForm = true; + } + + closeForm(): void { + this.showForm = false; + this.editingId = null; + this.form = this.emptyForm(); + this.formStep = 1; + this.submitted = false; + } + + save(): void { + this.submitted = true; + if ( + !this.form.designation || + !this.form.appointmentDate || + !this.form.tenureStartDate || + !this.form.tenureEndDate + ) { + return; + } + this.saving = true; + const payload = { + ...this.form, + appointmentDate: this.formatDate(this.form.appointmentDate), + tenureStartDate: this.formatDate(this.form.tenureStartDate), + tenureEndDate: this.formatDate(this.form.tenureEndDate) + }; + const call = + this.editingId != null + ? this.service.updateDirector(this.editingId, payload) + : this.service.appointDirector(payload); + + call.subscribe({ + next: () => { + this.saving = false; + this.closeForm(); + this.load(); + this.loadPending(); + this.snackBar.open( + this.editingId != null ? 'Director updated successfully!' : 'Director appointed successfully!', + 'Close', + { duration: 3000 } + ); + }, + error: (err: any) => { + this.saving = false; + const msg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Operation failed'; + this.snackBar.open(msg, 'Close', { duration: 5000 }); + } + }); + } + + confirmDelete(id: number): void { + if (!confirm('Remove this director appointment?')) return; + this.deleting = id; + this.service.deleteDirector(id).subscribe({ + next: () => { + this.deleting = null; + this.load(); + this.snackBar.open('Appointment removed.', 'Close', { duration: 3000 }); + }, + error: () => { + this.deleting = null; + this.snackBar.open('Failed to remove appointment.', 'Close', { duration: 4000 }); + } + }); + } + + get totalPages(): number { + return Math.ceil(this.totalElements / this.pageSize); + } + + prevPage(): void { + if (this.page > 0) { + this.page--; + this.load(); + } + } + + nextPage(): void { + if (this.page < this.totalPages - 1) { + this.page++; + this.load(); + } + } + + statusColor(status: string): string { + const map: Record = { + ACTIVE: '#10b981', + RESIGNED: '#f59e0b', + REMOVED: '#ef4444', + TENURE_ENDED: '#94a3b8' + }; + return map[status] ?? '#94a3b8'; + } + + private formatDate(date: Date | null): string | null { + if (!date) return null; + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + return `${y}-${m}-${d}`; + } + + private emptyForm(): any { + return { + electionId: null, + candidateId: null, + candidateName: '', + postName: '', + designation: '', + tenureStartDate: null, + tenureEndDate: null, + resolutionNo: '', + appointmentDate: null, + status: 'ACTIVE', + remarks: '' + }; + } +} 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 dbb1765e2..e35347808 100644 --- a/src/app/election/components/election-dashboard/election-dashboard.component.html +++ b/src/app/election/components/election-dashboard/election-dashboard.component.html @@ -9,7 +9,13 @@
@if (loading) { diff --git a/src/app/election/components/election-dashboard/election-dashboard.component.scss b/src/app/election/components/election-dashboard/election-dashboard.component.scss index 753c285d6..bc5f0190d 100644 --- a/src/app/election/components/election-dashboard/election-dashboard.component.scss +++ b/src/app/election/components/election-dashboard/election-dashboard.component.scss @@ -15,6 +15,8 @@ justify-content: space-between; align-items: center; margin-bottom: 14px; + flex-wrap: wrap; + gap: 10px; .page-title { font-size: 18px; @@ -24,6 +26,40 @@ } } +.header-actions { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.btn-directors { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + border-radius: 8px; + border: 1px solid #e2e8f0; + background: #fff; + color: #475569; + font-size: 13px; + font-weight: 600; + text-decoration: none; + transition: all 0.2s; + + mat-icon { + font-size: 16px; + height: 16px; + width: 16px; + } + + &:hover { + background: #f1f5f9; + color: #0f172a; + border-color: #cbd5e1; + } +} + .stats-row { display: flex; gap: 10px; diff --git a/src/app/election/election-routing.module.ts b/src/app/election/election-routing.module.ts index e6336e68e..56b068a5a 100644 --- a/src/app/election/election-routing.module.ts +++ b/src/app/election/election-routing.module.ts @@ -91,6 +91,12 @@ const routes: Routes = [ (m) => m.ShareholderAnalysisComponent ), data: { title: 'Overall Shareholder Analysis' } + }, + { + path: 'directors', + loadComponent: () => + import('./components/director-board/director-board.component').then((m) => m.DirectorBoardComponent), + data: { title: 'Board of Directors' } } ] } diff --git a/src/app/election/election.service.ts b/src/app/election/election.service.ts index 584f8011c..283aaf3c6 100644 --- a/src/app/election/election.service.ts +++ b/src/app/election/election.service.ts @@ -181,4 +181,34 @@ export class ElectionService { .get(`/election/config/${electionId}/audit-log`, { params: qp }) .pipe(catchError(() => of({ content: [], totalElements: 0, totalPages: 0, currentPage: 0, pageSize: 20 }))); } + + // ── Board of Directors ───────────────────────────────────────── + getDirectors(params?: { status?: string; electionId?: number; page?: number; size?: number }): Observable { + const qp: Record = {}; + if (params?.status) qp['status'] = params.status; + if (params?.electionId) qp['electionId'] = String(params.electionId); + if (params?.page !== undefined) qp['page'] = String(params.page); + if (params?.size !== undefined) qp['size'] = String(params.size); + return this.http.get('/election/directors', { params: qp }).pipe(catchError(() => of({ content: [] }))); + } + + getDirector(id: number): Observable { + return this.http.get(`/election/directors/${id}`); + } + + appointDirector(data: any): Observable { + return this.http.post('/election/directors', data); + } + + updateDirector(id: number, data: any): Observable { + return this.http.put(`/election/directors/${id}`, data); + } + + deleteDirector(id: number): Observable { + return this.http.delete(`/election/directors/${id}`); + } + + getPendingDirectors(): Observable { + return this.http.get('/election/directors/pending').pipe(catchError(() => of([]))); + } } diff --git a/src/environments/.env.ts b/src/environments/.env.ts index 4591d6cef..c8f444f52 100644 --- a/src/environments/.env.ts +++ b/src/environments/.env.ts @@ -9,8 +9,8 @@ /* tslint:disable */ export default { 'mifos_x': { - 'version': '260626', - 'hash': '9e33392b' + 'version': '260701', + 'hash': 'f2f2338c' }, 'allow_switching_backend_instance': true };