Split off config schema, update settings page
This commit is contained in:
126
inventory/src/components/settings/Configuration.tsx
Normal file
126
inventory/src/components/settings/Configuration.tsx
Normal file
@@ -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<StockThreshold>({
|
||||
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 (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Stock Thresholds Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Stock Thresholds</CardTitle>
|
||||
<CardDescription>Configure stock level thresholds for inventory management</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{/* Global Defaults Section */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">Global Defaults</h3>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="critical-days">Critical Days</Label>
|
||||
<Input
|
||||
id="critical-days"
|
||||
type="number"
|
||||
min="1"
|
||||
value={globalThresholds.critical_days}
|
||||
onChange={(e) => setGlobalThresholds(prev => ({
|
||||
...prev,
|
||||
critical_days: parseInt(e.target.value) || 1
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="reorder-days">Reorder Days</Label>
|
||||
<Input
|
||||
id="reorder-days"
|
||||
type="number"
|
||||
min="1"
|
||||
value={globalThresholds.reorder_days}
|
||||
onChange={(e) => setGlobalThresholds(prev => ({
|
||||
...prev,
|
||||
reorder_days: parseInt(e.target.value) || 1
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="overstock-days">Overstock Days</Label>
|
||||
<Input
|
||||
id="overstock-days"
|
||||
type="number"
|
||||
min="1"
|
||||
value={globalThresholds.overstock_days}
|
||||
onChange={(e) => setGlobalThresholds(prev => ({
|
||||
...prev,
|
||||
overstock_days: parseInt(e.target.value) || 1
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
className="mt-2"
|
||||
onClick={handleUpdateGlobalThresholds}
|
||||
>
|
||||
Update Global Defaults
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Category/Vendor Specific Section */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">Category & Vendor Specific</h3>
|
||||
<Button variant="outline" className="w-full">Add New Threshold Rule</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Future Config Cards can go here */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
745
inventory/src/components/settings/DataManagement.tsx
Normal file
745
inventory/src/components/settings/DataManagement.tsx
Normal file
@@ -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<ImportProgress | null>(null);
|
||||
const [importProgress, setImportProgress] = useState<ImportProgress | null>(null);
|
||||
const [purchaseOrdersProgress, setPurchaseOrdersProgress] = useState<ImportProgress | null>(null);
|
||||
const [resetProgress, setResetProgress] = useState<ImportProgress | null>(null);
|
||||
const [eventSource, setEventSource] = useState<EventSource | null>(null);
|
||||
const [limits, setLimits] = useState<ImportLimits>({
|
||||
products: 0,
|
||||
orders: 0,
|
||||
purchaseOrders: 0
|
||||
});
|
||||
const [isResettingMetrics, setIsResettingMetrics] = useState(false);
|
||||
const [resetMetricsProgress, setResetMetricsProgress] = useState<ImportProgress | null>(null);
|
||||
const [isCalculatingMetrics, setIsCalculatingMetrics] = useState(false);
|
||||
const [metricsProgress, setMetricsProgress] = useState<ImportProgress | null>(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 (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm text-muted-foreground">
|
||||
<span>{progress.operation || 'Processing...'}</span>
|
||||
{percentage !== null && <span>{Math.round(percentage)}%</span>}
|
||||
</div>
|
||||
{percentage !== null && (
|
||||
<>
|
||||
<Progress value={percentage} className="h-2" />
|
||||
<div className="flex flex-col gap-1 text-xs text-muted-foreground">
|
||||
{progress.current && progress.total && (
|
||||
<div className="flex justify-between">
|
||||
<span>Progress:</span>
|
||||
<span>{progress.current.toLocaleString()} / {progress.total.toLocaleString()} {progress.rate ? `(${Math.round(progress.rate)}/s)` : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
{(progress.elapsed || progress.remaining) && (
|
||||
<div className="flex justify-between">
|
||||
<span>Time:</span>
|
||||
<span>
|
||||
{progress.elapsed && `${progress.elapsed} elapsed`}
|
||||
{progress.elapsed && progress.remaining && ' - '}
|
||||
{progress.remaining && `${progress.remaining} remaining`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{(progress.added !== undefined || progress.updated !== undefined || progress.skipped !== undefined) && (
|
||||
<div className="flex justify-between">
|
||||
<span>Results:</span>
|
||||
<span>
|
||||
{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`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{message && (
|
||||
<div className="whitespace-pre-wrap font-mono text-xs">
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!percentage && message && (
|
||||
<div className="whitespace-pre-wrap font-mono text-xs">
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-[400px] space-y-4">
|
||||
{/* Update CSV Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Update CSV Files</CardTitle>
|
||||
<CardDescription>Download the latest CSV data files</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={handleUpdateCSV}
|
||||
disabled={isUpdating || isImporting}
|
||||
>
|
||||
{isUpdating ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Updating CSV Files...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Update CSV Files
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{isUpdating && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isUpdating && renderProgress(updateProgress)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Import Data Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Import Data</CardTitle>
|
||||
<CardDescription>Import current CSV files into database</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={handleImportCSV}
|
||||
disabled={isImporting || isUpdating || isResetting}
|
||||
>
|
||||
{isImporting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Importing Data...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
Import Data
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{isImporting && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isImporting && (
|
||||
<div className="space-y-4">
|
||||
{importProgress && renderProgress(importProgress)}
|
||||
{purchaseOrdersProgress && renderProgress(purchaseOrdersProgress)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Metrics Calculation Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Calculate Metrics</CardTitle>
|
||||
<CardDescription>Calculate metrics for all products based on current data</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={handleCalculateMetrics}
|
||||
disabled={isCalculatingMetrics || isImporting || isUpdating || isResetting || isResettingMetrics}
|
||||
>
|
||||
{isCalculatingMetrics ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Calculating Metrics...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Calculate Metrics
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{isCalculatingMetrics && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{metricsProgress && renderProgress(metricsProgress)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Database Management Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Database Management</CardTitle>
|
||||
<CardDescription>Reset database or metrics tables</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="flex-1 min-w-[140px]"
|
||||
disabled={isResetting || isImporting || isUpdating || isResettingMetrics}
|
||||
>
|
||||
{isResetting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Resetting Database...
|
||||
</>
|
||||
) : (
|
||||
<>Reset Database</>
|
||||
)}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Reset Database</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will delete all data in the database and recreate the tables. This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleResetDB}>Continue</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="flex-1 min-w-[140px]"
|
||||
disabled={isResetting || isImporting || isUpdating || isResettingMetrics}
|
||||
>
|
||||
Reset Metrics Only
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Reset metrics tables?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will permanently delete all data from metrics-related tables.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleResetMetrics}>Continue</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
|
||||
{resetProgress && (
|
||||
<div className="mt-4">
|
||||
<Progress value={Number(resetProgress.percentage)} className="mb-2" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{resetProgress.message || 'Resetting database...'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{resetMetricsProgress && (
|
||||
<div className="mt-4">
|
||||
<Progress value={Number(resetMetricsProgress.percentage)} className="mb-2" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{resetMetricsProgress.message || 'Resetting metrics...'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<ImportProgress | null>(null);
|
||||
const [importProgress, setImportProgress] = useState<ImportProgress | null>(null);
|
||||
const [purchaseOrdersProgress, setPurchaseOrdersProgress] = useState<ImportProgress | null>(null);
|
||||
const [resetProgress, setResetProgress] = useState<ImportProgress | null>(null);
|
||||
const [eventSource, setEventSource] = useState<EventSource | null>(null);
|
||||
const [limits, setLimits] = useState<ImportLimits>({
|
||||
products: 0,
|
||||
orders: 0,
|
||||
purchaseOrders: 0
|
||||
});
|
||||
const [isResettingMetrics, setIsResettingMetrics] = useState(false);
|
||||
const [resetMetricsProgress, setResetMetricsProgress] = useState<ImportProgress | null>(null);
|
||||
const [isCalculatingMetrics, setIsCalculatingMetrics] = useState(false);
|
||||
const [metricsProgress, setMetricsProgress] = useState<ImportProgress | null>(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 (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm text-muted-foreground">
|
||||
<span>{progress.operation || 'Processing...'}</span>
|
||||
{percentage !== null && <span>{Math.round(percentage)}%</span>}
|
||||
</div>
|
||||
{percentage !== null && (
|
||||
<>
|
||||
<Progress value={percentage} className="h-2" />
|
||||
<div className="flex flex-col gap-1 text-xs text-muted-foreground">
|
||||
{progress.current && progress.total && (
|
||||
<div className="flex justify-between">
|
||||
<span>Progress:</span>
|
||||
<span>{progress.current.toLocaleString()} / {progress.total.toLocaleString()} {progress.rate ? `(${Math.round(progress.rate)}/s)` : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
{(progress.elapsed || progress.remaining) && (
|
||||
<div className="flex justify-between">
|
||||
<span>Time:</span>
|
||||
<span>
|
||||
{progress.elapsed && `${progress.elapsed} elapsed`}
|
||||
{progress.elapsed && progress.remaining && ' - '}
|
||||
{progress.remaining && `${progress.remaining} remaining`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{(progress.added !== undefined || progress.updated !== undefined || progress.skipped !== undefined) && (
|
||||
<div className="flex justify-between">
|
||||
<span>Results:</span>
|
||||
<span>
|
||||
{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`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{message && (
|
||||
<div className="whitespace-pre-wrap font-mono text-xs">
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!percentage && message && (
|
||||
<div className="whitespace-pre-wrap font-mono text-xs">
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-8 space-y-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Settings</h1>
|
||||
</div>
|
||||
|
||||
<div className="max-w-[400px] space-y-4">
|
||||
{/* Update CSV Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Update CSV Files</CardTitle>
|
||||
<CardDescription>Download the latest CSV data files</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={handleUpdateCSV}
|
||||
disabled={isUpdating || isImporting}
|
||||
>
|
||||
{isUpdating ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Updating CSV Files...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Update CSV Files
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Tabs defaultValue="data" className="w-full">
|
||||
<TabsList>
|
||||
<TabsTrigger value="data">Data Management</TabsTrigger>
|
||||
<TabsTrigger value="config">Configuration</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{isUpdating && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<TabsContent value="data" className="space-y-4">
|
||||
<DataManagement />
|
||||
</TabsContent>
|
||||
|
||||
{isUpdating && renderProgress(updateProgress)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Import Data Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Import Data</CardTitle>
|
||||
<CardDescription>Import current CSV files into database. Set limits to 0 to import all records.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="products-limit" className="text-xs">Products Limit</Label>
|
||||
<Input
|
||||
id="products-limit"
|
||||
type="number"
|
||||
min="0"
|
||||
value={limits.products}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="orders-limit" className="text-xs">Orders Limit</Label>
|
||||
<Input
|
||||
id="orders-limit"
|
||||
type="number"
|
||||
min="0"
|
||||
value={limits.orders}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="purchase-orders-limit" className="text-xs">PO Limit</Label>
|
||||
<Input
|
||||
id="purchase-orders-limit"
|
||||
type="number"
|
||||
min="0"
|
||||
value={limits.purchaseOrders}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={handleImportCSV}
|
||||
disabled={isImporting || isUpdating || isResetting}
|
||||
>
|
||||
{isImporting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Importing Data...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
Import Data
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{isImporting && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isImporting && (
|
||||
<div className="space-y-4">
|
||||
{importProgress && renderProgress(importProgress)}
|
||||
{purchaseOrdersProgress && renderProgress(purchaseOrdersProgress)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Add new Metrics Calculation Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Metrics Calculation</CardTitle>
|
||||
<CardDescription>Calculate metrics for all products based on current data</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={handleCalculateMetrics}
|
||||
disabled={isCalculatingMetrics || isImporting || isUpdating || isResetting || isResettingMetrics}
|
||||
>
|
||||
{isCalculatingMetrics ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Calculating Metrics...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Calculate Metrics
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{isCalculatingMetrics && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{metricsProgress && renderProgress(metricsProgress)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Database Management Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Database Management</CardTitle>
|
||||
<CardDescription>Reset database or metrics tables</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="flex-1 min-w-[140px]"
|
||||
disabled={isResetting || isImporting || isUpdating || isResettingMetrics}
|
||||
>
|
||||
{isResetting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Resetting Database...
|
||||
</>
|
||||
) : (
|
||||
<>Reset Database</>
|
||||
)}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Reset Database</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will delete all data in the database and recreate the tables. This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleResetDB}>Continue</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="flex-1 min-w-[140px]"
|
||||
disabled={isResetting || isImporting || isUpdating || isResettingMetrics}
|
||||
>
|
||||
Reset Metrics Only
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Reset metrics tables?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will permanently delete all data from metrics-related tables.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleResetMetrics}>Continue</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
|
||||
{resetProgress && (
|
||||
<div className="mt-4">
|
||||
<Progress value={Number(resetProgress.percentage)} className="mb-2" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{resetProgress.message || 'Resetting database...'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{resetMetricsProgress && (
|
||||
<div className="mt-4">
|
||||
<Progress value={Number(resetMetricsProgress.percentage)} className="mb-2" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{resetMetricsProgress.message || 'Resetting metrics...'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
<TabsContent value="config" className="space-y-4">
|
||||
<Configuration />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user