Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
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>
222 lines
9.1 KiB
TypeScript
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>
|
|
);
|
|
}
|