modcomms/frontend/components/KnowledgeBase.tsx
Vadym Samoilenko 4302b9391a Restyle full application from Barclays to Oliver Agency brand
Replace entire Barclays colour palette (navy #1A2142, lime #C3FB5A, violet
#7A0FF9) with Oliver brand tokens: black #1A1A1A, gold #FFCB05, orange
#FF5C00, azure #0487B6, sky #5DF5EA, grey #EFEFEF, green #09821F.

- Switch font from Inter/Barclays Effra to Arial (system font)
- Add new Oliver logo asset (BAR-ModComms-logo-v4.png)
- Sidebar: black background, new logo, azure active state
- Hero: orange "Intelligent Review" text, hide AI-Powered tagline
- Hide ChecksOverview on Home page per Oliver design
- Toast notification: orange background with black text
- All tables: sky headers, alternating white/grey rows
- Campaign badges: gold "In Progress", green "Completed"
- Analytics: grey KPI cards, sky accent on Key Insight, oliver trend colours
- All buttons: azure fill, pill-shaped (rounded-full)
- All tabs/toggles/dropdowns: azure accent colour
- Update HTML title to "Mod Comms - Intelligent Review"
- Default border radius set to 10px

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 10:16:26 +00:00

672 lines
34 KiB
TypeScript

import React, { useState, useEffect, useCallback, useRef } from 'react';
import apiService from '../services/apiService';
import type {
KnowledgeBaseListItem, KnowledgeBaseDetail, SourceDocument,
ProcessingJob, SpecVersionListItem, SpecVersionDetail, DiffResult,
} from '../types';
import { ArrowLeftIcon } from './icons/ArrowLeftIcon';
import { TrashIcon } from './icons/TrashIcon';
import { UploadIcon } from './icons/UploadIcon';
import { SpinnerIcon } from './icons/SpinnerIcon';
// --- Helper Components ---
const StatusBadge: React.FC<{ status: string }> = ({ status }) => {
const colors: Record<string, string> = {
pending: 'bg-yellow-100 text-yellow-800',
parsing: 'bg-blue-100 text-blue-800',
parsed: 'bg-green-100 text-green-800',
partial: 'bg-orange-100 text-orange-800',
error: 'bg-red-100 text-red-800',
parsing_documents: 'bg-blue-100 text-blue-800',
distilling: 'bg-purple-100 text-purple-800',
completed: 'bg-green-100 text-green-800',
failed: 'bg-red-100 text-red-800',
};
const labels: Record<string, string> = {
partial: 'partial parse',
};
return (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colors[status] || 'bg-oliver-grey text-oliver-black/60'}`}>
{labels[status] || status}
</span>
);
};
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function formatDate(dateStr: string | null): string {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleDateString('en-GB', {
day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit',
});
}
// --- Main Component ---
export const KnowledgeBase: React.FC = () => {
// Level 1: List view
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBaseListItem[]>([]);
const [loading, setLoading] = useState(true);
// Level 2: Detail view
const [selectedKb, setSelectedKb] = useState<KnowledgeBaseDetail | null>(null);
const [activeTab, setActiveTab] = useState<'documents' | 'versions'>('documents');
const [versions, setVersions] = useState<SpecVersionListItem[]>([]);
const [uploading, setUploading] = useState(false);
// Processing job polling
const [activeJob, setActiveJob] = useState<ProcessingJob | null>(null);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Level 3: Diff view
const [diffResult, setDiffResult] = useState<DiffResult | null>(null);
const [selectedForDiff, setSelectedForDiff] = useState<string[]>([]);
// Spec content modal
const [viewingSpec, setViewingSpec] = useState<SpecVersionDetail | null>(null);
// --- Data Loading ---
const loadKnowledgeBases = useCallback(async () => {
try {
setLoading(true);
const data = await apiService.getKnowledgeBases();
setKnowledgeBases(data);
} catch (err) {
console.error('Failed to load knowledge bases:', err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadKnowledgeBases();
}, [loadKnowledgeBases]);
const loadKbDetail = useCallback(async (kbId: string) => {
try {
const [detail, vers] = await Promise.all([
apiService.getKnowledgeBase(kbId),
apiService.getSpecVersions(kbId),
]);
setSelectedKb(detail);
setVersions(vers);
// Check for active job - only treat as running if actually in progress
// A "pending" job older than 2 minutes is considered stale/stuck
if (detail.latest_job && ['parsing_documents', 'distilling'].includes(detail.latest_job.status)) {
setActiveJob(detail.latest_job);
} else if (detail.latest_job && detail.latest_job.status === 'pending' && detail.latest_job.started_at) {
const ageMs = Date.now() - new Date(detail.latest_job.started_at).getTime();
if (ageMs < 2 * 60 * 1000) {
setActiveJob(detail.latest_job);
} else {
setActiveJob(null);
}
} else {
setActiveJob(null);
}
} catch (err) {
console.error('Failed to load KB detail:', err);
}
}, []);
// Poll active job
useEffect(() => {
if (activeJob && selectedKb) {
pollRef.current = setInterval(async () => {
try {
const job = await apiService.getProcessingJob(selectedKb.id, activeJob.id);
setActiveJob(job);
if (['completed', 'failed'].includes(job.status)) {
if (pollRef.current) clearInterval(pollRef.current);
pollRef.current = null;
// Refresh data
loadKbDetail(selectedKb.id);
loadKnowledgeBases();
}
} catch (err) {
console.error('Failed to poll job:', err);
// Stop polling on error (e.g. 404 job not found)
setActiveJob(null);
if (pollRef.current) clearInterval(pollRef.current);
pollRef.current = null;
loadKbDetail(selectedKb.id);
}
}, 3000);
}
return () => {
if (pollRef.current) {
clearInterval(pollRef.current);
pollRef.current = null;
}
};
}, [activeJob, selectedKb, loadKbDetail, loadKnowledgeBases]);
// --- Handlers ---
const handleSelectKb = (kb: KnowledgeBaseListItem) => {
loadKbDetail(kb.id);
setActiveTab('documents');
setDiffResult(null);
setSelectedForDiff([]);
};
const handleBack = () => {
setSelectedKb(null);
setDiffResult(null);
setSelectedForDiff([]);
setViewingSpec(null);
loadKnowledgeBases();
};
const handleBackFromDiff = () => {
setDiffResult(null);
setSelectedForDiff([]);
};
const handleFileUpload = async (files: FileList | null) => {
if (!files || !selectedKb) return;
setUploading(true);
try {
for (let i = 0; i < files.length; i++) {
await apiService.uploadSourceDocument(selectedKb.id, files[i]);
}
await loadKbDetail(selectedKb.id);
loadKnowledgeBases();
} catch (err) {
console.error('Failed to upload file:', err);
} finally {
setUploading(false);
}
};
const handleRemoveDoc = async (docId: string) => {
if (!selectedKb) return;
if (!confirm('Are you sure you want to delete this document? This action cannot be undone.')) return;
try {
await apiService.removeSourceDocument(selectedKb.id, docId);
await loadKbDetail(selectedKb.id);
loadKnowledgeBases();
} catch (err) {
console.error('Failed to remove document:', err);
}
};
const handleProcess = async () => {
if (!selectedKb) return;
try {
const job = await apiService.triggerProcessing(selectedKb.id);
setActiveJob(job);
} catch (err: any) {
console.error('Failed to trigger processing:', err);
alert(err.message || 'Failed to trigger processing.');
}
};
const handleViewSpec = async (versionId: string) => {
if (!selectedKb) return;
try {
const spec = await apiService.getSpecVersion(selectedKb.id, versionId);
setViewingSpec(spec);
} catch (err) {
console.error('Failed to load spec:', err);
}
};
const handleDiffToggle = (versionId: string) => {
setSelectedForDiff(prev => {
if (prev.includes(versionId)) {
return prev.filter(id => id !== versionId);
}
if (prev.length >= 2) {
return [prev[1], versionId];
}
return [...prev, versionId];
});
};
const handleCompare = async () => {
if (selectedForDiff.length !== 2 || !selectedKb) return;
try {
const diff = await apiService.getSpecDiff(selectedKb.id, selectedForDiff[0], selectedForDiff[1]);
setDiffResult(diff);
} catch (err) {
console.error('Failed to compute diff:', err);
}
};
const handleActivateVersion = async (versionId: string) => {
if (!selectedKb) return;
if (!confirm('Are you sure you want to activate this version? This will change the spec used by the AI agent.')) return;
try {
await apiService.activateSpecVersion(selectedKb.id, versionId);
await loadKbDetail(selectedKb.id);
} catch (err) {
console.error('Failed to activate version:', err);
}
};
// --- Drag & Drop ---
const [isDragging, setIsDragging] = useState(false);
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
};
const handleDragLeave = () => setIsDragging(false);
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
handleFileUpload(e.dataTransfer.files);
};
// ===================== RENDER =====================
// Level 3: Diff view
if (diffResult && selectedKb) {
return (
<div className="p-8 max-w-6xl mx-auto w-full">
<button onClick={handleBackFromDiff} className="flex items-center gap-2 text-oliver-azure hover:underline mb-6 text-sm font-medium">
<ArrowLeftIcon className="h-4 w-4" /> Back to Version History
</button>
<div className="mb-6">
<h1 className="text-2xl font-bold text-oliver-black">{selectedKb.display_name} - Diff</h1>
<p className="text-oliver-black/60 mt-1">
Version {diffResult.version_a} vs Version {diffResult.version_b}
<span className="ml-4 text-green-600 font-medium">+{diffResult.additions}</span>
<span className="ml-2 text-red-600 font-medium">-{diffResult.deletions}</span>
</p>
</div>
<div className="bg-white rounded-xl border border-grey-300 overflow-hidden">
<div className="overflow-x-auto max-h-[70vh] overflow-y-auto font-mono text-sm">
{diffResult.lines.map((line, i) => (
<div
key={i}
className={`flex ${
line.type === 'add' ? 'bg-green-50' :
line.type === 'remove' ? 'bg-red-50' :
''
}`}
>
<span className="w-12 text-right pr-2 text-oliver-black/60 select-none border-r border-grey-200 flex-shrink-0 py-0.5">
{line.line_number_old || ''}
</span>
<span className="w-12 text-right pr-2 text-oliver-black/60 select-none border-r border-grey-200 flex-shrink-0 py-0.5">
{line.line_number_new || ''}
</span>
<span className={`w-6 text-center flex-shrink-0 py-0.5 ${
line.type === 'add' ? 'text-green-600' :
line.type === 'remove' ? 'text-red-600' :
'text-grey-400'
}`}>
{line.type === 'add' ? '+' : line.type === 'remove' ? '-' : ' '}
</span>
<pre className="flex-1 py-0.5 pr-4 whitespace-pre-wrap break-words">{line.content}</pre>
</div>
))}
</div>
</div>
</div>
);
}
// Spec content modal
if (viewingSpec) {
return (
<div className="p-8 max-w-6xl mx-auto w-full">
<button onClick={() => setViewingSpec(null)} className="flex items-center gap-2 text-oliver-azure hover:underline mb-6 text-sm font-medium">
<ArrowLeftIcon className="h-4 w-4" /> Back to Version History
</button>
<div className="mb-6">
<h1 className="text-2xl font-bold text-oliver-black">
{selectedKb?.display_name} - Version {viewingSpec.version_number}
</h1>
<p className="text-oliver-black/60 mt-1">
{formatDate(viewingSpec.created_at)} | {viewingSpec.char_count.toLocaleString()} chars
{viewingSpec.generated_by_name && <span> | Generated by {viewingSpec.generated_by_name}</span>}
{viewingSpec.is_active && <span className="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">Active</span>}
</p>
</div>
<div className="bg-white rounded-xl border border-grey-300 p-6 overflow-x-auto max-h-[70vh] overflow-y-auto">
<pre className="whitespace-pre-wrap text-sm text-oliver-black font-mono leading-relaxed">{viewingSpec.content}</pre>
</div>
</div>
);
}
// Level 2: Agent detail
if (selectedKb) {
const isJobRunning = activeJob && ['pending', 'parsing_documents', 'distilling'].includes(activeJob.status);
return (
<div className="p-8 max-w-6xl mx-auto w-full">
<button onClick={handleBack} className="flex items-center gap-2 text-oliver-azure hover:underline mb-6 text-sm font-medium">
<ArrowLeftIcon className="h-4 w-4" /> Back to Knowledge Bases
</button>
<div className="mb-6">
<h1 className="text-2xl font-bold text-oliver-black">{selectedKb.display_name}</h1>
{selectedKb.description && <p className="text-oliver-black/60 mt-1">{selectedKb.description}</p>}
</div>
{/* Tabs */}
<div className="flex gap-1 mb-6 bg-oliver-grey p-1 rounded-xl w-fit">
{(['documents', 'versions'] as const).map(tab => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`px-5 py-2 rounded-lg text-sm font-medium transition-all duration-200 ${
activeTab === tab
? 'bg-white text-oliver-black shadow-sm'
: 'text-oliver-black/60 hover:text-oliver-black'
}`}
>
{tab === 'documents' ? 'Source Documents' : 'Version History'}
</button>
))}
</div>
{activeTab === 'documents' ? (
<div>
{/* Upload area */}
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`border-2 border-dashed rounded-xl p-8 mb-6 text-center transition-colors ${
isDragging ? 'border-oliver-azure bg-oliver-azure/5' : 'border-grey-300 hover:border-oliver-azure/50'
}`}
>
<UploadIcon className="h-10 w-10 mx-auto text-oliver-black/60 mb-3" />
<p className="text-oliver-black/60 mb-2">Drag & drop files here, or</p>
<label className="inline-flex items-center gap-2 bg-oliver-azure text-white font-semibold py-2 px-6 rounded-full hover:bg-oliver-azure/90 transition-colors cursor-pointer">
{uploading ? <SpinnerIcon className="h-4 w-4 animate-spin" /> : null}
{uploading ? 'Uploading...' : 'Browse Files'}
<input
type="file"
multiple
className="hidden"
onChange={(e) => handleFileUpload(e.target.files)}
accept=".pdf,.docx,.pptx,.xlsx,.html,.txt,.md,.png,.jpg,.jpeg,.webp"
disabled={uploading}
/>
</label>
<p className="text-xs text-oliver-black/60 mt-2">PDF, DOCX, PPTX, XLSX, HTML, TXT, MD, PNG, JPG, WebP</p>
</div>
{/* Process button + job status */}
<div className="flex items-center gap-4 mb-6">
<button
onClick={handleProcess}
disabled={!!isJobRunning || selectedKb.source_documents.length === 0}
className="bg-oliver-azure text-white font-semibold py-2.5 px-8 rounded-full hover:bg-oliver-azure/90 transition-colors disabled:bg-gray-400 disabled:cursor-not-allowed"
>
{isJobRunning ? 'Processing...' : 'Process Documents'}
</button>
{selectedKb.active_spec_version && (
<span className="text-sm text-oliver-black/60">
Active spec: v{selectedKb.active_spec_version} ({selectedKb.active_spec_char_count?.toLocaleString()} chars)
</span>
)}
</div>
{/* Processing progress */}
{isJobRunning && activeJob && (
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 mb-6">
<div className="flex items-center gap-3 mb-2">
<SpinnerIcon className="h-5 w-5 text-oliver-azure animate-spin" />
<span className="font-medium text-oliver-black">
{activeJob.status === 'parsing_documents' ? 'Parsing documents...' :
activeJob.status === 'distilling' ? 'Distilling spec with AI...' :
'Starting...'}
</span>
</div>
{activeJob.status === 'parsing_documents' && (
<div className="w-full bg-blue-100 rounded-full h-2">
<div
className="bg-oliver-azure h-2 rounded-full transition-all duration-500"
style={{ width: `${activeJob.total_documents > 0 ? (activeJob.parsed_documents / activeJob.total_documents) * 100 : 0}%` }}
/>
</div>
)}
<p className="text-sm text-oliver-black/60 mt-1">
{activeJob.parsed_documents} / {activeJob.total_documents} documents parsed
</p>
</div>
)}
{/* Last job result */}
{selectedKb.latest_job && !isJobRunning && (
<div className={`border rounded-xl p-3 mb-6 text-sm ${
selectedKb.latest_job.status === 'completed'
? 'bg-green-50 border-green-200 text-green-800'
: selectedKb.latest_job.status === 'failed'
? 'bg-red-50 border-red-200 text-red-800'
: 'bg-oliver-grey border-grey-300 text-oliver-black/60'
}`}>
Last processing: <StatusBadge status={selectedKb.latest_job.status} />
{selectedKb.latest_job.completed_at && <span className="ml-2">{formatDate(selectedKb.latest_job.completed_at)}</span>}
{selectedKb.latest_job.error_message && <p className="mt-1 text-sm">{selectedKb.latest_job.error_message}</p>}
</div>
)}
{/* Documents table */}
{selectedKb.source_documents.length > 0 ? (
<div className="bg-white rounded-xl border border-grey-300 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-oliver-grey text-left text-oliver-black/60">
<th className="px-4 py-3 font-medium">Filename</th>
<th className="px-4 py-3 font-medium">Size</th>
<th className="px-4 py-3 font-medium">Uploaded</th>
<th className="px-4 py-3 font-medium">Uploaded By</th>
<th className="px-4 py-3 font-medium">Parse Status</th>
<th className="px-4 py-3 font-medium w-12"></th>
</tr>
</thead>
<tbody className="divide-y divide-grey-200">
{selectedKb.source_documents.map(doc => (
<React.Fragment key={doc.id}>
<tr className="hover:bg-grey-50 transition-colors">
<td className="px-4 py-3 font-medium text-oliver-black">{doc.filename}</td>
<td className="px-4 py-3 text-oliver-black/60">{formatBytes(doc.file_size_bytes)}</td>
<td className="px-4 py-3 text-oliver-black/60">{formatDate(doc.created_at)}</td>
<td className="px-4 py-3 text-oliver-black/60">{doc.uploaded_by_name || '-'}</td>
<td className="px-4 py-3"><StatusBadge status={doc.parse_status} /></td>
<td className="px-4 py-3">
<button
onClick={() => handleRemoveDoc(doc.id)}
className="text-oliver-black/60 hover:text-error transition-colors"
title="Remove document"
>
<TrashIcon className="h-4 w-4" />
</button>
</td>
</tr>
{doc.parse_error && (doc.parse_status === 'partial' || doc.parse_status === 'error') && (
<tr>
<td colSpan={6} className="px-4 pb-3 pt-0">
<div className={`text-xs px-3 py-2 rounded-lg ${doc.parse_status === 'partial' ? 'bg-orange-50 text-orange-700' : 'bg-red-50 text-red-700'}`}>
{doc.parse_error}
</div>
</td>
</tr>
)}
</React.Fragment>
))}
</tbody>
</table>
</div>
) : (
<div className="text-center py-12 text-oliver-black/60 bg-oliver-grey rounded-xl">
No source documents uploaded yet.
</div>
)}
</div>
) : (
/* Versions tab */
<div>
{/* Compare button */}
{selectedForDiff.length === 2 && (
<div className="mb-4">
<button
onClick={handleCompare}
className="bg-oliver-azure text-white font-semibold py-2 px-6 rounded-full hover:bg-oliver-azure/90 transition-colors"
>
Compare Selected Versions
</button>
</div>
)}
{versions.length > 0 ? (
<div className="bg-white rounded-xl border border-grey-300 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-oliver-grey text-left text-oliver-black/60">
<th className="px-4 py-3 font-medium w-10">
<span className="sr-only">Compare</span>
</th>
<th className="px-4 py-3 font-medium">Version</th>
<th className="px-4 py-3 font-medium">Date</th>
<th className="px-4 py-3 font-medium">Generated By</th>
<th className="px-4 py-3 font-medium">Chars</th>
<th className="px-4 py-3 font-medium">Status</th>
<th className="px-4 py-3 font-medium">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-grey-200">
{versions.map(v => (
<tr key={v.id} className="hover:bg-grey-50 transition-colors">
<td className="px-4 py-3">
<input
type="checkbox"
checked={selectedForDiff.includes(v.id)}
onChange={() => handleDiffToggle(v.id)}
className="h-4 w-4 rounded border-grey-300 text-oliver-azure focus:ring-oliver-azure"
/>
</td>
<td className="px-4 py-3 font-medium text-oliver-black">v{v.version_number}</td>
<td className="px-4 py-3 text-oliver-black/60">{formatDate(v.created_at)}</td>
<td className="px-4 py-3 text-oliver-black/60">{v.generated_by_name || '-'}</td>
<td className="px-4 py-3 text-oliver-black/60">{v.char_count.toLocaleString()}</td>
<td className="px-4 py-3">
{v.is_active ? (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
Active
</span>
) : (
<span className="text-oliver-black/60 text-xs">Inactive</span>
)}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<button
onClick={() => handleViewSpec(v.id)}
className="text-oliver-azure hover:underline text-xs font-medium"
>
View
</button>
{!v.is_active && (
<button
onClick={() => handleActivateVersion(v.id)}
className="text-amber-600 hover:underline text-xs font-medium"
>
Activate
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="text-center py-12 text-oliver-black/60 bg-oliver-grey rounded-xl">
No spec versions generated yet.
</div>
)}
</div>
)}
</div>
);
}
// Level 1: Agent list
if (loading) {
return (
<div className="p-8 flex items-center justify-center min-h-[400px]">
<SpinnerIcon className="h-8 w-8 text-oliver-azure animate-spin" />
</div>
);
}
return (
<div className="p-8 max-w-6xl mx-auto w-full">
<div className="mb-8">
<h1 className="text-2xl font-bold text-oliver-black">Knowledge Base</h1>
<p className="text-oliver-black/60 mt-1">Manage the AI agent knowledge bases. Upload source documents, process them, and version the resulting specs.</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{knowledgeBases.map(kb => {
const statusColor =
kb.latest_job_status === 'failed' ? 'border-red-300 bg-red-50' :
kb.latest_job_status === 'completed' ? 'border-grey-300 bg-white' :
'border-grey-300 bg-white';
return (
<button
key={kb.id}
onClick={() => handleSelectKb(kb)}
className={`text-left p-6 rounded-xl border-2 ${statusColor} hover:border-oliver-azure hover:shadow-md transition-all duration-200 group`}
>
<h3 className="text-lg font-semibold text-oliver-black group-hover:text-oliver-azure transition-colors">
{kb.display_name}
</h3>
{kb.description && (
<p className="text-sm text-oliver-black/60 mt-1 line-clamp-2">{kb.description}</p>
)}
<div className="mt-4 space-y-1 text-sm text-oliver-black/60">
<div className="flex justify-between">
<span>Source Documents</span>
<span className="font-medium text-oliver-black">{kb.source_document_count}</span>
</div>
<div className="flex justify-between">
<span>Active Spec</span>
<span className="font-medium text-oliver-black">
{kb.active_spec_version ? `v${kb.active_spec_version}` : 'None'}
</span>
</div>
{kb.latest_job_status && (
<div className="flex justify-between items-center">
<span>Last Job</span>
<StatusBadge status={kb.latest_job_status} />
</div>
)}
{kb.latest_job_completed_at && (
<div className="text-xs text-oliver-black/60 text-right">
{formatDate(kb.latest_job_completed_at)}
</div>
)}
</div>
</button>
);
})}
</div>
</div>
);
};