551 lines
19 KiB
TypeScript
551 lines
19 KiB
TypeScript
import { useState, useRef } from 'react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
import {
|
|
Upload,
|
|
FileText,
|
|
CheckCircle,
|
|
XCircle,
|
|
AlertCircle,
|
|
Server,
|
|
RefreshCw,
|
|
Loader2,
|
|
Download
|
|
} from 'lucide-react';
|
|
import { WorkPackage } from '@/types/traceability';
|
|
import { parseCSVContent, ParseResult } from '@/lib/csvParser';
|
|
|
|
const STORAGE_KEY = 'traceability_data';
|
|
const SERVER_URL_KEY = 'traceability_server_url';
|
|
|
|
// API URL for production deployment
|
|
const API_URL = import.meta.env.VITE_API_URL || '';
|
|
|
|
interface DataUpdateDialogProps {
|
|
onDataLoaded: (workPackages: WorkPackage[]) => void;
|
|
onClose?: () => void;
|
|
}
|
|
|
|
export function DataUpdateDialog({ onDataLoaded, onClose }: DataUpdateDialogProps) {
|
|
const [activeTab, setActiveTab] = useState('upload');
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [parseResult, setParseResult] = useState<ParseResult | null>(null);
|
|
const [logs, setLogs] = useState<string[]>([]);
|
|
const [errors, setErrors] = useState<string[]>([]);
|
|
const [fileName, setFileName] = useState<string>('');
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
// Server endpoint config - persisted
|
|
const [serverUrl, setServerUrl] = useState(() =>
|
|
localStorage.getItem(SERVER_URL_KEY) || (API_URL ? `${API_URL}/api/traceability` : '/api/traceability')
|
|
);
|
|
|
|
// Drag and drop state
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
|
|
const addLog = (log: string) => {
|
|
setLogs(prev => [...prev, log]);
|
|
};
|
|
|
|
const persistData = (workPackages: WorkPackage[]) => {
|
|
const data = {
|
|
lastUpdated: new Date().toISOString(),
|
|
workPackages
|
|
};
|
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
|
addLog(`💾 Data persisted to storage (${workPackages.length} items)`);
|
|
};
|
|
|
|
const handleFile = async (file: File) => {
|
|
setFileName(file.name);
|
|
setIsLoading(true);
|
|
setLogs([]);
|
|
setErrors([]);
|
|
|
|
try {
|
|
addLog(`📂 Reading file: ${file.name} (${(file.size / 1024).toFixed(1)} KB)`);
|
|
const text = await file.text();
|
|
addLog(`📝 File loaded, ${text.length} characters`);
|
|
|
|
const result = parseCSVContent(text);
|
|
setParseResult(result);
|
|
setLogs(prev => [...prev, ...result.logs]);
|
|
setErrors(result.errors);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleDrop = (e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
setIsDragging(false);
|
|
const file = e.dataTransfer.files[0];
|
|
if (file && file.name.endsWith('.csv')) {
|
|
handleFile(file);
|
|
}
|
|
};
|
|
|
|
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (file) handleFile(file);
|
|
};
|
|
|
|
// Reload from static CSV file
|
|
const handleReloadFromCSV = async () => {
|
|
setIsLoading(true);
|
|
setLogs([]);
|
|
setErrors([]);
|
|
setParseResult(null);
|
|
|
|
try {
|
|
const cacheBuster = `?t=${Date.now()}`;
|
|
addLog(`🔍 Fetching /data/traceability_export.csv${cacheBuster}`);
|
|
|
|
const response = await fetch(`/data/traceability_export.csv${cacheBuster}`);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to load CSV: ${response.status} ${response.statusText}`);
|
|
}
|
|
|
|
const csvText = await response.text();
|
|
addLog(`📄 Received ${csvText.length} bytes`);
|
|
|
|
const result = parseCSVContent(csvText);
|
|
setParseResult(result);
|
|
setLogs(prev => [...prev, ...result.logs]);
|
|
setErrors(result.errors);
|
|
|
|
} catch (error) {
|
|
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
|
setErrors([errorMsg]);
|
|
addLog(`❌ Error: ${errorMsg}`);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
// Trigger sync from OpenProject (runs Python script on server)
|
|
const handleSyncFromServer = async () => {
|
|
if (!serverUrl) {
|
|
setErrors(['Please enter a server endpoint URL']);
|
|
return;
|
|
}
|
|
|
|
localStorage.setItem(SERVER_URL_KEY, serverUrl);
|
|
|
|
setIsLoading(true);
|
|
setLogs([]);
|
|
setErrors([]);
|
|
setParseResult(null);
|
|
|
|
try {
|
|
// First, trigger the sync (runs Python script)
|
|
const syncUrl = serverUrl.replace(/\/api\/traceability\/?$/, '/api/traceability/sync');
|
|
addLog(`🔄 Triggering sync at: ${syncUrl}`);
|
|
|
|
const syncResponse = await fetch(syncUrl, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' }
|
|
});
|
|
|
|
if (!syncResponse.ok) {
|
|
const errorData = await syncResponse.json().catch(() => ({}));
|
|
throw new Error(errorData.error || `Sync failed: ${syncResponse.status}`);
|
|
}
|
|
|
|
const syncResult = await syncResponse.json();
|
|
addLog(`✅ Sync complete: ${syncResult.message}`);
|
|
if (syncResult.stdout) {
|
|
syncResult.stdout.split('\n').filter(Boolean).forEach((line: string) => {
|
|
addLog(`📋 ${line}`);
|
|
});
|
|
}
|
|
|
|
// Now fetch the updated data
|
|
addLog(`🔍 Fetching updated data from: ${serverUrl}`);
|
|
|
|
const response = await fetch(serverUrl, {
|
|
method: 'GET',
|
|
headers: { 'Accept': 'application/json, text/csv' }
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Server Error: ${response.status} ${response.statusText}`);
|
|
}
|
|
|
|
const contentType = response.headers.get('content-type') || '';
|
|
addLog(`📄 Response content-type: ${contentType}`);
|
|
|
|
if (contentType.includes('text/csv')) {
|
|
addLog(`📋 Parsing CSV response...`);
|
|
const csvText = await response.text();
|
|
const result = parseCSVContent(csvText);
|
|
|
|
setLogs(prev => [...prev, ...result.logs]);
|
|
setErrors(result.errors);
|
|
setParseResult(result);
|
|
|
|
} else if (contentType.includes('application/json')) {
|
|
addLog(`📋 Parsing JSON response...`);
|
|
const data = await response.json();
|
|
|
|
let workPackages: WorkPackage[];
|
|
if (Array.isArray(data)) {
|
|
workPackages = data;
|
|
} else if (data.workPackages) {
|
|
workPackages = data.workPackages;
|
|
} else {
|
|
throw new Error('Invalid JSON format: expected array or {workPackages: [...]}');
|
|
}
|
|
|
|
addLog(`✅ Received ${workPackages.length} work packages`);
|
|
|
|
const typeCounts = workPackages.reduce((acc, wp) => {
|
|
acc[wp.type] = (acc[wp.type] || 0) + 1;
|
|
return acc;
|
|
}, {} as Record<string, number>);
|
|
|
|
setParseResult({
|
|
success: true,
|
|
workPackages,
|
|
logs: [],
|
|
errors: [],
|
|
typeCounts
|
|
});
|
|
} else {
|
|
addLog(`⚠️ Unknown content-type, attempting CSV parse...`);
|
|
const text = await response.text();
|
|
const result = parseCSVContent(text);
|
|
setLogs(prev => [...prev, ...result.logs]);
|
|
setErrors(result.errors);
|
|
setParseResult(result);
|
|
}
|
|
|
|
} catch (error) {
|
|
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
|
setErrors([errorMsg]);
|
|
addLog(`❌ Error: ${errorMsg}`);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
// Just fetch existing data (no sync)
|
|
const handleFetchFromServer = async () => {
|
|
if (!serverUrl) {
|
|
setErrors(['Please enter a server endpoint URL']);
|
|
return;
|
|
}
|
|
|
|
localStorage.setItem(SERVER_URL_KEY, serverUrl);
|
|
|
|
setIsLoading(true);
|
|
setLogs([]);
|
|
setErrors([]);
|
|
setParseResult(null);
|
|
|
|
try {
|
|
addLog(`🔍 Connecting to server: ${serverUrl}`);
|
|
|
|
const response = await fetch(serverUrl, {
|
|
method: 'GET',
|
|
headers: {
|
|
'Accept': 'application/json, text/csv'
|
|
}
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Server Error: ${response.status} ${response.statusText}`);
|
|
}
|
|
|
|
const contentType = response.headers.get('content-type') || '';
|
|
addLog(`📄 Response content-type: ${contentType}`);
|
|
|
|
if (contentType.includes('text/csv')) {
|
|
addLog(`📋 Parsing CSV response...`);
|
|
const csvText = await response.text();
|
|
const result = parseCSVContent(csvText);
|
|
|
|
setLogs(prev => [...prev, ...result.logs]);
|
|
setErrors(result.errors);
|
|
setParseResult(result);
|
|
|
|
} else if (contentType.includes('application/json')) {
|
|
addLog(`📋 Parsing JSON response...`);
|
|
const data = await response.json();
|
|
|
|
let workPackages: WorkPackage[];
|
|
if (Array.isArray(data)) {
|
|
workPackages = data;
|
|
} else if (data.workPackages) {
|
|
workPackages = data.workPackages;
|
|
} else {
|
|
throw new Error('Invalid JSON format: expected array or {workPackages: [...]}');
|
|
}
|
|
|
|
addLog(`✅ Received ${workPackages.length} work packages`);
|
|
|
|
const typeCounts = workPackages.reduce((acc, wp) => {
|
|
acc[wp.type] = (acc[wp.type] || 0) + 1;
|
|
return acc;
|
|
}, {} as Record<string, number>);
|
|
|
|
setParseResult({
|
|
success: true,
|
|
workPackages,
|
|
logs: [],
|
|
errors: [],
|
|
typeCounts
|
|
});
|
|
} else {
|
|
addLog(`⚠️ Unknown content-type, attempting CSV parse...`);
|
|
const text = await response.text();
|
|
const result = parseCSVContent(text);
|
|
setLogs(prev => [...prev, ...result.logs]);
|
|
setErrors(result.errors);
|
|
setParseResult(result);
|
|
}
|
|
|
|
} catch (error) {
|
|
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
|
setErrors([errorMsg]);
|
|
addLog(`❌ Error: ${errorMsg}`);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleApply = () => {
|
|
if (parseResult?.success && parseResult.workPackages.length > 0) {
|
|
persistData(parseResult.workPackages);
|
|
onDataLoaded(parseResult.workPackages);
|
|
onClose?.();
|
|
}
|
|
};
|
|
|
|
const handleReset = () => {
|
|
setParseResult(null);
|
|
setFileName('');
|
|
setLogs([]);
|
|
setErrors([]);
|
|
if (fileInputRef.current) {
|
|
fileInputRef.current.value = '';
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Card className="w-full">
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<RefreshCw className="h-5 w-5" />
|
|
Update Traceability Data
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Upload a CSV file, reload from static file, or sync from OpenProject
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
|
<TabsList className="grid w-full grid-cols-2">
|
|
<TabsTrigger value="upload" className="flex items-center gap-2">
|
|
<Upload className="h-4 w-4" />
|
|
Upload / Reload
|
|
</TabsTrigger>
|
|
<TabsTrigger value="server" className="flex items-center gap-2">
|
|
<Server className="h-4 w-4" />
|
|
Server Sync
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
{/* Tab 1: Manual CSV Upload / Reload */}
|
|
<TabsContent value="upload" className="space-y-4">
|
|
<div
|
|
onDrop={handleDrop}
|
|
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
|
|
onDragLeave={() => setIsDragging(false)}
|
|
className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors cursor-pointer ${
|
|
isDragging ? 'border-primary bg-primary/5' : 'border-muted-foreground/25 hover:border-primary/50'
|
|
}`}
|
|
onClick={() => fileInputRef.current?.click()}
|
|
>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept=".csv"
|
|
onChange={handleFileSelect}
|
|
className="hidden"
|
|
/>
|
|
<FileText className="h-10 w-10 mx-auto mb-3 text-muted-foreground" />
|
|
<p className="text-sm text-muted-foreground">
|
|
{fileName ? (
|
|
<span className="font-medium text-foreground">{fileName}</span>
|
|
) : (
|
|
<>Drop CSV here or <span className="text-primary underline">browse</span></>
|
|
)}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
onClick={handleReloadFromCSV}
|
|
disabled={isLoading}
|
|
className="flex-1"
|
|
>
|
|
{isLoading ? (
|
|
<><Loader2 className="h-4 w-4 mr-2 animate-spin" /> Loading...</>
|
|
) : (
|
|
<><Download className="h-4 w-4 mr-2" /> Reload from Static CSV</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
Run <code className="bg-muted px-1 rounded">python get_traceability.py</code> to generate the CSV file, then reload.
|
|
</p>
|
|
</TabsContent>
|
|
|
|
{/* Tab 2: Server Sync */}
|
|
<TabsContent value="server" className="space-y-4">
|
|
<div className="space-y-3">
|
|
<div>
|
|
<Label htmlFor="serverUrl">API Endpoint</Label>
|
|
<Input
|
|
id="serverUrl"
|
|
placeholder="/api/traceability or https://your-api.com/api/traceability"
|
|
value={serverUrl}
|
|
onChange={(e) => setServerUrl(e.target.value)}
|
|
/>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
Uses the data service API to sync with OpenProject
|
|
</p>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<Button
|
|
onClick={handleSyncFromServer}
|
|
disabled={isLoading || !serverUrl}
|
|
>
|
|
{isLoading ? (
|
|
<><Loader2 className="h-4 w-4 mr-2 animate-spin" /> Syncing...</>
|
|
) : (
|
|
<><RefreshCw className="h-4 w-4 mr-2" /> Sync from OpenProject</>
|
|
)}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
onClick={handleFetchFromServer}
|
|
disabled={isLoading || !serverUrl}
|
|
>
|
|
{isLoading ? (
|
|
<><Loader2 className="h-4 w-4 mr-2 animate-spin" /> Fetching...</>
|
|
) : (
|
|
<><Server className="h-4 w-4 mr-2" /> Fetch Existing Data</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-muted/50 rounded-lg p-3 text-xs space-y-2">
|
|
<p className="font-medium">How it works:</p>
|
|
<ul className="list-disc ml-4 space-y-1 text-muted-foreground">
|
|
<li><strong>Sync from OpenProject</strong>: Runs the Python script on the server to fetch latest data from OpenProject API</li>
|
|
<li><strong>Fetch Existing Data</strong>: Gets the last synced CSV from the server (no OpenProject call)</li>
|
|
</ul>
|
|
<p className="text-muted-foreground mt-2">
|
|
Note: Server sync only works in deployed environment with the data-service running.
|
|
</p>
|
|
</div>
|
|
</TabsContent>
|
|
</Tabs>
|
|
|
|
{/* Results Section */}
|
|
{(parseResult || errors.length > 0) && (
|
|
<div className="space-y-3 pt-4 border-t">
|
|
<div className="flex items-center gap-2">
|
|
{parseResult?.success ? (
|
|
<>
|
|
<CheckCircle className="h-5 w-5 text-green-500" />
|
|
<span className="font-medium text-green-700 dark:text-green-400">
|
|
Successfully parsed {parseResult.workPackages.length} work packages
|
|
</span>
|
|
</>
|
|
) : errors.length > 0 ? (
|
|
<>
|
|
<XCircle className="h-5 w-5 text-red-500" />
|
|
<span className="font-medium text-red-700 dark:text-red-400">Operation failed</span>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
|
|
{/* Type counts */}
|
|
{parseResult?.success && Object.keys(parseResult.typeCounts).length > 0 && (
|
|
<div className="flex flex-wrap gap-2">
|
|
{Object.entries(parseResult.typeCounts)
|
|
.sort(([, a], [, b]) => b - a)
|
|
.map(([type, count]) => (
|
|
<Badge key={type} variant="secondary" className="capitalize">
|
|
{type}: {count}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Errors */}
|
|
{errors.length > 0 && (
|
|
<div className="bg-red-500/10 border border-red-500/20 rounded-lg p-3">
|
|
<div className="flex items-center gap-2 text-red-700 dark:text-red-400 mb-2">
|
|
<AlertCircle className="h-4 w-4" />
|
|
<span className="font-medium">Errors</span>
|
|
</div>
|
|
{errors.map((error, i) => (
|
|
<p key={i} className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Logs */}
|
|
{logs.length > 0 && (
|
|
<details className="text-xs" open>
|
|
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">
|
|
View logs ({logs.length} entries)
|
|
</summary>
|
|
<ScrollArea className="h-32 mt-2 bg-slate-950 rounded p-2">
|
|
<div className="font-mono text-green-400 space-y-0.5">
|
|
{logs.map((log, i) => (
|
|
<div key={i}>{log}</div>
|
|
))}
|
|
</div>
|
|
</ScrollArea>
|
|
</details>
|
|
)}
|
|
|
|
{/* Actions */}
|
|
<div className="flex gap-2 pt-2">
|
|
{parseResult?.success && (
|
|
<Button onClick={handleApply}>
|
|
<CheckCircle className="h-4 w-4 mr-2" />
|
|
Apply & Save
|
|
</Button>
|
|
)}
|
|
<Button variant="outline" onClick={handleReset}>
|
|
Reset
|
|
</Button>
|
|
{onClose && (
|
|
<Button variant="ghost" onClick={onClose}>
|
|
Cancel
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|