Add 4 AI bookkeeping features
Feature 7: Bank Rec Auto-Match — AiSuggestMatches endpoint scores uncleared transactions vs statement ending balance; AI Auto-Match panel in Reconcile.cshtml with confidence highlights and Apply All button. Feature 8: Late Payment Prediction — PredictLatePayments endpoint scores open AR customers by risk (high/medium/low) using historical avg-days-to-pay + late rate; rendered as badge table in AR Aging view via ar-aging-ai.js. Feature 9: Natural Language Financial Queries — FinancialQuery GET page + RunFinancialQuery POST; 12-month context snapshot pre-loaded; answers grounded in real data with supporting facts, follow-up suggestions, session history, and example chips. Feature 10: Recurring Bill Detection — RunRecurringDetection scans 12 months of bills for vendor payment patterns (monthly/quarterly/annual); card grid view in Bills/RecurringDetection.cshtml with confidence badges, next-expected-date, and suggested actions. Supporting: 4 new DTO groups in AccountingAiDtos.cs, 4 method signatures in IAccountingAiService.cs, 4 implementations in AccountingAiService.cs, 4 new AiFeatures constants, 2 new Landing page AI report cards. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
(function () {
|
||||
const btn = document.getElementById('aiRiskBtn');
|
||||
if (!btn) return;
|
||||
|
||||
btn.addEventListener('click', async function () {
|
||||
const body = document.getElementById('aiRiskBody');
|
||||
const spinner = document.getElementById('aiRiskSpinner');
|
||||
const errEl = document.getElementById('aiRiskError');
|
||||
const insights = document.getElementById('aiRiskInsights');
|
||||
const table = document.getElementById('aiRiskTable');
|
||||
const rows = document.getElementById('aiRiskRows');
|
||||
|
||||
body.classList.remove('d-none');
|
||||
spinner.classList.remove('d-none');
|
||||
errEl.classList.add('d-none');
|
||||
table.classList.add('d-none');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Analyzing…';
|
||||
|
||||
try {
|
||||
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
|
||||
const resp = await fetch('/Reports/PredictLatePayments', {
|
||||
method: 'POST',
|
||||
headers: { 'RequestVerificationToken': token }
|
||||
});
|
||||
const data = await resp.json();
|
||||
|
||||
spinner.classList.add('d-none');
|
||||
|
||||
if (!data.success) {
|
||||
errEl.textContent = data.errorMessage || 'AI service unavailable.';
|
||||
errEl.classList.remove('d-none');
|
||||
return;
|
||||
}
|
||||
|
||||
const preds = data.predictions || [];
|
||||
const insightList = data.insights || [];
|
||||
|
||||
insights.innerHTML = insightList
|
||||
.map(i => `<i class="bi bi-lightbulb text-warning me-1"></i>${i}`)
|
||||
.join('<br>');
|
||||
|
||||
const riskBadge = level => {
|
||||
if (level === 'high') return 'bg-danger text-white';
|
||||
if (level === 'medium') return 'bg-warning text-dark';
|
||||
return 'bg-success text-white';
|
||||
};
|
||||
|
||||
rows.innerHTML = preds.map(p => `
|
||||
<tr>
|
||||
<td class="fw-medium">${p.customerName}</td>
|
||||
<td><span class="badge ${riskBadge(p.riskLevel)}">${p.riskLevel.charAt(0).toUpperCase() + p.riskLevel.slice(1)}</span></td>
|
||||
<td>${p.estimatedDaysToPayment > 0 ? p.estimatedDaysToPayment + ' days' : 'Soon'}</td>
|
||||
<td class="text-muted small">${p.reasoning}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
if (preds.length > 0) table.classList.remove('d-none');
|
||||
else insights.innerHTML += '<br><span class="text-muted">No open invoices to predict.</span>';
|
||||
|
||||
} catch (err) {
|
||||
spinner.classList.add('d-none');
|
||||
errEl.textContent = 'Error contacting AI service.';
|
||||
errEl.classList.remove('d-none');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="bi bi-magic me-1"></i>Predict Payment Risk';
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,116 @@
|
||||
(function () {
|
||||
const queryInput = document.getElementById('queryInput');
|
||||
const queryBtn = document.getElementById('queryBtn');
|
||||
const answerArea = document.getElementById('answerArea');
|
||||
const spinner = document.getElementById('answerSpinner');
|
||||
const errEl = document.getElementById('answerError');
|
||||
const answerText = document.getElementById('answerText');
|
||||
const factsArea = document.getElementById('factsArea');
|
||||
const factsList = document.getElementById('factsList');
|
||||
const followUpArea = document.getElementById('followUpArea');
|
||||
const followUpBtn = document.getElementById('followUpBtn');
|
||||
const historyArea = document.getElementById('historyArea');
|
||||
const historyList = document.getElementById('historyList');
|
||||
const chips = document.getElementById('suggestionChips');
|
||||
const contextJson = document.getElementById('contextJson')?.value ?? '{}';
|
||||
|
||||
const suggestions = [
|
||||
'What was our revenue this year?',
|
||||
'What are our biggest expenses?',
|
||||
'How much do customers owe us?',
|
||||
'What is our net income year to date?',
|
||||
'Which month had the highest revenue?',
|
||||
];
|
||||
|
||||
let sessionHistory = [];
|
||||
|
||||
suggestions.forEach(s => {
|
||||
const chip = document.createElement('button');
|
||||
chip.type = 'button';
|
||||
chip.className = 'btn btn-sm btn-outline-secondary rounded-pill';
|
||||
chip.textContent = s;
|
||||
chip.addEventListener('click', () => { queryInput.value = s; runQuery(s); });
|
||||
chips.appendChild(chip);
|
||||
});
|
||||
|
||||
queryBtn.addEventListener('click', () => runQuery(queryInput.value.trim()));
|
||||
queryInput.addEventListener('keydown', e => { if (e.key === 'Enter') runQuery(queryInput.value.trim()); });
|
||||
followUpBtn.addEventListener('click', () => { queryInput.value = followUpBtn.textContent; runQuery(followUpBtn.textContent); });
|
||||
|
||||
async function runQuery(question) {
|
||||
if (!question) return;
|
||||
|
||||
// Save previous answer to history before replacing
|
||||
const prevQuestion = queryInput.dataset.lastQuestion;
|
||||
const prevAnswer = answerText.textContent;
|
||||
if (prevQuestion && prevAnswer) {
|
||||
sessionHistory.push({ question: prevQuestion, answer: prevAnswer });
|
||||
renderHistory();
|
||||
}
|
||||
queryInput.dataset.lastQuestion = question;
|
||||
|
||||
answerArea.classList.remove('d-none');
|
||||
spinner.classList.remove('d-none');
|
||||
errEl.classList.add('d-none');
|
||||
answerText.classList.add('d-none');
|
||||
factsArea.classList.add('d-none');
|
||||
followUpArea.classList.add('d-none');
|
||||
queryBtn.disabled = true;
|
||||
queryBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>';
|
||||
|
||||
try {
|
||||
let context = {};
|
||||
try { context = JSON.parse(contextJson); } catch {}
|
||||
|
||||
const resp = await fetch('/Reports/RunFinancialQuery', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ question, context })
|
||||
});
|
||||
const data = await resp.json();
|
||||
spinner.classList.add('d-none');
|
||||
|
||||
if (!data.success) {
|
||||
errEl.textContent = data.errorMessage || 'AI service unavailable.';
|
||||
errEl.classList.remove('d-none');
|
||||
return;
|
||||
}
|
||||
|
||||
answerText.textContent = data.answer;
|
||||
answerText.classList.remove('d-none');
|
||||
|
||||
const facts = data.relevantFacts || [];
|
||||
if (facts.length > 0) {
|
||||
factsList.innerHTML = facts.map(f => `<li><i class="bi bi-dot"></i>${f}</li>`).join('');
|
||||
factsArea.classList.remove('d-none');
|
||||
}
|
||||
|
||||
if (data.followUpSuggestion) {
|
||||
followUpBtn.textContent = data.followUpSuggestion;
|
||||
followUpArea.classList.remove('d-none');
|
||||
}
|
||||
|
||||
} catch {
|
||||
spinner.classList.add('d-none');
|
||||
errEl.textContent = 'Error contacting AI service.';
|
||||
errEl.classList.remove('d-none');
|
||||
} finally {
|
||||
queryBtn.disabled = false;
|
||||
queryBtn.innerHTML = '<i class="bi bi-send me-1"></i>Ask';
|
||||
}
|
||||
}
|
||||
|
||||
function renderHistory() {
|
||||
if (sessionHistory.length === 0) return;
|
||||
historyArea.classList.remove('d-none');
|
||||
historyList.innerHTML = '';
|
||||
sessionHistory.slice(-5).reverse().forEach(entry => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'card border-0 bg-light p-2 small cursor-pointer';
|
||||
item.style.cursor = 'pointer';
|
||||
item.innerHTML = `<span class="fw-medium text-muted">${entry.question}</span><br><span class="text-truncate d-block" style="max-width:100%">${entry.answer.substring(0, 100)}${entry.answer.length > 100 ? '…' : ''}</span>`;
|
||||
item.addEventListener('click', () => { queryInput.value = entry.question; });
|
||||
historyList.appendChild(item);
|
||||
});
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,111 @@
|
||||
(function () {
|
||||
const form = document.getElementById('scanForm');
|
||||
const scanBtn = document.getElementById('scanBtn');
|
||||
const resultArea = document.getElementById('resultArea');
|
||||
const spinner = document.getElementById('spinnerArea');
|
||||
const errArea = document.getElementById('errorArea');
|
||||
const insArea = document.getElementById('insightsArea');
|
||||
const insList = document.getElementById('insightsList');
|
||||
const noPatterns = document.getElementById('noPatterns');
|
||||
const patternsArea = document.getElementById('patternsArea');
|
||||
const patternCards = document.getElementById('patternCards');
|
||||
|
||||
if (!form) return;
|
||||
|
||||
form.addEventListener('submit', async function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
resultArea.classList.remove('d-none');
|
||||
spinner.classList.remove('d-none');
|
||||
errArea.classList.add('d-none');
|
||||
insArea.classList.add('d-none');
|
||||
noPatterns.classList.add('d-none');
|
||||
patternsArea.classList.add('d-none');
|
||||
patternCards.innerHTML = '';
|
||||
scanBtn.disabled = true;
|
||||
scanBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Analyzing…';
|
||||
|
||||
try {
|
||||
const token = form.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
|
||||
const resp = await fetch('/Bills/RunRecurringDetection', {
|
||||
method: 'POST',
|
||||
headers: { 'RequestVerificationToken': token }
|
||||
});
|
||||
const data = await resp.json();
|
||||
spinner.classList.add('d-none');
|
||||
|
||||
if (!data.success) {
|
||||
errArea.textContent = data.errorMessage || 'AI service unavailable.';
|
||||
errArea.classList.remove('d-none');
|
||||
return;
|
||||
}
|
||||
|
||||
const insights = data.insights || [];
|
||||
if (insights.length > 0) {
|
||||
insList.innerHTML = insights.join('<br>');
|
||||
insArea.classList.remove('d-none');
|
||||
}
|
||||
|
||||
const patterns = data.patterns || [];
|
||||
if (patterns.length === 0) {
|
||||
noPatterns.classList.remove('d-none');
|
||||
return;
|
||||
}
|
||||
|
||||
patternsArea.classList.remove('d-none');
|
||||
patterns.forEach(p => {
|
||||
const confBadge = p.confidence === 'high' ? 'bg-success text-white'
|
||||
: p.confidence === 'medium' ? 'bg-warning text-dark'
|
||||
: 'bg-secondary text-white';
|
||||
|
||||
const freqIcon = p.frequency === 'monthly' ? 'bi-calendar-month'
|
||||
: p.frequency === 'quarterly' ? 'bi-calendar3'
|
||||
: p.frequency === 'annual' ? 'bi-calendar-check'
|
||||
: 'bi-arrow-repeat';
|
||||
|
||||
const nextDate = p.nextExpectedDateIso
|
||||
? new Date(p.nextExpectedDateIso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
: null;
|
||||
|
||||
const col = document.createElement('div');
|
||||
col.className = 'col-md-6 col-xl-4';
|
||||
col.innerHTML = `
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-header d-flex align-items-center gap-2">
|
||||
<i class="bi ${freqIcon} text-primary"></i>
|
||||
<span class="fw-semibold text-truncate">${p.vendorName}</span>
|
||||
<span class="badge ${confBadge} ms-auto">${p.confidence}</span>
|
||||
</div>
|
||||
<div class="card-body small">
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span class="text-muted">Frequency</span>
|
||||
<span class="fw-medium text-capitalize">${p.frequency}</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span class="text-muted">Typical amount</span>
|
||||
<span class="fw-medium">${p.typicalAmount.toLocaleString('en-US', { style: 'currency', currency: 'USD' })}</span>
|
||||
</div>
|
||||
${nextDate ? `<div class="d-flex justify-content-between mb-2">
|
||||
<span class="text-muted">Next expected</span>
|
||||
<span class="fw-medium text-warning">${nextDate}</span>
|
||||
</div>` : ''}
|
||||
<p class="text-muted mb-2">${p.description}</p>
|
||||
${p.suggestedAction ? `<div class="alert alert-info alert-permanent py-1 px-2 mb-0 small">
|
||||
<i class="bi bi-arrow-right-circle me-1"></i>${p.suggestedAction}
|
||||
</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
patternCards.appendChild(col);
|
||||
});
|
||||
|
||||
} catch {
|
||||
spinner.classList.add('d-none');
|
||||
errArea.textContent = 'Error contacting AI service.';
|
||||
errArea.classList.remove('d-none');
|
||||
} finally {
|
||||
scanBtn.disabled = false;
|
||||
scanBtn.innerHTML = '<i class="bi bi-magic me-1"></i>Detect Recurring Bills';
|
||||
}
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user