feat: phase 3 - ledger and asset management

Add group expense tracking (ledger) and public asset inventory (asset) features.
Ledger supports income/expense recording with monthly summary. Asset tracks
group equipment with free-form holder transfer. Both are independent pages
accessible from GroupView navigation.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
congsh
2026-04-18 19:42:04 +08:00
parent 625d0baf7d
commit c5413644f9
21 changed files with 4407 additions and 1 deletions
+82
View File
@@ -0,0 +1,82 @@
import { pb } from './pocketbase'
import type { Asset } from '@/types'
export interface CreateAssetData {
group: string
name: string
type: string
description?: string
image?: File
}
export interface UpdateAssetData {
name?: string
type?: string
description?: string
}
export async function createAsset(data: CreateAssetData): Promise<Asset> {
const user = pb.authStore.model
const formData = new FormData()
formData.append('group', data.group)
formData.append('creator', user?.id || '')
formData.append('name', data.name)
formData.append('type', data.type)
if (data.description) formData.append('description', data.description)
if (data.image) formData.append('image', data.image)
return pb.collection('assets').create(formData) as Promise<Asset>
}
export async function listAssets(groupId: string): Promise<Asset[]> {
const result = await pb.collection('assets').getFullList({
filter: `group="${groupId}"`,
sort: 'created',
expand: 'creator,currentHolder'
})
return result as unknown as Asset[]
}
export async function updateAsset(
assetId: string,
data: UpdateAssetData,
image?: File
): Promise<Asset> {
if (image) {
const formData = new FormData()
if (data.name) formData.append('name', data.name)
if (data.type) formData.append('type', data.type)
if (data.description !== undefined) formData.append('description', data.description)
formData.append('image', image)
return pb.collection('assets').update(assetId, formData) as Promise<Asset>
}
return pb.collection('assets').update(assetId, data) as Promise<Asset>
}
export async function transferAsset(assetId: string, userId: string): Promise<Asset> {
return pb.collection('assets').update(assetId, {
currentHolder: userId
}) as Promise<Asset>
}
export async function deleteAsset(assetId: string): Promise<void> {
await pb.collection('assets').delete(assetId)
}
export function subscribeAssets(
groupId: string,
callback: (data: any) => void
): Promise<() => Promise<void>> {
return pb.collection('assets').subscribe('*', (data) => {
if (data.record?.group === groupId) {
callback(data)
}
})
}
export function getAssetImageUrl(assetId: string, filename: string, thumb?: string): string {
const record = { id: assetId, image: filename }
return pb.files.getUrl(record, filename, thumb ? { thumb } : undefined)
}
+132
View File
@@ -0,0 +1,132 @@
// src/api/ledgers.ts
import { pb } from './pocketbase'
import type { Ledger } from '@/types'
interface CreateLedgerData {
group: string
type: string
amount: number
category: string
description: string
relatedMembers?: string[]
occurredAt: string
}
interface ListLedgersOptions {
page?: number
limit?: number
type?: string
category?: string
month?: string
}
interface LedgerSummary {
totalIncome: number
totalExpense: number
balance: number
}
export async function createLedger(data: CreateLedgerData): Promise<Ledger> {
const user = pb.authStore.model
const record = await pb.collection('ledgers').create({
...data,
creator: user?.id || '',
relatedMembers: data.relatedMembers || []
})
return record as unknown as Ledger
}
export async function listLedgers(
groupId: string,
options?: ListLedgersOptions
): Promise<{ items: Ledger[]; total: number }> {
const { page = 1, limit = 30, type, category, month } = options || {}
let filter = `group="${groupId}"`
if (type) filter += ` && type="${type}"`
if (category) filter += ` && category="${category}"`
if (month) {
// month format: "2026-04"
const start = `${month}-01 00:00:00`
const year = parseInt(month.slice(0, 4))
const mon = parseInt(month.slice(5, 7))
// 下个月的第一天
const nextMonth = mon === 12 ? `${year + 1}-01` : `${year}-${String(mon + 1).padStart(2, '0')}`
const end = `${nextMonth}-01 00:00:00`
filter += ` && occurredAt>="${start}" && occurredAt<"${end}"`
}
const result = await pb.collection('ledgers').getList(page, limit, {
filter,
sort: '-occurredAt',
expand: 'creator,relatedMembers'
})
return { items: result.items as unknown as Ledger[], total: result.totalItems }
}
export async function updateLedger(
ledgerId: string,
data: Partial<CreateLedgerData>
): Promise<Ledger> {
const record = await pb.collection('ledgers').update(ledgerId, data)
return record as unknown as Ledger
}
export async function deleteLedger(ledgerId: string): Promise<void> {
await pb.collection('ledgers').delete(ledgerId)
}
export async function getLedgerSummary(
groupId: string,
month?: string
): Promise<LedgerSummary> {
let totalIncome = 0
let totalExpense = 0
let page = 1
const batchSize = 500
let hasMore = true
while (hasMore) {
let filter = `group="${groupId}"`
if (month) {
const start = `${month}-01 00:00:00`
const year = parseInt(month.slice(0, 4))
const mon = parseInt(month.slice(5, 7))
const nextMonth = mon === 12 ? `${year + 1}-01` : `${year}-${String(mon + 1).padStart(2, '0')}`
const end = `${nextMonth}-01 00:00:00`
filter += ` && occurredAt>="${start}" && occurredAt<"${end}"`
}
const result = await pb.collection('ledgers').getList(page, batchSize, {
filter,
fields: 'type,amount'
})
for (const item of result.items as any[]) {
if (item.type === 'income') {
totalIncome += item.amount || 0
} else if (item.type === 'expense') {
totalExpense += item.amount || 0
}
}
hasMore = result.items.length === batchSize && page * batchSize < result.totalItems
page++
}
return {
totalIncome,
totalExpense,
balance: totalIncome - totalExpense
}
}
export function subscribeLedgers(
groupId: string,
callback: (data: any) => void
) {
return pb.collection('ledgers').subscribe('*', (data) => {
if (data.record?.group === groupId) {
callback(data)
}
})
}