Tank Manual Correction Feature Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Add manual tank correction tracking with cylinder visualization for Flow Meter tanks, allowing users to record accurate Dustloc remaining amounts and calculate current levels based on corrections, refills, and usage.
Architecture: New database tables (ops_tank_corrections, cfg_tank_capacities) with RLS policies. Service layer (tankService.ts) handles CRUD and remaining calculation logic. React components for cylinder visualization and management pages following existing patterns (RefillManagement, AddRefillModal).
Tech Stack: React 19, TypeScript, Supabase (PostgreSQL), Tailwind CSS, Iconify icons
Task 1: Database Migration - Create Tank Tables
Files:
- Create:
supabase/migrations/20260204000000_add_tank_corrections_and_capacities.sql
Step 1: Write the migration file
-- Tank Manual Correction Feature
-- Adds tables for manual tank corrections and tank capacity configuration
-- =====================================================
-- Table: ops_tank_corrections
-- Stores manual correction records for tank remaining amounts
-- =====================================================
CREATE TABLE IF NOT EXISTS ops_tank_corrections (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
site TEXT NOT NULL,
asset_id TEXT NOT NULL,
correction_datetime TIMESTAMPTZ NOT NULL,
remaining_litres NUMERIC NOT NULL,
corrected_by TEXT,
evidence_url TEXT,
notes TEXT,
mine_site_id UUID REFERENCES cfg_mine_sites(id),
created_by UUID REFERENCES auth.users(id),
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- Add comments for documentation
COMMENT ON TABLE ops_tank_corrections IS 'Manual tank correction records for accurate remaining amount tracking';
COMMENT ON COLUMN ops_tank_corrections.site IS 'Site name (denormalized for queries)';
COMMENT ON COLUMN ops_tank_corrections.asset_id IS 'Flow meter asset ID from data_assets';
COMMENT ON COLUMN ops_tank_corrections.correction_datetime IS 'When the correction measurement was taken';
COMMENT ON COLUMN ops_tank_corrections.remaining_litres IS 'Actual remaining litres at correction time';
COMMENT ON COLUMN ops_tank_corrections.corrected_by IS 'Name of person who performed correction';
COMMENT ON COLUMN ops_tank_corrections.evidence_url IS 'URL to evidence photo in storage';
COMMENT ON COLUMN ops_tank_corrections.notes IS 'Additional notes about the correction';
-- Create indexes for efficient queries
CREATE INDEX idx_ops_tank_corrections_site ON ops_tank_corrections(site);
CREATE INDEX idx_ops_tank_corrections_asset_id ON ops_tank_corrections(asset_id);
CREATE INDEX idx_ops_tank_corrections_datetime ON ops_tank_corrections(correction_datetime DESC);
CREATE INDEX idx_ops_tank_corrections_mine_site ON ops_tank_corrections(mine_site_id);
-- =====================================================
-- Table: cfg_tank_capacities
-- Stores tank capacity configuration per asset
-- =====================================================
CREATE TABLE IF NOT EXISTS cfg_tank_capacities (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
site TEXT NOT NULL,
asset_id TEXT NOT NULL UNIQUE,
capacity_litres NUMERIC NOT NULL,
mine_site_id UUID REFERENCES cfg_mine_sites(id),
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- Add comments for documentation
COMMENT ON TABLE cfg_tank_capacities IS 'Tank capacity configuration for each flow meter asset';
COMMENT ON COLUMN cfg_tank_capacities.capacity_litres IS 'Total tank capacity in litres';
-- Create indexes
CREATE INDEX idx_cfg_tank_capacities_site ON cfg_tank_capacities(site);
CREATE INDEX idx_cfg_tank_capacities_asset_id ON cfg_tank_capacities(asset_id);
-- =====================================================
-- RLS Policies for ops_tank_corrections
-- =====================================================
ALTER TABLE ops_tank_corrections ENABLE ROW LEVEL SECURITY;
-- Read access based on site permissions
CREATE POLICY "Users can view tank corrections for permitted sites"
ON ops_tank_corrections FOR SELECT
USING (
is_admin() OR
mine_site_id IN (SELECT get_user_permitted_sites())
);
-- Insert access for authenticated users with site permission
CREATE POLICY "Users can insert tank corrections for permitted sites"
ON ops_tank_corrections FOR INSERT
WITH CHECK (
auth.role() = 'authenticated' AND
(is_admin() OR mine_site_id IN (SELECT get_user_permitted_sites()))
);
-- Update access for authenticated users
CREATE POLICY "Users can update tank corrections for permitted sites"
ON ops_tank_corrections FOR UPDATE
USING (
is_admin() OR mine_site_id IN (SELECT get_user_permitted_sites())
)
WITH CHECK (
is_admin() OR mine_site_id IN (SELECT get_user_permitted_sites())
);
-- Delete access for authenticated users
CREATE POLICY "Users can delete tank corrections for permitted sites"
ON ops_tank_corrections FOR DELETE
USING (
is_admin() OR mine_site_id IN (SELECT get_user_permitted_sites())
);
-- =====================================================
-- RLS Policies for cfg_tank_capacities
-- =====================================================
ALTER TABLE cfg_tank_capacities ENABLE ROW LEVEL SECURITY;
-- Read access based on site permissions
CREATE POLICY "Users can view tank capacities for permitted sites"
ON cfg_tank_capacities FOR SELECT
USING (
is_admin() OR
mine_site_id IN (SELECT get_user_permitted_sites())
);
-- Insert/Update/Delete - admin only or users with site permission
CREATE POLICY "Users can insert tank capacities for permitted sites"
ON cfg_tank_capacities FOR INSERT
WITH CHECK (
auth.role() = 'authenticated' AND
(is_admin() OR mine_site_id IN (SELECT get_user_permitted_sites()))
);
CREATE POLICY "Users can update tank capacities for permitted sites"
ON cfg_tank_capacities FOR UPDATE
USING (is_admin() OR mine_site_id IN (SELECT get_user_permitted_sites()))
WITH CHECK (is_admin() OR mine_site_id IN (SELECT get_user_permitted_sites()));
CREATE POLICY "Users can delete tank capacities for permitted sites"
ON cfg_tank_capacities FOR DELETE
USING (is_admin() OR mine_site_id IN (SELECT get_user_permitted_sites()));
-- =====================================================
-- Trigger for updated_at on both tables
-- =====================================================
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ language 'plpgsql';
CREATE TRIGGER update_ops_tank_corrections_updated_at
BEFORE UPDATE ON ops_tank_corrections
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_cfg_tank_capacities_updated_at
BEFORE UPDATE ON cfg_tank_capacities
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();Step 2: Push migration to database
Run: pnpm supabase:push Expected: Migration applied successfully
Step 3: Regenerate TypeScript types
Run: pnpm supabase:types Expected: Types regenerated in src/lib/supabaseTypes.ts
Step 4: Commit
git add supabase/migrations/20260204000000_add_tank_corrections_and_capacities.sql src/lib/supabaseTypes.ts
git commit -m "feat(db): add tank corrections and capacities tables"Task 2: Add TypeScript Type Definitions
Files:
- Modify:
src/features/flow-meter/types.ts
Step 1: Add new type definitions
Add the following types at the end of src/features/flow-meter/types.ts:
// =====================================================
// Tank Correction Types
// =====================================================
export type TankCorrection = {
id: string;
site: string;
assetId: string;
assetDisplayId: string; // Display ID from data_assets table
correctionDatetime: Date;
remainingLitres: number;
correctedBy?: string;
evidenceUrl?: string;
notes?: string;
mineSiteId?: string;
createdBy?: string;
createdAt?: Date;
updatedAt?: Date;
};
export type TankCapacity = {
id: string;
site: string;
assetId: string;
assetDisplayId: string; // Display ID from data_assets table
capacityLitres: number;
mineSiteId?: string;
createdAt?: Date;
updatedAt?: Date;
};
export type TankStatus = {
assetId: string;
assetDisplayId: string;
site: string;
mineSiteId?: string;
// Capacity info
capacityLitres?: number;
hasCapacityConfig: boolean;
// Correction info
latestCorrection?: TankCorrection;
hasCorrectionRecord: boolean;
// Calculated values
currentRemainingLitres?: number;
refillsSinceCorrection: number;
usageSinceCorrection: number;
// Status
percentRemaining?: number;
status: TankStatusType;
};
export type TankStatusType =
| 'normal' // > 50% remaining
| 'attention' // 20-50% remaining
| 'low' // 0-20% remaining
| 'anomaly' // < 0% (data error)
| 'overflow' // > capacity
| 'no-capacity' // capacity not configured
| 'no-correction'; // no correction recordStep 2: Verify TypeScript compilation
Run: pnpm build:check Expected: No TypeScript errors
Step 3: Commit
git add src/features/flow-meter/types.ts
git commit -m "feat(types): add tank correction and capacity types"Task 3: Create Tank Service
Files:
- Create:
src/features/flow-meter/services/tankService.ts - Create:
src/features/flow-meter/services/tankService.test.ts
Step 1: Write the test file first
Create src/features/flow-meter/services/tankService.test.ts:
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock the supabase client
vi.mock('@/lib/supabase', () => ({
supabase: {
from: vi.fn().mockReturnThis(),
select: vi.fn().mockReturnThis(),
insert: vi.fn().mockReturnThis(),
update: vi.fn().mockReturnThis(),
delete: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
gt: vi.fn().mockReturnThis(),
order: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
single: vi.fn().mockReturnThis(),
storage: {
from: vi.fn().mockReturnValue({
upload: vi.fn().mockResolvedValue({ error: null }),
getPublicUrl: vi.fn().mockReturnValue({ data: { publicUrl: 'https://example.com/photo.jpg' } }),
}),
},
},
}));
describe('tankService', () => {
describe('calculateTankRemaining', () => {
it('should return undefined when no correction record exists', async () => {
const { calculateTankRemaining } = await import('./tankService');
const result = calculateTankRemaining({
latestCorrection: undefined,
refillsSinceCorrection: 0,
usageSinceCorrection: 0,
});
expect(result).toBeUndefined();
});
it('should calculate remaining correctly with correction only', async () => {
const { calculateTankRemaining } = await import('./tankService');
const result = calculateTankRemaining({
latestCorrection: { remainingLitres: 500 },
refillsSinceCorrection: 0,
usageSinceCorrection: 0,
});
expect(result).toBe(500);
});
it('should add refills and subtract usage from correction value', async () => {
const { calculateTankRemaining } = await import('./tankService');
const result = calculateTankRemaining({
latestCorrection: { remainingLitres: 500 },
refillsSinceCorrection: 200,
usageSinceCorrection: 100,
});
expect(result).toBe(600); // 500 + 200 - 100
});
it('should handle negative remaining (anomaly case)', async () => {
const { calculateTankRemaining } = await import('./tankService');
const result = calculateTankRemaining({
latestCorrection: { remainingLitres: 100 },
refillsSinceCorrection: 0,
usageSinceCorrection: 200,
});
expect(result).toBe(-100); // 100 + 0 - 200
});
});
describe('getTankStatusType', () => {
it('should return no-correction when no correction exists', async () => {
const { getTankStatusType } = await import('./tankService');
const result = getTankStatusType({
hasCapacityConfig: true,
hasCorrectionRecord: false,
percentRemaining: undefined,
currentRemainingLitres: undefined,
capacityLitres: 1000,
});
expect(result).toBe('no-correction');
});
it('should return no-capacity when capacity not configured', async () => {
const { getTankStatusType } = await import('./tankService');
const result = getTankStatusType({
hasCapacityConfig: false,
hasCorrectionRecord: true,
percentRemaining: undefined,
currentRemainingLitres: 500,
capacityLitres: undefined,
});
expect(result).toBe('no-capacity');
});
it('should return anomaly when remaining is negative', async () => {
const { getTankStatusType } = await import('./tankService');
const result = getTankStatusType({
hasCapacityConfig: true,
hasCorrectionRecord: true,
percentRemaining: -10,
currentRemainingLitres: -100,
capacityLitres: 1000,
});
expect(result).toBe('anomaly');
});
it('should return overflow when remaining exceeds capacity', async () => {
const { getTankStatusType } = await import('./tankService');
const result = getTankStatusType({
hasCapacityConfig: true,
hasCorrectionRecord: true,
percentRemaining: 120,
currentRemainingLitres: 1200,
capacityLitres: 1000,
});
expect(result).toBe('overflow');
});
it('should return low when below 20%', async () => {
const { getTankStatusType } = await import('./tankService');
const result = getTankStatusType({
hasCapacityConfig: true,
hasCorrectionRecord: true,
percentRemaining: 15,
currentRemainingLitres: 150,
capacityLitres: 1000,
});
expect(result).toBe('low');
});
it('should return attention when 20-50%', async () => {
const { getTankStatusType } = await import('./tankService');
const result = getTankStatusType({
hasCapacityConfig: true,
hasCorrectionRecord: true,
percentRemaining: 35,
currentRemainingLitres: 350,
capacityLitres: 1000,
});
expect(result).toBe('attention');
});
it('should return normal when above 50%', async () => {
const { getTankStatusType } = await import('./tankService');
const result = getTankStatusType({
hasCapacityConfig: true,
hasCorrectionRecord: true,
percentRemaining: 75,
currentRemainingLitres: 750,
capacityLitres: 1000,
});
expect(result).toBe('normal');
});
});
});Step 2: Run test to verify it fails
Run: pnpm test:unit src/features/flow-meter/services/tankService.test.ts Expected: FAIL - module not found
Step 3: Write the service implementation
Create src/features/flow-meter/services/tankService.ts:
/**
* Tank Service
* Handles CRUD operations for tank corrections and capacities,
* and calculates current tank remaining amounts.
*/
import { supabase } from '@/lib/supabase';
import type {
TankCorrection,
TankCapacity,
TankStatus,
TankStatusType,
FlowMeterAsset,
} from '../types';
// =====================================================
// Type definitions for database rows
// =====================================================
interface TankCorrectionRow {
id: string;
site: string;
asset_id: string;
correction_datetime: string;
remaining_litres: number;
corrected_by: string | null;
evidence_url: string | null;
notes: string | null;
mine_site_id: string | null;
created_by: string | null;
created_at: string | null;
updated_at: string | null;
data_assets?: { display_id: string } | null;
}
interface TankCapacityRow {
id: string;
site: string;
asset_id: string;
capacity_litres: number;
mine_site_id: string | null;
created_at: string | null;
updated_at: string | null;
data_assets?: { display_id: string } | null;
}
// =====================================================
// Mapping functions
// =====================================================
const mapCorrectionRow = (row: TankCorrectionRow): TankCorrection => ({
id: row.id,
site: row.site,
assetId: row.asset_id,
assetDisplayId: row.data_assets?.display_id ?? row.asset_id,
correctionDatetime: new Date(row.correction_datetime),
remainingLitres: Number(row.remaining_litres),
correctedBy: row.corrected_by ?? undefined,
evidenceUrl: row.evidence_url ?? undefined,
notes: row.notes ?? undefined,
mineSiteId: row.mine_site_id ?? undefined,
createdBy: row.created_by ?? undefined,
createdAt: row.created_at ? new Date(row.created_at) : undefined,
updatedAt: row.updated_at ? new Date(row.updated_at) : undefined,
});
const mapCapacityRow = (row: TankCapacityRow): TankCapacity => ({
id: row.id,
site: row.site,
assetId: row.asset_id,
assetDisplayId: row.data_assets?.display_id ?? row.asset_id,
capacityLitres: Number(row.capacity_litres),
mineSiteId: row.mine_site_id ?? undefined,
createdAt: row.created_at ? new Date(row.created_at) : undefined,
updatedAt: row.updated_at ? new Date(row.updated_at) : undefined,
});
// =====================================================
// Calculation Functions (exported for testing)
// =====================================================
/**
* Calculate current tank remaining based on correction, refills, and usage
* Formula: Latest Correction + Refills Since - Usage Since
*/
export function calculateTankRemaining(params: {
latestCorrection?: { remainingLitres: number };
refillsSinceCorrection: number;
usageSinceCorrection: number;
}): number | undefined {
if (!params.latestCorrection) {
return undefined;
}
return (
params.latestCorrection.remainingLitres +
params.refillsSinceCorrection -
params.usageSinceCorrection
);
}
/**
* Determine tank status type based on current state
*/
export function getTankStatusType(params: {
hasCapacityConfig: boolean;
hasCorrectionRecord: boolean;
percentRemaining?: number;
currentRemainingLitres?: number;
capacityLitres?: number;
}): TankStatusType {
// Priority 1: No correction record
if (!params.hasCorrectionRecord) {
return 'no-correction';
}
// Priority 2: No capacity configured
if (!params.hasCapacityConfig) {
return 'no-capacity';
}
// Priority 3: Data anomaly (negative remaining)
if (
params.currentRemainingLitres !== undefined &&
params.currentRemainingLitres < 0
) {
return 'anomaly';
}
// Priority 4: Overflow (remaining > capacity)
if (
params.percentRemaining !== undefined &&
params.percentRemaining > 100
) {
return 'overflow';
}
// Priority 5: Level-based status
if (params.percentRemaining !== undefined) {
if (params.percentRemaining <= 20) {
return 'low';
}
if (params.percentRemaining <= 50) {
return 'attention';
}
}
return 'normal';
}
// =====================================================
// Tank Correction CRUD Operations
// =====================================================
/**
* Fetch all tank corrections
*/
export async function fetchAllCorrections(): Promise<TankCorrection[]> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data, error } = await (supabase as any)
.from('ops_tank_corrections')
.select(`
*,
data_assets!ops_tank_corrections_asset_id_fkey(display_id)
`)
.order('correction_datetime', { ascending: false });
if (error) {
console.error('Error fetching tank corrections:', error);
throw new Error(`Failed to fetch tank corrections: ${error.message}`);
}
return (data as TankCorrectionRow[] ?? []).map(mapCorrectionRow);
}
/**
* Fetch latest correction for a specific asset
*/
export async function fetchLatestCorrectionForAsset(
assetId: string
): Promise<TankCorrection | null> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data, error } = await (supabase as any)
.from('ops_tank_corrections')
.select(`
*,
data_assets!ops_tank_corrections_asset_id_fkey(display_id)
`)
.eq('asset_id', assetId)
.order('correction_datetime', { ascending: false })
.limit(1)
.single();
if (error) {
if (error.code === 'PGRST116') {
// No rows found
return null;
}
console.error('Error fetching latest correction:', error);
throw new Error(`Failed to fetch latest correction: ${error.message}`);
}
return data ? mapCorrectionRow(data as TankCorrectionRow) : null;
}
/**
* Insert a new tank correction
*/
export async function insertCorrection(correction: {
site: string;
asset_id: string;
correction_datetime: string;
remaining_litres: number;
corrected_by?: string;
evidence_url?: string;
notes?: string;
mine_site_id?: string;
created_by?: string;
}): Promise<TankCorrection[]> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data, error } = await (supabase as any)
.from('ops_tank_corrections')
.insert([correction])
.select(`
*,
data_assets!ops_tank_corrections_asset_id_fkey(display_id)
`);
if (error) {
console.error('Error inserting tank correction:', error);
throw new Error(`Failed to insert tank correction: ${error.message}`);
}
return (data as TankCorrectionRow[] ?? []).map(mapCorrectionRow);
}
/**
* Update an existing tank correction
*/
export async function updateCorrection(
id: string,
updates: {
site?: string;
asset_id?: string;
correction_datetime?: string;
remaining_litres?: number;
corrected_by?: string | null;
evidence_url?: string | null;
notes?: string | null;
mine_site_id?: string;
}
): Promise<TankCorrection[]> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data, error } = await (supabase as any)
.from('ops_tank_corrections')
.update(updates)
.eq('id', id)
.select(`
*,
data_assets!ops_tank_corrections_asset_id_fkey(display_id)
`);
if (error) {
console.error('Error updating tank correction:', error);
throw new Error(`Failed to update tank correction: ${error.message}`);
}
return (data as TankCorrectionRow[] ?? []).map(mapCorrectionRow);
}
/**
* Delete a tank correction
*/
export async function deleteCorrection(id: string): Promise<boolean> {
const { error } = await supabase
.from('ops_tank_corrections')
.delete()
.eq('id', id);
if (error) {
console.error('Error deleting tank correction:', error);
throw new Error(`Failed to delete tank correction: ${error.message}`);
}
return true;
}
// =====================================================
// Tank Capacity CRUD Operations
// =====================================================
/**
* Fetch all tank capacities
*/
export async function fetchAllCapacities(): Promise<TankCapacity[]> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data, error } = await (supabase as any)
.from('cfg_tank_capacities')
.select(`
*,
data_assets!cfg_tank_capacities_asset_id_fkey(display_id)
`)
.order('site', { ascending: true });
if (error) {
console.error('Error fetching tank capacities:', error);
throw new Error(`Failed to fetch tank capacities: ${error.message}`);
}
return (data as TankCapacityRow[] ?? []).map(mapCapacityRow);
}
/**
* Fetch capacity for a specific asset
*/
export async function fetchCapacityForAsset(
assetId: string
): Promise<TankCapacity | null> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data, error } = await (supabase as any)
.from('cfg_tank_capacities')
.select(`
*,
data_assets!cfg_tank_capacities_asset_id_fkey(display_id)
`)
.eq('asset_id', assetId)
.single();
if (error) {
if (error.code === 'PGRST116') {
return null;
}
console.error('Error fetching tank capacity:', error);
throw new Error(`Failed to fetch tank capacity: ${error.message}`);
}
return data ? mapCapacityRow(data as TankCapacityRow) : null;
}
/**
* Insert or update tank capacity (upsert)
*/
export async function upsertCapacity(capacity: {
site: string;
asset_id: string;
capacity_litres: number;
mine_site_id?: string;
}): Promise<TankCapacity[]> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data, error } = await (supabase as any)
.from('cfg_tank_capacities')
.upsert([capacity], { onConflict: 'asset_id' })
.select(`
*,
data_assets!cfg_tank_capacities_asset_id_fkey(display_id)
`);
if (error) {
console.error('Error upserting tank capacity:', error);
throw new Error(`Failed to upsert tank capacity: ${error.message}`);
}
return (data as TankCapacityRow[] ?? []).map(mapCapacityRow);
}
/**
* Delete tank capacity
*/
export async function deleteCapacity(id: string): Promise<boolean> {
const { error } = await supabase
.from('cfg_tank_capacities')
.delete()
.eq('id', id);
if (error) {
console.error('Error deleting tank capacity:', error);
throw new Error(`Failed to delete tank capacity: ${error.message}`);
}
return true;
}
// =====================================================
// Tank Status Calculation
// =====================================================
/**
* Calculate tank status for all flow meter assets
* Aggregates corrections, refills, and usage data
*/
export async function calculateAllTankStatuses(
flowMeterAssets: FlowMeterAsset[]
): Promise<TankStatus[]> {
// Fetch all capacities and corrections
const [capacities, corrections] = await Promise.all([
fetchAllCapacities(),
fetchAllCorrections(),
]);
// Create lookup maps
const capacityMap = new Map(capacities.map((c) => [c.assetId, c]));
const correctionsByAsset = new Map<string, TankCorrection[]>();
for (const correction of corrections) {
const existing = correctionsByAsset.get(correction.assetId) ?? [];
existing.push(correction);
correctionsByAsset.set(correction.assetId, existing);
}
const statuses: TankStatus[] = [];
for (const asset of flowMeterAssets) {
const capacity = capacityMap.get(asset.assetId);
const assetCorrections = correctionsByAsset.get(asset.assetId) ?? [];
const latestCorrection = assetCorrections[0]; // Already sorted by datetime desc
let refillsSinceCorrection = 0;
let usageSinceCorrection = 0;
// If there's a correction, fetch refills and usage since that time
if (latestCorrection) {
const correctionTime = latestCorrection.correctionDatetime.toISOString();
// Fetch refills after correction
const { data: refillsData } = await supabase
.from('ops_dustloc_refills')
.select('litres_refilled')
.eq('asset_id', asset.assetId)
.gt('refill_datetime', correctionTime);
refillsSinceCorrection = (refillsData ?? []).reduce(
(sum, r) => sum + Number(r.litres_refilled ?? 0),
0
);
// Fetch usage after correction (non-ignored records)
const { data: usageData } = await supabase
.from('data_flow_meters')
.select('litres_dispensed')
.eq('asset_id', asset.assetId)
.eq('is_ignored', false)
.gt('datetime_dispensed', correctionTime);
usageSinceCorrection = (usageData ?? []).reduce(
(sum, r) => sum + Number(r.litres_dispensed ?? 0),
0
);
}
const currentRemainingLitres = calculateTankRemaining({
latestCorrection,
refillsSinceCorrection,
usageSinceCorrection,
});
const hasCapacityConfig = capacity !== undefined;
const hasCorrectionRecord = latestCorrection !== undefined;
const percentRemaining =
currentRemainingLitres !== undefined && capacity
? (currentRemainingLitres / capacity.capacityLitres) * 100
: undefined;
const status = getTankStatusType({
hasCapacityConfig,
hasCorrectionRecord,
percentRemaining,
currentRemainingLitres,
capacityLitres: capacity?.capacityLitres,
});
statuses.push({
assetId: asset.assetId,
assetDisplayId: asset.displayId,
site: '', // Will be populated from mine site
mineSiteId: asset.mineSiteId ?? undefined,
capacityLitres: capacity?.capacityLitres,
hasCapacityConfig,
latestCorrection,
hasCorrectionRecord,
currentRemainingLitres,
refillsSinceCorrection,
usageSinceCorrection,
percentRemaining,
status,
});
}
return statuses;
}
// =====================================================
// Storage Operations
// =====================================================
/**
* Upload correction evidence image to Supabase Storage
*/
export async function uploadCorrectionEvidence(file: File): Promise<string> {
const fileExt = file.name.split('.').pop();
const fileName = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${fileExt ? `.${fileExt}` : ''}`;
const filePath = `correction/${fileName}`;
const { error } = await supabase.storage
.from('calibration-evidence') // Reuse existing bucket
.upload(filePath, file, {
cacheControl: '3600',
upsert: false,
});
if (error) {
console.error('Error uploading correction evidence:', error);
const msg = error.message.toLowerCase();
if (msg.includes('bucket') && msg.includes('not found')) {
throw new Error(
"Failed to upload evidence: bucket 'calibration-evidence' is missing."
);
}
throw new Error(`Failed to upload evidence: ${error.message}`);
}
const { data: publicUrlData } = supabase.storage
.from('calibration-evidence')
.getPublicUrl(filePath);
return publicUrlData.publicUrl;
}Step 4: Run test to verify it passes
Run: pnpm test:unit src/features/flow-meter/services/tankService.test.ts Expected: All tests PASS
Step 5: Commit
git add src/features/flow-meter/services/tankService.ts src/features/flow-meter/services/tankService.test.ts
git commit -m "feat(service): add tank service with correction and capacity CRUD"Task 4: Create TankCylinder Visualization Component
Files:
- Create:
src/features/flow-meter/components/TankCylinder.tsx
Step 1: Create the component
import { useMemo } from 'react';
import { Icon } from '@iconify/react';
import type { TankStatus, TankStatusType } from '../types';
interface TankCylinderProps {
status: TankStatus;
onClick?: () => void;
}
const STATUS_COLORS: Record<TankStatusType, { fill: string; text: string; bg: string }> = {
normal: {
fill: 'from-blue-500 to-blue-600',
text: 'text-blue-600 dark:text-blue-400',
bg: 'bg-blue-50 dark:bg-blue-900/20',
},
attention: {
fill: 'from-orange-500 to-orange-600',
text: 'text-orange-600 dark:text-orange-400',
bg: 'bg-orange-50 dark:bg-orange-900/20',
},
low: {
fill: 'from-red-500 to-red-600',
text: 'text-red-600 dark:text-red-400',
bg: 'bg-red-50 dark:bg-red-900/20',
},
anomaly: {
fill: 'from-red-700 to-red-800',
text: 'text-red-700 dark:text-red-500',
bg: 'bg-red-100 dark:bg-red-900/30',
},
overflow: {
fill: 'from-amber-500 to-amber-600',
text: 'text-amber-600 dark:text-amber-400',
bg: 'bg-amber-50 dark:bg-amber-900/20',
},
'no-capacity': {
fill: 'from-slate-400 to-slate-500',
text: 'text-slate-500 dark:text-slate-400',
bg: 'bg-slate-50 dark:bg-slate-800',
},
'no-correction': {
fill: 'from-slate-300 to-slate-400',
text: 'text-slate-400 dark:text-slate-500',
bg: 'bg-slate-50 dark:bg-slate-800',
},
};
export function TankCylinder({ status, onClick }: TankCylinderProps) {
const colors = STATUS_COLORS[status.status];
// Calculate fill height percentage (clamped to 0-100 for display)
const fillPercent = useMemo(() => {
if (status.percentRemaining === undefined) return 0;
return Math.max(0, Math.min(100, status.percentRemaining));
}, [status.percentRemaining]);
// Calculate fill height in pixels (max height is 120px for the cylinder body)
const fillHeight = Math.round((fillPercent / 100) * 120);
const statusMessage = useMemo(() => {
switch (status.status) {
case 'no-correction':
return 'Not Calibrated';
case 'no-capacity':
return 'Configure Capacity';
case 'anomaly':
return 'Data Anomaly';
case 'overflow':
return 'Check Data';
case 'low':
return 'Low Level';
case 'attention':
return 'Attention';
default:
return 'Normal';
}
}, [status.status]);
return (
<div
className={`
relative p-4 rounded-lg border cursor-pointer transition-all
hover:shadow-md hover:border-primary/50
${colors.bg}
${status.status === 'anomaly' ? 'animate-pulse border-red-500' : 'border-slate-200 dark:border-slate-700'}
`}
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
onClick?.();
}
}}
>
{/* Cylinder SVG */}
<div className="flex justify-center mb-3">
<svg
className="drop-shadow-sm"
height="160"
viewBox="0 0 80 160"
width="80"
>
{/* Cylinder body - empty background */}
<rect
className="fill-slate-200 dark:fill-slate-700"
height="120"
rx="4"
width="60"
x="10"
y="20"
/>
{/* Cylinder body - filled portion */}
{fillHeight > 0 && (
<rect
className={`bg-gradient-to-b ${colors.fill}`}
height={fillHeight}
rx="4"
style={{
fill: status.status === 'normal' ? '#3b82f6' :
status.status === 'attention' ? '#f97316' :
status.status === 'low' ? '#ef4444' :
status.status === 'anomaly' ? '#b91c1c' :
status.status === 'overflow' ? '#f59e0b' :
'#94a3b8',
}}
width="60"
x="10"
y={20 + (120 - fillHeight)}
/>
)}
{/* Top ellipse */}
<ellipse
className="fill-slate-300 dark:fill-slate-600"
cx="40"
cy="20"
rx="30"
ry="10"
/>
{/* Bottom ellipse (always visible) */}
<ellipse
className="fill-slate-300 dark:fill-slate-600"
cx="40"
cy="140"
rx="30"
ry="10"
/>
{/* Liquid level line indicator */}
{fillHeight > 0 && fillHeight < 120 && (
<ellipse
cx="40"
cy={20 + (120 - fillHeight)}
rx="30"
ry="6"
style={{
fill: status.status === 'normal' ? '#60a5fa' :
status.status === 'attention' ? '#fb923c' :
status.status === 'low' ? '#f87171' :
status.status === 'anomaly' ? '#dc2626' :
status.status === 'overflow' ? '#fbbf24' :
'#94a3b8',
}}
/>
)}
{/* Dashed border for unconfigured state */}
{(status.status === 'no-correction' || status.status === 'no-capacity') && (
<rect
className="stroke-slate-400 dark:stroke-slate-500"
fill="none"
height="120"
rx="4"
strokeDasharray="4 2"
strokeWidth="2"
width="60"
x="10"
y="20"
/>
)}
</svg>
</div>
{/* Asset name */}
<p className="text-sm font-semibold text-slate-900 dark:text-slate-100 text-center truncate">
{status.assetDisplayId}
</p>
{/* Values display */}
<div className="text-center mt-1">
{status.currentRemainingLitres !== undefined && status.capacityLitres !== undefined ? (
<p className={`text-sm font-medium ${colors.text}`}>
{status.currentRemainingLitres.toLocaleString()} L / {status.capacityLitres.toLocaleString()} L
</p>
) : status.currentRemainingLitres !== undefined ? (
<p className={`text-sm font-medium ${colors.text}`}>
{status.currentRemainingLitres.toLocaleString()} L
</p>
) : (
<p className="text-sm text-slate-400 dark:text-slate-500">--</p>
)}
</div>
{/* Percentage */}
{status.percentRemaining !== undefined && (
<p className={`text-xs ${colors.text} text-center mt-0.5`}>
({status.percentRemaining.toFixed(0)}%)
</p>
)}
{/* Status badge */}
<div className="flex justify-center mt-2">
<span
className={`
inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium
${colors.text} ${colors.bg}
`}
>
{status.status === 'anomaly' && (
<Icon className="w-3 h-3" icon="solar:danger-triangle-bold" />
)}
{status.status === 'low' && (
<Icon className="w-3 h-3" icon="solar:arrow-down-bold" />
)}
{status.status === 'overflow' && (
<Icon className="w-3 h-3" icon="solar:arrow-up-bold" />
)}
{statusMessage}
</span>
</div>
{/* Last correction date */}
{status.latestCorrection && (
<p className="text-xs text-slate-500 dark:text-slate-400 text-center mt-2">
Last: {status.latestCorrection.correctionDatetime.toLocaleDateString()}
</p>
)}
</div>
);
}Step 2: Verify TypeScript compilation
Run: pnpm build:check Expected: No TypeScript errors
Step 3: Commit
git add src/features/flow-meter/components/TankCylinder.tsx
git commit -m "feat(component): add TankCylinder visualization component"Task 5: Create TankStatusDisplay Component
Files:
- Create:
src/features/flow-meter/components/TankStatusDisplay.tsx
Step 1: Create the component
import { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { Icon } from '@iconify/react';
import { TankCylinder } from './TankCylinder';
import { calculateAllTankStatuses } from '../services/tankService';
import type { TankStatus, FlowMeterAsset } from '../types';
interface TankStatusDisplayProps {
flowMeterAssets: FlowMeterAsset[];
/** Optional: Filter to show only specific sites */
siteFilter?: string[];
}
export function TankStatusDisplay({ flowMeterAssets, siteFilter }: TankStatusDisplayProps) {
const navigate = useNavigate();
const [tankStatuses, setTankStatuses] = useState<TankStatus[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadTankStatuses = useCallback(async () => {
try {
setLoading(true);
setError(null);
const statuses = await calculateAllTankStatuses(flowMeterAssets);
// Apply site filter if provided
const filtered = siteFilter?.length
? statuses.filter((s) => siteFilter.includes(s.site))
: statuses;
setTankStatuses(filtered);
} catch (err) {
console.error('Failed to load tank statuses:', err);
setError(err instanceof Error ? err.message : 'Failed to load tank statuses');
} finally {
setLoading(false);
}
}, [flowMeterAssets, siteFilter]);
useEffect(() => {
if (flowMeterAssets.length > 0) {
void loadTankStatuses();
}
}, [flowMeterAssets, loadTankStatuses]);
const handleTankClick = useCallback((status: TankStatus) => {
// Navigate to correction management page with asset filter
navigate(`/correction-management?asset=${encodeURIComponent(status.assetId)}`);
}, [navigate]);
if (loading) {
return (
<div className="mb-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-slate-900 dark:text-slate-100">
Tank Status
</h2>
</div>
<div className="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 p-8">
<div className="flex items-center justify-center">
<Icon
className="h-8 w-8 animate-spin text-primary mr-3"
icon="svg-spinners:ring-resize"
/>
<span className="text-slate-600 dark:text-slate-400">Loading tank statuses...</span>
</div>
</div>
</div>
);
}
if (error) {
return (
<div className="mb-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-slate-900 dark:text-slate-100">
Tank Status
</h2>
</div>
<div className="bg-white dark:bg-slate-800 rounded-lg border border-red-200 dark:border-red-800 p-8">
<div className="flex flex-col items-center">
<Icon className="h-12 w-12 text-red-500 mb-2" icon="solar:danger-circle-bold" />
<p className="text-red-600 dark:text-red-400 mb-4">{error}</p>
<button
className="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90"
onClick={() => void loadTankStatuses()}
>
Retry
</button>
</div>
</div>
</div>
);
}
if (tankStatuses.length === 0) {
return (
<div className="mb-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-slate-900 dark:text-slate-100">
Tank Status
</h2>
</div>
<div className="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 p-8">
<div className="text-center text-slate-500 dark:text-slate-400">
<Icon className="h-12 w-12 mx-auto mb-2" icon="solar:inbox-line-bold-duotone" />
<p>No flow meter assets found</p>
</div>
</div>
</div>
);
}
return (
<div className="mb-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-slate-900 dark:text-slate-100">
Tank Status
</h2>
<div className="flex items-center gap-3">
<button
className="inline-flex items-center gap-2 px-3 py-1.5 text-sm bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300 rounded-lg hover:bg-slate-200 dark:hover:bg-slate-600"
onClick={() => navigate('/tank-configuration')}
>
<Icon className="h-4 w-4" icon="solar:settings-bold-duotone" />
Configure
</button>
<button
className="inline-flex items-center gap-2 px-3 py-1.5 text-sm bg-primary text-white rounded-lg hover:bg-primary/90"
onClick={() => navigate('/correction-management')}
>
<Icon className="h-4 w-4" icon="solar:tuning-2-bold-duotone" />
Corrections
</button>
</div>
</div>
<div className="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 p-4">
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
{tankStatuses.map((status) => (
<TankCylinder
key={status.assetId}
status={status}
onClick={() => handleTankClick(status)}
/>
))}
</div>
</div>
</div>
);
}Step 2: Verify TypeScript compilation
Run: pnpm build:check Expected: No TypeScript errors
Step 3: Commit
git add src/features/flow-meter/components/TankStatusDisplay.tsx
git commit -m "feat(component): add TankStatusDisplay container component"Task 6: Create CorrectionModal Component
Files:
- Create:
src/features/flow-meter/components/CorrectionModal.tsx
Step 1: Create the modal component
import { useState, useMemo, useEffect } from 'react';
import { Icon } from '@iconify/react';
import type { MineSiteOption, FlowMeterAsset, TankCorrection } from '../types';
import { fetchFlowMeterAssets, uploadCorrectionEvidence } from '../services/tankService';
interface CorrectionModalProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (correction: {
site: string;
mineSiteId: string;
assetId: string;
correctionDatetime: Date;
remainingLitres: number;
correctedBy?: string;
evidenceUrl?: string;
notes?: string;
}) => Promise<void>;
mineSites: MineSiteOption[];
/** For edit mode */
existingCorrection?: TankCorrection | null;
/** Pre-selected site */
selectedSiteId?: string;
}
export function CorrectionModal({
isOpen,
onClose,
onSubmit,
mineSites,
existingCorrection,
selectedSiteId,
}: CorrectionModalProps) {
const isEditMode = !!existingCorrection;
const [siteId, setSiteId] = useState(selectedSiteId || '');
const [assetId, setAssetId] = useState('');
const [flowMeterAssets, setFlowMeterAssets] = useState<FlowMeterAsset[]>([]);
const [loadingAssets, setLoadingAssets] = useState(false);
const [remainingLitres, setRemainingLitres] = useState('');
const [datetime, setDatetime] = useState(() => {
const now = new Date();
return `${now.toISOString().split('T')[0]}T${now.toTimeString().slice(0, 5)}`;
});
const [correctedBy, setCorrectedBy] = useState('');
const [notes, setNotes] = useState('');
const [evidenceFile, setEvidenceFile] = useState<File | null>(null);
const [evidencePreview, setEvidencePreview] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Load flow meter assets
useEffect(() => {
const loadAssets = async () => {
try {
setLoadingAssets(true);
const assets = await fetchFlowMeterAssets();
setFlowMeterAssets(assets);
} catch (err) {
console.error('Failed to load flow meter assets:', err);
} finally {
setLoadingAssets(false);
}
};
if (isOpen) {
void loadAssets();
}
}, [isOpen]);
// Populate form for edit mode
useEffect(() => {
if (existingCorrection && isOpen) {
// Find site ID from site name
const site = mineSites.find((s) => s.name === existingCorrection.site);
setSiteId(site?.id || '');
setAssetId(existingCorrection.assetId);
setRemainingLitres(existingCorrection.remainingLitres.toString());
const dt = existingCorrection.correctionDatetime;
setDatetime(
`${dt.toISOString().split('T')[0]}T${dt.toTimeString().slice(0, 5)}`
);
setCorrectedBy(existingCorrection.correctedBy || '');
setNotes(existingCorrection.notes || '');
if (existingCorrection.evidenceUrl) {
setEvidencePreview(existingCorrection.evidenceUrl);
}
}
}, [existingCorrection, isOpen, mineSites]);
// Reset form when modal closes
useEffect(() => {
if (!isOpen) {
setSiteId(selectedSiteId || '');
setAssetId('');
setRemainingLitres('');
const now = new Date();
setDatetime(`${now.toISOString().split('T')[0]}T${now.toTimeString().slice(0, 5)}`);
setCorrectedBy('');
setNotes('');
setEvidenceFile(null);
setEvidencePreview(null);
setError(null);
}
}, [isOpen, selectedSiteId]);
const selectedSite = useMemo(() => {
return mineSites.find((s) => s.id === siteId);
}, [mineSites, siteId]);
const availableAssets = useMemo(() => {
if (!selectedSite) return [];
return flowMeterAssets.filter(
(asset) => asset.mineSiteId === siteId || !asset.mineSiteId
);
}, [selectedSite, siteId, flowMeterAssets]);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setEvidenceFile(file);
// Create preview URL
const reader = new FileReader();
reader.onload = () => {
setEvidencePreview(reader.result as string);
};
reader.readAsDataURL(file);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!siteId || !selectedSite) {
setError('Please select a site');
return;
}
if (!assetId) {
setError('Please select a flow meter');
return;
}
const litresNum = parseFloat(remainingLitres);
if (isNaN(litresNum) || litresNum < 0) {
setError('Please enter a valid remaining litres amount');
return;
}
try {
setIsSubmitting(true);
// Upload evidence if provided
let evidenceUrl: string | undefined;
if (evidenceFile) {
evidenceUrl = await uploadCorrectionEvidence(evidenceFile);
} else if (existingCorrection?.evidenceUrl) {
evidenceUrl = existingCorrection.evidenceUrl;
}
await onSubmit({
site: selectedSite.name,
mineSiteId: siteId,
assetId,
correctionDatetime: new Date(datetime),
remainingLitres: litresNum,
correctedBy: correctedBy.trim() || undefined,
evidenceUrl,
notes: notes.trim() || undefined,
});
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save correction');
} finally {
setIsSubmitting(false);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white dark:bg-slate-800 rounded-lg shadow-xl max-w-md w-full mx-4 max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between border-b border-slate-200 dark:border-slate-700 p-6">
<h2 className="text-xl font-semibold text-slate-900 dark:text-slate-100">
{isEditMode ? 'Edit Correction' : 'Add Tank Correction'}
</h2>
<button
className="text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"
type="button"
onClick={onClose}
>
<Icon className="h-6 w-6" icon="solar:close-circle-bold" />
</button>
</div>
<form onSubmit={handleSubmit}>
<div className="p-6 space-y-4">
{error && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-3 flex items-start gap-2">
<Icon
className="h-5 w-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5"
icon="solar:danger-circle-bold"
/>
<p className="text-sm text-red-700 dark:text-red-300">{error}</p>
</div>
)}
{/* Site Selection */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
Site <span className="text-red-500">*</span>
</label>
<select
required
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100"
disabled={isEditMode}
value={siteId}
onChange={(e) => {
setSiteId(e.target.value);
setAssetId('');
}}
>
<option value="">Select a site...</option>
{mineSites.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
</div>
{/* Flow Meter Selection */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
Flow Meter <span className="text-red-500">*</span>
</label>
{loadingAssets ? (
<div className="text-sm text-slate-500 py-2">Loading assets...</div>
) : availableAssets.length > 0 ? (
<select
required
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100"
disabled={isEditMode}
value={assetId}
onChange={(e) => setAssetId(e.target.value)}
>
<option value="">Select a flow meter...</option>
{availableAssets.map((asset) => (
<option key={asset.assetId} value={asset.assetId}>
{asset.displayId}
{asset.description ? ` - ${asset.description}` : ''}
</option>
))}
</select>
) : (
<div className="text-sm text-amber-600 py-2">
No flow meter assets found for this site.
</div>
)}
</div>
{/* Remaining Litres */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
Remaining Litres <span className="text-red-500">*</span>
</label>
<input
required
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100"
min="0"
placeholder="e.g., 450"
step="0.01"
type="number"
value={remainingLitres}
onChange={(e) => setRemainingLitres(e.target.value)}
/>
<p className="mt-1 text-xs text-slate-500">
Estimate the tank scale line and enter the actual remaining Dustloc
</p>
</div>
{/* Correction DateTime */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
Correction Date & Time <span className="text-red-500">*</span>
</label>
<input
required
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100"
type="datetime-local"
value={datetime}
onChange={(e) => setDatetime(e.target.value)}
/>
</div>
{/* Corrected By */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
Corrected By
</label>
<input
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100"
placeholder="Name of person"
type="text"
value={correctedBy}
onChange={(e) => setCorrectedBy(e.target.value)}
/>
</div>
{/* Evidence Photo */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
Evidence Photo
</label>
<input
accept="image/*"
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100 file:mr-4 file:py-1 file:px-3 file:rounded file:border-0 file:text-sm file:bg-primary file:text-white"
type="file"
onChange={handleFileChange}
/>
{evidencePreview && (
<div className="mt-2">
<img
alt="Evidence preview"
className="w-full max-h-32 object-contain rounded border border-slate-200 dark:border-slate-700"
src={evidencePreview}
/>
</div>
)}
</div>
{/* Notes */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
Notes
</label>
<textarea
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100 resize-none"
placeholder="Additional notes..."
rows={3}
value={notes}
onChange={(e) => setNotes(e.target.value)}
/>
</div>
</div>
<div className="flex items-center justify-end gap-3 border-t border-slate-200 dark:border-slate-700 p-6">
<button
className="px-4 py-2 text-sm font-medium text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg"
disabled={isSubmitting}
type="button"
onClick={onClose}
>
Cancel
</button>
<button
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-primary hover:bg-primary/90 rounded-lg disabled:opacity-50"
disabled={isSubmitting}
type="submit"
>
{isSubmitting ? (
<>
<Icon className="h-4 w-4 animate-spin" icon="svg-spinners:ring-resize" />
{isEditMode ? 'Saving...' : 'Adding...'}
</>
) : (
<>
<Icon className="h-4 w-4" icon={isEditMode ? 'solar:diskette-bold' : 'solar:add-circle-bold-duotone'} />
{isEditMode ? 'Save Changes' : 'Add Correction'}
</>
)}
</button>
</div>
</form>
</div>
</div>
);
}Step 2: Export from databaseService
Add to src/features/flow-meter/services/databaseService.ts exports section or re-export from tankService:
// At the bottom of databaseService.ts, add:
export {
fetchFlowMeterAssets,
uploadCorrectionEvidence,
} from './tankService';Actually, we need to update the import in CorrectionModal.tsx to import from the correct location:
// Update import at top of CorrectionModal.tsx:
import { fetchFlowMeterAssets } from '../services/databaseService';
import { uploadCorrectionEvidence } from '../services/tankService';Step 3: Verify TypeScript compilation
Run: pnpm build:check Expected: No TypeScript errors
Step 4: Commit
git add src/features/flow-meter/components/CorrectionModal.tsx
git commit -m "feat(component): add CorrectionModal for adding/editing corrections"Task 7: Create Correction Management Page
Files:
- Create:
src/app/(admin)/(pages)/correction-management/index.tsx
Step 1: Create the page
import { useState, useEffect, useMemo, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Icon } from '@iconify/react';
import { format } from 'date-fns';
import PageMeta from '@/components/PageMeta';
import PageBreadcrumb from '@/components/PageBreadcrumb';
import { useAuthContext } from '@/contexts/AuthContext';
import { supabase } from '@/lib/supabase';
import {
fetchAllCorrections,
insertCorrection,
updateCorrection,
deleteCorrection,
} from '@/features/flow-meter/services/tankService';
import { CorrectionModal } from '@/features/flow-meter/components/CorrectionModal';
import { DeleteConfirmModal } from '@/features/flow-meter/components/DeleteConfirmModal';
import type {
TankCorrection,
MineSiteOption,
} from '@/features/flow-meter/types';
export default function CorrectionManagementPage() {
const { user } = useAuthContext();
const [searchParams] = useSearchParams();
const initialAssetFilter = searchParams.get('asset') || '';
const [corrections, setCorrections] = useState<TankCorrection[]>([]);
const [mineSites, setMineSites] = useState<MineSiteOption[]>([]);
const [userProfiles, setUserProfiles] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Modal states
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedCorrection, setSelectedCorrection] = useState<TankCorrection | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
// Filter and pagination states
const [searchTerm, setSearchTerm] = useState('');
const [selectedSiteFilter, setSelectedSiteFilter] = useState<string>('all');
const [selectedAssetFilter, setSelectedAssetFilter] = useState<string>(initialAssetFilter || 'all');
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [sortField, setSortField] = useState<'correctionDatetime' | 'site' | 'remainingLitres'>('correctionDatetime');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
const availableSiteNames = useMemo(() => {
return mineSites.map((s) => s.name).sort();
}, [mineSites]);
const availableAssets = useMemo(() => {
const assets = new Set(corrections.map((c) => c.assetDisplayId));
return Array.from(assets).sort();
}, [corrections]);
const loadCorrections = useCallback(async () => {
try {
setLoading(true);
setError(null);
const [correctionsData, mineSitesResult] = await Promise.all([
fetchAllCorrections(),
supabase
.from('cfg_mine_sites')
.select('id, name')
.eq('flow_meter_enabled', true)
.order('name'),
]);
setCorrections(correctionsData);
setMineSites(mineSitesResult.data ?? []);
// Fetch user profiles for created_by display
const userIds = correctionsData
.map((c) => c.createdBy)
.filter((id): id is string => !!id);
const uniqueUserIds = [...new Set(userIds)];
if (uniqueUserIds.length > 0) {
const { data: profiles } = await supabase
.from('user_profiles')
.select('id, full_name')
.in('id', uniqueUserIds);
if (profiles) {
const profileMap: Record<string, string> = {};
for (const p of profiles) {
profileMap[p.id] = p.full_name || 'Unknown';
}
setUserProfiles(profileMap);
}
}
} catch (err) {
console.error('Failed to load corrections:', err);
setError(err instanceof Error ? err.message : 'Failed to load correction data');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void loadCorrections();
}, [loadCorrections]);
const handleAddCorrection = useCallback(
async (correction: {
site: string;
mineSiteId: string;
assetId: string;
correctionDatetime: Date;
remainingLitres: number;
correctedBy?: string;
evidenceUrl?: string;
notes?: string;
}) => {
try {
await insertCorrection({
site: correction.site,
asset_id: correction.assetId,
correction_datetime: correction.correctionDatetime.toISOString(),
remaining_litres: correction.remainingLitres,
corrected_by: correction.correctedBy,
evidence_url: correction.evidenceUrl,
notes: correction.notes,
mine_site_id: correction.mineSiteId,
created_by: user?.id ?? undefined,
});
await loadCorrections();
setIsAddModalOpen(false);
} catch (err) {
console.error('Failed to add correction:', err);
throw err;
}
},
[loadCorrections, user]
);
const handleEditCorrection = useCallback(
async (correction: {
site: string;
mineSiteId: string;
assetId: string;
correctionDatetime: Date;
remainingLitres: number;
correctedBy?: string;
evidenceUrl?: string;
notes?: string;
}) => {
if (!selectedCorrection) return;
try {
await updateCorrection(selectedCorrection.id, {
site: correction.site,
asset_id: correction.assetId,
correction_datetime: correction.correctionDatetime.toISOString(),
remaining_litres: correction.remainingLitres,
corrected_by: correction.correctedBy ?? null,
evidence_url: correction.evidenceUrl ?? null,
notes: correction.notes ?? null,
mine_site_id: correction.mineSiteId,
});
await loadCorrections();
setIsEditModalOpen(false);
setSelectedCorrection(null);
} catch (err) {
console.error('Failed to update correction:', err);
throw err;
}
},
[loadCorrections, selectedCorrection]
);
const handleDeleteCorrection = useCallback(async () => {
if (!selectedCorrection?.id) return;
try {
setIsDeleting(true);
await deleteCorrection(selectedCorrection.id);
await loadCorrections();
setIsDeleteModalOpen(false);
setSelectedCorrection(null);
} catch (err) {
console.error('Failed to delete correction:', err);
setError(err instanceof Error ? err.message : 'Failed to delete correction');
} finally {
setIsDeleting(false);
}
}, [selectedCorrection, loadCorrections]);
const handleSort = (field: typeof sortField) => {
if (sortField === field) {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortDirection('desc');
}
};
const filteredAndSortedCorrections = useMemo(() => {
let filtered = [...corrections];
// Filter by site
if (selectedSiteFilter !== 'all') {
filtered = filtered.filter((c) => c.site === selectedSiteFilter);
}
// Filter by asset
if (selectedAssetFilter !== 'all') {
filtered = filtered.filter((c) => c.assetId === selectedAssetFilter || c.assetDisplayId === selectedAssetFilter);
}
// Filter by search term
if (searchTerm) {
const term = searchTerm.toLowerCase();
filtered = filtered.filter(
(c) =>
c.site.toLowerCase().includes(term) ||
c.assetDisplayId.toLowerCase().includes(term) ||
c.correctedBy?.toLowerCase().includes(term) ||
c.notes?.toLowerCase().includes(term)
);
}
// Sort
filtered.sort((a, b) => {
let aVal: string | number | Date = a[sortField];
let bVal: string | number | Date = b[sortField];
if (sortField === 'correctionDatetime') {
aVal = new Date(aVal).getTime();
bVal = new Date(bVal).getTime();
}
if (sortDirection === 'asc') {
return aVal > bVal ? 1 : -1;
} else {
return aVal < bVal ? 1 : -1;
}
});
return filtered;
}, [corrections, selectedSiteFilter, selectedAssetFilter, searchTerm, sortField, sortDirection]);
const paginatedCorrections = useMemo(() => {
const startIndex = (currentPage - 1) * pageSize;
return filteredAndSortedCorrections.slice(startIndex, startIndex + pageSize);
}, [filteredAndSortedCorrections, currentPage, pageSize]);
const totalPages = Math.ceil(filteredAndSortedCorrections.length / pageSize);
// Statistics
const stats = useMemo(() => ({
totalCorrections: filteredAndSortedCorrections.length,
sitesCount: new Set(filteredAndSortedCorrections.map((c) => c.site)).size,
assetsCount: new Set(filteredAndSortedCorrections.map((c) => c.assetId)).size,
latestCorrection: filteredAndSortedCorrections[0]?.correctionDatetime,
}), [filteredAndSortedCorrections]);
if (loading) {
return (
<>
<PageMeta title="Manage Corrections" />
<main>
<PageBreadcrumb subtitle="Flow Meter" title="Manage Corrections" />
<div className="flex h-96 items-center justify-center">
<div className="text-center">
<Icon className="mx-auto mb-4 h-12 w-12 animate-spin text-primary" icon="svg-spinners:ring-resize" />
<p className="text-slate-600 dark:text-slate-400">Loading correction data...</p>
</div>
</div>
</main>
</>
);
}
if (error) {
return (
<>
<PageMeta title="Manage Corrections" />
<main>
<PageBreadcrumb subtitle="Flow Meter" title="Manage Corrections" />
<div className="flex h-96 items-center justify-center">
<div className="text-center">
<Icon className="mx-auto mb-4 h-12 w-12 text-red-600" icon="solar:danger-circle-bold" />
<p className="text-slate-600 dark:text-slate-400 mb-4">{error}</p>
<button
className="inline-flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90"
onClick={() => void loadCorrections()}
>
Retry
</button>
</div>
</div>
</main>
</>
);
}
return (
<>
<PageMeta title="Manage Corrections" />
<main>
<PageBreadcrumb subtitle="Flow Meter" title="Manage Corrections" />
{/* Stats Cards */}
<div className="mb-6 grid grid-cols-1 gap-4 md:grid-cols-4">
<div className="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-600 dark:text-slate-400">Total Corrections</p>
<p className="mt-2 text-2xl font-bold text-blue-600">{stats.totalCorrections}</p>
</div>
<Icon className="h-12 w-12 text-blue-600" icon="solar:tuning-2-bold-duotone" />
</div>
</div>
<div className="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-600 dark:text-slate-400">Sites Covered</p>
<p className="mt-2 text-2xl font-bold text-green-600">{stats.sitesCount}</p>
</div>
<Icon className="h-12 w-12 text-green-600" icon="solar:map-point-bold-duotone" />
</div>
</div>
<div className="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-600 dark:text-slate-400">Tanks Tracked</p>
<p className="mt-2 text-2xl font-bold text-purple-600">{stats.assetsCount}</p>
</div>
<Icon className="h-12 w-12 text-purple-600" icon="solar:gas-station-bold-duotone" />
</div>
</div>
<div className="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-600 dark:text-slate-400">Latest Correction</p>
<p className="mt-2 text-lg font-bold text-amber-600">
{stats.latestCorrection ? format(stats.latestCorrection, 'dd/MM/yyyy') : 'N/A'}
</p>
</div>
<Icon className="h-12 w-12 text-amber-600" icon="solar:calendar-bold-duotone" />
</div>
</div>
</div>
{/* Filters and Actions */}
<div className="mb-6 bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 p-4">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex flex-1 gap-4 flex-wrap">
{/* Search */}
<div className="flex-1 min-w-[200px]">
<div className="relative">
<Icon
className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-slate-400"
icon="solar:magnifer-linear"
/>
<input
className="w-full pl-10 pr-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100"
placeholder="Search..."
type="text"
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
setCurrentPage(1);
}}
/>
</div>
</div>
{/* Site Filter */}
<div className="w-40">
<select
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100"
value={selectedSiteFilter}
onChange={(e) => {
setSelectedSiteFilter(e.target.value);
setCurrentPage(1);
}}
>
<option value="all">All Sites</option>
{availableSiteNames.map((site) => (
<option key={site} value={site}>{site}</option>
))}
</select>
</div>
{/* Asset Filter */}
<div className="w-40">
<select
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100"
value={selectedAssetFilter}
onChange={(e) => {
setSelectedAssetFilter(e.target.value);
setCurrentPage(1);
}}
>
<option value="all">All Assets</option>
{availableAssets.map((asset) => (
<option key={asset} value={asset}>{asset}</option>
))}
</select>
</div>
</div>
{/* Add Button */}
<button
className="inline-flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90"
onClick={() => setIsAddModalOpen(true)}
>
<Icon className="h-5 w-5" icon="solar:add-circle-bold-duotone" />
Add Correction
</button>
</div>
</div>
{/* Table */}
<div className="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-slate-200 dark:divide-slate-700">
<thead className="bg-slate-50 dark:bg-slate-900">
<tr>
<th
className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-slate-500 dark:text-slate-400 cursor-pointer hover:bg-slate-100 dark:hover:bg-slate-800"
onClick={() => handleSort('correctionDatetime')}
>
<div className="flex items-center gap-2">
Date & Time
{sortField === 'correctionDatetime' && (
<Icon className="h-4 w-4" icon={sortDirection === 'asc' ? 'solar:arrow-up-linear' : 'solar:arrow-down-linear'} />
)}
</div>
</th>
<th
className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-slate-500 dark:text-slate-400 cursor-pointer hover:bg-slate-100 dark:hover:bg-slate-800"
onClick={() => handleSort('site')}
>
<div className="flex items-center gap-2">
Site
{sortField === 'site' && (
<Icon className="h-4 w-4" icon={sortDirection === 'asc' ? 'solar:arrow-up-linear' : 'solar:arrow-down-linear'} />
)}
</div>
</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-slate-500 dark:text-slate-400">
Asset
</th>
<th
className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-slate-500 dark:text-slate-400 cursor-pointer hover:bg-slate-100 dark:hover:bg-slate-800"
onClick={() => handleSort('remainingLitres')}
>
<div className="flex items-center gap-2">
Remaining (L)
{sortField === 'remainingLitres' && (
<Icon className="h-4 w-4" icon={sortDirection === 'asc' ? 'solar:arrow-up-linear' : 'solar:arrow-down-linear'} />
)}
</div>
</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-slate-500 dark:text-slate-400">
Corrected By
</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-slate-500 dark:text-slate-400">
Notes
</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-slate-500 dark:text-slate-400">
Evidence
</th>
<th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-slate-500 dark:text-slate-400">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200 dark:divide-slate-700 bg-white dark:bg-slate-800">
{paginatedCorrections.length === 0 ? (
<tr>
<td className="px-6 py-12 text-center text-slate-500 dark:text-slate-400" colSpan={8}>
<Icon className="mx-auto mb-2 h-12 w-12 text-slate-300 dark:text-slate-600" icon="solar:inbox-line-bold-duotone" />
<p>No correction records found</p>
</td>
</tr>
) : (
paginatedCorrections.map((correction) => (
<tr key={correction.id} className="hover:bg-slate-50 dark:hover:bg-slate-700">
<td className="whitespace-nowrap px-6 py-4 text-sm text-slate-900 dark:text-slate-100">
{format(correction.correctionDatetime, 'dd/MM/yyyy HH:mm')}
</td>
<td className="whitespace-nowrap px-6 py-4 text-sm font-medium text-slate-900 dark:text-slate-100">
{correction.site}
</td>
<td className="whitespace-nowrap px-6 py-4 text-sm text-slate-700 dark:text-slate-300">
{correction.assetDisplayId}
</td>
<td className="whitespace-nowrap px-6 py-4 text-sm font-semibold text-blue-600 dark:text-blue-400">
{correction.remainingLitres.toLocaleString()} L
</td>
<td className="whitespace-nowrap px-6 py-4 text-sm text-slate-600 dark:text-slate-400">
{correction.correctedBy || '-'}
</td>
<td className="px-6 py-4 text-sm text-slate-600 dark:text-slate-400 max-w-xs truncate">
{correction.notes || '-'}
</td>
<td className="whitespace-nowrap px-6 py-4 text-sm">
{correction.evidenceUrl ? (
<a
className="text-primary hover:underline"
href={correction.evidenceUrl}
rel="noopener noreferrer"
target="_blank"
>
<Icon className="h-5 w-5" icon="solar:gallery-bold-duotone" />
</a>
) : (
<span className="text-slate-400">-</span>
)}
</td>
<td className="whitespace-nowrap px-6 py-4 text-right text-sm">
<div className="flex items-center justify-end gap-2">
<button
className="inline-flex items-center gap-1 px-3 py-1 text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded"
onClick={() => {
setSelectedCorrection(correction);
setIsEditModalOpen(true);
}}
>
<Icon className="h-4 w-4" icon="solar:pen-bold-duotone" />
Edit
</button>
<button
className="inline-flex items-center gap-1 px-3 py-1 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded"
onClick={() => {
setSelectedCorrection(correction);
setIsDeleteModalOpen(true);
}}
>
<Icon className="h-4 w-4" icon="solar:trash-bin-trash-bold" />
Delete
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
{filteredAndSortedCorrections.length > 0 && (
<div className="bg-slate-50 dark:bg-slate-900 px-6 py-4 flex items-center justify-between border-t border-slate-200 dark:border-slate-700">
<div className="flex items-center gap-4">
<span className="text-sm text-slate-600 dark:text-slate-400">
Showing {Math.min((currentPage - 1) * pageSize + 1, filteredAndSortedCorrections.length)} to{' '}
{Math.min(currentPage * pageSize, filteredAndSortedCorrections.length)} of{' '}
{filteredAndSortedCorrections.length} records
</span>
<select
className="px-2 py-1 text-sm border border-slate-300 dark:border-slate-600 rounded bg-white dark:bg-slate-800"
value={pageSize}
onChange={(e) => {
setPageSize(Number(e.target.value));
setCurrentPage(1);
}}
>
<option value="10">10 per page</option>
<option value="25">25 per page</option>
<option value="50">50 per page</option>
</select>
</div>
<div className="flex items-center gap-2">
<button
className="px-3 py-1 rounded bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-600 text-sm disabled:opacity-50"
disabled={currentPage === 1}
onClick={() => setCurrentPage(1)}
>
First
</button>
<button
className="px-3 py-1 rounded bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-600 text-sm disabled:opacity-50"
disabled={currentPage === 1}
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
>
Previous
</button>
<span className="text-sm text-slate-600 dark:text-slate-400">
Page {currentPage} of {totalPages}
</span>
<button
className="px-3 py-1 rounded bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-600 text-sm disabled:opacity-50"
disabled={currentPage === totalPages}
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
>
Next
</button>
<button
className="px-3 py-1 rounded bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-600 text-sm disabled:opacity-50"
disabled={currentPage === totalPages}
onClick={() => setCurrentPage(totalPages)}
>
Last
</button>
</div>
</div>
)}
</div>
{/* Modals */}
<CorrectionModal
isOpen={isAddModalOpen}
mineSites={mineSites}
onClose={() => setIsAddModalOpen(false)}
onSubmit={handleAddCorrection}
/>
<CorrectionModal
existingCorrection={selectedCorrection}
isOpen={isEditModalOpen}
mineSites={mineSites}
onClose={() => {
setIsEditModalOpen(false);
setSelectedCorrection(null);
}}
onSubmit={handleEditCorrection}
/>
<DeleteConfirmModal
isDeleting={isDeleting}
isOpen={isDeleteModalOpen}
itemName={`${selectedCorrection?.site} - ${selectedCorrection?.assetDisplayId} (${selectedCorrection?.remainingLitres.toLocaleString()} L)`}
onClose={() => {
setIsDeleteModalOpen(false);
setSelectedCorrection(null);
}}
onConfirm={handleDeleteCorrection}
/>
</main>
</>
);
}Step 2: Verify TypeScript compilation
Run: pnpm build:check Expected: No TypeScript errors
Step 3: Commit
git add src/app/\(admin\)/\(pages\)/correction-management/index.tsx
git commit -m "feat(page): add Correction Management page"Task 8: Create Tank Configuration Page
Files:
- Create:
src/app/(admin)/(pages)/tank-configuration/index.tsx
Step 1: Create the page
Create a simpler tank configuration page for managing capacities. Due to length, I'll provide the key structure:
import { useState, useEffect, useCallback, useMemo } from 'react';
import { Icon } from '@iconify/react';
import PageMeta from '@/components/PageMeta';
import PageBreadcrumb from '@/components/PageBreadcrumb';
import { supabase } from '@/lib/supabase';
import {
fetchAllCapacities,
upsertCapacity,
deleteCapacity,
} from '@/features/flow-meter/services/tankService';
import { fetchFlowMeterAssets } from '@/features/flow-meter/services/databaseService';
import type {
TankCapacity,
FlowMeterAsset,
MineSiteOption,
} from '@/features/flow-meter/types';
export default function TankConfigurationPage() {
const [capacities, setCapacities] = useState<TankCapacity[]>([]);
const [flowMeterAssets, setFlowMeterAssets] = useState<FlowMeterAsset[]>([]);
const [mineSites, setMineSites] = useState<MineSiteOption[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Inline editing state
const [editingAssetId, setEditingAssetId] = useState<string | null>(null);
const [editingCapacity, setEditingCapacity] = useState<string>('');
const [isSaving, setIsSaving] = useState(false);
const loadData = useCallback(async () => {
try {
setLoading(true);
setError(null);
const [capacitiesData, assetsData, sitesResult] = await Promise.all([
fetchAllCapacities(),
fetchFlowMeterAssets(),
supabase
.from('cfg_mine_sites')
.select('id, name')
.eq('flow_meter_enabled', true)
.order('name'),
]);
setCapacities(capacitiesData);
setFlowMeterAssets(assetsData);
setMineSites(sitesResult.data ?? []);
} catch (err) {
console.error('Failed to load data:', err);
setError(err instanceof Error ? err.message : 'Failed to load data');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void loadData();
}, [loadData]);
// Group assets by site
const assetsBySite = useMemo(() => {
const result: Record<string, { site: MineSiteOption; assets: FlowMeterAsset[] }> = {};
for (const site of mineSites) {
const siteAssets = flowMeterAssets.filter((a) => a.mineSiteId === site.id);
if (siteAssets.length > 0) {
result[site.id] = { site, assets: siteAssets };
}
}
return result;
}, [flowMeterAssets, mineSites]);
// Create capacity lookup
const capacityLookup = useMemo(() => {
return new Map(capacities.map((c) => [c.assetId, c]));
}, [capacities]);
const handleSaveCapacity = async (asset: FlowMeterAsset, site: MineSiteOption) => {
const litres = parseFloat(editingCapacity);
if (isNaN(litres) || litres <= 0) {
return;
}
try {
setIsSaving(true);
await upsertCapacity({
site: site.name,
asset_id: asset.assetId,
capacity_litres: litres,
mine_site_id: site.id,
});
await loadData();
setEditingAssetId(null);
setEditingCapacity('');
} catch (err) {
console.error('Failed to save capacity:', err);
} finally {
setIsSaving(false);
}
};
const handleDeleteCapacity = async (capacity: TankCapacity) => {
if (!confirm('Are you sure you want to remove this capacity configuration?')) {
return;
}
try {
await deleteCapacity(capacity.id);
await loadData();
} catch (err) {
console.error('Failed to delete capacity:', err);
}
};
if (loading) {
return (
<>
<PageMeta title="Tank Configuration" />
<main>
<PageBreadcrumb subtitle="Flow Meter" title="Tank Configuration" />
<div className="flex h-96 items-center justify-center">
<Icon className="h-12 w-12 animate-spin text-primary" icon="svg-spinners:ring-resize" />
</div>
</main>
</>
);
}
return (
<>
<PageMeta title="Tank Configuration" />
<main>
<PageBreadcrumb subtitle="Flow Meter" title="Tank Configuration" />
<div className="mb-6">
<p className="text-slate-600 dark:text-slate-400">
Configure tank capacities for each flow meter asset. This allows the system to calculate percentage remaining.
</p>
</div>
{Object.entries(assetsBySite).map(([siteId, { site, assets }]) => (
<div key={siteId} className="mb-6">
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-3">
{site.name}
</h3>
<div className="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 overflow-hidden">
<table className="min-w-full divide-y divide-slate-200 dark:divide-slate-700">
<thead className="bg-slate-50 dark:bg-slate-900">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium uppercase text-slate-500">Asset</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase text-slate-500">Description</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase text-slate-500">Capacity (L)</th>
<th className="px-6 py-3 text-right text-xs font-medium uppercase text-slate-500">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200 dark:divide-slate-700">
{assets.map((asset) => {
const capacity = capacityLookup.get(asset.assetId);
const isEditing = editingAssetId === asset.assetId;
return (
<tr key={asset.assetId} className="hover:bg-slate-50 dark:hover:bg-slate-700">
<td className="px-6 py-4 text-sm font-medium text-slate-900 dark:text-slate-100">
{asset.displayId}
</td>
<td className="px-6 py-4 text-sm text-slate-600 dark:text-slate-400">
{asset.description || '-'}
</td>
<td className="px-6 py-4 text-sm">
{isEditing ? (
<input
autoFocus
className="w-24 px-2 py-1 border border-slate-300 dark:border-slate-600 rounded bg-white dark:bg-slate-900"
min="0"
placeholder="Litres"
type="number"
value={editingCapacity}
onBlur={() => {
setEditingAssetId(null);
setEditingCapacity('');
}}
onChange={(e) => setEditingCapacity(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
void handleSaveCapacity(asset, site);
} else if (e.key === 'Escape') {
setEditingAssetId(null);
setEditingCapacity('');
}
}}
/>
) : capacity ? (
<span className="font-semibold text-blue-600">
{capacity.capacityLitres.toLocaleString()} L
</span>
) : (
<span className="text-slate-400">Not configured</span>
)}
</td>
<td className="px-6 py-4 text-right text-sm">
{isEditing ? (
<button
className="px-3 py-1 bg-primary text-white rounded text-xs"
disabled={isSaving}
onMouseDown={(e) => {
e.preventDefault();
void handleSaveCapacity(asset, site);
}}
>
{isSaving ? 'Saving...' : 'Save'}
</button>
) : (
<div className="flex items-center justify-end gap-2">
<button
className="px-3 py-1 text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded text-xs"
onClick={() => {
setEditingAssetId(asset.assetId);
setEditingCapacity(capacity?.capacityLitres.toString() || '');
}}
>
{capacity ? 'Edit' : 'Configure'}
</button>
{capacity && (
<button
className="px-3 py-1 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded text-xs"
onClick={() => void handleDeleteCapacity(capacity)}
>
Remove
</button>
)}
</div>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
))}
{Object.keys(assetsBySite).length === 0 && (
<div className="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 p-12 text-center">
<Icon className="mx-auto mb-4 h-16 w-16 text-slate-400" icon="solar:inbox-line-bold-duotone" />
<p className="text-slate-600 dark:text-slate-400">No flow meter assets found</p>
</div>
)}
</main>
</>
);
}Step 2: Commit
git add src/app/\(admin\)/\(pages\)/tank-configuration/index.tsx
git commit -m "feat(page): add Tank Configuration page"Task 9: Add Routes
Files:
- Modify:
src/routes/Routes.tsx
Step 1: Add lazy imports
Add after line ~136 (after CalibrationManagement):
const CorrectionManagement = lazy(
() => import("@/app/(admin)/(pages)/correction-management")
);
const TankConfiguration = lazy(
() => import("@/app/(admin)/(pages)/tank-configuration")
);Step 2: Add route configurations
Add after the /calibration-management route (around line 435):
{
path: "/correction-management",
name: "CorrectionManagement",
element: <CorrectionManagement />,
requiredModule: "flow_meter",
},
{
path: "/tank-configuration",
name: "TankConfiguration",
element: <TankConfiguration />,
requiredModule: "flow_meter",
},Step 3: Verify TypeScript compilation
Run: pnpm build:check Expected: No TypeScript errors
Step 4: Commit
git add src/routes/Routes.tsx
git commit -m "feat(routes): add correction management and tank configuration routes"Task 10: Update Sidebar Menu
Files:
- Modify:
src/components/layouts/SideNav/menu.ts
Step 1: Update menu configuration
Find the Flow Meter menu item (around line 79) and add children for the submenu:
{
key: "FlowMeter",
label: "Flow Meter",
icon: MdWaterDrop,
requiredModule: "flow_meter",
children: [
{
key: "FlowMeterMain",
label: "Flow Meter",
href: "/flow-meter",
parentKey: "FlowMeter",
},
{
key: "RefillManagement",
label: "Manage Refills",
href: "/refill-management",
parentKey: "FlowMeter",
},
{
key: "CorrectionManagement",
label: "Manage Corrections",
href: "/correction-management",
parentKey: "FlowMeter",
},
{
key: "TankConfiguration",
label: "Tank Configuration",
href: "/tank-configuration",
parentKey: "FlowMeter",
},
{
key: "CalibrationReminders",
label: "Calibration Reminders",
href: "/calibration-management",
parentKey: "FlowMeter",
},
],
},Step 2: Update MenuItemType to include requiredModule
Ensure the type definition includes requiredModule:
export type MenuItemType = {
key: string;
label: string;
isTitle?: boolean;
href?: string;
children?: MenuItemType[];
icon?: IconType;
parentKey?: string;
target?: string;
isDisabled?: boolean;
requiredModule?: AppModule;
adminOnly?: boolean;
};Step 3: Verify TypeScript compilation
Run: pnpm build:check Expected: No TypeScript errors
Step 4: Commit
git add src/components/layouts/SideNav/menu.ts
git commit -m "feat(menu): add Flow Meter submenu with new pages"Task 11: Integrate TankStatusDisplay into Flow Meter Page
Files:
- Modify:
src/app/(admin)/(pages)/flow-meter/index.tsx
Step 1: Add import
Add import at top of file:
import { TankStatusDisplay } from '@/features/flow-meter/components/TankStatusDisplay';Step 2: Add TankStatusDisplay after DateRangeSelector
Find the <DateRangeSelector ... /> component (around line 727-731) and add TankStatusDisplay right after it:
{/* Date Range Selector */}
<DateRangeSelector
dataDateRange={actualDataDateRange}
value={dateRange}
onChange={handleDateRangeChange}
/>
{/* Tank Status Display - Shows current remaining, NOT affected by date filter */}
<TankStatusDisplay flowMeterAssets={allFlowMeterAssets} />Step 3: Verify TypeScript compilation
Run: pnpm build:check Expected: No TypeScript errors
Step 4: Commit
git add src/app/\(admin\)/\(pages\)/flow-meter/index.tsx
git commit -m "feat(flow-meter): integrate TankStatusDisplay on main page"Task 12: Final Verification
Step 1: Run all tests
Run: pnpm test:unit Expected: All tests pass
Step 2: Run linting
Run: pnpm lint Expected: No errors (warnings okay)
Step 3: Build check
Run: pnpm build:check Expected: Build succeeds
Step 4: Start dev server and manual test
Run: pnpm dev Expected: App starts on port 3000
Manual verification checklist:
- [ ] Navigate to Flow Meter page - Tank Status section visible
- [ ] Navigate to
/correction-management- Page loads - [ ] Navigate to
/tank-configuration- Page loads - [ ] Add a tank capacity configuration
- [ ] Add a correction record
- [ ] Verify cylinder visualization updates
- [ ] Test edit and delete functionality
Step 5: Final commit
git add .
git commit -m "feat: complete tank manual correction feature"Summary
This implementation plan covers:
- Database Layer (Task 1): Two new tables with RLS policies
- Type Definitions (Task 2): TypeScript types for corrections, capacities, and status
- Service Layer (Task 3): tankService.ts with CRUD and calculation logic
- Components (Tasks 4-6): TankCylinder, TankStatusDisplay, CorrectionModal
- Pages (Tasks 7-8): Correction Management and Tank Configuration pages
- Routing (Task 9): Route definitions for new pages
- Navigation (Task 10): Sidebar menu updates with submenu
- Integration (Task 11): TankStatusDisplay on Flow Meter main page
- Verification (Task 12): Testing and final checks
Plan complete and saved to docs/plans/2026-02-04-tank-correction-implementation.md. Two execution options:
1. Subagent-Driven (this session) - I dispatch fresh subagent per task, review between tasks, fast iteration
2. Parallel Session (separate) - Open new session with executing-plans, batch execution with checkpoints
Which approach?