This commit is contained in:
2026-02-01 15:46:19 +01:00
parent b1511d124e
commit 1f00856167
13 changed files with 1520 additions and 2921 deletions

View File

@@ -0,0 +1,407 @@
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,
Cloud,
Server,
RefreshCw,
Loader2
} from 'lucide-react';
import { WorkPackage } from '@/types/traceability';
import { parseCSVContent, ParseResult } from '@/lib/csvParser';
import {
fetchFromOpenProject,
fetchFromBackendProxy,
OpenProjectConfig,
DEFAULT_CONFIG
} from '@/services/openProjectService';
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);
// Backend proxy config
const [proxyUrl, setProxyUrl] = useState('');
// Direct OpenProject config
const [opConfig, setOpConfig] = useState<OpenProjectConfig>(DEFAULT_CONFIG);
// Drag and drop state
const [isDragging, setIsDragging] = useState(false);
const addLog = (log: string) => {
setLogs(prev => [...prev, log]);
};
const handleFile = async (file: File) => {
setFileName(file.name);
setIsLoading(true);
setLogs([]);
setErrors([]);
try {
const text = await file.text();
const result = parseCSVContent(text);
setParseResult(result);
setLogs(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);
};
const handleFetchFromProxy = async () => {
if (!proxyUrl) {
setErrors(['Please enter a backend proxy URL']);
return;
}
setIsLoading(true);
setLogs([]);
setErrors([]);
setParseResult(null);
try {
const result = await fetchFromBackendProxy(proxyUrl, addLog);
setLogs(result.logs);
setErrors(result.errors);
if (result.success) {
const typeCounts = result.workPackages.reduce((acc, wp) => {
acc[wp.type] = (acc[wp.type] || 0) + 1;
return acc;
}, {} as Record<string, number>);
setParseResult({
success: true,
workPackages: result.workPackages,
logs: result.logs,
errors: result.errors,
typeCounts
});
}
} finally {
setIsLoading(false);
}
};
const handleFetchFromOpenProject = async () => {
setIsLoading(true);
setLogs([]);
setErrors([]);
setParseResult(null);
try {
const result = await fetchFromOpenProject(opConfig, undefined, addLog);
setLogs(result.logs);
setErrors(result.errors);
if (result.success) {
const typeCounts = result.workPackages.reduce((acc, wp) => {
acc[wp.type] = (acc[wp.type] || 0) + 1;
return acc;
}, {} as Record<string, number>);
setParseResult({
success: true,
workPackages: result.workPackages,
logs: result.logs,
errors: result.errors,
typeCounts
});
}
} finally {
setIsLoading(false);
}
};
const handleApply = () => {
if (parseResult?.success && parseResult.workPackages.length > 0) {
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>
Choose how to update your data from OpenProject
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="upload" className="flex items-center gap-2">
<Upload className="h-4 w-4" />
Upload CSV
</TabsTrigger>
<TabsTrigger value="proxy" className="flex items-center gap-2">
<Server className="h-4 w-4" />
Backend API
</TabsTrigger>
<TabsTrigger value="direct" className="flex items-center gap-2">
<Cloud className="h-4 w-4" />
Direct Fetch
</TabsTrigger>
</TabsList>
{/* Tab 1: Manual CSV Upload */}
<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>
<p className="text-xs text-muted-foreground">
Run <code className="bg-muted px-1 rounded">python get_traceability.py</code> locally to generate the CSV
</p>
</TabsContent>
{/* Tab 2: Backend Proxy */}
<TabsContent value="proxy" className="space-y-4">
<div className="space-y-3">
<div>
<Label htmlFor="proxyUrl">Backend API Endpoint</Label>
<Input
id="proxyUrl"
placeholder="https://your-server.com/api/traceability"
value={proxyUrl}
onChange={(e) => setProxyUrl(e.target.value)}
/>
<p className="text-xs text-muted-foreground mt-1">
Your backend should return JSON or CSV with work package data
</p>
</div>
<Button
onClick={handleFetchFromProxy}
disabled={isLoading || !proxyUrl}
className="w-full"
>
{isLoading ? (
<><Loader2 className="h-4 w-4 mr-2 animate-spin" /> Fetching...</>
) : (
<><Server className="h-4 w-4 mr-2" /> Fetch from Backend</>
)}
</Button>
</div>
<div className="bg-muted/50 rounded-lg p-3 text-xs space-y-2">
<p className="font-medium">Backend Setup Guide:</p>
<ol className="list-decimal ml-4 space-y-1 text-muted-foreground">
<li>Deploy the Python script on your server</li>
<li>Create an API endpoint that runs the script</li>
<li>Return JSON or serve the generated CSV</li>
</ol>
</div>
</TabsContent>
{/* Tab 3: Direct OpenProject Fetch */}
<TabsContent value="direct" className="space-y-4">
<div className="space-y-3">
<div>
<Label htmlFor="baseUrl">OpenProject URL</Label>
<Input
id="baseUrl"
placeholder="https://openproject.example.com"
value={opConfig.baseUrl}
onChange={(e) => setOpConfig(prev => ({ ...prev, baseUrl: e.target.value }))}
/>
</div>
<div>
<Label htmlFor="projectId">Project Identifier</Label>
<Input
id="projectId"
placeholder="project-id"
value={opConfig.projectIdentifier}
onChange={(e) => setOpConfig(prev => ({ ...prev, projectIdentifier: e.target.value }))}
/>
</div>
<div>
<Label htmlFor="apiKey">API Key</Label>
<Input
id="apiKey"
type="password"
placeholder="Your OpenProject API Key"
value={opConfig.apiKey}
onChange={(e) => setOpConfig(prev => ({ ...prev, apiKey: e.target.value }))}
/>
</div>
<Button
onClick={handleFetchFromOpenProject}
disabled={isLoading}
className="w-full"
>
{isLoading ? (
<><Loader2 className="h-4 w-4 mr-2 animate-spin" /> Fetching...</>
) : (
<><Cloud className="h-4 w-4 mr-2" /> Fetch from OpenProject</>
)}
</Button>
</div>
<div className="bg-amber-500/10 border border-amber-500/20 rounded-lg p-3 text-xs">
<div className="flex items-start gap-2">
<AlertCircle className="h-4 w-4 text-amber-600 mt-0.5" />
<div>
<p className="font-medium text-amber-700">CORS Notice</p>
<p className="text-amber-600 mt-1">
Direct browser fetch may fail due to CORS. Configure your OpenProject server to allow
cross-origin requests, or use the Backend API option.
</p>
</div>
</div>
</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">
<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 Data
</Button>
)}
<Button variant="outline" onClick={handleReset}>
Reset
</Button>
{onClose && (
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
)}
</div>
</div>
)}
</CardContent>
</Card>
);
}