updates
This commit is contained in:
407
src/components/DataUpdateDialog.tsx
Normal file
407
src/components/DataUpdateDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user