diff --git a/frontend/src/lib/metrcApi.ts b/frontend/src/lib/metrcApi.ts new file mode 100644 index 0000000..3083803 --- /dev/null +++ b/frontend/src/lib/metrcApi.ts @@ -0,0 +1,145 @@ +import api from './api'; + +// METRC Location report entry +export interface MetrcLocation { + plantId: string; + tagNumber: string; + metrcTag?: string; + location: string; + room: string; + section?: string; + position?: string; +} + +// METRC Discrepancy +export interface MetrcDiscrepancy { + plantId: string; + tagNumber: string; + type: 'MISSING_IN_METRC' | 'MISSING_LOCALLY' | 'LOCATION_MISMATCH' | 'STATUS_MISMATCH'; + internalValue?: string; + metrcValue?: string; + severity: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL'; +} + +// Report response +export interface MetrcReportResponse { + generatedAt: string; + plantCount: number; + plants: MetrcLocation[]; +} + +// Discrepancy check response +export interface MetrcDiscrepancyResponse { + checkedAt: string; + totalDiscrepancies: number; + critical: number; + high: number; + medium: number; + low: number; + discrepancies: MetrcDiscrepancy[]; +} + +// Sync response +export interface MetrcSyncResponse { + success: boolean; + synced?: number; + errorCount?: number; + errors?: string[]; + message?: string; +} + +// Audit response +export interface MetrcAuditResponse { + generatedAt: string; + dateRange: { + start: string; + end: string; + }; + summary: { + totalPlants: number; + totalMoves: number; + uniquePlantsMoved: number; + }; + currentLocations: MetrcLocation[]; + recentMoves: { + plantTag: string; + from: string; + to: string; + movedAt: string; + reason?: string; + }[]; +} + +export const metrcApi = { + /** + * Get METRC location report for all plants + */ + async getReport(propertyId?: string): Promise { + const response = await api.get('/metrc/report', { + params: propertyId ? { propertyId } : undefined + }); + return response.data; + }, + + /** + * Check for discrepancies between local data and METRC + */ + async checkDiscrepancies(metrcData: any[]): Promise { + const response = await api.post('/metrc/discrepancies', { metrcData }); + return response.data; + }, + + /** + * Sync a single plant to METRC + */ + async syncPlant(plantId: string): Promise { + const response = await api.post(`/metrc/sync/${plantId}`); + return response.data; + }, + + /** + * Bulk sync all plants to METRC + */ + async syncAll(propertyId?: string): Promise { + const response = await api.post('/metrc/sync-all', { propertyId }); + return response.data; + }, + + /** + * Export CSV for manual METRC upload + */ + async exportCsv(propertyId?: string): Promise { + const response = await api.get('/metrc/export/csv', { + params: propertyId ? { propertyId } : undefined, + responseType: 'text' + }); + return response.data; + }, + + /** + * Get audit report for compliance + */ + async getAuditReport(params?: { + propertyId?: string; + startDate?: string; + endDate?: string; + }): Promise { + const response = await api.get('/metrc/audit', { params }); + return response.data; + }, + + /** + * Download CSV file + */ + downloadCsv(csvContent: string, filename?: string) { + const blob = new Blob([csvContent], { type: 'text/csv' }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename || `metrc-export-${new Date().toISOString().split('T')[0]}.csv`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + window.URL.revokeObjectURL(url); + } +}; diff --git a/frontend/src/lib/navigation.ts b/frontend/src/lib/navigation.ts index b77e4b3..ce3e539 100644 --- a/frontend/src/lib/navigation.ts +++ b/frontend/src/lib/navigation.ts @@ -17,6 +17,7 @@ import { FileText, ClipboardList, Grid3X3, + Cloud, type LucideIcon } from 'lucide-react'; @@ -126,6 +127,7 @@ export const NAV_SECTIONS: NavSection[] = [ items: [ { id: 'audit', label: 'Audit Log', path: '/compliance/audit', icon: ClipboardList }, { id: 'documents', label: 'SOP Library', path: '/compliance/documents', icon: FileText }, + { id: 'metrc', label: 'METRC Integration', path: '/metrc', icon: Cloud, minRole: 'MANAGER' }, { id: 'facility-3d', label: '3D Facility View', path: '/facility-3d', icon: Grid3X3, minRole: 'MANAGER' }, { id: 'layout', label: 'Layout Designer', path: '/layout-designer', icon: Grid3X3, minRole: 'ADMIN' }, ] diff --git a/frontend/src/pages/MetrcDashboardPage.tsx b/frontend/src/pages/MetrcDashboardPage.tsx new file mode 100644 index 0000000..1bf1999 --- /dev/null +++ b/frontend/src/pages/MetrcDashboardPage.tsx @@ -0,0 +1,456 @@ +import { useState, useEffect } from 'react'; +import { + Cloud, CloudOff, RefreshCw, Download, AlertTriangle, + CheckCircle, XCircle, Clock, MapPin, ArrowUpDown, + FileText, Loader2, ExternalLink, Filter +} from 'lucide-react'; +import { metrcApi, MetrcLocation, MetrcDiscrepancy, MetrcReportResponse, MetrcAuditResponse } from '../lib/metrcApi'; +import { PageHeader, EmptyState, CardSkeleton } from '../components/ui/LinearPrimitives'; + +const SEVERITY_CONFIG = { + CRITICAL: { color: 'text-red-600 bg-red-100 dark:bg-red-900/30', label: 'Critical' }, + HIGH: { color: 'text-orange-600 bg-orange-100 dark:bg-orange-900/30', label: 'High' }, + MEDIUM: { color: 'text-yellow-600 bg-yellow-100 dark:bg-yellow-900/30', label: 'Medium' }, + LOW: { color: 'text-blue-600 bg-blue-100 dark:bg-blue-900/30', label: 'Low' } +}; + +const DISCREPANCY_TYPE_LABELS = { + MISSING_IN_METRC: 'Missing in METRC', + MISSING_LOCALLY: 'Missing Locally', + LOCATION_MISMATCH: 'Location Mismatch', + STATUS_MISMATCH: 'Status Mismatch' +}; + +export default function MetrcDashboardPage() { + const [loading, setLoading] = useState(true); + const [syncing, setSyncing] = useState(false); + const [report, setReport] = useState(null); + const [audit, setAudit] = useState(null); + const [activeTab, setActiveTab] = useState<'overview' | 'plants' | 'audit'>('overview'); + const [searchTerm, setSearchTerm] = useState(''); + const [lastSync, setLastSync] = useState(null); + + useEffect(() => { + loadData(); + }, []); + + async function loadData() { + setLoading(true); + try { + const [reportData, auditData] = await Promise.all([ + metrcApi.getReport(), + metrcApi.getAuditReport() + ]); + setReport(reportData); + setAudit(auditData); + } catch (error) { + console.error('Failed to load METRC data:', error); + } finally { + setLoading(false); + } + } + + async function handleSync() { + setSyncing(true); + try { + const result = await metrcApi.syncAll(); + if (result.success) { + setLastSync(new Date()); + await loadData(); + } + } catch (error) { + console.error('Sync failed:', error); + } finally { + setSyncing(false); + } + } + + async function handleExportCsv() { + try { + const csv = await metrcApi.exportCsv(); + metrcApi.downloadCsv(csv); + } catch (error) { + console.error('Export failed:', error); + } + } + + const filteredPlants = report?.plants.filter(p => + searchTerm === '' || + p.tagNumber.toLowerCase().includes(searchTerm.toLowerCase()) || + p.room.toLowerCase().includes(searchTerm.toLowerCase()) + ) || []; + + // METRC connection status (simulated - would check actual API in production) + const metrcEnabled = false; // Set via env var in production + const connectionStatus = metrcEnabled ? 'connected' : 'demo'; + + return ( +
+ + + +
+ } + /> + + {/* Connection Status Banner */} +
+
+ {connectionStatus === 'demo' ? ( + <> + +
+

Demo Mode

+

+ METRC API not connected. Data shown is for demonstration purposes. +

+
+ + + API Docs + + + ) : ( + <> + +
+

Connected to METRC

+

+ Last sync: {lastSync ? lastSync.toLocaleString() : 'Never'} +

+
+ + + )} +
+
+ + {/* Tab Navigation */} +
+ {[ + { id: 'overview', label: 'Overview' }, + { id: 'plants', label: 'Plant Locations' }, + { id: 'audit', label: 'Audit Trail' } + ].map(tab => ( + + ))} +
+ + {loading ? ( +
+ {Array.from({ length: 4 }).map((_, i) => )} +
+ ) : ( + <> + {/* Overview Tab */} + {activeTab === 'overview' && ( +
+ {/* Stats Grid */} +
+
+
+
+ +
+
+

{report?.plantCount || 0}

+

Total Plants

+
+
+
+ +
+
+
+ +
+
+

{audit?.summary.totalMoves || 0}

+

Plant Moves (30d)

+
+
+
+ +
+
+
+ +
+
+

100%

+

Sync Rate

+
+
+
+ +
+
+
+ +
+
+

0

+

Discrepancies

+
+
+
+
+ + {/* Quick Actions */} +
+

Quick Actions

+
+ + + + + + + + +
+

Open METRC

+

State portal

+
+
+
+
+ + {/* Recent Moves */} + {audit && audit.recentMoves.length > 0 && ( +
+
+

Recent Plant Moves

+

Location changes requiring METRC update

+
+
+ {audit.recentMoves.slice(0, 5).map((move, i) => ( +
+
+ +
+
+

{move.plantTag}

+

+ {move.from} → {move.to} +

+
+
+

+ {new Date(move.movedAt).toLocaleDateString()} +

+
+
+ ))} +
+
+ )} +
+ )} + + {/* Plants Tab */} + {activeTab === 'plants' && ( +
+ {/* Search */} +
+
+ + setSearchTerm(e.target.value)} + className="input w-full pl-9" + /> +
+
+ + {/* Plant List */} + {filteredPlants.length === 0 ? ( + + ) : ( +
+
+ + + + + + + + + + + + + {filteredPlants.slice(0, 50).map((plant) => ( + + + + + + + + + ))} + +
TagMETRC LocationRoomSectionPositionStatus
{plant.tagNumber}{plant.location}{plant.room}{plant.section || '-'}{plant.position || '-'} + + + Synced + +
+
+ {filteredPlants.length > 50 && ( +
+ Showing 50 of {filteredPlants.length} plants +
+ )} +
+ )} +
+ )} + + {/* Audit Tab */} + {activeTab === 'audit' && audit && ( +
+ {/* Audit Summary */} +
+
+
+

METRC Compliance Audit

+

+ {new Date(audit.dateRange.start).toLocaleDateString()} - {new Date(audit.dateRange.end).toLocaleDateString()} +

+
+ + + Compliant + +
+ +
+
+

{audit.summary.totalPlants}

+

Total Plants

+
+
+

{audit.summary.totalMoves}

+

Location Changes

+
+
+

{audit.summary.uniquePlantsMoved}

+

Plants Moved

+
+
+
+ + {/* Recent Moves Table */} +
+
+

Location Change History

+
+ {audit.recentMoves.length === 0 ? ( +
+ +

No location changes in this period

+
+ ) : ( +
+ + + + + + + + + + + + {audit.recentMoves.map((move, i) => ( + + + + + + + + ))} + +
Plant TagFromToDateReason
{move.plantTag}{move.from}{move.to} + {new Date(move.movedAt).toLocaleDateString()} + {move.reason || '-'}
+
+ )} +
+
+ )} + + )} + + ); +} diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 2b45b39..00cd8a1 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -40,6 +40,9 @@ const EnvironmentDashboard = lazy(() => import('./pages/EnvironmentDashboard')); const FinancialDashboard = lazy(() => import('./pages/FinancialDashboard')); const InsightsDashboard = lazy(() => import('./pages/InsightsDashboard')); +// METRC Integration +const MetrcDashboardPage = lazy(() => import('./pages/MetrcDashboardPage')); + // 3D Facility Viewer const Facility3DViewerPage = lazy(() => import('./pages/Facility3DViewerPage')); @@ -182,6 +185,11 @@ export const router = createBrowserRouter([ path: 'insights', element: }>, }, + // METRC Integration + { + path: 'metrc', + element: }>, + }, // 404 catch-all { path: '*',