{{ theme.skipToContentLabel || 'Skip to content' }}

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

typescript
// 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

bash
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

typescript
// 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

bash
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

typescript
// 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

bash
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

typescript
// 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

bash
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

typescript
// 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

bash
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

typescript
// 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

bash
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

typescript
// 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

bash
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

typescript
// 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

bash
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:

typescript
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:

typescript
  {
    path: "/tank-alerts",
    name: "TankAlerts",
    element: <TankAlerts />,
    adminOnly: true,
  },

Step 3: Commit

bash
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:

typescript
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:

typescript
  {
    key: "TankAlerts",
    label: "Tank Alerts",
    icon: IoAlertCircle,
    href: "/tank-alerts",
    adminOnly: true,
  },

Step 3: Commit

bash
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:

typescript
    "/tank-alerts": { parent: "Messaging", current: "Tank Alerts" },

Step 2: Commit

bash
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

typescript
// 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

bash
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

typescript
// 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

bash
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

bash
pnpm lint

Step 2: Run type check

bash
pnpm build:check

Step 3: Start dev server and test manually

bash
pnpm dev

Test checklist:

  • [ ] Navigate to /tank-alerts as 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

bash
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:

  1. Types (types.ts) - AlertStatus, TankAlertStats, TankAlertListItem, TankAlertFilters
  2. Service (tankAlertManagementService.ts) - CRUD operations, stats, email sending
  3. Hook (useTankAlerts.ts) - State management, auto-refresh, filter handling
  4. Components:
    • MiniTankIndicator - Mini tank visualization
    • TankAlertStats - Statistics cards
    • TankAlertTable - Alert list with actions
    • DatePickerModal - Date selection modal
    • TankAlertManagement - Main page component
  5. Integration:
    • Route configuration
    • Navigation menu item
    • Breadcrumb mapping
    • Feature exports

Total estimated time: 2-3 hours