Private
Public Access
1
0

Add provider sub-tabs and collapsible panels to Bank Connections settings

Split Bank Connections into SimpleFIN/Plaid sub-tabs with institution
cards as collapsible expansion panels. Persist active tab, provider
sub-tab, and panel expand/collapse state to localStorage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Catherine Renelle
2026-02-16 19:05:24 -05:00
parent db878c109c
commit 4808e5d67d
+319 -305
View File
@@ -127,58 +127,59 @@
<!-- 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>
<v-tabs v-model="activeProviderTab" color="primary" class="mb-4">
<v-tab value="simplefin">
<v-icon start size="small">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-chip v-if="simpleFinConnections.length" color="green" size="x-small" class="ml-2">{{ simpleFinConnections.length }}</v-chip>
</v-tab>
<v-tab value="plaid">
<v-icon start size="small">mdi-bank</v-icon>
Plaid
<v-chip v-if="plaidConnections.length" color="blue" size="x-small" class="ml-2">{{ plaidConnections.length }}</v-chip>
</v-tab>
</v-tabs>
<v-card-text>
<div class="d-flex align-center mb-4">
<v-tabs-window v-model="activeProviderTab">
<!-- SimpleFIN sub-tab -->
<v-tabs-window-item value="simplefin">
<div class="d-flex align-center flex-wrap ga-2 mb-4">
<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-spacer />
<v-select
v-model="simpleFinGlobalInterval"
:items="intervalOptions"
item-title="text"
item-value="value"
label="Sync Interval (all SimpleFIN)"
label="Sync Interval"
density="compact"
variant="outlined"
hide-details
style="max-width: 260px"
style="max-width: 220px"
@update:model-value="updateSimpleFinGlobalInterval"
/>
</div>
@@ -187,281 +188,269 @@
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-expansion-panels v-model="expandedSimpleFinPanels" multiple>
<v-expansion-panel v-for="conn in simpleFinConnections" :key="conn.id" :value="conn.id">
<v-expansion-panel-title>
<div class="d-flex align-center flex-grow-1 mr-2">
<span class="text-subtitle-2">{{ conn.institutionName }}</span>
<v-chip :color="conn.isActive ? 'success' : 'grey'" size="x-small" class="ml-2">
{{ conn.isActive ? 'Active' : 'Inactive' }}
</v-chip>
<v-chip size="x-small" variant="tonal" class="ml-1">
{{ conn.linkedAccounts.length }} account{{ conn.linkedAccounts.length !== 1 ? 's' : '' }}
</v-chip>
<v-chip v-if="syncingConnections[conn.id]" size="x-small" color="info" class="ml-1">
Syncing...
</v-chip>
</div>
</v-expansion-panel-title>
<v-expansion-panel-text>
<div class="d-flex align-center flex-wrap ga-2 mb-3">
<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" prepend-icon="mdi-delete" @click="confirmDeleteConnection(conn)">
Delete
</v-btn>
<v-spacer />
<span class="text-body-2 text-medium-emphasis">
Last synced: {{ conn.lastSyncAt ? new Date(conn.lastSyncAt).toLocaleString() : 'Never' }}
</span>
</div>
<v-alert v-if="conn.lastSyncError" type="error" variant="tonal" density="compact" class="mb-3">
{{ conn.lastSyncError }}
</v-alert>
<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>
<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-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</v-tabs-window-item>
<!-- Plaid sub-tab -->
<v-tabs-window-item value="plaid">
<!-- Plaid API Credentials -->
<v-card variant="outlined" class="mb-4">
<v-card-text>
<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>
<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>
<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>
</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 class="d-flex align-center flex-wrap ga-2 mb-4">
<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>
</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-expansion-panels v-model="expandedPlaidPanels" multiple>
<v-expansion-panel v-for="conn in plaidConnections" :key="conn.id" :value="conn.id">
<v-expansion-panel-title>
<div class="d-flex align-center flex-grow-1 mr-2">
<span class="text-subtitle-2">{{ conn.institutionName }}</span>
<v-chip :color="conn.isActive ? 'success' : 'grey'" size="x-small" class="ml-2">
{{ conn.isActive ? 'Active' : 'Inactive' }}
</v-chip>
<v-chip size="x-small" variant="tonal" class="ml-1">
{{ conn.linkedAccounts.length }} account{{ conn.linkedAccounts.length !== 1 ? 's' : '' }}
</v-chip>
<v-chip v-if="syncingConnections[conn.id]" size="x-small" color="info" class="ml-1">
Syncing...
</v-chip>
</div>
</v-expansion-panel-title>
<v-expansion-panel-text>
<div class="d-flex align-center flex-wrap ga-2 mb-3">
<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" prepend-icon="mdi-delete" @click="confirmDeleteConnection(conn)">
Delete
</v-btn>
<v-spacer />
<span class="text-body-2 text-medium-emphasis">
Last synced: {{ conn.lastSyncAt ? new Date(conn.lastSyncAt).toLocaleString() : 'Never' }}
</span>
</div>
<v-alert v-if="conn.lastSyncError" type="error" variant="tonal" density="compact" class="mb-3">
{{ conn.lastSyncError }}
</v-alert>
<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-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>
<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-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</v-tabs-window-item>
</v-tabs-window>
<!-- Account Mapping Dialog -->
<v-dialog v-model="showMappingDialog" max-width="700" persistent>
@@ -840,7 +829,32 @@ import type { SyncConnection, LinkedAccountInfo, Account, SyncLogEntry, PlaidCre
const authStore = useAuthStore()
const router = useRouter()
const activeTab = ref('profile')
// UI state persistence
const SETTINGS_UI_KEY = 'purrse-settings-ui-state'
function loadUiState(): Record<string, any> | null {
try {
const stored = localStorage.getItem(SETTINGS_UI_KEY)
return stored ? JSON.parse(stored) : null
} catch { return null }
}
function saveUiState() {
localStorage.setItem(SETTINGS_UI_KEY, JSON.stringify({
settingsTab: activeTab.value,
providerTab: activeProviderTab.value,
expandedSimpleFin: expandedSimpleFinPanels.value,
expandedPlaid: expandedPlaidPanels.value
}))
}
const _savedUiState = loadUiState()
const activeTab = ref(_savedUiState?.settingsTab || 'profile')
const activeProviderTab = ref(_savedUiState?.providerTab || 'simplefin')
const expandedSimpleFinPanels = ref<string[]>(_savedUiState?.expandedSimpleFin || [])
const expandedPlaidPanels = ref<string[]>(_savedUiState?.expandedPlaid || [])
watch([activeTab, activeProviderTab, expandedSimpleFinPanels, expandedPlaidPanels], saveUiState, { deep: true })
// Profile
const profile = ref<UserProfile | null>(null)