From 5b2eafcbee2b4170559ceb9ffe842787d8d9849d Mon Sep 17 00:00:00 2001 From: Huzaifa Inam Date: Tue, 4 Aug 2026 01:24:04 +0500 Subject: [PATCH] Initial Commit --- server.ts | 80 ++++ src/App.tsx | 16 + src/components/Header.tsx | 4 +- src/components/LoginModal.tsx | 592 ++++++++++++++++++++------ src/components/Sidebar.tsx | 2 + src/components/TimelineView.tsx | 726 ++++++++++++++++++++++++++++++++ src/components/UsersView.tsx | 62 ++- src/data/defaultData.ts | 64 ++- src/types.ts | 20 + 9 files changed, 1431 insertions(+), 135 deletions(-) create mode 100644 src/components/TimelineView.tsx diff --git a/server.ts b/server.ts index 3e9bd47..53e89fa 100644 --- a/server.ts +++ b/server.ts @@ -229,10 +229,90 @@ app.post("/api/auth/change-password", authenticateToken, (req: any, res: any) => } user.password = hashPassword(newPassword); + user.mustChangePassword = false; res.json({ success: true, message: "Password updated successfully." }); }); +// User Authentication: First Login Password Change +app.post("/api/auth/first-login-change-password", (req: any, res: any) => { + const { userId, username, newPassword } = req.body; + + if (!newPassword || newPassword.length < 6) { + return res.status(400).json({ error: "New password must be at least 6 characters long." }); + } + + let users = memoryStore?.users || []; + let user = users.find((u: any) => u.id === userId || u.username.toLowerCase() === String(username).toLowerCase()); + + if (user) { + user.password = hashPassword(newPassword); + user.mustChangePassword = false; + } + + res.json({ success: true, message: "Password changed successfully for first login." }); +}); + +// User Authentication: Request Forgot Password +app.post("/api/auth/forgot-password", async (req: any, res: any) => { + const { identifier, resetCode } = req.body; + + if (!identifier) { + return res.status(400).json({ error: "Identifier (username or email) is required." }); + } + + let users = memoryStore?.users || []; + let user = users.find((u: any) => u.username.toLowerCase() === identifier.toLowerCase() || u.email.toLowerCase() === identifier.toLowerCase()); + + if (!user) { + return res.status(404).json({ error: "User account not found." }); + } + + // Send email if SMTP is configured + const transporter = createSmtpTransporter(); + if (transporter) { + try { + await transporter.sendMail({ + from: `"${backendSmtpConfig.fromName}" <${backendSmtpConfig.fromEmail}>`, + to: user.email, + subject: "Password Reset Request - Pinnacle Portal", + html: ` +
+

Password Reset Code

+

Dear ${user.fullName},

+

You requested a password reset for your Pinnacle Executive Portal account.

+

Your 6-digit verification code is: ${resetCode}

+

If you did not request this, please contact your administrator immediately.

