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

File diff suppressed because one or more lines are too long

View File

@@ -11,6 +11,7 @@ import AnalysisPage from "./pages/AnalysisPage";
import ALMTypePage from "./pages/ALMTypePage";
import TraceabilityMatrixPage from "./pages/TraceabilityMatrixPage";
import ESPIDFHelperPage from "./pages/ESPIDFHelperPage";
import WorkPackageGraphPage from "./pages/WorkPackageGraphPage";
import LoginPage from "./pages/LoginPage";
import NotFound from "./pages/NotFound";
@@ -65,6 +66,14 @@ const App = () => (
</ProtectedRoute>
}
/>
<Route
path="/graph"
element={
<ProtectedRoute>
<WorkPackageGraphPage />
</ProtectedRoute>
}
/>
<Route
path="/alm/:type"
element={

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>
);
}

View File

@@ -17,6 +17,7 @@ import {
ChevronDown,
ChevronRight,
Cpu,
Share2,
} from "lucide-react";
import { NavLink } from "@/components/NavLink";
import { useLocation } from "react-router-dom";
@@ -36,6 +37,7 @@ import { cn } from "@/lib/utils";
const mainItems = [
{ title: "Dashboard", url: "/", icon: LayoutDashboard },
{ title: "Traceability Matrix", url: "/matrix", icon: GitBranch },
{ title: "Work Package Graph", url: "/graph", icon: Share2 },
{ title: "Documentation", url: "/documentation", icon: BookOpen },
{ title: "Gap Analysis", url: "/analysis", icon: Search },
{ title: "ESP-IDF Helper", url: "/esp-idf", icon: Cpu },
@@ -43,9 +45,13 @@ const mainItems = [
const almItems = [
{ 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: "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: "User Stories", url: "/alm/user-story", icon: FolderKanban, type: "user story" },
{ title: "Tasks", url: "/alm/task", icon: CheckSquare, type: "task" },

View File

@@ -21,7 +21,7 @@ interface AuthContextType {
const AuthContext = createContext<AuthContextType | undefined>(undefined);
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 {
children: ReactNode;

View File

@@ -127,18 +127,29 @@ export function useTraceabilityData() {
const loadData = useCallback(async () => {
setLoading(true);
setError(null);
setParseLog([]);
const newLogs: string[] = [];
newLogs.push(`[Loader] Starting data load at ${new Date().toLocaleTimeString()}`);
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) {
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();
newLogs.push(`[Loader] Received ${csvText.length} bytes`);
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();
setData({
@@ -147,7 +158,10 @@ export function useTraceabilityData() {
});
setLastUpdated(now);
} 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 {
setLoading(false);
}

125
src/lib/csvParser.ts Normal file
View 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
};
}

View File

@@ -5,7 +5,7 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { ScrollArea } from "@/components/ui/scroll-area";
import { CSVUpload } from "@/components/CSVUpload";
import { DataUpdateDialog } from "@/components/DataUpdateDialog";
import { WorkPackage } from "@/types/traceability";
import {
Target,
@@ -21,7 +21,7 @@ import {
Download,
ChevronDown,
Terminal,
Upload,
RefreshCw,
} from "lucide-react";
import { Link } from "react-router-dom";
import { useState } from "react";
@@ -142,7 +142,7 @@ export default function Dashboard() {
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Download className="h-5 w-5" />
<RefreshCw className="h-5 w-5" />
Data Management
</CardTitle>
</CardHeader>
@@ -150,19 +150,19 @@ export default function Dashboard() {
<div className="flex flex-wrap gap-3">
<Button variant="outline" onClick={handleDownloadCSV}>
<Download className="h-4 w-4 mr-2" />
Download CSV
Download Current CSV
</Button>
<Button variant="outline" onClick={() => setShowUpload(!showUpload)}>
<Upload className="h-4 w-4 mr-2" />
Upload CSV
<Button onClick={() => setShowUpload(!showUpload)}>
<RefreshCw className="h-4 w-4 mr-2" />
Update Data
</Button>
<Button variant="outline" onClick={refresh} disabled={loading}>
Reload Data
Reload from CSV
</Button>
</div>
{showUpload && (
<CSVUpload
<DataUpdateDialog
onDataLoaded={handleDataLoaded}
onClose={() => setShowUpload(false)}
/>
@@ -186,7 +186,7 @@ export default function Dashboard() {
<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>
</ScrollArea>
@@ -194,13 +194,6 @@ export default function Dashboard() {
</Card>
</CollapsibleContent>
</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>
</Card>

View File

@@ -111,7 +111,15 @@ export default function LoginPage() {
</Card>
<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>
</div>
</div>

View File

@@ -93,7 +93,7 @@ export default function TraceabilityMatrixPage() {
const features = allWPs.filter(wp => wp.type === 'feature');
const requirements = allWPs.filter(wp => wp.type === 'requirements');
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
const wpById = new Map(allWPs.map(wp => [wp.id, wp]));

View 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>
);
}

View 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 };

View File

@@ -10,8 +10,12 @@ export type WorkPackageType =
| 'requirements'
| 'swreq'
| 'phase'
| 'test case'
| 'risk';
| 'software test case'
| 'risk'
| 'interface'
| 'component'
| 'sw_feature'
| 'system test';
export interface WorkPackage {
id: number;