1154 lines
52 KiB
TypeScript
1154 lines
52 KiB
TypeScript
import React, { useState, useMemo, useRef } from 'react';
|
||
import {
|
||
Clock,
|
||
Plus,
|
||
FileText,
|
||
Calendar,
|
||
Users,
|
||
Award,
|
||
Gavel,
|
||
ShieldAlert,
|
||
Search,
|
||
Filter,
|
||
Paperclip,
|
||
MapPin,
|
||
User,
|
||
Edit2,
|
||
Trash2,
|
||
Download,
|
||
X,
|
||
Tag,
|
||
ArrowUpDown,
|
||
Sparkles,
|
||
ExternalLink,
|
||
Zap,
|
||
ChevronLeft,
|
||
ChevronRight,
|
||
Maximize2,
|
||
Minimize2,
|
||
Info,
|
||
List,
|
||
GitCommit,
|
||
CheckCircle2,
|
||
RotateCcw
|
||
} from 'lucide-react';
|
||
import { ProjectStore, TimelineEvent, TimelineEventType, UserRole, Attachment } from '../types';
|
||
import { AttachmentUploader } from './AttachmentUploader';
|
||
import { generateDynamicTimeline } from '../utils/timelineGenerator';
|
||
|
||
interface TimelineViewProps {
|
||
store: ProjectStore;
|
||
onUpdateStore: (newStore: ProjectStore) => void;
|
||
userRole?: UserRole;
|
||
currentUserName?: string;
|
||
onNavigateTab?: (tab: string) => void;
|
||
}
|
||
|
||
export const TimelineView: React.FC<TimelineViewProps> = ({
|
||
store,
|
||
onUpdateStore,
|
||
userRole = 'viewer',
|
||
currentUserName = 'System User',
|
||
onNavigateTab
|
||
}) => {
|
||
const canEdit = userRole === 'admin' || userRole === 'editor';
|
||
|
||
// Automatically and dynamically generate the unified timeline & circular registry from all portal updates
|
||
const dynamicTimelineEvents = useMemo(() => {
|
||
return generateDynamicTimeline(store);
|
||
}, [store]);
|
||
|
||
// View Mode: 'flow' (Horizontal Infographic Flow like Slide Deck), 'feed' (Vertical Detailed Feed), or 'circulars' (Table)
|
||
const [activeViewMode, setActiveViewMode] = useState<'flow' | 'feed' | 'circulars'>('flow');
|
||
|
||
// Selected Event Modal Window State (Replaces confinement-prone hover balloons)
|
||
const [selectedDetailEvent, setSelectedDetailEvent] = useState<TimelineEvent | null>(null);
|
||
|
||
// Search, Type, Source & Date Skip Filters
|
||
const [searchQuery, setSearchQuery] = useState('');
|
||
const [typeFilter, setTypeFilter] = useState<string>('all');
|
||
const [sourceFilter, setSourceFilter] = useState<string>('all');
|
||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc'); // Chronological order
|
||
const [fromDateFilter, setFromDateFilter] = useState<string>(''); // Date skip onwards (e.g. "2026-05-01")
|
||
const [selectedQuickMonth, setSelectedQuickMonth] = useState<string>('all');
|
||
|
||
// Manual Event Creation/Editing Modal State
|
||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||
const [editingEvent, setEditingEvent] = useState<Partial<TimelineEvent> | null>(null);
|
||
|
||
// Scroll Container Ref for Horizontal Flow
|
||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||
|
||
// Modal Form Inputs
|
||
const [title, setTitle] = useState('');
|
||
const [eventType, setEventType] = useState<TimelineEventType>('circular');
|
||
const [category, setCategory] = useState('');
|
||
const [date, setDate] = useState(new Date().toISOString().split('T')[0]);
|
||
const [time, setTime] = useState('10:00');
|
||
const [referenceNumber, setReferenceNumber] = useState('');
|
||
const [location, setLocation] = useState('');
|
||
const [author, setAuthor] = useState(currentUserName);
|
||
const [description, setDescription] = useState('');
|
||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||
|
||
// Open modal to create a new manual event or circular
|
||
const handleOpenCreateModal = () => {
|
||
setEditingEvent(null);
|
||
setTitle('');
|
||
setEventType('circular');
|
||
setCategory('Official Governance Circular');
|
||
setDate(new Date().toISOString().split('T')[0]);
|
||
setTime('10:00');
|
||
setReferenceNumber(`PIN-${new Date().getFullYear()}-${Math.floor(100 + Math.random() * 900)}`);
|
||
setLocation('Executive Office');
|
||
setAuthor(currentUserName);
|
||
setDescription('');
|
||
setAttachments([]);
|
||
setIsModalOpen(true);
|
||
};
|
||
|
||
// Open modal to edit existing manual event
|
||
const handleOpenEditModal = (event: TimelineEvent) => {
|
||
setEditingEvent(event);
|
||
setTitle(event.title);
|
||
setEventType(event.type);
|
||
setCategory(event.category || '');
|
||
setDate(event.date);
|
||
setTime(event.time || '');
|
||
setReferenceNumber(event.referenceNumber || '');
|
||
setLocation(event.location || '');
|
||
setAuthor(event.author || currentUserName);
|
||
setDescription(event.description);
|
||
setAttachments(event.attachments || []);
|
||
setIsModalOpen(true);
|
||
};
|
||
|
||
// Save manual event
|
||
const handleSaveEvent = (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (!title.trim() || !date) return;
|
||
|
||
const currentManualEvents = store.timelineEvents || [];
|
||
let updatedManualEvents = [...currentManualEvents];
|
||
|
||
if (editingEvent && editingEvent.id) {
|
||
updatedManualEvents = updatedManualEvents.map((evt) =>
|
||
String(evt.id) === String(editingEvent.id)
|
||
? {
|
||
...evt,
|
||
title: title.trim(),
|
||
type: eventType,
|
||
category: category.trim() || undefined,
|
||
date,
|
||
time: time.trim() || undefined,
|
||
referenceNumber: referenceNumber.trim() || undefined,
|
||
location: location.trim() || undefined,
|
||
author: author.trim() || currentUserName,
|
||
description: description.trim(),
|
||
attachments
|
||
}
|
||
: evt
|
||
);
|
||
} else {
|
||
const nextId = (store.ids.timelineEvent || currentManualEvents.length + 1) + 1;
|
||
const newEvt: TimelineEvent = {
|
||
id: nextId,
|
||
title: title.trim(),
|
||
type: eventType,
|
||
category: category.trim() || getTypeLabel(eventType),
|
||
date,
|
||
time: time.trim() || undefined,
|
||
referenceNumber: referenceNumber.trim() || undefined,
|
||
location: location.trim() || undefined,
|
||
author: author.trim() || currentUserName,
|
||
description: description.trim(),
|
||
attachments,
|
||
createdAt: new Date().toISOString(),
|
||
isAutoGenerated: false,
|
||
sourceType: 'manual'
|
||
};
|
||
|
||
updatedManualEvents.push(newEvt);
|
||
}
|
||
|
||
onUpdateStore({
|
||
...store,
|
||
timelineEvents: updatedManualEvents,
|
||
ids: {
|
||
...store.ids,
|
||
timelineEvent: (store.ids.timelineEvent || currentManualEvents.length + 1) + 1
|
||
},
|
||
logs: [
|
||
{
|
||
id: store.ids.log || Date.now(),
|
||
action: editingEvent ? 'TIMELINE_EVENT_UPDATED' : 'TIMELINE_EVENT_CREATED',
|
||
type: 'timeline',
|
||
details: `${editingEvent ? 'Updated' : 'Created'} timeline publication: ${title.trim()} (${referenceNumber || eventType})`,
|
||
user: currentUserName,
|
||
timestamp: new Date().toISOString()
|
||
},
|
||
...store.logs
|
||
]
|
||
});
|
||
|
||
setIsModalOpen(false);
|
||
};
|
||
|
||
// Delete manual event
|
||
const handleDeleteManualEvent = (id: string | number) => {
|
||
if (!window.confirm('Are you sure you want to delete this custom timeline entry?')) return;
|
||
|
||
const currentManualEvents = store.timelineEvents || [];
|
||
const updatedManualEvents = currentManualEvents.filter((e) => String(e.id) !== String(id));
|
||
|
||
onUpdateStore({
|
||
...store,
|
||
timelineEvents: updatedManualEvents,
|
||
logs: [
|
||
{
|
||
id: store.ids.log || Date.now(),
|
||
action: 'TIMELINE_EVENT_DELETED',
|
||
type: 'timeline',
|
||
details: `Deleted manual timeline entry ID #${id}`,
|
||
user: currentUserName,
|
||
timestamp: new Date().toISOString()
|
||
},
|
||
...store.logs
|
||
]
|
||
});
|
||
};
|
||
|
||
// Unique list of available Months for Quick-Jump Picker
|
||
const availableMonths = useMemo(() => {
|
||
const monthSet = new Map<string, string>(); // key YYYY-MM, label e.g. "May 2026"
|
||
dynamicTimelineEvents.forEach((e) => {
|
||
if (e.date && e.date.length >= 7) {
|
||
const ym = e.date.substring(0, 7);
|
||
const [y, m] = ym.split('-');
|
||
if (y && m) {
|
||
const dateObj = new Date(parseInt(y), parseInt(m) - 1, 1);
|
||
const label = dateObj.toLocaleDateString('en-US', { month: 'short', year: 'numeric' });
|
||
monthSet.set(ym, label);
|
||
}
|
||
}
|
||
});
|
||
return Array.from(monthSet.entries()).sort((a, b) => a[0].localeCompare(b[0]));
|
||
}, [dynamicTimelineEvents]);
|
||
|
||
// Filter & Sort Dynamic Timeline Events chronologically based on set date & skip filters
|
||
const filteredEvents = useMemo(() => {
|
||
return dynamicTimelineEvents
|
||
.filter((evt) => {
|
||
if (activeViewMode === 'circulars') {
|
||
const isCirc =
|
||
evt.type === 'circular' ||
|
||
(evt.category && evt.category.toLowerCase().includes('circular')) ||
|
||
(evt.referenceNumber && evt.referenceNumber.toLowerCase().includes('pin-')) ||
|
||
evt.title.toLowerCase().includes('circular');
|
||
if (!isCirc) return false;
|
||
}
|
||
|
||
if (typeFilter !== 'all') {
|
||
if (typeFilter === 'circular' && evt.type !== 'circular') return false;
|
||
if (typeFilter === 'meeting' && evt.type !== 'meeting') return false;
|
||
if (typeFilter === 'decision' && evt.type !== 'decision') return false;
|
||
if (typeFilter === 'milestone' && evt.type !== 'milestone') return false;
|
||
if (typeFilter === 'audit' && evt.type !== 'audit') return false;
|
||
}
|
||
|
||
if (sourceFilter !== 'all') {
|
||
if (sourceFilter === 'manual' && evt.isAutoGenerated) return false;
|
||
if (sourceFilter === 'auto' && !evt.isAutoGenerated) return false;
|
||
if (evt.sourceTab && evt.sourceTab !== sourceFilter && sourceFilter !== 'manual' && sourceFilter !== 'auto') {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// Date Skip Onwards Filter
|
||
if (fromDateFilter && evt.date < fromDateFilter) {
|
||
return false;
|
||
}
|
||
|
||
// Quick Month Filter
|
||
if (selectedQuickMonth !== 'all') {
|
||
if (!evt.date.startsWith(selectedQuickMonth)) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
if (searchQuery.trim()) {
|
||
const q = searchQuery.toLowerCase();
|
||
const matchTitle = evt.title.toLowerCase().includes(q);
|
||
const matchDesc = evt.description.toLowerCase().includes(q);
|
||
const matchRef = evt.referenceNumber ? evt.referenceNumber.toLowerCase().includes(q) : false;
|
||
const matchCat = evt.category ? evt.category.toLowerCase().includes(q) : false;
|
||
const matchAuthor = evt.author ? evt.author.toLowerCase().includes(q) : false;
|
||
const matchLoc = evt.location ? evt.location.toLowerCase().includes(q) : false;
|
||
const matchAtt = evt.attachments ? evt.attachments.some((a) => a.name.toLowerCase().includes(q)) : false;
|
||
|
||
return matchTitle || matchDesc || matchRef || matchCat || matchAuthor || matchLoc || matchAtt;
|
||
}
|
||
|
||
return true;
|
||
})
|
||
.sort((a, b) => {
|
||
const timeA = a.time || '00:00';
|
||
const timeB = b.time || '00:00';
|
||
const dateA = new Date(`${a.date}T${timeA}`).getTime();
|
||
const dateB = new Date(`${b.date}T${timeB}`).getTime();
|
||
|
||
if (isNaN(dateA) || isNaN(dateB)) return 0;
|
||
return sortOrder === 'asc' ? dateA - dateB : dateB - dateA;
|
||
});
|
||
}, [
|
||
dynamicTimelineEvents,
|
||
activeViewMode,
|
||
typeFilter,
|
||
sourceFilter,
|
||
fromDateFilter,
|
||
selectedQuickMonth,
|
||
searchQuery,
|
||
sortOrder
|
||
]);
|
||
|
||
// Scroll to Specific Event Node in Horizontal Flow
|
||
const handleJumpToEvent = (eventId: string | number) => {
|
||
const nodeElem = document.getElementById(`timeline-node-${eventId}`);
|
||
if (nodeElem) {
|
||
nodeElem.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
|
||
setPinnedEventId(eventId);
|
||
}
|
||
};
|
||
|
||
// Horizontal Scroll Controls
|
||
const handleScrollLeft = () => {
|
||
if (scrollContainerRef.current) {
|
||
scrollContainerRef.current.scrollBy({ left: -360, behavior: 'smooth' });
|
||
}
|
||
};
|
||
|
||
const handleScrollRight = () => {
|
||
if (scrollContainerRef.current) {
|
||
scrollContainerRef.current.scrollBy({ left: 360, behavior: 'smooth' });
|
||
}
|
||
};
|
||
|
||
// Analytics Metrics
|
||
const autoGeneratedCount = dynamicTimelineEvents.filter((e) => e.isAutoGenerated).length;
|
||
const manualCount = dynamicTimelineEvents.filter((e) => !e.isAutoGenerated).length;
|
||
const circularsCount = dynamicTimelineEvents.filter(
|
||
(e) =>
|
||
e.type === 'circular' ||
|
||
(e.category && e.category.toLowerCase().includes('circular')) ||
|
||
(e.referenceNumber && e.referenceNumber.toLowerCase().includes('pin-'))
|
||
).length;
|
||
const meetingsCount = dynamicTimelineEvents.filter((e) => e.type === 'meeting').length;
|
||
const decisionsCount = dynamicTimelineEvents.filter((e) => e.type === 'decision').length;
|
||
const docsCount = dynamicTimelineEvents.reduce((acc, e) => acc + (e.attachments ? e.attachments.length : 0), 0);
|
||
|
||
// Helper Badge Colors & Formatting
|
||
function getTypeBadge(type: TimelineEventType) {
|
||
switch (type) {
|
||
case 'circular':
|
||
return 'bg-cyan-500/10 text-cyan-400 border-cyan-500/30';
|
||
case 'meeting':
|
||
return 'bg-amber-500/10 text-amber-400 border-amber-500/30';
|
||
case 'milestone':
|
||
return 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30';
|
||
case 'decision':
|
||
return 'bg-purple-500/10 text-purple-400 border-purple-500/30';
|
||
case 'release':
|
||
case 'audit':
|
||
return 'bg-rose-500/10 text-rose-400 border-rose-500/30';
|
||
default:
|
||
return 'bg-slate-800 text-slate-300 border-slate-700';
|
||
}
|
||
}
|
||
|
||
function getTypeIcon(type: TimelineEventType) {
|
||
switch (type) {
|
||
case 'circular':
|
||
return <FileText className="w-3.5 h-3.5 text-cyan-400" />;
|
||
case 'meeting':
|
||
return <Users className="w-3.5 h-3.5 text-amber-400" />;
|
||
case 'milestone':
|
||
return <Award className="w-3.5 h-3.5 text-emerald-400" />;
|
||
case 'decision':
|
||
return <Gavel className="w-3.5 h-3.5 text-purple-400" />;
|
||
case 'release':
|
||
case 'audit':
|
||
return <ShieldAlert className="w-3.5 h-3.5 text-rose-400" />;
|
||
default:
|
||
return <Clock className="w-3.5 h-3.5 text-slate-400" />;
|
||
}
|
||
}
|
||
|
||
function getTypeLabel(type: TimelineEventType) {
|
||
switch (type) {
|
||
case 'circular':
|
||
return 'Governance Circular';
|
||
case 'meeting':
|
||
return 'Committee Session';
|
||
case 'milestone':
|
||
return 'Project Milestone';
|
||
case 'decision':
|
||
return 'Executive Decision';
|
||
case 'release':
|
||
return 'Official Release';
|
||
case 'audit':
|
||
return 'Risk & Audit Alert';
|
||
default:
|
||
return 'Portal Update';
|
||
}
|
||
}
|
||
|
||
function getSourceLabel(sourceTab?: string, isAuto?: boolean) {
|
||
if (!isAuto) return 'Manual Publication';
|
||
switch (sourceTab) {
|
||
case 'meetings':
|
||
return 'Meetings Register';
|
||
case 'decisions':
|
||
return 'Executive Decisions';
|
||
case 'phases':
|
||
return 'Phase Deliverables';
|
||
case 'risks':
|
||
return 'Risk Register';
|
||
default:
|
||
return 'Portal Activity';
|
||
}
|
||
}
|
||
|
||
// Helper to format date string nicely (e.g. "May 13, 2026")
|
||
function formatDateNice(dateStr: string) {
|
||
try {
|
||
const [y, m, d] = dateStr.split('-');
|
||
if (!y || !m || !d) return dateStr;
|
||
const dateObj = new Date(parseInt(y), parseInt(m) - 1, parseInt(d));
|
||
return dateObj.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||
} catch {
|
||
return dateStr;
|
||
}
|
||
}
|
||
|
||
// Extract caption for node display (truncated only after 50 words)
|
||
function getTruncatedCaption(description: string, maxWords: number = 50) {
|
||
if (!description) return '';
|
||
const words = description.trim().split(/\s+/);
|
||
if (words.length <= maxWords) return description;
|
||
return words.slice(0, maxWords).join(' ') + '...';
|
||
}
|
||
|
||
return (
|
||
<div className="w-full max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 space-y-6 min-w-0">
|
||
{/* Page Header */}
|
||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 bg-slate-900 border border-slate-800/80 p-6 rounded-3xl shadow-xl">
|
||
<div className="space-y-2">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<div className="p-2.5 bg-amber-500/10 border border-amber-500/30 rounded-2xl text-amber-400">
|
||
<Clock className="w-6 h-6" />
|
||
</div>
|
||
<div>
|
||
<div className="flex items-center space-x-2">
|
||
<h1 className="text-xl font-bold text-slate-100">Project Flow – Interactive Timeline</h1>
|
||
<span className="px-2.5 py-0.5 bg-amber-500/10 text-amber-400 border border-amber-500/30 rounded-full text-[10px] font-bold flex items-center space-x-1">
|
||
<Zap className="w-3 h-3 text-amber-400 animate-pulse" />
|
||
<span>Auto-Generated</span>
|
||
</span>
|
||
</div>
|
||
<p className="text-xs text-slate-400 mt-0.5">
|
||
Compact presentation-grade timeline flow. Hover or click any node for details.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex flex-wrap items-center gap-2.5 self-start md:self-auto shrink-0">
|
||
{/* View Mode Switcher */}
|
||
<div className="bg-slate-950 p-1 rounded-xl border border-slate-800 flex items-center space-x-1 text-xs">
|
||
<button
|
||
onClick={() => setActiveViewMode('flow')}
|
||
className={`px-3 py-1.5 rounded-lg font-bold flex items-center space-x-1.5 transition cursor-pointer ${
|
||
activeViewMode === 'flow'
|
||
? 'bg-amber-500 text-slate-950 shadow'
|
||
: 'text-slate-400 hover:text-slate-200'
|
||
}`}
|
||
>
|
||
<GitCommit className="w-3.5 h-3.5" />
|
||
<span>Process Flow</span>
|
||
</button>
|
||
<button
|
||
onClick={() => setActiveViewMode('feed')}
|
||
className={`px-3 py-1.5 rounded-lg font-bold flex items-center space-x-1.5 transition cursor-pointer ${
|
||
activeViewMode === 'feed'
|
||
? 'bg-amber-500 text-slate-950 shadow'
|
||
: 'text-slate-400 hover:text-slate-200'
|
||
}`}
|
||
>
|
||
<List className="w-3.5 h-3.5" />
|
||
<span>Detailed Feed</span>
|
||
</button>
|
||
<button
|
||
onClick={() => setActiveViewMode('circulars')}
|
||
className={`px-3 py-1.5 rounded-lg font-bold flex items-center space-x-1.5 transition cursor-pointer ${
|
||
activeViewMode === 'circulars'
|
||
? 'bg-cyan-500 text-slate-950 shadow'
|
||
: 'text-slate-400 hover:text-slate-200'
|
||
}`}
|
||
>
|
||
<FileText className="w-3.5 h-3.5" />
|
||
<span>Circulars</span>
|
||
<span className="px-1.5 py-0.2 bg-slate-900/60 text-[10px] rounded font-mono">{circularsCount}</span>
|
||
</button>
|
||
</div>
|
||
|
||
{canEdit && (
|
||
<button
|
||
onClick={handleOpenCreateModal}
|
||
className="px-4 py-2 bg-amber-500 hover:bg-amber-400 text-slate-950 font-bold rounded-xl text-xs flex items-center justify-center space-x-1.5 shadow-lg transition cursor-pointer"
|
||
>
|
||
<Plus className="w-4 h-4" />
|
||
<span>Create Event / Circular</span>
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Analytics Summary Cards */}
|
||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3.5">
|
||
<div className="bg-slate-900 border border-slate-800 p-4 rounded-2xl space-y-1">
|
||
<div className="flex items-center justify-between text-xs text-slate-400">
|
||
<span>Total Timeline Events</span>
|
||
<Clock className="w-4 h-4 text-amber-400" />
|
||
</div>
|
||
<div className="text-2xl font-extrabold text-slate-100">{dynamicTimelineEvents.length}</div>
|
||
<div className="text-[10px] text-slate-500 flex items-center space-x-1">
|
||
<span className="text-amber-400 font-semibold">{autoGeneratedCount} auto</span>
|
||
<span>•</span>
|
||
<span>{manualCount} manual</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-slate-900 border border-slate-800 p-4 rounded-2xl space-y-1">
|
||
<div className="flex items-center justify-between text-xs text-slate-400">
|
||
<span>Circulars Issued</span>
|
||
<FileText className="w-4 h-4 text-cyan-400" />
|
||
</div>
|
||
<div className="text-2xl font-extrabold text-cyan-400">{circularsCount}</div>
|
||
<div className="text-[10px] text-slate-500">Governance & directive publications</div>
|
||
</div>
|
||
|
||
<div className="bg-slate-900 border border-slate-800 p-4 rounded-2xl space-y-1">
|
||
<div className="flex items-center justify-between text-xs text-slate-400">
|
||
<span>Committee Sessions</span>
|
||
<Users className="w-4 h-4 text-amber-400" />
|
||
</div>
|
||
<div className="text-2xl font-extrabold text-amber-400">{meetingsCount}</div>
|
||
<div className="text-[10px] text-slate-500">Meetings & committee minutes</div>
|
||
</div>
|
||
|
||
<div className="bg-slate-900 border border-slate-800 p-4 rounded-2xl space-y-1">
|
||
<div className="flex items-center justify-between text-xs text-slate-400">
|
||
<span>Executive Decisions</span>
|
||
<Gavel className="w-4 h-4 text-purple-400" />
|
||
</div>
|
||
<div className="text-2xl font-extrabold text-purple-400">{decisionsCount}</div>
|
||
<div className="text-[10px] text-slate-500">Board & PMT resolutions</div>
|
||
</div>
|
||
|
||
<div className="bg-slate-900 border border-slate-800 p-4 rounded-2xl col-span-2 md:col-span-1 space-y-1">
|
||
<div className="flex items-center justify-between text-xs text-slate-400">
|
||
<span>Attachments & PDFs</span>
|
||
<Paperclip className="w-4 h-4 text-emerald-400" />
|
||
</div>
|
||
<div className="text-2xl font-extrabold text-emerald-400">{docsCount}</div>
|
||
<div className="text-[10px] text-slate-500">Attached files & documents</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* FILTER & SKIP-TO-DATE CONTROL PANEL */}
|
||
<div className="bg-slate-900 border border-slate-800 p-4 rounded-2xl space-y-3 shadow-lg">
|
||
{/* Row 1: Search, Type Badges & Source Dropdown */}
|
||
<div className="flex flex-col md:flex-row gap-3 items-center justify-between">
|
||
<div className="relative w-full md:w-80">
|
||
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
|
||
<input
|
||
type="text"
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
placeholder="Search reference, title, author, attachments..."
|
||
className="w-full bg-slate-950 border border-slate-800 rounded-xl pl-9 pr-3 py-2 text-xs text-slate-200 focus:outline-none focus:border-amber-500 transition"
|
||
/>
|
||
{searchQuery && (
|
||
<button
|
||
onClick={() => setSearchQuery('')}
|
||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-500 hover:text-slate-300"
|
||
>
|
||
<X className="w-3.5 h-3.5" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex items-center space-x-1 overflow-x-auto w-full md:w-auto pb-1 md:pb-0 text-xs">
|
||
{[
|
||
{ id: 'all', label: 'All Updates' },
|
||
{ id: 'circular', label: 'Circulars' },
|
||
{ id: 'meeting', label: 'Meetings' },
|
||
{ id: 'decision', label: 'Decisions' },
|
||
{ id: 'milestone', label: 'Milestones' },
|
||
{ id: 'audit', label: 'Risks / Audits' }
|
||
].map((tab) => (
|
||
<button
|
||
key={tab.id}
|
||
onClick={() => setTypeFilter(tab.id)}
|
||
className={`px-3 py-1.5 rounded-lg font-medium whitespace-nowrap transition cursor-pointer ${
|
||
typeFilter === tab.id
|
||
? 'bg-amber-500 text-slate-950 font-bold shadow'
|
||
: 'bg-slate-950 text-slate-400 hover:bg-slate-800 hover:text-slate-200 border border-slate-800'
|
||
}`}
|
||
>
|
||
{tab.label}
|
||
</button>
|
||
))}
|
||
|
||
<select
|
||
value={sourceFilter}
|
||
onChange={(e) => setSourceFilter(e.target.value)}
|
||
className="px-2.5 py-1.5 bg-slate-950 text-slate-300 rounded-lg border border-slate-800 font-medium text-xs focus:outline-none focus:border-amber-500 ml-2 cursor-pointer"
|
||
>
|
||
<option value="all">All Sources</option>
|
||
<option value="auto">Auto-Generated</option>
|
||
<option value="manual">Manual</option>
|
||
<option value="meetings">Meetings Tab</option>
|
||
<option value="decisions">Decisions Tab</option>
|
||
<option value="phases">Phases Tab</option>
|
||
<option value="risks">Risks Tab</option>
|
||
</select>
|
||
|
||
<button
|
||
onClick={() => setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')}
|
||
className="px-3 py-1.5 bg-slate-950 hover:bg-slate-800 text-slate-300 rounded-lg border border-slate-800 flex items-center space-x-1 font-medium transition cursor-pointer ml-auto shrink-0"
|
||
title="Sort Order based on Set Date"
|
||
>
|
||
<ArrowUpDown className="w-3.5 h-3.5 text-amber-400" />
|
||
<span className="text-[11px] font-semibold">{sortOrder === 'asc' ? 'Oldest First' : 'Newest First'}</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Row 2: SKIP TO DATE & QUICK MONTH JUMP PANEL */}
|
||
<div className="pt-2 border-t border-slate-800/80 flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3 text-xs">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<div className="flex items-center space-x-1.5 text-amber-400 font-semibold bg-amber-500/10 px-2.5 py-1 rounded-lg border border-amber-500/20">
|
||
<Calendar className="w-3.5 h-3.5 text-amber-400" />
|
||
<span>Skip / Onwards Date:</span>
|
||
</div>
|
||
|
||
{/* Skip Date Onwards Input */}
|
||
<input
|
||
type="date"
|
||
value={fromDateFilter}
|
||
onChange={(e) => setFromDateFilter(e.target.value)}
|
||
className="bg-slate-950 border border-slate-800 text-slate-200 rounded-lg px-2.5 py-1 text-xs focus:outline-none focus:border-amber-500 cursor-pointer"
|
||
title="Filter events on or after this date"
|
||
/>
|
||
|
||
{/* Quick Month Filter Chips */}
|
||
<div className="flex items-center space-x-1 overflow-x-auto max-w-full">
|
||
<button
|
||
onClick={() => {
|
||
setSelectedQuickMonth('all');
|
||
setFromDateFilter('');
|
||
}}
|
||
className={`px-2.5 py-1 rounded-lg text-[11px] font-medium transition cursor-pointer whitespace-nowrap ${
|
||
selectedQuickMonth === 'all' && !fromDateFilter
|
||
? 'bg-amber-500/20 text-amber-300 border border-amber-500/40 font-bold'
|
||
: 'bg-slate-950 text-slate-400 hover:text-slate-200 border border-slate-800'
|
||
}`}
|
||
>
|
||
All Dates
|
||
</button>
|
||
{availableMonths.map(([ym, label]) => (
|
||
<button
|
||
key={ym}
|
||
onClick={() => {
|
||
setSelectedQuickMonth(ym);
|
||
setFromDateFilter('');
|
||
}}
|
||
className={`px-2.5 py-1 rounded-lg text-[11px] font-medium transition cursor-pointer whitespace-nowrap ${
|
||
selectedQuickMonth === ym
|
||
? 'bg-amber-500/20 text-amber-300 border border-amber-500/40 font-bold'
|
||
: 'bg-slate-950 text-slate-400 hover:text-slate-200 border border-slate-800'
|
||
}`}
|
||
>
|
||
{label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{(fromDateFilter || selectedQuickMonth !== 'all') && (
|
||
<button
|
||
onClick={() => {
|
||
setFromDateFilter('');
|
||
setSelectedQuickMonth('all');
|
||
}}
|
||
className="px-2 py-1 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-[10px] flex items-center space-x-1 transition cursor-pointer"
|
||
title="Reset Date Skip Filter"
|
||
>
|
||
<RotateCcw className="w-3 h-3 text-amber-400" />
|
||
<span>Reset Date Skip</span>
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* VIEW 1: INFOGRAPHIC PROCESS FLOW (BOXED LAYOUT WITH MODAL DETAILS) */}
|
||
{activeViewMode === 'flow' && (
|
||
<div className="bg-slate-900 border border-slate-800 rounded-3xl p-6 shadow-2xl space-y-6 relative overflow-hidden">
|
||
{/* Controls Bar for Horizontal Timeline */}
|
||
<div className="flex items-center justify-between text-xs text-slate-400 border-b border-slate-800/80 pb-3">
|
||
<div className="flex items-center space-x-2">
|
||
<Sparkles className="w-4 h-4 text-emerald-400" />
|
||
<span className="font-bold text-slate-200">Interactive Timeline Flow</span>
|
||
<span className="text-slate-500">
|
||
({filteredEvents.length} chronological milestones • Click any node or card to view modal)
|
||
</span>
|
||
</div>
|
||
|
||
<div className="flex items-center space-x-2">
|
||
<button
|
||
onClick={handleScrollLeft}
|
||
className="p-1.5 bg-slate-950 hover:bg-slate-800 border border-slate-800 text-slate-300 rounded-xl transition cursor-pointer"
|
||
title="Scroll Left"
|
||
>
|
||
<ChevronLeft className="w-4 h-4" />
|
||
</button>
|
||
<button
|
||
onClick={handleScrollRight}
|
||
className="p-1.5 bg-slate-950 hover:bg-slate-800 border border-slate-800 text-slate-300 rounded-xl transition cursor-pointer"
|
||
title="Scroll Right"
|
||
>
|
||
<ChevronRight className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{filteredEvents.length === 0 ? (
|
||
<div className="py-16 text-center space-y-3">
|
||
<div className="w-12 h-12 rounded-2xl bg-slate-800 text-slate-500 mx-auto flex items-center justify-center">
|
||
<Clock className="w-6 h-6" />
|
||
</div>
|
||
<div className="text-sm font-semibold text-slate-300">No events match your selected date or filters</div>
|
||
{(fromDateFilter || selectedQuickMonth !== 'all') && (
|
||
<button
|
||
onClick={() => {
|
||
setFromDateFilter('');
|
||
setSelectedQuickMonth('all');
|
||
}}
|
||
className="px-4 py-1.5 bg-amber-500 text-slate-950 font-bold rounded-xl text-xs shadow inline-block cursor-pointer"
|
||
>
|
||
Clear Date Filters
|
||
</button>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div
|
||
ref={scrollContainerRef}
|
||
className="relative overflow-x-auto scrollbar-thin scrollbar-thumb-slate-800 scrollbar-track-transparent pt-52 pb-52 px-8 min-h-[540px]"
|
||
>
|
||
<div className="relative flex items-center space-x-32 min-w-max py-4 z-10">
|
||
{/* Central Vector Arrow Line spanning full scroll width */}
|
||
<div className="absolute top-1/2 left-0 right-4 -translate-y-1/2 h-1 bg-gradient-to-r from-emerald-500/60 via-amber-500/80 to-emerald-400/90 rounded-full z-0 pointer-events-none" />
|
||
{/* Arrow Head */}
|
||
<div className="absolute top-1/2 right-0 -translate-y-1/2 text-emerald-400 z-0 pointer-events-none">
|
||
<ChevronRight className="w-7 h-7 -ml-3 stroke-[3]" />
|
||
</div>
|
||
|
||
{filteredEvents.map((evt, idx) => {
|
||
const isTop = idx % 2 === 0;
|
||
const formattedDate = formatDateNice(evt.date);
|
||
const caption = getTruncatedCaption(evt.description, 50);
|
||
|
||
return (
|
||
<div
|
||
key={evt.id}
|
||
id={`timeline-node-${evt.id}`}
|
||
className="relative flex flex-col items-center shrink-0 w-80 group cursor-pointer"
|
||
onClick={() => setSelectedDetailEvent(evt)}
|
||
>
|
||
{/* TOP CALLOUT CARD (For Even Index) */}
|
||
{isTop && (
|
||
<div className="absolute bottom-12 left-1/2 -translate-x-1/2 w-80 text-center space-y-1 bg-slate-950/95 border border-slate-800 group-hover:border-amber-500/80 p-4 rounded-2xl shadow-xl transition-all duration-200 transform group-hover:-translate-y-1.5 group-hover:bg-slate-900">
|
||
<div className="text-xs font-bold text-emerald-400 tracking-wide flex items-center justify-center space-x-1">
|
||
<span>{formattedDate}</span>
|
||
</div>
|
||
<div className="text-xs font-bold text-slate-100 group-hover:text-amber-400 transition leading-snug">{evt.title}</div>
|
||
{caption && <div className="text-[11px] text-slate-400 italic leading-snug line-clamp-6">{caption}</div>}
|
||
<div className="pt-1 text-[10px] text-amber-400 font-semibold opacity-0 group-hover:opacity-100 transition flex items-center justify-center space-x-1">
|
||
<span>Click for Details</span>
|
||
<Info className="w-3 h-3" />
|
||
</div>
|
||
<div className="w-0.5 h-8 bg-emerald-500/40 group-hover:bg-amber-500/80 mx-auto absolute -bottom-8 left-1/2 -translate-x-1/2 transition" />
|
||
</div>
|
||
)}
|
||
|
||
{/* NODE DOT ON THE TIMELINE VECTOR */}
|
||
<div
|
||
className={`w-8 h-8 rounded-full border-3 flex items-center justify-center shadow-lg transition-all duration-300 z-20 group-hover:scale-125 group-hover:border-white group-hover:bg-amber-400 group-hover:text-slate-950 group-hover:ring-4 group-hover:ring-amber-500/30 ${
|
||
evt.type === 'circular'
|
||
? 'bg-slate-950 border-cyan-400 text-cyan-400'
|
||
: evt.type === 'meeting'
|
||
? 'bg-slate-950 border-amber-400 text-amber-400'
|
||
: evt.type === 'milestone'
|
||
? 'bg-slate-950 border-emerald-400 text-emerald-400'
|
||
: evt.type === 'decision'
|
||
? 'bg-slate-950 border-purple-400 text-purple-400'
|
||
: 'bg-slate-950 border-rose-400 text-rose-400'
|
||
}`}
|
||
title={`${evt.title} (${evt.date}) - Click for details`}
|
||
>
|
||
<div className="w-2.5 h-2.5 rounded-full bg-current" />
|
||
</div>
|
||
|
||
{/* BOTTOM CALLOUT CARD (For Odd Index) */}
|
||
{!isTop && (
|
||
<div className="absolute top-12 left-1/2 -translate-x-1/2 w-80 text-center space-y-1 bg-slate-950/95 border border-slate-800 group-hover:border-amber-500/80 p-4 rounded-2xl shadow-xl transition-all duration-200 transform group-hover:translate-y-1.5 group-hover:bg-slate-900">
|
||
<div className="w-0.5 h-8 bg-emerald-500/40 group-hover:bg-amber-500/80 mx-auto absolute -top-8 left-1/2 -translate-x-1/2 transition" />
|
||
<div className="text-xs font-bold text-emerald-400 tracking-wide">{formattedDate}</div>
|
||
<div className="text-xs font-bold text-slate-100 group-hover:text-amber-400 transition leading-snug">{evt.title}</div>
|
||
{caption && <div className="text-[11px] text-slate-400 italic leading-snug line-clamp-6">{caption}</div>}
|
||
<div className="pt-1 text-[10px] text-amber-400 font-semibold opacity-0 group-hover:opacity-100 transition flex items-center justify-center space-x-1">
|
||
<span>Click for Details</span>
|
||
<Info className="w-3 h-3" />
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* VIEW 2: VERTICAL DETAILED FEED */}
|
||
{activeViewMode === 'feed' && (
|
||
<div className="bg-slate-900 border border-slate-800 rounded-3xl p-6 shadow-xl relative">
|
||
<div className="relative pl-6 md:pl-8 border-l-2 border-slate-800 space-y-8 my-2">
|
||
{filteredEvents.map((evt) => (
|
||
<div
|
||
key={evt.id}
|
||
className="relative group cursor-pointer"
|
||
onClick={() => setSelectedDetailEvent(evt)}
|
||
>
|
||
<div
|
||
className={`absolute -left-[31px] md:-left-[39px] top-1.5 w-6 h-6 rounded-full border-2 flex items-center justify-center shadow-lg transition-transform group-hover:scale-125 ${
|
||
evt.type === 'circular'
|
||
? 'bg-slate-900 border-cyan-400 text-cyan-400'
|
||
: evt.type === 'meeting'
|
||
? 'bg-slate-900 border-amber-400 text-amber-400'
|
||
: evt.type === 'milestone'
|
||
? 'bg-slate-900 border-emerald-400 text-emerald-400'
|
||
: evt.type === 'decision'
|
||
? 'bg-slate-900 border-purple-400 text-purple-400'
|
||
: 'bg-slate-900 border-rose-400 text-rose-400'
|
||
}`}
|
||
>
|
||
<div className="w-2 h-2 rounded-full bg-current" />
|
||
</div>
|
||
|
||
<div className="bg-slate-950/90 border border-slate-800/90 group-hover:border-amber-500/60 rounded-2xl p-5 space-y-3 transition">
|
||
<div className="flex flex-wrap items-start justify-between gap-2 border-b border-slate-800/60 pb-3">
|
||
<div className="space-y-1.5 flex-1">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<span className={`px-2.5 py-0.5 rounded-lg text-[10px] font-bold border ${getTypeBadge(evt.type)}`}>
|
||
{getTypeLabel(evt.type)}
|
||
</span>
|
||
{evt.referenceNumber && (
|
||
<span className="px-2 py-0.5 bg-slate-800 text-amber-300 font-mono text-[10px] rounded border border-slate-700 font-bold">
|
||
Ref: {evt.referenceNumber}
|
||
</span>
|
||
)}
|
||
<span className="px-2 py-0.5 bg-slate-900 text-slate-300 rounded text-[10px] border border-slate-800">
|
||
{getSourceLabel(evt.sourceTab, evt.isAutoGenerated)}
|
||
</span>
|
||
<span className="text-xs text-amber-400 font-semibold ml-auto">{evt.date}</span>
|
||
</div>
|
||
<h3 className="text-base font-bold text-slate-100 group-hover:text-amber-400 transition">{evt.title}</h3>
|
||
</div>
|
||
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setSelectedDetailEvent(evt);
|
||
}}
|
||
className="px-3 py-1 bg-amber-500/10 hover:bg-amber-500 text-amber-400 hover:text-slate-950 border border-amber-500/30 rounded-xl text-xs font-bold flex items-center space-x-1 transition cursor-pointer"
|
||
>
|
||
<Info className="w-3.5 h-3.5" />
|
||
<span>Details</span>
|
||
</button>
|
||
</div>
|
||
|
||
<div className="text-xs text-slate-300 leading-relaxed whitespace-pre-line bg-slate-950 p-3 rounded-xl border border-slate-800/80 line-clamp-3">
|
||
{evt.description}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* VIEW 3: OFFICIAL CIRCULARS REGISTRY */}
|
||
{activeViewMode === 'circulars' && (
|
||
<div className="bg-slate-900 border border-slate-800 rounded-3xl p-6 shadow-xl space-y-4">
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-left text-xs border-collapse">
|
||
<thead>
|
||
<tr className="border-b border-slate-800 text-slate-400 font-semibold bg-slate-950/60">
|
||
<th className="p-3">Reference PIN</th>
|
||
<th className="p-3">Set Date</th>
|
||
<th className="p-3">Circular Title & Subject</th>
|
||
<th className="p-3">Issuing Authority</th>
|
||
<th className="p-3 text-right">Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-slate-800/60">
|
||
{filteredEvents.map((circ) => (
|
||
<tr
|
||
key={circ.id}
|
||
className="hover:bg-slate-800/40 transition cursor-pointer"
|
||
onClick={() => setSelectedDetailEvent(circ)}
|
||
>
|
||
<td className="p-3 font-mono text-amber-300 font-bold whitespace-nowrap">
|
||
{circ.referenceNumber || 'PIN-OFFICIAL'}
|
||
</td>
|
||
<td className="p-3 text-slate-300 font-medium whitespace-nowrap">{circ.date}</td>
|
||
<td className="p-3 text-slate-100 font-bold min-w-[220px]">{circ.title}</td>
|
||
<td className="p-3 text-slate-300 whitespace-nowrap font-medium">{circ.author || 'Executive Office'}</td>
|
||
<td className="p-3 text-right whitespace-nowrap">
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setSelectedDetailEvent(circ);
|
||
}}
|
||
className="px-2.5 py-1 bg-cyan-500/10 hover:bg-cyan-500/20 text-cyan-400 border border-cyan-500/30 rounded-lg text-[11px] font-bold inline-flex items-center space-x-1 transition cursor-pointer"
|
||
>
|
||
<Info className="w-3 h-3" />
|
||
<span>View Details</span>
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* MODAL WINDOW FOR EVENT DETAILS (Replaces Confinement-Prone Hover Balloons) */}
|
||
{selectedDetailEvent && (
|
||
<div
|
||
className="fixed inset-0 bg-slate-950/80 backdrop-blur-md z-50 flex items-center justify-center p-4 overflow-y-auto animate-in fade-in duration-200"
|
||
onClick={() => setSelectedDetailEvent(null)}
|
||
>
|
||
<div
|
||
className="bg-slate-900 border-2 border-amber-500/60 rounded-3xl max-w-2xl w-full p-6 shadow-2xl space-y-5 my-8 relative text-left"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
{/* Header */}
|
||
<div className="flex items-start justify-between border-b border-slate-800 pb-4">
|
||
<div className="space-y-1.5 pr-4">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<span className={`px-2.5 py-0.5 rounded-lg text-[10px] font-bold border ${getTypeBadge(selectedDetailEvent.type)}`}>
|
||
{getTypeLabel(selectedDetailEvent.type)}
|
||
</span>
|
||
{selectedDetailEvent.referenceNumber && (
|
||
<span className="px-2 py-0.5 bg-slate-800 text-amber-300 font-mono text-[10px] rounded border border-slate-700 font-bold">
|
||
Ref: {selectedDetailEvent.referenceNumber}
|
||
</span>
|
||
)}
|
||
<span className="px-2 py-0.5 bg-slate-950 text-slate-400 font-mono text-[10px] rounded border border-slate-800">
|
||
{selectedDetailEvent.date}
|
||
</span>
|
||
</div>
|
||
<h2 className="text-lg font-bold text-slate-100">{selectedDetailEvent.title}</h2>
|
||
</div>
|
||
|
||
<button
|
||
onClick={() => setSelectedDetailEvent(null)}
|
||
className="p-1.5 rounded-xl bg-slate-950 text-slate-400 hover:text-white border border-slate-800 hover:bg-slate-800 transition cursor-pointer"
|
||
title="Close Dialog"
|
||
>
|
||
<X className="w-5 h-5" />
|
||
</button>
|
||
</div>
|
||
|
||
{/* Metadata Grid */}
|
||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 text-xs bg-slate-950 p-3.5 rounded-2xl border border-slate-800/80">
|
||
<div>
|
||
<span className="text-slate-500 block text-[10px]">Publication Date</span>
|
||
<strong className="text-amber-400 font-semibold">{selectedDetailEvent.date}</strong>
|
||
</div>
|
||
<div>
|
||
<span className="text-slate-500 block text-[10px]">Authority / Author</span>
|
||
<strong className="text-slate-200">{selectedDetailEvent.author || 'Governance Office'}</strong>
|
||
</div>
|
||
<div>
|
||
<span className="text-slate-500 block text-[10px]">Source Classification</span>
|
||
<span className="text-slate-300">{getSourceLabel(selectedDetailEvent.sourceTab, selectedDetailEvent.isAutoGenerated)}</span>
|
||
</div>
|
||
{selectedDetailEvent.location && (
|
||
<div className="col-span-2 sm:col-span-3 pt-1 border-t border-slate-800/60">
|
||
<span className="text-slate-500 block text-[10px]">Venue / Scope</span>
|
||
<span className="text-slate-200">{selectedDetailEvent.location}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Description / Content Box */}
|
||
<div className="space-y-1.5">
|
||
<label className="text-[11px] font-bold text-slate-400">Directive Details & Context</label>
|
||
<div className="text-xs text-slate-200 leading-relaxed bg-slate-950 p-4 rounded-2xl border border-slate-800 max-h-60 overflow-y-auto whitespace-pre-line">
|
||
{selectedDetailEvent.description || 'No detailed text recorded for this milestone.'}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Attachments Section */}
|
||
{selectedDetailEvent.attachments && selectedDetailEvent.attachments.length > 0 && (
|
||
<div className="space-y-2 pt-1 border-t border-slate-800">
|
||
<div className="text-xs font-bold text-slate-300 flex items-center space-x-1.5">
|
||
<Paperclip className="w-4 h-4 text-amber-400" />
|
||
<span>Attached Documents ({selectedDetailEvent.attachments.length})</span>
|
||
</div>
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||
{selectedDetailEvent.attachments.map((att) => (
|
||
<a
|
||
key={att.id}
|
||
href={att.dataUrl || '#'}
|
||
download={att.name}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="px-3 py-2 bg-slate-950 hover:bg-slate-800 border border-slate-800 text-slate-200 rounded-xl text-xs flex items-center justify-between transition group"
|
||
>
|
||
<div className="flex items-center space-x-2 truncate pr-2">
|
||
<FileText className="w-4 h-4 text-amber-400 shrink-0" />
|
||
<span className="truncate">{att.name}</span>
|
||
</div>
|
||
<Download className="w-4 h-4 text-slate-500 group-hover:text-amber-400 shrink-0 transition" />
|
||
</a>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Footer Navigation & Actions */}
|
||
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 pt-3 border-t border-slate-800">
|
||
<div>
|
||
{selectedDetailEvent.isAutoGenerated && selectedDetailEvent.sourceTab && onNavigateTab && (
|
||
<button
|
||
onClick={() => {
|
||
const tab = selectedDetailEvent.sourceTab;
|
||
setSelectedDetailEvent(null);
|
||
if (tab) onNavigateTab(tab);
|
||
}}
|
||
className="px-4 py-2 bg-amber-500 hover:bg-amber-400 text-slate-950 font-bold rounded-xl text-xs flex items-center space-x-1.5 shadow transition cursor-pointer"
|
||
>
|
||
<span>Open Source Register ({getSourceLabel(selectedDetailEvent.sourceTab, true)})</span>
|
||
<ExternalLink className="w-3.5 h-3.5" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
<button
|
||
onClick={() => setSelectedDetailEvent(null)}
|
||
className="px-5 py-2 bg-slate-800 hover:bg-slate-700 text-slate-200 text-xs font-bold rounded-xl transition cursor-pointer"
|
||
>
|
||
Close Modal
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* MODAL FOR MANUAL CREATION */}
|
||
{isModalOpen && (
|
||
<div className="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-50 flex items-center justify-center p-4 overflow-y-auto">
|
||
<div className="bg-slate-900 border border-slate-800 rounded-3xl max-w-xl w-full p-6 shadow-2xl space-y-5 my-8">
|
||
<div className="flex items-center justify-between border-b border-slate-800 pb-3">
|
||
<h2 className="text-lg font-bold text-slate-100 flex items-center space-x-2">
|
||
<Clock className="w-5 h-5 text-amber-400" />
|
||
<span>Create Custom Publication / Circular</span>
|
||
</h2>
|
||
<button onClick={() => setIsModalOpen(false)} className="p-1 rounded-lg text-slate-400 hover:text-white">
|
||
<X className="w-5 h-5" />
|
||
</button>
|
||
</div>
|
||
|
||
<form onSubmit={handleSaveEvent} className="space-y-4 text-xs">
|
||
<div>
|
||
<label className="block text-slate-400 mb-1 font-medium">Publication Title *</label>
|
||
<input
|
||
type="text"
|
||
value={title}
|
||
onChange={(e) => setTitle(e.target.value)}
|
||
placeholder="e.g. Published Governance Circular PIN-2026-003"
|
||
required
|
||
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-2.5 text-slate-200 focus:outline-none focus:border-amber-500"
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<label className="block text-slate-400 mb-1 font-medium">Set Date *</label>
|
||
<input
|
||
type="date"
|
||
value={date}
|
||
onChange={(e) => setDate(e.target.value)}
|
||
required
|
||
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-2.5 text-slate-200 focus:outline-none focus:border-amber-500"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-slate-400 mb-1 font-medium">Reference PIN</label>
|
||
<input
|
||
type="text"
|
||
value={referenceNumber}
|
||
onChange={(e) => setReferenceNumber(e.target.value)}
|
||
placeholder="e.g. PIN-2026-003"
|
||
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-2.5 text-amber-300 font-mono focus:outline-none focus:border-amber-500"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-slate-400 mb-1 font-medium">Directive Details & Context</label>
|
||
<textarea
|
||
rows={3}
|
||
value={description}
|
||
onChange={(e) => setDescription(e.target.value)}
|
||
placeholder="Provide details..."
|
||
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-2.5 text-slate-200 focus:outline-none focus:border-amber-500"
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex justify-end space-x-3 pt-3">
|
||
<button
|
||
type="button"
|
||
onClick={() => setIsModalOpen(false)}
|
||
className="px-4 py-2 bg-slate-800 text-slate-300 rounded-xl"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button type="submit" className="px-5 py-2 bg-amber-500 text-slate-950 font-bold rounded-xl shadow">
|
||
Publish Event
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|