Add floating AI chat panel with page context awareness
Replace the standalone /chat page with a bottom-right floating widget available on every page. The panel sends page context with each message so the AI knows what the user is viewing. On the Transactions page, the full visible transaction list (with filters) is serialized into the context so the AI can answer questions about on-screen entries. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="d-flex align-center mb-4">
|
||||
<h1 class="text-h4">{{ accountName || 'Transactions' }}</h1>
|
||||
<h1 class="text-h4">Transactions</h1>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
color="info"
|
||||
@@ -20,16 +20,28 @@
|
||||
<v-card class="mb-4">
|
||||
<v-card-text>
|
||||
<v-row>
|
||||
<v-col cols="12" md="3">
|
||||
<v-select
|
||||
v-model="accountFilter"
|
||||
label="Account"
|
||||
:items="accountOptions"
|
||||
item-title="name"
|
||||
item-value="id"
|
||||
density="compact"
|
||||
clearable
|
||||
@update:model-value="onAccountFilterChange"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
<v-text-field v-model="search" label="Search" prepend-icon="mdi-magnify" clearable density="compact" @update:model-value="doSearch" />
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
<v-col cols="12" md="2">
|
||||
<v-text-field v-model="startDate" label="Start Date" type="date" density="compact" @update:model-value="doSearch" />
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
<v-col cols="12" md="2">
|
||||
<v-text-field v-model="endDate" label="End Date" type="date" density="compact" @update:model-value="doSearch" />
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
<v-col cols="12" md="2">
|
||||
<v-select v-model="statusFilter" label="Status" :items="['All','Uncleared','Cleared','Reconciled']" density="compact" @update:model-value="doSearch" />
|
||||
</v-col>
|
||||
</v-row>
|
||||
@@ -98,7 +110,7 @@
|
||||
<v-card>
|
||||
<v-card-title>{{ editingTxn ? 'Edit Transaction' : 'Add Transaction' }}</v-card-title>
|
||||
<v-card-text>
|
||||
<v-select v-if="!props.id" v-model="txnForm.accountId" label="Account" :items="accountsStore.accounts" item-title="name" item-value="id" />
|
||||
<v-select v-if="!accountFilter" v-model="txnForm.accountId" label="Account" :items="accountsStore.accounts" item-title="name" item-value="id" />
|
||||
<v-text-field v-model="txnForm.date" label="Date" type="date" />
|
||||
<v-text-field v-model.number="txnForm.amount" label="Amount" type="number" prefix="$" />
|
||||
<v-select v-model="txnForm.type" label="Type" :items="['Debit','Credit']" />
|
||||
@@ -187,22 +199,26 @@
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<v-snackbar v-model="classifySnackbar" :color="classifySnackbarColor" :timeout="4000">
|
||||
<v-snackbar v-model="classifySnackbar" :color="classifySnackbarColor" :timeout="5000">
|
||||
{{ classifySnackbarText }}
|
||||
</v-snackbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, watch, watchEffect } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { transactionsApi } from '@/services/transactions'
|
||||
import { categoriesApi } from '@/services/categories'
|
||||
import { aiCategorizationApi } from '@/services/aiCategorization'
|
||||
import { useAccountsStore } from '@/stores/accounts'
|
||||
import { useNotifications } from '@/composables/useNotifications'
|
||||
import { usePageContext } from '@/composables/usePageContext'
|
||||
import { formatCurrency, formatDate } from '@/utils/formatters'
|
||||
import type { Transaction, Category, SplitFormItem } from '@/types'
|
||||
import type { Transaction, Category, SplitFormItem, AiClassificationNotification } from '@/types'
|
||||
|
||||
const props = defineProps<{ id?: string }>()
|
||||
const router = useRouter()
|
||||
const accountsStore = useAccountsStore()
|
||||
const transactions = ref<Transaction[]>([])
|
||||
const totalCount = ref(0)
|
||||
@@ -210,11 +226,37 @@ const page = ref(1)
|
||||
const itemsPerPage = ref(50)
|
||||
const categories = ref<Category[]>([])
|
||||
const loading = ref(false)
|
||||
const accountName = ref('')
|
||||
const accountFilter = ref<string | null>(props.id || null)
|
||||
const search = ref('')
|
||||
const startDate = ref('')
|
||||
const endDate = ref('')
|
||||
const statusFilter = ref('All')
|
||||
|
||||
const { setPageContext, clearPageContext } = usePageContext()
|
||||
|
||||
watchEffect(() => {
|
||||
const filters: string[] = []
|
||||
if (accountFilter.value) {
|
||||
const acct = accountsStore.accounts.find(a => a.id === accountFilter.value)
|
||||
filters.push(`account="${acct?.name || accountFilter.value}"`)
|
||||
}
|
||||
if (search.value) filters.push(`search="${search.value}"`)
|
||||
if (startDate.value) filters.push(`from ${startDate.value}`)
|
||||
if (endDate.value) filters.push(`to ${endDate.value}`)
|
||||
if (statusFilter.value !== 'All') filters.push(`status=${statusFilter.value}`)
|
||||
|
||||
const filterDesc = filters.length > 0 ? ` (${filters.join(', ')})` : ''
|
||||
const lines = [`Transactions page${filterDesc} — showing ${transactions.value.length} of ${totalCount.value} transactions:`]
|
||||
|
||||
for (const txn of transactions.value) {
|
||||
lines.push(`- ${txn.date.split('T')[0]} | ${txn.payeeName || '—'} | ${txn.categoryName || 'Uncategorized'} | ${txn.amount >= 0 ? '+' : ''}${txn.amount.toFixed(2)} | ${txn.status} | ID:${txn.id}`)
|
||||
}
|
||||
|
||||
setPageContext(lines.join('\n'))
|
||||
})
|
||||
|
||||
onUnmounted(() => clearPageContext())
|
||||
|
||||
const showAddDialog = ref(false)
|
||||
const showTransferDialog = ref(false)
|
||||
const editingTxn = ref(false)
|
||||
@@ -255,17 +297,26 @@ const splitRemaining = computed(() => {
|
||||
return Math.round((total - splitTotal) * 100) / 100
|
||||
})
|
||||
|
||||
const accountOptions = computed(() => accountsStore.accounts)
|
||||
|
||||
function onAccountFilterChange(value: string | null) {
|
||||
if (value) {
|
||||
router.replace({ name: 'account-transactions', params: { id: value } })
|
||||
} else {
|
||||
router.replace({ name: 'transactions' })
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await accountsStore.fetchAccounts()
|
||||
loadCategories()
|
||||
if (props.id) {
|
||||
txnForm.value.accountId = props.id
|
||||
const acct = accountsStore.accounts.find(a => a.id === props.id)
|
||||
accountName.value = acct?.name || ''
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.id, () => {
|
||||
watch(() => props.id, (newId) => {
|
||||
accountFilter.value = newId || null
|
||||
page.value = 1
|
||||
loadTransactions()
|
||||
})
|
||||
@@ -280,13 +331,14 @@ async function loadCategories() {
|
||||
async function loadTransactions() {
|
||||
loading.value = true
|
||||
try {
|
||||
if (props.id && !search.value && !startDate.value && !endDate.value && statusFilter.value === 'All') {
|
||||
const { data } = await transactionsApi.getByAccount(props.id, page.value, itemsPerPage.value)
|
||||
const acctId = accountFilter.value || undefined
|
||||
if (acctId && !search.value && !startDate.value && !endDate.value && statusFilter.value === 'All') {
|
||||
const { data } = await transactionsApi.getByAccount(acctId, page.value, itemsPerPage.value)
|
||||
transactions.value = data.items
|
||||
totalCount.value = data.totalCount
|
||||
} else {
|
||||
const { data } = await transactionsApi.search({
|
||||
accountId: props.id || undefined,
|
||||
accountId: acctId,
|
||||
searchText: search.value || undefined,
|
||||
startDate: startDate.value || undefined,
|
||||
endDate: endDate.value || undefined,
|
||||
@@ -320,7 +372,7 @@ function openAddDialog() {
|
||||
editingTxn.value = false
|
||||
editingTxnId.value = ''
|
||||
txnForm.value = {
|
||||
accountId: props.id || '',
|
||||
accountId: accountFilter.value || '',
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
amount: 0,
|
||||
type: 'Debit',
|
||||
@@ -388,7 +440,7 @@ async function saveTxn() {
|
||||
if (editingTxn.value) {
|
||||
await transactionsApi.update(editingTxnId.value, payload)
|
||||
} else {
|
||||
payload.accountId = txnForm.value.accountId || props.id
|
||||
payload.accountId = txnForm.value.accountId || accountFilter.value
|
||||
await transactionsApi.create(payload)
|
||||
}
|
||||
showAddDialog.value = false
|
||||
@@ -407,25 +459,42 @@ async function createTransfer() {
|
||||
await loadTransactions()
|
||||
}
|
||||
|
||||
const { notifications } = useNotifications()
|
||||
|
||||
watch(notifications, (items) => {
|
||||
const latest = items.find(n => n.type === 'AiClassificationComplete' && !n.read)
|
||||
if (!latest || !classifying.value) return
|
||||
const data = latest.data as AiClassificationNotification
|
||||
if (data.error) {
|
||||
classifySnackbarColor.value = 'error'
|
||||
classifySnackbarText.value = data.error
|
||||
} else {
|
||||
classifySnackbarColor.value = 'success'
|
||||
classifySnackbarText.value = `Classified ${data.classified} of ${data.totalUncategorized} uncategorized transactions`
|
||||
}
|
||||
classifySnackbar.value = true
|
||||
classifying.value = false
|
||||
latest.read = true
|
||||
loadTransactions()
|
||||
}, { deep: true })
|
||||
|
||||
async function classifyUncategorized() {
|
||||
classifying.value = true
|
||||
try {
|
||||
const { data } = await aiCategorizationApi.classifyUncategorized({
|
||||
accountId: props.id || undefined,
|
||||
await aiCategorizationApi.classifyUncategorized({
|
||||
accountId: accountFilter.value || undefined,
|
||||
startDate: startDate.value || undefined,
|
||||
endDate: endDate.value || undefined,
|
||||
searchText: search.value || undefined,
|
||||
status: statusFilter.value === 'All' ? undefined : statusFilter.value,
|
||||
})
|
||||
classifySnackbarColor.value = 'success'
|
||||
classifySnackbarText.value = `Classified ${data.classified} of ${data.totalUncategorized} uncategorized transactions`
|
||||
classifySnackbarColor.value = 'info'
|
||||
classifySnackbarText.value = 'Classification started. You will be notified when it completes.'
|
||||
classifySnackbar.value = true
|
||||
await doSearch()
|
||||
} catch (e: any) {
|
||||
classifySnackbarColor.value = 'error'
|
||||
classifySnackbarText.value = e?.response?.data?.message || 'Failed to classify transactions'
|
||||
classifySnackbarText.value = e?.response?.data?.message || 'Failed to start classification'
|
||||
classifySnackbar.value = true
|
||||
} finally {
|
||||
classifying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user