feat(charge-points): add pricing mode for charge points with validation

feat(pricing): implement tariff management with peak, valley, and flat pricing
feat(api): add tariff API for fetching and updating pricing configurations
feat(tariff-schema): create database schema for tariff configuration
feat(pricing-page): create UI for displaying and managing pricing tiers
fix(sidebar): update sidebar to include pricing settings link
This commit is contained in:
2026-03-12 17:23:06 +08:00
parent 2638af3f7f
commit f7ee298060
17 changed files with 2729 additions and 89 deletions

View File

@@ -1,10 +1,12 @@
"use client";
import { useState, useCallback, useEffect, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Button, Input, Label, Spinner, TextField, toast } from "@heroui/react";
import { TagDollar, Lock, ArrowRotateRight, ChartLine } from "@gravity-ui/icons";
// TagDollar is used in the page header badge
import { useSession } from "@/lib/auth-client";
import { api } from "@/lib/api";
// ─────────────────────────────────────────────────────────────────────────────
// Types (designed for future backend API integration)
@@ -155,6 +157,14 @@ const DEFAULT_PRICES: Record<PriceTier, TierPricing> = {
export default function PricingPage() {
const { data: session } = useSession();
const isAdmin = session?.user?.role === "admin";
const queryClient = useQueryClient();
// Load current tariff from API
const { data: remoteTariff, isLoading: loadingTariff } = useQuery({
queryKey: ["tariff"],
queryFn: () => api.tariff.get(),
enabled: isAdmin,
});
const [schedule, setSchedule] = useState<PriceTier[]>([...DEFAULT_SCHEDULE]);
@@ -176,6 +186,26 @@ export default function PricingPage() {
const [saving, setSaving] = useState(false);
const [showPayload, setShowPayload] = useState(false);
// Populate state once remote tariff loads
useEffect(() => {
if (!remoteTariff) return;
// Reconstruct 24-element schedule from slots
const s: PriceTier[] = [];
for (let i = 0; i < 24; i++) s.push("flat");
for (const slot of remoteTariff.slots) {
for (let h = slot.start; h < slot.end; h++) s[h] = slot.tier as PriceTier;
}
setSchedule(s);
const p = remoteTariff.prices as Record<PriceTier, TierPricing>;
setPrices(p);
setPriceStrings({
peak: toStrings(p.peak),
valley: toStrings(p.valley),
flat: toStrings(p.flat),
});
setIsDirty(false);
}, [remoteTariff]);
// ── Drag state via ref (avoids stale closures in global handler) ─────────
const dragRef = useRef({
active: false,
@@ -239,14 +269,16 @@ export default function PricingPage() {
setIsDirty(false);
};
// ── Save (stub: simulates API call) ─────────────────────────────────────
// ── Save ──────────────────────────────────────────────────────────────────────
const handleSave = async () => {
setSaving(true);
try {
// Future: await apiFetch("/api/tariff", { method: "PUT", body: JSON.stringify(apiPayload) })
await new Promise<void>((r) => setTimeout(r, 700));
await api.tariff.put({ slots, prices });
setIsDirty(false);
queryClient.invalidateQueries({ queryKey: ["tariff"] });
toast.success("电价配置已保存");
} catch {
toast.warning("保存失败,请稍候重试");
} finally {
setSaving(false);
}
@@ -269,6 +301,14 @@ export default function PricingPage() {
);
}
if (loadingTariff) {
return (
<div className="flex items-center justify-center py-24">
<Spinner size="lg" />
</div>
);
}
return (
<div className="mx-auto max-w-3xl space-y-6">
{/* ── Page header ───────────────────────────────────────────────────── */}
@@ -479,28 +519,6 @@ export default function PricingPage() {
</ul>
</div>
{/* ── API Payload preview ───────────────────────────────────────────── */}
<div className="overflow-hidden rounded-xl border border-border bg-surface-secondary">
<button
type="button"
className="flex w-full items-center justify-between px-5 py-4 text-left transition-colors hover:bg-surface-tertiary"
onClick={() => setShowPayload((v) => !v)}
>
<div>
<p className="text-sm font-semibold text-foreground">API </p>
<p className="text-xs text-muted"> PUT /api/tariff </p>
</div>
<span className="shrink-0 text-xs text-muted">{showPayload ? "收起 ▲" : "展开 ▼"}</span>
</button>
{showPayload && (
<div className="border-t border-border px-5 py-4">
<pre className="overflow-x-auto rounded-lg bg-surface-tertiary p-4 text-xs leading-5 text-foreground">
{JSON.stringify(apiPayload, null, 2)}
</pre>
</div>
)}
</div>
{/* ── Actions ───────────────────────────────────────────────────────── */}
<div className="flex items-center justify-between pb-2">
<Button variant="danger-soft" size="sm" onPress={handleReset} isDisabled={saving}>