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 --> <!-- Tab 2: Bank Connections -->
<v-tabs-window-item value="connections"> <v-tabs-window-item value="connections">
<!-- SimpleFIN Section --> <v-tabs v-model="activeProviderTab" color="primary" class="mb-4">
<v-card class="mb-6"> <v-tab value="simplefin">
<v-card-title class="d-flex align-center flex-wrap ga-2"> <v-icon start size="small">mdi-link-variant</v-icon>
<v-icon class="mr-1">mdi-link-variant</v-icon>
SimpleFIN SimpleFIN
<v-chip color="green" size="small">{{ simpleFinConnections.length }}</v-chip> <v-chip v-if="simpleFinConnections.length" color="green" size="x-small" class="ml-2">{{ simpleFinConnections.length }}</v-chip>
<v-spacer /> </v-tab>
<v-btn <v-tab value="plaid">
size="small" <v-icon start size="small">mdi-bank</v-icon>
variant="tonal" Plaid
color="secondary" <v-chip v-if="plaidConnections.length" color="blue" size="x-small" class="ml-2">{{ plaidConnections.length }}</v-chip>
prepend-icon="mdi-link-plus" </v-tab>
@click="showSimpleFinDialog = true" </v-tabs>
>
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> <v-tabs-window v-model="activeProviderTab">
<div class="d-flex align-center mb-4"> <!-- 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-select
v-model="simpleFinGlobalInterval" v-model="simpleFinGlobalInterval"
:items="intervalOptions" :items="intervalOptions"
item-title="text" item-title="text"
item-value="value" item-value="value"
label="Sync Interval (all SimpleFIN)" label="Sync Interval"
density="compact" density="compact"
variant="outlined" variant="outlined"
hide-details hide-details
style="max-width: 260px" style="max-width: 220px"
@update:model-value="updateSimpleFinGlobalInterval" @update:model-value="updateSimpleFinGlobalInterval"
/> />
</div> </div>
@@ -187,281 +188,269 @@
No SimpleFIN connections yet. Click Connect to add one. No SimpleFIN connections yet. Click Connect to add one.
</v-alert> </v-alert>
<v-card v-for="conn in simpleFinConnections" :key="conn.id" variant="outlined" class="mb-3"> <v-expansion-panels v-model="expandedSimpleFinPanels" multiple>
<v-card-title class="d-flex align-center text-subtitle-1 py-2"> <v-expansion-panel v-for="conn in simpleFinConnections" :key="conn.id" :value="conn.id">
{{ conn.institutionName }} <v-expansion-panel-title>
<v-spacer /> <div class="d-flex align-center flex-grow-1 mr-2">
<v-chip <span class="text-subtitle-2">{{ conn.institutionName }}</span>
:color="conn.isActive ? 'success' : 'grey'" <v-chip :color="conn.isActive ? 'success' : 'grey'" size="x-small" class="ml-2">
size="x-small" {{ conn.isActive ? 'Active' : 'Inactive' }}
class="mr-2" </v-chip>
> <v-chip size="x-small" variant="tonal" class="ml-1">
{{ conn.isActive ? 'Active' : 'Inactive' }} {{ conn.linkedAccounts.length }} account{{ conn.linkedAccounts.length !== 1 ? 's' : '' }}
</v-chip> </v-chip>
<v-btn <v-chip v-if="syncingConnections[conn.id]" size="x-small" color="info" class="ml-1">
variant="text" Syncing...
:color="conn.isActive ? 'warning' : 'success'" </v-chip>
size="small" </div>
@click="toggleConnection(conn)" </v-expansion-panel-title>
> <v-expansion-panel-text>
{{ conn.isActive ? 'Disable' : 'Enable' }} <div class="d-flex align-center flex-wrap ga-2 mb-3">
</v-btn> <v-btn variant="text" :color="conn.isActive ? 'warning' : 'success'" size="small" @click="toggleConnection(conn)">
<v-btn {{ conn.isActive ? 'Disable' : 'Enable' }}
variant="text" </v-btn>
color="error" <v-btn variant="text" color="error" size="small" prepend-icon="mdi-delete" @click="confirmDeleteConnection(conn)">
size="small" Delete
icon="mdi-delete" </v-btn>
@click="confirmDeleteConnection(conn)" <v-spacer />
/> <span class="text-body-2 text-medium-emphasis">
</v-card-title> 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"> <v-table density="compact" v-if="conn.linkedAccounts.length > 0">
<div class="text-body-2 text-medium-emphasis mb-2"> <thead>
Last synced: {{ conn.lastSyncAt ? new Date(conn.lastSyncAt).toLocaleString() : 'Never' }} <tr>
<v-chip v-if="syncingConnections[conn.id]" size="x-small" color="info" class="ml-2"> <th>External Account</th>
Syncing... <th>Type</th>
</v-chip> <th>Balance</th>
<span v-if="conn.lastSyncError" class="text-error ml-2">{{ conn.lastSyncError }}</span> <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> </div>
<p class="text-body-2 text-medium-emphasis mb-3">
<v-table density="compact" v-if="conn.linkedAccounts.length > 0"> Enter your Plaid developer credentials. Get them from your Plaid dashboard.
<thead> </p>
<tr> <v-row dense>
<th>External Account</th> <v-col cols="12" sm="4">
<th>Type</th> <v-text-field
<th>Balance</th> v-model="plaidForm.clientId"
<th>Purrse Account</th> label="Client ID"
<th>Enabled</th> variant="outlined"
<th>Last Synced</th> density="compact"
</tr> :placeholder="plaidCredentials.isConfigured ? '(saved)' : ''"
</thead> />
<tbody> </v-col>
<tr v-for="la in conn.linkedAccounts" :key="la.id"> <v-col cols="12" sm="4">
<td>{{ la.externalAccountName }}</td> <v-text-field
<td>{{ la.externalAccountType }}</td> v-model="plaidForm.secret"
<td>{{ la.lastKnownBalance != null ? formatCurrency(la.lastKnownBalance) : '-' }}</td> label="Secret"
<td> variant="outlined"
<v-select density="compact"
:model-value="la.accountId" type="password"
:items="accountOptions" :placeholder="plaidCredentials.isConfigured ? '(saved)' : ''"
item-title="text" />
item-value="value" </v-col>
density="compact" <v-col cols="12" sm="2">
variant="outlined" <v-select
hide-details v-model="plaidForm.environment"
clearable :items="plaidEnvironments"
placeholder="Select account..." label="Environment"
@update:model-value="(val: string | null) => updateLinkedAccount(la, val)" variant="outlined"
/> density="compact"
</td> />
<td> </v-col>
<v-switch <v-col cols="12" sm="2" class="d-flex align-center">
:model-value="la.isEnabled" <v-btn
density="compact" color="primary"
hide-details :loading="plaidCredsSaving"
color="primary" :disabled="!plaidForm.clientId || !plaidForm.secret"
@update:model-value="(val: boolean | null) => toggleLinkedAccount(la, val ?? false)" @click="savePlaidCredentials"
/> >
</td> Save
<td class="text-body-2">{{ la.lastSyncAt ? new Date(la.lastSyncAt).toLocaleString() : 'Never' }}</td> </v-btn>
</tr> </v-col>
</tbody> </v-row>
</v-table>
</v-card-text> </v-card-text>
</v-card> </v-card>
</v-card-text>
</v-card>
<!-- Plaid Section --> <div class="d-flex align-center flex-wrap ga-2 mb-4">
<v-card class="mb-6"> <v-btn
<v-card-title class="d-flex align-center flex-wrap ga-2"> size="small"
<v-icon class="mr-1">mdi-bank</v-icon> variant="tonal"
Plaid color="primary"
<v-chip color="blue" size="small">{{ plaidConnections.length }}</v-chip> prepend-icon="mdi-bank-plus"
<v-spacer /> :loading="plaidLoading"
<v-btn :disabled="!plaidCredentials.isConfigured"
size="small" @click="startPlaidLink"
variant="tonal" >
color="primary" Connect
prepend-icon="mdi-bank-plus" </v-btn>
:loading="plaidLoading" <v-btn
:disabled="!plaidCredentials.isConfigured" size="small"
@click="startPlaidLink" variant="tonal"
> color="primary"
Connect prepend-icon="mdi-sync"
</v-btn> :loading="syncingAllPlaid"
<v-btn :disabled="plaidConnections.filter(c => c.isActive).length === 0"
size="small" @click="syncAllPlaid"
variant="tonal" >
color="primary" Sync All
prepend-icon="mdi-sync" </v-btn>
: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> </div>
<v-divider class="mb-4" />
<v-alert v-if="plaidConnections.length === 0 && !loading" type="info" variant="tonal" 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. No Plaid connections yet. Configure your API credentials above, then click Connect.
</v-alert> </v-alert>
<v-card v-for="conn in plaidConnections" :key="conn.id" variant="outlined" class="mb-3"> <v-expansion-panels v-model="expandedPlaidPanels" multiple>
<v-card-title class="d-flex align-center text-subtitle-1 py-2"> <v-expansion-panel v-for="conn in plaidConnections" :key="conn.id" :value="conn.id">
{{ conn.institutionName }} <v-expansion-panel-title>
<v-spacer /> <div class="d-flex align-center flex-grow-1 mr-2">
<v-chip <span class="text-subtitle-2">{{ conn.institutionName }}</span>
:color="conn.isActive ? 'success' : 'grey'" <v-chip :color="conn.isActive ? 'success' : 'grey'" size="x-small" class="ml-2">
size="x-small" {{ conn.isActive ? 'Active' : 'Inactive' }}
class="mr-2" </v-chip>
> <v-chip size="x-small" variant="tonal" class="ml-1">
{{ conn.isActive ? 'Active' : 'Inactive' }} {{ conn.linkedAccounts.length }} account{{ conn.linkedAccounts.length !== 1 ? 's' : '' }}
</v-chip> </v-chip>
<v-btn <v-chip v-if="syncingConnections[conn.id]" size="x-small" color="info" class="ml-1">
variant="text" Syncing...
:color="conn.isActive ? 'warning' : 'success'" </v-chip>
size="small" </div>
@click="toggleConnection(conn)" </v-expansion-panel-title>
> <v-expansion-panel-text>
{{ conn.isActive ? 'Disable' : 'Enable' }} <div class="d-flex align-center flex-wrap ga-2 mb-3">
</v-btn> <v-btn variant="text" :color="conn.isActive ? 'warning' : 'success'" size="small" @click="toggleConnection(conn)">
<v-btn {{ conn.isActive ? 'Disable' : 'Enable' }}
variant="text" </v-btn>
color="error" <v-btn variant="text" color="error" size="small" prepend-icon="mdi-delete" @click="confirmDeleteConnection(conn)">
size="small" Delete
icon="mdi-delete" </v-btn>
@click="confirmDeleteConnection(conn)" <v-spacer />
/> <span class="text-body-2 text-medium-emphasis">
</v-card-title> 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"> <v-table density="compact" v-if="conn.linkedAccounts.length > 0">
<div class="text-body-2 text-medium-emphasis mb-2"> <thead>
Last synced: {{ conn.lastSyncAt ? new Date(conn.lastSyncAt).toLocaleString() : 'Never' }} <tr>
<v-chip v-if="syncingConnections[conn.id]" size="x-small" color="info" class="ml-2"> <th>External Account</th>
Syncing... <th>Type</th>
</v-chip> <th>Balance</th>
<span v-if="conn.lastSyncError" class="text-error ml-2">{{ conn.lastSyncError }}</span> <th>Purrse Account</th>
</div> <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"> <div class="d-flex align-center mt-3 ga-2">
<thead> <v-select
<tr> v-model="syncIntervals[conn.id]"
<th>External Account</th> :items="intervalOptions"
<th>Type</th> item-title="text"
<th>Balance</th> item-value="value"
<th>Purrse Account</th> label="Sync Interval"
<th>Enabled</th> density="compact"
<th>Last Synced</th> variant="outlined"
</tr> hide-details
</thead> style="max-width: 200px"
<tbody> @update:model-value="() => updateConnectionInterval(conn)"
<tr v-for="la in conn.linkedAccounts" :key="la.id"> />
<td>{{ la.externalAccountName }}</td> </div>
<td>{{ la.externalAccountType }}</td> </v-expansion-panel-text>
<td>{{ la.lastKnownBalance != null ? formatCurrency(la.lastKnownBalance) : '-' }}</td> </v-expansion-panel>
<td> </v-expansion-panels>
<v-select </v-tabs-window-item>
:model-value="la.accountId" </v-tabs-window>
: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 --> <!-- Account Mapping Dialog -->
<v-dialog v-model="showMappingDialog" max-width="700" persistent> <v-dialog v-model="showMappingDialog" max-width="700" persistent>
@@ -840,7 +829,32 @@ import type { SyncConnection, LinkedAccountInfo, Account, SyncLogEntry, PlaidCre
const authStore = useAuthStore() const authStore = useAuthStore()
const router = useRouter() 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 // Profile
const profile = ref<UserProfile | null>(null) const profile = ref<UserProfile | null>(null)