Compare commits
38 Commits
55debd082b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1400aa99d6 | |||
| 948ac2afe6 | |||
| 705a23c520 | |||
| a472749b21 | |||
| 99e41aab78 | |||
| 038539c191 | |||
| decfb19ec6 | |||
| 3c13ae3597 | |||
| 6f1376cc53 | |||
| 34bf24b35d | |||
| 587ac19b18 | |||
| 00d4796008 | |||
| 5a795d7e93 | |||
| 60dabb6264 | |||
| da938c1fcf | |||
| 0cf4612106 | |||
| 0ea6a7c698 | |||
| 62ca178308 | |||
| a9c12b94e1 | |||
| 1a984d1eac | |||
| c2b9184f2c | |||
| 8c0e4ad684 | |||
| 2a63b9120e | |||
| e6f5d5a33b | |||
| 7ccd64a7bb | |||
| d67a80b413 | |||
| 91a33cdfec | |||
| d865b02752 | |||
| f4216815e8 | |||
|
|
60fc836b73 | ||
|
|
efe42ac366 | ||
|
|
42f1d34ddd | ||
|
|
adb5d144a0 | ||
|
|
2e264014b6 | ||
|
|
8b0fba5014 | ||
|
|
874b022139 | ||
|
|
d41ab0c4e8 | ||
|
|
0b4f9f5c0e |
20
README.md
20
README.md
@@ -26,14 +26,19 @@ Edit `.env`:
|
|||||||
- Change `POSTGRES_PASSWORD` to a strong password
|
- Change `POSTGRES_PASSWORD` to a strong password
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# 2. Build and start the stack
|
# 2. Build the images
|
||||||
docker compose up --build -d
|
docker compose build
|
||||||
|
|
||||||
# 3. First-run only: apply the schema and create your user
|
# 3. Start the database
|
||||||
docker compose exec app npx prisma db push
|
docker compose up db -d
|
||||||
docker compose exec app npx prisma db seed
|
|
||||||
|
|
||||||
# 4. Open the app
|
# 4. First-run only: apply the schema and create your user
|
||||||
|
docker compose --profile setup run --rm setup
|
||||||
|
|
||||||
|
# 5. Start the app
|
||||||
|
docker compose up app -d
|
||||||
|
|
||||||
|
# 6. Open the app
|
||||||
open http://localhost:3000
|
open http://localhost:3000
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -65,8 +70,7 @@ docker compose down
|
|||||||
docker compose down -v
|
docker compose down -v
|
||||||
|
|
||||||
# Re-seed after a wipe
|
# Re-seed after a wipe
|
||||||
docker compose exec app npx prisma db push
|
docker compose --profile setup run --rm setup
|
||||||
docker compose exec app npx prisma db seed
|
|
||||||
|
|
||||||
# Check health
|
# Check health
|
||||||
curl http://localhost:3000/api/health
|
curl http://localhost:3000/api/health
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: ${POSTGRES_USER}
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
@@ -17,8 +18,9 @@ services:
|
|||||||
|
|
||||||
app:
|
app:
|
||||||
build: .
|
build: .
|
||||||
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:3000:3000"
|
- "0.0.0.0:3000:3000"
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||||
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
|
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
|
||||||
@@ -33,5 +35,23 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
start_period: 60s
|
start_period: 60s
|
||||||
|
|
||||||
|
# First-run setup: applies the schema and seeds the user.
|
||||||
|
# Uses the builder stage so all dev tools (prisma CLI, tsx, bcryptjs) are available.
|
||||||
|
# Run once with: docker compose --profile setup run --rm setup
|
||||||
|
setup:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
target: builder
|
||||||
|
command: sh -c "npx prisma db push && npx prisma db seed"
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||||
|
SEED_EMAIL: ${SEED_EMAIL}
|
||||||
|
SEED_PASSWORD: ${SEED_PASSWORD}
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
profiles:
|
||||||
|
- setup
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export default defineConfig({
|
|||||||
schema: "prisma/schema.prisma",
|
schema: "prisma/schema.prisma",
|
||||||
migrations: {
|
migrations: {
|
||||||
path: "prisma/migrations",
|
path: "prisma/migrations",
|
||||||
|
seed: "tsx prisma/seed.ts",
|
||||||
},
|
},
|
||||||
datasource: {
|
datasource: {
|
||||||
url: process.env["DATABASE_URL"],
|
url: process.env["DATABASE_URL"],
|
||||||
|
|||||||
@@ -15,11 +15,14 @@ model User {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
accounts Account[]
|
accounts Account[]
|
||||||
budgets Budget[]
|
budgets Budget[]
|
||||||
|
budgetRules BudgetRule[]
|
||||||
|
transferRules TransferRule[]
|
||||||
}
|
}
|
||||||
|
|
||||||
enum AccountType {
|
enum AccountType {
|
||||||
BANK
|
BANK
|
||||||
CREDIT_CARD
|
CREDIT_CARD
|
||||||
|
INVESTMENT
|
||||||
}
|
}
|
||||||
|
|
||||||
model Account {
|
model Account {
|
||||||
@@ -43,6 +46,7 @@ model Account {
|
|||||||
enum TransactionType {
|
enum TransactionType {
|
||||||
DEBIT
|
DEBIT
|
||||||
CREDIT
|
CREDIT
|
||||||
|
TRANSFER
|
||||||
}
|
}
|
||||||
|
|
||||||
model Transaction {
|
model Transaction {
|
||||||
@@ -59,11 +63,9 @@ model Transaction {
|
|||||||
type TransactionType
|
type TransactionType
|
||||||
category String?
|
category String?
|
||||||
notes String?
|
notes String?
|
||||||
dedupeHash String
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@unique([dedupeHash])
|
|
||||||
@@index([accountId, date])
|
@@index([accountId, date])
|
||||||
@@index([date])
|
@@index([date])
|
||||||
@@index([budgetId])
|
@@index([budgetId])
|
||||||
@@ -80,6 +82,30 @@ model Budget {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
transactions Transaction[]
|
transactions Transaction[]
|
||||||
|
rules BudgetRule[]
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model BudgetRule {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
budgetId String
|
||||||
|
budget Budget @relation(fields: [budgetId], references: [id], onDelete: Cascade)
|
||||||
|
pattern String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([budgetId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model TransferRule {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
pattern String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
}
|
}
|
||||||
@@ -87,7 +113,7 @@ model Budget {
|
|||||||
model CsvUpload {
|
model CsvUpload {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
accountId String
|
accountId String
|
||||||
account Account @relation(fields: [accountId], references: [id])
|
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
|
||||||
fileName String
|
fileName String
|
||||||
rowCount Int
|
rowCount Int
|
||||||
importedCount Int
|
importedCount Int
|
||||||
|
|||||||
@@ -1,34 +1,55 @@
|
|||||||
import { auth } from '@/lib/auth'
|
import { auth } from '@/lib/auth'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { monthBounds } from '@/lib/utils/dates'
|
||||||
import { BudgetList } from '@/components/budgets/BudgetList'
|
import { BudgetList } from '@/components/budgets/BudgetList'
|
||||||
|
import { MonthYearPicker } from '@/components/dashboard/MonthYearPicker'
|
||||||
|
|
||||||
export default async function BudgetsPage() {
|
type SearchParams = Promise<Record<string, string | string[] | undefined>>
|
||||||
|
|
||||||
|
export default async function BudgetsPage({ searchParams }: { searchParams: SearchParams }) {
|
||||||
const session = await auth()
|
const session = await auth()
|
||||||
if (!session?.user?.id) return null
|
if (!session?.user?.id) return null
|
||||||
|
|
||||||
const now = new Date()
|
const userId = session.user.id
|
||||||
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)
|
const sp = await searchParams
|
||||||
const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999)
|
const get = (k: string) => (Array.isArray(sp[k]) ? sp[k][0] : sp[k]) ?? ''
|
||||||
|
|
||||||
const [budgets, spendRows] = await Promise.all([
|
const now = new Date()
|
||||||
|
const month = Number(get('month')) || (now.getMonth() + 1)
|
||||||
|
const year = Number(get('year')) || now.getFullYear()
|
||||||
|
const selectedDate = new Date(year, month - 1, 1)
|
||||||
|
const { start, end } = monthBounds(selectedDate)
|
||||||
|
|
||||||
|
const [budgets, spendRows, rules] = await Promise.all([
|
||||||
prisma.budget.findMany({
|
prisma.budget.findMany({
|
||||||
where: { userId: session.user.id },
|
where: { userId },
|
||||||
orderBy: { createdAt: 'asc' },
|
orderBy: { createdAt: 'asc' },
|
||||||
}),
|
}),
|
||||||
prisma.$queryRaw<{ budgetId: string; total: bigint }[]>`
|
prisma.$queryRaw<{ budgetId: string; total: bigint }[]>`
|
||||||
SELECT t."budgetId", COALESCE(SUM(t."amountCents"), 0)::bigint AS total
|
SELECT t."budgetId",
|
||||||
|
COALESCE(SUM(CASE WHEN t.type = 'DEBIT' THEN t."amountCents" ELSE -t."amountCents" END), 0)::bigint AS total
|
||||||
FROM "Transaction" t
|
FROM "Transaction" t
|
||||||
JOIN "Account" a ON t."accountId" = a.id
|
JOIN "Account" a ON t."accountId" = a.id
|
||||||
WHERE a."userId" = ${session.user.id}
|
WHERE a."userId" = ${userId}
|
||||||
AND t."budgetId" IS NOT NULL
|
AND t."budgetId" IS NOT NULL
|
||||||
AND t.type = 'DEBIT'
|
AND t.date >= ${start}
|
||||||
AND t.date >= ${monthStart}
|
AND t.date <= ${end}
|
||||||
AND t.date <= ${monthEnd}
|
|
||||||
GROUP BY t."budgetId"
|
GROUP BY t."budgetId"
|
||||||
`,
|
`,
|
||||||
|
prisma.budgetRule.findMany({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
select: { id: true, budgetId: true, pattern: true },
|
||||||
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
const spendMap = new Map(spendRows.map((r) => [r.budgetId, Number(r.total)]))
|
const spendMap = new Map(spendRows.map((r) => [r.budgetId, Number(r.total)]))
|
||||||
|
const rulesMap = new Map<string, { id: string; pattern: string }[]>()
|
||||||
|
for (const rule of rules) {
|
||||||
|
const existing = rulesMap.get(rule.budgetId) ?? []
|
||||||
|
existing.push({ id: rule.id, pattern: rule.pattern })
|
||||||
|
rulesMap.set(rule.budgetId, existing)
|
||||||
|
}
|
||||||
|
|
||||||
const budgetsWithSpend = budgets.map((b) => ({
|
const budgetsWithSpend = budgets.map((b) => ({
|
||||||
id: b.id,
|
id: b.id,
|
||||||
@@ -37,10 +58,15 @@ export default async function BudgetsPage() {
|
|||||||
color: b.color,
|
color: b.color,
|
||||||
isActive: b.isActive,
|
isActive: b.isActive,
|
||||||
spendCents: spendMap.get(b.id) ?? 0,
|
spendCents: spendMap.get(b.id) ?? 0,
|
||||||
|
rules: rulesMap.get(b.id) ?? [],
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-6 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold">Budgets</h1>
|
||||||
|
<MonthYearPicker />
|
||||||
|
</div>
|
||||||
<BudgetList budgets={budgetsWithSpend} />
|
<BudgetList budgets={budgetsWithSpend} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,26 +5,34 @@ import { NetWorthCard } from '@/components/dashboard/NetWorthCard'
|
|||||||
import { CashFlowCard } from '@/components/dashboard/CashFlowCard'
|
import { CashFlowCard } from '@/components/dashboard/CashFlowCard'
|
||||||
import { RecentTransactions } from '@/components/dashboard/RecentTransactions'
|
import { RecentTransactions } from '@/components/dashboard/RecentTransactions'
|
||||||
import { BudgetSummary } from '@/components/dashboard/BudgetSummary'
|
import { BudgetSummary } from '@/components/dashboard/BudgetSummary'
|
||||||
|
import { MonthYearPicker } from '@/components/dashboard/MonthYearPicker'
|
||||||
|
|
||||||
export default async function DashboardPage() {
|
type SearchParams = Promise<Record<string, string | string[] | undefined>>
|
||||||
|
|
||||||
|
export default async function DashboardPage({ searchParams }: { searchParams: SearchParams }) {
|
||||||
const session = await auth()
|
const session = await auth()
|
||||||
if (!session?.user?.id) return null
|
if (!session?.user?.id) return null
|
||||||
|
|
||||||
const userId = session.user.id
|
const userId = session.user.id
|
||||||
const { start, end } = monthBounds()
|
const sp = await searchParams
|
||||||
|
const get = (k: string) => (Array.isArray(sp[k]) ? sp[k][0] : sp[k]) ?? ''
|
||||||
|
|
||||||
const [accounts, cashFlowRows, budgets, spendRows, recentTx] = await Promise.all([
|
const now = new Date()
|
||||||
prisma.account.findMany({
|
const month = Number(get('month')) || (now.getMonth() + 1)
|
||||||
where: { userId, isActive: true },
|
const year = Number(get('year')) || now.getFullYear()
|
||||||
select: { id: true, name: true, type: true, currentBalanceCents: true },
|
const isCurrentMonth = month === (now.getMonth() + 1) && year === now.getFullYear()
|
||||||
orderBy: { name: 'asc' },
|
|
||||||
}),
|
const selectedDate = new Date(year, month - 1, 1)
|
||||||
|
const { start, end } = monthBounds(selectedDate)
|
||||||
|
|
||||||
|
const [cashFlowRows, budgets, spendRows, recentTx] = await Promise.all([
|
||||||
prisma.$queryRaw<{ type: string; total: bigint }[]>`
|
prisma.$queryRaw<{ type: string; total: bigint }[]>`
|
||||||
SELECT t.type, COALESCE(SUM(t."amountCents"), 0)::bigint AS total
|
SELECT t.type, COALESCE(SUM(t."amountCents"), 0)::bigint AS total
|
||||||
FROM "Transaction" t
|
FROM "Transaction" t
|
||||||
JOIN "Account" a ON t."accountId" = a.id
|
JOIN "Account" a ON t."accountId" = a.id
|
||||||
WHERE a."userId" = ${userId}
|
WHERE a."userId" = ${userId}
|
||||||
AND a.type = 'BANK'
|
AND a.type = 'BANK'
|
||||||
|
AND t.type != 'TRANSFER'
|
||||||
AND t.date >= ${start}
|
AND t.date >= ${start}
|
||||||
AND t.date <= ${end}
|
AND t.date <= ${end}
|
||||||
GROUP BY t.type
|
GROUP BY t.type
|
||||||
@@ -34,26 +42,56 @@ export default async function DashboardPage() {
|
|||||||
orderBy: { name: 'asc' },
|
orderBy: { name: 'asc' },
|
||||||
}),
|
}),
|
||||||
prisma.$queryRaw<{ budgetId: string; total: bigint }[]>`
|
prisma.$queryRaw<{ budgetId: string; total: bigint }[]>`
|
||||||
SELECT t."budgetId", COALESCE(SUM(t."amountCents"), 0)::bigint AS total
|
SELECT t."budgetId",
|
||||||
|
COALESCE(SUM(CASE WHEN t.type = 'DEBIT' THEN t."amountCents" ELSE -t."amountCents" END), 0)::bigint AS total
|
||||||
FROM "Transaction" t
|
FROM "Transaction" t
|
||||||
JOIN "Account" a ON t."accountId" = a.id
|
JOIN "Account" a ON t."accountId" = a.id
|
||||||
WHERE a."userId" = ${userId}
|
WHERE a."userId" = ${userId}
|
||||||
AND t."budgetId" IS NOT NULL
|
AND t."budgetId" IS NOT NULL
|
||||||
AND t.type = 'DEBIT'
|
|
||||||
AND t.date >= ${start}
|
AND t.date >= ${start}
|
||||||
AND t.date <= ${end}
|
AND t.date <= ${end}
|
||||||
GROUP BY t."budgetId"
|
GROUP BY t."budgetId"
|
||||||
`,
|
`,
|
||||||
prisma.transaction.findMany({
|
prisma.transaction.findMany({
|
||||||
where: { account: { userId } },
|
where: {
|
||||||
|
account: { userId },
|
||||||
|
date: { gte: start, lte: end },
|
||||||
|
},
|
||||||
include: { account: { select: { name: true } } },
|
include: { account: { select: { name: true } } },
|
||||||
orderBy: { date: 'desc' },
|
orderBy: { date: 'desc' },
|
||||||
take: 5,
|
take: 5,
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
const bankAccounts = accounts.filter((a) => a.type === 'BANK')
|
// Net worth: live balances for current month, snapshots for past months
|
||||||
const netWorthCents = bankAccounts.reduce((s, a) => s + a.currentBalanceCents, 0)
|
let netWorthCents: number
|
||||||
|
let netWorthAccounts: { id: string; name: string; balanceCents: number }[]
|
||||||
|
|
||||||
|
if (isCurrentMonth) {
|
||||||
|
const accounts = await prisma.account.findMany({
|
||||||
|
where: { userId, isActive: true, type: { in: ['BANK', 'INVESTMENT'] } },
|
||||||
|
select: { id: true, name: true, currentBalanceCents: true },
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
})
|
||||||
|
netWorthAccounts = accounts.map((a) => ({ id: a.id, name: a.name, balanceCents: a.currentBalanceCents }))
|
||||||
|
netWorthCents = netWorthAccounts.reduce((s, a) => s + a.balanceCents, 0)
|
||||||
|
} else {
|
||||||
|
const snapshots = await prisma.$queryRaw<{ accountId: string; name: string; balanceCents: bigint }[]>`
|
||||||
|
SELECT bs."accountId", a.name, bs."balanceCents"
|
||||||
|
FROM "BalanceSnapshot" bs
|
||||||
|
JOIN "Account" a ON bs."accountId" = a.id
|
||||||
|
WHERE a."userId" = ${userId}
|
||||||
|
AND a.type IN ('BANK', 'INVESTMENT')
|
||||||
|
AND bs.year = ${year}
|
||||||
|
AND bs.month = ${month}
|
||||||
|
`
|
||||||
|
netWorthAccounts = snapshots.map((s) => ({
|
||||||
|
id: s.accountId,
|
||||||
|
name: s.name,
|
||||||
|
balanceCents: Number(s.balanceCents),
|
||||||
|
}))
|
||||||
|
netWorthCents = netWorthAccounts.reduce((s, a) => s + a.balanceCents, 0)
|
||||||
|
}
|
||||||
|
|
||||||
const cfMap = Object.fromEntries(cashFlowRows.map((r) => [r.type, Number(r.total)]))
|
const cfMap = Object.fromEntries(cashFlowRows.map((r) => [r.type, Number(r.total)]))
|
||||||
const creditsCents = cfMap['CREDIT'] ?? 0
|
const creditsCents = cfMap['CREDIT'] ?? 0
|
||||||
@@ -80,15 +118,15 @@ export default async function DashboardPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
<div>
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold">Dashboard</h1>
|
<h1 className="text-2xl font-bold">Dashboard</h1>
|
||||||
<p className="text-sm text-muted-foreground">{monthLabel()}</p>
|
<MonthYearPicker />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<NetWorthCard netWorthCents={netWorthCents} bankAccounts={bankAccounts} />
|
<NetWorthCard netWorthCents={netWorthCents} bankAccounts={netWorthAccounts} />
|
||||||
<CashFlowCard
|
<CashFlowCard
|
||||||
monthLabel={monthLabel()}
|
monthLabel={monthLabel(selectedDate)}
|
||||||
creditsCents={creditsCents}
|
creditsCents={creditsCents}
|
||||||
debitsCents={debitsCents}
|
debitsCents={debitsCents}
|
||||||
netCents={creditsCents - debitsCents}
|
netCents={creditsCents - debitsCents}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { prisma } from '@/lib/prisma'
|
|||||||
import { Prisma } from '@/generated/prisma/client'
|
import { Prisma } from '@/generated/prisma/client'
|
||||||
import { TransactionFilters } from '@/components/transactions/TransactionFilters'
|
import { TransactionFilters } from '@/components/transactions/TransactionFilters'
|
||||||
import { TransactionTable } from '@/components/transactions/TransactionTable'
|
import { TransactionTable } from '@/components/transactions/TransactionTable'
|
||||||
|
import { TransferRulesButton } from '@/components/transactions/TransferRulesButton'
|
||||||
|
|
||||||
const PAGE_LIMIT = 50
|
const PAGE_LIMIT = 50
|
||||||
|
|
||||||
@@ -35,7 +36,7 @@ export default async function TransactionsPage({ searchParams }: { searchParams:
|
|||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|
||||||
const [transactions, total, accounts, budgets] = await Promise.all([
|
const [transactions, total, accounts, budgets, transferRules] = await Promise.all([
|
||||||
prisma.transaction.findMany({
|
prisma.transaction.findMany({
|
||||||
where,
|
where,
|
||||||
include: {
|
include: {
|
||||||
@@ -57,6 +58,11 @@ export default async function TransactionsPage({ searchParams }: { searchParams:
|
|||||||
select: { id: true, name: true, color: true },
|
select: { id: true, name: true, color: true },
|
||||||
orderBy: { name: 'asc' },
|
orderBy: { name: 'asc' },
|
||||||
}),
|
}),
|
||||||
|
prisma.transferRule.findMany({
|
||||||
|
where: { userId: session.user.id },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
select: { id: true, pattern: true },
|
||||||
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
// Serialize dates for client components
|
// Serialize dates for client components
|
||||||
@@ -69,7 +75,10 @@ export default async function TransactionsPage({ searchParams }: { searchParams:
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<h1 className="text-2xl font-bold mb-4">Transactions</h1>
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h1 className="text-2xl font-bold">Transactions</h1>
|
||||||
|
<TransferRulesButton rules={transferRules} />
|
||||||
|
</div>
|
||||||
<TransactionFilters accounts={accounts} />
|
<TransactionFilters accounts={accounts} />
|
||||||
<TransactionTable
|
<TransactionTable
|
||||||
transactions={rows}
|
transactions={rows}
|
||||||
|
|||||||
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 })
|
||||||
|
}
|
||||||
@@ -58,6 +58,7 @@ export async function DELETE(_req: Request, { params }: Params) {
|
|||||||
})
|
})
|
||||||
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||||
|
|
||||||
|
await prisma.csvUpload.deleteMany({ where: { accountId: id } })
|
||||||
await prisma.account.delete({ where: { id } })
|
await prisma.account.delete({ where: { id } })
|
||||||
return new NextResponse(null, { status: 204 })
|
return new NextResponse(null, { status: 204 })
|
||||||
}
|
}
|
||||||
|
|||||||
42
src/app/api/admin/recalculate-balances/route.ts
Normal file
42
src/app/api/admin/recalculate-balances/route.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { auth } from '@/lib/auth'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
|
||||||
|
const userId = session.user.id
|
||||||
|
|
||||||
|
const accounts = await prisma.account.findMany({
|
||||||
|
where: { userId },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const account of accounts) {
|
||||||
|
const [balRow] = await prisma.$queryRaw<[{ balance: bigint }]>`
|
||||||
|
SELECT COALESCE(SUM(
|
||||||
|
CASE WHEN type = 'CREDIT' THEN "amountCents" ELSE -"amountCents" END
|
||||||
|
), 0)::bigint AS balance
|
||||||
|
FROM "Transaction"
|
||||||
|
WHERE "accountId" = ${account.id}
|
||||||
|
`
|
||||||
|
await prisma.account.update({
|
||||||
|
where: { id: account.id },
|
||||||
|
data: { currentBalanceCents: Number(balRow.balance) },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove all snapshots for accounts that now have no transactions
|
||||||
|
await prisma.$executeRaw`
|
||||||
|
DELETE FROM "BalanceSnapshot"
|
||||||
|
WHERE "accountId" IN (
|
||||||
|
SELECT id FROM "Account" WHERE "userId" = ${userId}
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM "Transaction" WHERE "accountId" = "BalanceSnapshot"."accountId"
|
||||||
|
)
|
||||||
|
`
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true, accounts: accounts.length })
|
||||||
|
}
|
||||||
16
src/app/api/budget-rules/[id]/route.ts
Normal file
16
src/app/api/budget-rules/[id]/route.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { auth } from '@/lib/auth'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
|
export async function DELETE(_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 rule = await prisma.budgetRule.findFirst({ where: { id, userId: session.user.id } })
|
||||||
|
if (!rule) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||||
|
|
||||||
|
await prisma.budgetRule.delete({ where: { id } })
|
||||||
|
return new NextResponse(null, { status: 204 })
|
||||||
|
}
|
||||||
37
src/app/api/budget-rules/route.ts
Normal file
37
src/app/api/budget-rules/route.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { auth } from '@/lib/auth'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { createBudgetRuleSchema } from '@/lib/validations/budget-rule'
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
|
||||||
|
const rules = await prisma.budgetRule.findMany({
|
||||||
|
where: { userId: session.user.id },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
select: { id: true, budgetId: true, pattern: true },
|
||||||
|
})
|
||||||
|
return NextResponse.json(rules)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
|
||||||
|
const body = await req.json()
|
||||||
|
const parsed = createBudgetRuleSchema.safeParse(body)
|
||||||
|
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 })
|
||||||
|
|
||||||
|
const { budgetId, pattern } = parsed.data
|
||||||
|
|
||||||
|
// Verify budget belongs to this user
|
||||||
|
const budget = await prisma.budget.findFirst({ where: { id: budgetId, userId: session.user.id } })
|
||||||
|
if (!budget) return NextResponse.json({ error: 'Budget not found' }, { status: 404 })
|
||||||
|
|
||||||
|
const rule = await prisma.budgetRule.create({
|
||||||
|
data: { userId: session.user.id, budgetId, pattern },
|
||||||
|
select: { id: true, budgetId: true, pattern: true },
|
||||||
|
})
|
||||||
|
return NextResponse.json(rule, { status: 201 })
|
||||||
|
}
|
||||||
132
src/app/api/transactions/bulk/route.ts
Normal file
132
src/app/api/transactions/bulk/route.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { auth } from '@/lib/auth'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
|
const bulkSchema = z.discriminatedUnion('action', [
|
||||||
|
z.object({
|
||||||
|
action: z.literal('delete'),
|
||||||
|
ids: z.array(z.string()).min(1),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
action: z.literal('assignBudget'),
|
||||||
|
ids: z.array(z.string()).min(1),
|
||||||
|
budgetId: z.string().nullable(),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
action: z.literal('addNotes'),
|
||||||
|
ids: z.array(z.string()).min(1),
|
||||||
|
notes: z.string().max(500),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
action: z.literal('markTransfer'),
|
||||||
|
ids: z.array(z.string()).min(1),
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
async function recomputeAccount(accountId: string) {
|
||||||
|
const [balRow] = await prisma.$queryRaw<[{ balance: bigint }]>`
|
||||||
|
SELECT COALESCE(SUM(
|
||||||
|
CASE WHEN type = 'CREDIT' THEN "amountCents" ELSE -"amountCents" END
|
||||||
|
), 0)::bigint AS balance
|
||||||
|
FROM "Transaction"
|
||||||
|
WHERE "accountId" = ${accountId}
|
||||||
|
`
|
||||||
|
await prisma.account.update({
|
||||||
|
where: { id: accountId },
|
||||||
|
data: { currentBalanceCents: Number(balRow.balance) },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recomputeSnapshot(accountId: string, year: number, month: number) {
|
||||||
|
const endOfMonth = new Date(year, month, 0, 23, 59, 59, 999)
|
||||||
|
const [snap] = await prisma.$queryRaw<[{ balance: bigint }]>`
|
||||||
|
SELECT COALESCE(SUM(
|
||||||
|
CASE WHEN type = 'CREDIT' THEN "amountCents" ELSE -"amountCents" END
|
||||||
|
), 0)::bigint AS balance
|
||||||
|
FROM "Transaction"
|
||||||
|
WHERE "accountId" = ${accountId}
|
||||||
|
AND date <= ${endOfMonth}
|
||||||
|
`
|
||||||
|
const balanceCents = Number(snap.balance)
|
||||||
|
if (balanceCents === 0) {
|
||||||
|
await prisma.balanceSnapshot.deleteMany({
|
||||||
|
where: { accountId, year, month },
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await prisma.balanceSnapshot.upsert({
|
||||||
|
where: { accountId_year_month: { accountId, year, month } },
|
||||||
|
update: { balanceCents, computedAt: new Date() },
|
||||||
|
create: { accountId, year, month, balanceCents },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
|
||||||
|
const body = await req.json()
|
||||||
|
const result = bulkSchema.safeParse(body)
|
||||||
|
if (!result.success) return NextResponse.json({ error: result.error.flatten() }, { status: 400 })
|
||||||
|
|
||||||
|
const { action, ids } = result.data
|
||||||
|
const userId = session.user.id
|
||||||
|
|
||||||
|
// Verify all transaction IDs belong to this user
|
||||||
|
const owned = await prisma.transaction.findMany({
|
||||||
|
where: { id: { in: ids }, account: { userId } },
|
||||||
|
select: { id: true, accountId: true, date: true },
|
||||||
|
})
|
||||||
|
if (owned.length !== ids.length) {
|
||||||
|
return NextResponse.json({ error: 'One or more transactions not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'delete') {
|
||||||
|
// Collect affected account+month combos before deleting
|
||||||
|
const accountIds = [...new Set(owned.map((t) => t.accountId))]
|
||||||
|
const monthKeys = new Map<string, { accountId: string; year: number; month: number }>()
|
||||||
|
for (const t of owned) {
|
||||||
|
const y = t.date.getFullYear()
|
||||||
|
const m = t.date.getMonth() + 1
|
||||||
|
monthKeys.set(`${t.accountId}-${y}-${m}`, { accountId: t.accountId, year: y, month: m })
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.transaction.deleteMany({ where: { id: { in: ids } } })
|
||||||
|
|
||||||
|
// Recompute current balance and snapshots for affected accounts/months
|
||||||
|
await Promise.all(accountIds.map(recomputeAccount))
|
||||||
|
await Promise.all([...monthKeys.values()].map((k) => recomputeSnapshot(k.accountId, k.year, k.month)))
|
||||||
|
|
||||||
|
return NextResponse.json({ deleted: ids.length })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'assignBudget') {
|
||||||
|
const { budgetId } = result.data
|
||||||
|
if (budgetId !== null) {
|
||||||
|
const budget = await prisma.budget.findFirst({ where: { id: budgetId, userId } })
|
||||||
|
if (!budget) return NextResponse.json({ error: 'Budget not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
await prisma.transaction.updateMany({
|
||||||
|
where: { id: { in: ids } },
|
||||||
|
data: { budgetId },
|
||||||
|
})
|
||||||
|
return NextResponse.json({ updated: ids.length })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'addNotes') {
|
||||||
|
const { notes } = result.data
|
||||||
|
await prisma.transaction.updateMany({
|
||||||
|
where: { id: { in: ids } },
|
||||||
|
data: { notes: notes || null },
|
||||||
|
})
|
||||||
|
return NextResponse.json({ updated: ids.length })
|
||||||
|
}
|
||||||
|
|
||||||
|
// markTransfer
|
||||||
|
await prisma.transaction.updateMany({
|
||||||
|
where: { id: { in: ids } },
|
||||||
|
data: { type: 'TRANSFER' },
|
||||||
|
})
|
||||||
|
return NextResponse.json({ updated: ids.length })
|
||||||
|
}
|
||||||
19
src/app/api/transfer-rules/[id]/route.ts
Normal file
19
src/app/api/transfer-rules/[id]/route.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { auth } from '@/lib/auth'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
|
type Params = { params: Promise<{ id: string }> }
|
||||||
|
|
||||||
|
export async function DELETE(_req: Request, { params }: Params) {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
const rule = await prisma.transferRule.findFirst({
|
||||||
|
where: { id, userId: session.user.id },
|
||||||
|
})
|
||||||
|
if (!rule) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||||
|
|
||||||
|
await prisma.transferRule.delete({ where: { id } })
|
||||||
|
return new NextResponse(null, { status: 204 })
|
||||||
|
}
|
||||||
34
src/app/api/transfer-rules/route.ts
Normal file
34
src/app/api/transfer-rules/route.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { auth } from '@/lib/auth'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
pattern: z.string().min(1).max(200).trim(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
|
||||||
|
const rules = await prisma.transferRule.findMany({
|
||||||
|
where: { userId: session.user.id },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
select: { id: true, pattern: true },
|
||||||
|
})
|
||||||
|
return NextResponse.json(rules)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
|
||||||
|
const result = schema.safeParse(await req.json())
|
||||||
|
if (!result.success) return NextResponse.json({ error: result.error.flatten() }, { status: 400 })
|
||||||
|
|
||||||
|
const rule = await prisma.transferRule.create({
|
||||||
|
data: { userId: session.user.id, pattern: result.data.pattern },
|
||||||
|
select: { id: true, pattern: true },
|
||||||
|
})
|
||||||
|
return NextResponse.json(rule, { status: 201 })
|
||||||
|
}
|
||||||
@@ -74,8 +74,42 @@ export async function POST(req: Request) {
|
|||||||
config = result.data
|
config = result.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For all BANK accounts using a single amount column, positive = deposit (CREDIT),
|
||||||
|
// negative = withdrawal (DEBIT). Override whatever the profile or manual mapping says.
|
||||||
|
if (account.type === 'BANK' && config.strategy === 'A') {
|
||||||
|
config = { ...config, invertAmountSign: true }
|
||||||
|
}
|
||||||
|
|
||||||
const normalized = normalizeRows(allRows, accountId, config)
|
const normalized = normalizeRows(allRows, accountId, config)
|
||||||
|
|
||||||
|
// Apply transfer rules first, then budget rules (transfer takes priority)
|
||||||
|
const [budgetRules, transferRules] = await Promise.all([
|
||||||
|
prisma.budgetRule.findMany({
|
||||||
|
where: { userId: session.user.id },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
select: { pattern: true, budgetId: true },
|
||||||
|
}),
|
||||||
|
prisma.transferRule.findMany({
|
||||||
|
where: { userId: session.user.id },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
select: { pattern: true },
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
const rowsWithBudgets = normalized.map((row) => {
|
||||||
|
const desc = row.description.toLowerCase()
|
||||||
|
for (const rule of transferRules) {
|
||||||
|
if (desc.includes(rule.pattern.toLowerCase())) {
|
||||||
|
return { ...row, type: 'TRANSFER' as const, budgetId: null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const rule of budgetRules) {
|
||||||
|
if (desc.includes(rule.pattern.toLowerCase())) {
|
||||||
|
return { ...row, budgetId: rule.budgetId }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ...row, budgetId: null }
|
||||||
|
})
|
||||||
|
|
||||||
const upload = await prisma.csvUpload.create({
|
const upload = await prisma.csvUpload.create({
|
||||||
data: {
|
data: {
|
||||||
accountId,
|
accountId,
|
||||||
@@ -89,17 +123,18 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const { count: importedCount } = await prisma.transaction.createMany({
|
const { count: importedCount } = await prisma.transaction.createMany({
|
||||||
data: normalized.map((r) => ({
|
data: rowsWithBudgets.map((r) => ({
|
||||||
accountId,
|
accountId,
|
||||||
uploadId: upload.id,
|
uploadId: upload.id,
|
||||||
date: r.date,
|
date: r.date,
|
||||||
description: r.description,
|
description: r.description,
|
||||||
amountCents: r.amountCents,
|
amountCents: r.amountCents,
|
||||||
type: r.type,
|
type: r.type,
|
||||||
dedupeHash: r.dedupeHash,
|
category: r.category ?? null,
|
||||||
|
budgetId: r.budgetId,
|
||||||
})),
|
})),
|
||||||
skipDuplicates: true,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const skippedCount = normalized.length - importedCount
|
const skippedCount = normalized.length - importedCount
|
||||||
|
|
||||||
// Recompute current balance
|
// Recompute current balance
|
||||||
@@ -118,7 +153,7 @@ export async function POST(req: Request) {
|
|||||||
// Upsert balance snapshots for each affected month
|
// Upsert balance snapshots for each affected month
|
||||||
const months = [
|
const months = [
|
||||||
...new Map(
|
...new Map(
|
||||||
normalized.map((r) => {
|
rowsWithBudgets.map((r) => {
|
||||||
const y = r.date.getFullYear()
|
const y = r.date.getFullYear()
|
||||||
const m = r.date.getMonth() + 1
|
const m = r.date.getMonth() + 1
|
||||||
return [`${y}-${m}`, { year: y, month: m }]
|
return [`${y}-${m}`, { year: y, month: m }]
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,11 +7,17 @@ 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, ListFilter } from 'lucide-react'
|
||||||
import { BudgetProgress } from './BudgetProgress'
|
import { BudgetProgress } from './BudgetProgress'
|
||||||
import { CreateBudgetDialog } from './CreateBudgetDialog'
|
import { CreateBudgetDialog } from './CreateBudgetDialog'
|
||||||
|
import { BudgetRulesDialog } from './BudgetRulesDialog'
|
||||||
import { formatCents } from '@/lib/utils/currency'
|
import { formatCents } from '@/lib/utils/currency'
|
||||||
|
|
||||||
|
export interface BudgetRule {
|
||||||
|
id: string
|
||||||
|
pattern: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface BudgetWithSpend {
|
export interface BudgetWithSpend {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
@@ -19,11 +25,13 @@ export interface BudgetWithSpend {
|
|||||||
color: string | null
|
color: string | null
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
spendCents: number
|
spendCents: number
|
||||||
|
rules?: BudgetRule[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BudgetCard({ budget }: { budget: BudgetWithSpend }) {
|
export function BudgetCard({ budget }: { budget: BudgetWithSpend }) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [editOpen, setEditOpen] = useState(false)
|
const [editOpen, setEditOpen] = useState(false)
|
||||||
|
const [rulesOpen, setRulesOpen] = useState(false)
|
||||||
|
|
||||||
async function handleToggle() {
|
async function handleToggle() {
|
||||||
await fetch(`/api/budgets/${budget.id}`, {
|
await fetch(`/api/budgets/${budget.id}`, {
|
||||||
@@ -62,6 +70,9 @@ export function BudgetCard({ budget }: { budget: BudgetWithSpend }) {
|
|||||||
<DropdownMenuItem onClick={() => setEditOpen(true)}>
|
<DropdownMenuItem onClick={() => setEditOpen(true)}>
|
||||||
<Pencil className="h-4 w-4 mr-2" />Edit
|
<Pencil className="h-4 w-4 mr-2" />Edit
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setRulesOpen(true)}>
|
||||||
|
<ListFilter className="h-4 w-4 mr-2" />Rules
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={handleToggle}>
|
<DropdownMenuItem onClick={handleToggle}>
|
||||||
{budget.isActive
|
{budget.isActive
|
||||||
? <><EyeOff className="h-4 w-4 mr-2" />Deactivate</>
|
? <><EyeOff className="h-4 w-4 mr-2" />Deactivate</>
|
||||||
@@ -92,7 +103,7 @@ export function BudgetCard({ budget }: { budget: BudgetWithSpend }) {
|
|||||||
{budget.limitCents ? (
|
{budget.limitCents ? (
|
||||||
<BudgetProgress spendCents={budget.spendCents} limitCents={budget.limitCents} />
|
<BudgetProgress spendCents={budget.spendCents} limitCents={budget.limitCents} />
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-muted-foreground">This month</p>
|
<p className="text-xs text-muted-foreground">All time</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!budget.isActive && (
|
{!budget.isActive && (
|
||||||
@@ -102,6 +113,13 @@ export function BudgetCard({ budget }: { budget: BudgetWithSpend }) {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<CreateBudgetDialog open={editOpen} onOpenChange={setEditOpen} budget={budget} />
|
<CreateBudgetDialog open={editOpen} onOpenChange={setEditOpen} budget={budget} />
|
||||||
|
<BudgetRulesDialog
|
||||||
|
open={rulesOpen}
|
||||||
|
onOpenChange={setRulesOpen}
|
||||||
|
budgetId={budget.id}
|
||||||
|
budgetName={budget.name}
|
||||||
|
rules={budget.rules ?? []}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
96
src/components/budgets/BudgetRulesDialog.tsx
Normal file
96
src/components/budgets/BudgetRulesDialog.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Trash2, Plus } from 'lucide-react'
|
||||||
|
|
||||||
|
interface Rule {
|
||||||
|
id: string
|
||||||
|
pattern: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
budgetId: string
|
||||||
|
budgetName: string
|
||||||
|
rules: Rule[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BudgetRulesDialog({ open, onOpenChange, budgetId, budgetName, rules }: Props) {
|
||||||
|
const router = useRouter()
|
||||||
|
const [pattern, setPattern] = useState('')
|
||||||
|
const [adding, setAdding] = useState(false)
|
||||||
|
|
||||||
|
async function handleAdd(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!pattern.trim()) return
|
||||||
|
setAdding(true)
|
||||||
|
await fetch('/api/budget-rules', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ budgetId, pattern: pattern.trim() }),
|
||||||
|
})
|
||||||
|
setPattern('')
|
||||||
|
setAdding(false)
|
||||||
|
router.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: string) {
|
||||||
|
await fetch(`/api/budget-rules/${id}`, { method: 'DELETE' })
|
||||||
|
router.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Auto-assign rules — {budgetName}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Transactions whose description contains a pattern (case-insensitive) are automatically
|
||||||
|
assigned to this budget on upload. First matching rule wins.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{rules.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-2">No rules yet.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{rules.map((rule) => (
|
||||||
|
<li
|
||||||
|
key={rule.id}
|
||||||
|
className="flex items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<span className="font-mono truncate">{rule.pattern}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(rule.id)}
|
||||||
|
className="shrink-0 text-muted-foreground hover:text-destructive transition-colors"
|
||||||
|
aria-label="Delete rule"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleAdd} className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="e.g. Amazon, Netflix, Starbucks"
|
||||||
|
value={pattern}
|
||||||
|
onChange={(e) => setPattern(e.target.value)}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button type="submit" disabled={adding || !pattern.trim()} size="sm">
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
52
src/components/dashboard/MonthYearPicker.tsx
Normal file
52
src/components/dashboard/MonthYearPicker.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useSearchParams, useRouter, usePathname } from 'next/navigation'
|
||||||
|
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
export function MonthYearPicker() {
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const router = useRouter()
|
||||||
|
const pathname = usePathname()
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const currentMonth = now.getMonth() + 1
|
||||||
|
const currentYear = now.getFullYear()
|
||||||
|
|
||||||
|
const month = Number(searchParams.get('month')) || currentMonth
|
||||||
|
const year = Number(searchParams.get('year')) || currentYear
|
||||||
|
const isCurrentMonth = month === currentMonth && year === currentYear
|
||||||
|
|
||||||
|
function navigate(delta: number) {
|
||||||
|
let m = month + delta
|
||||||
|
let y = year
|
||||||
|
if (m < 1) { m = 12; y-- }
|
||||||
|
if (m > 12) { m = 1; y++ }
|
||||||
|
const params = new URLSearchParams(searchParams.toString())
|
||||||
|
params.set('month', String(m))
|
||||||
|
params.set('year', String(y))
|
||||||
|
router.push(`${pathname}?${params.toString()}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = new Date(year, month - 1, 1).toLocaleDateString('en-US', {
|
||||||
|
month: 'long', year: 'numeric',
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => navigate(-1)}>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm font-medium min-w-[140px] text-center">{label}</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={() => navigate(1)}
|
||||||
|
disabled={isCurrentMonth}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import { formatCents } from '@/lib/utils/currency'
|
|||||||
interface BankAccount {
|
interface BankAccount {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
currentBalanceCents: number
|
balanceCents: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NetWorthCardProps {
|
interface NetWorthCardProps {
|
||||||
@@ -26,7 +26,7 @@ export function NetWorthCard({ netWorthCents, bankAccounts }: NetWorthCardProps)
|
|||||||
<div key={a.id} className="flex items-center justify-between text-sm">
|
<div key={a.id} className="flex items-center justify-between text-sm">
|
||||||
<span className="text-muted-foreground truncate">{a.name}</span>
|
<span className="text-muted-foreground truncate">{a.name}</span>
|
||||||
<span className="tabular-nums font-medium ml-4 shrink-0">
|
<span className="tabular-nums font-medium ml-4 shrink-0">
|
||||||
{formatCents(a.currentBalanceCents)}
|
{formatCents(a.balanceCents)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -80,8 +80,8 @@ export function EditTransactionDialog({ transaction, onOpenChange, budgets }: Pr
|
|||||||
{transaction.description}
|
{transaction.description}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{date} · <span className={transaction.type === 'CREDIT' ? 'text-green-600' : ''}>
|
{date} · <span className={transaction.type === 'CREDIT' ? 'text-green-600' : transaction.type === 'TRANSFER' ? 'text-muted-foreground' : ''}>
|
||||||
{transaction.type === 'CREDIT' ? '+' : '-'}{formatCents(transaction.amountCents)}
|
{transaction.type === 'TRANSFER' ? '⇄ ' : transaction.type === 'CREDIT' ? '+' : '-'}{formatCents(transaction.amountCents)}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -101,7 +101,11 @@ export function EditTransactionDialog({ transaction, onOpenChange, budgets }: Pr
|
|||||||
<Label htmlFor="budget">Budget</Label>
|
<Label htmlFor="budget">Budget</Label>
|
||||||
<Select value={budgetId} onValueChange={(v) => setBudgetId(v ?? '')}>
|
<Select value={budgetId} onValueChange={(v) => setBudgetId(v ?? '')}>
|
||||||
<SelectTrigger id="budget">
|
<SelectTrigger id="budget">
|
||||||
<SelectValue placeholder="No budget" />
|
<SelectValue placeholder="No budget">
|
||||||
|
{budgetId
|
||||||
|
? (budgets.find((b) => b.id === budgetId)?.name ?? 'No budget')
|
||||||
|
: 'No budget'}
|
||||||
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="">No budget</SelectItem>
|
<SelectItem value="">No budget</SelectItem>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { useRouter, useSearchParams, usePathname } from 'next/navigation'
|
import { useRouter, useSearchParams, usePathname } from 'next/navigation'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
@@ -20,17 +20,19 @@ export function TransactionFilters({ accounts }: { accounts: AccountOption[] })
|
|||||||
const sp = (key: string) => searchParams.get(key) ?? ''
|
const sp = (key: string) => searchParams.get(key) ?? ''
|
||||||
|
|
||||||
const [search, setSearch] = useState(sp('search'))
|
const [search, setSearch] = useState(sp('search'))
|
||||||
|
const searchParamsRef = useRef(searchParams)
|
||||||
|
useEffect(() => { searchParamsRef.current = searchParams }, [searchParams])
|
||||||
|
|
||||||
const push = useCallback(
|
const push = useCallback(
|
||||||
(updates: Record<string, string>) => {
|
(updates: Record<string, string>) => {
|
||||||
const params = new URLSearchParams(searchParams.toString())
|
const params = new URLSearchParams(searchParamsRef.current.toString())
|
||||||
for (const [k, v] of Object.entries(updates)) {
|
for (const [k, v] of Object.entries(updates)) {
|
||||||
if (v) params.set(k, v); else params.delete(k)
|
if (v) params.set(k, v); else params.delete(k)
|
||||||
}
|
}
|
||||||
params.delete('page')
|
params.delete('page')
|
||||||
router.replace(`${pathname}?${params.toString()}`)
|
router.replace(`${pathname}?${params.toString()}`)
|
||||||
},
|
},
|
||||||
[searchParams, pathname, router],
|
[pathname, router],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Debounce search → URL
|
// Debounce search → URL
|
||||||
@@ -52,7 +54,9 @@ export function TransactionFilters({ accounts }: { accounts: AccountOption[] })
|
|||||||
<Label className="text-xs">Account</Label>
|
<Label className="text-xs">Account</Label>
|
||||||
<Select value={sp('accountId')} onValueChange={(v) => push({ accountId: v ?? '' })}>
|
<Select value={sp('accountId')} onValueChange={(v) => push({ accountId: v ?? '' })}>
|
||||||
<SelectTrigger className="h-8 w-44 text-sm">
|
<SelectTrigger className="h-8 w-44 text-sm">
|
||||||
<SelectValue placeholder="All accounts" />
|
<SelectValue placeholder="All accounts">
|
||||||
|
{accounts.find((a) => a.id === sp('accountId'))?.name ?? 'All accounts'}
|
||||||
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="">All accounts</SelectItem>
|
<SelectItem value="">All accounts</SelectItem>
|
||||||
|
|||||||
@@ -2,11 +2,15 @@
|
|||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { usePathname, useSearchParams } from 'next/navigation'
|
import { usePathname, useSearchParams, useRouter } from 'next/navigation'
|
||||||
import {
|
import {
|
||||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||||
} from '@/components/ui/table'
|
} from '@/components/ui/table'
|
||||||
import { Pencil, ChevronLeft, ChevronRight } from 'lucide-react'
|
import { Pencil, ChevronLeft, ChevronRight, Trash2, Tag, StickyNote, X, ArrowLeftRight } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
import { formatCents } from '@/lib/utils/currency'
|
import { formatCents } from '@/lib/utils/currency'
|
||||||
import { EditTransactionDialog } from './EditTransactionDialog'
|
import { EditTransactionDialog } from './EditTransactionDialog'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
@@ -46,16 +50,86 @@ export function TransactionTable({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
|
const router = useRouter()
|
||||||
const [editing, setEditing] = useState<TransactionRow | null>(null)
|
const [editing, setEditing] = useState<TransactionRow | null>(null)
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||||
|
const [bulkDialog, setBulkDialog] = useState<'delete' | 'budget' | 'notes' | 'transfer' | null>(null)
|
||||||
|
const [budgetTarget, setBudgetTarget] = useState<string>('__none__')
|
||||||
|
const [notesValue, setNotesValue] = useState('')
|
||||||
|
const [working, setWorking] = useState(false)
|
||||||
const totalPages = Math.ceil(total / limit)
|
const totalPages = Math.ceil(total / limit)
|
||||||
|
|
||||||
|
const allIds = transactions.map((t) => t.id)
|
||||||
|
const allSelected = allIds.length > 0 && allIds.every((id) => selected.has(id))
|
||||||
|
const someSelected = selected.size > 0
|
||||||
|
|
||||||
|
function toggleAll() {
|
||||||
|
if (allSelected) {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
allIds.forEach((id) => next.delete(id))
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setSelected((prev) => new Set([...prev, ...allIds]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleOne(id: string) {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(id)) next.delete(id)
|
||||||
|
else next.add(id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSelection() {
|
||||||
|
setSelected(new Set())
|
||||||
|
}
|
||||||
|
|
||||||
function pageHref(p: number) {
|
function pageHref(p: number) {
|
||||||
const params = new URLSearchParams(searchParams.toString())
|
const params = new URLSearchParams(searchParams.toString())
|
||||||
params.set('page', String(p))
|
params.set('page', String(p))
|
||||||
return `${pathname}?${params.toString()}`
|
return `${pathname}?${params.toString()}`
|
||||||
}
|
}
|
||||||
|
|
||||||
if (transactions.length === 0) {
|
async function bulkPost(body: object) {
|
||||||
|
setWorking(true)
|
||||||
|
const res = await fetch('/api/transactions/bulk', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
setWorking(false)
|
||||||
|
if (res.ok) {
|
||||||
|
clearSelection()
|
||||||
|
setBulkDialog(null)
|
||||||
|
router.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
await bulkPost({ action: 'delete', ids: [...selected] })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAssignBudget() {
|
||||||
|
await bulkPost({
|
||||||
|
action: 'assignBudget',
|
||||||
|
ids: [...selected],
|
||||||
|
budgetId: budgetTarget === '__none__' ? null : budgetTarget,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddNotes() {
|
||||||
|
await bulkPost({ action: 'addNotes', ids: [...selected], notes: notesValue })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMarkTransfer() {
|
||||||
|
await bulkPost({ action: 'markTransfer', ids: [...selected] })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transactions.length === 0 && !someSelected) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-dashed p-12 text-center text-muted-foreground">
|
<div className="rounded-lg border border-dashed p-12 text-center text-muted-foreground">
|
||||||
No transactions found.
|
No transactions found.
|
||||||
@@ -63,12 +137,63 @@ export function TransactionTable({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const colSpan = showAccount ? 8 : 7
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{/* Bulk action bar */}
|
||||||
|
{someSelected && (
|
||||||
|
<div className="flex items-center gap-3 rounded-md border bg-muted/50 px-4 py-2 mb-2">
|
||||||
|
<span className="text-sm font-medium">{selected.size} selected</span>
|
||||||
|
<div className="flex items-center gap-2 ml-auto">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => { setNotesValue(''); setBulkDialog('notes') }}
|
||||||
|
>
|
||||||
|
<StickyNote className="h-3.5 w-3.5 mr-1.5" />Notes
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => { setBudgetTarget('__none__'); setBulkDialog('budget') }}
|
||||||
|
>
|
||||||
|
<Tag className="h-3.5 w-3.5 mr-1.5" />Budget
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setBulkDialog('transfer')}
|
||||||
|
>
|
||||||
|
<ArrowLeftRight className="h-3.5 w-3.5 mr-1.5" />Transfer
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => setBulkDialog('delete')}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5 mr-1.5" />Delete
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={clearSelection}>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="rounded-md border overflow-hidden">
|
<div className="rounded-md border overflow-hidden">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
|
<TableHead className="w-8 px-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4 rounded border"
|
||||||
|
checked={allSelected}
|
||||||
|
onChange={toggleAll}
|
||||||
|
aria-label="Select all"
|
||||||
|
/>
|
||||||
|
</TableHead>
|
||||||
<TableHead className="w-28">Date</TableHead>
|
<TableHead className="w-28">Date</TableHead>
|
||||||
<TableHead>Description</TableHead>
|
<TableHead>Description</TableHead>
|
||||||
{showAccount && <TableHead className="w-36">Account</TableHead>}
|
{showAccount && <TableHead className="w-36">Account</TableHead>}
|
||||||
@@ -81,11 +206,22 @@ export function TransactionTable({
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{transactions.map((tx) => {
|
{transactions.map((tx) => {
|
||||||
const isCredit = tx.type === 'CREDIT'
|
const isCredit = tx.type === 'CREDIT'
|
||||||
|
const isTransfer = tx.type === 'TRANSFER'
|
||||||
|
const isSelected = selected.has(tx.id)
|
||||||
const dateStr = new Date(tx.date).toLocaleDateString('en-US', {
|
const dateStr = new Date(tx.date).toLocaleDateString('en-US', {
|
||||||
month: 'short', day: 'numeric', year: 'numeric',
|
month: 'short', day: 'numeric', year: 'numeric',
|
||||||
})
|
})
|
||||||
return (
|
return (
|
||||||
<TableRow key={tx.id}>
|
<TableRow key={tx.id} className={isSelected ? 'bg-muted/40' : undefined}>
|
||||||
|
<TableCell className="px-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4 rounded border"
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={() => toggleOne(tx.id)}
|
||||||
|
aria-label="Select transaction"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
<TableCell className="text-sm text-muted-foreground whitespace-nowrap">
|
<TableCell className="text-sm text-muted-foreground whitespace-nowrap">
|
||||||
{dateStr}
|
{dateStr}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -101,8 +237,8 @@ export function TransactionTable({
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
)}
|
)}
|
||||||
<TableCell className="text-right tabular-nums font-medium">
|
<TableCell className="text-right tabular-nums font-medium">
|
||||||
<span className={cn('text-sm', isCredit ? 'text-green-600' : '')}>
|
<span className={cn('text-sm', isTransfer ? 'text-muted-foreground' : isCredit ? 'text-green-600' : '')}>
|
||||||
{isCredit ? '+' : '-'}{formatCents(tx.amountCents)}
|
{isTransfer ? '⇄ ' : isCredit ? '+' : '-'}{formatCents(tx.amountCents)}
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-sm text-muted-foreground">
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
@@ -180,6 +316,101 @@ export function TransactionTable({
|
|||||||
onOpenChange={(open) => { if (!open) setEditing(null) }}
|
onOpenChange={(open) => { if (!open) setEditing(null) }}
|
||||||
budgets={budgets}
|
budgets={budgets}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Delete confirm dialog */}
|
||||||
|
<Dialog open={bulkDialog === 'delete'} onOpenChange={(o) => { if (!o) setBulkDialog(null) }}>
|
||||||
|
<DialogContent className="max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete {selected.size} transaction{selected.size !== 1 ? 's' : ''}?</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<p className="text-sm text-muted-foreground">This cannot be undone.</p>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setBulkDialog(null)}>Cancel</Button>
|
||||||
|
<Button variant="destructive" onClick={handleDelete} disabled={working}>
|
||||||
|
{working ? 'Deleting…' : 'Delete'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Assign budget dialog */}
|
||||||
|
<Dialog open={bulkDialog === 'budget'} onOpenChange={(o) => { if (!o) setBulkDialog(null) }}>
|
||||||
|
<DialogContent className="max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Assign budget</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Apply to {selected.size} selected transaction{selected.size !== 1 ? 's' : ''}.
|
||||||
|
</p>
|
||||||
|
<Select value={budgetTarget} onValueChange={(v) => setBudgetTarget(v ?? '__none__')}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue>
|
||||||
|
{budgetTarget === '__none__'
|
||||||
|
? 'No budget'
|
||||||
|
: (budgets.find((b) => b.id === budgetTarget)?.name ?? 'No budget')}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__">No budget</SelectItem>
|
||||||
|
{budgets.map((b) => (
|
||||||
|
<SelectItem key={b.id} value={b.id}>
|
||||||
|
{b.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setBulkDialog(null)}>Cancel</Button>
|
||||||
|
<Button onClick={handleAssignBudget} disabled={working}>
|
||||||
|
{working ? 'Saving…' : 'Apply'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Mark as transfer dialog */}
|
||||||
|
<Dialog open={bulkDialog === 'transfer'} onOpenChange={(o) => { if (!o) setBulkDialog(null) }}>
|
||||||
|
<DialogContent className="max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Mark as transfer?</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{selected.size} transaction{selected.size !== 1 ? 's' : ''} will be marked as transfers
|
||||||
|
and excluded from cash flow calculations.
|
||||||
|
</p>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setBulkDialog(null)}>Cancel</Button>
|
||||||
|
<Button onClick={handleMarkTransfer} disabled={working}>
|
||||||
|
{working ? 'Saving…' : 'Mark as transfer'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Add notes dialog */}
|
||||||
|
<Dialog open={bulkDialog === 'notes'} onOpenChange={(o) => { if (!o) setBulkDialog(null) }}>
|
||||||
|
<DialogContent className="max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Set notes</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Apply to {selected.size} selected transaction{selected.size !== 1 ? 's' : ''}. Leave blank to clear.
|
||||||
|
</p>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Add a note…"
|
||||||
|
value={notesValue}
|
||||||
|
onChange={(e) => setNotesValue(e.target.value)}
|
||||||
|
maxLength={500}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setBulkDialog(null)}>Cancel</Button>
|
||||||
|
<Button onClick={handleAddNotes} disabled={working}>
|
||||||
|
{working ? 'Saving…' : 'Apply'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
24
src/components/transactions/TransferRulesButton.tsx
Normal file
24
src/components/transactions/TransferRulesButton.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { ArrowLeftRight } from 'lucide-react'
|
||||||
|
import { TransferRulesDialog } from './TransferRulesDialog'
|
||||||
|
|
||||||
|
interface Rule {
|
||||||
|
id: string
|
||||||
|
pattern: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TransferRulesButton({ rules }: { rules: Rule[] }) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
|
||||||
|
<ArrowLeftRight className="h-4 w-4 mr-2" />
|
||||||
|
Transfer rules
|
||||||
|
</Button>
|
||||||
|
<TransferRulesDialog open={open} onOpenChange={setOpen} rules={rules} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
94
src/components/transactions/TransferRulesDialog.tsx
Normal file
94
src/components/transactions/TransferRulesDialog.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Trash2, Plus } from 'lucide-react'
|
||||||
|
|
||||||
|
interface Rule {
|
||||||
|
id: string
|
||||||
|
pattern: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
rules: Rule[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TransferRulesDialog({ open, onOpenChange, rules }: Props) {
|
||||||
|
const router = useRouter()
|
||||||
|
const [pattern, setPattern] = useState('')
|
||||||
|
const [adding, setAdding] = useState(false)
|
||||||
|
|
||||||
|
async function handleAdd(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!pattern.trim()) return
|
||||||
|
setAdding(true)
|
||||||
|
await fetch('/api/transfer-rules', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ pattern: pattern.trim() }),
|
||||||
|
})
|
||||||
|
setPattern('')
|
||||||
|
setAdding(false)
|
||||||
|
router.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: string) {
|
||||||
|
await fetch(`/api/transfer-rules/${id}`, { method: 'DELETE' })
|
||||||
|
router.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Transfer rules</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Transactions whose description matches a pattern (case-insensitive) are automatically
|
||||||
|
marked as transfers on upload and excluded from cash flow.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{rules.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-2">No rules yet.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{rules.map((rule) => (
|
||||||
|
<li
|
||||||
|
key={rule.id}
|
||||||
|
className="flex items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<span className="font-mono truncate">{rule.pattern}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(rule.id)}
|
||||||
|
className="shrink-0 text-muted-foreground hover:text-destructive transition-colors"
|
||||||
|
aria-label="Delete rule"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleAdd} className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="e.g. Fidelity Transfer, Schwab"
|
||||||
|
value={pattern}
|
||||||
|
onChange={(e) => setPattern(e.target.value)}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button type="submit" disabled={adding || !pattern.trim()} size="sm">
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -97,7 +97,9 @@ export function UploadForm({ accounts }: Props) {
|
|||||||
) : (
|
) : (
|
||||||
<Select value={accountId} onValueChange={(v) => setAccountId(v ?? '')}>
|
<Select value={accountId} onValueChange={(v) => setAccountId(v ?? '')}>
|
||||||
<SelectTrigger id="account" className="w-72">
|
<SelectTrigger id="account" className="w-72">
|
||||||
<SelectValue placeholder="Select account" />
|
<SelectValue placeholder="Select account">
|
||||||
|
{accounts.find((a) => a.id === accountId)?.name}
|
||||||
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{accounts.map((a) => (
|
{accounts.map((a) => (
|
||||||
|
|||||||
21
src/lib/auth.config.ts
Normal file
21
src/lib/auth.config.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { NextAuthConfig } from 'next-auth'
|
||||||
|
|
||||||
|
// Edge-compatible config — no Node.js-only imports (no Prisma, no bcrypt).
|
||||||
|
// Used by middleware for JWT verification. Providers are added in auth.ts.
|
||||||
|
export const authConfig = {
|
||||||
|
session: { strategy: 'jwt' as const, maxAge: 60 * 60 },
|
||||||
|
secret: process.env.NEXTAUTH_SECRET,
|
||||||
|
trustHost: true,
|
||||||
|
pages: { signIn: '/login' },
|
||||||
|
callbacks: {
|
||||||
|
jwt({ token, user }) {
|
||||||
|
if (user) token.id = user.id
|
||||||
|
return token
|
||||||
|
},
|
||||||
|
session({ session, token }) {
|
||||||
|
if (token.id) session.user.id = token.id as string
|
||||||
|
return session
|
||||||
|
},
|
||||||
|
},
|
||||||
|
providers: [],
|
||||||
|
} satisfies NextAuthConfig
|
||||||
@@ -3,6 +3,7 @@ import Credentials from 'next-auth/providers/credentials'
|
|||||||
import bcrypt from 'bcryptjs'
|
import bcrypt from 'bcryptjs'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { authConfig } from '@/lib/auth.config'
|
||||||
|
|
||||||
const loginSchema = z.object({
|
const loginSchema = z.object({
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
@@ -10,6 +11,7 @@ const loginSchema = z.object({
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const { handlers, signIn, signOut, auth } = NextAuth({
|
export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||||
|
...authConfig,
|
||||||
providers: [
|
providers: [
|
||||||
Credentials({
|
Credentials({
|
||||||
credentials: {
|
credentials: {
|
||||||
@@ -32,23 +34,4 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
session: {
|
|
||||||
strategy: 'jwt',
|
|
||||||
maxAge: 60 * 60, // 1 hour
|
|
||||||
},
|
|
||||||
secret: process.env.NEXTAUTH_SECRET,
|
|
||||||
trustHost: true,
|
|
||||||
pages: {
|
|
||||||
signIn: '/login',
|
|
||||||
},
|
|
||||||
callbacks: {
|
|
||||||
jwt({ token, user }) {
|
|
||||||
if (user) token.id = user.id
|
|
||||||
return token
|
|
||||||
},
|
|
||||||
session({ session, token }) {
|
|
||||||
if (token.id) session.user.id = token.id as string
|
|
||||||
return session
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export interface NormalizerConfig {
|
|||||||
strategy: ParseStrategy
|
strategy: ParseStrategy
|
||||||
dateColumn: string
|
dateColumn: string
|
||||||
descriptionColumn: string
|
descriptionColumn: string
|
||||||
|
categoryColumn?: string
|
||||||
// Strategy A
|
// Strategy A
|
||||||
amountColumn?: string
|
amountColumn?: string
|
||||||
invertAmountSign?: boolean
|
invertAmountSign?: boolean
|
||||||
@@ -39,7 +40,8 @@ export const bankProfiles: BankProfile[] = [
|
|||||||
dateColumn: 'Trans. Date',
|
dateColumn: 'Trans. Date',
|
||||||
descriptionColumn: 'Description',
|
descriptionColumn: 'Description',
|
||||||
amountColumn: 'Amount',
|
amountColumn: 'Amount',
|
||||||
invertAmountSign: false, // positive = DEBIT (charge), negative = CREDIT (payment)
|
invertAmountSign: true, // negative = DEBIT (charge), positive = CREDIT (payment/refund)
|
||||||
|
categoryColumn: 'Category',
|
||||||
detectColumns: ['Trans. Date', 'Post Date', 'Description', 'Amount', 'Category'],
|
detectColumns: ['Trans. Date', 'Post Date', 'Description', 'Amount', 'Category'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -50,7 +52,7 @@ export const bankProfiles: BankProfile[] = [
|
|||||||
dateColumn: 'Date',
|
dateColumn: 'Date',
|
||||||
descriptionColumn: 'Description',
|
descriptionColumn: 'Description',
|
||||||
amountColumn: 'Amount',
|
amountColumn: 'Amount',
|
||||||
invertAmountSign: true, // negative = DEBIT, positive = CREDIT
|
invertAmountSign: true, // negative = DEBIT (withdrawal), positive = CREDIT (deposit)
|
||||||
detectColumns: ['Date', 'Description', 'Amount', 'Split', 'Tags'],
|
detectColumns: ['Date', 'Description', 'Amount', 'Split', 'Tags'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -61,7 +63,7 @@ export const bankProfiles: BankProfile[] = [
|
|||||||
dateColumn: 'Run Date',
|
dateColumn: 'Run Date',
|
||||||
descriptionColumn: 'Description',
|
descriptionColumn: 'Description',
|
||||||
amountColumn: 'Amount($)',
|
amountColumn: 'Amount($)',
|
||||||
invertAmountSign: true, // negative = DEBIT (purchase), positive = CREDIT
|
invertAmountSign: true, // negative = DEBIT (purchase/withdrawal), positive = CREDIT (deposit/dividend)
|
||||||
detectColumns: ['Run Date', 'Description', 'Amount($)'],
|
detectColumns: ['Run Date', 'Description', 'Amount($)'],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { createHash } from 'crypto'
|
|
||||||
import type { NormalizerConfig } from './bank-profiles'
|
import type { NormalizerConfig } from './bank-profiles'
|
||||||
|
|
||||||
export interface NormalizedRow {
|
export interface NormalizedRow {
|
||||||
@@ -6,7 +5,7 @@ export interface NormalizedRow {
|
|||||||
description: string
|
description: string
|
||||||
amountCents: number
|
amountCents: number
|
||||||
type: 'DEBIT' | 'CREDIT'
|
type: 'DEBIT' | 'CREDIT'
|
||||||
dedupeHash: string
|
category?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseCents(raw: string): number {
|
export function parseCents(raw: string): number {
|
||||||
@@ -50,18 +49,6 @@ function strategyB(
|
|||||||
return { amountCents: Math.abs(parseCents(creditRaw)), type: 'CREDIT' }
|
return { amountCents: Math.abs(parseCents(creditRaw)), type: 'CREDIT' }
|
||||||
}
|
}
|
||||||
|
|
||||||
function dedupeHash(
|
|
||||||
accountId: string,
|
|
||||||
date: Date,
|
|
||||||
description: string,
|
|
||||||
amountCents: number,
|
|
||||||
): string {
|
|
||||||
const dateStr = date.toISOString().split('T')[0]
|
|
||||||
return createHash('sha256')
|
|
||||||
.update(`${accountId}|${dateStr}|${description}|${amountCents}`)
|
|
||||||
.digest('hex')
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeRows(
|
export function normalizeRows(
|
||||||
rows: Record<string, string>[],
|
rows: Record<string, string>[],
|
||||||
accountId: string,
|
accountId: string,
|
||||||
@@ -92,12 +79,13 @@ export function normalizeRows(
|
|||||||
|
|
||||||
if (amountCents === 0) continue
|
if (amountCents === 0) continue
|
||||||
|
|
||||||
|
const rawCategory = config.categoryColumn ? (row[config.categoryColumn] ?? '').trim() : ''
|
||||||
out.push({
|
out.push({
|
||||||
date,
|
date,
|
||||||
description,
|
description,
|
||||||
amountCents,
|
amountCents,
|
||||||
type,
|
type,
|
||||||
dedupeHash: dedupeHash(accountId, date, description, amountCents),
|
category: rawCategory || undefined,
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
// skip unparseable rows
|
// skip unparseable rows
|
||||||
|
|||||||
@@ -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'),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
6
src/lib/validations/budget-rule.ts
Normal file
6
src/lib/validations/budget-rule.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
export const createBudgetRuleSchema = z.object({
|
||||||
|
budgetId: z.string().cuid(),
|
||||||
|
pattern: z.string().min(1).max(200).trim(),
|
||||||
|
})
|
||||||
@@ -4,7 +4,7 @@ export const transactionQuerySchema = z.object({
|
|||||||
accountId: z.string().optional(),
|
accountId: z.string().optional(),
|
||||||
dateFrom: z.string().optional(),
|
dateFrom: z.string().optional(),
|
||||||
dateTo: z.string().optional(),
|
dateTo: z.string().optional(),
|
||||||
type: z.enum(['DEBIT', 'CREDIT']).optional(),
|
type: z.enum(['DEBIT', 'CREDIT', 'TRANSFER']).optional(),
|
||||||
search: z.string().optional(),
|
search: z.string().optional(),
|
||||||
budgetId: z.string().optional(),
|
budgetId: z.string().optional(),
|
||||||
page: z.coerce.number().min(1).default(1),
|
page: z.coerce.number().min(1).default(1),
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { auth } from '@/lib/auth'
|
import NextAuth from 'next-auth'
|
||||||
|
import { authConfig } from '@/lib/auth.config'
|
||||||
import { NextResponse, type NextRequest } from 'next/server'
|
import { NextResponse, type NextRequest } from 'next/server'
|
||||||
|
|
||||||
|
// Use the Edge-compatible config so no Node.js-only modules are bundled here.
|
||||||
|
const { auth } = NextAuth(authConfig)
|
||||||
|
|
||||||
// Process-local store — adequate for a self-hosted single-instance deployment.
|
// Process-local store — adequate for a self-hosted single-instance deployment.
|
||||||
const rateLimitStore = new Map<string, { count: number; resetAt: number }>()
|
const rateLimitStore = new Map<string, { count: number; resetAt: number }>()
|
||||||
const RATE_LIMIT = 10
|
const RATE_LIMIT = 10
|
||||||
@@ -29,10 +33,22 @@ function isRateLimited(ip: string): boolean {
|
|||||||
function hasValidOrigin(req: NextRequest): boolean {
|
function hasValidOrigin(req: NextRequest): boolean {
|
||||||
const origin = req.headers.get('origin')
|
const origin = req.headers.get('origin')
|
||||||
if (!origin) return true // non-browser (curl, server-to-server)
|
if (!origin) return true // non-browser (curl, server-to-server)
|
||||||
const expected = process.env.NEXTAUTH_URL
|
// Accept the host the browser actually used to reach this server.
|
||||||
? new URL(process.env.NEXTAUTH_URL).origin
|
const host = req.headers.get('x-forwarded-host') ?? req.headers.get('host')
|
||||||
: req.nextUrl.origin
|
const proto = req.headers.get('x-forwarded-proto') ?? 'http'
|
||||||
return origin === expected
|
const requestOrigin = host ? `${proto}://${host}` : null
|
||||||
|
if (requestOrigin && origin === requestOrigin) return true
|
||||||
|
// Also accept the statically configured NEXTAUTH_URL origin (reverse-proxy setups).
|
||||||
|
if (process.env.NEXTAUTH_URL && origin === new URL(process.env.NEXTAUTH_URL).origin) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build an absolute URL using the Host header the browser sent, not the
|
||||||
|
// internal hostname Next.js resolves to inside Docker.
|
||||||
|
function siteUrl(req: NextRequest, path: string): URL {
|
||||||
|
const host = req.headers.get('x-forwarded-host') ?? req.headers.get('host') ?? 'localhost:3000'
|
||||||
|
const proto = req.headers.get('x-forwarded-proto') ?? 'http'
|
||||||
|
return new URL(path, `${proto}://${host}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default auth((req) => {
|
export default auth((req) => {
|
||||||
@@ -68,13 +84,17 @@ export default auth((req) => {
|
|||||||
if (pathname.startsWith('/api/')) {
|
if (pathname.startsWith('/api/')) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
const loginUrl = new URL('/login', req.nextUrl.origin)
|
if (pathname !== '/login') {
|
||||||
|
const loginUrl = siteUrl(req, '/login')
|
||||||
loginUrl.searchParams.set('callbackUrl', pathname)
|
loginUrl.searchParams.set('callbackUrl', pathname)
|
||||||
return NextResponse.redirect(loginUrl)
|
return NextResponse.redirect(loginUrl)
|
||||||
}
|
}
|
||||||
|
return NextResponse.next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logged-in users hitting /login get sent to the dashboard
|
||||||
if (pathname === '/login') {
|
if (pathname === '/login') {
|
||||||
return NextResponse.redirect(new URL('/dashboard', req.nextUrl.origin))
|
return NextResponse.redirect(siteUrl(req, '/dashboard'))
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.next()
|
return NextResponse.next()
|
||||||
|
|||||||
Reference in New Issue
Block a user