updates
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -11,6 +11,7 @@ import AnalysisPage from "./pages/AnalysisPage";
|
|||||||
import ALMTypePage from "./pages/ALMTypePage";
|
import ALMTypePage from "./pages/ALMTypePage";
|
||||||
import TraceabilityMatrixPage from "./pages/TraceabilityMatrixPage";
|
import TraceabilityMatrixPage from "./pages/TraceabilityMatrixPage";
|
||||||
import ESPIDFHelperPage from "./pages/ESPIDFHelperPage";
|
import ESPIDFHelperPage from "./pages/ESPIDFHelperPage";
|
||||||
|
import WorkPackageGraphPage from "./pages/WorkPackageGraphPage";
|
||||||
import LoginPage from "./pages/LoginPage";
|
import LoginPage from "./pages/LoginPage";
|
||||||
import NotFound from "./pages/NotFound";
|
import NotFound from "./pages/NotFound";
|
||||||
|
|
||||||
@@ -65,6 +66,14 @@ const App = () => (
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/graph"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<WorkPackageGraphPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/alm/:type"
|
path="/alm/:type"
|
||||||
element={
|
element={
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Cpu,
|
Cpu,
|
||||||
|
Share2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { NavLink } from "@/components/NavLink";
|
import { NavLink } from "@/components/NavLink";
|
||||||
import { useLocation } from "react-router-dom";
|
import { useLocation } from "react-router-dom";
|
||||||
@@ -36,6 +37,7 @@ import { cn } from "@/lib/utils";
|
|||||||
const mainItems = [
|
const mainItems = [
|
||||||
{ title: "Dashboard", url: "/", icon: LayoutDashboard },
|
{ title: "Dashboard", url: "/", icon: LayoutDashboard },
|
||||||
{ title: "Traceability Matrix", url: "/matrix", icon: GitBranch },
|
{ title: "Traceability Matrix", url: "/matrix", icon: GitBranch },
|
||||||
|
{ title: "Work Package Graph", url: "/graph", icon: Share2 },
|
||||||
{ title: "Documentation", url: "/documentation", icon: BookOpen },
|
{ title: "Documentation", url: "/documentation", icon: BookOpen },
|
||||||
{ title: "Gap Analysis", url: "/analysis", icon: Search },
|
{ title: "Gap Analysis", url: "/analysis", icon: Search },
|
||||||
{ title: "ESP-IDF Helper", url: "/esp-idf", icon: Cpu },
|
{ title: "ESP-IDF Helper", url: "/esp-idf", icon: Cpu },
|
||||||
@@ -43,9 +45,13 @@ const mainItems = [
|
|||||||
|
|
||||||
const almItems = [
|
const almItems = [
|
||||||
{ title: "Features", url: "/alm/feature", icon: Target, type: "feature" },
|
{ title: "Features", url: "/alm/feature", icon: Target, type: "feature" },
|
||||||
|
{ title: "SW Features", url: "/alm/sw_feature", icon: Target, type: "sw_feature" },
|
||||||
{ title: "Requirements", url: "/alm/requirements", icon: CheckSquare, type: "requirements" },
|
{ title: "Requirements", url: "/alm/requirements", icon: CheckSquare, type: "requirements" },
|
||||||
{ title: "SW Requirements", url: "/alm/swreq", icon: FileText, type: "swreq" },
|
{ title: "SW Requirements", url: "/alm/swreq", icon: FileText, type: "swreq" },
|
||||||
{ title: "Test Cases", url: "/alm/test-case", icon: TestTube, type: "test case" },
|
{ title: "Components", url: "/alm/component", icon: Layers, type: "component" },
|
||||||
|
{ title: "Interfaces", url: "/alm/interface", icon: GitBranch, type: "interface" },
|
||||||
|
{ title: "SW Test Cases", url: "/alm/software-test-case", icon: TestTube, type: "software test case" },
|
||||||
|
{ title: "System Tests", url: "/alm/system-test", icon: TestTube, type: "system test" },
|
||||||
{ title: "Epics", url: "/alm/epic", icon: Layers, type: "epic" },
|
{ title: "Epics", url: "/alm/epic", icon: Layers, type: "epic" },
|
||||||
{ title: "User Stories", url: "/alm/user-story", icon: FolderKanban, type: "user story" },
|
{ title: "User Stories", url: "/alm/user-story", icon: FolderKanban, type: "user story" },
|
||||||
{ title: "Tasks", url: "/alm/task", icon: CheckSquare, type: "task" },
|
{ title: "Tasks", url: "/alm/task", icon: CheckSquare, type: "task" },
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ interface AuthContextType {
|
|||||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
||||||
const SSO_API_URL = "https://sso.nabd-co.com/verify";
|
const SSO_API_URL = "https://sso.nabd-co.com/verify";
|
||||||
const SSO_API_KEY = "3sNtQ8G5_5j7R8AsZFeabuGYiVWw0OX9W_pHb3KTOSs";
|
const SSO_API_KEY = "yPkNLCYNm7-UrSZtr_hi-oCx6LZ1DQFAKTGNOoCiMic";
|
||||||
|
|
||||||
interface AuthProviderProps {
|
interface AuthProviderProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
|||||||
@@ -127,18 +127,29 @@ export function useTraceabilityData() {
|
|||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setParseLog([]);
|
const newLogs: string[] = [];
|
||||||
|
newLogs.push(`[Loader] Starting data load at ${new Date().toLocaleTimeString()}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/data/traceability_export.csv');
|
// Add cache-busting to force fresh data
|
||||||
|
const cacheBuster = `?t=${Date.now()}`;
|
||||||
|
newLogs.push(`[Loader] Fetching /data/traceability_export.csv${cacheBuster}`);
|
||||||
|
|
||||||
|
const response = await fetch(`/data/traceability_export.csv${cacheBuster}`);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to load traceability data');
|
throw new Error(`Failed to load traceability data: ${response.status} ${response.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const csvText = await response.text();
|
const csvText = await response.text();
|
||||||
|
newLogs.push(`[Loader] Received ${csvText.length} bytes`);
|
||||||
|
|
||||||
const { workPackages, logs } = parseCSV(csvText);
|
const { workPackages, logs } = parseCSV(csvText);
|
||||||
|
|
||||||
setParseLog(logs);
|
// Combine loader logs with parser logs
|
||||||
|
const allLogs = [...newLogs, ...logs];
|
||||||
|
allLogs.push(`[Loader] Data load complete at ${new Date().toLocaleTimeString()}`);
|
||||||
|
|
||||||
|
setParseLog(allLogs);
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
setData({
|
setData({
|
||||||
@@ -147,7 +158,10 @@ export function useTraceabilityData() {
|
|||||||
});
|
});
|
||||||
setLastUpdated(now);
|
setLastUpdated(now);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
const errorMsg = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
newLogs.push(`[Loader] ERROR: ${errorMsg}`);
|
||||||
|
setParseLog(newLogs);
|
||||||
|
setError(errorMsg);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
125
src/lib/csvParser.ts
Normal file
125
src/lib/csvParser.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* CSV Parser utility for traceability data
|
||||||
|
* Extracted from CSVUpload component for reuse
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { WorkPackage, WorkPackageType } from '@/types/traceability';
|
||||||
|
|
||||||
|
export interface ParseResult {
|
||||||
|
success: boolean;
|
||||||
|
workPackages: WorkPackage[];
|
||||||
|
logs: string[];
|
||||||
|
errors: string[];
|
||||||
|
typeCounts: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCSVContent(csvText: string): ParseResult {
|
||||||
|
const logs: string[] = [];
|
||||||
|
const errors: string[] = [];
|
||||||
|
const workPackages: WorkPackage[] = [];
|
||||||
|
|
||||||
|
logs.push(`Starting CSV parse, ${csvText.length} characters`);
|
||||||
|
|
||||||
|
// Remove BOM if present
|
||||||
|
const cleanText = csvText.replace(/^\uFEFF/, '');
|
||||||
|
const lines = cleanText.split('\n');
|
||||||
|
logs.push(`Total lines: ${lines.length}`);
|
||||||
|
|
||||||
|
// Check header
|
||||||
|
const header = lines[0];
|
||||||
|
if (!header.includes('ID') || !header.includes('Type')) {
|
||||||
|
errors.push('Invalid CSV header. Expected: ID,Type,Status,Title,Description,Parent_ID,Relations');
|
||||||
|
return { success: false, workPackages: [], logs, errors, typeCounts: {} };
|
||||||
|
}
|
||||||
|
logs.push(`Header validated: ${header.substring(0, 60)}...`);
|
||||||
|
|
||||||
|
// Parse content
|
||||||
|
let currentRow: string[] = [];
|
||||||
|
let inQuotedField = false;
|
||||||
|
let currentField = '';
|
||||||
|
const content = lines.slice(1).join('\n');
|
||||||
|
|
||||||
|
for (let i = 0; i < content.length; i++) {
|
||||||
|
const char = content[i];
|
||||||
|
const nextChar = content[i + 1];
|
||||||
|
|
||||||
|
if (inQuotedField) {
|
||||||
|
if (char === '"') {
|
||||||
|
if (nextChar === '"') {
|
||||||
|
currentField += '"';
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
inQuotedField = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
currentField += char;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (char === '"' && currentField === '') {
|
||||||
|
inQuotedField = true;
|
||||||
|
} else if (char === ',') {
|
||||||
|
currentRow.push(currentField);
|
||||||
|
currentField = '';
|
||||||
|
} else if (char === '\n') {
|
||||||
|
currentRow.push(currentField);
|
||||||
|
currentField = '';
|
||||||
|
|
||||||
|
if (currentRow.length >= 4 && /^\d+$/.test(currentRow[0]?.trim())) {
|
||||||
|
const [id, type, status, title, description = '', parentId = '', relations = ''] = currentRow;
|
||||||
|
const wpType = type?.toLowerCase().replace(/\s+/g, ' ').trim() as WorkPackageType;
|
||||||
|
|
||||||
|
workPackages.push({
|
||||||
|
id: parseInt(id, 10),
|
||||||
|
type: wpType,
|
||||||
|
status: status || '',
|
||||||
|
title: title || '',
|
||||||
|
description: description || '',
|
||||||
|
parentId: parentId?.trim() || '',
|
||||||
|
relations: relations?.trim() || ''
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
currentRow = [];
|
||||||
|
} else {
|
||||||
|
currentField += char;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle last row
|
||||||
|
if (currentField || currentRow.length > 0) {
|
||||||
|
currentRow.push(currentField);
|
||||||
|
if (currentRow.length >= 4 && /^\d+$/.test(currentRow[0]?.trim())) {
|
||||||
|
const [id, type, status, title, description = '', parentId = '', relations = ''] = currentRow;
|
||||||
|
const wpType = type?.toLowerCase().replace(/\s+/g, ' ').trim() as WorkPackageType;
|
||||||
|
|
||||||
|
workPackages.push({
|
||||||
|
id: parseInt(id, 10),
|
||||||
|
type: wpType,
|
||||||
|
status: status || '',
|
||||||
|
title: title || '',
|
||||||
|
description: description || '',
|
||||||
|
parentId: parentId?.trim() || '',
|
||||||
|
relations: relations?.trim() || ''
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logs.push(`Parsed ${workPackages.length} work packages`);
|
||||||
|
|
||||||
|
// Calculate type distribution
|
||||||
|
const typeCounts = workPackages.reduce((acc, wp) => {
|
||||||
|
acc[wp.type] = (acc[wp.type] || 0) + 1;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, number>);
|
||||||
|
|
||||||
|
logs.push(`Type distribution: ${JSON.stringify(typeCounts)}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: workPackages.length > 0,
|
||||||
|
workPackages,
|
||||||
|
logs,
|
||||||
|
errors,
|
||||||
|
typeCounts
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { CSVUpload } from "@/components/CSVUpload";
|
import { DataUpdateDialog } from "@/components/DataUpdateDialog";
|
||||||
import { WorkPackage } from "@/types/traceability";
|
import { WorkPackage } from "@/types/traceability";
|
||||||
import {
|
import {
|
||||||
Target,
|
Target,
|
||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
Download,
|
Download,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Terminal,
|
Terminal,
|
||||||
Upload,
|
RefreshCw,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
@@ -142,7 +142,7 @@ export default function Dashboard() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Download className="h-5 w-5" />
|
<RefreshCw className="h-5 w-5" />
|
||||||
Data Management
|
Data Management
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -150,19 +150,19 @@ export default function Dashboard() {
|
|||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
<Button variant="outline" onClick={handleDownloadCSV}>
|
<Button variant="outline" onClick={handleDownloadCSV}>
|
||||||
<Download className="h-4 w-4 mr-2" />
|
<Download className="h-4 w-4 mr-2" />
|
||||||
Download CSV
|
Download Current CSV
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" onClick={() => setShowUpload(!showUpload)}>
|
<Button onClick={() => setShowUpload(!showUpload)}>
|
||||||
<Upload className="h-4 w-4 mr-2" />
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
Upload CSV
|
Update Data
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" onClick={refresh} disabled={loading}>
|
<Button variant="outline" onClick={refresh} disabled={loading}>
|
||||||
Reload Data
|
Reload from CSV
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showUpload && (
|
{showUpload && (
|
||||||
<CSVUpload
|
<DataUpdateDialog
|
||||||
onDataLoaded={handleDataLoaded}
|
onDataLoaded={handleDataLoaded}
|
||||||
onClose={() => setShowUpload(false)}
|
onClose={() => setShowUpload(false)}
|
||||||
/>
|
/>
|
||||||
@@ -186,7 +186,7 @@ export default function Dashboard() {
|
|||||||
<div key={i} className="opacity-90">{log}</div>
|
<div key={i} className="opacity-90">{log}</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className="text-slate-500">No logs available. Click "Reload Data" to see parsing logs.</div>
|
<div className="text-slate-500">No logs available. Click "Reload from CSV" to see parsing logs.</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
@@ -194,13 +194,6 @@ export default function Dashboard() {
|
|||||||
</Card>
|
</Card>
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
<p>To update data from OpenProject, run the Python script locally:</p>
|
|
||||||
<code className="bg-muted px-2 py-1 rounded text-xs mt-1 block">
|
|
||||||
python public/data/get_traceability.py
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -111,7 +111,15 @@ export default function LoginPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<p className="text-center text-sm text-muted-foreground">
|
<p className="text-center text-sm text-muted-foreground">
|
||||||
Contact your administrator if you need access
|
Need access?{" "}
|
||||||
|
<a
|
||||||
|
href="https://sso.nabd-co.com/register.html"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary hover:underline font-medium"
|
||||||
|
>
|
||||||
|
Request access from administrator
|
||||||
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export default function TraceabilityMatrixPage() {
|
|||||||
const features = allWPs.filter(wp => wp.type === 'feature');
|
const features = allWPs.filter(wp => wp.type === 'feature');
|
||||||
const requirements = allWPs.filter(wp => wp.type === 'requirements');
|
const requirements = allWPs.filter(wp => wp.type === 'requirements');
|
||||||
const swReqs = allWPs.filter(wp => wp.type === 'swreq');
|
const swReqs = allWPs.filter(wp => wp.type === 'swreq');
|
||||||
const testCases = allWPs.filter(wp => wp.type === 'test case');
|
const testCases = allWPs.filter(wp => wp.type === 'software test case' || wp.type === 'system test');
|
||||||
|
|
||||||
// Create lookup maps
|
// Create lookup maps
|
||||||
const wpById = new Map(allWPs.map(wp => [wp.id, wp]));
|
const wpById = new Map(allWPs.map(wp => [wp.id, wp]));
|
||||||
|
|||||||
555
src/pages/WorkPackageGraphPage.tsx
Normal file
555
src/pages/WorkPackageGraphPage.tsx
Normal file
@@ -0,0 +1,555 @@
|
|||||||
|
import { useState, useMemo, useCallback, useRef, useEffect } from "react";
|
||||||
|
import { AppLayout } from "@/components/layout/AppLayout";
|
||||||
|
import { useTraceabilityData } from "@/hooks/useTraceabilityData";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { Loader2, ZoomIn, ZoomOut, Maximize2, ExternalLink, Move, Filter } from "lucide-react";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { WorkPackage } from "@/types/traceability";
|
||||||
|
|
||||||
|
const typeColors: Record<string, string> = {
|
||||||
|
feature: "#10b981",
|
||||||
|
sw_feature: "#059669",
|
||||||
|
requirements: "#3b82f6",
|
||||||
|
swreq: "#6366f1",
|
||||||
|
component: "#8b5cf6",
|
||||||
|
interface: "#a855f7",
|
||||||
|
"software test case": "#f59e0b",
|
||||||
|
"system test": "#f97316",
|
||||||
|
epic: "#ec4899",
|
||||||
|
"user story": "#14b8a6",
|
||||||
|
task: "#64748b",
|
||||||
|
bug: "#ef4444",
|
||||||
|
risk: "#f43f5e",
|
||||||
|
milestone: "#0ea5e9",
|
||||||
|
phase: "#84cc16",
|
||||||
|
"summary task": "#78716c",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface GraphNode {
|
||||||
|
id: number;
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
level: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GraphEdge {
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
type: "parent" | "relation";
|
||||||
|
relationName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WorkPackageGraphPage() {
|
||||||
|
const { data, loading, error, lastUpdated, refresh } = useTraceabilityData();
|
||||||
|
const [selectedType, setSelectedType] = useState<string>("all");
|
||||||
|
const [zoom, setZoom] = useState(1);
|
||||||
|
const [pan, setPan] = useState({ x: 0, y: 0 });
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [hoveredNode, setHoveredNode] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const availableTypes = useMemo(() => {
|
||||||
|
if (!data?.workPackages) return [];
|
||||||
|
const types = new Set(data.workPackages.map(wp => wp.type));
|
||||||
|
return Array.from(types).sort();
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const filteredWorkPackages = useMemo(() => {
|
||||||
|
if (!data?.workPackages) return [];
|
||||||
|
if (selectedType === "all") return data.workPackages.slice(0, 100); // Limit for performance
|
||||||
|
return data.workPackages.filter(wp => wp.type === selectedType).slice(0, 100);
|
||||||
|
}, [data, selectedType]);
|
||||||
|
|
||||||
|
const parseRelations = useCallback((relationsStr: string): { type: string; targetId: number }[] => {
|
||||||
|
if (!relationsStr) return [];
|
||||||
|
const relations: { type: string; targetId: number }[] = [];
|
||||||
|
const regex = /(\w+)\(#(\d+)\)/g;
|
||||||
|
let match;
|
||||||
|
while ((match = regex.exec(relationsStr)) !== null) {
|
||||||
|
relations.push({ type: match[1], targetId: parseInt(match[2], 10) });
|
||||||
|
}
|
||||||
|
return relations;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { nodes, edges, width, height } = useMemo(() => {
|
||||||
|
if (filteredWorkPackages.length === 0) {
|
||||||
|
return { nodes: [], edges: [], width: 800, height: 600 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const wpById = new Map(data?.workPackages.map(wp => [wp.id, wp]) || []);
|
||||||
|
const nodesMap = new Map<number, GraphNode>();
|
||||||
|
const edgesList: GraphEdge[] = [];
|
||||||
|
const nodeIds = new Set(filteredWorkPackages.map(wp => wp.id));
|
||||||
|
|
||||||
|
// Calculate hierarchy levels based on parent relationships
|
||||||
|
const levels = new Map<number, number>();
|
||||||
|
const calculateLevel = (wp: WorkPackage, visited = new Set<number>()): number => {
|
||||||
|
if (visited.has(wp.id)) return 0;
|
||||||
|
visited.add(wp.id);
|
||||||
|
|
||||||
|
if (levels.has(wp.id)) return levels.get(wp.id)!;
|
||||||
|
|
||||||
|
if (!wp.parentId) {
|
||||||
|
levels.set(wp.id, 0);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentId = parseInt(wp.parentId, 10);
|
||||||
|
const parent = wpById.get(parentId);
|
||||||
|
if (!parent) {
|
||||||
|
levels.set(wp.id, 0);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentLevel = calculateLevel(parent, visited);
|
||||||
|
const level = parentLevel + 1;
|
||||||
|
levels.set(wp.id, level);
|
||||||
|
return level;
|
||||||
|
};
|
||||||
|
|
||||||
|
filteredWorkPackages.forEach(wp => calculateLevel(wp));
|
||||||
|
|
||||||
|
// Group by level for layout
|
||||||
|
const byLevel = new Map<number, WorkPackage[]>();
|
||||||
|
filteredWorkPackages.forEach(wp => {
|
||||||
|
const level = levels.get(wp.id) || 0;
|
||||||
|
if (!byLevel.has(level)) byLevel.set(level, []);
|
||||||
|
byLevel.get(level)!.push(wp);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Layout nodes
|
||||||
|
const nodeWidth = 180;
|
||||||
|
const nodeHeight = 60;
|
||||||
|
const horizontalGap = 40;
|
||||||
|
const verticalGap = 80;
|
||||||
|
|
||||||
|
let maxX = 0;
|
||||||
|
let maxY = 0;
|
||||||
|
|
||||||
|
const sortedLevels = Array.from(byLevel.keys()).sort((a, b) => a - b);
|
||||||
|
|
||||||
|
sortedLevels.forEach((level, levelIndex) => {
|
||||||
|
const wpsAtLevel = byLevel.get(level)!;
|
||||||
|
const y = levelIndex * (nodeHeight + verticalGap) + 50;
|
||||||
|
maxY = Math.max(maxY, y + nodeHeight);
|
||||||
|
|
||||||
|
wpsAtLevel.forEach((wp, idx) => {
|
||||||
|
const x = idx * (nodeWidth + horizontalGap) + 50;
|
||||||
|
maxX = Math.max(maxX, x + nodeWidth);
|
||||||
|
|
||||||
|
nodesMap.set(wp.id, {
|
||||||
|
id: wp.id,
|
||||||
|
type: wp.type,
|
||||||
|
title: wp.title,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
level
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create edges
|
||||||
|
filteredWorkPackages.forEach(wp => {
|
||||||
|
// Parent edge
|
||||||
|
if (wp.parentId) {
|
||||||
|
const parentId = parseInt(wp.parentId, 10);
|
||||||
|
if (wpById.has(parentId)) {
|
||||||
|
// Add parent node if not in view
|
||||||
|
if (!nodesMap.has(parentId)) {
|
||||||
|
const parent = wpById.get(parentId)!;
|
||||||
|
const parentLevel = levels.get(parentId) || 0;
|
||||||
|
nodesMap.set(parentId, {
|
||||||
|
id: parentId,
|
||||||
|
type: parent.type,
|
||||||
|
title: parent.title,
|
||||||
|
x: maxX + 50,
|
||||||
|
y: parentLevel * (nodeHeight + verticalGap) + 50,
|
||||||
|
level: parentLevel
|
||||||
|
});
|
||||||
|
maxX += nodeWidth + horizontalGap;
|
||||||
|
}
|
||||||
|
edgesList.push({ from: parentId, to: wp.id, type: "parent" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relation edges
|
||||||
|
const relations = parseRelations(wp.relations);
|
||||||
|
relations.forEach(rel => {
|
||||||
|
if (wpById.has(rel.targetId)) {
|
||||||
|
if (nodeIds.has(rel.targetId) || nodesMap.has(rel.targetId)) {
|
||||||
|
edgesList.push({
|
||||||
|
from: wp.id,
|
||||||
|
to: rel.targetId,
|
||||||
|
type: "relation",
|
||||||
|
relationName: rel.type
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodes: Array.from(nodesMap.values()),
|
||||||
|
edges: edgesList,
|
||||||
|
width: maxX + 100,
|
||||||
|
height: maxY + 100
|
||||||
|
};
|
||||||
|
}, [filteredWorkPackages, data, parseRelations]);
|
||||||
|
|
||||||
|
const handleMouseDown = (e: React.MouseEvent) => {
|
||||||
|
if (e.button === 0) {
|
||||||
|
setIsDragging(true);
|
||||||
|
setDragStart({ x: e.clientX - pan.x, y: e.clientY - pan.y });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseMove = (e: React.MouseEvent) => {
|
||||||
|
if (isDragging) {
|
||||||
|
setPan({
|
||||||
|
x: e.clientX - dragStart.x,
|
||||||
|
y: e.clientY - dragStart.y
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
setIsDragging(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleWheel = (e: React.WheelEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const delta = e.deltaY > 0 ? -0.1 : 0.1;
|
||||||
|
setZoom(z => Math.max(0.2, Math.min(3, z + delta)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const openWorkPackage = (id: number) => {
|
||||||
|
window.open(`https://openproject.nabd-co.com/projects/asf/work_packages/${id}/activity`, "_blank");
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetView = () => {
|
||||||
|
setZoom(1);
|
||||||
|
setPan({ x: 0, y: 0 });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<AppLayout lastUpdated={lastUpdated} onRefresh={refresh} isRefreshing={loading}>
|
||||||
|
<div className="flex items-center justify-center h-96">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<AppLayout lastUpdated={lastUpdated} onRefresh={refresh} isRefreshing={loading}>
|
||||||
|
<div className="text-center text-destructive p-8">Error: {error}</div>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppLayout lastUpdated={lastUpdated} onRefresh={refresh} isRefreshing={loading}>
|
||||||
|
<div className="space-y-4 p-4">
|
||||||
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Work Package Graph</h1>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Visual representation of work packages and their relationships
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls Bar */}
|
||||||
|
<Card className="border-border/50">
|
||||||
|
<CardContent className="py-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Filter className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<Select value={selectedType} onValueChange={setSelectedType}>
|
||||||
|
<SelectTrigger className="w-48 h-9">
|
||||||
|
<SelectValue placeholder="Filter by type" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Types ({data?.workPackages.length || 0})</SelectItem>
|
||||||
|
{availableTypes.map(type => (
|
||||||
|
<SelectItem key={type} value={type}>
|
||||||
|
<span className="capitalize">{type}</span>
|
||||||
|
<span className="ml-2 text-muted-foreground">
|
||||||
|
({data?.workPackages.filter(wp => wp.type === type).length || 0})
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 bg-muted/50 rounded-lg p-1">
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setZoom(z => Math.max(0.2, z - 0.2))}>
|
||||||
|
<ZoomOut className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm w-14 text-center font-medium">{Math.round(zoom * 100)}%</span>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setZoom(z => Math.min(3, z + 0.2))}>
|
||||||
|
<ZoomIn className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={resetView}>
|
||||||
|
<Maximize2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Move className="h-4 w-4" />
|
||||||
|
<span>Drag to pan • Scroll to zoom</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Badge variant="secondary" className="ml-auto">
|
||||||
|
{nodes.length} nodes • {edges.length} connections
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<Card className="border-border/50">
|
||||||
|
<CardHeader className="py-2 px-4">
|
||||||
|
<CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Type Legend</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="py-2 px-4">
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{Object.entries(typeColors).map(([type, color]) => (
|
||||||
|
<Badge
|
||||||
|
key={type}
|
||||||
|
className="text-[10px] px-2 py-0.5 font-medium cursor-pointer hover:opacity-80 transition-opacity"
|
||||||
|
style={{ backgroundColor: color, color: '#fff' }}
|
||||||
|
onClick={() => setSelectedType(type)}
|
||||||
|
>
|
||||||
|
{type}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Graph Canvas */}
|
||||||
|
<Card className="border-border/50 overflow-hidden">
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={`relative bg-gradient-to-br from-background to-muted/20 overflow-hidden ${isDragging ? 'cursor-grabbing' : 'cursor-grab'}`}
|
||||||
|
style={{ height: "calc(100vh - 320px)", minHeight: "500px" }}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseUp={handleMouseUp}
|
||||||
|
onMouseLeave={handleMouseUp}
|
||||||
|
onWheel={handleWheel}
|
||||||
|
>
|
||||||
|
{/* Grid pattern background */}
|
||||||
|
<svg className="absolute inset-0 w-full h-full pointer-events-none opacity-30">
|
||||||
|
<defs>
|
||||||
|
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
|
||||||
|
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="currentColor" strokeWidth="0.5" className="text-border" />
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width="100%" height="100%" fill="url(#grid)" />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* SVG for edges and nodes */}
|
||||||
|
<svg
|
||||||
|
width={width * zoom}
|
||||||
|
height={height * zoom}
|
||||||
|
style={{
|
||||||
|
transform: `translate(${pan.x}px, ${pan.y}px)`,
|
||||||
|
transformOrigin: "0 0"
|
||||||
|
}}
|
||||||
|
className="transition-transform duration-75"
|
||||||
|
>
|
||||||
|
<g transform={`scale(${zoom})`}>
|
||||||
|
{/* Draw edges */}
|
||||||
|
{edges.map((edge, i) => {
|
||||||
|
const fromNode = nodes.find(n => n.id === edge.from);
|
||||||
|
const toNode = nodes.find(n => n.id === edge.to);
|
||||||
|
if (!fromNode || !toNode) return null;
|
||||||
|
|
||||||
|
const x1 = fromNode.x + 90;
|
||||||
|
const y1 = fromNode.y + 30;
|
||||||
|
const x2 = toNode.x + 90;
|
||||||
|
const y2 = toNode.y + 30;
|
||||||
|
|
||||||
|
// Calculate control points for curved lines
|
||||||
|
const midY = (y1 + y2) / 2;
|
||||||
|
const path = `M ${x1} ${y1} Q ${x1} ${midY} ${(x1 + x2) / 2} ${midY} Q ${x2} ${midY} ${x2} ${y2}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g key={`edge-${i}`}>
|
||||||
|
<path
|
||||||
|
d={path}
|
||||||
|
fill="none"
|
||||||
|
stroke={edge.type === "parent" ? "hsl(var(--primary))" : "hsl(var(--muted-foreground))"}
|
||||||
|
strokeWidth={edge.type === "parent" ? 2 : 1}
|
||||||
|
strokeDasharray={edge.type === "relation" ? "4,4" : undefined}
|
||||||
|
opacity={0.5}
|
||||||
|
/>
|
||||||
|
{/* Arrow marker */}
|
||||||
|
<circle
|
||||||
|
cx={x2}
|
||||||
|
cy={y2}
|
||||||
|
r={4}
|
||||||
|
fill={edge.type === "parent" ? "hsl(var(--primary))" : "hsl(var(--muted-foreground))"}
|
||||||
|
opacity={0.5}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Draw nodes */}
|
||||||
|
{nodes.map((node) => {
|
||||||
|
const isHovered = hoveredNode === node.id;
|
||||||
|
const color = typeColors[node.type] || "#64748b";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
key={node.id}
|
||||||
|
transform={`translate(${node.x}, ${node.y})`}
|
||||||
|
onClick={() => openWorkPackage(node.id)}
|
||||||
|
onMouseEnter={() => setHoveredNode(node.id)}
|
||||||
|
onMouseLeave={() => setHoveredNode(null)}
|
||||||
|
className="cursor-pointer"
|
||||||
|
style={{ pointerEvents: 'all' }}
|
||||||
|
>
|
||||||
|
{/* Node shadow */}
|
||||||
|
<rect
|
||||||
|
x={2}
|
||||||
|
y={2}
|
||||||
|
width={180}
|
||||||
|
height={56}
|
||||||
|
rx={8}
|
||||||
|
fill="black"
|
||||||
|
opacity={0.1}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Node background */}
|
||||||
|
<rect
|
||||||
|
x={0}
|
||||||
|
y={0}
|
||||||
|
width={180}
|
||||||
|
height={56}
|
||||||
|
rx={8}
|
||||||
|
fill={isHovered ? color : "hsl(var(--card))"}
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={isHovered ? 3 : 2}
|
||||||
|
className="transition-all duration-150"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Type indicator bar */}
|
||||||
|
<rect
|
||||||
|
x={0}
|
||||||
|
y={0}
|
||||||
|
width={6}
|
||||||
|
height={56}
|
||||||
|
rx={8}
|
||||||
|
fill={color}
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x={3}
|
||||||
|
y={0}
|
||||||
|
width={3}
|
||||||
|
height={56}
|
||||||
|
fill={color}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ID badge */}
|
||||||
|
<rect
|
||||||
|
x={14}
|
||||||
|
y={8}
|
||||||
|
width={40}
|
||||||
|
height={18}
|
||||||
|
rx={4}
|
||||||
|
fill={color}
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={34}
|
||||||
|
y={21}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill="white"
|
||||||
|
fontSize={11}
|
||||||
|
fontWeight="bold"
|
||||||
|
>
|
||||||
|
#{node.id}
|
||||||
|
</text>
|
||||||
|
|
||||||
|
{/* Type label */}
|
||||||
|
<text
|
||||||
|
x={60}
|
||||||
|
y={20}
|
||||||
|
fill={isHovered ? "white" : "hsl(var(--muted-foreground))"}
|
||||||
|
fontSize={10}
|
||||||
|
className="capitalize"
|
||||||
|
>
|
||||||
|
{node.type.length > 12 ? node.type.slice(0, 12) + "…" : node.type}
|
||||||
|
</text>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<text
|
||||||
|
x={14}
|
||||||
|
y={42}
|
||||||
|
fill={isHovered ? "white" : "hsl(var(--foreground))"}
|
||||||
|
fontSize={12}
|
||||||
|
fontWeight="500"
|
||||||
|
>
|
||||||
|
{node.title.length > 22 ? node.title.slice(0, 22) + "…" : node.title}
|
||||||
|
</text>
|
||||||
|
|
||||||
|
{/* External link icon on hover */}
|
||||||
|
{isHovered && (
|
||||||
|
<g transform="translate(158, 8)">
|
||||||
|
<rect width={18} height={18} rx={4} fill="white" opacity={0.2} />
|
||||||
|
<ExternalLink x={3} y={3} width={12} height={12} stroke="white" strokeWidth={1.5} fill="none" />
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{nodes.length === 0 && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-lg font-medium">No work packages to display</p>
|
||||||
|
<p className="text-sm">Try selecting a different type filter</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="w-4 h-0.5 bg-primary rounded"></span>
|
||||||
|
Parent-child link
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="w-4 h-0.5 bg-muted-foreground rounded border-dashed"></span>
|
||||||
|
Relation link
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
Click any node to open in OpenProject
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
324
src/services/openProjectService.ts
Normal file
324
src/services/openProjectService.ts
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
/**
|
||||||
|
* OpenProject API Service
|
||||||
|
* Handles fetching work packages directly from OpenProject API
|
||||||
|
* For self-hosted deployments where CORS is configured
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { WorkPackage, WorkPackageType } from '@/types/traceability';
|
||||||
|
|
||||||
|
export interface OpenProjectConfig {
|
||||||
|
baseUrl: string;
|
||||||
|
apiKey: string;
|
||||||
|
projectIdentifier: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OpenProjectWorkPackage {
|
||||||
|
id: number;
|
||||||
|
subject: string;
|
||||||
|
description?: { raw: string };
|
||||||
|
_links: {
|
||||||
|
type: { title: string };
|
||||||
|
status: { title: string };
|
||||||
|
parent?: { href: string } | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OpenProjectRelation {
|
||||||
|
type: string;
|
||||||
|
_links: {
|
||||||
|
to?: { href: string };
|
||||||
|
from?: { href: string };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FetchResult {
|
||||||
|
success: boolean;
|
||||||
|
workPackages: WorkPackage[];
|
||||||
|
logs: string[];
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default configuration - can be overridden
|
||||||
|
const DEFAULT_CONFIG: OpenProjectConfig = {
|
||||||
|
baseUrl: 'https://openproject.nabd-co.com',
|
||||||
|
apiKey: 'dfc009a268f8490c2502458bad364c2f0ae27762c6b4a38c4dac6d394ca3058f',
|
||||||
|
projectIdentifier: 'asf'
|
||||||
|
};
|
||||||
|
|
||||||
|
function getAuthHeader(apiKey: string): string {
|
||||||
|
return 'Basic ' + btoa(`apikey:${apiKey}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getTypeIdMap(config: OpenProjectConfig): Promise<Record<string, string>> {
|
||||||
|
const url = `${config.baseUrl}/api/v3/types`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': getAuthHeader(config.apiKey),
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const types: Record<string, string> = {};
|
||||||
|
|
||||||
|
for (const t of data._embedded?.elements || []) {
|
||||||
|
types[t.name.toLowerCase()] = String(t.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return types;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching type definitions:', error);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRelations(wpId: number, config: OpenProjectConfig): Promise<string> {
|
||||||
|
const url = `${config.baseUrl}/api/v3/work_packages/${wpId}/relations`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': getAuthHeader(config.apiKey),
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) return '';
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const relations: OpenProjectRelation[] = data._embedded?.elements || [];
|
||||||
|
const relList: string[] = [];
|
||||||
|
|
||||||
|
for (const r of relations) {
|
||||||
|
const relType = r.type;
|
||||||
|
const toLink = r._links?.to?.href || '';
|
||||||
|
const fromLink = r._links?.from?.href || '';
|
||||||
|
|
||||||
|
const otherId = toLink.includes(`/${wpId}`)
|
||||||
|
? fromLink.split('/').pop()
|
||||||
|
: toLink.split('/').pop();
|
||||||
|
|
||||||
|
if (otherId) {
|
||||||
|
relList.push(`${relType}(#${otherId})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return relList.join('; ');
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchFromOpenProject(
|
||||||
|
config: OpenProjectConfig = DEFAULT_CONFIG,
|
||||||
|
typesFilter?: string[],
|
||||||
|
onProgress?: (log: string) => void
|
||||||
|
): Promise<FetchResult> {
|
||||||
|
const logs: string[] = [];
|
||||||
|
const errors: string[] = [];
|
||||||
|
const workPackages: WorkPackage[] = [];
|
||||||
|
|
||||||
|
const log = (msg: string) => {
|
||||||
|
logs.push(msg);
|
||||||
|
onProgress?.(msg);
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
log(`🔍 Connecting to OpenProject: ${config.baseUrl}`);
|
||||||
|
log(`📁 Project: ${config.projectIdentifier}`);
|
||||||
|
|
||||||
|
// Build URL and params
|
||||||
|
let url = `${config.baseUrl}/api/v3/projects/${config.projectIdentifier}/work_packages?pageSize=1000`;
|
||||||
|
|
||||||
|
if (typesFilter && typesFilter.length > 0) {
|
||||||
|
log(`🏷️ Filtering by types: ${typesFilter.join(', ')}`);
|
||||||
|
const typeMap = await getTypeIdMap(config);
|
||||||
|
const typeIds = typesFilter
|
||||||
|
.map(t => typeMap[t.toLowerCase()])
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (typeIds.length > 0) {
|
||||||
|
const filters = [{ type: { operator: '=', values: typeIds } }];
|
||||||
|
url += `&filters=${encodeURIComponent(JSON.stringify(filters))}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`📡 Fetching work packages...`);
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': getAuthHeader(config.apiKey),
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`API Error: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const wps: OpenProjectWorkPackage[] = data._embedded?.elements || [];
|
||||||
|
|
||||||
|
log(`✅ Received ${wps.length} work packages`);
|
||||||
|
log(`🔗 Fetching relations for each work package...`);
|
||||||
|
|
||||||
|
let processed = 0;
|
||||||
|
for (const wp of wps) {
|
||||||
|
const wpId = wp.id;
|
||||||
|
|
||||||
|
// Get parent ID
|
||||||
|
let parentId = '';
|
||||||
|
const parentLink = wp._links?.parent;
|
||||||
|
if (parentLink && parentLink.href) {
|
||||||
|
parentId = parentLink.href.split('/').pop() || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get relations
|
||||||
|
const relations = await getRelations(wpId, config);
|
||||||
|
|
||||||
|
const wpType = wp._links.type.title.toLowerCase().replace(/\s+/g, ' ') as WorkPackageType;
|
||||||
|
|
||||||
|
workPackages.push({
|
||||||
|
id: wpId,
|
||||||
|
type: wpType,
|
||||||
|
status: wp._links.status.title,
|
||||||
|
title: wp.subject,
|
||||||
|
description: wp.description?.raw || '',
|
||||||
|
parentId,
|
||||||
|
relations
|
||||||
|
});
|
||||||
|
|
||||||
|
processed++;
|
||||||
|
if (processed % 20 === 0) {
|
||||||
|
log(`⏳ Processed ${processed}/${wps.length} work packages...`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`✅ Successfully fetched ${workPackages.length} work packages`);
|
||||||
|
|
||||||
|
// Log type distribution
|
||||||
|
const typeDist = workPackages.reduce((acc, wp) => {
|
||||||
|
acc[wp.type] = (acc[wp.type] || 0) + 1;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, number>);
|
||||||
|
|
||||||
|
log(`📊 Type distribution: ${JSON.stringify(typeDist)}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
workPackages,
|
||||||
|
logs,
|
||||||
|
errors
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
errors.push(errorMsg);
|
||||||
|
log(`❌ Error: ${errorMsg}`);
|
||||||
|
|
||||||
|
// Check for CORS error
|
||||||
|
if (errorMsg.includes('Failed to fetch') || errorMsg.includes('NetworkError')) {
|
||||||
|
errors.push('This may be a CORS issue. Ensure your OpenProject server allows cross-origin requests, or use a backend proxy.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
workPackages: [],
|
||||||
|
logs,
|
||||||
|
errors
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchFromBackendProxy(
|
||||||
|
proxyUrl: string,
|
||||||
|
onProgress?: (log: string) => void
|
||||||
|
): Promise<FetchResult> {
|
||||||
|
const logs: string[] = [];
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
const log = (msg: string) => {
|
||||||
|
logs.push(msg);
|
||||||
|
onProgress?.(msg);
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
log(`🔍 Connecting to backend proxy: ${proxyUrl}`);
|
||||||
|
|
||||||
|
const response = await fetch(proxyUrl, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Proxy Error: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = response.headers.get('content-type') || '';
|
||||||
|
|
||||||
|
if (contentType.includes('text/csv')) {
|
||||||
|
// Handle CSV response
|
||||||
|
log(`📄 Received CSV data, parsing...`);
|
||||||
|
const csvText = await response.text();
|
||||||
|
|
||||||
|
// Import the CSV parser dynamically
|
||||||
|
const { parseCSVContent } = await import('@/lib/csvParser');
|
||||||
|
const result = parseCSVContent(csvText);
|
||||||
|
|
||||||
|
logs.push(...result.logs);
|
||||||
|
errors.push(...result.errors);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: result.success,
|
||||||
|
workPackages: result.workPackages,
|
||||||
|
logs,
|
||||||
|
errors
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Handle JSON response
|
||||||
|
log(`📋 Received JSON data`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
log(`✅ Received ${data.length} work packages`);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
workPackages: data as WorkPackage[],
|
||||||
|
logs,
|
||||||
|
errors
|
||||||
|
};
|
||||||
|
} else if (data.workPackages) {
|
||||||
|
log(`✅ Received ${data.workPackages.length} work packages`);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
workPackages: data.workPackages as WorkPackage[],
|
||||||
|
logs,
|
||||||
|
errors
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
throw new Error('Invalid response format from proxy');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
errors.push(errorMsg);
|
||||||
|
log(`❌ Error: ${errorMsg}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
workPackages: [],
|
||||||
|
logs,
|
||||||
|
errors
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { DEFAULT_CONFIG };
|
||||||
@@ -10,8 +10,12 @@ export type WorkPackageType =
|
|||||||
| 'requirements'
|
| 'requirements'
|
||||||
| 'swreq'
|
| 'swreq'
|
||||||
| 'phase'
|
| 'phase'
|
||||||
| 'test case'
|
| 'software test case'
|
||||||
| 'risk';
|
| 'risk'
|
||||||
|
| 'interface'
|
||||||
|
| 'component'
|
||||||
|
| 'sw_feature'
|
||||||
|
| 'system test';
|
||||||
|
|
||||||
export interface WorkPackage {
|
export interface WorkPackage {
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user