727 lines
31 KiB
TypeScript
727 lines
31 KiB
TypeScript
|
|
import React, { useState } from 'react';
|
||
|
|
import {
|
||
|
|
Clock,
|
||
|
|
Plus,
|
||
|
|
FileText,
|
||
|
|
Calendar,
|
||
|
|
Users,
|
||
|
|
Award,
|
||
|
|
Gavel,
|
||
|
|
ShieldAlert,
|
||
|
|
Search,
|
||
|
|
Filter,
|
||
|
|
Paperclip,
|
||
|
|
MapPin,
|
||
|
|
User,
|
||
|
|
Edit2,
|
||
|
|
Trash2,
|
||
|
|
Download,
|
||
|
|
X,
|
||
|
|
CheckCircle,
|
||
|
|
FileCode,
|
||
|
|
Tag,
|
||
|
|
ArrowUpDown,
|
||
|
|
Sparkles,
|
||
|
|
ExternalLink
|
||
|
|
} from 'lucide-react';
|
||
|
|
import { ProjectStore, TimelineEvent, TimelineEventType, UserRole, Attachment } from '../types';
|
||
|
|
import { AttachmentUploader } from './AttachmentUploader';
|
||
|
|
|
||
|
|
interface TimelineViewProps {
|
||
|
|
store: ProjectStore;
|
||
|
|
onUpdateStore: (newStore: ProjectStore) => void;
|
||
|
|
userRole?: UserRole;
|
||
|
|
currentUserName?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export const TimelineView: React.FC<TimelineViewProps> = ({
|
||
|
|
store,
|
||
|
|
onUpdateStore,
|
||
|
|
userRole = 'viewer',
|
||
|
|
currentUserName = 'System User'
|
||
|
|
}) => {
|
||
|
|
const events = store.timelineEvents || [];
|
||
|
|
const canEdit = userRole === 'admin' || userRole === 'editor';
|
||
|
|
|
||
|
|
// Search & Filter State
|
||
|
|
const [searchQuery, setSearchQuery] = useState('');
|
||
|
|
const [typeFilter, setTypeFilter] = useState<string>('all');
|
||
|
|
const [sortOrder, setSortOrder] = useState<'desc' | 'asc'>('desc');
|
||
|
|
|
||
|
|
// Modal State
|
||
|
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||
|
|
const [editingEvent, setEditingEvent] = useState<Partial<TimelineEvent> | null>(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 event
|
||
|
|
const handleOpenCreateModal = () => {
|
||
|
|
setEditingEvent(null);
|
||
|
|
setTitle('');
|
||
|
|
setEventType('circular');
|
||
|
|
setCategory('Official 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 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 event (Create or Update)
|
||
|
|
const handleSaveEvent = (e: React.FormEvent) => {
|
||
|
|
e.preventDefault();
|
||
|
|
if (!title.trim() || !date) return;
|
||
|
|
|
||
|
|
let updatedEvents = [...events];
|
||
|
|
|
||
|
|
if (editingEvent && editingEvent.id) {
|
||
|
|
// Update existing
|
||
|
|
updatedEvents = updatedEvents.map((evt) =>
|
||
|
|
evt.id === 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 {
|
||
|
|
// Create new
|
||
|
|
const nextId = (store.ids.timelineEvent || events.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()
|
||
|
|
};
|
||
|
|
|
||
|
|
updatedEvents.push(newEvt);
|
||
|
|
}
|
||
|
|
|
||
|
|
onUpdateStore({
|
||
|
|
...store,
|
||
|
|
timelineEvents: updatedEvents,
|
||
|
|
ids: {
|
||
|
|
...store.ids,
|
||
|
|
timelineEvent: (store.ids.timelineEvent || events.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 event: ${title.trim()} (${referenceNumber || eventType})`,
|
||
|
|
user: currentUserName,
|
||
|
|
timestamp: new Date().toISOString()
|
||
|
|
},
|
||
|
|
...store.logs
|
||
|
|
]
|
||
|
|
});
|
||
|
|
|
||
|
|
setIsModalOpen(false);
|
||
|
|
};
|
||
|
|
|
||
|
|
// Delete Event
|
||
|
|
const handleDeleteEvent = (id: number) => {
|
||
|
|
if (!window.confirm('Are you sure you want to delete this timeline event?')) return;
|
||
|
|
|
||
|
|
const updatedEvents = events.filter((e) => e.id !== id);
|
||
|
|
onUpdateStore({
|
||
|
|
...store,
|
||
|
|
timelineEvents: updatedEvents,
|
||
|
|
logs: [
|
||
|
|
{
|
||
|
|
id: store.ids.log || Date.now(),
|
||
|
|
action: 'TIMELINE_EVENT_DELETED',
|
||
|
|
type: 'timeline',
|
||
|
|
details: `Deleted timeline event ID #${id}`,
|
||
|
|
user: currentUserName,
|
||
|
|
timestamp: new Date().toISOString()
|
||
|
|
},
|
||
|
|
...store.logs
|
||
|
|
]
|
||
|
|
});
|
||
|
|
};
|
||
|
|
|
||
|
|
// Filter & Sort Events
|
||
|
|
const filteredEvents = events
|
||
|
|
.filter((e) => {
|
||
|
|
if (typeFilter !== 'all' && e.type !== typeFilter) return false;
|
||
|
|
if (searchQuery.trim()) {
|
||
|
|
const q = searchQuery.toLowerCase();
|
||
|
|
return (
|
||
|
|
e.title.toLowerCase().includes(q) ||
|
||
|
|
e.description.toLowerCase().includes(q) ||
|
||
|
|
(e.referenceNumber && e.referenceNumber.toLowerCase().includes(q)) ||
|
||
|
|
(e.category && e.category.toLowerCase().includes(q)) ||
|
||
|
|
(e.author && e.author.toLowerCase().includes(q))
|
||
|
|
);
|
||
|
|
}
|
||
|
|
return true;
|
||
|
|
})
|
||
|
|
.sort((a, b) => {
|
||
|
|
const dateA = new Date(`${a.date}T${a.time || '00:00'}`).getTime();
|
||
|
|
const dateB = new Date(`${b.date}T${b.time || '00:00'}`).getTime();
|
||
|
|
return sortOrder === 'desc' ? dateB - dateA : dateA - dateB;
|
||
|
|
});
|
||
|
|
|
||
|
|
// Helpers for Type Colors & Icons
|
||
|
|
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-4 h-4 text-cyan-400" />;
|
||
|
|
case 'meeting':
|
||
|
|
return <Users className="w-4 h-4 text-amber-400" />;
|
||
|
|
case 'milestone':
|
||
|
|
return <Award className="w-4 h-4 text-emerald-400" />;
|
||
|
|
case 'decision':
|
||
|
|
return <Gavel className="w-4 h-4 text-purple-400" />;
|
||
|
|
case 'release':
|
||
|
|
case 'audit':
|
||
|
|
return <ShieldAlert className="w-4 h-4 text-rose-400" />;
|
||
|
|
default:
|
||
|
|
return <Clock className="w-4 h-4 text-slate-400" />;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function getTypeLabel(type: TimelineEventType) {
|
||
|
|
switch (type) {
|
||
|
|
case 'circular':
|
||
|
|
return 'Circular Published';
|
||
|
|
case 'meeting':
|
||
|
|
return 'Committee Meeting';
|
||
|
|
case 'milestone':
|
||
|
|
return 'Milestone Reached';
|
||
|
|
case 'decision':
|
||
|
|
return 'Key Decision';
|
||
|
|
case 'release':
|
||
|
|
return 'Official Release';
|
||
|
|
case 'audit':
|
||
|
|
return 'Audit Review';
|
||
|
|
default:
|
||
|
|
return 'Custom Event';
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Count Statistics
|
||
|
|
const circularsCount = events.filter((e) => e.type === 'circular').length;
|
||
|
|
const meetingsCount = events.filter((e) => e.type === 'meeting').length;
|
||
|
|
const milestonesCount = events.filter((e) => e.type === 'milestone').length;
|
||
|
|
const docsCount = events.reduce((acc, e) => acc + (e.attachments ? e.attachments.length : 0), 0);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="space-y-6">
|
||
|
|
{/* 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-1">
|
||
|
|
<div className="flex items-center space-x-3">
|
||
|
|
<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>
|
||
|
|
<h1 className="text-xl font-bold text-slate-100">Interactive Timeline & Circular Registry</h1>
|
||
|
|
<p className="text-xs text-slate-400">
|
||
|
|
Chronological record of official meetings, circulars published, milestone achievements, and executive directives.
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{canEdit && (
|
||
|
|
<button
|
||
|
|
onClick={handleOpenCreateModal}
|
||
|
|
className="px-4 py-2.5 bg-amber-500 hover:bg-amber-400 text-slate-950 font-bold rounded-xl text-xs flex items-center justify-center space-x-2 shadow-lg transition cursor-pointer self-start md:self-auto shrink-0"
|
||
|
|
>
|
||
|
|
<Plus className="w-4 h-4" />
|
||
|
|
<span>Create Timeline Event</span>
|
||
|
|
</button>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Summary Analytics Cards */}
|
||
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||
|
|
<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">{events.length}</div>
|
||
|
|
<div className="text-[10px] text-slate-500">Historical & scheduled entries</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">Official governance 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 Meetings</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">PMT & PSC sessions logged</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>Supporting Documents</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 & circular PDFs</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Filter and Search Bar */}
|
||
|
|
<div className="bg-slate-900 border border-slate-800 p-4 rounded-2xl space-y-3">
|
||
|
|
<div className="flex flex-col md:flex-row gap-3 items-center justify-between">
|
||
|
|
{/* Search Box */}
|
||
|
|
<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 timeline, circular ref, title..."
|
||
|
|
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>
|
||
|
|
|
||
|
|
{/* Type Filter Tabs */}
|
||
|
|
<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 Events' },
|
||
|
|
{ id: 'circular', label: 'Circulars' },
|
||
|
|
{ id: 'meeting', label: 'Meetings' },
|
||
|
|
{ id: 'milestone', label: 'Milestones' },
|
||
|
|
{ id: 'decision', label: 'Decisions' }
|
||
|
|
].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>
|
||
|
|
))}
|
||
|
|
|
||
|
|
{/* Sort Toggle */}
|
||
|
|
<button
|
||
|
|
onClick={() => setSortOrder(sortOrder === 'desc' ? 'asc' : 'desc')}
|
||
|
|
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"
|
||
|
|
title="Toggle Date Sort Order"
|
||
|
|
>
|
||
|
|
<ArrowUpDown className="w-3.5 h-3.5 text-amber-400" />
|
||
|
|
<span className="text-[11px]">{sortOrder === 'desc' ? 'Newest First' : 'Oldest First'}</span>
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Vertical Interactive Timeline */}
|
||
|
|
<div className="bg-slate-900 border border-slate-800 rounded-3xl p-6 shadow-xl relative">
|
||
|
|
{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 timeline events match your search criteria</div>
|
||
|
|
<p className="text-xs text-slate-500 max-w-sm mx-auto">
|
||
|
|
Try adjusting your search query, selecting "All Events", or creating a new timeline entry.
|
||
|
|
</p>
|
||
|
|
{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 inline-flex items-center space-x-1.5 shadow mt-2 cursor-pointer"
|
||
|
|
>
|
||
|
|
<Plus className="w-4 h-4" />
|
||
|
|
<span>Create Timeline Event</span>
|
||
|
|
</button>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<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">
|
||
|
|
{/* Pulsing Timeline Node */}
|
||
|
|
<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-110 ${
|
||
|
|
evt.type === 'circular'
|
||
|
|
? 'bg-slate-900 border-cyan-400 text-cyan-400 shadow-cyan-500/20'
|
||
|
|
: evt.type === 'meeting'
|
||
|
|
? 'bg-slate-900 border-amber-400 text-amber-400 shadow-amber-500/20'
|
||
|
|
: evt.type === 'milestone'
|
||
|
|
? 'bg-slate-900 border-emerald-400 text-emerald-400 shadow-emerald-500/20'
|
||
|
|
: evt.type === 'decision'
|
||
|
|
? 'bg-slate-900 border-purple-400 text-purple-400 shadow-purple-500/20'
|
||
|
|
: 'bg-slate-900 border-rose-400 text-rose-400 shadow-rose-500/20'
|
||
|
|
}`}
|
||
|
|
>
|
||
|
|
<div className="w-2 h-2 rounded-full bg-current" />
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Event Card */}
|
||
|
|
<div className="bg-slate-950/80 border border-slate-800/90 rounded-2xl p-5 hover:border-slate-700 transition shadow-md space-y-3">
|
||
|
|
<div className="flex flex-wrap items-start justify-between gap-2 border-b border-slate-800/60 pb-3">
|
||
|
|
<div className="space-y-1">
|
||
|
|
<div className="flex flex-wrap items-center gap-2">
|
||
|
|
{/* Type Badge */}
|
||
|
|
<span
|
||
|
|
className={`px-2.5 py-0.5 rounded-lg text-[10px] font-bold border inline-flex items-center space-x-1 ${getTypeBadge(
|
||
|
|
evt.type
|
||
|
|
)}`}
|
||
|
|
>
|
||
|
|
{getTypeIcon(evt.type)}
|
||
|
|
<span>{evt.category || getTypeLabel(evt.type)}</span>
|
||
|
|
</span>
|
||
|
|
|
||
|
|
{/* Reference Number if present */}
|
||
|
|
{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 inline-flex items-center space-x-1">
|
||
|
|
<Tag className="w-3 h-3 text-amber-400" />
|
||
|
|
<span>Ref: {evt.referenceNumber}</span>
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<h3 className="text-base font-bold text-slate-100 group-hover:text-amber-400 transition">
|
||
|
|
{evt.title}
|
||
|
|
</h3>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Controls for Admin / Editor */}
|
||
|
|
<div className="flex items-center space-x-1 opacity-90 sm:opacity-0 group-hover:opacity-100 transition">
|
||
|
|
{canEdit && (
|
||
|
|
<>
|
||
|
|
<button
|
||
|
|
onClick={() => handleOpenEditModal(evt)}
|
||
|
|
className="p-1.5 text-slate-400 hover:text-amber-400 hover:bg-slate-800 rounded-lg transition"
|
||
|
|
title="Edit Event"
|
||
|
|
>
|
||
|
|
<Edit2 className="w-4 h-4" />
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
onClick={() => handleDeleteEvent(evt.id)}
|
||
|
|
className="p-1.5 text-slate-400 hover:text-rose-400 hover:bg-slate-800 rounded-lg transition"
|
||
|
|
title="Delete Event"
|
||
|
|
>
|
||
|
|
<Trash2 className="w-4 h-4" />
|
||
|
|
</button>
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Metadata Bar */}
|
||
|
|
<div className="flex flex-wrap items-center gap-y-2 gap-x-4 text-xs text-slate-400">
|
||
|
|
<div className="flex items-center space-x-1.5 text-slate-300 font-medium">
|
||
|
|
<Calendar className="w-3.5 h-3.5 text-amber-400" />
|
||
|
|
<span>{evt.date}</span>
|
||
|
|
{evt.time && <span className="text-slate-500">at {evt.time}</span>}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{evt.location && (
|
||
|
|
<div className="flex items-center space-x-1 text-slate-400">
|
||
|
|
<MapPin className="w-3.5 h-3.5 text-slate-500" />
|
||
|
|
<span>{evt.location}</span>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{evt.author && (
|
||
|
|
<div className="flex items-center space-x-1 text-slate-400">
|
||
|
|
<User className="w-3.5 h-3.5 text-slate-500" />
|
||
|
|
<span>Published by: <strong className="text-slate-300 font-normal">{evt.author}</strong></span>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Description Body */}
|
||
|
|
<p className="text-xs text-slate-300 leading-relaxed whitespace-pre-line">{evt.description}</p>
|
||
|
|
|
||
|
|
{/* Supporting Attachments Display */}
|
||
|
|
{evt.attachments && evt.attachments.length > 0 && (
|
||
|
|
<div className="pt-2 border-t border-slate-800/60">
|
||
|
|
<div className="text-[11px] font-bold text-slate-400 mb-2 flex items-center space-x-1.5">
|
||
|
|
<Paperclip className="w-3.5 h-3.5 text-amber-400" />
|
||
|
|
<span>Supporting Documents & Circular Files ({evt.attachments.length})</span>
|
||
|
|
</div>
|
||
|
|
<div className="flex flex-wrap gap-2">
|
||
|
|
{evt.attachments.map((att) => (
|
||
|
|
<a
|
||
|
|
key={att.id}
|
||
|
|
href={att.url}
|
||
|
|
download={att.name}
|
||
|
|
target="_blank"
|
||
|
|
rel="noopener noreferrer"
|
||
|
|
className="px-3 py-1.5 bg-slate-900 hover:bg-slate-800 border border-slate-800 hover:border-amber-500/40 text-slate-200 rounded-xl text-xs flex items-center space-x-2 transition group/file"
|
||
|
|
>
|
||
|
|
<FileText className="w-3.5 h-3.5 text-amber-400 shrink-0" />
|
||
|
|
<span className="font-medium truncate max-w-[180px]">{att.name}</span>
|
||
|
|
<span className="text-[10px] text-slate-500">
|
||
|
|
({(att.size / 1024).toFixed(0)} KB)
|
||
|
|
</span>
|
||
|
|
<Download className="w-3.5 h-3.5 text-slate-500 group-hover/file:text-amber-400 transition shrink-0 ml-1" />
|
||
|
|
</a>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* CREATE / EDIT TIMELINE EVENT MODAL */}
|
||
|
|
{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 max-h-[90vh] overflow-y-auto">
|
||
|
|
<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>{editingEvent ? 'Edit Timeline Event' : 'Create Custom Timeline Event'}</span>
|
||
|
|
</h2>
|
||
|
|
<button
|
||
|
|
onClick={() => setIsModalOpen(false)}
|
||
|
|
className="p-1 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800"
|
||
|
|
>
|
||
|
|
<X className="w-5 h-5" />
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<form onSubmit={handleSaveEvent} className="space-y-4 text-xs">
|
||
|
|
{/* Event Title */}
|
||
|
|
<div>
|
||
|
|
<label className="block text-slate-400 mb-1 font-medium">Event Title *</label>
|
||
|
|
<input
|
||
|
|
type="text"
|
||
|
|
value={title}
|
||
|
|
onChange={(e) => setTitle(e.target.value)}
|
||
|
|
placeholder="e.g. Published Circular PIN-2026-003 or Mid-Year Strategy Review"
|
||
|
|
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>
|
||
|
|
|
||
|
|
{/* Event Type & Category */}
|
||
|
|
<div className="grid grid-cols-2 gap-3">
|
||
|
|
<div>
|
||
|
|
<label className="block text-slate-400 mb-1 font-medium">Event Type</label>
|
||
|
|
<select
|
||
|
|
value={eventType}
|
||
|
|
onChange={(e) => {
|
||
|
|
const newT = e.target.value as TimelineEventType;
|
||
|
|
setEventType(newT);
|
||
|
|
if (!category || category === getTypeLabel(eventType)) {
|
||
|
|
setCategory(getTypeLabel(newT));
|
||
|
|
}
|
||
|
|
}}
|
||
|
|
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"
|
||
|
|
>
|
||
|
|
<option value="circular">Circular Published</option>
|
||
|
|
<option value="meeting">Committee Meeting</option>
|
||
|
|
<option value="milestone">Milestone Reached</option>
|
||
|
|
<option value="decision">Executive Decision</option>
|
||
|
|
<option value="release">Official Release / Launch</option>
|
||
|
|
<option value="audit">Audit & Compliance Review</option>
|
||
|
|
<option value="other">Other Event</option>
|
||
|
|
</select>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div>
|
||
|
|
<label className="block text-slate-400 mb-1 font-medium">Category / Badge Name</label>
|
||
|
|
<input
|
||
|
|
type="text"
|
||
|
|
value={category}
|
||
|
|
onChange={(e) => setCategory(e.target.value)}
|
||
|
|
placeholder="e.g. Official Circular, Board Directive"
|
||
|
|
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>
|
||
|
|
|
||
|
|
{/* Date, Time & Reference Number */}
|
||
|
|
<div className="grid grid-cols-3 gap-3">
|
||
|
|
<div>
|
||
|
|
<label className="block text-slate-400 mb-1 font-medium">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">Time (Optional)</label>
|
||
|
|
<input
|
||
|
|
type="time"
|
||
|
|
value={time}
|
||
|
|
onChange={(e) => setTime(e.target.value)}
|
||
|
|
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 Number</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>
|
||
|
|
|
||
|
|
{/* Location & Author */}
|
||
|
|
<div className="grid grid-cols-2 gap-3">
|
||
|
|
<div>
|
||
|
|
<label className="block text-slate-400 mb-1 font-medium">Location / Venue</label>
|
||
|
|
<input
|
||
|
|
type="text"
|
||
|
|
value={location}
|
||
|
|
onChange={(e) => setLocation(e.target.value)}
|
||
|
|
placeholder="e.g. Main Boardroom / Portal"
|
||
|
|
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">Author / Published By</label>
|
||
|
|
<input
|
||
|
|
type="text"
|
||
|
|
value={author}
|
||
|
|
onChange={(e) => setAuthor(e.target.value)}
|
||
|
|
placeholder="e.g. PMO Office"
|
||
|
|
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>
|
||
|
|
|
||
|
|
{/* Description */}
|
||
|
|
<div>
|
||
|
|
<label className="block text-slate-400 mb-1 font-medium">Description / Details</label>
|
||
|
|
<textarea
|
||
|
|
rows={3}
|
||
|
|
value={description}
|
||
|
|
onChange={(e) => setDescription(e.target.value)}
|
||
|
|
placeholder="Provide comprehensive details, agenda, rationale or context for this timeline event..."
|
||
|
|
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>
|
||
|
|
|
||
|
|
{/* Attachment Uploader */}
|
||
|
|
<div className="pt-2 border-t border-slate-800">
|
||
|
|
<AttachmentUploader
|
||
|
|
attachments={attachments}
|
||
|
|
onChange={setAttachments}
|
||
|
|
label="Upload Supporting Documents (PDF, Circulars, Reports)"
|
||
|
|
/>
|
||
|
|
</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 hover:bg-slate-700 text-slate-300 rounded-xl font-medium cursor-pointer"
|
||
|
|
>
|
||
|
|
Cancel
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
type="submit"
|
||
|
|
className="px-5 py-2 bg-amber-500 hover:bg-amber-400 text-slate-950 font-bold rounded-xl shadow cursor-pointer"
|
||
|
|
>
|
||
|
|
{editingEvent ? 'Save Changes' : 'Create Event'}
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</form>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
};
|