feat(transactions): add transaction detail page with live energy and cost estimation

feat(transactions): implement active transaction checks and idTag validation
feat(id-tag): enhance idTag card with disabled state for active transactions
fix(transactions): improve error handling and user feedback for transaction actions
This commit is contained in:
2026-03-13 11:51:06 +08:00
parent c8ddaa4dcc
commit 83e6ed2412
7 changed files with 747 additions and 60 deletions

View File

@@ -1,8 +1,10 @@
"use client";
import { useState } from "react";
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Button, Chip, Modal, Pagination, Spinner, Table } from "@heroui/react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { TrashBin, ArrowRotateRight } from "@gravity-ui/icons";
import { api } from "@/lib/api";
import { useSession } from "@/lib/auth-client";
@@ -10,6 +12,39 @@ import dayjs from "@/lib/dayjs";
const LIMIT = 15;
const idTagRejectLabelMap: Record<string, string> = {
Blocked: "卡片不可用或余额不足",
Expired: "卡片已过期",
Invalid: "卡片无效",
ConcurrentTx: "该卡已有进行中的订单",
};
const stopReasonLabelMap: Record<string, string> = {
EmergencyStop: "紧急停止",
EVDisconnected: "车辆断开",
HardReset: "硬重启",
Local: "本地结束",
Other: "其他原因",
PowerLoss: "断电结束",
Reboot: "重启结束",
Remote: "远程结束",
SoftReset: "软重启",
UnlockCommand: "解锁结束",
};
const stopReasonColorMap: Record<string, "success" | "warning" | "danger" | "default"> = {
Local: "success",
EVDisconnected: "success",
Remote: "warning",
UnlockCommand: "warning",
EmergencyStop: "danger",
PowerLoss: "danger",
HardReset: "danger",
SoftReset: "warning",
Reboot: "warning",
Other: "default",
};
function formatDuration(start: string, stop: string | null): string {
if (!stop) return "进行中";
const min = dayjs(stop).diff(dayjs(start), "minute");
@@ -20,13 +55,27 @@ function formatDuration(start: string, stop: string | null): string {
}
export default function TransactionsPage() {
const searchParams = useSearchParams();
const { data: sessionData } = useSession();
const isAdmin = sessionData?.user?.role === "admin";
const statusFromQuery = searchParams.get("status");
const initialStatus: "all" | "active" | "completed" =
statusFromQuery === "active" || statusFromQuery === "completed" ? statusFromQuery : "all";
const [page, setPage] = useState(1);
const [status, setStatus] = useState<"all" | "active" | "completed">("all");
const [status, setStatus] = useState<"all" | "active" | "completed">(initialStatus);
const [stoppingId, setStoppingId] = useState<number | null>(null);
const [deletingId, setDeletingId] = useState<number | null>(null);
useEffect(() => {
if (status !== initialStatus) {
setStatus(initialStatus);
setPage(1);
}
// We intentionally depend on searchParams-derived value only.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialStatus]);
const {
data,
isPending: loading,
@@ -131,12 +180,23 @@ export default function TransactionsPage() {
>
{(data?.data ?? []).map((tx) => (
<Table.Row key={tx.id} id={tx.id}>
<Table.Cell className="font-mono text-sm">{tx.id}</Table.Cell>
<Table.Cell className="font-mono text-sm">
<Link
href={`/dashboard/transactions/${tx.id}`}
className="text-accent hover:text-accent/80"
>
{tx.id}
</Link>
</Table.Cell>
<Table.Cell className="font-mono">{tx.chargePointIdentifier ?? "—"}</Table.Cell>
<Table.Cell>{tx.connectorNumber ?? "—"}</Table.Cell>
<Table.Cell className="font-mono">{tx.idTag}</Table.Cell>
<Table.Cell>
{tx.stopTimestamp ? (
{tx.stopTimestamp && tx.stopReason === "DeAuthorized" ? (
<Chip color="danger" size="sm" variant="soft">
</Chip>
) : tx.stopTimestamp ? (
<Chip color="success" size="sm" variant="soft">
</Chip>
@@ -181,13 +241,23 @@ export default function TransactionsPage() {
)}
</Table.Cell>
<Table.Cell>
{tx.stopReason ? (
<Chip color="default" size="sm" variant="soft">
{tx.stopReason}
{tx.stopReason === "DeAuthorized" ? (
<p className="text-xs font-medium text-muted text-nowrap">
{tx.idTagStatus && tx.idTagStatus !== "Accepted"
? `${idTagRejectLabelMap[tx.idTagStatus] ?? tx.idTagStatus}`
: "鉴权失败"}
</p>
) : tx.stopReason ? (
<Chip
color={stopReasonColorMap[tx.stopReason] ?? "default"}
size="sm"
variant="soft"
>
{stopReasonLabelMap[tx.stopReason] ?? tx.stopReason}
</Chip>
) : tx.stopTimestamp ? (
<Chip color="default" size="sm" variant="soft">
Local
</Chip>
) : (
"—"