+
+ ` + }); + } catch (err) { + console.warn("SMTP send failed during forgot password request:", err); + } + } + + res.json({ success: true, message: `Reset PIN generated and sent to ${user.email}` }); +}); + +// User Authentication: Reset Password with Code +app.post("/api/auth/reset-password", (req: any, res: any) => { + const { username, newPassword } = req.body; + + if (!newPassword || newPassword.length < 6) { + return res.status(400).json({ error: "New password must be at least 6 characters long." }); + } + + let users = memoryStore?.users || []; + let user = users.find((u: any) => u.username.toLowerCase() === String(username).toLowerCase()); + + if (user) { + user.password = hashPassword(newPassword); + user.mustChangePassword = false; + } + + res.json({ success: true, message: "Password reset successfully." }); +}); + // Backend Secure SMTP Config Store let backendSmtpConfig = { host: process.env.SMTP_HOST || 'smtp.mailtrap.io', diff --git a/src/App.tsx b/src/App.tsx index 5ffb734..52c3899 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,6 +11,7 @@ import { RisksView } from './components/RisksView'; import { UsersView } from './components/UsersView'; import { SmtpSettingsView } from './components/SmtpSettingsView'; import { LogsView } from './components/LogsView'; +import { TimelineView } from './components/TimelineView'; import { DeploymentView } from './components/DeploymentView'; import { LoginModal } from './components/LoginModal'; import { ProfileModal } from './components/ProfileModal'; @@ -308,6 +309,15 @@ export default function App() { /> )} + {activeTab === 'timeline' && ( + + )} + {activeTab === 'users' && ( { + handleUpdateStore({ + ...store, + users: updatedUsers + }); + }} /> )} diff --git a/src/components/Header.tsx b/src/components/Header.tsx index e10913f..2904ca5 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -75,7 +75,7 @@ export const Header: React.FC = ({ d.title.toLowerCase().includes(q) || d.description?.toLowerCase().includes(q) || d.decisionBy?.toLowerCase().includes(q) || - d.id.toLowerCase().includes(q) + String(d.id).toLowerCase().includes(q) ) : []; const matchedRisks = store && q ? store.risks.filter(r => @@ -83,7 +83,7 @@ export const Header: React.FC = ({ r.mitigation?.toLowerCase().includes(q) || r.category?.toLowerCase().includes(q) || r.owner?.toLowerCase().includes(q) || - r.id.toLowerCase().includes(q) + String(r.id).toLowerCase().includes(q) ) : []; const matchedMeetings = store && q ? store.meetings.filter(m => diff --git a/src/components/LoginModal.tsx b/src/components/LoginModal.tsx index 7666af2..2af76b1 100644 --- a/src/components/LoginModal.tsx +++ b/src/components/LoginModal.tsx @@ -1,51 +1,112 @@ import React, { useState } from 'react'; -import { Crown, Lock, User, ArrowRight, ShieldCheck, Eye, EyeOff, KeyRound, AlertCircle } from 'lucide-react'; +import { Crown, Lock, User, ArrowRight, ShieldCheck, Eye, EyeOff, KeyRound, AlertCircle, ArrowLeft, CheckCircle2, RefreshCw, Mail } from 'lucide-react'; import { User as UserType } from '../types'; interface LoginModalProps { users: UserType[]; onLoginSuccess: (user: UserType, token: string) => void; + onUpdateUsers?: (updatedUsers: UserType[]) => void; } -export const LoginModal: React.FC = ({ users, onLoginSuccess }) => { - const [username, setUsername] = useState('admin'); - const [password, setPassword] = useState('Admin'); +type Mode = 'login' | 'forgot_password' | 'first_login_change_password'; + +export const LoginModal: React.FC = ({ users, onLoginSuccess, onUpdateUsers }) => { + const [mode, setMode] = useState('login'); + + // Login form state + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); const [showPassword, setShowPassword] = useState(false); + + // General feedback const [errorMsg, setErrorMsg] = useState(''); + const [successMsg, setSuccessMsg] = useState(''); const [isLoading, setIsLoading] = useState(false); - const performLogin = async (un: string, pw: string) => { - setIsLoading(true); + // Pending target user for first login password change + const [targetUser, setTargetUser] = useState(null); + const [targetToken, setTargetToken] = useState(''); + + // First Login Change Password state + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [showNewPassword, setShowNewPassword] = useState(false); + + // Forgot Password state + const [forgotIdentifier, setForgotIdentifier] = useState(''); + const [resetStep, setResetStep] = useState<1 | 2>(1); + const [generatedCode, setGeneratedCode] = useState(''); + const [inputCode, setInputCode] = useState(''); + const [resetNewPass, setResetNewPass] = useState(''); + const [resetConfirmPass, setResetConfirmPass] = useState(''); + + const clearMessages = () => { setErrorMsg(''); + setSuccessMsg(''); + }; + + // Perform Login Verification + const handleCustomLogin = async (e: React.FormEvent) => { + e.preventDefault(); + clearMessages(); + + if (!username.trim() || !password.trim()) { + setErrorMsg('Please enter both username and password.'); + return; + } + + setIsLoading(true); try { const res = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ username: un, password: pw }) + body: JSON.stringify({ username: username.trim(), password }) }); const data = await res.json(); if (!res.ok || !data.success) { - throw new Error(data.error || 'Authentication failed. Please check credentials.'); + throw new Error(data.error || 'Authentication failed. Invalid credentials.'); } - // Save token to localStorage for persistent sessions - if (data.token) { - localStorage.setItem('pinnacle_auth_token', data.token); + const foundUser: UserType = data.user; + const token: string = data.token; + + // Check if Admin enforced password change on first login + if (foundUser.mustChangePassword || data.mustChangePassword) { + setTargetUser(foundUser); + setTargetToken(token); + setMode('first_login_change_password'); + setIsLoading(false); + return; } - onLoginSuccess(data.user, data.token); + if (token) { + localStorage.setItem('pinnacle_auth_token', token); + } + onLoginSuccess(foundUser, token); } catch (err: any) { - // Fallback offline handler if backend API isn't reached + // Offline / fallback handler matching stored user objects const found = users.find( - (u) => u.username.toLowerCase() === un.toLowerCase() + (u) => u.username.toLowerCase() === username.trim().toLowerCase() || u.email.toLowerCase() === username.trim().toLowerCase() ); - if (found && (pw.toLowerCase() === un.toLowerCase() || pw === 'Admin' || pw === 'Editor' || pw === 'Viewer')) { - const dummyToken = `demo_jwt_token_${found.username}_${Date.now()}`; + + if (found && found.active !== false && (password === found.password || password === 'Admin' || password === 'Editor' || password === 'Viewer')) { + const dummyToken = `jwt_session_${found.username}_${Date.now()}`; + + if (found.mustChangePassword) { + setTargetUser(found); + setTargetToken(dummyToken); + setMode('first_login_change_password'); + setIsLoading(false); + return; + } + localStorage.setItem('pinnacle_auth_token', dummyToken); onLoginSuccess(found, dummyToken); + } else if (found && found.active === false) { + setErrorMsg('Your account has been deactivated by an administrator.'); } else { setErrorMsg(err.message || 'Invalid username or password.'); } @@ -54,134 +115,428 @@ export const LoginModal: React.FC = ({ users, onLoginSuccess }) } }; - const handleCustomLogin = (e: React.FormEvent) => { + // Handle Password Change on First Login + const handleFirstLoginPasswordSubmit = async (e: React.FormEvent) => { e.preventDefault(); - if (!username.trim() || !password.trim()) { - setErrorMsg('Please enter both username and password.'); + clearMessages(); + + if (!newPassword || newPassword.length < 6) { + setErrorMsg('New password must be at least 6 characters long.'); return; } - performLogin(username, password); + + if (newPassword !== confirmPassword) { + setErrorMsg('New password and confirm password do not match.'); + return; + } + + if (newPassword === password) { + setErrorMsg('Your new password cannot be the same as your initial temporary password.'); + return; + } + + if (!targetUser) return; + + setIsLoading(true); + + try { + // Call backend API if running + await fetch('/api/auth/first-login-change-password', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userId: targetUser.id, username: targetUser.username, newPassword }) + }); + } catch (e) { + console.warn('Backend password change sync notice, updating state locally.'); + } + + // Update state locally + const updatedUser: UserType = { + ...targetUser, + password: newPassword, + mustChangePassword: false + }; + + if (onUpdateUsers) { + const updatedList = users.map((u) => (u.id === targetUser.id ? updatedUser : u)); + onUpdateUsers(updatedList); + } + + if (targetToken) { + localStorage.setItem('pinnacle_auth_token', targetToken); + } + + setSuccessMsg('Password updated successfully! Redirecting to Portal...'); + setTimeout(() => { + onLoginSuccess(updatedUser, targetToken); + }, 1200); }; - const handleQuickSelect = (user: UserType) => { - const defaultPwd = user.role === 'admin' ? 'Admin' : user.role === 'editor' ? 'Editor' : 'Viewer'; - setUsername(user.username); - setPassword(defaultPwd); - performLogin(user.username, defaultPwd); + // Step 1: Request Forgot Password Verification Code + const handleRequestForgotCode = async (e: React.FormEvent) => { + e.preventDefault(); + clearMessages(); + + if (!forgotIdentifier.trim()) { + setErrorMsg('Please enter your registered Username or Email address.'); + return; + } + + const matched = users.find( + (u) => + u.username.toLowerCase() === forgotIdentifier.trim().toLowerCase() || + u.email.toLowerCase() === forgotIdentifier.trim().toLowerCase() + ); + + if (!matched) { + setErrorMsg('No active user account found matching that username or email.'); + return; + } + + // Generate 6-digit code + const code = Math.floor(100000 + Math.random() * 900000).toString(); + setGeneratedCode(code); + setTargetUser(matched); + + // Try sending notification via email endpoint + try { + await fetch('/api/auth/forgot-password', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ identifier: forgotIdentifier.trim(), resetCode: code }) + }); + } catch (e) { + console.warn('Forgot password email notification fallback'); + } + + setResetStep(2); + setSuccessMsg(`Reset code generated! For testing, your 6-digit verification code is: ${code}`); + }; + + // Step 2: Confirm Code and Reset Password + const handleResetPasswordSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + clearMessages(); + + if (inputCode.trim() !== generatedCode) { + setErrorMsg('Invalid verification code. Please check the 6-digit code provided.'); + return; + } + + if (!resetNewPass || resetNewPass.length < 6) { + setErrorMsg('New password must be at least 6 characters long.'); + return; + } + + if (resetNewPass !== resetConfirmPass) { + setErrorMsg('New password and confirm password do not match.'); + return; + } + + if (!targetUser) return; + + setIsLoading(true); + + try { + await fetch('/api/auth/reset-password', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: targetUser.username, resetCode: inputCode, newPassword: resetNewPass }) + }); + } catch (e) { + console.warn('Backend reset password notice'); + } + + const updatedUser: UserType = { + ...targetUser, + password: resetNewPass, + mustChangePassword: false + }; + + if (onUpdateUsers) { + const updatedList = users.map((u) => (u.id === targetUser.id ? updatedUser : u)); + onUpdateUsers(updatedList); + } + + setIsLoading(false); + setSuccessMsg('Password reset successfully! You can now sign in with your new password.'); + setMode('login'); + setUsername(targetUser.username); + setPassword(resetNewPass); }; return ( -
-
-
-
- +
+
+ {/* Glow Accent Header */} +
+ +
+
+
-

Pinnacle Portal

+

Pinnacle Executive Portal

- Production Grade JWT Authentication + Mandatory Authentication & Single Sign-On
- {/* Quick Demo Profile Selector */} -
- -
- {users.map((u) => ( - - ))} + {/* Global Feedback Banner */} + {errorMsg && ( +
+ + {errorMsg}
-
+ )} -
-
-
+ {successMsg && ( +
+ + {successMsg}
-
- Or Sign In With Credentials -
-
+ )} - {/* Authentication Form */} -
- {errorMsg && ( -
- - {errorMsg} -
- )} - -
-
- - setUsername(e.target.value)} - placeholder="e.g. admin, pm, observer" - 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" - /> -
- -
- -
+ {/* MODE 1: Standard Mandatory Login */} + {mode === 'login' && ( + +
+
+ setPassword(e.target.value)} - placeholder="Enter password" + type="text" + value={username} + onChange={(e) => setUsername(e.target.value)} + placeholder="Enter username (e.g. admin, pm, observer)" required - className="w-full bg-slate-950 border border-slate-800 rounded-xl p-2.5 pr-10 text-slate-200 focus:outline-none focus:border-amber-500" + autoFocus + className="w-full bg-slate-950 border border-slate-800 rounded-xl p-3 text-slate-200 focus:outline-none focus:border-amber-500 transition" /> - +
+ +
+
+ + +
+
+ setPassword(e.target.value)} + placeholder="Enter password" + required + className="w-full bg-slate-950 border border-slate-800 rounded-xl p-3 pr-10 text-slate-200 focus:outline-none focus:border-amber-500 transition" + /> + +
-
- + + )} + + {/* MODE 2: First Login Enforced Password Change */} + {mode === 'first_login_change_password' && ( +
+
+
+ + Action Required: First Login Password Update +
+

+ Welcome {targetUser?.fullName}. Your account administrator requires you to set a custom secure password before accessing the system. +

+
+ +
+
+ +
+ setNewPassword(e.target.value)} + placeholder="Enter new strong password (min 6 chars)" + required + className="w-full bg-slate-950 border border-slate-800 rounded-xl p-3 pr-10 text-slate-200 focus:outline-none focus:border-amber-500" + /> + +
+
+ +
+ + setConfirmPassword(e.target.value)} + placeholder="Re-enter new password" + required + className="w-full bg-slate-950 border border-slate-800 rounded-xl p-3 text-slate-200 focus:outline-none focus:border-amber-500" + /> +
+
+ + +
+ )} + + {/* MODE 3: Forgot Password Flow */} + {mode === 'forgot_password' && ( +
+
+

+ + Reset Portal Password +

+ +
+ + {resetStep === 1 ? ( +
+

+ Enter your registered username or email address below to receive password recovery instructions and a 6-digit verification pin. +

+ +
+ + setForgotIdentifier(e.target.value)} + placeholder="e.g. admin or admin@pinnacle.local" + required + className="w-full bg-slate-950 border border-slate-800 rounded-xl p-3 text-slate-200 focus:outline-none focus:border-amber-500" + /> +
+ + +
) : ( - <> - Sign In to Portal - - +
+
+
+ + setInputCode(e.target.value)} + placeholder="Enter code (e.g. 123456)" + required + className="w-full bg-slate-950 border border-slate-800 rounded-xl p-3 text-amber-300 font-mono text-center tracking-widest text-base font-bold focus:outline-none focus:border-amber-500" + /> +
+ +
+ + setResetNewPass(e.target.value)} + placeholder="Enter new password (min 6 chars)" + required + className="w-full bg-slate-950 border border-slate-800 rounded-xl p-3 text-slate-200 focus:outline-none focus:border-amber-500" + /> +
+ +
+ + setResetConfirmPass(e.target.value)} + placeholder="Confirm new password" + required + className="w-full bg-slate-950 border border-slate-800 rounded-xl p-3 text-slate-200 focus:outline-none focus:border-amber-500" + /> +
+
+ + +
)} - - +
+ )}
Protected by bcrypt password hashing & 24-hour signed JWT session tokens. @@ -190,4 +545,3 @@ export const LoginModal: React.FC = ({ users, onLoginSuccess })
); }; - diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 3dc21ba..3e42dcc 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -7,6 +7,7 @@ import { CalendarCheck, Handshake, ShieldAlert, + Clock, UserCog, FileText, Mail, @@ -49,6 +50,7 @@ export const Sidebar: React.FC = ({ { id: 'committees', label: 'PMT & PSC Committees', icon: Users }, { id: 'meetings', label: 'Meetings Tracker', icon: CalendarCheck }, { id: 'stakeholders', label: 'Stakeholder Matrix', icon: Handshake }, + { id: 'timeline', label: 'Timeline & Events', icon: Clock }, { id: 'risks', label: 'Risk Register', diff --git a/src/components/TimelineView.tsx b/src/components/TimelineView.tsx new file mode 100644 index 0000000..9f06b2a --- /dev/null +++ b/src/components/TimelineView.tsx @@ -0,0 +1,726 @@ +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 = ({ + 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('all'); + const [sortOrder, setSortOrder] = useState<'desc' | 'asc'>('desc'); + + // Modal State + const [isModalOpen, setIsModalOpen] = useState(false); + const [editingEvent, setEditingEvent] = useState | null>(null); + + // Modal Form Inputs + const [title, setTitle] = useState(''); + const [eventType, setEventType] = useState('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([]); + + // 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 ; + case 'meeting': + return ; + case 'milestone': + return ; + case 'decision': + return ; + case 'release': + case 'audit': + return ; + default: + return ; + } + } + + 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 ( +
+ {/* Page Header */} +
+
+
+
+ +
+
+

Interactive Timeline & Circular Registry

+

+ Chronological record of official meetings, circulars published, milestone achievements, and executive directives. +

+
+
+
+ + {canEdit && ( + + )} +
+ + {/* Summary Analytics Cards */} +
+
+
+ Total Timeline Events + +
+
{events.length}
+
Historical & scheduled entries
+
+ +
+
+ Circulars Issued + +
+
{circularsCount}
+
Official governance publications
+
+ +
+
+ Committee Meetings + +
+
{meetingsCount}
+
PMT & PSC sessions logged
+
+ +
+
+ Supporting Documents + +
+
{docsCount}
+
Attached files & circular PDFs
+
+
+ + {/* Filter and Search Bar */} +
+
+ {/* Search Box */} +
+ + 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 && ( + + )} +
+ + {/* Type Filter Tabs */} +
+ {[ + { id: 'all', label: 'All Events' }, + { id: 'circular', label: 'Circulars' }, + { id: 'meeting', label: 'Meetings' }, + { id: 'milestone', label: 'Milestones' }, + { id: 'decision', label: 'Decisions' } + ].map((tab) => ( + + ))} + + {/* Sort Toggle */} + +
+
+
+ + {/* Vertical Interactive Timeline */} +
+ {filteredEvents.length === 0 ? ( +
+
+ +
+
No timeline events match your search criteria
+

+ Try adjusting your search query, selecting "All Events", or creating a new timeline entry. +

+ {canEdit && ( + + )} +
+ ) : ( +
+ {filteredEvents.map((evt) => ( +
+ {/* Pulsing Timeline Node */} +
+
+
+ + {/* Event Card */} +
+
+
+
+ {/* Type Badge */} + + {getTypeIcon(evt.type)} + {evt.category || getTypeLabel(evt.type)} + + + {/* Reference Number if present */} + {evt.referenceNumber && ( + + + Ref: {evt.referenceNumber} + + )} +
+ +

+ {evt.title} +

+
+ + {/* Controls for Admin / Editor */} +
+ {canEdit && ( + <> + + + + )} +
+
+ + {/* Metadata Bar */} +
+
+ + {evt.date} + {evt.time && at {evt.time}} +
+ + {evt.location && ( +
+ + {evt.location} +
+ )} + + {evt.author && ( +
+ + Published by: {evt.author} +
+ )} +
+ + {/* Description Body */} +

{evt.description}

+ + {/* Supporting Attachments Display */} + {evt.attachments && evt.attachments.length > 0 && ( +
+
+ + Supporting Documents & Circular Files ({evt.attachments.length}) +
+
+ {evt.attachments.map((att) => ( + + + {att.name} + + ({(att.size / 1024).toFixed(0)} KB) + + + + ))} +
+
+ )} +
+
+ ))} +
+ )} +
+ + {/* CREATE / EDIT TIMELINE EVENT MODAL */} + {isModalOpen && ( +
+
+
+

+ + {editingEvent ? 'Edit Timeline Event' : 'Create Custom Timeline Event'} +

+ +
+ +
+ {/* Event Title */} +
+ + 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" + /> +
+ + {/* Event Type & Category */} +
+
+ + +
+ +
+ + 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" + /> +
+
+ + {/* Date, Time & Reference Number */} +
+
+ + 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" + /> +
+ +
+ + 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" + /> +
+ +
+ + 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" + /> +
+
+ + {/* Location & Author */} +
+
+ + 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" + /> +
+ +
+ + 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" + /> +
+
+ + {/* Description */} +
+ +