stock-portfolio_byQwen3.6/app/dashboard/transactions/transactions-client.tsx

339 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import { useState, useTransition } from 'react';
import {
Table,
TableBody,
TableCaption,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { Edit, Trash2 } from 'lucide-react';
import { formatAmount, formatQuantity } from '@/lib/formatters';
import { deleteTransaction, updateTransaction } from '@/actions/transaction';
import { AddTransactionDialog } from '@/components/transactions/add-transaction-dialog';
interface Asset {
id: string;
symbol: string;
name: string | null;
type: string;
baseCurrency: string;
}
interface Transaction {
id: string;
assetId: string;
txType: string;
quantity: string;
price: string;
fee: string;
txCurrency: string;
executedAt: Date;
}
export default function TransactionsPageClient({
assets,
transactions,
}: {
assets: Asset[];
transactions: Transaction[];
}) {
const [isPending, startTransition] = useTransition();
const [deleteTarget, setDeleteTarget] = useState<Transaction | null>(null);
const [editTarget, setEditTarget] = useState<Transaction | null>(null);
const typeLabels: Record<string, string> = {
BUY: '买入',
SELL: '卖出',
DIVIDEND: '分红',
AIRDROP: '空投',
FEE: '手续费',
};
const assetMap = new Map(assets.map((a) => [a.id, { symbol: a.symbol, name: a.name, type: a.type }]));
const editForm = useForm<{ quantity: string; price: string; fee: string; txCurrency: string; executedAt: string }>({
resolver: zodResolver(z.object({
quantity: z.string().regex(/^-?\d+(\.\d+)?$/, '数量必须是数字'),
price: z.string().regex(/^-?\d+(\.\d+)?$/, '价格必须是数字'),
fee: z.string().regex(/^-?\d+(\.\d+)?$/, '手续费必须是有效数字'),
txCurrency: z.string().min(1, '交易币种不能为空'),
executedAt: z.string(),
})),
defaultValues: { quantity: '', price: '', fee: '0', txCurrency: 'USD', executedAt: '' },
});
function handleEditClick(tx: Transaction) {
setEditTarget(tx);
editForm.reset({
quantity: tx.quantity.toString(),
price: tx.price.toString(),
fee: tx.fee.toString(),
txCurrency: tx.txCurrency,
executedAt: tx.executedAt
? new Date(tx.executedAt).toISOString().slice(0, 16)
: '',
});
}
function handleEditSubmit(values: { quantity: string; price: string; fee: string; txCurrency: string; executedAt: string }) {
if (!editTarget) return;
startTransition(async () => {
const result = await updateTransaction({
id: editTarget.id,
quantity: values.quantity,
price: values.price,
fee: values.fee,
txCurrency: values.txCurrency,
executedAt: new Date(values.executedAt),
});
if (result.success) {
setEditTarget(null);
editForm.reset();
window.location.reload();
}
});
}
function handleDelete(tx: Transaction) {
startTransition(async () => {
await deleteTransaction(tx.id);
setDeleteTarget(null);
window.location.reload();
});
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold"></h1>
<AddTransactionDialog assets={assets} />
</div>
<div className="rounded-md border">
<Table>
<TableCaption>
{transactions.length}
</TableCaption>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-[100px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{transactions.length === 0 ? (
<TableRow>
<TableCell
colSpan={9}
className="text-center text-muted-foreground"
>
"添加流水"
</TableCell>
</TableRow>
) : (
transactions.map((tx) => {
const assetInfo = assetMap.get(tx.assetId);
const symbol = assetInfo?.symbol || tx.assetId;
const name = assetInfo?.name || null;
const assetType = assetInfo?.type || 'CASH';
return (
<TableRow key={tx.id}>
<TableCell className="font-medium">{name || '-'}</TableCell>
<TableCell>{symbol}</TableCell>
<TableCell>{typeLabels[tx.txType] || tx.txType}</TableCell>
<TableCell>{formatQuantity(tx.quantity, assetType)}</TableCell>
<TableCell>{formatAmount(tx.price)}</TableCell>
<TableCell>{formatAmount(tx.fee)}</TableCell>
<TableCell>{tx.txCurrency}</TableCell>
<TableCell>
{tx.executedAt
? new Date(tx.executedAt).toLocaleString('zh-CN')
: '-'}
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => handleEditClick(tx)}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setDeleteTarget(tx)}
className="text-red-500 hover:text-red-700"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
<Dialog open={!!editTarget} onOpenChange={(open) => !open && setEditTarget(null)}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
<Form {...editForm}>
<form onSubmit={editForm.handleSubmit(handleEditSubmit)} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<FormField
control={editForm.control}
name="quantity"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input type="text" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={editForm.control}
name="price"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input type="text" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<FormField
control={editForm.control}
name="fee"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input type="text" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={editForm.control}
name="txCurrency"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="选择币种" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="USD">USD</SelectItem>
<SelectItem value="CNY">CNY</SelectItem>
<SelectItem value="HKD">HKD</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={editForm.control}
name="executedAt"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input type="datetime-local" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="submit" disabled={isPending}>
{isPending ? '保存中...' : '保存'}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
<Dialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={() => setDeleteTarget(null)}>
</Button>
<Button
variant="destructive"
onClick={() => deleteTarget && handleDelete(deleteTarget)}
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}