updates
This commit is contained in:
@@ -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={
|
||||
|
||||
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,
|
||||
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" },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
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 { 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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]));
|
||||
|
||||
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'
|
||||
| 'swreq'
|
||||
| 'phase'
|
||||
| 'test case'
|
||||
| 'risk';
|
||||
| 'software test case'
|
||||
| 'risk'
|
||||
| 'interface'
|
||||
| 'component'
|
||||
| 'sw_feature'
|
||||
| 'system test';
|
||||
|
||||
export interface WorkPackage {
|
||||
id: number;
|
||||
|
||||
Reference in New Issue
Block a user