Files
Aegis/frontend/src/components/WorklogTimeline.tsx
kitos bd0493aade
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
fix(ui): make all Jira and time panels read-only everywhere
WorklogTimeline: add readOnly prop — hides 'Log Time' button and form.
TestPhaseTimeline: remove 'Sync to Tempo' button from TempoSyncBadge;
  only displays the green 'Tempo' badge when already synced. Cleans up
  unused imports (useState, useMutation, useQueryClient, syncTestToTempo).
CampaignDetailPage: JiraLinkPanel and WorklogTimeline both now rendered
  with readOnly=true; JiraLinkPanel receives campaign name as label.

Jira tickets and time worklogs are created automatically by the system
(campaign activation, test workflow) — no manual editing from detail pages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 11:33:55 +02:00

222 lines
9.1 KiB
TypeScript

import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Clock, Plus, Loader2, ShieldCheck, ShieldAlert, X } from "lucide-react";
import { listWorklogs, createWorklog, type Worklog } from "../api/worklogs";
import { useAuth } from "../context/AuthContext";
interface WorklogTimelineProps {
entityType: string;
entityId: string;
/** When true, hides the Log Time button and form (read-only display). */
readOnly?: boolean;
}
const activityColors: Record<string, { bg: string; text: string; icon: string }> = {
red_team: { bg: "bg-orange-900/30", text: "text-orange-400", icon: "border-orange-500/40" },
blue_validation: { bg: "bg-indigo-900/30", text: "text-indigo-400", icon: "border-indigo-500/40" },
purple_review: { bg: "bg-purple-900/30", text: "text-purple-400", icon: "border-purple-500/40" },
reporting: { bg: "bg-cyan-900/30", text: "text-cyan-400", icon: "border-cyan-500/40" },
execution: { bg: "bg-orange-900/30", text: "text-orange-400", icon: "border-orange-500/40" },
};
const defaultActivity = { bg: "bg-gray-800/50", text: "text-gray-400", icon: "border-gray-600" };
function formatDuration(seconds: number): string {
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
return m > 0 ? `${h}h ${m}m` : `${h}h`;
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
export default function WorklogTimeline({ entityType, entityId, readOnly = false }: WorklogTimelineProps) {
const queryClient = useQueryClient();
const { user } = useAuth();
const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState({
activity_type: "red_team",
duration_minutes: "60",
description: "",
});
// ── Query ───────────────────────────────────────────────────────
const { data: worklogs = [], isLoading } = useQuery({
queryKey: ["worklogs", entityType, entityId],
queryFn: () => listWorklogs({ entity_type: entityType, entity_id: entityId }),
});
const createMutation = useMutation({
mutationFn: () =>
createWorklog({
entity_type: entityType,
entity_id: entityId,
activity_type: form.activity_type,
started_at: new Date().toISOString(),
duration_seconds: parseInt(form.duration_minutes, 10) * 60,
description: form.description || undefined,
}),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["worklogs", entityType, entityId],
});
setShowForm(false);
setForm({ activity_type: "red_team", duration_minutes: "60", description: "" });
},
});
// ── Total time ──────────────────────────────────────────────────
const totalSeconds = worklogs.reduce(
(sum: number, wl: Worklog) => sum + wl.duration_seconds,
0,
);
return (
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<div className="mb-4 flex items-center justify-between">
<h2 className="flex items-center gap-2 text-lg font-semibold text-white">
<Clock className="h-5 w-5 text-cyan-400" />
Time Log
</h2>
<div className="flex items-center gap-3">
{totalSeconds > 0 && (
<span className="text-xs text-gray-400">
Total: <span className="text-cyan-400 font-medium">{formatDuration(totalSeconds)}</span>
</span>
)}
{!readOnly && (
<button
onClick={() => setShowForm(!showForm)}
className="flex items-center gap-1 rounded-lg border border-gray-700 px-3 py-1.5 text-xs text-gray-300 hover:border-cyan-500/50 hover:text-cyan-400 transition-colors"
>
{showForm ? (
<>
<X className="h-3.5 w-3.5" /> Cancel
</>
) : (
<>
<Plus className="h-3.5 w-3.5" /> Log Time
</>
)}
</button>
)}
</div>
</div>
{/* New worklog form — only in edit mode */}
{!readOnly && showForm && (
<div className="mb-4 rounded-lg border border-gray-700 bg-gray-800/50 p-3 space-y-3">
<div className="grid gap-3 sm:grid-cols-2">
<div>
<label className="mb-1 block text-xs text-gray-400">Activity Type</label>
<select
value={form.activity_type}
onChange={(e) => setForm({ ...form, activity_type: e.target.value })}
className="w-full rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200 focus:border-cyan-500 focus:outline-none"
>
<option value="red_team">Red Team</option>
<option value="blue_validation">Blue Validation</option>
<option value="purple_review">Purple Review</option>
<option value="reporting">Reporting</option>
<option value="execution">Execution</option>
</select>
</div>
<div>
<label className="mb-1 block text-xs text-gray-400">Duration (minutes)</label>
<input
type="number"
min="1"
value={form.duration_minutes}
onChange={(e) => setForm({ ...form, duration_minutes: e.target.value })}
className="w-full rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200 focus:border-cyan-500 focus:outline-none"
/>
</div>
</div>
<div>
<label className="mb-1 block text-xs text-gray-400">Description (optional)</label>
<input
type="text"
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
placeholder="What did you work on?"
className="w-full rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200 placeholder-gray-500 focus:border-cyan-500 focus:outline-none"
/>
</div>
<button
onClick={() => createMutation.mutate()}
disabled={createMutation.isPending || !form.duration_minutes}
className="flex items-center gap-1.5 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-medium text-white hover:bg-cyan-500 disabled:opacity-50 transition-colors"
>
{createMutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
Save Worklog
</button>
</div>
)}
{/* Timeline */}
{isLoading ? (
<div className="flex justify-center py-4">
<Loader2 className="h-5 w-5 animate-spin text-gray-500" />
</div>
) : worklogs.length === 0 ? (
<p className="text-center text-sm text-gray-500 py-4">No time logged yet</p>
) : (
<div className="relative space-y-0">
{/* Vertical line */}
<div className="absolute left-[15px] top-2 bottom-2 w-px bg-gray-700" />
{worklogs.map((wl: Worklog) => {
const style = activityColors[wl.activity_type] || defaultActivity;
return (
<div key={wl.id} className="relative flex gap-3 py-2">
{/* Dot */}
<div
className={`relative z-10 mt-1 h-[10px] w-[10px] shrink-0 rounded-full border-2 bg-gray-900 ${style.icon}`}
style={{ marginLeft: "6px" }}
/>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span
className={`rounded px-1.5 py-0.5 text-xs font-medium ${style.bg} ${style.text}`}
>
{wl.activity_type.replace(/_/g, " ")}
</span>
<span className="text-xs font-medium text-gray-200">
{formatDuration(wl.duration_seconds)}
</span>
{wl.tempo_synced && (
<span className="text-xs text-green-500" title="Synced to Tempo">
<ShieldCheck className="inline h-3 w-3" />
</span>
)}
</div>
{wl.description && (
<p className="mt-0.5 text-xs text-gray-400 truncate">
{wl.description}
</p>
)}
<p className="mt-0.5 text-xs text-gray-500">
{formatDate(wl.started_at)}
</p>
</div>
</div>
);
})}
</div>
)}
</div>
);
}