import MobileControllerBase from "./mobile-controller-base.js"
import { MOBILE_QUEUE_DEFAULT_PER_PAGE } from "../mobile-queue-pagination.js"
/**
* Mobile Approvals Controller
*
* Touch-optimized approval workflow with card-based UI.
* Fetches pending approvals, renders expandable cards,
* and handles inline approve/reject with serial-pick-next support.
*/
class MobileApprovalsController extends MobileControllerBase {
static targets = [
"list", "loading", "empty", "error", "errorMessage",
"countBadge", "refreshBtn", "loadMore", "loadMoreBtn", "status",
"toast", "toastBody"
]
static values = {
dataUrl: String,
recordUrl: String,
triageUrl: String,
eligibleUrl: String,
detailUrl: String,
perPage: { type: Number, default: MOBILE_QUEUE_DEFAULT_PER_PAGE },
}
initialize() {
super.initialize()
this._approvals = []
this._expandedId = null
this._submitting = false
this._page = 1
this._perPage = this._configuredPerPage()
this._totalCount = 0
this._hasNextPage = false
}
onConnect() {
if (this.online) {
this.loadApprovals()
}
}
onConnectionStateChanged(isOnline) {
if (isOnline && this._approvals.length === 0) {
this.loadApprovals()
}
}
async refresh() {
this.refreshBtnTarget.disabled = true
await this.loadApprovals()
this.refreshBtnTarget.disabled = false
}
async loadApprovals({ append = false } = {}) {
if (!append) {
this._page = 1
this._approvals = []
this._expandedId = null
this._showState('loading')
} else {
this._setLoadMoreBusy(true)
}
try {
const response = await this.fetchWithRetry(this._pageUrl(this._page))
const data = await response.json()
const pagination = data.pagination || {}
this._totalCount = parseInt(pagination.total ?? (data.approvals || []).length, 10) || 0
this._perPage = parseInt(pagination.perPage ?? this._perPage, 10) || this._perPage
this._hasNextPage = pagination.hasNextPage === true
this._page = parseInt(pagination.page ?? this._page, 10) || this._page
this._approvals = append
? this._mergeApprovals(this._approvals, data.approvals || [])
: data.approvals || []
if (this._approvals.length === 0) {
this._showState('empty')
} else {
this._showState('list')
this._renderCards()
}
this._updateBadge()
this._updateLoadMore()
if (append) {
this._announce(`Loaded ${this._approvals.length} of ${this._totalCount} pending approvals.`)
}
} catch (error) {
console.error('Failed to load approvals:', error)
if (this.hasErrorMessageTarget) {
this.errorMessageTarget.textContent = error.message || 'Failed to load approvals.'
}
this._showState('error')
} finally {
this._setLoadMoreBusy(false)
}
}
async loadMore() {
if (!this._hasNextPage) return
this._page += 1
await this.loadApprovals({ append: true })
}
// --- Card Rendering ---
_renderCards() {
const html = this._approvals.map(a => this._renderCard(a)).join('')
this.listTarget.innerHTML = html
}
_renderCard(approval) {
const hideProgress = approval.approverConfig?.hideProgress === true
|| approval.approverConfig?.feedbackResponse === true
const pct = approval.progress.required > 0
? Math.round((approval.progress.approved / approval.progress.required) * 100)
: 0
const age = this._timeAgo(approval.modified)
const progressHtml = hideProgress ? "" : `
<span class="approval-progress">
<span class="approval-progress-bar">
<span class="approval-progress-fill" style="width: ${pct}%"></span>
</span>
<small>${approval.progress.approved}/${approval.progress.required}</small>
</span>`
const detailId = `approval-card-detail-${approval.id}`
return `
<div class="card approval-card" data-approval-id="${approval.id}">
<div class="approval-card-summary"
data-action="click->mobile-approvals#toggleCard keydown->mobile-approvals#toggleCardWithKeyboard"
role="button"
tabindex="0"
aria-expanded="false"
aria-controls="${detailId}"
data-id="${approval.id}">
<div class="approval-card-icon">
<i class="bi ${this._escHtml(approval.icon)}"></i>
</div>
<div class="approval-card-info">
<div class="approval-card-title">${this._escHtml(approval.title)}</div>
<div class="approval-card-meta">
<span><i class="bi bi-person me-1"></i>${this._escHtml(approval.requester)}</span>
${approval.triage?.stateLabel ? `<span><i class="bi bi-journal-text me-1"></i>${this._escHtml(approval.triage.stateLabel)}</span>` : ''}
${progressHtml}
<span><i class="bi bi-clock me-1"></i>${age}</span>
</div>
</div>
<div class="approval-card-chevron">
<i class="bi bi-chevron-right"></i>
</div>
</div>
</div>`
}
toggleCard(event) {
event.preventDefault()
const id = parseInt(event.currentTarget.dataset.id)
const card = this.listTarget.querySelector(`[data-approval-id="${id}"]`)
if (!card) return
if (this._expandedId === id) {
this._collapseCard(card, id)
} else {
// Collapse previous
if (this._expandedId !== null) {
const prev = this.listTarget.querySelector(`[data-approval-id="${this._expandedId}"]`)
if (prev) this._collapseCard(prev, this._expandedId)
}
this._expandCard(card, id)
}
}
toggleCardWithKeyboard(event) {
if (event.key !== 'Enter' && event.key !== ' ') {
return
}
this.toggleCard(event)
}
_collapseCard(card, id) {
card.classList.remove('expanded')
card.querySelector('.approval-card-summary')?.setAttribute('aria-expanded', 'false')
const detail = card.querySelector('.approval-card-detail')
if (detail) detail.remove()
this._expandedId = null
}
async _expandCard(card, id) {
card.classList.add('expanded')
card.querySelector('.approval-card-summary')?.setAttribute('aria-expanded', 'true')
this._expandedId = id
const approval = this._approvals.find(a => a.id === id)
if (!approval) return
// Show loading detail
const loadingHtml = `<div class="approval-card-detail" id="approval-card-detail-${id}" role="region" aria-live="polite">
<div class="text-center py-3">
<div class="spinner-border spinner-border-sm" style="color: var(--section-approvals);" role="status"></div>
<small class="ms-2 text-muted">Loading details...</small>
</div>
</div>`
card.insertAdjacentHTML('beforeend', loadingHtml)
// Fetch full detail (response timeline)
let responses = []
try {
const detailResp = await this.fetchWithRetry(`${this.detailUrlValue}/${id}`)
const detailData = await detailResp.json()
responses = detailData.responses || []
} catch (e) {
// Non-fatal — show card without response timeline
console.warn('Could not load approval detail:', e)
}
// Replace loading with full detail
const existingDetail = card.querySelector('.approval-card-detail')
if (existingDetail) existingDetail.remove()
// Only render if still expanded
if (this._expandedId !== id) return
const detailHtml = this._renderDetail(approval, responses)
card.insertAdjacentHTML('beforeend', detailHtml)
// Scroll card into view
card.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}
_renderDetail(approval, responses) {
let html = `<div class="approval-card-detail" id="approval-card-detail-${approval.id}" role="region" aria-live="polite">`
// Description
if (approval.description) {
html += `<p class="mb-2" style="font-size: 0.85rem; color: var(--medieval-stone-dark, #555);">${this._escHtml(approval.description)}</p>`
}
// Context fields
if (approval.fields && approval.fields.length > 0) {
html += '<div class="approval-detail-fields">'
for (const f of approval.fields) {
html += `<div class="approval-detail-field">
<span class="approval-detail-label">${this._escHtml(f.label)}</span>
<span class="approval-detail-value">${this._escHtml(f.value)}</span>
</div>`
}
html += '</div>'
}
if (approval.entityUrl) {
html += `<a href="${this._escHtml(approval.entityUrl)}" class="btn btn-sm btn-outline-primary mb-3" data-turbo-frame="_top"><i class="bi bi-box-arrow-up-right me-1" aria-hidden="true"></i>View Source</a>`
}
// Response timeline
if (responses.length > 0) {
html += '<div class="approval-responses"><small class="fw-semibold text-muted d-block mb-1">Responses:</small>'
for (const r of responses) {
const icon = r.decision === 'approve'
? '<i class="bi bi-check-circle-fill text-success approval-response-icon"></i>'
: '<i class="bi bi-x-circle-fill text-danger approval-response-icon"></i>'
html += `<div class="approval-response-item">
${icon}
<div>
<strong>${this._escHtml(r.memberName)}</strong>
<span class="text-muted"> — ${this._escHtml(r.respondedAt)}</span>
${r.comment ? `<div class="text-muted fst-italic">"${this._escHtml(r.comment)}"</div>` : ''}
</div>
</div>`
}
html += '</div>'
}
// Response form
html += this._renderTriageForm(approval)
html += this._renderResponseForm(approval)
html += '</div>'
return html
}
_renderTriageForm(approval) {
const triage = approval.triage || { state: "new", note: "", states: { new: "New" } }
const stateId = `mobile-approval-triage-state-${approval.id}`
const noteId = `mobile-approval-triage-note-${approval.id}`
const helpId = `mobile-approval-triage-help-${approval.id}`
const statusId = `mobile-approval-triage-status-${approval.id}`
const options = Object.entries(triage.states || {}).map(([value, label]) => {
return `<option value="${this._escHtml(value)}"${value === triage.state ? " selected" : ""}>${this._escHtml(label)}</option>`
}).join("")
return `<form class="approval-triage-form mb-3"
data-mobile-triage-form="${approval.id}">
<div class="mb-2">
<label class="form-label fw-semibold" style="font-size: 0.85rem;" for="${stateId}">Private triage state</label>
<select class="form-select form-select-sm" id="${stateId}" data-mobile-triage-state aria-describedby="${helpId}">
${options}
</select>
</div>
<div class="mb-2">
<label class="form-label fw-semibold" style="font-size: 0.85rem;" for="${noteId}">Private note</label>
<textarea class="form-control form-control-sm" id="${noteId}" data-mobile-triage-note rows="2" aria-describedby="${helpId}">${this._escHtml(triage.note || "")}</textarea>
<div class="form-text" id="${helpId}">Only you can see this triage note. It does not submit an approval decision.</div>
</div>
<div class="d-flex align-items-center gap-2">
<button type="button" class="btn btn-sm btn-outline-secondary" data-action="click->mobile-approvals#saveTriage">
Save private triage
</button>
<span class="small text-muted" id="${statusId}" data-mobile-triage-status role="status" aria-live="polite"></span>
</div>
</form>`
}
_renderResponseForm(approval) {
const cfg = approval.approverConfig || {}
const commentWarning = cfg.commentWarning || ''
const isFeedbackResponse = cfg.feedbackResponse === true
const decisionOptions = Array.isArray(cfg.decisionOptions) ? cfg.decisionOptions : []
const decisionPromptLabel = cfg.decisionPromptLabel || 'Decision'
const requiresComment = cfg.requiresComment === true
const commentId = `mobile-approval-comment-${approval.id}`
const commentErrorId = `mobile-approval-comment-error-${approval.id}`
const commentHelpId = `mobile-approval-comment-help-${approval.id}`
const nextApproverId = `mobile-approval-next-approver-${approval.id}`
const gatheringId = `mobile-approval-bestowal-gathering-${approval.id}`
const gatheringHelpId = `mobile-approval-bestowal-gathering-help-${approval.id}`
const gatheringErrorId = `mobile-approval-bestowal-gathering-error-${approval.id}`
let html = `<div class="approval-response-form" data-approval-form-id="${approval.id}"${isFeedbackResponse && decisionOptions.length === 0 ? ' data-selected-decision="approve"' : ''}>`
if (decisionOptions.length > 0) {
html += `<fieldset class="mb-2">
<legend class="form-label fw-semibold mb-1" style="font-size: 0.85rem;">${this._escHtml(decisionPromptLabel)}</legend>`
for (const [index, option] of decisionOptions.entries()) {
const inputId = `approval-${approval.id}-decision-${index}`
html += `<div class="form-check">
<input class="form-check-input" type="radio" name="decision-${approval.id}" id="${inputId}"
value="${this._escHtml(option.value)}"
data-action="change->mobile-approvals#selectDecision"
data-id="${approval.id}" data-decision="${this._escHtml(option.value)}">
<label class="form-check-label" for="${inputId}">${this._escHtml(option.label)}</label>
</div>`
}
html += '</fieldset>'
} else if (!isFeedbackResponse) {
html += `<div class="approval-decision-btns">
<button type="button" class="btn btn-approve" aria-pressed="false"
data-action="click->mobile-approvals#selectDecision"
data-id="${approval.id}" data-decision="approve">
<i class="bi bi-check-circle me-1"></i>Approve
</button>
<button type="button" class="btn btn-reject" aria-pressed="false"
data-action="click->mobile-approvals#selectDecision"
data-id="${approval.id}" data-decision="reject">
<i class="bi bi-x-circle me-1"></i>Reject
</button>
</div>`
}
// Comment
const commentPlaceholder = isFeedbackResponse
? 'Enter requested feedback...'
: 'Comment (required for rejections)...'
html += `<div class="mb-2">
<label class="form-label fw-semibold" style="font-size: 0.85rem;" for="${commentId}">${isFeedbackResponse ? 'Feedback' : 'Comment'}${requiresComment ? ' <span class="text-danger">(required)</span>' : ''}</label>
<textarea class="approval-comment-box"
id="${commentId}"
data-approval-comment="${approval.id}"
placeholder="${commentPlaceholder}"
aria-describedby="${commentHelpId} ${commentErrorId}"
aria-invalid="false"
rows="2"></textarea>`
if (commentWarning) {
html += `<div class="approval-comment-warning" id="${commentHelpId}"><i class="bi bi-eye me-1"></i>${this._escHtml(commentWarning)}</div>`
} else {
html += `<div class="visually-hidden" id="${commentHelpId}">${isFeedbackResponse ? 'Enter feedback for this request.' : 'A comment is required when rejecting.'}</div>`
}
html += `<div class="text-danger small" id="${commentErrorId}" data-comment-required="${approval.id}" hidden>
<i class="bi bi-exclamation-circle me-1"></i>${isFeedbackResponse ? 'Feedback is required.' : 'A comment is required when rejecting.'}
</div></div>`
const gatheringOptions = Array.isArray(cfg.bestowalGatheringOptions) ? cfg.bestowalGatheringOptions : []
html += `<div class="mb-2" data-bestowal-gathering-section="${approval.id}" hidden>
<label class="form-label fw-semibold" style="font-size: 0.85rem;" for="${gatheringId}">
Bestowal Gathering <span class="text-muted fw-normal">(optional)</span>
</label>
<select class="form-select form-select-sm" id="${gatheringId}" data-bestowal-gathering-select="${approval.id}"
aria-describedby="${gatheringHelpId} ${gatheringErrorId}" aria-invalid="false"
data-action="change->mobile-approvals#bestowalGatheringChanged">
<option value="">Select a gathering...</option>
${gatheringOptions.map(option => `<option value="${this._escHtml(option.id)}">${this._escHtml(option.label)}</option>`).join('')}
</select>
<div class="form-text" id="${gatheringHelpId}">Optional: choose the event or court if you already know where the bestowal should be scheduled.</div>
<div class="text-danger small" id="${gatheringErrorId}" data-bestowal-gathering-error="${approval.id}" hidden>
<i class="bi bi-exclamation-circle me-1" aria-hidden="true"></i>Select a valid gathering or leave this field blank.
</div>
</div>`
// Next approver (hidden until needed)
html += `<div data-next-approver-section="${approval.id}" hidden>
<div class="alert alert-info py-2 small" role="alert" data-next-approver-info="${approval.id}"></div>
<label class="form-label fw-semibold" style="font-size: 0.85rem;" for="${nextApproverId}">Select Next Approver</label>
<div class="approval-next-approver">
<select data-next-approver-select="${approval.id}" id="${nextApproverId}" class="form-select form-select-lg">
<option value="">Loading approvers...</option>
</select>
</div>
</div>`
// Submit
html += `<button type="button" class="btn approval-submit-btn"
data-action="click->mobile-approvals#submitResponse"
data-submit-btn="${approval.id}"${isFeedbackResponse && decisionOptions.length === 0 ? '' : ' disabled'}>
<i class="bi bi-send me-1"></i>${isFeedbackResponse ? 'Send Feedback' : 'Submit Response'}
</button>`
html += '</div>'
return html
}
// --- Decision Handling ---
selectDecision(event) {
event.preventDefault()
const id = parseInt(event.currentTarget.dataset.id)
const decision = event.currentTarget.dataset.decision
const form = this.element.querySelector(`[data-approval-form-id="${id}"]`)
if (!form) return
// Update button states
form.querySelectorAll('.btn-approve, .btn-reject').forEach(btn => {
btn.classList.remove('active')
btn.setAttribute('aria-pressed', 'false')
})
if (event.currentTarget.classList.contains('btn')) {
event.currentTarget.classList.add('active')
event.currentTarget.setAttribute('aria-pressed', 'true')
}
// Store decision
form.dataset.selectedDecision = decision
// Show/hide comment required hint
const commentRequired = form.querySelector(`[data-comment-required="${id}"]`)
if (commentRequired) {
commentRequired.hidden = decision !== 'reject'
}
const comment = form.querySelector(`[data-approval-comment="${id}"]`)
if (comment && decision !== 'reject') {
comment.setAttribute('aria-invalid', 'false')
}
// Handle bestowal gathering and serial-pick-next for approve
const approval = this._approvals.find(a => a.id === id)
const cfg = approval?.approverConfig || {}
const gatheringSection = form.querySelector(`[data-bestowal-gathering-section="${id}"]`)
const gatheringSelect = form.querySelector(`[data-bestowal-gathering-select="${id}"]`)
if (gatheringSection && gatheringSelect) {
const showGathering = decision === 'approve' && cfg.requiresBestowalGathering === true
gatheringSection.hidden = !showGathering
if (showGathering) {
gatheringSelect.removeAttribute('required')
gatheringSelect.removeAttribute('aria-required')
} else {
gatheringSelect.removeAttribute('required')
gatheringSelect.removeAttribute('aria-required')
gatheringSelect.value = ''
gatheringSelect.setAttribute('aria-invalid', 'false')
const gatheringError = form.querySelector(`[data-bestowal-gathering-error="${id}"]`)
if (gatheringError) gatheringError.hidden = true
}
}
const nextSection = form.querySelector(`[data-next-approver-section="${id}"]`)
if (decision === 'approve' && cfg.serialPickNext && cfg.feedbackResponse !== true) {
const remaining = (approval.progress.required || 1) - (approval.progress.approved || 0) - 1
if (remaining > 0) {
if (nextSection) {
nextSection.hidden = false
const infoEl = form.querySelector(`[data-next-approver-info="${id}"]`)
if (infoEl) {
infoEl.textContent = `This approval requires ${remaining} more approver(s). Select who should review next.`
}
}
this._loadEligibleApprovers(id)
} else if (nextSection) {
nextSection.hidden = true
}
} else if (nextSection) {
nextSection.hidden = true
}
// Enable submit
const submitBtn = form.querySelector(`[data-submit-btn="${id}"]`)
if (submitBtn) submitBtn.disabled = false
}
async _loadEligibleApprovers(approvalId) {
const select = this.element.querySelector(`[data-next-approver-select="${approvalId}"]`)
if (!select) return
select.innerHTML = '<option value="">Loading...</option>'
select.disabled = true
try {
const resp = await this.fetchWithRetry(`${this.eligibleUrlValue}/${approvalId}`)
const htmlContent = await resp.text()
// Parse the HTML list items returned by eligibleApprovers endpoint
select.innerHTML = '<option value="">-- Choose next approver --</option>'
const temp = document.createElement('div')
temp.innerHTML = htmlContent
const items = temp.querySelectorAll('li[data-ac-value]')
items.forEach(li => {
const option = document.createElement('option')
option.value = li.dataset.acValue
option.textContent = li.textContent
select.appendChild(option)
})
select.disabled = false
} catch (e) {
console.error('Failed to load eligible approvers:', e)
select.innerHTML = '<option value="">Error loading approvers</option>'
}
}
bestowalGatheringChanged(event) {
if (!event.currentTarget.value) return
const id = event.currentTarget.dataset.bestowalGatheringSelect
event.currentTarget.setAttribute('aria-invalid', 'false')
const form = event.currentTarget.closest(`[data-approval-form-id="${id}"]`)
const error = form?.querySelector(`[data-bestowal-gathering-error="${id}"]`)
if (error) error.hidden = true
}
// --- Submit Response ---
async saveTriage(event) {
event.preventDefault()
const form = event.currentTarget.closest("[data-mobile-triage-form]")
if (!form || !this.hasTriageUrlValue) return
const approvalId = parseInt(form.dataset.mobileTriageForm)
const state = form.querySelector("[data-mobile-triage-state]")?.value || "new"
const note = form.querySelector("[data-mobile-triage-note]")?.value || ""
const status = form.querySelector("[data-mobile-triage-status]")
const button = event.currentTarget
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || ''
button.disabled = true
if (status) status.textContent = "Saving triage state..."
try {
const body = new FormData()
body.append('approvalId', approvalId)
body.append('state', state)
body.append('note', note)
body.append('_csrfToken', csrfToken)
const response = await fetch(this.triageUrlValue, {
method: 'POST',
headers: {
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-Token': csrfToken,
},
body,
})
const result = await this._readJsonResponse(response, 'Unable to save triage state. Please refresh the page and try again.')
if (!response.ok || result.success === false) {
throw new Error(result.error || `HTTP ${response.status}`)
}
const approval = this._approvals.find(a => a.id === approvalId)
if (approval) {
approval.triage = result.triage
}
if (status) status.textContent = "Private triage state saved."
this._showToast("Private triage state saved.", "success")
} catch (error) {
const message = error.message || "Unable to save triage state."
if (status) status.textContent = message
this._showToast(message, "danger")
} finally {
button.disabled = false
}
}
async _readJsonResponse(response, fallbackMessage = 'Unexpected response from the server.') {
const contentType = response.headers.get('content-type') || ''
if (contentType.includes('application/json')) {
try {
return await response.json()
} catch (error) {
throw new Error(fallbackMessage)
}
}
throw new Error(fallbackMessage)
}
async submitResponse(event) {
event.preventDefault()
if (this._submitting) return
const id = parseInt(event.currentTarget.dataset.submitBtn)
const form = this.element.querySelector(`[data-approval-form-id="${id}"]`)
if (!form) return
const decision = form.dataset.selectedDecision
if (!decision) return
const comment = (form.querySelector(`[data-approval-comment="${id}"]`)?.value || '').trim()
const nextApproverId = form.querySelector(`[data-next-approver-select="${id}"]`)?.value || null
// Validate
const approval = this._approvals.find(a => a.id === id)
const cfg = approval?.approverConfig || {}
const requiresComment = decision === 'reject' || cfg.requiresComment === true
if (requiresComment && !comment) {
const hint = form.querySelector(`[data-comment-required="${id}"]`)
if (hint) hint.hidden = false
const commentField = form.querySelector(`[data-approval-comment="${id}"]`)
commentField?.setAttribute('aria-invalid', 'true')
commentField?.focus()
return
}
const bestowalGatheringId = form.querySelector(`[data-bestowal-gathering-select="${id}"]`)?.value || ''
if (!this.online) {
this._showToast('You must be online to submit responses.', 'danger')
return
}
// Submit
this._submitting = true
const card = this.listTarget.querySelector(`[data-approval-id="${id}"]`)
if (card) card.classList.add('approval-card-submitting')
const submitBtn = form.querySelector(`[data-submit-btn="${id}"]`)
if (submitBtn) {
submitBtn.disabled = true
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Submitting...'
}
try {
// Get CSRF token
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || ''
const body = new FormData()
body.append('approvalId', id)
body.append('decision', decision)
body.append('comment', comment)
if (bestowalGatheringId) body.append('bestowal_gathering_id', bestowalGatheringId)
if (nextApproverId) body.append('next_approver_id', nextApproverId)
body.append('_csrfToken', csrfToken)
const response = await fetch(this.recordUrlValue, {
method: 'POST',
headers: {
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-Token': csrfToken,
},
body: body,
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const result = await this._readJsonResponse(response, 'Failed to submit response. Please refresh the page and try again.')
if (result.success === false) {
throw new Error(result.error || result.reason || 'Failed to record response')
}
// Success — animate card out and remove from list
if (card) {
card.classList.remove('approval-card-submitting')
card.classList.add('approval-card-done')
card.addEventListener('animationend', () => card.remove(), { once: true })
}
this._approvals = this._approvals.filter(a => a.id !== id)
this._totalCount = Math.max(0, this._totalCount - 1)
this._expandedId = null
this._updateBadge()
this._updateLoadMore()
const message = cfg.feedbackResponse === true
? 'Feedback sent successfully.'
: `Approval ${decision === 'approve' ? 'approved' : 'rejected'} successfully.`
this._showToast(message, 'success')
// Show empty state if none left
if (this._approvals.length === 0 && this._hasNextPage) {
await this.loadMore()
} else if (this._approvals.length === 0) {
setTimeout(() => this._showState('empty'), 500)
}
} catch (error) {
console.error('Failed to submit response:', error)
this._showToast(error.message || 'Failed to submit response.', 'danger')
if (card) card.classList.remove('approval-card-submitting')
if (submitBtn) {
submitBtn.disabled = false
submitBtn.innerHTML = '<i class="bi bi-send me-1"></i>Submit Response'
}
} finally {
this._submitting = false
}
}
// --- UI Helpers ---
_showState(state) {
if (this.hasLoadingTarget) this.loadingTarget.hidden = state !== 'loading'
if (this.hasEmptyTarget) this.emptyTarget.hidden = state !== 'empty'
if (this.hasErrorTarget) this.errorTarget.hidden = state !== 'error'
if (this.hasListTarget) this.listTarget.hidden = state !== 'list'
if (this.hasLoadMoreTarget) this.loadMoreTarget.hidden = state !== 'list' || !this._hasNextPage
}
_updateBadge() {
if (!this.hasCountBadgeTarget) return
const loaded = this._approvals.length
const total = this._totalCount || loaded
this.countBadgeTarget.textContent = loaded === total
? `${total} pending`
: `${loaded} of ${total} pending`
this.countBadgeTarget.hidden = total === 0
}
_updateLoadMore() {
if (this.hasLoadMoreTarget) {
this.loadMoreTarget.hidden = !this._hasNextPage || this._approvals.length === 0
}
if (this.hasLoadMoreBtnTarget) {
this.loadMoreBtnTarget.disabled = !this._hasNextPage
const remaining = Math.max(0, this._totalCount - this._approvals.length)
this.loadMoreBtnTarget.textContent = remaining > 0
? `Load more (${remaining} remaining)`
: 'Load more'
}
}
_setLoadMoreBusy(isBusy) {
if (!this.hasLoadMoreBtnTarget) return
this.loadMoreBtnTarget.disabled = isBusy || !this._hasNextPage
this.loadMoreBtnTarget.setAttribute('aria-busy', isBusy ? 'true' : 'false')
if (isBusy) {
this.loadMoreBtnTarget.innerHTML = '<span class="spinner-border spinner-border-sm me-1" aria-hidden="true"></span>Loading...'
} else {
this._updateLoadMore()
}
}
_mergeApprovals(existing, incoming) {
const seen = new Set(existing.map(approval => approval.id))
return existing.concat(incoming.filter(approval => {
if (seen.has(approval.id)) return false
seen.add(approval.id)
return true
}))
}
_pageUrl(page) {
const url = new URL(this.dataUrlValue, window.location.origin)
url.searchParams.set('page', page)
url.searchParams.set('per_page', this._perPage)
return url.toString()
}
_configuredPerPage() {
const perPage = parseInt(this.perPageValue, 10)
return perPage > 0 ? perPage : MOBILE_QUEUE_DEFAULT_PER_PAGE
}
_announce(message) {
if (this.hasStatusTarget) {
this.statusTarget.textContent = message
}
window.KMP_accessibility?.announce?.(message)
}
_showToast(message, type) {
if (!this.hasToastTarget || !this.hasToastBodyTarget) return
this.toastBodyTarget.textContent = message
this.toastTarget.className = `toast align-items-center border-0 text-bg-${type}`
const toast = new bootstrap.Toast(this.toastTarget)
toast.show()
}
_timeAgo(isoString) {
if (!isoString) return ''
const diff = Date.now() - new Date(isoString).getTime()
const mins = Math.floor(diff / 60000)
if (mins < 1) return 'just now'
if (mins < 60) return `${mins}m ago`
const hrs = Math.floor(mins / 60)
if (hrs < 24) return `${hrs}h ago`
const days = Math.floor(hrs / 24)
return `${days}d ago`
}
_escHtml(str) {
if (!str) return ''
const div = document.createElement('div')
div.textContent = String(str)
return div.innerHTML
}
}
if (!window.Controllers) {
window.Controllers = {}
}
window.Controllers["mobile-approvals"] = MobileApprovalsController
export default MobileApprovalsController