Files
helios-evcs/apps/web/app/dashboard/settings/pricing/page.tsx

520 lines
22 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, useCallback, useEffect, useRef } from "react";
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";
// ─────────────────────────────────────────────────────────────────────────────
// Types (designed for future backend API integration)
// Backend API: PUT /api/tariff
// ─────────────────────────────────────────────────────────────────────────────
export type PriceTier = "peak" | "valley" | "flat";
/**
* Compact time slot for the backend API.
* `start`: inclusive hour (023), `end`: exclusive hour (124).
* Example: { start: 8, end: 12, tier: "peak" } → 08:0012:00 峰时
*/
export type TimeSlot = {
start: number;
end: number;
tier: PriceTier;
};
export type TierPricing = {
/** Grid electricity tariff in CNY/kWh */
electricityPrice: number;
/** Charging service fee in CNY/kWh */
serviceFee: number;
};
/**
* Full tariff configuration payload.
* Intended for: PUT /api/tariff
*/
export type TariffConfig = {
/** Compact slot representation — what gets stored in the DB */
slots: TimeSlot[];
/** Electricity price + service fee breakdown per tier, in CNY/kWh */
prices: Record<PriceTier, TierPricing>;
};
// ─────────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────────
/** Compress a 24-element hourly schedule into minimal time slots */
function scheduleToSlots(schedule: PriceTier[]): TimeSlot[] {
const slots: TimeSlot[] = [];
let i = 0;
while (i < 24) {
const tier = schedule[i];
let j = i + 1;
while (j < 24 && schedule[j] === tier) j++;
slots.push({ start: i, end: j, tier });
i = j;
}
return slots;
}
// ─────────────────────────────────────────────────────────────────────────────
// Constants
// ─────────────────────────────────────────────────────────────────────────────
const TIERS: PriceTier[] = ["peak", "valley", "flat"];
const TIER_META: Record<
PriceTier,
{
label: string;
sublabel: string;
cellBg: string;
activeBorder: string;
activeBg: string;
activeText: string;
dotClass: string;
}
> = {
peak: {
label: "峰时",
sublabel: "高峰时段",
cellBg: "bg-orange-500/70",
activeBorder: "border-orange-500/60",
activeBg: "bg-orange-500/10",
activeText: "text-orange-500",
dotClass: "bg-orange-500",
},
valley: {
label: "谷时",
sublabel: "低谷时段",
cellBg: "bg-blue-500/65",
activeBorder: "border-blue-500/60",
activeBg: "bg-blue-500/10",
activeText: "text-blue-500",
dotClass: "bg-blue-500",
},
flat: {
label: "平时",
sublabel: "肩峰时段",
cellBg: "bg-neutral-400/45",
activeBorder: "border-neutral-400/60",
activeBg: "bg-neutral-400/10",
activeText: "text-neutral-400",
dotClass: "bg-neutral-400",
},
};
/**
* A typical residential TOU schedule for the Shanghai area (illustrative).
* 0007 谷, 0811 峰, 12 平, 1316 峰, 1721 峰, 2223 谷
*/
const DEFAULT_SCHEDULE: PriceTier[] = [
// 0-5
"valley",
"valley",
"valley",
"valley",
"valley",
"valley",
// 6-11
"flat",
"flat",
"peak",
"peak",
"peak",
"flat",
// 12-17
"peak",
"peak",
"flat",
"flat",
"flat",
"peak",
// 18-23
"peak",
"peak",
"flat",
"flat",
"valley",
"valley",
];
const DEFAULT_PRICES: Record<PriceTier, TierPricing> = {
peak: { electricityPrice: 1.0, serviceFee: 0.2 },
valley: { electricityPrice: 0.3, serviceFee: 0.1 },
flat: { electricityPrice: 0.65, serviceFee: 0.15 },
};
// ─────────────────────────────────────────────────────────────────────────────
// Component
// ─────────────────────────────────────────────────────────────────────────────
export default function PricingPage() {
const { data: session } = useSession();
const isAdmin = session?.user?.role === "admin";
const [schedule, setSchedule] = useState<PriceTier[]>([...DEFAULT_SCHEDULE]);
// ── Price inputs: keep as strings to avoid controlled input jitter ──────
type TierPricingStrings = { electricityPrice: string; serviceFee: string };
const toStrings = (p: TierPricing): TierPricingStrings => ({
electricityPrice: String(p.electricityPrice),
serviceFee: String(p.serviceFee),
});
const [priceStrings, setPriceStrings] = useState<Record<PriceTier, TierPricingStrings>>({
peak: toStrings(DEFAULT_PRICES.peak),
valley: toStrings(DEFAULT_PRICES.valley),
flat: toStrings(DEFAULT_PRICES.flat),
});
const [prices, setPrices] = useState<Record<PriceTier, TierPricing>>({ ...DEFAULT_PRICES });
const [activeTier, setActiveTier] = useState<PriceTier>("peak");
const [isDirty, setIsDirty] = useState(false);
const [saving, setSaving] = useState(false);
const [showPayload, setShowPayload] = useState(false);
// ── Drag state via ref (avoids stale closures in global handler) ─────────
const dragRef = useRef({
active: false,
startHour: 0,
endHour: 0,
tier: "peak" as PriceTier,
});
const [dragHighlight, setDragHighlight] = useState<[number, number] | null>(null);
const commitDrag = useCallback(() => {
if (!dragRef.current.active) return;
const { startHour, endHour, tier } = dragRef.current;
const lo = Math.min(startHour, endHour);
const hi = Math.max(startHour, endHour);
dragRef.current.active = false;
setDragHighlight(null);
setSchedule((prev) => {
const next = [...prev];
for (let i = lo; i <= hi; i++) next[i] = tier;
return next;
});
setIsDirty(true);
}, []); // reads from ref, no stale closure
useEffect(() => {
window.addEventListener("mouseup", commitDrag);
return () => window.removeEventListener("mouseup", commitDrag);
}, [commitDrag]);
const handleCellMouseDown = (hour: number, e: React.MouseEvent) => {
e.preventDefault();
dragRef.current = { active: true, startHour: hour, endHour: hour, tier: activeTier };
setDragHighlight([hour, hour]);
};
const handleCellMouseEnter = (hour: number) => {
if (!dragRef.current.active) return;
dragRef.current.endHour = hour;
setDragHighlight([dragRef.current.startHour, hour]);
};
// ── Price input handlers ─────────────────────────────────────────────────
const handlePriceChange = (tier: PriceTier, field: keyof TierPricing, value: string) => {
setPriceStrings((prev) => ({ ...prev, [tier]: { ...prev[tier], [field]: value } }));
const num = parseFloat(value);
if (!isNaN(num) && num >= 0) {
setPrices((prev) => ({ ...prev, [tier]: { ...prev[tier], [field]: num } }));
setIsDirty(true);
}
};
// ── Reset ────────────────────────────────────────────────────────────────
const handleReset = () => {
setSchedule([...DEFAULT_SCHEDULE]);
setPrices({ ...DEFAULT_PRICES });
setPriceStrings({
peak: toStrings(DEFAULT_PRICES.peak),
valley: toStrings(DEFAULT_PRICES.valley),
flat: toStrings(DEFAULT_PRICES.flat),
});
setIsDirty(false);
};
// ── Save (stub: simulates API call) ─────────────────────────────────────
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));
setIsDirty(false);
toast.success("电价配置已保存");
} finally {
setSaving(false);
}
};
// ── Derived values ───────────────────────────────────────────────────────
const slots = scheduleToSlots(schedule);
const apiPayload: TariffConfig = { slots, prices };
// ── Admin gate ───────────────────────────────────────────────────────────
if (!isAdmin) {
return (
<div className="flex flex-col items-center justify-center py-24 text-center">
<div className="mb-4 flex size-12 items-center justify-center rounded-full bg-warning/10">
<Lock className="size-6 text-warning" />
</div>
<p className="text-sm font-semibold text-foreground"></p>
<p className="mt-1 text-sm text-muted"></p>
</div>
);
}
return (
<div className="mx-auto max-w-3xl space-y-6">
{/* ── Page header ───────────────────────────────────────────────────── */}
<div className="flex items-start gap-2">
<div className="flex-1">
<div className="flex items-center gap-2">
<TagDollar className="size-5 text-foreground" />
<h1 className="text-xl font-semibold text-foreground"></h1>
</div>
<p className="mt-0.5 text-sm text-muted">
</p>
</div>
</div>
{/* ── Timeline editor ───────────────────────────────────────────────── */}
<div className="rounded-xl border border-border bg-surface-secondary">
<div className="flex items-center gap-3 border-b border-border px-5 py-4">
<div className="flex size-9 items-center justify-center rounded-lg bg-accent/10">
<ChartLine className="size-5 text-accent" />
</div>
<div>
<p className="text-sm font-semibold text-foreground"></p>
<p className="text-xs text-muted"></p>
</div>
</div>
<div className="space-y-4 px-5 py-5">
{/* Tier palette */}
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs text-muted"></span>
{TIERS.map((tier) => {
const meta = TIER_META[tier];
const isActive = activeTier === tier;
return (
<button
key={tier}
type="button"
onClick={() => setActiveTier(tier)}
className={[
"flex items-center gap-1.5 rounded-lg ring-2 px-3 py-1.5 text-sm font-medium transition-all select-none",
isActive
? `${meta.activeBorder} ${meta.activeBg} ${meta.activeText}`
: "ring-transparent text-muted hover:bg-surface-tertiary hover:text-foreground",
].join(" ")}
>
<span className={`size-2.5 shrink-0 rounded-sm ${meta.cellBg}`} />
{meta.label}
</button>
);
})}
</div>
{/* 24-hour grid */}
<div className="select-none">
{/* Hour tick labels at 0, 6, 12, 18, 24 */}
<div className="relative mb-1 h-4">
{[0, 6, 12, 18, 24].map((h) => (
<span
key={h}
className="absolute text-[10px] tabular-nums text-muted"
style={{
left: `${(h / 24) * 100}%`,
transform:
h === 24 ? "translateX(-100%)" : h > 0 ? "translateX(-50%)" : undefined,
}}
>
{String(h).padStart(2, "0")}:00
</span>
))}
</div>
{/* Hour cells */}
<div className="flex h-12 overflow-hidden rounded-lg">
{schedule.map((tier, hour) => {
const inDrag = dragHighlight
? hour >= Math.min(dragHighlight[0], dragHighlight[1]) &&
hour <= Math.max(dragHighlight[0], dragHighlight[1])
: false;
const displayTier = inDrag ? activeTier : tier;
const meta = TIER_META[displayTier];
return (
<div
key={hour}
className={[
"group relative flex h-full flex-1 cursor-crosshair flex-col items-center justify-center transition-colors",
meta.cellBg,
].join(" ")}
onMouseDown={(e) => handleCellMouseDown(hour, e)}
onMouseEnter={() => handleCellMouseEnter(hour)}
>
<span className="hidden text-[10px] font-semibold text-white drop-shadow lg:block">
{String(hour).padStart(2, "0")}
</span>
</div>
);
})}
</div>
{/* Legend */}
<div className="mt-2.5 flex flex-wrap items-center gap-2 gap-y-1">
{TIERS.map((tier) => {
const meta = TIER_META[tier];
const hours = schedule.filter((t) => t === tier).length;
return (
<div key={tier} className="flex items-center gap-1">
<span className={`size-3 rounded-sm ${meta.cellBg}`} />
<span className="text-xs text-muted">
{meta.label}
<span className="ml-1 text-muted/60">
({meta.sublabel} · {hours}h)
</span>
</span>
</div>
);
})}
</div>
</div>
</div>
</div>
{/* ── Price configuration ───────────────────────────────────────────── */}
<div className="rounded-xl border border-border bg-surface-secondary">
<div className="border-b border-border px-5 py-4">
<p className="text-sm font-semibold text-foreground"></p>
<p className="text-xs text-muted"> / kWh</p>
</div>
<div className="grid grid-cols-3 divide-x divide-border">
{TIERS.map((tier) => {
const meta = TIER_META[tier];
const hours = schedule.filter((t) => t === tier).length;
const pct = Math.round((hours / 24) * 100);
const effective = prices[tier].electricityPrice + prices[tier].serviceFee;
return (
<div key={tier} className="space-y-3 p-5">
<div className="flex items-center gap-2">
<span className={`size-2.5 shrink-0 rounded-full ${meta.dotClass}`} />
<span className={`text-sm font-semibold ${meta.activeText}`}>{meta.label}</span>
<span className="ml-auto text-xs tabular-nums text-muted">
{hours}h · {pct}%
</span>
</div>
<TextField fullWidth>
<Label className="text-xs text-muted">¥ / kWh</Label>
<Input
type="number"
min="0"
step="0.01"
placeholder="0.00"
value={priceStrings[tier].electricityPrice}
onChange={(e) => handlePriceChange(tier, "electricityPrice", e.target.value)}
/>
</TextField>
<TextField fullWidth>
<Label className="text-xs text-muted">¥ / kWh</Label>
<Input
type="number"
min="0"
step="0.01"
placeholder="0.00"
value={priceStrings[tier].serviceFee}
onChange={(e) => handlePriceChange(tier, "serviceFee", e.target.value)}
/>
</TextField>
<div className="rounded-lg bg-surface-tertiary px-3 py-2">
<div className="flex items-center justify-between">
<span className="text-xs text-muted"></span>
<span className={`text-sm font-semibold tabular-nums ${meta.activeText}`}>
¥{effective.toFixed(2)} /kWh
</span>
</div>
<p className="mt-0.5 text-xs text-muted">
1 kW · {hours}h ¥{(effective * hours).toFixed(2)} /
</p>
</div>
</div>
);
})}
</div>
</div>
{/* ── Time slots summary ────────────────────────────────────────────── */}
<div className="rounded-xl border border-border bg-surface-secondary">
<div className="border-b border-border px-5 py-4">
<p className="text-sm font-semibold text-foreground"></p>
<p className="text-xs text-muted"> {slots.length} </p>
</div>
<ul className="divide-y divide-border">
{slots.map((slot, i) => {
const meta = TIER_META[slot.tier];
return (
<li key={i} className="flex items-center gap-3 px-5 py-3">
<span className={`size-2.5 shrink-0 rounded-sm ${meta.cellBg}`} />
<span className={`w-9 text-sm font-semibold ${meta.activeText}`}>{meta.label}</span>
<span className="text-sm tabular-nums text-foreground">
{String(slot.start).padStart(2, "0")}:00
<span className="mx-1 text-muted"></span>
{String(slot.end).padStart(2, "0")}:00
</span>
<span className="text-xs text-muted">{slot.end - slot.start}h</span>
<span className="ml-auto text-sm tabular-nums text-foreground">
¥{(prices[slot.tier].electricityPrice + prices[slot.tier].serviceFee).toFixed(2)}
<span className="text-xs text-muted"> /kWh</span>
</span>
</li>
);
})}
</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}>
<ArrowRotateRight className="size-4" />
</Button>
<div className="flex items-center gap-3">
{isDirty && <span className="text-xs text-warning"></span>}
<Button size="sm" onPress={handleSave} isDisabled={saving || !isDirty}>
{saving ? <Spinner size="sm" color="current" /> : "保存配置"}
</Button>
</div>
</div>
</div>
);
}