Tank Alert Management Page Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Build an admin-only page to monitor and manage tank level alerts with full CRUD operations and email sending capabilities.
Architecture: Feature-based structure under src/features/tank-alerts/ with service layer for database operations, custom hook for state management, and React components for UI. Reuses existing tankAlertService functions and extends with management-specific operations.
Tech Stack: React 19, TypeScript, Supabase, Tailwind CSS, React Icons, date-fns
Task 1: Create Types Definition
Files:
- Create:
src/features/tank-alerts/types.ts
Step 1: Create the types file
// src/features/tank-alerts/types.ts
/**
* Alert status derived from database fields:
* - pending: resolved_at = null AND notified = false
* - sent: notified = true AND resolved_at = null
* - resolved: resolved_at != null
*/
export type AlertStatus = "pending" | "sent" | "resolved";
/**
* Statistics for the dashboard overview
*/
export type TankAlertStats = {
pending: number;
sent: number;
resolved: number;
total: number;
};
/**
* List item combining alert data with tank information
*/
export type TankAlertListItem = {
// Alert basic info
id: string;
assetId: string;
triggeredAt: Date;
scheduledSendDate?: Date;
notified: boolean;
resolvedAt?: Date;
createdAt: Date;
// Tank info (joined from data_assets and cfg_tank_capacities)
assetDisplayId: string;
siteName: string;
// Real-time water level (calculated)
currentLitres?: number;
capacityLitres?: number;
percentRemaining?: number;
// Computed status
status: AlertStatus;
// Flag for deleted tanks
tankDeleted?: boolean;
};
/**
* Filter options for the alert list
*/
export type TankAlertFilters = {
status?: AlertStatus;
siteId?: string;
};Step 2: Commit
git add src/features/tank-alerts/types.ts
git commit -m "feat(tank-alerts): add type definitions"Task 2: Create Management Service
Files:
- Create:
src/features/tank-alerts/services/tankAlertManagementService.ts
Step 1: Create the service file with database operations
// src/features/tank-alerts/services/tankAlertManagementService.ts
/**
* Tank Alert Management Service
* Extends tankAlertService with management-specific operations
*/
/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */
import { supabase } from "@/lib/supabase";
import { format } from "date-fns";
import type {
AlertStatus,
TankAlertFilters,
TankAlertListItem,
TankAlertStats,
} from "../types";
import type { TankLevel } from "@/features/flow-meter/types";
import { fetchFlowMeterAssets } from "@/features/flow-meter/services/databaseService";
import { calculateAllTankLevels } from "@/features/flow-meter/services/tankService";
// =====================================================
// Type definitions for database rows
// =====================================================
interface TankLevelAlertRow {
id: string;
asset_id: string;
triggered_at: string;
resolved_at: string | null;
notified: boolean;
created_at: string;
scheduled_send_date: string | null;
}
interface AssetRow {
asset_id: string;
display_id: string;
}
interface CapacityRow {
asset_id: string;
site: string;
capacity_litres: number;
}
// =====================================================
// Helper Functions
// =====================================================
function deriveStatus(row: TankLevelAlertRow): AlertStatus {
if (row.resolved_at) return "resolved";
if (row.notified) return "sent";
return "pending";
}
// =====================================================
// Service Class
// =====================================================
export class TankAlertManagementService {
/**
* Get alert statistics
*/
static async getStats(): Promise<TankAlertStats> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data, error } = await (supabase as any)
.from("tank_level_alerts")
.select("id, notified, resolved_at");
if (error) {
throw new Error(`Failed to fetch alert stats: ${error.message}`);
}
const rows = (data ?? []) as TankLevelAlertRow[];
const stats: TankAlertStats = { pending: 0, sent: 0, resolved: 0, total: rows.length };
for (const row of rows) {
const status = deriveStatus(row);
stats[status]++;
}
return stats;
}
/**
* Get all alerts with optional filtering
*/
static async getAllAlerts(filters?: TankAlertFilters): Promise<TankAlertListItem[]> {
// 1. Fetch all alerts
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let query = (supabase as any)
.from("tank_level_alerts")
.select("*")
.order("triggered_at", { ascending: false });
// Apply status filter at query level where possible
if (filters?.status === "resolved") {
query = query.not("resolved_at", "is", null);
} else if (filters?.status === "sent") {
query = query.is("resolved_at", null).eq("notified", true);
} else if (filters?.status === "pending") {
query = query.is("resolved_at", null).eq("notified", false);
}
const { data: alertsData, error: alertsError } = await query;
if (alertsError) {
throw new Error(`Failed to fetch alerts: ${alertsError.message}`);
}
const alertRows = (alertsData ?? []) as TankLevelAlertRow[];
if (alertRows.length === 0) return [];
// 2. Get unique asset IDs
const assetIds = [...new Set(alertRows.map((a) => a.asset_id))];
// 3. Fetch asset display names
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data: assetsData } = await (supabase as any)
.from("data_assets")
.select("asset_id, display_id")
.in("asset_id", assetIds);
const assetsMap = new Map<string, AssetRow>();
for (const asset of (assetsData ?? []) as AssetRow[]) {
assetsMap.set(asset.asset_id, asset);
}
// 4. Fetch tank capacities (includes site name)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data: capacitiesData } = await (supabase as any)
.from("cfg_tank_capacities")
.select("asset_id, site, capacity_litres")
.in("asset_id", assetIds);
const capacitiesMap = new Map<string, CapacityRow>();
for (const cap of (capacitiesData ?? []) as CapacityRow[]) {
capacitiesMap.set(cap.asset_id, cap);
}
// 5. Calculate current tank levels
const flowMeterAssets = await fetchFlowMeterAssets();
const tankLevels = await calculateAllTankLevels(flowMeterAssets);
const tankLevelMap = new Map<string, TankLevel>();
for (const tank of tankLevels) {
tankLevelMap.set(tank.assetId, tank);
}
// 6. Build list items
let items: TankAlertListItem[] = alertRows.map((row) => {
const asset = assetsMap.get(row.asset_id);
const capacity = capacitiesMap.get(row.asset_id);
const tankLevel = tankLevelMap.get(row.asset_id);
return {
id: row.id,
assetId: row.asset_id,
triggeredAt: new Date(row.triggered_at),
scheduledSendDate: row.scheduled_send_date
? new Date(row.scheduled_send_date)
: undefined,
notified: row.notified,
resolvedAt: row.resolved_at ? new Date(row.resolved_at) : undefined,
createdAt: new Date(row.created_at),
assetDisplayId: asset?.display_id ?? row.asset_id,
siteName: capacity?.site ?? "Unknown",
currentLitres: tankLevel?.currentRemainingLitres,
capacityLitres: tankLevel?.capacityLitres ?? capacity?.capacity_litres,
percentRemaining: tankLevel?.percentRemaining,
status: deriveStatus(row),
tankDeleted: !asset,
};
});
// 7. Apply site filter (client-side since we need joined data)
if (filters?.siteId) {
items = items.filter((item) => item.siteName === filters.siteId);
}
return items;
}
/**
* Update scheduled send date
*/
static async updateScheduledDate(alertId: string, newDate: Date): Promise<void> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { error } = await (supabase as any)
.from("tank_level_alerts")
.update({ scheduled_send_date: format(newDate, "yyyy-MM-dd") })
.eq("id", alertId);
if (error) {
throw new Error(`Failed to update scheduled date: ${error.message}`);
}
}
/**
* Mark alerts as sent (without sending email)
*/
static async markAsSent(alertIds: string[]): Promise<void> {
if (alertIds.length === 0) return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { error } = await (supabase as any)
.from("tank_level_alerts")
.update({ notified: true })
.in("id", alertIds);
if (error) {
throw new Error(`Failed to mark alerts as sent: ${error.message}`);
}
}
/**
* Mark alerts as resolved
*/
static async markAsResolved(alertIds: string[]): Promise<void> {
if (alertIds.length === 0) return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { error } = await (supabase as any)
.from("tank_level_alerts")
.update({ resolved_at: new Date().toISOString() })
.in("id", alertIds);
if (error) {
throw new Error(`Failed to mark alerts as resolved: ${error.message}`);
}
}
/**
* Delete alerts
*/
static async deleteAlerts(alertIds: string[]): Promise<void> {
if (alertIds.length === 0) return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { error } = await (supabase as any)
.from("tank_level_alerts")
.delete()
.in("id", alertIds);
if (error) {
throw new Error(`Failed to delete alerts: ${error.message}`);
}
}
/**
* Send alert email immediately via Edge Function
*/
static async sendAlertEmailNow(alertIds: string[]): Promise<void> {
if (alertIds.length === 0) return;
// Update scheduled_send_date to today so the Edge Function picks them up
const today = format(new Date(), "yyyy-MM-dd");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { error: updateError } = await (supabase as any)
.from("tank_level_alerts")
.update({ scheduled_send_date: today, notified: false })
.in("id", alertIds);
if (updateError) {
throw new Error(`Failed to update alerts for sending: ${updateError.message}`);
}
// Call the Edge Function
const { error: fnError } = await supabase.functions.invoke(
"send-tank-level-alert",
{ body: {} }
);
if (fnError) {
throw new Error(`Failed to send alert email: ${fnError.message}`);
}
}
/**
* Get unique site names from alerts for filtering
*/
static async getAlertSites(): Promise<string[]> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data: alertsData } = await (supabase as any)
.from("tank_level_alerts")
.select("asset_id");
if (!alertsData || alertsData.length === 0) return [];
const assetIds = [...new Set((alertsData as { asset_id: string }[]).map((a) => a.asset_id))];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data: capacitiesData } = await (supabase as any)
.from("cfg_tank_capacities")
.select("site")
.in("asset_id", assetIds);
const sites = new Set<string>();
for (const cap of (capacitiesData ?? []) as { site: string }[]) {
if (cap.site) sites.add(cap.site);
}
return [...sites].sort();
}
}Step 2: Commit
git add src/features/tank-alerts/services/tankAlertManagementService.ts
git commit -m "feat(tank-alerts): add management service with CRUD operations"Task 3: Create Custom Hook
Files:
- Create:
src/features/tank-alerts/hooks/useTankAlerts.ts
Step 1: Create the hook file
// src/features/tank-alerts/hooks/useTankAlerts.ts
import { useCallback, useEffect, useState } from "react";
import { TankAlertManagementService } from "../services/tankAlertManagementService";
import type {
AlertStatus,
TankAlertFilters,
TankAlertListItem,
TankAlertStats,
} from "../types";
export function useTankAlerts(initialFilters?: TankAlertFilters) {
const [alerts, setAlerts] = useState<TankAlertListItem[]>([]);
const [stats, setStats] = useState<TankAlertStats>({
pending: 0,
sent: 0,
resolved: 0,
total: 0,
});
const [sites, setSites] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [filters, setFilters] = useState<TankAlertFilters>(initialFilters ?? {});
const refreshStats = useCallback(async () => {
try {
const data = await TankAlertManagementService.getStats();
setStats(data);
} catch (err) {
console.error("Failed to load stats:", err);
}
}, []);
const refreshAlerts = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await TankAlertManagementService.getAllAlerts(filters);
setAlerts(data);
} catch (err) {
console.error("Failed to load alerts:", err);
setError(err instanceof Error ? err.message : "Failed to load alerts");
} finally {
setLoading(false);
}
}, [filters]);
const refreshSites = useCallback(async () => {
try {
const data = await TankAlertManagementService.getAlertSites();
setSites(data);
} catch (err) {
console.error("Failed to load sites:", err);
}
}, []);
useEffect(() => {
void refreshStats();
void refreshAlerts();
void refreshSites();
}, [refreshStats, refreshAlerts, refreshSites]);
useEffect(() => {
const interval = setInterval(() => void refreshStats(), 60000);
return () => clearInterval(interval);
}, [refreshStats]);
const setStatusFilter = useCallback((status?: AlertStatus) => {
setFilters((prev) => ({ ...prev, status }));
}, []);
const updateScheduledDate = useCallback(
async (alertId: string, newDate: Date) => {
await TankAlertManagementService.updateScheduledDate(alertId, newDate);
await refreshAlerts();
await refreshStats();
},
[refreshAlerts, refreshStats]
);
const markAsSent = useCallback(
async (alertIds: string[]) => {
await TankAlertManagementService.markAsSent(alertIds);
await refreshAlerts();
await refreshStats();
},
[refreshAlerts, refreshStats]
);
const markAsResolved = useCallback(
async (alertIds: string[]) => {
await TankAlertManagementService.markAsResolved(alertIds);
await refreshAlerts();
await refreshStats();
},
[refreshAlerts, refreshStats]
);
const deleteAlerts = useCallback(
async (alertIds: string[]) => {
await TankAlertManagementService.deleteAlerts(alertIds);
await refreshAlerts();
await refreshStats();
},
[refreshAlerts, refreshStats]
);
const sendAlertEmailNow = useCallback(
async (alertIds: string[]) => {
await TankAlertManagementService.sendAlertEmailNow(alertIds);
await refreshAlerts();
await refreshStats();
},
[refreshAlerts, refreshStats]
);
return {
alerts, stats, sites, loading, error, filters,
setStatusFilter, refreshAlerts, refreshStats,
updateScheduledDate, markAsSent, markAsResolved, deleteAlerts, sendAlertEmailNow,
};
}Step 2: Commit
git add src/features/tank-alerts/hooks/useTankAlerts.ts
git commit -m "feat(tank-alerts): add useTankAlerts hook"Task 4: Create MiniTankIndicator Component
Files:
- Create:
src/features/tank-alerts/components/MiniTankIndicator.tsx
Step 1: Create the mini tank visualization component
// src/features/tank-alerts/components/MiniTankIndicator.tsx
interface MiniTankIndicatorProps {
percentRemaining?: number;
size?: "sm" | "md";
}
export function MiniTankIndicator({ percentRemaining, size = "sm" }: MiniTankIndicatorProps) {
const dimensions = size === "sm" ? { width: 24, height: 36 } : { width: 32, height: 48 };
const { width, height } = dimensions;
if (percentRemaining === undefined || isNaN(percentRemaining)) {
return (
<div
className="flex items-center justify-center bg-gray-100 dark:bg-slate-700 rounded"
style={{ width, height }}
>
<span className="text-xs text-gray-400">--</span>
</div>
);
}
const clampedPercent = Math.max(0, Math.min(100, percentRemaining));
const liquidHeight = (clampedPercent / 100) * (height - 8);
let liquidColor = "#22c55e";
if (clampedPercent <= 20) liquidColor = "#ef4444";
else if (clampedPercent <= 50) liquidColor = "#f97316";
return (
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`}>
<rect x={2} y={4} width={width - 4} height={height - 8} rx={3}
fill="none" stroke="#94a3b8" strokeWidth={1.5} />
<rect x={3} y={height - 4 - liquidHeight} width={width - 6} height={liquidHeight}
rx={2} fill={liquidColor} opacity={0.8} />
<ellipse cx={width / 2} cy={4} rx={(width - 4) / 2} ry={2}
fill="#e2e8f0" stroke="#94a3b8" strokeWidth={1} />
<ellipse cx={width / 2} cy={height - 4} rx={(width - 4) / 2} ry={2}
fill="#e2e8f0" stroke="#94a3b8" strokeWidth={1} />
</svg>
);
}Step 2: Commit
git add src/features/tank-alerts/components/MiniTankIndicator.tsx
git commit -m "feat(tank-alerts): add MiniTankIndicator component"Task 5: Create TankAlertStats Component
Files:
- Create:
src/features/tank-alerts/components/TankAlertStats.tsx
Step 1: Create the statistics cards component
// src/features/tank-alerts/components/TankAlertStats.tsx
import type { AlertStatus, TankAlertStats as Stats } from "../types";
interface TankAlertStatsProps {
stats: Stats;
activeStatus?: AlertStatus;
onStatusClick: (status?: AlertStatus) => void;
}
const statCards = [
{ key: "pending", label: "待发送", color: "text-orange-600", bg: "bg-orange-50", border: "border-orange-200" },
{ key: "sent", label: "已发送", color: "text-green-600", bg: "bg-green-50", border: "border-green-200" },
{ key: "resolved", label: "已解决", color: "text-gray-600", bg: "bg-gray-50", border: "border-gray-200" },
{ key: "total", label: "总告警", color: "text-blue-600", bg: "bg-blue-50", border: "border-blue-200" },
] as const;
export function TankAlertStats({ stats, activeStatus, onStatusClick }: TankAlertStatsProps) {
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
{statCards.map((card) => {
const isActive = card.key === "total" ? !activeStatus : activeStatus === card.key;
return (
<button
key={card.key}
type="button"
onClick={() => onStatusClick(card.key === "total" ? undefined : card.key)}
className={`p-4 rounded-xl border-2 transition-all text-left ${card.bg} ${card.border}
${isActive ? "ring-2 ring-offset-2 ring-blue-500" : "hover:shadow-md"}`}
>
<p className="text-sm font-medium text-gray-500 mb-1">{card.label}</p>
<p className={`text-3xl font-bold ${card.color}`}>{stats[card.key]}</p>
</button>
);
})}
</div>
);
}Step 2: Commit
git add src/features/tank-alerts/components/TankAlertStats.tsx
git commit -m "feat(tank-alerts): add TankAlertStats component"Task 6: Create TankAlertTable Component
Files:
- Create:
src/features/tank-alerts/components/TankAlertTable.tsx
Step 1: Create the alert table component
// src/features/tank-alerts/components/TankAlertTable.tsx
import { useState } from "react";
import { format } from "date-fns";
import { MiniTankIndicator } from "./MiniTankIndicator";
import type { AlertStatus, TankAlertListItem } from "../types";
interface TankAlertTableProps {
alerts: TankAlertListItem[];
activeTab: AlertStatus;
selectedIds: string[];
onSelectionChange: (ids: string[]) => void;
onSendNow: (id: string) => void;
onChangeDate: (id: string) => void;
onMarkSent: (id: string) => void;
onMarkResolved: (id: string) => void;
onDelete: (id: string) => void;
}
function formatDateTime(date?: Date): string {
if (!date) return "--";
return format(date, "dd/MM/yyyy HH:mm");
}
function formatLevel(current?: number, capacity?: number, percent?: number): string {
if (current === undefined || capacity === undefined) return "--";
return `${current.toLocaleString()}L / ${capacity.toLocaleString()}L (${percent ?? 0}%)`;
}
export function TankAlertTable({
alerts,
activeTab,
selectedIds,
onSelectionChange,
onSendNow,
onChangeDate,
onMarkSent,
onMarkResolved,
onDelete,
}: TankAlertTableProps) {
const allSelected = alerts.length > 0 && selectedIds.length === alerts.length;
const toggleAll = () => {
if (allSelected) {
onSelectionChange([]);
} else {
onSelectionChange(alerts.map((a) => a.id));
}
};
const toggleOne = (id: string) => {
if (selectedIds.includes(id)) {
onSelectionChange(selectedIds.filter((i) => i !== id));
} else {
onSelectionChange([...selectedIds, id]);
}
};
if (alerts.length === 0) {
const emptyMessages = {
pending: "暂无待发送的告警",
sent: "暂无已发送的告警",
resolved: "暂无已解决的告警",
};
return (
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
{emptyMessages[activeTab]}
</div>
);
}
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-gray-50 dark:bg-slate-800">
<tr>
{activeTab === "pending" && (
<th className="w-10 px-3 py-3">
<input type="checkbox" checked={allSelected} onChange={toggleAll}
className="rounded border-gray-300" />
</th>
)}
<th className="w-14 px-3 py-3"></th>
<th className="px-3 py-3 text-left font-medium text-gray-600 dark:text-gray-300">Tank</th>
<th className="px-3 py-3 text-left font-medium text-gray-600 dark:text-gray-300">站点</th>
<th className="px-3 py-3 text-left font-medium text-gray-600 dark:text-gray-300">水位</th>
<th className="px-3 py-3 text-left font-medium text-gray-600 dark:text-gray-300">触发时间</th>
{activeTab === "pending" && (
<th className="px-3 py-3 text-left font-medium text-gray-600 dark:text-gray-300">计划发送</th>
)}
{activeTab === "sent" && (
<th className="px-3 py-3 text-left font-medium text-gray-600 dark:text-gray-300">发送时间</th>
)}
{activeTab === "resolved" && (
<th className="px-3 py-3 text-left font-medium text-gray-600 dark:text-gray-300">解决时间</th>
)}
<th className="px-3 py-3 text-right font-medium text-gray-600 dark:text-gray-300">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-slate-700">
{alerts.map((alert) => (
<tr key={alert.id} className="hover:bg-gray-50 dark:hover:bg-slate-800/50">
{activeTab === "pending" && (
<td className="px-3 py-3">
<input type="checkbox" checked={selectedIds.includes(alert.id)}
onChange={() => toggleOne(alert.id)} className="rounded border-gray-300" />
</td>
)}
<td className="px-3 py-3">
<MiniTankIndicator percentRemaining={alert.percentRemaining} />
</td>
<td className="px-3 py-3 font-medium text-gray-900 dark:text-white">
{alert.assetDisplayId}
{alert.tankDeleted && (
<span className="ml-2 text-xs text-red-500">(已删除)</span>
)}
</td>
<td className="px-3 py-3 text-gray-600 dark:text-gray-300">{alert.siteName}</td>
<td className="px-3 py-3 text-gray-600 dark:text-gray-300 font-mono text-xs">
{formatLevel(alert.currentLitres, alert.capacityLitres, alert.percentRemaining)}
</td>
<td className="px-3 py-3 text-gray-600 dark:text-gray-300">
{formatDateTime(alert.triggeredAt)}
</td>
{activeTab === "pending" && (
<td className="px-3 py-3 text-gray-600 dark:text-gray-300">
{formatDateTime(alert.scheduledSendDate)}
</td>
)}
{activeTab === "sent" && (
<td className="px-3 py-3 text-gray-600 dark:text-gray-300">
{formatDateTime(alert.triggeredAt)}
</td>
)}
{activeTab === "resolved" && (
<td className="px-3 py-3 text-gray-600 dark:text-gray-300">
{formatDateTime(alert.resolvedAt)}
</td>
)}
<td className="px-3 py-3 text-right">
<div className="flex justify-end gap-1">
{activeTab === "pending" && (
<>
<button onClick={() => onSendNow(alert.id)} title="立即发送"
className="p-1.5 text-blue-600 hover:bg-blue-50 rounded">📤</button>
<button onClick={() => onChangeDate(alert.id)} title="修改日期"
className="p-1.5 text-gray-600 hover:bg-gray-50 rounded">📅</button>
<button onClick={() => onMarkSent(alert.id)} title="标记已发送"
className="p-1.5 text-green-600 hover:bg-green-50 rounded">✅</button>
</>
)}
{(activeTab === "pending" || activeTab === "sent") && (
<button onClick={() => onMarkResolved(alert.id)} title="标记已解决"
className="p-1.5 text-gray-600 hover:bg-gray-50 rounded">✓</button>
)}
<button onClick={() => onDelete(alert.id)} title="删除"
className="p-1.5 text-red-600 hover:bg-red-50 rounded">🗑️</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}Step 2: Commit
git add src/features/tank-alerts/components/TankAlertTable.tsx
git commit -m "feat(tank-alerts): add TankAlertTable component"Task 7: Create Main Page Component
Files:
- Create:
src/features/tank-alerts/components/TankAlertManagement.tsx
Step 1: Create the main page component
// src/features/tank-alerts/components/TankAlertManagement.tsx
import { useState, useCallback } from "react";
import toast from "react-hot-toast";
import { useTankAlerts } from "../hooks/useTankAlerts";
import { TankAlertStats } from "./TankAlertStats";
import { TankAlertTable } from "./TankAlertTable";
import type { AlertStatus } from "../types";
const tabs: { key: AlertStatus; label: string }[] = [
{ key: "pending", label: "待发送" },
{ key: "sent", label: "已发送" },
{ key: "resolved", label: "已解决" },
];
export function TankAlertManagement() {
const {
alerts, stats, sites, loading, error, filters,
setStatusFilter, refreshAlerts,
updateScheduledDate, markAsSent, markAsResolved, deleteAlerts, sendAlertEmailNow,
} = useTankAlerts({ status: "pending" });
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [dateModalOpen, setDateModalOpen] = useState(false);
const [editingAlertId, setEditingAlertId] = useState<string | null>(null);
const activeTab = filters.status ?? "pending";
const handleTabChange = (status: AlertStatus) => {
setStatusFilter(status);
setSelectedIds([]);
};
const handleSendNow = useCallback(async (id: string) => {
if (!confirm("确定要立即发送这条告警邮件吗?")) return;
try {
await sendAlertEmailNow([id]);
toast.success("邮件发送成功");
} catch (err) {
toast.error(err instanceof Error ? err.message : "发送失败");
}
}, [sendAlertEmailNow]);
const handleBulkSend = useCallback(async () => {
if (selectedIds.length === 0) return;
if (!confirm(`确定要立即发送 ${selectedIds.length} 条告警邮件吗?`)) return;
try {
await sendAlertEmailNow(selectedIds);
toast.success(`成功发送 ${selectedIds.length} 条告警邮件`);
setSelectedIds([]);
} catch (err) {
toast.error(err instanceof Error ? err.message : "发送失败");
}
}, [selectedIds, sendAlertEmailNow]);
const handleMarkSent = useCallback(async (id: string) => {
try {
await markAsSent([id]);
toast.success("已标记为已发送");
} catch (err) {
toast.error(err instanceof Error ? err.message : "操作失败");
}
}, [markAsSent]);
const handleMarkResolved = useCallback(async (id: string) => {
try {
await markAsResolved([id]);
toast.success("已标记为已解决");
} catch (err) {
toast.error(err instanceof Error ? err.message : "操作失败");
}
}, [markAsResolved]);
const handleDelete = useCallback(async (id: string) => {
if (!confirm("确定要删除这条告警记录吗?此操作不可恢复。")) return;
try {
await deleteAlerts([id]);
toast.success("删除成功");
} catch (err) {
toast.error(err instanceof Error ? err.message : "删除失败");
}
}, [deleteAlerts]);
const handleBulkDelete = useCallback(async () => {
if (selectedIds.length === 0) return;
if (!confirm(`确定要删除 ${selectedIds.length} 条告警记录吗?此操作不可恢复。`)) return;
try {
await deleteAlerts(selectedIds);
toast.success(`成功删除 ${selectedIds.length} 条记录`);
setSelectedIds([]);
} catch (err) {
toast.error(err instanceof Error ? err.message : "删除失败");
}
}, [selectedIds, deleteAlerts]);
const handleChangeDate = (id: string) => {
setEditingAlertId(id);
setDateModalOpen(true);
};
return (
<div className="p-6">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Tank Alert Management
</h1>
{activeTab === "pending" && selectedIds.length > 0 && (
<div className="flex gap-2">
<button onClick={handleBulkSend}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
批量发送 ({selectedIds.length})
</button>
<button onClick={handleBulkDelete}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700">
批量删除 ({selectedIds.length})
</button>
</div>
)}
</div>
<TankAlertStats stats={stats} activeStatus={filters.status} onStatusClick={setStatusFilter} />
<div className="bg-white dark:bg-slate-900 rounded-xl shadow-sm border border-gray-200 dark:border-slate-700">
<div className="border-b border-gray-200 dark:border-slate-700">
<nav className="flex -mb-px">
{tabs.map((tab) => (
<button
key={tab.key}
onClick={() => handleTabChange(tab.key)}
className={`px-6 py-4 text-sm font-medium border-b-2 transition-colors
${activeTab === tab.key
? "border-blue-500 text-blue-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
}`}
>
{tab.label}
</button>
))}
</nav>
</div>
{loading ? (
<div className="p-12 text-center text-gray-500">加载中...</div>
) : error ? (
<div className="p-12 text-center text-red-500">{error}</div>
) : (
<TankAlertTable
alerts={alerts}
activeTab={activeTab}
selectedIds={selectedIds}
onSelectionChange={setSelectedIds}
onSendNow={handleSendNow}
onChangeDate={handleChangeDate}
onMarkSent={handleMarkSent}
onMarkResolved={handleMarkResolved}
onDelete={handleDelete}
/>
)}
</div>
</div>
);
}
export default TankAlertManagement;Step 2: Commit
git add src/features/tank-alerts/components/TankAlertManagement.tsx
git commit -m "feat(tank-alerts): add main TankAlertManagement page component"Task 8: Create Page Entry Point
Files:
- Create:
src/app/(admin)/(pages)/tank-alerts/index.tsx
Step 1: Create the page entry point
// src/app/(admin)/(pages)/tank-alerts/index.tsx
import PageMeta from "@/components/PageMeta";
import { TankAlertManagement } from "@/features/tank-alerts/components/TankAlertManagement";
export default function TankAlertsPage() {
return (
<>
<PageMeta title="Tank Alerts | Dust Ranger" />
<TankAlertManagement />
</>
);
}Step 2: Commit
git add src/app/\(admin\)/\(pages\)/tank-alerts/index.tsx
git commit -m "feat(tank-alerts): add page entry point"Task 9: Add Route Configuration
Files:
- Modify:
src/routes/Routes.tsx
Step 1: Add lazy import at the top of the file (around line 140)
Find the line with const TankConfiguration = lazy( and add after it:
const TankAlerts = lazy(
() => import("@/app/(admin)/(pages)/tank-alerts")
);Step 2: Add route configuration in layoutsRoutes array (around line 450)
Find the route for /tank-configuration and add after it:
{
path: "/tank-alerts",
name: "TankAlerts",
element: <TankAlerts />,
adminOnly: true,
},Step 3: Commit
git add src/routes/Routes.tsx
git commit -m "feat(tank-alerts): add route configuration"Task 10: Add Navigation Menu Item
Files:
- Modify:
src/components/layouts/SideNav/menu.ts
Step 1: Add icon import at the top
Add to the imports from react-icons/io5:
import {
IoBarChart,
IoPersonCircle,
IoSettings,
IoDocumentText,
IoPeople,
IoAlertCircle, // Add this
} from "react-icons/io5";Step 2: Add menu item after EmailSchedules (around line 70)
Find the EmailSchedules menu item and add after it:
{
key: "TankAlerts",
label: "Tank Alerts",
icon: IoAlertCircle,
href: "/tank-alerts",
adminOnly: true,
},Step 3: Commit
git add src/components/layouts/SideNav/menu.ts
git commit -m "feat(tank-alerts): add navigation menu item"Task 11: Add Breadcrumb Mapping
Files:
- Modify:
src/components/layouts/topbar/NewHeader.tsx
Step 1: Add breadcrumb mapping (around line 50)
Find the pathMap object and add:
"/tank-alerts": { parent: "Messaging", current: "Tank Alerts" },Step 2: Commit
git add src/components/layouts/topbar/NewHeader.tsx
git commit -m "feat(tank-alerts): add breadcrumb mapping"Task 12: Create Feature Index Export
Files:
- Create:
src/features/tank-alerts/index.ts
Step 1: Create the index file
// src/features/tank-alerts/index.ts
export * from "./types";
export * from "./hooks/useTankAlerts";
export * from "./services/tankAlertManagementService";
export { TankAlertManagement } from "./components/TankAlertManagement";
export { TankAlertStats } from "./components/TankAlertStats";
export { TankAlertTable } from "./components/TankAlertTable";
export { MiniTankIndicator } from "./components/MiniTankIndicator";Step 2: Commit
git add src/features/tank-alerts/index.ts
git commit -m "feat(tank-alerts): add feature index exports"Task 13: Add Date Picker Modal
Files:
- Create:
src/features/tank-alerts/components/DatePickerModal.tsx
Step 1: Create the date picker modal component
// src/features/tank-alerts/components/DatePickerModal.tsx
import { useState } from "react";
import { format } from "date-fns";
interface DatePickerModalProps {
isOpen: boolean;
currentDate?: Date;
onClose: () => void;
onConfirm: (date: Date) => void;
}
export function DatePickerModal({
isOpen,
currentDate,
onClose,
onConfirm,
}: DatePickerModalProps) {
const [selectedDate, setSelectedDate] = useState(
currentDate ? format(currentDate, "yyyy-MM-dd") : format(new Date(), "yyyy-MM-dd")
);
if (!isOpen) return null;
const handleConfirm = () => {
onConfirm(new Date(selectedDate));
onClose();
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
<div className="relative bg-white dark:bg-slate-800 rounded-xl p-6 shadow-xl w-80">
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">
修改发送日期
</h3>
<input
type="date"
value={selectedDate}
onChange={(e) => setSelectedDate(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg
bg-white dark:bg-slate-700 text-gray-900 dark:text-white mb-4"
/>
<div className="flex justify-end gap-2">
<button
onClick={onClose}
className="px-4 py-2 text-gray-600 dark:text-gray-300 hover:bg-gray-100
dark:hover:bg-slate-700 rounded-lg"
>
取消
</button>
<button
onClick={handleConfirm}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
确认
</button>
</div>
</div>
</div>
);
}Step 2: Update TankAlertManagement to use DatePickerModal
Add import and integrate the modal in TankAlertManagement.tsx.
Step 3: Commit
git add src/features/tank-alerts/components/DatePickerModal.tsx
git commit -m "feat(tank-alerts): add DatePickerModal component"Task 14: Final Integration and Testing
Step 1: Run lint check
pnpm lintStep 2: Run type check
pnpm build:checkStep 3: Start dev server and test manually
pnpm devTest checklist:
- [ ] Navigate to
/tank-alertsas admin - [ ] Verify stats cards display correctly
- [ ] Test tab switching (pending/sent/resolved)
- [ ] Test single alert actions (send, mark sent, mark resolved, delete)
- [ ] Test bulk selection and bulk actions
- [ ] Test date picker modal
- [ ] Verify non-admin users cannot access the page
Step 4: Final commit
git add -A
git commit -m "feat(tank-alerts): complete tank alert management page"Summary
This implementation plan creates a complete Tank Alert Management page with:
- Types (
types.ts) - AlertStatus, TankAlertStats, TankAlertListItem, TankAlertFilters - Service (
tankAlertManagementService.ts) - CRUD operations, stats, email sending - Hook (
useTankAlerts.ts) - State management, auto-refresh, filter handling - Components:
MiniTankIndicator- Mini tank visualizationTankAlertStats- Statistics cardsTankAlertTable- Alert list with actionsDatePickerModal- Date selection modalTankAlertManagement- Main page component
- Integration:
- Route configuration
- Navigation menu item
- Breadcrumb mapping
- Feature exports
Total estimated time: 2-3 hours