Split off config schema, update settings page
This commit is contained in:
49
inventory-server/db/config-schema.sql
Normal file
49
inventory-server/db/config-schema.sql
Normal file
@@ -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;
|
||||||
@@ -88,30 +88,6 @@ CREATE TABLE IF NOT EXISTS vendor_metrics (
|
|||||||
PRIMARY KEY (vendor)
|
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
|
-- Re-enable foreign key checks
|
||||||
SET FOREIGN_KEY_CHECKS = 1;
|
SET FOREIGN_KEY_CHECKS = 1;
|
||||||
|
|
||||||
|
|||||||
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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Button } from "@/components/ui/button";
|
import { DataManagement } from "@/components/settings/DataManagement";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Configuration } from "@/components/settings/Configuration";
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Settings() {
|
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 (
|
return (
|
||||||
<div className="p-8 space-y-8">
|
<div className="p-8 space-y-8">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold">Settings</h1>
|
<h1 className="text-2xl font-bold">Settings</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="max-w-[400px] space-y-4">
|
<Tabs defaultValue="data" className="w-full">
|
||||||
{/* Update CSV Card */}
|
<TabsList>
|
||||||
<Card>
|
<TabsTrigger value="data">Data Management</TabsTrigger>
|
||||||
<CardHeader>
|
<TabsTrigger value="config">Configuration</TabsTrigger>
|
||||||
<CardTitle>Update CSV Files</CardTitle>
|
</TabsList>
|
||||||
<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 && (
|
<TabsContent value="data" className="space-y-4">
|
||||||
<Button
|
<DataManagement />
|
||||||
variant="destructive"
|
</TabsContent>
|
||||||
onClick={handleCancel}
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isUpdating && renderProgress(updateProgress)}
|
<TabsContent value="config" className="space-y-4">
|
||||||
</CardContent>
|
<Configuration />
|
||||||
</Card>
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
{/* 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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user