Add INVESTMENT account type with manual portfolio value recording
This commit is contained in:
@@ -21,6 +21,7 @@ model User {
|
|||||||
enum AccountType {
|
enum AccountType {
|
||||||
BANK
|
BANK
|
||||||
CREDIT_CARD
|
CREDIT_CARD
|
||||||
|
INVESTMENT
|
||||||
}
|
}
|
||||||
|
|
||||||
model Account {
|
model Account {
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export default async function DashboardPage({ searchParams }: { searchParams: Se
|
|||||||
|
|
||||||
if (isCurrentMonth) {
|
if (isCurrentMonth) {
|
||||||
const accounts = await prisma.account.findMany({
|
const accounts = await prisma.account.findMany({
|
||||||
where: { userId, isActive: true, type: 'BANK' },
|
where: { userId, isActive: true, type: { in: ['BANK', 'INVESTMENT'] } },
|
||||||
select: { id: true, name: true, currentBalanceCents: true },
|
select: { id: true, name: true, currentBalanceCents: true },
|
||||||
orderBy: { name: 'asc' },
|
orderBy: { name: 'asc' },
|
||||||
})
|
})
|
||||||
@@ -80,7 +80,7 @@ export default async function DashboardPage({ searchParams }: { searchParams: Se
|
|||||||
FROM "BalanceSnapshot" bs
|
FROM "BalanceSnapshot" bs
|
||||||
JOIN "Account" a ON bs."accountId" = a.id
|
JOIN "Account" a ON bs."accountId" = a.id
|
||||||
WHERE a."userId" = ${userId}
|
WHERE a."userId" = ${userId}
|
||||||
AND a.type = 'BANK'
|
AND a.type IN ('BANK', 'INVESTMENT')
|
||||||
AND bs.year = ${year}
|
AND bs.year = ${year}
|
||||||
AND bs.month = ${month}
|
AND bs.month = ${month}
|
||||||
`
|
`
|
||||||
|
|||||||
44
src/app/api/accounts/[id]/record-value/route.ts
Normal file
44
src/app/api/accounts/[id]/record-value/route.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { auth } from '@/lib/auth'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
valueCents: z.number().int(),
|
||||||
|
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be YYYY-MM-DD'),
|
||||||
|
})
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
const account = await prisma.account.findFirst({
|
||||||
|
where: { id, userId: session.user.id, type: 'INVESTMENT' },
|
||||||
|
})
|
||||||
|
if (!account) return NextResponse.json({ error: 'Account not found' }, { status: 404 })
|
||||||
|
|
||||||
|
const result = schema.safeParse(await req.json())
|
||||||
|
if (!result.success) return NextResponse.json({ error: result.error.flatten() }, { status: 400 })
|
||||||
|
|
||||||
|
const { valueCents, date } = result.data
|
||||||
|
const d = new Date(date + 'T12:00:00')
|
||||||
|
const year = d.getFullYear()
|
||||||
|
const month = d.getMonth() + 1
|
||||||
|
|
||||||
|
await prisma.account.update({
|
||||||
|
where: { id },
|
||||||
|
data: { currentBalanceCents: valueCents },
|
||||||
|
})
|
||||||
|
|
||||||
|
await prisma.balanceSnapshot.upsert({
|
||||||
|
where: { accountId_year_month: { accountId: id, year, month } },
|
||||||
|
update: { balanceCents: valueCents, computedAt: new Date() },
|
||||||
|
create: { accountId: id, year, month, balanceCents: valueCents },
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true })
|
||||||
|
}
|
||||||
@@ -2,9 +2,7 @@ import { Badge } from '@/components/ui/badge'
|
|||||||
import { AccountType } from '@/generated/prisma/client'
|
import { AccountType } from '@/generated/prisma/client'
|
||||||
|
|
||||||
export function AccountBadge({ type }: { type: AccountType }) {
|
export function AccountBadge({ type }: { type: AccountType }) {
|
||||||
return type === 'BANK' ? (
|
if (type === 'BANK') return <Badge variant="secondary">Bank</Badge>
|
||||||
<Badge variant="secondary">Bank</Badge>
|
if (type === 'INVESTMENT') return <Badge variant="secondary" className="bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">Investment</Badge>
|
||||||
) : (
|
return <Badge variant="outline">Credit Card</Badge>
|
||||||
<Badge variant="outline">Credit Card</Badge>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,14 +9,16 @@ import {
|
|||||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator,
|
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
import { MoreHorizontal, Pencil, Trash2, EyeOff, Eye } from 'lucide-react'
|
import { MoreHorizontal, Pencil, Trash2, EyeOff, Eye, TrendingUp } from 'lucide-react'
|
||||||
import { AccountBadge } from './AccountBadge'
|
import { AccountBadge } from './AccountBadge'
|
||||||
import { CreateAccountDialog } from './CreateAccountDialog'
|
import { CreateAccountDialog } from './CreateAccountDialog'
|
||||||
|
import { RecordValueDialog } from './RecordValueDialog'
|
||||||
import { formatCents } from '@/lib/utils/currency'
|
import { formatCents } from '@/lib/utils/currency'
|
||||||
|
|
||||||
export function AccountCard({ account }: { account: Account }) {
|
export function AccountCard({ account }: { account: Account }) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [editOpen, setEditOpen] = useState(false)
|
const [editOpen, setEditOpen] = useState(false)
|
||||||
|
const [recordOpen, setRecordOpen] = useState(false)
|
||||||
|
|
||||||
async function handleToggleActive() {
|
async function handleToggleActive() {
|
||||||
await fetch(`/api/accounts/${account.id}`, {
|
await fetch(`/api/accounts/${account.id}`, {
|
||||||
@@ -56,9 +58,13 @@ export function AccountCard({ account }: { account: Account }) {
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem onClick={() => setEditOpen(true)}>
|
<DropdownMenuItem onClick={() => setEditOpen(true)}>
|
||||||
<Pencil className="h-4 w-4 mr-2" />
|
<Pencil className="h-4 w-4 mr-2" />Edit
|
||||||
Edit
|
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
{account.type === 'INVESTMENT' && (
|
||||||
|
<DropdownMenuItem onClick={() => setRecordOpen(true)}>
|
||||||
|
<TrendingUp className="h-4 w-4 mr-2" />Record value
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
<DropdownMenuItem onClick={handleToggleActive}>
|
<DropdownMenuItem onClick={handleToggleActive}>
|
||||||
{account.isActive
|
{account.isActive
|
||||||
? <><EyeOff className="h-4 w-4 mr-2" />Deactivate</>
|
? <><EyeOff className="h-4 w-4 mr-2" />Deactivate</>
|
||||||
@@ -87,11 +93,15 @@ export function AccountCard({ account }: { account: Account }) {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<CreateAccountDialog
|
<CreateAccountDialog open={editOpen} onOpenChange={setEditOpen} account={account} />
|
||||||
open={editOpen}
|
{account.type === 'INVESTMENT' && (
|
||||||
onOpenChange={setEditOpen}
|
<RecordValueDialog
|
||||||
account={account}
|
open={recordOpen}
|
||||||
|
onOpenChange={setRecordOpen}
|
||||||
|
accountId={account.id}
|
||||||
|
accountName={account.name}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ export function CreateAccountDialog({ open, onOpenChange, account }: Props) {
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="BANK">Bank</SelectItem>
|
<SelectItem value="BANK">Bank</SelectItem>
|
||||||
<SelectItem value="CREDIT_CARD">Credit Card</SelectItem>
|
<SelectItem value="CREDIT_CARD">Credit Card</SelectItem>
|
||||||
|
<SelectItem value="INVESTMENT">Investment</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
77
src/components/accounts/RecordValueDialog.tsx
Normal file
77
src/components/accounts/RecordValueDialog.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
accountId: string
|
||||||
|
accountName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecordValueDialog({ open, onOpenChange, accountId, accountName }: Props) {
|
||||||
|
const router = useRouter()
|
||||||
|
const [value, setValue] = useState('')
|
||||||
|
const [date, setDate] = useState(() => new Date().toISOString().split('T')[0])
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
const num = parseFloat(value.replace(/[$,]/g, ''))
|
||||||
|
if (isNaN(num)) { setError('Enter a valid dollar amount'); return }
|
||||||
|
setError('')
|
||||||
|
setSaving(true)
|
||||||
|
const res = await fetch(`/api/accounts/${accountId}/record-value`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ valueCents: Math.round(num * 100), date }),
|
||||||
|
})
|
||||||
|
setSaving(false)
|
||||||
|
if (!res.ok) { setError('Failed to save. Please try again.'); return }
|
||||||
|
onOpenChange(false)
|
||||||
|
router.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Record portfolio value — {accountName}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4 pt-1">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="rv-value">Portfolio value</Label>
|
||||||
|
<Input
|
||||||
|
id="rv-value"
|
||||||
|
placeholder="e.g. 12500.00"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="rv-date">As of date</Label>
|
||||||
|
<Input
|
||||||
|
id="rv-date"
|
||||||
|
type="date"
|
||||||
|
value={date}
|
||||||
|
onChange={(e) => setDate(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
|
||||||
|
<Button type="submit" disabled={saving}>{saving ? 'Saving…' : 'Save'}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { z } from 'zod'
|
|||||||
export const createAccountSchema = z.object({
|
export const createAccountSchema = z.object({
|
||||||
name: z.string().min(1, 'Name is required').max(100),
|
name: z.string().min(1, 'Name is required').max(100),
|
||||||
institution: z.string().max(100).optional(),
|
institution: z.string().max(100).optional(),
|
||||||
type: z.enum(['BANK', 'CREDIT_CARD']),
|
type: z.enum(['BANK', 'CREDIT_CARD', 'INVESTMENT']),
|
||||||
currency: z.string().length(3).default('USD'),
|
currency: z.string().length(3).default('USD'),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user