Refactored projects and milestones page

This commit is contained in:
2026-09-28 11:34:11 +02:00
parent 93b3416d2c
commit 8b68f7b99c
8 changed files with 3307 additions and 1185 deletions
@@ -0,0 +1,367 @@
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import {
ShieldAlert,
Plus,
Edit2,
AlertTriangle,
X,
RotateCcw,
CheckCircle2,
ShieldCheck,
} from 'lucide-react';
import { Modal } from '../ui/Modal';
import { HelpTooltip } from '../ui/HelpTooltip';
import { useToast } from '../ui/Toast';
import {
getProjectRisks,
createRisk,
updateRisk,
} from '../../services/api';
import { Project, Risk, RiskLikelihood, RiskImpact } from '../../types';
import {
RISK_LIKELIHOOD_OPTIONS,
RISK_IMPACT_OPTIONS,
getRiskScoreColor,
} from '../../lib/formatters';
const riskSchema = z.object({
name: z.string().min(5, 'Risk title required (min 5 chars)'),
description: z.string().min(10, 'Risk description required (min 10 chars)'),
likelihood: z.coerce.number().min(1).max(5),
impact: z.coerce.number().min(1).max(5),
});
type RiskFormValues = z.infer<typeof riskSchema>;
interface ProjectRisksModalProps {
isOpen: boolean;
onClose: () => void;
project: Project | null;
}
export const ProjectRisksModal: React.FC<ProjectRisksModalProps> = ({
isOpen,
onClose,
project,
}) => {
const queryClient = useQueryClient();
const { success, error: toastError } = useToast();
const [isFormOpen, setIsFormOpen] = useState(false);
const [editingRisk, setEditingRisk] = useState<Risk | null>(null);
const projectId = project?.id;
const {
data: risks = [],
isLoading,
refetch,
} = useQuery({
queryKey: ['risks', projectId],
queryFn: () => (projectId ? getProjectRisks(projectId) : Promise.resolve([])),
enabled: isOpen && !!projectId,
});
const form = useForm<RiskFormValues>({
resolver: zodResolver(riskSchema) as any,
defaultValues: {
name: '',
description: '',
likelihood: RiskLikelihood.Possible,
impact: RiskImpact.Moderate,
},
});
const openCreateForm = () => {
setEditingRisk(null);
form.reset({
name: '',
description: '',
likelihood: RiskLikelihood.Possible,
impact: RiskImpact.Moderate,
});
setIsFormOpen(true);
};
const openEditForm = (r: Risk) => {
setEditingRisk(r);
form.reset({
name: r.name || '',
description: r.description || '',
likelihood: r.likelihood || RiskLikelihood.Possible,
impact: r.impact || RiskImpact.Moderate,
});
setIsFormOpen(true);
};
const mutation = useMutation({
mutationFn: async (data: RiskFormValues): Promise<void> => {
if (!projectId) return;
if (editingRisk) {
await updateRisk({
riskId: editingRisk.id,
name: data.name,
description: data.description,
likelihood: Number(data.likelihood),
impact: Number(data.impact),
});
} else {
await createRisk({
projectId: projectId,
name: data.name,
description: data.description,
likelihood: Number(data.likelihood),
impact: Number(data.impact),
});
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['risks', projectId] });
queryClient.invalidateQueries({ queryKey: ['risks'] });
success(
editingRisk ? 'Risk Updated' : 'Risk Registered',
editingRisk ? 'Risk register item updated' : 'Risk registered to ISO 31000 risk matrix'
);
setIsFormOpen(false);
setEditingRisk(null);
form.reset();
},
onError: (err: unknown) => {
toastError(editingRisk ? 'Failed to update risk' : 'Failed to register risk', err);
},
});
if (!project) return null;
const highRisksCount = risks.filter((r) => r.likelihood * r.impact >= 12).length;
return (
<Modal
isOpen={isOpen}
onClose={() => {
setIsFormOpen(false);
setEditingRisk(null);
onClose();
}}
title={`Risk Register: ${project.name}`}
subtitle={`Project #${project.id} • ISO 31000 Risk & Mitigation Controls`}
helpText="Assesses project operational, financial, and technical hazards by Likelihood (1-5) and Consequence/Impact (1-5), establishing proactive mitigation commitments."
maxWidth="xl"
>
<div className="space-y-5">
{/* KPI Summary Card */}
<div className="bg-[#0b101c] p-4 rounded-xl border border-slate-800/90 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="space-y-1">
<span className="text-[10px] uppercase font-bold text-slate-400 flex items-center gap-1.5">
<ShieldAlert className="w-3.5 h-3.5 text-rose-400" />
Project Risk Profile
</span>
<div className="flex items-baseline gap-2">
<span className="text-2xl font-bold text-white">{risks.length}</span>
<span className="text-xs text-slate-400">
({highRisksCount} critical/high exposure items)
</span>
</div>
</div>
<div className="flex items-center gap-2">
{!isFormOpen && (
<button
type="button"
onClick={openCreateForm}
className="inline-flex items-center gap-1.5 px-3.5 py-2 text-xs font-semibold text-white bg-gradient-to-r from-rose-600 to-amber-600 hover:from-rose-500 hover:to-amber-500 rounded-xl shadow-md shadow-rose-600/20 transition cursor-pointer"
>
<Plus className="w-3.5 h-3.5" />
Add Risk
</button>
)}
</div>
</div>
{/* Create / Edit Risk Form */}
{isFormOpen && (
<div className="bg-[#0d121f] p-4 rounded-xl border border-rose-500/30 space-y-4 animate-in fade-in">
<div className="flex items-center justify-between border-b border-slate-800 pb-2.5">
<span className="text-xs font-bold text-rose-400 flex items-center gap-1.5">
<ShieldAlert className="w-3.5 h-3.5" />
{editingRisk ? `Edit Risk #${editingRisk.id}` : 'Log New Project Hazard & Risk'}
</span>
<button
type="button"
onClick={() => {
setIsFormOpen(false);
setEditingRisk(null);
}}
className="text-slate-400 hover:text-slate-200 text-xs p-1"
>
<X className="w-4 h-4" />
</button>
</div>
<form onSubmit={form.handleSubmit((data) => mutation.mutate(data as any))} className="space-y-3">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Risk Hazard Title *</label>
<input
type="text"
{...form.register('name')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-rose-500 text-white bg-[#090d16]"
placeholder="e.g. Critical Supply Chain Delay for Imported Telemetry Chips"
/>
{form.formState.errors.name && (
<p className="text-[10px] text-rose-500 mt-1">{form.formState.errors.name.message}</p>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Likelihood (1 - 5) *</label>
<select
{...form.register('likelihood')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-rose-500 bg-[#090d16] text-white font-medium"
>
{RISK_LIKELIHOOD_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.value} - {opt.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Consequence / Impact (1 - 5) *</label>
<select
{...form.register('impact')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-rose-500 bg-[#090d16] text-white font-medium"
>
{RISK_IMPACT_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.value} - {opt.label}
</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Description & Potential Impact *</label>
<textarea
rows={2}
{...form.register('description')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-rose-500 text-white bg-[#090d16]"
placeholder="Identify root cause, exposure pathway, and operational consequences..."
/>
{form.formState.errors.description && (
<p className="text-[10px] text-rose-500 mt-1">{form.formState.errors.description.message}</p>
)}
</div>
<div className="pt-2 flex justify-end gap-2 border-t border-slate-800">
<button
type="button"
onClick={() => {
setIsFormOpen(false);
setEditingRisk(null);
}}
className="px-3.5 py-1.5 text-xs font-semibold text-slate-300 bg-slate-800 hover:bg-slate-700 rounded-xl cursor-pointer"
>
Cancel
</button>
<button
type="submit"
disabled={mutation.isPending}
className="px-4 py-1.5 text-xs font-semibold text-white bg-gradient-to-r from-rose-600 to-amber-600 hover:from-rose-500 hover:to-amber-500 rounded-xl shadow-md shadow-rose-500/20 cursor-pointer"
>
{mutation.isPending ? 'Saving...' : editingRisk ? 'Save Changes' : 'Log Risk'}
</button>
</div>
</form>
</div>
)}
{/* Risks List */}
<div className="space-y-2.5">
<div className="flex items-center justify-between text-xs font-semibold text-slate-400 px-1">
<span>Risk Matrix Registry ({risks.length})</span>
<button
type="button"
onClick={() => refetch()}
className="text-[11px] text-slate-400 hover:text-white flex items-center gap-1 cursor-pointer"
>
<RotateCcw className="w-3 h-3" /> Refresh
</button>
</div>
{isLoading ? (
<div className="p-8 text-center text-xs text-slate-400 bg-[#0b101c] rounded-xl border border-slate-800">
Loading project risks...
</div>
) : risks.length === 0 ? (
<div className="p-8 text-center bg-[#0b101c] rounded-xl border border-dashed border-slate-800 text-slate-400 space-y-2">
<ShieldCheck className="w-8 h-8 text-emerald-500 mx-auto" />
<p className="text-xs font-semibold text-slate-300">No project risks logged</p>
<p className="text-[11px] text-slate-500">Log hazards to enforce compliance with ISO 31000 risk controls.</p>
<button
type="button"
onClick={openCreateForm}
className="mt-2 inline-flex items-center gap-1 text-xs font-semibold text-rose-400 hover:underline"
>
<Plus className="w-3.5 h-3.5" /> Log first risk
</button>
</div>
) : (
<div className="space-y-2.5 max-h-[380px] overflow-y-auto pr-1">
{risks.map((rk) => {
const score = (rk.likelihood || 1) * (rk.impact || 1);
const riskInfo = getRiskScoreColor(rk.likelihood || 1, rk.impact || 1);
return (
<div
key={rk.id}
className="p-4 rounded-xl border border-slate-800 bg-[#0d121f] hover:border-slate-700 transition space-y-2.5"
>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<div className="space-y-0.5">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-bold text-white">{rk.name}</span>
<span className={`text-[10px] px-2 py-0.5 rounded-md font-mono ${riskInfo.badge}`}>
Score: {score} / 25 ({riskInfo.level})
</span>
</div>
<span className="text-[10px] text-slate-500 font-mono">
Likelihood: {rk.likelihood}/5 &bull; Impact: {rk.impact}/5 &bull; Risk ID #{rk.id}
</span>
</div>
<div className="flex items-center gap-2 self-end sm:self-center">
<button
type="button"
onClick={() => openEditForm(rk)}
className="p-1.5 rounded-lg text-slate-400 hover:text-rose-400 hover:bg-slate-800 transition cursor-pointer"
title="Edit Risk"
>
<Edit2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
<p className="text-xs text-slate-400 leading-relaxed">{rk.description}</p>
{rk.mitigationAction && (
<div className="text-[11px] text-emerald-400 bg-emerald-500/10 p-2.5 rounded-lg border border-emerald-500/20">
<strong className="text-emerald-300">Mitigation Control:</strong> {rk.mitigationAction}
</div>
)}
</div>
);
})}
</div>
)}
</div>
</div>
</Modal>
);
};