ac08e21e55
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1452 lines
50 KiB
Vue
1452 lines
50 KiB
Vue
<template>
|
|
<div>
|
|
<v-tabs v-model="activeTab" color="primary">
|
|
<v-tab value="profile">Profile</v-tab>
|
|
<v-tab value="connections">Bank Connections</v-tab>
|
|
<v-tab value="history">Sync History</v-tab>
|
|
<v-tab value="ai">AI</v-tab>
|
|
</v-tabs>
|
|
|
|
<v-tabs-window v-model="activeTab" class="mt-4">
|
|
<!-- Tab 1: Profile -->
|
|
<v-tabs-window-item value="profile">
|
|
<!-- Profile Info Card -->
|
|
<v-card class="mb-6">
|
|
<v-card-title>Profile Information</v-card-title>
|
|
<v-card-text>
|
|
<v-text-field
|
|
v-model="profileForm.displayName"
|
|
label="Display Name"
|
|
variant="outlined"
|
|
class="mb-4"
|
|
placeholder="Enter a display name..."
|
|
/>
|
|
<v-text-field
|
|
v-model="profileForm.email"
|
|
label="Email"
|
|
variant="outlined"
|
|
class="mb-4"
|
|
type="email"
|
|
/>
|
|
<v-text-field
|
|
:model-value="authStore.username"
|
|
label="Username"
|
|
readonly
|
|
variant="outlined"
|
|
class="mb-4"
|
|
hint="Username cannot be changed"
|
|
persistent-hint
|
|
/>
|
|
<v-btn color="primary" :loading="profileSaving" @click="saveProfile">Save</v-btn>
|
|
</v-card-text>
|
|
</v-card>
|
|
|
|
<!-- Change Password Card -->
|
|
<v-card class="mb-6">
|
|
<v-card-title>Change Password</v-card-title>
|
|
<v-card-text>
|
|
<v-text-field
|
|
v-model="passwordForm.currentPassword"
|
|
label="Current Password"
|
|
variant="outlined"
|
|
type="password"
|
|
class="mb-4"
|
|
/>
|
|
<v-text-field
|
|
v-model="passwordForm.newPassword"
|
|
label="New Password"
|
|
variant="outlined"
|
|
type="password"
|
|
class="mb-4"
|
|
/>
|
|
<v-text-field
|
|
v-model="passwordForm.confirmPassword"
|
|
label="Confirm New Password"
|
|
variant="outlined"
|
|
type="password"
|
|
class="mb-4"
|
|
:error-messages="passwordForm.confirmPassword && passwordForm.newPassword !== passwordForm.confirmPassword ? ['Passwords do not match'] : []"
|
|
/>
|
|
<v-btn
|
|
color="primary"
|
|
:loading="passwordChanging"
|
|
:disabled="!passwordForm.currentPassword || !passwordForm.newPassword || passwordForm.newPassword !== passwordForm.confirmPassword"
|
|
@click="changePassword"
|
|
>
|
|
Change Password
|
|
</v-btn>
|
|
</v-card-text>
|
|
</v-card>
|
|
|
|
<!-- Account Info Card -->
|
|
<v-card class="mb-6">
|
|
<v-card-title>Account Info</v-card-title>
|
|
<v-card-text>
|
|
<div class="text-body-1 mb-2">
|
|
<strong>Member since:</strong> {{ profile ? new Date(profile.createdAt).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }) : '-' }}
|
|
</div>
|
|
<div class="text-body-1">
|
|
<strong>Last login:</strong> {{ profile?.lastLoginAt ? new Date(profile.lastLoginAt).toLocaleString() : 'Never' }}
|
|
</div>
|
|
</v-card-text>
|
|
</v-card>
|
|
|
|
<!-- Danger Zone Card -->
|
|
<v-card variant="outlined" color="error">
|
|
<v-card-title class="text-error">Danger Zone</v-card-title>
|
|
<v-card-text>
|
|
<p class="text-body-2 mb-4">Permanently delete your account and all associated data. This action cannot be undone.</p>
|
|
<v-btn color="error" variant="tonal" @click="showDeleteAccountDialog = true">Delete Account</v-btn>
|
|
</v-card-text>
|
|
</v-card>
|
|
|
|
<!-- Delete Account Confirmation Dialog -->
|
|
<v-dialog v-model="showDeleteAccountDialog" max-width="400">
|
|
<v-card>
|
|
<v-card-title class="text-error">Delete Account</v-card-title>
|
|
<v-card-text>
|
|
Are you sure you want to permanently delete your account? All your data including accounts, transactions, and settings will be removed. This cannot be undone.
|
|
</v-card-text>
|
|
<v-card-actions>
|
|
<v-spacer />
|
|
<v-btn @click="showDeleteAccountDialog = false">Cancel</v-btn>
|
|
<v-btn color="error" :loading="accountDeleting" @click="deleteAccount">Delete</v-btn>
|
|
</v-card-actions>
|
|
</v-card>
|
|
</v-dialog>
|
|
|
|
<!-- Profile Snackbars -->
|
|
<v-snackbar v-model="showProfileSuccess" :timeout="4000" color="success">
|
|
{{ profileSuccessMessage }}
|
|
</v-snackbar>
|
|
<v-snackbar v-model="showProfileError" :timeout="6000" color="error">
|
|
{{ profileErrorMessage }}
|
|
</v-snackbar>
|
|
</v-tabs-window-item>
|
|
|
|
<!-- Tab 2: Bank Connections -->
|
|
<v-tabs-window-item value="connections">
|
|
|
|
<!-- SimpleFIN Section -->
|
|
<v-card class="mb-6">
|
|
<v-card-title class="d-flex align-center flex-wrap ga-2">
|
|
<v-icon class="mr-1">mdi-link-variant</v-icon>
|
|
SimpleFIN
|
|
<v-chip color="green" size="small">{{ simpleFinConnections.length }}</v-chip>
|
|
<v-spacer />
|
|
<v-btn
|
|
size="small"
|
|
variant="tonal"
|
|
color="secondary"
|
|
prepend-icon="mdi-link-plus"
|
|
@click="showSimpleFinDialog = true"
|
|
>
|
|
Connect
|
|
</v-btn>
|
|
<v-btn
|
|
size="small"
|
|
variant="tonal"
|
|
color="primary"
|
|
prepend-icon="mdi-sync"
|
|
:loading="syncingAllSimpleFin"
|
|
:disabled="simpleFinConnections.filter(c => c.isActive).length === 0"
|
|
@click="syncAllSimpleFin"
|
|
>
|
|
Sync All
|
|
</v-btn>
|
|
<v-btn
|
|
size="small"
|
|
variant="tonal"
|
|
color="secondary"
|
|
prepend-icon="mdi-refresh"
|
|
:loading="resyncingAllSimpleFin"
|
|
:disabled="simpleFinConnections.filter(c => c.isActive).length === 0"
|
|
@click="resyncAllSimpleFin"
|
|
>
|
|
Re-sync All
|
|
</v-btn>
|
|
</v-card-title>
|
|
|
|
<v-card-text>
|
|
<div class="d-flex align-center mb-4">
|
|
<v-select
|
|
v-model="simpleFinGlobalInterval"
|
|
:items="intervalOptions"
|
|
item-title="text"
|
|
item-value="value"
|
|
label="Sync Interval (all SimpleFIN)"
|
|
density="compact"
|
|
variant="outlined"
|
|
hide-details
|
|
style="max-width: 260px"
|
|
@update:model-value="updateSimpleFinGlobalInterval"
|
|
/>
|
|
</div>
|
|
|
|
<v-alert v-if="simpleFinConnections.length === 0 && !loading" type="info" variant="tonal" class="mb-4">
|
|
No SimpleFIN connections yet. Click Connect to add one.
|
|
</v-alert>
|
|
|
|
<v-card v-for="conn in simpleFinConnections" :key="conn.id" variant="outlined" class="mb-3">
|
|
<v-card-title class="d-flex align-center text-subtitle-1 py-2">
|
|
{{ conn.institutionName }}
|
|
<v-spacer />
|
|
<v-chip
|
|
:color="conn.isActive ? 'success' : 'grey'"
|
|
size="x-small"
|
|
class="mr-2"
|
|
>
|
|
{{ conn.isActive ? 'Active' : 'Inactive' }}
|
|
</v-chip>
|
|
<v-btn
|
|
variant="text"
|
|
:color="conn.isActive ? 'warning' : 'success'"
|
|
size="small"
|
|
@click="toggleConnection(conn)"
|
|
>
|
|
{{ conn.isActive ? 'Disable' : 'Enable' }}
|
|
</v-btn>
|
|
<v-btn
|
|
variant="text"
|
|
color="error"
|
|
size="small"
|
|
icon="mdi-delete"
|
|
@click="confirmDeleteConnection(conn)"
|
|
/>
|
|
</v-card-title>
|
|
|
|
<v-card-text class="pt-0">
|
|
<div class="text-body-2 text-medium-emphasis mb-2">
|
|
Last synced: {{ conn.lastSyncAt ? new Date(conn.lastSyncAt).toLocaleString() : 'Never' }}
|
|
<v-chip v-if="syncingConnections[conn.id]" size="x-small" color="info" class="ml-2">
|
|
Syncing...
|
|
</v-chip>
|
|
<span v-if="conn.lastSyncError" class="text-error ml-2">{{ conn.lastSyncError }}</span>
|
|
</div>
|
|
|
|
<v-table density="compact" v-if="conn.linkedAccounts.length > 0">
|
|
<thead>
|
|
<tr>
|
|
<th>External Account</th>
|
|
<th>Type</th>
|
|
<th>Balance</th>
|
|
<th>Purrse Account</th>
|
|
<th>Enabled</th>
|
|
<th>Last Synced</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="la in conn.linkedAccounts" :key="la.id">
|
|
<td>{{ la.externalAccountName }}</td>
|
|
<td>{{ la.externalAccountType }}</td>
|
|
<td>{{ la.lastKnownBalance != null ? formatCurrency(la.lastKnownBalance) : '-' }}</td>
|
|
<td>
|
|
<v-select
|
|
:model-value="la.accountId"
|
|
:items="accountOptions"
|
|
item-title="text"
|
|
item-value="value"
|
|
density="compact"
|
|
variant="outlined"
|
|
hide-details
|
|
clearable
|
|
placeholder="Select account..."
|
|
@update:model-value="(val: string | null) => updateLinkedAccount(la, val)"
|
|
/>
|
|
</td>
|
|
<td>
|
|
<v-switch
|
|
:model-value="la.isEnabled"
|
|
density="compact"
|
|
hide-details
|
|
color="primary"
|
|
@update:model-value="(val: boolean | null) => toggleLinkedAccount(la, val ?? false)"
|
|
/>
|
|
</td>
|
|
<td class="text-body-2">{{ la.lastSyncAt ? new Date(la.lastSyncAt).toLocaleString() : 'Never' }}</td>
|
|
</tr>
|
|
</tbody>
|
|
</v-table>
|
|
</v-card-text>
|
|
</v-card>
|
|
</v-card-text>
|
|
</v-card>
|
|
|
|
<!-- Plaid Section -->
|
|
<v-card class="mb-6">
|
|
<v-card-title class="d-flex align-center flex-wrap ga-2">
|
|
<v-icon class="mr-1">mdi-bank</v-icon>
|
|
Plaid
|
|
<v-chip color="blue" size="small">{{ plaidConnections.length }}</v-chip>
|
|
<v-spacer />
|
|
<v-btn
|
|
size="small"
|
|
variant="tonal"
|
|
color="primary"
|
|
prepend-icon="mdi-bank-plus"
|
|
:loading="plaidLoading"
|
|
:disabled="!plaidCredentials.isConfigured"
|
|
@click="startPlaidLink"
|
|
>
|
|
Connect
|
|
</v-btn>
|
|
<v-btn
|
|
size="small"
|
|
variant="tonal"
|
|
color="primary"
|
|
prepend-icon="mdi-sync"
|
|
:loading="syncingAllPlaid"
|
|
:disabled="plaidConnections.filter(c => c.isActive).length === 0"
|
|
@click="syncAllPlaid"
|
|
>
|
|
Sync All
|
|
</v-btn>
|
|
</v-card-title>
|
|
|
|
<v-card-text>
|
|
<!-- Plaid API Credentials -->
|
|
<div class="mb-4">
|
|
<div class="d-flex align-center mb-2">
|
|
<v-icon size="small" class="mr-1">mdi-key</v-icon>
|
|
<span class="text-subtitle-2">API Credentials</span>
|
|
<v-chip v-if="plaidCredentials.isConfigured" color="success" size="x-small" class="ml-2">Configured</v-chip>
|
|
<v-chip v-else color="warning" size="x-small" class="ml-2">Not configured</v-chip>
|
|
</div>
|
|
<p class="text-body-2 text-medium-emphasis mb-3">
|
|
Enter your Plaid developer credentials. Get them from your Plaid dashboard.
|
|
</p>
|
|
<v-row dense>
|
|
<v-col cols="12" sm="4">
|
|
<v-text-field
|
|
v-model="plaidForm.clientId"
|
|
label="Client ID"
|
|
variant="outlined"
|
|
density="compact"
|
|
:placeholder="plaidCredentials.isConfigured ? '(saved)' : ''"
|
|
/>
|
|
</v-col>
|
|
<v-col cols="12" sm="4">
|
|
<v-text-field
|
|
v-model="plaidForm.secret"
|
|
label="Secret"
|
|
variant="outlined"
|
|
density="compact"
|
|
type="password"
|
|
:placeholder="plaidCredentials.isConfigured ? '(saved)' : ''"
|
|
/>
|
|
</v-col>
|
|
<v-col cols="12" sm="2">
|
|
<v-select
|
|
v-model="plaidForm.environment"
|
|
:items="plaidEnvironments"
|
|
label="Environment"
|
|
variant="outlined"
|
|
density="compact"
|
|
/>
|
|
</v-col>
|
|
<v-col cols="12" sm="2" class="d-flex align-center">
|
|
<v-btn
|
|
color="primary"
|
|
:loading="plaidCredsSaving"
|
|
:disabled="!plaidForm.clientId || !plaidForm.secret"
|
|
@click="savePlaidCredentials"
|
|
>
|
|
Save
|
|
</v-btn>
|
|
</v-col>
|
|
</v-row>
|
|
</div>
|
|
|
|
<v-divider class="mb-4" />
|
|
|
|
<v-alert v-if="plaidConnections.length === 0 && !loading" type="info" variant="tonal" class="mb-4">
|
|
No Plaid connections yet. Configure your API credentials above, then click Connect.
|
|
</v-alert>
|
|
|
|
<v-card v-for="conn in plaidConnections" :key="conn.id" variant="outlined" class="mb-3">
|
|
<v-card-title class="d-flex align-center text-subtitle-1 py-2">
|
|
{{ conn.institutionName }}
|
|
<v-spacer />
|
|
<v-chip
|
|
:color="conn.isActive ? 'success' : 'grey'"
|
|
size="x-small"
|
|
class="mr-2"
|
|
>
|
|
{{ conn.isActive ? 'Active' : 'Inactive' }}
|
|
</v-chip>
|
|
<v-btn
|
|
variant="text"
|
|
:color="conn.isActive ? 'warning' : 'success'"
|
|
size="small"
|
|
@click="toggleConnection(conn)"
|
|
>
|
|
{{ conn.isActive ? 'Disable' : 'Enable' }}
|
|
</v-btn>
|
|
<v-btn
|
|
variant="text"
|
|
color="error"
|
|
size="small"
|
|
icon="mdi-delete"
|
|
@click="confirmDeleteConnection(conn)"
|
|
/>
|
|
</v-card-title>
|
|
|
|
<v-card-text class="pt-0">
|
|
<div class="text-body-2 text-medium-emphasis mb-2">
|
|
Last synced: {{ conn.lastSyncAt ? new Date(conn.lastSyncAt).toLocaleString() : 'Never' }}
|
|
<v-chip v-if="syncingConnections[conn.id]" size="x-small" color="info" class="ml-2">
|
|
Syncing...
|
|
</v-chip>
|
|
<span v-if="conn.lastSyncError" class="text-error ml-2">{{ conn.lastSyncError }}</span>
|
|
</div>
|
|
|
|
<v-table density="compact" v-if="conn.linkedAccounts.length > 0">
|
|
<thead>
|
|
<tr>
|
|
<th>External Account</th>
|
|
<th>Type</th>
|
|
<th>Balance</th>
|
|
<th>Purrse Account</th>
|
|
<th>Enabled</th>
|
|
<th>Last Synced</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="la in conn.linkedAccounts" :key="la.id">
|
|
<td>{{ la.externalAccountName }}</td>
|
|
<td>{{ la.externalAccountType }}</td>
|
|
<td>{{ la.lastKnownBalance != null ? formatCurrency(la.lastKnownBalance) : '-' }}</td>
|
|
<td>
|
|
<v-select
|
|
:model-value="la.accountId"
|
|
:items="accountOptions"
|
|
item-title="text"
|
|
item-value="value"
|
|
density="compact"
|
|
variant="outlined"
|
|
hide-details
|
|
clearable
|
|
placeholder="Select account..."
|
|
@update:model-value="(val: string | null) => updateLinkedAccount(la, val)"
|
|
/>
|
|
</td>
|
|
<td>
|
|
<v-switch
|
|
:model-value="la.isEnabled"
|
|
density="compact"
|
|
hide-details
|
|
color="primary"
|
|
@update:model-value="(val: boolean | null) => toggleLinkedAccount(la, val ?? false)"
|
|
/>
|
|
</td>
|
|
<td class="text-body-2">{{ la.lastSyncAt ? new Date(la.lastSyncAt).toLocaleString() : 'Never' }}</td>
|
|
</tr>
|
|
</tbody>
|
|
</v-table>
|
|
|
|
<div class="d-flex align-center mt-3 ga-2">
|
|
<v-select
|
|
v-model="syncIntervals[conn.id]"
|
|
:items="intervalOptions"
|
|
item-title="text"
|
|
item-value="value"
|
|
label="Sync Interval"
|
|
density="compact"
|
|
variant="outlined"
|
|
hide-details
|
|
style="max-width: 200px"
|
|
@update:model-value="() => updateConnectionInterval(conn)"
|
|
/>
|
|
</div>
|
|
</v-card-text>
|
|
</v-card>
|
|
</v-card-text>
|
|
</v-card>
|
|
|
|
<!-- Account Mapping Dialog -->
|
|
<v-dialog v-model="showMappingDialog" max-width="700" persistent>
|
|
<v-card>
|
|
<v-card-title>Map Bank Accounts</v-card-title>
|
|
<v-card-text>
|
|
<p class="mb-4">Map your external bank accounts to Purrse accounts, or auto-create new ones.</p>
|
|
<v-table density="compact">
|
|
<thead>
|
|
<tr>
|
|
<th>External Account</th>
|
|
<th>Type</th>
|
|
<th>Balance</th>
|
|
<th>Purrse Account</th>
|
|
<th>Auto-create</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="la in newLinkedAccounts" :key="la.id">
|
|
<td>{{ la.externalAccountName }}</td>
|
|
<td>{{ la.externalAccountType }}</td>
|
|
<td>{{ la.lastKnownBalance != null ? formatCurrency(la.lastKnownBalance) : '-' }}</td>
|
|
<td>
|
|
<v-select
|
|
v-model="mappingSelections[la.id]"
|
|
:items="accountOptions"
|
|
item-title="text"
|
|
item-value="value"
|
|
density="compact"
|
|
variant="outlined"
|
|
hide-details
|
|
clearable
|
|
:disabled="autoCreateSelections[la.id]"
|
|
placeholder="Select..."
|
|
/>
|
|
</td>
|
|
<td>
|
|
<v-checkbox
|
|
v-model="autoCreateSelections[la.id]"
|
|
density="compact"
|
|
hide-details
|
|
/>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</v-table>
|
|
</v-card-text>
|
|
<v-card-actions>
|
|
<v-spacer />
|
|
<v-btn @click="showMappingDialog = false">Skip</v-btn>
|
|
<v-btn color="primary" @click="saveMappings" :loading="mappingSaving">Save Mappings</v-btn>
|
|
</v-card-actions>
|
|
</v-card>
|
|
</v-dialog>
|
|
|
|
<!-- SimpleFIN Dialog -->
|
|
<v-dialog v-model="showSimpleFinDialog" max-width="500">
|
|
<v-card>
|
|
<v-card-title>Connect via SimpleFIN</v-card-title>
|
|
<v-card-text>
|
|
<p class="mb-4">
|
|
Visit <strong>beta-bridge.simplefin.org</strong> to get a setup token for your bank, then paste it below.
|
|
</p>
|
|
<v-text-field
|
|
v-model="simpleFinToken"
|
|
label="Setup Token"
|
|
variant="outlined"
|
|
placeholder="Paste your SimpleFIN setup token..."
|
|
/>
|
|
</v-card-text>
|
|
<v-card-actions>
|
|
<v-spacer />
|
|
<v-btn @click="showSimpleFinDialog = false">Cancel</v-btn>
|
|
<v-btn color="primary" @click="connectSimpleFin" :loading="simpleFinLoading" :disabled="!simpleFinToken">
|
|
Connect
|
|
</v-btn>
|
|
</v-card-actions>
|
|
</v-card>
|
|
</v-dialog>
|
|
|
|
<!-- Delete Confirmation -->
|
|
<v-dialog v-model="showDeleteDialog" max-width="400">
|
|
<v-card>
|
|
<v-card-title>Delete Connection</v-card-title>
|
|
<v-card-text>
|
|
Are you sure you want to delete the connection to <strong>{{ deleteTarget?.institutionName }}</strong>?
|
|
This will remove all linked account mappings and sync history.
|
|
</v-card-text>
|
|
<v-card-actions>
|
|
<v-spacer />
|
|
<v-btn @click="showDeleteDialog = false">Cancel</v-btn>
|
|
<v-btn color="error" @click="deleteConnection" :loading="deleteLoading">Delete</v-btn>
|
|
</v-card-actions>
|
|
</v-card>
|
|
</v-dialog>
|
|
|
|
<!-- Snackbars -->
|
|
<v-snackbar v-model="showSyncResult" :timeout="5000" color="success">
|
|
{{ syncResultMessage }}
|
|
</v-snackbar>
|
|
<v-snackbar v-model="showError" :timeout="6000" color="error">
|
|
{{ errorMessage }}
|
|
</v-snackbar>
|
|
</v-tabs-window-item>
|
|
|
|
<!-- Tab 3: Sync History -->
|
|
<v-tabs-window-item value="history">
|
|
<v-card>
|
|
<v-card-title class="d-flex align-center">
|
|
Sync History
|
|
<v-spacer />
|
|
<v-select
|
|
v-model="logFilterConnectionId"
|
|
:items="connectionFilterOptions"
|
|
item-title="text"
|
|
item-value="value"
|
|
label="Filter by connection"
|
|
density="compact"
|
|
variant="outlined"
|
|
hide-details
|
|
clearable
|
|
style="max-width: 300px"
|
|
@update:model-value="loadSyncLogs"
|
|
/>
|
|
</v-card-title>
|
|
<v-data-table
|
|
:headers="logHeaders"
|
|
:items="syncLogs"
|
|
:loading="logsLoading"
|
|
density="compact"
|
|
items-per-page="25"
|
|
>
|
|
<template v-slot:item.startedAt="{ item }">
|
|
{{ new Date(item.startedAt).toLocaleString() }}
|
|
</template>
|
|
<template v-slot:item.status="{ item }">
|
|
<v-chip
|
|
:color="statusColor(item.status)"
|
|
size="small"
|
|
>
|
|
{{ item.status }}
|
|
</v-chip>
|
|
</template>
|
|
<template v-slot:item.duration="{ item }">
|
|
{{ item.duration || '-' }}
|
|
</template>
|
|
<template v-slot:item.errorMessage="{ item }">
|
|
<span v-if="item.errorMessage" class="text-error text-body-2">{{ item.errorMessage }}</span>
|
|
<span v-else>-</span>
|
|
</template>
|
|
</v-data-table>
|
|
</v-card>
|
|
</v-tabs-window-item>
|
|
|
|
<!-- Tab 4: AI -->
|
|
<v-tabs-window-item value="ai">
|
|
<v-alert type="info" variant="tonal" class="mb-4">
|
|
AI features use a local Ollama LLM. Ollama runs locally on your machine — no data is sent to external services.
|
|
</v-alert>
|
|
|
|
<!-- Card 1: AI Configuration -->
|
|
<v-card class="mb-4">
|
|
<v-card-title>AI Configuration</v-card-title>
|
|
<v-card-text>
|
|
<v-row dense>
|
|
<v-col cols="12" sm="6">
|
|
<v-text-field
|
|
v-model="aiSettings.ollamaUrl"
|
|
label="Ollama URL"
|
|
variant="outlined"
|
|
density="compact"
|
|
placeholder="http://localhost:11434"
|
|
/>
|
|
</v-col>
|
|
<v-col cols="12" sm="6">
|
|
<v-combobox
|
|
v-model="aiSettings.modelName"
|
|
:items="availableModels"
|
|
label="Model"
|
|
variant="outlined"
|
|
density="compact"
|
|
placeholder="llama3.1:8b"
|
|
/>
|
|
</v-col>
|
|
</v-row>
|
|
|
|
<v-btn
|
|
variant="tonal"
|
|
color="secondary"
|
|
:loading="aiTesting"
|
|
@click="testAiConnection"
|
|
>
|
|
Test Connection
|
|
</v-btn>
|
|
|
|
<v-alert
|
|
v-if="aiTestResult"
|
|
:type="aiTestResult.success ? 'success' : 'error'"
|
|
variant="tonal"
|
|
class="mt-4"
|
|
closable
|
|
@click:close="aiTestResult = null"
|
|
>
|
|
<template v-if="aiTestResult.success">
|
|
Connected to Ollama. Available models: {{ aiTestResult.availableModels?.join(', ') || 'none' }}
|
|
</template>
|
|
<template v-else>
|
|
Connection failed: {{ aiTestResult.errorMessage }}
|
|
</template>
|
|
</v-alert>
|
|
</v-card-text>
|
|
</v-card>
|
|
|
|
<!-- Card 2: AI Categorization -->
|
|
<v-card class="mb-4">
|
|
<v-card-title>AI Categorization</v-card-title>
|
|
<v-card-text>
|
|
<v-switch
|
|
v-model="aiSettings.isEnabled"
|
|
label="Enable AI Categorization"
|
|
color="primary"
|
|
hide-details
|
|
class="mb-4"
|
|
:disabled="!isOllamaConfigured"
|
|
/>
|
|
|
|
<v-alert v-if="!isOllamaConfigured" type="warning" variant="tonal" class="mb-4">
|
|
Configure Ollama connection above to enable AI features
|
|
</v-alert>
|
|
|
|
<v-row dense>
|
|
<v-col cols="12" sm="6">
|
|
<div class="text-body-2 mb-1">Confidence Threshold: {{ aiSettings.confidenceThreshold.toFixed(2) }}</div>
|
|
<v-slider
|
|
v-model="aiSettings.confidenceThreshold"
|
|
:min="0.1"
|
|
:max="1.0"
|
|
:step="0.05"
|
|
thumb-label
|
|
color="primary"
|
|
hide-details
|
|
/>
|
|
</v-col>
|
|
<v-col cols="12" sm="6">
|
|
<v-text-field
|
|
v-model.number="aiSettings.timeoutSeconds"
|
|
label="Timeout (seconds)"
|
|
variant="outlined"
|
|
density="compact"
|
|
type="number"
|
|
:min="5"
|
|
:max="120"
|
|
/>
|
|
</v-col>
|
|
</v-row>
|
|
|
|
<v-checkbox
|
|
v-model="aiSettings.updatePayeeDefaults"
|
|
label="Auto-update payee default categories (learning loop)"
|
|
color="primary"
|
|
hide-details
|
|
class="mb-4"
|
|
/>
|
|
|
|
<v-divider class="mb-4" />
|
|
|
|
<div class="text-subtitle-2 mb-2">Prompt Templates</div>
|
|
<p class="text-body-2 text-medium-emphasis mb-4">
|
|
Customize the prompts sent to the AI model. Use placeholders to control where dynamic data is inserted:
|
|
<strong>{categories}</strong> = category list,
|
|
<strong>{examples}</strong> = recent categorized transactions,
|
|
<strong>{transactions}</strong> = uncategorized transactions to classify.
|
|
</p>
|
|
|
|
<v-textarea
|
|
v-model="aiSettings.systemPrompt"
|
|
label="System Prompt"
|
|
variant="outlined"
|
|
rows="8"
|
|
class="mb-4"
|
|
/>
|
|
|
|
<v-textarea
|
|
v-model="aiSettings.userPromptTemplate"
|
|
label="User Prompt Template"
|
|
variant="outlined"
|
|
rows="5"
|
|
class="mb-4"
|
|
/>
|
|
|
|
<v-btn
|
|
variant="tonal"
|
|
color="secondary"
|
|
@click="resetPromptsToDefaults"
|
|
>
|
|
Reset to Defaults
|
|
</v-btn>
|
|
</v-card-text>
|
|
</v-card>
|
|
|
|
<!-- Card 3: AI Chat -->
|
|
<v-card class="mb-4">
|
|
<v-card-title>AI Chat</v-card-title>
|
|
<v-card-text>
|
|
<v-switch
|
|
v-model="aiSettings.isChatEnabled"
|
|
label="Enable AI Chat"
|
|
color="primary"
|
|
hide-details
|
|
class="mb-4"
|
|
:disabled="!isOllamaConfigured"
|
|
/>
|
|
|
|
<v-alert v-if="!isOllamaConfigured" type="warning" variant="tonal" class="mb-4">
|
|
Configure Ollama connection above to enable AI features
|
|
</v-alert>
|
|
|
|
<div class="text-body-2 mb-1">Context Size: {{ aiSettings.chatContextSize.toLocaleString() }} tokens</div>
|
|
<v-slider
|
|
v-model="aiSettings.chatContextSize"
|
|
:min="2048"
|
|
:max="131072"
|
|
:step="2048"
|
|
thumb-label
|
|
color="primary"
|
|
hide-details
|
|
class="mb-1"
|
|
/>
|
|
<div class="text-caption text-medium-emphasis mb-4">
|
|
Estimated VRAM: ~{{ Math.round((aiSettings.chatContextSize / 1024) * 75) }} MB — Increase if the AI loses context in long chats. Decrease if Ollama runs out of memory.
|
|
</div>
|
|
|
|
<v-text-field
|
|
v-model="aiSettings.chatBotName"
|
|
label="Chatbot Name"
|
|
variant="outlined"
|
|
density="compact"
|
|
placeholder="Purrse AI"
|
|
hint="Custom name shown in the chat panel header and used by the AI"
|
|
persistent-hint
|
|
clearable
|
|
/>
|
|
</v-card-text>
|
|
</v-card>
|
|
|
|
<v-btn
|
|
color="primary"
|
|
:loading="aiSaving"
|
|
@click="saveAiSettings"
|
|
>
|
|
Save AI Settings
|
|
</v-btn>
|
|
|
|
<v-snackbar v-model="showAiSuccess" :timeout="4000" color="success">
|
|
AI settings saved successfully
|
|
</v-snackbar>
|
|
<v-snackbar v-model="showAiError" :timeout="6000" color="error">
|
|
{{ aiErrorMessage }}
|
|
</v-snackbar>
|
|
</v-tabs-window-item>
|
|
</v-tabs-window>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
|
|
import { useRouter } from 'vue-router'
|
|
import { useAuthStore } from '@/stores/auth'
|
|
import { useNotifications } from '@/composables/useNotifications'
|
|
import { bankSyncApi } from '@/services/bankSync'
|
|
import { accountsApi } from '@/services/accounts'
|
|
import { aiCategorizationApi } from '@/services/aiCategorization'
|
|
import api from '@/services/api'
|
|
import type { SyncConnection, LinkedAccountInfo, Account, SyncLogEntry, PlaidCredentials, AiCategorizationSettings, OllamaConnectionTestResult, BankSyncNotification, UserProfile } from '@/types'
|
|
|
|
const authStore = useAuthStore()
|
|
const router = useRouter()
|
|
|
|
const activeTab = ref('profile')
|
|
|
|
// Profile
|
|
const profile = ref<UserProfile | null>(null)
|
|
const profileForm = reactive({ displayName: '', email: '' })
|
|
const passwordForm = reactive({ currentPassword: '', newPassword: '', confirmPassword: '' })
|
|
const profileSaving = ref(false)
|
|
const passwordChanging = ref(false)
|
|
const accountDeleting = ref(false)
|
|
const showDeleteAccountDialog = ref(false)
|
|
const showProfileSuccess = ref(false)
|
|
const profileSuccessMessage = ref('')
|
|
const showProfileError = ref(false)
|
|
const profileErrorMessage = ref('')
|
|
|
|
async function loadProfile() {
|
|
try {
|
|
const { data } = await api.get<UserProfile>('/auth/profile')
|
|
profile.value = data
|
|
profileForm.displayName = data.displayName || ''
|
|
profileForm.email = data.email
|
|
} catch (err: any) {
|
|
profileErrorMessage.value = err.response?.data?.error || 'Failed to load profile'
|
|
showProfileError.value = true
|
|
}
|
|
}
|
|
|
|
async function saveProfile() {
|
|
profileSaving.value = true
|
|
try {
|
|
const { data } = await api.put<UserProfile>('/auth/profile', {
|
|
displayName: profileForm.displayName || null,
|
|
email: profileForm.email
|
|
})
|
|
profile.value = data
|
|
profileForm.displayName = data.displayName || ''
|
|
profileForm.email = data.email
|
|
authStore.setDisplayName(data.displayName)
|
|
profileSuccessMessage.value = 'Profile updated'
|
|
showProfileSuccess.value = true
|
|
} catch (err: any) {
|
|
profileErrorMessage.value = err.response?.data?.error || 'Failed to update profile'
|
|
showProfileError.value = true
|
|
} finally {
|
|
profileSaving.value = false
|
|
}
|
|
}
|
|
|
|
async function changePassword() {
|
|
passwordChanging.value = true
|
|
try {
|
|
await api.post('/auth/change-password', {
|
|
currentPassword: passwordForm.currentPassword,
|
|
newPassword: passwordForm.newPassword
|
|
})
|
|
passwordForm.currentPassword = ''
|
|
passwordForm.newPassword = ''
|
|
passwordForm.confirmPassword = ''
|
|
profileSuccessMessage.value = 'Password changed successfully'
|
|
showProfileSuccess.value = true
|
|
} catch (err: any) {
|
|
profileErrorMessage.value = err.response?.data?.error || 'Failed to change password'
|
|
showProfileError.value = true
|
|
} finally {
|
|
passwordChanging.value = false
|
|
}
|
|
}
|
|
|
|
async function deleteAccount() {
|
|
accountDeleting.value = true
|
|
try {
|
|
await api.delete('/auth/account')
|
|
showDeleteAccountDialog.value = false
|
|
authStore.logout()
|
|
router.push('/login')
|
|
} catch (err: any) {
|
|
profileErrorMessage.value = err.response?.data?.error || 'Failed to delete account'
|
|
showProfileError.value = true
|
|
} finally {
|
|
accountDeleting.value = false
|
|
}
|
|
}
|
|
|
|
const loading = ref(false)
|
|
const connections = ref<SyncConnection[]>([])
|
|
const accounts = ref<Account[]>([])
|
|
const syncIntervals = reactive<Record<string, number>>({})
|
|
const syncingConnections = reactive<Record<string, boolean>>({})
|
|
const resyncingConnections = reactive<Record<string, boolean>>({})
|
|
|
|
// Provider-separated computed lists
|
|
const simpleFinConnections = computed(() =>
|
|
connections.value.filter(c => c.provider === 'SimpleFIN')
|
|
)
|
|
const plaidConnections = computed(() =>
|
|
connections.value.filter(c => c.provider === 'Plaid')
|
|
)
|
|
|
|
// Section-level loading states
|
|
const syncingAllSimpleFin = ref(false)
|
|
const resyncingAllSimpleFin = ref(false)
|
|
const syncingAllPlaid = ref(false)
|
|
const simpleFinGlobalInterval = ref(360)
|
|
|
|
// Plaid credentials
|
|
const plaidCredentials = ref<PlaidCredentials>({ isConfigured: false, environment: 'sandbox' })
|
|
const plaidForm = reactive({ clientId: '', secret: '', environment: 'sandbox' })
|
|
const plaidCredsSaving = ref(false)
|
|
const plaidEnvironments = ['sandbox', 'development', 'production']
|
|
|
|
// Plaid link
|
|
const plaidLoading = ref(false)
|
|
|
|
// SimpleFIN
|
|
const showSimpleFinDialog = ref(false)
|
|
const simpleFinToken = ref('')
|
|
const simpleFinLoading = ref(false)
|
|
|
|
// Mapping dialog
|
|
const showMappingDialog = ref(false)
|
|
const mappingSaving = ref(false)
|
|
const newConnectionId = ref('')
|
|
const newLinkedAccounts = ref<LinkedAccountInfo[]>([])
|
|
const mappingSelections = reactive<Record<string, string | null>>({})
|
|
const autoCreateSelections = reactive<Record<string, boolean>>({})
|
|
|
|
// Delete
|
|
const showDeleteDialog = ref(false)
|
|
const deleteTarget = ref<SyncConnection | null>(null)
|
|
const deleteLoading = ref(false)
|
|
|
|
// Sync result
|
|
const showSyncResult = ref(false)
|
|
const syncResultMessage = ref('')
|
|
|
|
// Error display
|
|
const showError = ref(false)
|
|
const errorMessage = ref('')
|
|
|
|
// Sync logs
|
|
const syncLogs = ref<SyncLogEntry[]>([])
|
|
const logsLoading = ref(false)
|
|
const logFilterConnectionId = ref<string | null>(null)
|
|
|
|
const accountOptions = computed(() =>
|
|
accounts.value
|
|
.filter(a => a.isActive && !a.isClosed)
|
|
.map(a => ({ text: `${a.name} (${a.type})`, value: a.id }))
|
|
)
|
|
|
|
const connectionFilterOptions = computed(() =>
|
|
connections.value.map(c => ({ text: c.institutionName, value: c.id }))
|
|
)
|
|
|
|
const intervalOptions = [
|
|
{ text: '1 hour', value: 60 },
|
|
{ text: '3 hours', value: 180 },
|
|
{ text: '6 hours', value: 360 },
|
|
{ text: '12 hours', value: 720 },
|
|
{ text: '24 hours', value: 1440 },
|
|
]
|
|
|
|
const logHeaders = [
|
|
{ title: 'Time', key: 'startedAt', sortable: true },
|
|
{ title: 'Institution', key: 'institutionName', sortable: true },
|
|
{ title: 'Account', key: 'linkedAccountName', sortable: false },
|
|
{ title: 'Status', key: 'status', sortable: true },
|
|
{ title: 'Imported', key: 'transactionsImported', sortable: false },
|
|
{ title: 'Skipped', key: 'transactionsSkipped', sortable: false },
|
|
{ title: 'Duration', key: 'duration', sortable: false },
|
|
{ title: 'Error', key: 'errorMessage', sortable: false },
|
|
]
|
|
|
|
function statusColor(status: string) {
|
|
switch (status) {
|
|
case 'Success': return 'success'
|
|
case 'PartialSuccess': return 'warning'
|
|
case 'Failed': return 'error'
|
|
case 'Running': return 'info'
|
|
default: return 'grey'
|
|
}
|
|
}
|
|
|
|
function formatCurrency(value: number) {
|
|
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(value)
|
|
}
|
|
|
|
async function loadPlaidCredentials() {
|
|
try {
|
|
const { data } = await bankSyncApi.getPlaidCredentials()
|
|
plaidCredentials.value = data
|
|
plaidForm.environment = data.environment
|
|
} catch {
|
|
// Not configured yet
|
|
}
|
|
}
|
|
|
|
async function savePlaidCredentials() {
|
|
plaidCredsSaving.value = true
|
|
try {
|
|
const { data } = await bankSyncApi.savePlaidCredentials({
|
|
clientId: plaidForm.clientId,
|
|
secret: plaidForm.secret,
|
|
environment: plaidForm.environment
|
|
})
|
|
plaidCredentials.value = data
|
|
plaidForm.clientId = ''
|
|
plaidForm.secret = ''
|
|
syncResultMessage.value = 'Plaid credentials saved'
|
|
showSyncResult.value = true
|
|
} catch (err: any) {
|
|
errorMessage.value = err.response?.data?.error || 'Failed to save Plaid credentials'
|
|
showError.value = true
|
|
} finally {
|
|
plaidCredsSaving.value = false
|
|
}
|
|
}
|
|
|
|
async function loadConnections() {
|
|
loading.value = true
|
|
try {
|
|
const { data } = await bankSyncApi.getConnections()
|
|
connections.value = data
|
|
data.forEach(c => { syncIntervals[c.id] = c.syncIntervalMinutes })
|
|
// Initialize global SimpleFIN interval from first SimpleFIN connection
|
|
const firstSf = data.find(c => c.provider === 'SimpleFIN')
|
|
if (firstSf) {
|
|
simpleFinGlobalInterval.value = firstSf.syncIntervalMinutes
|
|
}
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
async function loadAccounts() {
|
|
const { data } = await accountsApi.getAll()
|
|
accounts.value = data
|
|
}
|
|
|
|
async function loadSyncLogs() {
|
|
logsLoading.value = true
|
|
try {
|
|
const { data } = await bankSyncApi.getSyncLogs(logFilterConnectionId.value || undefined)
|
|
syncLogs.value = data
|
|
} finally {
|
|
logsLoading.value = false
|
|
}
|
|
}
|
|
|
|
async function startPlaidLink() {
|
|
plaidLoading.value = true
|
|
try {
|
|
const { data: linkData } = await bankSyncApi.createLinkToken()
|
|
|
|
if (!linkData.linkToken) {
|
|
errorMessage.value = 'Plaid returned an empty link token. Check your Plaid credentials in appsettings.json.'
|
|
showError.value = true
|
|
plaidLoading.value = false
|
|
return
|
|
}
|
|
|
|
// Dynamically load Plaid Link script
|
|
await loadPlaidScript()
|
|
|
|
const plaid = (window as any).Plaid.create({
|
|
token: linkData.linkToken,
|
|
onSuccess: async (publicToken: string) => {
|
|
try {
|
|
const { data: conn } = await bankSyncApi.completePlaidLink(publicToken)
|
|
connections.value.unshift(conn)
|
|
syncIntervals[conn.id] = conn.syncIntervalMinutes
|
|
openMappingDialog(conn)
|
|
} catch (err: any) {
|
|
errorMessage.value = err.response?.data?.error || 'Failed to complete Plaid link'
|
|
showError.value = true
|
|
}
|
|
},
|
|
onExit: () => {
|
|
plaidLoading.value = false
|
|
}
|
|
})
|
|
plaid.open()
|
|
} catch (err: any) {
|
|
errorMessage.value = err.response?.data?.error || 'Failed to create Plaid link token. Check your Plaid credentials.'
|
|
showError.value = true
|
|
plaidLoading.value = false
|
|
}
|
|
}
|
|
|
|
function loadPlaidScript(): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
if ((window as any).Plaid) {
|
|
resolve()
|
|
return
|
|
}
|
|
const script = document.createElement('script')
|
|
script.src = 'https://cdn.plaid.com/link/v2/stable/link-initialize.js'
|
|
script.onload = () => resolve()
|
|
script.onerror = () => reject(new Error('Failed to load Plaid Link script'))
|
|
document.head.appendChild(script)
|
|
})
|
|
}
|
|
|
|
async function connectSimpleFin() {
|
|
simpleFinLoading.value = true
|
|
try {
|
|
const { data: newConnections } = await bankSyncApi.connectSimpleFin(simpleFinToken.value)
|
|
for (const conn of newConnections) {
|
|
connections.value.unshift(conn)
|
|
syncIntervals[conn.id] = conn.syncIntervalMinutes
|
|
}
|
|
showSimpleFinDialog.value = false
|
|
simpleFinToken.value = ''
|
|
if (newConnections.length > 0) {
|
|
openMappingDialog(newConnections[0])
|
|
}
|
|
} catch (err: any) {
|
|
errorMessage.value = err.response?.data?.error || 'Failed to connect via SimpleFIN. Check your setup token.'
|
|
showError.value = true
|
|
} finally {
|
|
simpleFinLoading.value = false
|
|
}
|
|
}
|
|
|
|
function openMappingDialog(conn: SyncConnection) {
|
|
newConnectionId.value = conn.id
|
|
newLinkedAccounts.value = conn.linkedAccounts
|
|
conn.linkedAccounts.forEach(la => {
|
|
mappingSelections[la.id] = null
|
|
autoCreateSelections[la.id] = false
|
|
})
|
|
showMappingDialog.value = true
|
|
}
|
|
|
|
async function saveMappings() {
|
|
mappingSaving.value = true
|
|
try {
|
|
for (const la of newLinkedAccounts.value) {
|
|
const accountId = mappingSelections[la.id]
|
|
const autoCreate = autoCreateSelections[la.id]
|
|
if (accountId || autoCreate) {
|
|
await bankSyncApi.linkAccount(newConnectionId.value, {
|
|
linkedAccountId: la.id,
|
|
accountId: autoCreate ? null : accountId,
|
|
autoCreate
|
|
})
|
|
}
|
|
}
|
|
showMappingDialog.value = false
|
|
await loadConnections()
|
|
await loadAccounts()
|
|
} finally {
|
|
mappingSaving.value = false
|
|
}
|
|
}
|
|
|
|
async function updateLinkedAccount(la: LinkedAccountInfo, accountId: string | null) {
|
|
await bankSyncApi.updateLinkedAccount(la.id, { accountId, isEnabled: la.isEnabled })
|
|
await loadConnections()
|
|
}
|
|
|
|
async function toggleLinkedAccount(la: LinkedAccountInfo, enabled: boolean) {
|
|
await bankSyncApi.updateLinkedAccount(la.id, { accountId: la.accountId, isEnabled: enabled })
|
|
await loadConnections()
|
|
}
|
|
|
|
async function updateConnectionInterval(conn: SyncConnection) {
|
|
await bankSyncApi.updateConnection(conn.id, {
|
|
isActive: conn.isActive,
|
|
syncIntervalMinutes: syncIntervals[conn.id]
|
|
})
|
|
}
|
|
|
|
async function toggleConnection(conn: SyncConnection) {
|
|
await bankSyncApi.updateConnection(conn.id, {
|
|
isActive: !conn.isActive,
|
|
syncIntervalMinutes: conn.syncIntervalMinutes
|
|
})
|
|
await loadConnections()
|
|
}
|
|
|
|
async function syncNow(conn: SyncConnection) {
|
|
syncingConnections[conn.id] = true
|
|
try {
|
|
await bankSyncApi.syncNow(conn.id)
|
|
} catch (err: any) {
|
|
syncingConnections[conn.id] = false
|
|
errorMessage.value = err.response?.data?.error || 'Sync failed'
|
|
showError.value = true
|
|
}
|
|
}
|
|
|
|
async function resyncFromScratch(conn: SyncConnection) {
|
|
resyncingConnections[conn.id] = true
|
|
try {
|
|
await bankSyncApi.resetSync(conn.id)
|
|
await bankSyncApi.syncNow(conn.id)
|
|
syncingConnections[conn.id] = true
|
|
} catch (err: any) {
|
|
errorMessage.value = err.response?.data?.error || 'Re-sync failed'
|
|
showError.value = true
|
|
} finally {
|
|
resyncingConnections[conn.id] = false
|
|
}
|
|
}
|
|
|
|
async function syncAllSimpleFin() {
|
|
const active = simpleFinConnections.value.filter(c => c.isActive)
|
|
if (active.length === 0) return
|
|
syncingAllSimpleFin.value = true
|
|
try {
|
|
for (const conn of active) {
|
|
syncingConnections[conn.id] = true
|
|
await bankSyncApi.syncNow(conn.id)
|
|
}
|
|
} catch (err: any) {
|
|
errorMessage.value = err.response?.data?.error || 'SimpleFIN sync failed'
|
|
showError.value = true
|
|
}
|
|
}
|
|
|
|
async function resyncAllSimpleFin() {
|
|
const active = simpleFinConnections.value.filter(c => c.isActive)
|
|
if (active.length === 0) return
|
|
resyncingAllSimpleFin.value = true
|
|
try {
|
|
for (const conn of active) {
|
|
await bankSyncApi.resetSync(conn.id)
|
|
syncingConnections[conn.id] = true
|
|
await bankSyncApi.syncNow(conn.id)
|
|
}
|
|
} catch (err: any) {
|
|
errorMessage.value = err.response?.data?.error || 'SimpleFIN re-sync failed'
|
|
showError.value = true
|
|
} finally {
|
|
resyncingAllSimpleFin.value = false
|
|
}
|
|
}
|
|
|
|
async function syncAllPlaid() {
|
|
const active = plaidConnections.value.filter(c => c.isActive)
|
|
if (active.length === 0) return
|
|
syncingAllPlaid.value = true
|
|
try {
|
|
for (const conn of active) {
|
|
syncingConnections[conn.id] = true
|
|
await bankSyncApi.syncNow(conn.id)
|
|
}
|
|
} catch (err: any) {
|
|
errorMessage.value = err.response?.data?.error || 'Plaid sync failed'
|
|
showError.value = true
|
|
}
|
|
}
|
|
|
|
async function updateSimpleFinGlobalInterval() {
|
|
try {
|
|
for (const conn of simpleFinConnections.value) {
|
|
syncIntervals[conn.id] = simpleFinGlobalInterval.value
|
|
await bankSyncApi.updateConnection(conn.id, {
|
|
isActive: conn.isActive,
|
|
syncIntervalMinutes: simpleFinGlobalInterval.value
|
|
})
|
|
}
|
|
} catch (err: any) {
|
|
errorMessage.value = err.response?.data?.error || 'Failed to update sync interval'
|
|
showError.value = true
|
|
}
|
|
}
|
|
|
|
// Handle sync results via SignalR notification
|
|
const { notifications } = useNotifications()
|
|
watch(notifications, (notifs) => {
|
|
const latest = notifs[0]
|
|
if (latest && !latest.read && latest.type === 'BankSyncComplete') {
|
|
const data = latest.data as BankSyncNotification
|
|
syncingConnections[data.connectionId] = false
|
|
syncResultMessage.value = `Synced ${data.institutionName}: ${data.transactionsImported} imported, ${data.transactionsSkipped} skipped`
|
|
showSyncResult.value = true
|
|
loadConnections()
|
|
|
|
// Clear section-level loading if all connections in the group are done
|
|
if (syncingAllSimpleFin.value) {
|
|
const stillSyncing = simpleFinConnections.value.some(c => syncingConnections[c.id])
|
|
if (!stillSyncing) syncingAllSimpleFin.value = false
|
|
}
|
|
if (syncingAllPlaid.value) {
|
|
const stillSyncing = plaidConnections.value.some(c => syncingConnections[c.id])
|
|
if (!stillSyncing) syncingAllPlaid.value = false
|
|
}
|
|
}
|
|
})
|
|
|
|
function confirmDeleteConnection(conn: SyncConnection) {
|
|
deleteTarget.value = conn
|
|
showDeleteDialog.value = true
|
|
}
|
|
|
|
async function deleteConnection() {
|
|
if (!deleteTarget.value) return
|
|
deleteLoading.value = true
|
|
try {
|
|
await bankSyncApi.deleteConnection(deleteTarget.value.id)
|
|
connections.value = connections.value.filter(c => c.id !== deleteTarget.value!.id)
|
|
showDeleteDialog.value = false
|
|
} finally {
|
|
deleteLoading.value = false
|
|
}
|
|
}
|
|
|
|
// AI Categorization
|
|
const aiSettings = reactive<AiCategorizationSettings>({
|
|
isEnabled: false,
|
|
ollamaUrl: 'http://localhost:11434',
|
|
modelName: 'llama3.1:8b',
|
|
confidenceThreshold: 0.7,
|
|
timeoutSeconds: 30,
|
|
updatePayeeDefaults: true,
|
|
isChatEnabled: false,
|
|
systemPrompt: null,
|
|
userPromptTemplate: null,
|
|
chatContextSize: 16384,
|
|
chatBotName: null
|
|
})
|
|
|
|
const isOllamaConfigured = computed(() =>
|
|
!!aiSettings.ollamaUrl?.trim() && !!aiSettings.modelName?.trim()
|
|
)
|
|
const aiSaving = ref(false)
|
|
const aiTesting = ref(false)
|
|
const aiTestResult = ref<OllamaConnectionTestResult | null>(null)
|
|
const availableModels = ref<string[]>([])
|
|
const showAiSuccess = ref(false)
|
|
const showAiError = ref(false)
|
|
const aiErrorMessage = ref('')
|
|
|
|
const defaultSystemPrompt = `You are a personal finance transaction categorizer. Classify each transaction into the most appropriate category.
|
|
|
|
You MUST respond with valid JSON in exactly this format:
|
|
{
|
|
"classifications": [
|
|
{"index": 0, "categoryName": "Groceries", "confidence": 0.9},
|
|
{"index": 1, "categoryName": "Utilities", "confidence": 0.85}
|
|
]
|
|
}
|
|
|
|
Rules:
|
|
- "index" must be the transaction number (integer) from the list below
|
|
- "categoryName" must be one of the exact category names listed below
|
|
- "confidence" must be a number between 0 and 1
|
|
- Include one entry for every transaction
|
|
|
|
Available categories:
|
|
{categories}`
|
|
|
|
const defaultUserPromptTemplate = `{examples}Classify these uncategorized transactions:
|
|
{transactions}`
|
|
|
|
function resetPromptsToDefaults() {
|
|
aiSettings.systemPrompt = defaultSystemPrompt
|
|
aiSettings.userPromptTemplate = defaultUserPromptTemplate
|
|
}
|
|
|
|
async function loadAiSettings() {
|
|
try {
|
|
const { data } = await aiCategorizationApi.getSettings()
|
|
Object.assign(aiSettings, data)
|
|
aiSettings.systemPrompt = aiSettings.systemPrompt || defaultSystemPrompt
|
|
aiSettings.userPromptTemplate = aiSettings.userPromptTemplate || defaultUserPromptTemplate
|
|
} catch {
|
|
// Not configured yet — defaults are fine
|
|
aiSettings.systemPrompt = defaultSystemPrompt
|
|
aiSettings.userPromptTemplate = defaultUserPromptTemplate
|
|
}
|
|
}
|
|
|
|
async function saveAiSettings() {
|
|
aiSaving.value = true
|
|
try {
|
|
const { data } = await aiCategorizationApi.updateSettings({ ...aiSettings })
|
|
Object.assign(aiSettings, data)
|
|
showAiSuccess.value = true
|
|
} catch (err: any) {
|
|
aiErrorMessage.value = err.response?.data?.error || 'Failed to save AI settings'
|
|
showAiError.value = true
|
|
} finally {
|
|
aiSaving.value = false
|
|
}
|
|
}
|
|
|
|
async function testAiConnection() {
|
|
aiTesting.value = true
|
|
aiTestResult.value = null
|
|
try {
|
|
const { data } = await aiCategorizationApi.testConnection()
|
|
aiTestResult.value = data
|
|
if (data.availableModels && data.availableModels.length > 0) {
|
|
availableModels.value = data.availableModels
|
|
}
|
|
} catch (err: any) {
|
|
aiTestResult.value = { success: false, errorMessage: err.response?.data?.error || 'Connection test failed', availableModels: null }
|
|
} finally {
|
|
aiTesting.value = false
|
|
}
|
|
}
|
|
|
|
onMounted(async () => {
|
|
await Promise.all([loadProfile(), loadPlaidCredentials(), loadConnections(), loadAccounts(), loadSyncLogs(), loadAiSettings()])
|
|
})
|
|
</script>
|