feat(dashboard): add transactions and users management pages with CRUD functionality

feat(auth): implement login page and authentication middleware
feat(sidebar): create sidebar component with user info and navigation links
feat(api): establish API client for interacting with backend services
This commit is contained in:
2026-03-10 15:17:32 +08:00
parent 9a2668fae5
commit 2cb89c74b3
32 changed files with 4648 additions and 83 deletions

View File

@@ -0,0 +1,101 @@
import { eq, sql } from "drizzle-orm";
import { useDrizzle } from "@/lib/db.js";
import { chargePoint, connector, idTag, meterValue, transaction } from "@/db/schema.js";
import type {
StopTransactionRequest,
StopTransactionResponse,
OcppConnectionContext,
} from "../types.ts";
import { resolveIdTagInfo } from "./authorize.ts";
export async function handleStopTransaction(
payload: StopTransactionRequest,
_ctx: OcppConnectionContext,
): Promise<StopTransactionResponse> {
const db = useDrizzle();
// Update the transaction record
const [tx] = await db
.update(transaction)
.set({
stopTimestamp: new Date(payload.timestamp),
stopMeterValue: payload.meterStop,
stopIdTag: payload.idTag ?? null,
stopReason: (payload.reason as (typeof transaction.$inferSelect)["stopReason"]) ?? null,
updatedAt: new Date(),
})
.where(eq(transaction.id, payload.transactionId))
.returning();
if (!tx) {
console.warn(`[OCPP] StopTransaction: transaction ${payload.transactionId} not found`);
return {};
}
// Set connector back to Available
await db
.update(connector)
.set({ status: "Available", updatedAt: new Date() })
.where(eq(connector.id, tx.connectorId));
// Store embedded meter values (transactionData)
if (payload.transactionData?.length) {
const records = payload.transactionData.flatMap((mv) =>
mv.sampledValue?.length
? [
{
id: crypto.randomUUID(),
transactionId: tx.id,
connectorId: tx.connectorId,
chargePointId: tx.chargePointId,
connectorNumber: tx.connectorNumber,
timestamp: new Date(mv.timestamp),
sampledValues:
mv.sampledValue as unknown as (typeof meterValue.$inferInsert)["sampledValues"],
},
]
: [],
);
if (records.length) {
await db.insert(meterValue).values(records);
}
}
const energyWh = payload.meterStop - tx.startMeterValue;
// Deduct balance from the idTag based on actual energy consumed
const [cp] = await db
.select({ feePerKwh: chargePoint.feePerKwh })
.from(chargePoint)
.where(eq(chargePoint.id, tx.chargePointId))
.limit(1);
const feeFen =
cp && cp.feePerKwh > 0 && energyWh > 0 ? Math.ceil((energyWh * cp.feePerKwh) / 1000) : 0;
// Always record the charge amount (0 if free)
await db
.update(transaction)
.set({ chargeAmount: feeFen, updatedAt: new Date() })
.where(eq(transaction.id, tx.id));
if (feeFen > 0) {
await db
.update(idTag)
.set({
balance: sql`${idTag.balance} - ${feeFen}`,
updatedAt: new Date(),
})
.where(eq(idTag.idTag, tx.idTag));
}
console.log(
`[OCPP] StopTransaction txId=${payload.transactionId} ` +
`reason=${payload.reason ?? "none"} energyWh=${energyWh} feeFen=${feeFen}`,
);
// Resolve idTagInfo for the stop tag if provided (no balance check — charging already occurred)
const idTagInfo = payload.idTag ? await resolveIdTagInfo(payload.idTag, false) : undefined;
return { idTagInfo };
}