From b8150625258f8f1dbfb87921e7e443f2ebc86995 Mon Sep 17 00:00:00 2001 From: Matt Date: Sun, 12 Jan 2025 15:00:54 -0500 Subject: [PATCH] Split off config schema, update settings page --- inventory-server/db/config-schema.sql | 49 + inventory-server/db/metrics-schema.sql | 24 - .../src/components/settings/Configuration.tsx | 126 +++ .../components/settings/DataManagement.tsx | 745 +++++++++++++ inventory/src/pages/Settings.tsx | 977 +----------------- 5 files changed, 935 insertions(+), 986 deletions(-) create mode 100644 inventory-server/db/config-schema.sql create mode 100644 inventory/src/components/settings/Configuration.tsx create mode 100644 inventory/src/components/settings/DataManagement.tsx diff --git a/inventory-server/db/config-schema.sql b/inventory-server/db/config-schema.sql new file mode 100644 index 0000000..ce450b4 --- /dev/null +++ b/inventory-server/db/config-schema.sql @@ -0,0 +1,49 @@ +-- Configuration tables schema + +-- Stock threshold configurations +CREATE TABLE IF NOT EXISTS stock_thresholds ( + id INT NOT NULL, + category_id BIGINT, -- NULL means default/global threshold + vendor VARCHAR(100), -- NULL means applies to all vendors + critical_days INT NOT NULL DEFAULT 7, + reorder_days INT NOT NULL DEFAULT 14, + overstock_days INT NOT NULL DEFAULT 90, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE, + UNIQUE KEY unique_category_vendor (category_id, vendor) +); + +-- Insert default thresholds with ID=1 if not exists +INSERT INTO stock_thresholds (id, category_id, vendor, critical_days, reorder_days, overstock_days) +VALUES (1, NULL, NULL, 7, 14, 90) +ON DUPLICATE KEY UPDATE + critical_days = VALUES(critical_days), + reorder_days = VALUES(reorder_days), + overstock_days = VALUES(overstock_days); + +-- View to show thresholds with category names +CREATE OR REPLACE VIEW stock_thresholds_view AS +SELECT + st.*, + c.name as category_name, + CASE + WHEN st.category_id IS NULL AND st.vendor IS NULL THEN 'Global Default' + WHEN st.category_id IS NULL THEN CONCAT('Vendor: ', st.vendor) + WHEN st.vendor IS NULL THEN CONCAT('Category: ', c.name) + ELSE CONCAT('Category: ', c.name, ' / Vendor: ', st.vendor) + END as threshold_scope +FROM + stock_thresholds st +LEFT JOIN + categories c ON st.category_id = c.id +ORDER BY + CASE + WHEN st.category_id IS NULL AND st.vendor IS NULL THEN 1 + WHEN st.category_id IS NULL THEN 2 + WHEN st.vendor IS NULL THEN 3 + ELSE 4 + END, + c.name, + st.vendor; \ No newline at end of file diff --git a/inventory-server/db/metrics-schema.sql b/inventory-server/db/metrics-schema.sql index 5f66234..7aa152a 100644 --- a/inventory-server/db/metrics-schema.sql +++ b/inventory-server/db/metrics-schema.sql @@ -88,30 +88,6 @@ CREATE TABLE IF NOT EXISTS vendor_metrics ( PRIMARY KEY (vendor) ); --- New table for stock threshold configurations -CREATE TABLE IF NOT EXISTS stock_thresholds ( - id INT NOT NULL, -- Changed from AUTO_INCREMENT to explicitly set ID - category_id BIGINT, -- NULL means default/global threshold - vendor VARCHAR(100), -- NULL means applies to all vendors - critical_days INT NOT NULL DEFAULT 7, - reorder_days INT NOT NULL DEFAULT 14, - overstock_days INT NOT NULL DEFAULT 90, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - PRIMARY KEY (id), - FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE, - UNIQUE KEY unique_category_vendor (category_id, vendor), - INDEX idx_thresholds_category_vendor (category_id, vendor) -); - --- Insert default thresholds with ID=1 -INSERT INTO stock_thresholds (id, category_id, vendor, critical_days, reorder_days, overstock_days) -VALUES (1, NULL, NULL, 7, 14, 90) -ON DUPLICATE KEY UPDATE - critical_days = VALUES(critical_days), - reorder_days = VALUES(reorder_days), - overstock_days = VALUES(overstock_days); - -- Re-enable foreign key checks SET FOREIGN_KEY_CHECKS = 1; diff --git a/inventory/src/components/settings/Configuration.tsx b/inventory/src/components/settings/Configuration.tsx new file mode 100644 index 0000000..ab880e3 --- /dev/null +++ b/inventory/src/components/settings/Configuration.tsx @@ -0,0 +1,126 @@ +import { useState } from 'react'; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { toast } from "sonner"; +import config from '../../config'; + +interface StockThreshold { + id: number; + category_id: number | null; + vendor: string | null; + critical_days: number; + reorder_days: number; + overstock_days: number; + category_name?: string; + threshold_scope?: string; +} + +export function Configuration() { + const [globalThresholds, setGlobalThresholds] = useState({ + id: 1, + category_id: null, + vendor: null, + critical_days: 7, + reorder_days: 14, + overstock_days: 90 + }); + + const handleUpdateGlobalThresholds = async () => { + try { + const response = await fetch(`${config.apiUrl}/config/stock-thresholds/1`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json' + }, + credentials: 'include', + body: JSON.stringify(globalThresholds) + }); + + if (!response.ok) { + const data = await response.json().catch(() => ({})); + throw new Error(data.error || 'Failed to update global thresholds'); + } + + toast.success('Global thresholds updated successfully'); + } catch (error) { + toast.error(`Failed to update thresholds: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + }; + + return ( +
+ {/* Stock Thresholds Card */} + + + Stock Thresholds + Configure stock level thresholds for inventory management + + +
+ {/* Global Defaults Section */} +
+

Global Defaults

+
+
+ + setGlobalThresholds(prev => ({ + ...prev, + critical_days: parseInt(e.target.value) || 1 + }))} + /> +
+
+ + setGlobalThresholds(prev => ({ + ...prev, + reorder_days: parseInt(e.target.value) || 1 + }))} + /> +
+
+ + setGlobalThresholds(prev => ({ + ...prev, + overstock_days: parseInt(e.target.value) || 1 + }))} + /> +
+
+ +
+ + {/* Category/Vendor Specific Section */} +
+

Category & Vendor Specific

+ +
+
+
+
+ + {/* Future Config Cards can go here */} +
+ ); +} \ No newline at end of file diff --git a/inventory/src/components/settings/DataManagement.tsx b/inventory/src/components/settings/DataManagement.tsx new file mode 100644 index 0000000..52e977e --- /dev/null +++ b/inventory/src/components/settings/DataManagement.tsx @@ -0,0 +1,745 @@ +import { useState } from 'react'; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Progress } from "@/components/ui/progress"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Loader2, RefreshCw, Upload, X } from "lucide-react"; +import config from '../../config'; +import { toast } from "sonner"; + +interface ImportProgress { + status: 'running' | 'error' | 'complete'; + operation?: string; + current?: number; + total?: number; + rate?: number; + elapsed?: string; + remaining?: string; + progress?: string; + error?: string; + percentage?: string; + message?: string; + testLimit?: number; + added?: number; + updated?: number; + skipped?: number; + duration?: string; +} + +interface ImportLimits { + products: number; + orders: number; + purchaseOrders: number; +} + +export function DataManagement() { + const [isUpdating, setIsUpdating] = useState(false); + const [isImporting, setIsImporting] = useState(false); + const [isResetting, setIsResetting] = useState(false); + const [updateProgress, setUpdateProgress] = useState(null); + const [importProgress, setImportProgress] = useState(null); + const [purchaseOrdersProgress, setPurchaseOrdersProgress] = useState(null); + const [resetProgress, setResetProgress] = useState(null); + const [eventSource, setEventSource] = useState(null); + const [limits, setLimits] = useState({ + products: 0, + orders: 0, + purchaseOrders: 0 + }); + const [isResettingMetrics, setIsResettingMetrics] = useState(false); + const [resetMetricsProgress, setResetMetricsProgress] = useState(null); + const [isCalculatingMetrics, setIsCalculatingMetrics] = useState(false); + const [metricsProgress, setMetricsProgress] = useState(null); + + // Helper to connect to event source + const connectToEventSource = (type: 'update' | 'import' | 'reset' | 'reset-metrics' | 'calculate-metrics') => { + console.log(`Setting up EventSource for ${type}...`); + + // Clean up existing connection first + if (eventSource) { + console.log('Closing existing event source'); + eventSource.close(); + setEventSource(null); + } + + let retryCount = 0; + const MAX_RETRIES = 3; + const RETRY_DELAY = 2000; + + const setupConnection = () => { + try { + console.log(`Creating new EventSource for ${config.apiUrl}/csv/${type}/progress`); + const source = new EventSource(`${config.apiUrl}/csv/${type}/progress`, { + withCredentials: true + }); + + source.onopen = () => { + console.log('EventSource connected successfully'); + retryCount = 0; + setEventSource(source); + }; + + source.onerror = async (event) => { + console.error('EventSource error:', event); + source.close(); + + const isActive = type === 'import' ? isImporting : + type === 'update' ? isUpdating : + type === 'reset' ? isResetting : + type === 'reset-metrics' ? isResettingMetrics : + type === 'calculate-metrics' ? isCalculatingMetrics : false; + + if (retryCount < MAX_RETRIES && isActive) { + console.log(`Retrying connection (${retryCount + 1}/${MAX_RETRIES})...`); + retryCount++; + await new Promise(resolve => setTimeout(resolve, RETRY_DELAY)); + setupConnection(); + } else if (retryCount >= MAX_RETRIES) { + console.log('Max retries exceeded, but operation may still be running...'); + console.warn(`Lost connection to ${type} progress stream after ${MAX_RETRIES} retries`); + } + + setEventSource(null); + }; + + source.onmessage = (event) => { + try { + console.log(`Received message for ${type}:`, event.data); + const data = JSON.parse(event.data); + handleProgressUpdate(type, data.progress || data, source); + } catch (error) { + console.error('Error parsing event data:', error, event.data); + console.warn('Failed to parse server response:', error); + } + }; + + } catch (error) { + console.error('Failed to set up EventSource:', error); + if (retryCount < MAX_RETRIES) { + console.log(`Retrying connection (${retryCount + 1}/${MAX_RETRIES})...`); + retryCount++; + setTimeout(setupConnection, RETRY_DELAY); + } else { + console.log('Max retries exceeded, but operation may still be running...'); + } + } + }; + + setupConnection(); + }; + + const handleProgressUpdate = ( + type: 'update' | 'import' | 'reset' | 'reset-metrics' | 'calculate-metrics', + progressData: any, + source: EventSource + ) => { + const processedData = { + ...progressData, + message: progressData.message && typeof progressData.message === 'object' + ? JSON.stringify(progressData.message, null, 2) + : progressData.message, + status: progressData.status || 'running' + }; + + if (type === 'import' && progressData.operation) { + const operation = progressData.operation.toLowerCase(); + if (operation.includes('purchase orders')) { + setPurchaseOrdersProgress(prev => ({ + ...prev, + ...processedData + })); + return; + } + } + + switch (type) { + case 'update': + setUpdateProgress(prev => ({ + ...prev, + ...processedData + })); + break; + case 'reset': + setResetProgress(prev => ({ + ...prev, + ...processedData + })); + break; + case 'reset-metrics': + setResetMetricsProgress(prev => ({ + ...prev, + ...processedData + })); + break; + case 'calculate-metrics': + setMetricsProgress(prev => ({ + ...prev, + ...processedData + })); + break; + default: + setImportProgress(prev => ({ + ...prev, + ...processedData + })); + } + + if (progressData.status === 'complete' || progressData.status === 'cancelled') { + console.log(`Operation ${type} completed or cancelled`); + + if (type === 'import') { + const operation = progressData.operation?.toLowerCase() || ''; + if (operation.includes('purchase orders')) { + setPurchaseOrdersProgress(null); + } else { + setImportProgress(null); + } + + if (!importProgress || !purchaseOrdersProgress) { + source.close(); + setEventSource(null); + setIsImporting(false); + if (!progressData.operation?.includes('cancelled')) { + toast.success(`${type.charAt(0).toUpperCase() + type.slice(1)} completed successfully`); + } + } + return; + } + + source.close(); + setEventSource(null); + + switch (type) { + case 'update': + setIsUpdating(false); + setUpdateProgress(null); + break; + case 'reset': + setIsResetting(false); + setResetProgress(null); + break; + case 'reset-metrics': + setIsResettingMetrics(false); + setResetMetricsProgress(null); + break; + case 'calculate-metrics': + setIsCalculatingMetrics(false); + setMetricsProgress(null); + break; + } + + if (!progressData.operation?.includes('cancelled')) { + toast.success(`${type.charAt(0).toUpperCase() + type.slice(1)} completed successfully`); + } + } + else if (progressData.status === 'error') { + console.error(`Operation ${type} failed:`, progressData.error); + source.close(); + setEventSource(null); + + switch (type) { + case 'update': + setIsUpdating(false); + break; + case 'import': + setIsImporting(false); + setImportProgress(null); + setPurchaseOrdersProgress(null); + break; + case 'reset': + setIsResetting(false); + break; + case 'reset-metrics': + setIsResettingMetrics(false); + break; + case 'calculate-metrics': + setIsCalculatingMetrics(false); + break; + } + + toast.error(`${type.charAt(0).toUpperCase() + type.slice(1)} failed: ${progressData.error || 'Unknown error'}`); + } + }; + + const handleCancel = async () => { + const operation = isImporting ? 'import' : + isUpdating ? 'update' : + isResetting ? 'reset' : + isCalculatingMetrics ? 'calculate-metrics' : 'reset'; + + toast.warning(`${operation.charAt(0).toUpperCase() + operation.slice(1)} cancelled`); + + if (eventSource) { + eventSource.close(); + setEventSource(null); + } + setIsUpdating(false); + setIsImporting(false); + setIsResetting(false); + setIsCalculatingMetrics(false); + setUpdateProgress(null); + setImportProgress(null); + setResetProgress(null); + setMetricsProgress(null); + + try { + await fetch(`${config.apiUrl}/csv/cancel?operation=${operation}`, { + method: 'POST', + credentials: 'include' + }); + } catch (error) { + console.log('Cancel request failed:', error); + } + }; + + const handleUpdateCSV = async () => { + setIsUpdating(true); + setUpdateProgress({ status: 'running', operation: 'Starting CSV update' }); + + try { + connectToEventSource('update'); + + const response = await fetch(`${config.apiUrl}/csv/update`, { + method: 'POST', + credentials: 'include' + }); + + if (!response.ok) { + const data = await response.json().catch(() => ({})); + if (data.error === 'Import already in progress') { + return; + } + throw new Error(data.error || `Failed to update CSV files: ${response.status} ${response.statusText}`); + } + } catch (error) { + if (eventSource) { + eventSource.close(); + setEventSource(null); + } + setIsUpdating(false); + setUpdateProgress(null); + toast.error(`CSV update failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + }; + + const handleImportCSV = async () => { + setIsImporting(true); + setImportProgress({ status: 'running', operation: 'Starting import process' }); + + try { + connectToEventSource('import'); + + const response = await fetch(`${config.apiUrl}/csv/import`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + credentials: 'include', + body: JSON.stringify(limits) + }).catch(error => { + if ((importProgress?.current || purchaseOrdersProgress?.current) && + (error.name === 'TypeError')) { + console.log('Request error but import is in progress:', error); + return null; + } + throw error; + }); + + if (!response) { + console.log('Continuing with existing progress...'); + return; + } + + if (!importProgress?.current && !purchaseOrdersProgress?.current) { + if (!response.ok && response.status !== 200) { + const data = await response.json().catch(() => ({})); + throw new Error(data.error || 'Failed to start CSV import'); + } + } else { + console.log('Response not ok but import is in progress, continuing...'); + } + } catch (error) { + toast.error(`CSV import failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + setIsImporting(false); + } + }; + + const handleResetDB = async () => { + setIsResetting(true); + setResetProgress({ + status: 'running', + operation: 'Starting database reset', + percentage: '0' + }); + + try { + connectToEventSource('reset'); + + const response = await fetch(`${config.apiUrl}/csv/reset`, { + method: 'POST', + credentials: 'include' + }); + + if (!response.ok) { + const data = await response.json().catch(() => ({})); + console.error('Reset request failed:', response.status, response.statusText, data); + throw new Error(data.error || `Failed to reset database: ${response.status} ${response.statusText}`); + } + } catch (error) { + console.error('Reset error:', error); + if (eventSource) { + eventSource.close(); + setEventSource(null); + } + setIsResetting(false); + setResetProgress(null); + toast.error(`Database reset failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + }; + + const handleResetMetrics = async () => { + setIsResettingMetrics(true); + setResetMetricsProgress({ status: 'running', operation: 'Starting metrics reset' }); + + try { + connectToEventSource('reset-metrics'); + + const response = await fetch(`${config.apiUrl}/csv/reset-metrics`, { + method: 'POST', + credentials: 'include' + }); + + if (!response.ok) { + const data = await response.json().catch(() => ({})); + throw new Error(data.error || 'Failed to reset metrics'); + } + } catch (error) { + if (eventSource) { + eventSource.close(); + setEventSource(null); + } + setIsResettingMetrics(false); + setResetMetricsProgress(null); + toast.error(`Metrics reset failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + }; + + const handleCalculateMetrics = async () => { + setIsCalculatingMetrics(true); + setMetricsProgress({ status: 'running', operation: 'Starting metrics calculation' }); + + try { + connectToEventSource('calculate-metrics'); + + const response = await fetch(`${config.apiUrl}/csv/calculate-metrics`, { + method: 'POST', + credentials: 'include' + }); + + if (!response.ok) { + const data = await response.json().catch(() => ({})); + throw new Error(data.error || 'Failed to calculate metrics'); + } + } catch (error) { + if (eventSource) { + eventSource.close(); + setEventSource(null); + } + setIsCalculatingMetrics(false); + setMetricsProgress(null); + toast.error(`Metrics calculation failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + }; + + const renderProgress = (progress: ImportProgress | null) => { + if (!progress) return null; + + let percentage = progress.percentage ? parseFloat(progress.percentage) : + (progress.current && progress.total) ? (progress.current / progress.total) * 100 : null; + + const message = progress.message ? + (typeof progress.message === 'object' ? + JSON.stringify(progress.message, null, 2) : + progress.message + ) : null; + + return ( +
+
+ {progress.operation || 'Processing...'} + {percentage !== null && {Math.round(percentage)}%} +
+ {percentage !== null && ( + <> + +
+ {progress.current && progress.total && ( +
+ Progress: + {progress.current.toLocaleString()} / {progress.total.toLocaleString()} {progress.rate ? `(${Math.round(progress.rate)}/s)` : ''} +
+ )} + {(progress.elapsed || progress.remaining) && ( +
+ Time: + + {progress.elapsed && `${progress.elapsed} elapsed`} + {progress.elapsed && progress.remaining && ' - '} + {progress.remaining && `${progress.remaining} remaining`} + +
+ )} + {(progress.added !== undefined || progress.updated !== undefined || progress.skipped !== undefined) && ( +
+ Results: + + {progress.added !== undefined && `${progress.added.toLocaleString()} added`} + {progress.added !== undefined && progress.updated !== undefined && ', '} + {progress.updated !== undefined && `${progress.updated.toLocaleString()} updated`} + {((progress.added !== undefined || progress.updated !== undefined) && progress.skipped !== undefined) && ', '} + {progress.skipped !== undefined && `${progress.skipped.toLocaleString()} skipped`} + +
+ )} + {message && ( +
+ {message} +
+ )} +
+ + )} + {!percentage && message && ( +
+ {message} +
+ )} +
+ ); + }; + + return ( +
+ {/* Update CSV Card */} + + + Update CSV Files + Download the latest CSV data files + + +
+ + + {isUpdating && ( + + )} +
+ + {isUpdating && renderProgress(updateProgress)} +
+
+ + {/* Import Data Card */} + + + Import Data + Import current CSV files into database + + +
+ + + {isImporting && ( + + )} +
+ + {isImporting && ( +
+ {importProgress && renderProgress(importProgress)} + {purchaseOrdersProgress && renderProgress(purchaseOrdersProgress)} +
+ )} +
+
+ + {/* Metrics Calculation Card */} + + + Calculate Metrics + Calculate metrics for all products based on current data + + +
+ + + {isCalculatingMetrics && ( + + )} +
+ + {metricsProgress && renderProgress(metricsProgress)} +
+
+ + {/* Database Management Card */} + + + Database Management + Reset database or metrics tables + + +
+ + + + + + + Reset Database + + This will delete all data in the database and recreate the tables. This action cannot be undone. + + + + Cancel + Continue + + + + + + + + + + + Reset metrics tables? + + This action cannot be undone. This will permanently delete all data from metrics-related tables. + + + + Cancel + Continue + + + +
+ + {resetProgress && ( +
+ +

+ {resetProgress.message || 'Resetting database...'} +

+
+ )} + + {resetMetricsProgress && ( +
+ +

+ {resetMetricsProgress.message || 'Resetting metrics...'} +

+
+ )} +
+
+
+ ); +} \ No newline at end of file diff --git a/inventory/src/pages/Settings.tsx b/inventory/src/pages/Settings.tsx index eb29634..e5f15bd 100644 --- a/inventory/src/pages/Settings.tsx +++ b/inventory/src/pages/Settings.tsx @@ -1,975 +1,28 @@ -import { useState, useEffect, useCallback } from 'react'; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { Progress } from "@/components/ui/progress"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { toast } from "sonner"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@/components/ui/alert-dialog"; -import { Loader2, RefreshCw, Upload, X } from "lucide-react"; -import config from '../config'; - -interface ImportProgress { - status: 'running' | 'error' | 'complete'; - operation?: string; - current?: number; - total?: number; - rate?: number; - elapsed?: string; - remaining?: string; - progress?: string; - error?: string; - percentage?: string; - message?: string; - testLimit?: number; - added?: number; - updated?: number; - skipped?: number; - duration?: string; -} - -interface ImportLimits { - products: number; - orders: number; - purchaseOrders: number; -} +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { DataManagement } from "@/components/settings/DataManagement"; +import { Configuration } from "@/components/settings/Configuration"; export function Settings() { - const [isUpdating, setIsUpdating] = useState(false); - const [isImporting, setIsImporting] = useState(false); - const [isResetting, setIsResetting] = useState(false); - const [updateProgress, setUpdateProgress] = useState(null); - const [importProgress, setImportProgress] = useState(null); - const [purchaseOrdersProgress, setPurchaseOrdersProgress] = useState(null); - const [resetProgress, setResetProgress] = useState(null); - const [eventSource, setEventSource] = useState(null); - const [limits, setLimits] = useState({ - products: 0, - orders: 0, - purchaseOrders: 0 - }); - const [isResettingMetrics, setIsResettingMetrics] = useState(false); - const [resetMetricsProgress, setResetMetricsProgress] = useState(null); - const [isCalculatingMetrics, setIsCalculatingMetrics] = useState(false); - const [metricsProgress, setMetricsProgress] = useState(null); - - // Update the message handlers to use toast - const handleComplete = (operation: string) => { - toast.success(`${operation} completed successfully`); - }; - - const handleError = (operation: string, error: string) => { - // Skip error toast if we're cancelling or if it's a cancellation error - if (error.includes('cancelled') || - error.includes('Process exited with code 143') || - error.includes('Operation cancelled') || - error.includes('500 Internal Server Error') || - // Skip "Failed to start" errors if we have active progress - (error.includes('Failed to start CSV import') && (importProgress || purchaseOrdersProgress)) || - // Skip connection errors if we have active progress - (error.includes('Failed to fetch') && (importProgress || purchaseOrdersProgress))) { - return; - } - toast.error(`${operation} failed: ${error}`); - }; - - // Helper to connect to event source - const connectToEventSource = useCallback((type: 'update' | 'import' | 'reset' | 'reset-metrics' | 'calculate-metrics') => { - console.log(`Setting up EventSource for ${type}...`); - - // Clean up existing connection first - if (eventSource) { - console.log('Closing existing event source'); - eventSource.close(); - setEventSource(null); - } - - let retryCount = 0; - const MAX_RETRIES = 3; - const RETRY_DELAY = 2000; // 2 seconds - - const setupConnection = () => { - try { - console.log(`Creating new EventSource for ${config.apiUrl}/csv/${type}/progress`); - const source = new EventSource(`${config.apiUrl}/csv/${type}/progress`, { - withCredentials: true - }); - - source.onopen = () => { - console.log('EventSource connected successfully'); - retryCount = 0; // Reset retry count on successful connection - setEventSource(source); - }; - - source.onerror = async (event) => { - console.error('EventSource error:', event); - source.close(); - - // Only retry if we haven't exceeded max retries and we're still in the active state - const isActive = type === 'import' ? isImporting : - type === 'update' ? isUpdating : - type === 'reset' ? isResetting : - type === 'reset-metrics' ? isResettingMetrics : - type === 'calculate-metrics' ? isCalculatingMetrics : false; - - if (retryCount < MAX_RETRIES && isActive) { - console.log(`Retrying connection (${retryCount + 1}/${MAX_RETRIES})...`); - retryCount++; - // Wait before retrying - await new Promise(resolve => setTimeout(resolve, RETRY_DELAY)); - setupConnection(); - } else if (retryCount >= MAX_RETRIES) { - console.log('Max retries exceeded, but operation may still be running...'); - // Don't reset states or show error, just log it - console.warn(`Lost connection to ${type} progress stream after ${MAX_RETRIES} retries`); - } - - setEventSource(null); - }; - - source.onmessage = (event) => { - try { - console.log(`Received message for ${type}:`, event.data); - - // First parse the outer message - const data = JSON.parse(event.data); - - // If we have a progress field that's a string containing multiple JSON objects - if (data.progress && typeof data.progress === 'string') { - // Split the progress string into separate JSON objects - const progressMessages = data.progress - .split('\n') - .filter(Boolean) - .map((message: string) => { - try { - return JSON.parse(message); - } catch (err) { - console.warn('Failed to parse progress message:', message, err); - return null; - } - }) - .filter((msg: unknown): msg is ImportProgress => msg !== null); - - // Process each progress message - progressMessages.forEach((progressData: ImportProgress) => { - handleProgressUpdate(type, progressData, source); - }); - } else { - // Handle single message case - const progressData = data.progress || data; - handleProgressUpdate(type, progressData, source); - } - } catch (error) { - console.error('Error parsing event data:', error, event.data); - // Don't show error to user for parsing issues, just log them - console.warn('Failed to parse server response:', error); - } - }; - - } catch (error) { - console.error('Failed to set up EventSource:', error); - if (retryCount < MAX_RETRIES) { - console.log(`Retrying connection (${retryCount + 1}/${MAX_RETRIES})...`); - retryCount++; - setTimeout(setupConnection, RETRY_DELAY); - } else { - console.log('Max retries exceeded, but operation may still be running...'); - } - } - }; - - setupConnection(); - }, [eventSource, handleComplete, handleError, isImporting, isUpdating, isResetting, isResettingMetrics, isCalculatingMetrics]); - - // Helper function to process a single progress update - const handleProgressUpdate = ( - type: 'update' | 'import' | 'reset' | 'reset-metrics' | 'calculate-metrics', - progressData: any, - source: EventSource - ) => { - console.log('Processing progress data:', progressData); - - // If message is an object, stringify it before setting state - const processedData = { - ...progressData, - message: progressData.message && typeof progressData.message === 'object' - ? JSON.stringify(progressData.message, null, 2) - : progressData.message, - status: progressData.status || 'running' - }; - - // For import type, handle orders and purchase orders separately - if (type === 'import' && progressData.operation) { - const operation = progressData.operation.toLowerCase(); - if (operation.includes('purchase orders')) { - setPurchaseOrdersProgress(prev => ({ - ...prev, - ...processedData - })); - return; // Don't update main import progress for PO updates - } - } - - // Update progress state based on type - switch (type) { - case 'update': - setUpdateProgress(prev => ({ - ...prev, - ...processedData - })); - break; - case 'reset': - setResetProgress(prev => ({ - ...prev, - ...processedData - })); - break; - case 'reset-metrics': - setResetMetricsProgress(prev => ({ - ...prev, - ...processedData - })); - break; - case 'calculate-metrics': - setMetricsProgress(prev => ({ - ...prev, - ...processedData - })); - break; - default: - setImportProgress(prev => ({ - ...prev, - ...processedData - })); - } - - // Handle completion - if (progressData.status === 'complete' || progressData.status === 'cancelled') { - console.log(`Operation ${type} completed or cancelled`); - - // For import, only close connection when both operations are complete - if (type === 'import') { - const operation = progressData.operation?.toLowerCase() || ''; - if (operation.includes('purchase orders')) { - setPurchaseOrdersProgress(null); - } else { - setImportProgress(null); - } - - // Only fully complete if both are done - if (!importProgress || !purchaseOrdersProgress) { - source.close(); - setEventSource(null); - setIsImporting(false); - if (!progressData.operation?.includes('cancelled')) { - handleComplete(`${type.charAt(0).toUpperCase() + type.slice(1)}`); - } - } - return; - } - - // For other operations, close immediately - source.close(); - setEventSource(null); - - // Reset operation state - switch (type) { - case 'update': - setIsUpdating(false); - setUpdateProgress(null); - break; - case 'reset': - setIsResetting(false); - setResetProgress(null); - break; - case 'reset-metrics': - setIsResettingMetrics(false); - setResetMetricsProgress(null); - break; - case 'calculate-metrics': - setIsCalculatingMetrics(false); - setMetricsProgress(null); - break; - } - - if (!progressData.operation?.includes('cancelled')) { - handleComplete(`${type.charAt(0).toUpperCase() + type.slice(1)}`); - } - } - // Handle errors - else if (progressData.status === 'error') { - console.error(`Operation ${type} failed:`, progressData.error); - source.close(); - setEventSource(null); - - // Reset operation state - switch (type) { - case 'update': - setIsUpdating(false); - break; - case 'import': - setIsImporting(false); - setImportProgress(null); - setPurchaseOrdersProgress(null); - break; - case 'reset': - setIsResetting(false); - break; - case 'reset-metrics': - setIsResettingMetrics(false); - break; - case 'calculate-metrics': - setIsCalculatingMetrics(false); - break; - } - - handleError(`${type.charAt(0).toUpperCase() + type.slice(1)}`, progressData.error || 'Unknown error'); - } - }; - - // Check for active operations on mount - useEffect(() => { - console.log('Checking status on mount'); // Debug log - const checkStatus = async () => { - try { - const response = await fetch(`${config.apiUrl}/csv/status`, { - credentials: 'include' - }); - const data = await response.json(); - console.log('Status check response:', data); // Debug log - - if (data.active) { - // Try to determine the operation type from progress if available - let operationType: 'update' | 'import' | 'reset' | 'reset-metrics' | 'calculate-metrics' | null = null; - - if (data.progress?.operation) { - const operation = data.progress.operation.toLowerCase(); - if (operation.includes('update')) operationType = 'update'; - else if (operation.includes('import')) operationType = 'import'; - else if (operation.includes('reset')) operationType = 'reset'; - else if (operation.includes('reset-metrics')) operationType = 'reset-metrics'; - else if (operation.includes('calculate-metrics')) operationType = 'calculate-metrics'; - } else { - // If no progress data, try to connect to import stream by default - // since that's the most common long-running operation - operationType = 'import'; - } - - if (operationType) { - // Set initial state - if (operationType === 'update') { - setIsUpdating(true); - if (data.progress) { - setUpdateProgress({ - ...data.progress, - status: data.progress.status || 'running' - }); - } - } else if (operationType === 'import') { - setIsImporting(true); - if (data.progress) { - setImportProgress({ - ...data.progress, - status: data.progress.status || 'running' - }); - } - } else if (operationType === 'reset') { - setIsResetting(true); - if (data.progress) { - setResetProgress({ - ...data.progress, - status: data.progress.status || 'running' - }); - } - } else if (operationType === 'reset-metrics') { - setIsResettingMetrics(true); - if (data.progress) { - setResetMetricsProgress({ - ...data.progress, - status: data.progress.status || 'running' - }); - } - } else if (operationType === 'calculate-metrics') { - setIsCalculatingMetrics(true); - if (data.progress) { - setMetricsProgress({ - ...data.progress, - status: data.progress.status || 'running' - }); - } - } - - // Connect to the appropriate event source - console.log('Connecting to event source for active operation:', operationType); - connectToEventSource(operationType); - } - } - } catch (error) { - console.error('Failed to check operation status:', error); - } - }; - - checkStatus(); - }, []); // Remove connectToEventSource dependency to ensure it runs on mount - - // Clean up function to reset state - useEffect(() => { - return () => { - if (eventSource) { - console.log('Cleaning up event source'); // Debug log - eventSource.close(); - } - }; - }, [eventSource]); - - const handleCancel = async () => { - // Determine which operation is running - const operation = isImporting ? 'import' : - isUpdating ? 'update' : - isResetting ? 'reset' : - isCalculatingMetrics ? 'calculate-metrics' : 'reset'; - - // Show cancellation toast immediately - toast.warning(`${operation.charAt(0).toUpperCase() + operation.slice(1)} cancelled`); - - // Clean up everything immediately - if (eventSource) { - eventSource.close(); - setEventSource(null); - } - setIsUpdating(false); - setIsImporting(false); - setIsResetting(false); - setIsCalculatingMetrics(false); - setUpdateProgress(null); - setImportProgress(null); - setResetProgress(null); - setMetricsProgress(null); - - // Fire and forget the cancel request with the operation type - try { - await fetch(`${config.apiUrl}/csv/cancel?operation=${operation}`, { - method: 'POST', - credentials: 'include' - }); - } catch (error) { - // Silently ignore any errors from the cancel request - console.log('Cancel request failed:', error); - } - }; - - const handleUpdateCSV = async () => { - setIsUpdating(true); - setUpdateProgress({ status: 'running', operation: 'Starting CSV update' }); - - try { - // Connect to SSE for progress updates - connectToEventSource('update'); - - // Make the update request - const response = await fetch(`${config.apiUrl}/csv/update`, { - method: 'POST', - credentials: 'include' - }); - - if (!response.ok) { - const data = await response.json().catch(() => ({})); - if (data.error === 'Import already in progress') { - // If there's already an import, just let the SSE connection handle showing progress - return; - } - throw new Error(data.error || `Failed to update CSV files: ${response.status} ${response.statusText}`); - } - } catch (error) { - if (eventSource) { - eventSource.close(); - setEventSource(null); - } - setIsUpdating(false); - setUpdateProgress(null); - handleError('CSV update', error instanceof Error ? error.message : 'Unknown error'); - } - }; - - const handleImportCSV = async () => { - setIsImporting(true); - setImportProgress({ status: 'running', operation: 'Starting import process' }); - - try { - // Connect to SSE for progress updates first - connectToEventSource('import'); - - // Make the import request - no timeout needed since this just starts the process - const response = await fetch(`${config.apiUrl}/csv/import`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - credentials: 'include', - body: JSON.stringify(limits) - }).catch(error => { - // If we already have progress, ignore network errors - if ((importProgress?.current || purchaseOrdersProgress?.current) && - (error.name === 'TypeError')) { - console.log('Request error but import is in progress:', error); - return null; - } - throw error; - }); - - // If response is null, it means we caught an error but have progress - if (!response) { - console.log('Continuing with existing progress...'); - return; - } - - // Only check response status if we don't have any progress - if (!importProgress?.current && !purchaseOrdersProgress?.current) { - if (!response.ok && response.status !== 200) { - const data = await response.json().catch(() => ({})); - throw new Error(data.error || 'Failed to start CSV import'); - } - } else { - console.log('Response not ok but import is in progress, continuing...'); - } - } catch (error) { - handleError('CSV import', error instanceof Error ? error.message : 'Unknown error'); - setIsImporting(false); - } - }; - - const handleResetDB = async () => { - // Set initial state - setIsResetting(true); - setResetProgress({ - status: 'running', - operation: 'Starting database reset', - percentage: '0' - }); - - try { - // Connect to SSE for progress updates - connectToEventSource('reset'); - - // Make the reset request - const response = await fetch(`${config.apiUrl}/csv/reset`, { - method: 'POST', - credentials: 'include' - }); - - if (!response.ok) { - const data = await response.json().catch(() => ({})); - console.error('Reset request failed:', response.status, response.statusText, data); - throw new Error(data.error || `Failed to reset database: ${response.status} ${response.statusText}`); - } - } catch (error) { - console.error('Reset error:', error); - if (eventSource) { - eventSource.close(); - setEventSource(null); - } - setIsResetting(false); - setResetProgress(null); - handleError('Database reset', error instanceof Error ? error.message : 'Unknown error'); - } - }; - - const handleResetMetrics = async () => { - setIsResettingMetrics(true); - setResetMetricsProgress({ status: 'running', operation: 'Starting metrics reset' }); - - try { - // Connect to SSE for progress updates - connectToEventSource('reset-metrics'); - - // Make the reset request - const response = await fetch(`${config.apiUrl}/csv/reset-metrics`, { - method: 'POST', - credentials: 'include' - }); - - if (!response.ok) { - const data = await response.json().catch(() => ({})); - throw new Error(data.error || 'Failed to reset metrics'); - } - } catch (error) { - if (eventSource) { - eventSource.close(); - setEventSource(null); - } - setIsResettingMetrics(false); - setResetMetricsProgress(null); - handleError('Metrics reset', error instanceof Error ? error.message : 'Unknown error'); - } - }; - - const handleCalculateMetrics = async () => { - setIsCalculatingMetrics(true); - setMetricsProgress({ status: 'running', operation: 'Starting metrics calculation' }); - - try { - // Connect to SSE for progress updates - connectToEventSource('calculate-metrics'); - - // Make the calculation request - const response = await fetch(`${config.apiUrl}/csv/calculate-metrics`, { - method: 'POST', - credentials: 'include' - }); - - if (!response.ok) { - const data = await response.json().catch(() => ({})); - throw new Error(data.error || 'Failed to calculate metrics'); - } - } catch (error) { - if (eventSource) { - eventSource.close(); - setEventSource(null); - } - setIsCalculatingMetrics(false); - setMetricsProgress(null); - handleError('Metrics calculation', error instanceof Error ? error.message : 'Unknown error'); - } - }; - - const renderProgress = (progress: ImportProgress | null) => { - if (!progress) return null; - - let percentage = progress.percentage ? parseFloat(progress.percentage) : - (progress.current && progress.total) ? (progress.current / progress.total) * 100 : null; - - // Handle object messages by converting them to strings - const message = progress.message ? - (typeof progress.message === 'object' ? - JSON.stringify(progress.message, null, 2) : - progress.message - ) : null; - - return ( -
-
- {progress.operation || 'Processing...'} - {percentage !== null && {Math.round(percentage)}%} -
- {percentage !== null && ( - <> - -
- {progress.current && progress.total && ( -
- Progress: - {progress.current.toLocaleString()} / {progress.total.toLocaleString()} {progress.rate ? `(${Math.round(progress.rate)}/s)` : ''} -
- )} - {(progress.elapsed || progress.remaining) && ( -
- Time: - - {progress.elapsed && `${progress.elapsed} elapsed`} - {progress.elapsed && progress.remaining && ' - '} - {progress.remaining && `${progress.remaining} remaining`} - -
- )} - {(progress.added !== undefined || progress.updated !== undefined || progress.skipped !== undefined) && ( -
- Results: - - {progress.added !== undefined && `${progress.added.toLocaleString()} added`} - {progress.added !== undefined && progress.updated !== undefined && ', '} - {progress.updated !== undefined && `${progress.updated.toLocaleString()} updated`} - {((progress.added !== undefined || progress.updated !== undefined) && progress.skipped !== undefined) && ', '} - {progress.skipped !== undefined && `${progress.skipped.toLocaleString()} skipped`} - -
- )} - {message && ( -
- {message} -
- )} -
- - )} - {!percentage && message && ( -
- {message} -
- )} -
- ); - }; - return (

Settings

-
- {/* Update CSV Card */} - - - Update CSV Files - Download the latest CSV data files - - -
- + + + Data Management + Configuration + - {isUpdating && ( - - )} -
+ + + - {isUpdating && renderProgress(updateProgress)} -
-
- - {/* Import Data Card */} - - - Import Data - Import current CSV files into database. Set limits to 0 to import all records. - - -
-
- - setLimits(prev => ({ ...prev, products: parseInt(e.target.value) || 0 }))} - disabled={isUpdating || isImporting} - className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" - placeholder="0 for no limit" - /> -
-
- - setLimits(prev => ({ ...prev, orders: parseInt(e.target.value) || 0 }))} - disabled={isUpdating || isImporting} - className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" - placeholder="0 for no limit" - /> -
-
- - setLimits(prev => ({ ...prev, purchaseOrders: parseInt(e.target.value) || 0 }))} - disabled={isUpdating || isImporting} - className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" - placeholder="0 for no limit" - /> -
-
- -
- - - {isImporting && ( - - )} -
- - {isImporting && ( -
- {importProgress && renderProgress(importProgress)} - {purchaseOrdersProgress && renderProgress(purchaseOrdersProgress)} -
- )} -
-
- {/* Add new Metrics Calculation Card */} - - - Metrics Calculation - Calculate metrics for all products based on current data - - -
- - - {isCalculatingMetrics && ( - - )} -
- - {metricsProgress && renderProgress(metricsProgress)} -
-
- - {/* Database Management Card */} - - - Database Management - Reset database or metrics tables - - -
- - - - - - - Reset Database - - This will delete all data in the database and recreate the tables. This action cannot be undone. - - - - Cancel - Continue - - - - - - - - - - - Reset metrics tables? - - This action cannot be undone. This will permanently delete all data from metrics-related tables. - - - - Cancel - Continue - - - -
- - {resetProgress && ( -
- -

- {resetProgress.message || 'Resetting database...'} -

-
- )} - - {resetMetricsProgress && ( -
- -

- {resetMetricsProgress.message || 'Resetting metrics...'} -

-
- )} -
-
- - - -
+ + + +
); } \ No newline at end of file