Private
Public Access
1
0

Add investment holdings CRUD (create, edit, delete)

Add POST/PUT/DELETE endpoints to InvestmentsController with find-or-create
Security by normalized symbol, price upsert helper, and ownership checks.
Frontend gets add/edit dialog with per-share cost basis input, delete
confirmation, actions column on holdings table, and success/error snackbar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Catherine Renelle
2026-02-15 23:31:44 -05:00
parent 24839a3a21
commit 7f15cd11e3
5 changed files with 346 additions and 9 deletions
+10 -1
View File
@@ -1,5 +1,5 @@
import api from './api'
import type { InvestmentHolding, InvestmentPerformance } from '@/types'
import type { InvestmentHolding, InvestmentPerformance, CreateInvestmentHoldingRequest, UpdateInvestmentHoldingRequest } from '@/types'
export const investmentsApi = {
getHoldings: (accountId: string) =>
@@ -7,4 +7,13 @@ export const investmentsApi = {
getPerformance: (accountId: string) =>
api.get<InvestmentPerformance>(`/investments/${accountId}/performance`),
createHolding: (accountId: string, data: CreateInvestmentHoldingRequest) =>
api.post<InvestmentHolding>(`/investments/${accountId}/holdings`, data),
updateHolding: (accountId: string, holdingId: string, data: UpdateInvestmentHoldingRequest) =>
api.put<InvestmentHolding>(`/investments/${accountId}/holdings/${holdingId}`, data),
deleteHolding: (accountId: string, holdingId: string) =>
api.delete(`/investments/${accountId}/holdings/${holdingId}`),
}
+15
View File
@@ -364,6 +364,21 @@ export interface InvestmentPerformance {
holdings: InvestmentHolding[]
}
export interface CreateInvestmentHoldingRequest {
symbol: string
securityName: string
securityType?: string | null
shares: number
costBasis: number
currentPrice?: number | null
}
export interface UpdateInvestmentHoldingRequest {
shares: number
costBasis: number
currentPrice?: number | null
}
// Reconciliation
export interface ReconciliationResponse {
id: string
@@ -38,7 +38,8 @@
<div class="text-caption text-medium-emphasis mb-6">Account Balance</div>
<v-icon size="48" color="grey-lighten-1" class="mb-4">mdi-chart-pie</v-icon>
<p class="text-medium-emphasis">No individual holdings tracked for this account.</p>
<p class="text-medium-emphasis text-caption">Add holdings to see allocation, cost basis, and performance.</p>
<p class="text-medium-emphasis text-caption mb-4">Add holdings to see allocation, cost basis, and performance.</p>
<v-btn color="primary" prepend-icon="mdi-plus" @click="openAddDialog">Add Holding</v-btn>
</v-card>
</template>
@@ -97,7 +98,11 @@
</v-col>
<v-col cols="12" md="8">
<v-card>
<v-card-title>Holdings</v-card-title>
<v-card-title class="d-flex align-center">
Holdings
<v-spacer />
<v-btn color="primary" size="small" prepend-icon="mdi-plus" @click="openAddDialog">Add Holding</v-btn>
</v-card-title>
<v-card-text>
<v-data-table
:headers="holdingsHeaders"
@@ -120,6 +125,16 @@
{{ formatPercent(item.gainLossPercent) }}
</span>
</template>
<template #item.actions="{ item }">
<div class="d-flex align-center ga-1">
<v-btn icon size="x-small" variant="text" @click="openEditDialog(item)">
<v-icon size="small">mdi-pencil</v-icon>
</v-btn>
<v-btn icon size="x-small" variant="text" color="error" @click="confirmDelete(item)">
<v-icon size="small">mdi-delete</v-icon>
</v-btn>
</div>
</template>
</v-data-table>
</v-card-text>
</v-card>
@@ -134,7 +149,74 @@
</v-card>
</template>
<v-snackbar v-model="snackbar" color="error" :timeout="3000">{{ snackbarText }}</v-snackbar>
<!-- Add/Edit Holding Dialog -->
<v-dialog v-model="showHoldingDialog" max-width="500" persistent>
<v-card>
<v-card-title>{{ editingHolding ? 'Edit Holding' : 'Add Holding' }}</v-card-title>
<v-card-text>
<v-text-field
v-if="!editingHolding"
v-model="holdingForm.symbol"
label="Symbol"
placeholder="e.g. AAPL"
:rules="[v => !!v || 'Symbol is required']"
/>
<v-text-field
v-if="!editingHolding"
v-model="holdingForm.securityName"
label="Security Name"
placeholder="e.g. Apple Inc."
:rules="[v => !!v || 'Name is required']"
/>
<v-text-field
v-model.number="holdingForm.shares"
label="Shares"
type="number"
:rules="[v => v > 0 || 'Shares must be greater than 0']"
/>
<v-text-field
v-model.number="holdingForm.costBasisPerShare"
label="Cost Basis Per Share"
type="number"
prefix="$"
:rules="[v => v >= 0 || 'Cost basis must be non-negative']"
/>
<div v-if="holdingForm.shares > 0 && holdingForm.costBasisPerShare > 0" class="text-caption text-medium-emphasis mb-4">
Total Cost Basis: {{ formatCurrency(holdingForm.shares * holdingForm.costBasisPerShare) }}
</div>
<v-text-field
v-model.number="holdingForm.currentPrice"
label="Current Price (optional)"
type="number"
prefix="$"
hint="Leave empty to keep existing price"
persistent-hint
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn @click="showHoldingDialog = false">Cancel</v-btn>
<v-btn color="primary" :loading="saving" @click="saveHolding">Save</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Delete Confirmation Dialog -->
<v-dialog v-model="showDeleteDialog" max-width="400">
<v-card>
<v-card-title>Delete Holding</v-card-title>
<v-card-text>
Are you sure you want to delete the holding for "{{ deletingHolding?.symbol }}"? This cannot be undone.
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn @click="showDeleteDialog = false">Cancel</v-btn>
<v-btn color="error" :loading="saving" @click="deleteHolding">Delete</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-snackbar v-model="snackbar" :color="snackbarColor" :timeout="3000">{{ snackbarText }}</v-snackbar>
</div>
</template>
@@ -144,7 +226,7 @@ import VueApexCharts from 'vue3-apexcharts'
import { investmentsApi } from '@/services/investments'
import { useAccountsStore } from '@/stores/accounts'
import { formatCurrency, formatPercent, accountTypeLabel, accountTypeIcon } from '@/utils/formatters'
import type { InvestmentPerformance } from '@/types'
import type { InvestmentPerformance, InvestmentHolding } from '@/types'
const apexchart = VueApexCharts
@@ -153,8 +235,23 @@ const accountsStore = useAccountsStore()
const selectedAccountId = ref<string | null>(null)
const performance = ref<InvestmentPerformance | null>(null)
const loading = ref(false)
const saving = ref(false)
const snackbar = ref(false)
const snackbarText = ref('')
const snackbarColor = ref('success')
// Dialog state
const showHoldingDialog = ref(false)
const showDeleteDialog = ref(false)
const editingHolding = ref<InvestmentHolding | null>(null)
const deletingHolding = ref<InvestmentHolding | null>(null)
const holdingForm = ref({
symbol: '',
securityName: '',
shares: 0,
costBasisPerShare: 0,
currentPrice: null as number | null,
})
const investmentAccountTypes = ['Retirement401k', 'IRA', 'Brokerage']
@@ -202,6 +299,7 @@ const holdingsHeaders = [
{ title: 'Market Value', key: 'marketValue', align: 'end' as const },
{ title: 'Gain/Loss', key: 'gainLoss', align: 'end' as const },
{ title: 'Return', key: 'gainLossPercent', align: 'end' as const },
{ title: 'Actions', key: 'actions', sortable: false, width: '100px' },
]
async function loadPerformance() {
@@ -214,14 +312,89 @@ async function loadPerformance() {
const { data } = await investmentsApi.getPerformance(selectedAccountId.value)
performance.value = data
} catch {
snackbarText.value = 'Failed to load investment performance'
snackbar.value = true
showSnackbar('Failed to load investment performance', 'error')
performance.value = null
} finally {
loading.value = false
}
}
function openAddDialog() {
editingHolding.value = null
holdingForm.value = { symbol: '', securityName: '', shares: 0, costBasisPerShare: 0, currentPrice: null }
showHoldingDialog.value = true
}
function openEditDialog(holding: InvestmentHolding) {
editingHolding.value = holding
const costBasisPerShare = holding.shares > 0 ? holding.costBasis / holding.shares : 0
holdingForm.value = {
symbol: holding.symbol,
securityName: holding.securityName,
shares: holding.shares,
costBasisPerShare: Math.round(costBasisPerShare * 100) / 100,
currentPrice: holding.currentPrice || null,
}
showHoldingDialog.value = true
}
async function saveHolding() {
if (!selectedAccountId.value) return
saving.value = true
try {
const totalCostBasis = holdingForm.value.shares * holdingForm.value.costBasisPerShare
if (editingHolding.value) {
await investmentsApi.updateHolding(selectedAccountId.value, editingHolding.value.id, {
shares: holdingForm.value.shares,
costBasis: totalCostBasis,
currentPrice: holdingForm.value.currentPrice || undefined,
})
showSnackbar('Holding updated')
} else {
await investmentsApi.createHolding(selectedAccountId.value, {
symbol: holdingForm.value.symbol,
securityName: holdingForm.value.securityName,
shares: holdingForm.value.shares,
costBasis: totalCostBasis,
currentPrice: holdingForm.value.currentPrice || undefined,
})
showSnackbar('Holding added')
}
showHoldingDialog.value = false
await loadPerformance()
} catch {
showSnackbar('Failed to save holding', 'error')
} finally {
saving.value = false
}
}
function confirmDelete(holding: InvestmentHolding) {
deletingHolding.value = holding
showDeleteDialog.value = true
}
async function deleteHolding() {
if (!selectedAccountId.value || !deletingHolding.value) return
saving.value = true
try {
await investmentsApi.deleteHolding(selectedAccountId.value, deletingHolding.value.id)
showDeleteDialog.value = false
showSnackbar('Holding deleted')
await loadPerformance()
} catch {
showSnackbar('Failed to delete holding', 'error')
} finally {
saving.value = false
}
}
function showSnackbar(text: string, color = 'success') {
snackbarText.value = text
snackbarColor.value = color
snackbar.value = true
}
watch(selectedAccountId, () => loadPerformance())
onMounted(async () => {