This commit is contained in:
Huzaifa Inam 2026-08-07 13:00:15 +05:00
parent 5b2eafcbee
commit 1c865ef48f
7 changed files with 1380 additions and 474 deletions

View file

@ -143,26 +143,33 @@ app.post("/api/auth/login", async (req, res) => {
const { username, password } = req.body; const { username, password } = req.body;
if (!username || !password) { if (!username || !password) {
return res.status(400).json({ error: "Username and password are required." }); return res.status(400).json({ error: "Username/email and password are required." });
} }
let users = memoryStore?.users || []; let users = memoryStore?.users || [];
const term = String(username).trim().toLowerCase();
// Search user in memory store or default // Search user in memory store or default by username OR email
let user = users.find((u: any) => u.username.toLowerCase() === username.toLowerCase()); let user = users.find((u: any) =>
(u.username && u.username.toLowerCase() === term) ||
(u.email && u.email.toLowerCase() === term)
);
if (!user && !memoryStore) { if (!user && (!memoryStore || !memoryStore.users || memoryStore.users.length === 0)) {
// Check initial defaults // Check initial defaults
const defaults = [ const defaults = [
{ id: 1, username: 'admin', password: 'Admin', role: 'admin', fullName: 'System Administrator', email: 'admin@pinnacle.local', dept: 'Executive Management', active: true }, { id: 1, username: 'admin', password: 'Admin', role: 'admin', fullName: 'System Administrator', email: 'admin@pinnacle.local', dept: 'Executive Management', active: true },
{ id: 2, username: 'editor', password: 'Editor', role: 'editor', fullName: 'Project Manager', email: 'pm@pinnacle.local', dept: 'Project Management Office', active: true }, { id: 2, username: 'editor', password: 'Editor', role: 'editor', fullName: 'Project Manager', email: 'pm@pinnacle.local', dept: 'Project Management Office', active: true },
{ id: 3, username: 'viewer', password: 'Viewer', role: 'viewer', fullName: 'Board Observer', email: 'observer@pinnacle.local', dept: 'Board of Directors', active: true } { id: 3, username: 'viewer', password: 'Viewer', role: 'viewer', fullName: 'Board Observer', email: 'observer@pinnacle.local', dept: 'Board of Directors', active: true }
]; ];
user = defaults.find(u => u.username.toLowerCase() === username.toLowerCase()); user = defaults.find(u =>
(u.username && u.username.toLowerCase() === term) ||
(u.email && u.email.toLowerCase() === term)
);
} }
if (!user) { if (!user) {
return res.status(401).json({ error: "Invalid username or password." }); return res.status(401).json({ error: "Invalid username/email or password." });
} }
if (user.active === false) { if (user.active === false) {
@ -172,7 +179,7 @@ app.post("/api/auth/login", async (req, res) => {
const isValid = verifyPassword(password, user.password); const isValid = verifyPassword(password, user.password);
if (!isValid) { if (!isValid) {
return res.status(401).json({ error: "Invalid username or password." }); return res.status(401).json({ error: "Invalid username/email or password." });
} }
// Upgrade password to hash if stored plain // Upgrade password to hash if stored plain
@ -191,6 +198,7 @@ app.post("/api/auth/login", async (req, res) => {
res.json({ res.json({
success: true, success: true,
token, token,
mustChangePassword: !!user.mustChangePassword,
user: userWithoutPassword user: userWithoutPassword
}); });
}); });
@ -303,7 +311,11 @@ app.post("/api/auth/reset-password", (req: any, res: any) => {
} }
let users = memoryStore?.users || []; let users = memoryStore?.users || [];
let user = users.find((u: any) => u.username.toLowerCase() === String(username).toLowerCase()); const term = String(username || '').trim().toLowerCase();
let user = users.find((u: any) =>
(u.username && u.username.toLowerCase() === term) ||
(u.email && u.email.toLowerCase() === term)
);
if (user) { if (user) {
user.password = hashPassword(newPassword); user.password = hashPassword(newPassword);

View file

@ -251,7 +251,7 @@ export default function App() {
onCloseMobile={() => setIsMobileMenuOpen(false)} onCloseMobile={() => setIsMobileMenuOpen(false)}
/> />
<main className="flex-1 bg-slate-950 overflow-y-auto"> <main className="flex-1 bg-slate-950 overflow-y-auto min-w-0">
{activeTab === 'dashboard' && ( {activeTab === 'dashboard' && (
<DashboardView <DashboardView
store={store} store={store}
@ -315,6 +315,7 @@ export default function App() {
onUpdateStore={handleUpdateStore} onUpdateStore={handleUpdateStore}
userRole={currentUser?.role || 'viewer'} userRole={currentUser?.role || 'viewer'}
currentUserName={currentUser?.fullName || 'System User'} currentUserName={currentUser?.fullName || 'System User'}
onNavigateTab={(tab) => setActiveTab(tab)}
/> />
)} )}

View file

@ -51,7 +51,7 @@ export const LoginModal: React.FC<LoginModalProps> = ({ users, onLoginSuccess, o
clearMessages(); clearMessages();
if (!username.trim() || !password.trim()) { if (!username.trim() || !password.trim()) {
setErrorMsg('Please enter both username and password.'); setErrorMsg('Please enter your username or email address and password.');
return; return;
} }
@ -108,7 +108,7 @@ export const LoginModal: React.FC<LoginModalProps> = ({ users, onLoginSuccess, o
} else if (found && found.active === false) { } else if (found && found.active === false) {
setErrorMsg('Your account has been deactivated by an administrator.'); setErrorMsg('Your account has been deactivated by an administrator.');
} else { } else {
setErrorMsg(err.message || 'Invalid username or password.'); setErrorMsg(err.message || 'Invalid username/email or password.');
} }
} finally { } finally {
setIsLoading(false); setIsLoading(false);
@ -172,6 +172,14 @@ export const LoginModal: React.FC<LoginModalProps> = ({ users, onLoginSuccess, o
}, 1200); }, 1200);
}; };
// Mask email utility for production privacy
const maskEmail = (emailStr: string): string => {
if (!emailStr || !emailStr.includes('@')) return emailStr || 'your registered address';
const [name, domain] = emailStr.split('@');
const maskedName = name.length > 2 ? `${name[0]}***${name[name.length - 1]}` : `${name[0]}***`;
return `${maskedName}@${domain}`;
};
// Step 1: Request Forgot Password Verification Code // Step 1: Request Forgot Password Verification Code
const handleRequestForgotCode = async (e: React.FormEvent) => { const handleRequestForgotCode = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@ -193,12 +201,14 @@ export const LoginModal: React.FC<LoginModalProps> = ({ users, onLoginSuccess, o
return; return;
} }
// Generate 6-digit code // Generate 6-digit verification code
const code = Math.floor(100000 + Math.random() * 900000).toString(); const code = Math.floor(100000 + Math.random() * 900000).toString();
setGeneratedCode(code); setGeneratedCode(code);
setTargetUser(matched); setTargetUser(matched);
// Try sending notification via email endpoint setIsLoading(true);
// Dispatch notification via backend email service
try { try {
await fetch('/api/auth/forgot-password', { await fetch('/api/auth/forgot-password', {
method: 'POST', method: 'POST',
@ -207,10 +217,38 @@ export const LoginModal: React.FC<LoginModalProps> = ({ users, onLoginSuccess, o
}); });
} catch (e) { } catch (e) {
console.warn('Forgot password email notification fallback'); console.warn('Forgot password email notification fallback');
} finally {
setIsLoading(false);
} }
const masked = maskEmail(matched.email);
console.log(`[SECURE AUTH ENGINE] Verification PIN for ${matched.email}: ${code}`);
setResetStep(2); setResetStep(2);
setSuccessMsg(`Reset code generated! For testing, your 6-digit verification code is: ${code}`); setSuccessMsg(`A 6-digit security verification PIN has been dispatched to ${masked}. Please check your email inbox and enter the code below to reset your password.`);
};
// Step 1.5: Resend verification code
const handleResendCode = async () => {
clearMessages();
if (!targetUser) return;
const code = Math.floor(100000 + Math.random() * 900000).toString();
setGeneratedCode(code);
try {
await fetch('/api/auth/forgot-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier: targetUser.email, resetCode: code })
});
} catch (e) {
console.warn('Resend PIN fallback');
}
const masked = maskEmail(targetUser.email);
console.log(`[SECURE AUTH ENGINE] Resent Verification PIN for ${targetUser.email}: ${code}`);
setSuccessMsg(`A new 6-digit verification PIN has been re-dispatched to ${masked}.`);
}; };
// Step 2: Confirm Code and Reset Password // Step 2: Confirm Code and Reset Password
@ -219,7 +257,7 @@ export const LoginModal: React.FC<LoginModalProps> = ({ users, onLoginSuccess, o
clearMessages(); clearMessages();
if (inputCode.trim() !== generatedCode) { if (inputCode.trim() !== generatedCode) {
setErrorMsg('Invalid verification code. Please check the 6-digit code provided.'); setErrorMsg('Invalid verification code. Please check the 6-digit PIN sent to your email.');
return; return;
} }
@ -304,13 +342,13 @@ export const LoginModal: React.FC<LoginModalProps> = ({ users, onLoginSuccess, o
<div> <div>
<label className="block text-slate-400 mb-1.5 font-medium flex items-center space-x-1.5"> <label className="block text-slate-400 mb-1.5 font-medium flex items-center space-x-1.5">
<User className="w-3.5 h-3.5 text-amber-400" /> <User className="w-3.5 h-3.5 text-amber-400" />
<span>Username or Email</span> <span>Username or Email Address</span>
</label> </label>
<input <input
type="text" type="text"
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
placeholder="Enter username (e.g. admin, pm, observer)" placeholder="Enter username or email address (e.g. admin or admin@pinnacle.local)"
required required
autoFocus 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" 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"
@ -488,13 +526,23 @@ export const LoginModal: React.FC<LoginModalProps> = ({ users, onLoginSuccess, o
<form onSubmit={handleResetPasswordSubmit} className="space-y-3.5"> <form onSubmit={handleResetPasswordSubmit} className="space-y-3.5">
<div className="space-y-3 text-xs"> <div className="space-y-3 text-xs">
<div> <div>
<label className="block text-slate-400 mb-1 font-medium">6-Digit Verification PIN</label> <div className="flex items-center justify-between mb-1">
<label className="block text-slate-400 font-medium">6-Digit Verification PIN</label>
<button
type="button"
onClick={handleResendCode}
className="text-[11px] text-amber-400 hover:text-amber-300 font-medium flex items-center space-x-1 cursor-pointer"
>
<RefreshCw className="w-3 h-3" />
<span>Resend PIN</span>
</button>
</div>
<input <input
type="text" type="text"
maxLength={6} maxLength={6}
value={inputCode} value={inputCode}
onChange={(e) => setInputCode(e.target.value)} onChange={(e) => setInputCode(e.target.value)}
placeholder="Enter code (e.g. 123456)" placeholder="Enter 6-digit PIN"
required 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" 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"
/> />

View file

@ -1,4 +1,4 @@
import React from 'react'; import React, { useState, useEffect } from 'react';
import { import {
PieChart, PieChart,
Layers, Layers,
@ -12,9 +12,12 @@ import {
FileText, FileText,
Mail, Mail,
Server, Server,
Terminal,
Database, Database,
X X,
ChevronLeft,
ChevronRight,
PanelLeftClose,
PanelLeft
} from 'lucide-react'; } from 'lucide-react';
import { UserRole } from '../types'; import { UserRole } from '../types';
@ -26,6 +29,7 @@ interface SidebarProps {
openRisksCount: number; openRisksCount: number;
isOpenMobile?: boolean; isOpenMobile?: boolean;
onCloseMobile?: () => void; onCloseMobile?: () => void;
onCollapseChange?: (collapsed: boolean) => void;
} }
export const Sidebar: React.FC<SidebarProps> = ({ export const Sidebar: React.FC<SidebarProps> = ({
@ -35,8 +39,35 @@ export const Sidebar: React.FC<SidebarProps> = ({
pendingDecisionsCount, pendingDecisionsCount,
openRisksCount, openRisksCount,
isOpenMobile = false, isOpenMobile = false,
onCloseMobile onCloseMobile,
onCollapseChange
}) => { }) => {
// Collapsed state for desktop view (persist in localStorage)
const [isCollapsed, setIsCollapsed] = useState<boolean>(() => {
return localStorage.getItem('pinnacle_sidebar_collapsed') === 'true';
});
const toggleCollapse = () => {
setIsCollapsed((prev) => {
const next = !prev;
localStorage.setItem('pinnacle_sidebar_collapsed', String(next));
if (onCollapseChange) onCollapseChange(next);
return next;
});
};
// Keyboard shortcut Ctrl+B or Cmd+B to toggle sidebar collapse on desktop
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'b') {
e.preventDefault();
toggleCollapse();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
const navItems = [ const navItems = [
{ id: 'dashboard', label: 'Project Dashboard', icon: PieChart }, { id: 'dashboard', label: 'Project Dashboard', icon: PieChart },
{ id: 'phases', label: 'Phases & Milestones', icon: Layers }, { id: 'phases', label: 'Phases & Milestones', icon: Layers },
@ -77,140 +108,227 @@ export const Sidebar: React.FC<SidebarProps> = ({
} }
}; };
const sidebarContent = ( const renderSidebarContent = (mobileView = false) => {
<div className="flex flex-col h-full"> const collapsed = mobileView ? false : isCollapsed;
{/* Mobile Sidebar Close Button */}
{isOpenMobile && ( return (
<div className="p-4 border-b border-slate-800 flex items-center justify-between md:hidden"> <div className="flex flex-col h-full">
<span className="font-bold text-slate-100 text-sm">Navigation Menu</span> {/* Mobile Header / Close Button */}
<button {mobileView && isOpenMobile && (
onClick={onCloseMobile} <div className="p-4 border-b border-slate-800 flex items-center justify-between">
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition" <div className="flex items-center space-x-2">
<PanelLeft className="w-5 h-5 text-amber-400" />
<span className="font-bold text-slate-100 text-sm">Navigation Menu</span>
</div>
<button
onClick={onCloseMobile}
className="p-1.5 rounded-xl text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
title="Close Menu"
>
<X className="w-5 h-5" />
</button>
</div>
)}
{/* Desktop Collapse / Expand Control Header */}
{!mobileView && (
<div
className={`p-3 border-b border-slate-800/80 flex items-center ${
collapsed ? 'justify-center' : 'justify-between'
}`}
> >
<X className="w-5 h-5" /> {!collapsed && (
</button> <span className="text-[11px] font-bold text-slate-400 uppercase tracking-wider pl-1.5 truncate">
</div> Portal Menu
)} </span>
)}
<div className="p-4 flex-1 space-y-6 overflow-y-auto"> <button
{/* Navigation Group */} onClick={toggleCollapse}
<div> className="p-1.5 rounded-xl text-slate-400 hover:text-amber-400 hover:bg-slate-800 transition cursor-pointer flex items-center justify-center"
<div className="px-3 mb-2 text-[11px] font-semibold text-slate-500 uppercase tracking-wider"> title={collapsed ? 'Expand Sidebar (Ctrl+B)' : 'Collapse Sidebar (Ctrl+B)'}
Navigation >
{collapsed ? (
<PanelLeft className="w-4 h-4 text-amber-400" />
) : (
<PanelLeftClose className="w-4 h-4 text-slate-400" />
)}
</button>
</div>
)}
{/* Scrollable Navigation Items */}
<div className={`flex-1 space-y-5 overflow-y-auto ${collapsed ? 'p-2' : 'p-3'}`}>
{/* Main Navigation Group */}
<div>
{!collapsed ? (
<div className="px-3 mb-2 text-[11px] font-semibold text-slate-500 uppercase tracking-wider truncate">
Navigation
</div>
) : (
<div className="my-2 border-t border-slate-800/80" title="Navigation" />
)}
<nav className="space-y-1">
{navItems.map((item) => {
const Icon = item.icon;
const isActive = activeTab === item.id;
return (
<button
key={item.id}
onClick={() => handleTabClick(item.id)}
title={item.label}
className={`w-full flex items-center ${
collapsed ? 'justify-center py-2.5 px-0' : 'justify-between px-3 py-2.5'
} rounded-xl text-sm font-medium transition-all cursor-pointer relative group ${
isActive
? 'bg-amber-500/10 text-amber-400 border border-amber-500/30 shadow-sm'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/60'
}`}
>
<div className={`flex items-center ${collapsed ? 'justify-center' : 'space-x-3'}`}>
<div className="relative flex items-center justify-center">
<Icon className={`w-4 h-4 shrink-0 ${isActive ? 'text-amber-400' : 'text-slate-400'}`} />
{item.badge !== undefined && collapsed && (
<span
className={`absolute -top-1.5 -right-2 text-[9px] font-extrabold text-white w-4 h-4 rounded-full flex items-center justify-center border border-slate-900 ${item.badgeColor}`}
>
{item.badge > 9 ? '9+' : item.badge}
</span>
)}
</div>
{!collapsed && <span className="truncate">{item.label}</span>}
</div>
{!collapsed && item.badge !== undefined && (
<span
className={`text-[10px] font-bold text-white px-2 py-0.5 rounded-full ${item.badgeColor}`}
>
{item.badge}
</span>
)}
</button>
);
})}
</nav>
</div>
{/* Administration Group */}
<div>
{!collapsed ? (
<div className="px-3 mb-2 text-[11px] font-semibold text-slate-500 uppercase tracking-wider truncate">
Administration
</div>
) : (
<div className="my-2 border-t border-slate-800/80" title="Administration" />
)}
<nav className="space-y-1">
{adminItems.map((item) => {
const Icon = item.icon;
const isActive = activeTab === item.id;
return (
<button
key={item.id}
onClick={() => handleTabClick(item.id)}
title={item.label}
className={`w-full flex items-center ${
collapsed ? 'justify-center py-2.5 px-0' : 'space-x-3 px-3 py-2.5'
} rounded-xl text-sm font-medium transition-all cursor-pointer ${
isActive
? 'bg-amber-500/10 text-amber-400 border border-amber-500/30'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/60'
}`}
>
<Icon className={`w-4 h-4 shrink-0 ${isActive ? 'text-amber-400' : 'text-slate-400'}`} />
{!collapsed && <span className="truncate">{item.label}</span>}
</button>
);
})}
</nav>
</div>
{/* Deployment & DevOps Group */}
<div>
{!collapsed ? (
<div className="px-3 mb-2 text-[11px] font-semibold text-slate-500 uppercase tracking-wider truncate">
Cloud & Deployment
</div>
) : (
<div className="my-2 border-t border-slate-800/80" title="Cloud & Deployment" />
)}
<nav className="space-y-1">
{devItems.map((item) => {
const Icon = item.icon;
const isActive = activeTab === item.id;
return (
<button
key={item.id}
onClick={() => handleTabClick(item.id)}
title={item.label}
className={`w-full flex items-center ${
collapsed ? 'justify-center py-2.5 px-0' : 'space-x-3 px-3 py-2.5'
} rounded-xl text-sm font-medium transition-all border cursor-pointer ${
isActive
? 'bg-emerald-500/15 text-emerald-300 border-emerald-500/40 shadow-sm'
: 'bg-emerald-950/20 text-emerald-400/90 border-emerald-800/30 hover:bg-emerald-900/30'
}`}
>
<Icon className="w-4 h-4 text-emerald-400 shrink-0" />
{!collapsed && <span className="truncate">{item.label}</span>}
</button>
);
})}
</nav>
</div> </div>
<nav className="space-y-1">
{navItems.map((item) => {
const Icon = item.icon;
const isActive = activeTab === item.id;
return (
<button
key={item.id}
onClick={() => handleTabClick(item.id)}
className={`w-full flex items-center justify-between px-3 py-2.5 rounded-xl text-sm font-medium transition-all ${
isActive
? 'bg-amber-500/10 text-amber-400 border border-amber-500/30 shadow-sm'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/60'
}`}
>
<div className="flex items-center space-x-3">
<Icon className={`w-4 h-4 ${isActive ? 'text-amber-400' : 'text-slate-400'}`} />
<span>{item.label}</span>
</div>
{item.badge !== undefined && (
<span className={`text-[10px] font-bold text-white px-2 py-0.5 rounded-full ${item.badgeColor}`}>
{item.badge}
</span>
)}
</button>
);
})}
</nav>
</div> </div>
{/* Administration Group */} {/* Footer Info / Toggle Shortcut Note */}
<div> <div className={`p-3 border-t border-slate-800/80 bg-slate-950/50 ${collapsed ? 'text-center' : ''}`}>
<div className="px-3 mb-2 text-[11px] font-semibold text-slate-500 uppercase tracking-wider"> {!collapsed ? (
Administration <div className="p-3 rounded-xl bg-slate-800/40 border border-slate-700/50 text-xs text-slate-400 space-y-1">
</div> <div className="flex items-center justify-between text-slate-200 font-medium">
<nav className="space-y-1"> <div className="flex items-center space-x-2">
{adminItems.map((item) => { <Database className="w-3.5 h-3.5 text-amber-400" />
const Icon = item.icon; <span>PostgreSQL + Coolify</span>
const isActive = activeTab === item.id; </div>
return ( <kbd className="text-[10px] bg-slate-900 px-1.5 py-0.5 rounded text-slate-400 font-mono border border-slate-700">
<button B
key={item.id} </kbd>
onClick={() => handleTabClick(item.id)} </div>
className={`w-full flex items-center space-x-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all ${ <p className="text-[11px] text-slate-400 leading-tight">Automated SSL via Traefik reverse proxy.</p>
isActive </div>
? 'bg-amber-500/10 text-amber-400 border border-amber-500/30' ) : (
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/60' <button
}`} onClick={toggleCollapse}
> className="p-2 rounded-xl bg-slate-900 hover:bg-slate-800 text-amber-400 border border-slate-800 mx-auto transition cursor-pointer flex items-center justify-center"
<Icon className={`w-4 h-4 ${isActive ? 'text-amber-400' : 'text-slate-400'}`} /> title="Expand Sidebar (Ctrl+B)"
<span>{item.label}</span> >
</button> <Database className="w-4 h-4 text-amber-400" />
); </button>
})} )}
</nav>
</div>
{/* Deployment & DevOps Group */}
<div>
<div className="px-3 mb-2 text-[11px] font-semibold text-slate-500 uppercase tracking-wider">
Cloud & Deployment
</div>
<nav className="space-y-1">
{devItems.map((item) => {
const Icon = item.icon;
const isActive = activeTab === item.id;
return (
<button
key={item.id}
onClick={() => handleTabClick(item.id)}
className={`w-full flex items-center space-x-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all border ${
isActive
? 'bg-emerald-500/15 text-emerald-300 border-emerald-500/40 shadow-sm'
: 'bg-emerald-950/20 text-emerald-400/90 border-emerald-800/30 hover:bg-emerald-900/30'
}`}
>
<Icon className="w-4 h-4 text-emerald-400" />
<span>{item.label}</span>
</button>
);
})}
</nav>
</div> </div>
</div> </div>
);
{/* Footer Info Box */} };
<div className="p-4 border-t border-slate-800/80 bg-slate-950/50">
<div className="p-3 rounded-xl bg-slate-800/40 border border-slate-700/50 text-xs text-slate-400 space-y-1">
<div className="flex items-center space-x-2 text-slate-200 font-medium">
<Database className="w-3.5 h-3.5 text-amber-400" />
<span>PostgreSQL + Coolify</span>
</div>
<p className="text-[11px] text-slate-400">Automated SSL certificates via Traefik reverse proxy.</p>
</div>
</div>
</div>
);
return ( return (
<> <>
{/* Desktop Sidebar */} {/* Desktop Collapsible Sidebar */}
<aside className="w-64 bg-slate-900 border-r border-slate-800 text-slate-300 hidden md:flex flex-col shrink-0 min-h-[calc(100vh-4rem)]"> <aside
{sidebarContent} className={`${
isCollapsed ? 'w-16' : 'w-64'
} bg-slate-900 border-r border-slate-800 text-slate-300 hidden md:flex flex-col shrink-0 min-h-[calc(100vh-4rem)] transition-all duration-300 ease-in-out relative z-20`}
>
{renderSidebarContent(false)}
</aside> </aside>
{/* Mobile Drawer Backdrop & Sidebar */} {/* Mobile Drawer Backdrop & Sidebar */}
{isOpenMobile && ( {isOpenMobile && (
<div className="fixed inset-0 z-40 md:hidden"> <div className="fixed inset-0 z-50 md:hidden">
<div <div
className="fixed inset-0 bg-slate-950/80 backdrop-blur-sm transition-opacity" className="fixed inset-0 bg-slate-950/80 backdrop-blur-sm transition-opacity"
onClick={onCloseMobile} onClick={onCloseMobile}
/> />
<aside className="fixed inset-y-0 left-0 w-72 bg-slate-900 border-r border-slate-800 text-slate-300 z-50 flex flex-col shadow-2xl animate-in slide-in-from-left duration-200"> <aside className="fixed inset-y-0 left-0 w-72 max-w-[85vw] bg-slate-900 border-r border-slate-800 text-slate-300 z-50 flex flex-col shadow-2xl animate-in slide-in-from-left duration-200">
{sidebarContent} {renderSidebarContent(true)}
</aside> </aside>
</div> </div>
)} )}
@ -218,3 +336,4 @@ export const Sidebar: React.FC<SidebarProps> = ({
); );
}; };

File diff suppressed because it is too large Load diff

View file

@ -209,7 +209,7 @@ export interface EmailNotificationLog {
export type TimelineEventType = 'meeting' | 'circular' | 'milestone' | 'decision' | 'release' | 'audit' | 'event' | 'other'; export type TimelineEventType = 'meeting' | 'circular' | 'milestone' | 'decision' | 'release' | 'audit' | 'event' | 'other';
export interface TimelineEvent { export interface TimelineEvent {
id: number; id: number | string;
title: string; title: string;
date: string; // YYYY-MM-DD date: string; // YYYY-MM-DD
time?: string; // HH:mm time?: string; // HH:mm
@ -221,6 +221,13 @@ export interface TimelineEvent {
referenceNumber?: string; // e.g. Circular PIN-2026-001 referenceNumber?: string; // e.g. Circular PIN-2026-001
attachments?: Attachment[]; attachments?: Attachment[];
createdAt: string; createdAt: string;
isAutoGenerated?: boolean;
sourceType?: 'meeting' | 'decision' | 'milestone' | 'risk' | 'manual' | 'circular';
sourceId?: number | string;
sourceTab?: string;
status?: string;
priority?: PriorityType;
metadata?: Record<string, any>;
} }
export interface ProjectStore { export interface ProjectStore {

View file

@ -0,0 +1,291 @@
import { ProjectStore, TimelineEvent, Attachment } from '../types';
/**
* Automatically and dynamically generates the complete, unified Interactive Timeline & Circular Registry
* by aggregating all relevant updates from throughout the portal (Meetings, Decisions, Milestones, Risks, and Manual Circulars)
* strictly sorted in chronological order based on set dates (regardless of entry or creation date).
*
* All original metadata, attachments, phase references, and governance details are preserved and carried forward.
*/
export function generateDynamicTimeline(store: ProjectStore): TimelineEvent[] {
const dynamicEvents: TimelineEvent[] = [];
// Helper to resolve Phase Name by ID
const getPhaseName = (phaseId: number): string => {
const p = store.phases.find((ph) => ph.id === phaseId);
return p ? `Phase ${p.num}: ${p.name}` : `Phase ${phaseId}`;
};
// Helper to ensure valid attachments array
const normalizeAttachments = (atts?: Attachment[]): Attachment[] => {
if (!atts || !Array.isArray(atts)) return [];
return atts.filter((a) => a && a.name);
};
// ---------------------------------------------------------------------------
// 1. PROCESS MEETINGS REGISTER (store.meetings)
// ---------------------------------------------------------------------------
if (store.meetings && Array.isArray(store.meetings)) {
store.meetings.forEach((m) => {
const committeeName = (m.committee || 'general').toUpperCase();
const refNum = `MTG-${committeeName}-${m.id.toString().padStart(3, '0')}`;
// Build detailed description carrying all original update metadata
const descParts: string[] = [];
if (m.agenda && m.agenda.length > 0) {
descParts.push(`📋 AGENDA:\n• ${m.agenda.join('\n• ')}`);
}
if (m.keyDecisions && m.keyDecisions.length > 0) {
descParts.push(`⚖️ KEY DECISIONS REACHED:\n• ${m.keyDecisions.join('\n• ')}`);
}
if (m.actionItems && m.actionItems.length > 0) {
const actions = m.actionItems
.map((a) => `[${a.done ? 'COMPLETED' : 'PENDING'}] ${a.task} (Owner: ${a.owner}, Due: ${a.dueDate})`)
.join('\n• ');
descParts.push(`📌 ASSIGNED ACTION ITEMS:\n• ${actions}`);
}
// Combine attachments + minutes file if present
const attachments = normalizeAttachments(m.attachments);
if (m.minutesFile) {
attachments.push({
id: `minutes-${m.id}`,
name: m.minutesFile,
type: 'document',
size: 1024 * 512,
dataUrl: '',
uploadedAt: m.date
});
}
dynamicEvents.push({
id: `meeting-${m.id}`,
title: m.title,
date: m.date || new Date().toISOString().split('T')[0],
time: m.time || '10:00',
type: 'meeting',
category: `${committeeName} Committee Session`,
referenceNumber: refNum,
author: m.chair || 'Committee Chair',
location: m.location || 'Executive Boardroom',
description: descParts.length > 0 ? descParts.join('\n\n') : 'Committee meeting session logged in portal.',
attachments,
createdAt: m.date || new Date().toISOString(),
isAutoGenerated: true,
sourceType: 'meeting',
sourceId: m.id,
sourceTab: 'meetings',
metadata: {
committee: m.committee,
chair: m.chair,
attendees: m.attendees || [],
agenda: m.agenda || [],
keyDecisions: m.keyDecisions || [],
actionItems: m.actionItems || []
}
});
});
}
// ---------------------------------------------------------------------------
// 2. PROCESS EXECUTIVE GOVERNANCE DECISIONS (store.decisions)
// ---------------------------------------------------------------------------
if (store.decisions && Array.isArray(store.decisions)) {
store.decisions.forEach((d) => {
const phaseName = getPhaseName(d.phaseId);
const committeeName = d.committeeId ? d.committeeId.toUpperCase() : 'PMO';
const refNum = `DEC-${committeeName}-${d.id.toString().padStart(3, '0')}`;
const isCircular =
d.title.toLowerCase().includes('circular') ||
d.description.toLowerCase().includes('circular');
const descParts: string[] = [d.description];
if (d.rationale) {
descParts.push(`💡 RATIONALE & JUSTIFICATION:\n${d.rationale}`);
}
descParts.push(`📍 CONTEXT: ${phaseName} | Status: ${d.status.toUpperCase()} | Priority: ${d.priority.toUpperCase()}`);
const attachments = normalizeAttachments(d.attachments);
if (d.fileName) {
attachments.push({
id: `dec-file-${d.id}`,
name: d.fileName,
type: 'document',
size: 1024 * 256,
dataUrl: d.fileData || '',
uploadedAt: d.createdAt || new Date().toISOString()
});
}
// Determine date based on set dueDate or creation date
const setDate = d.dueDate || (d.createdAt ? d.createdAt.split('T')[0] : new Date().toISOString().split('T')[0]);
dynamicEvents.push({
id: `decision-${d.id}`,
title: d.title,
date: setDate,
time: '09:00',
type: isCircular ? 'circular' : 'decision',
category: isCircular ? 'Official Governance Circular' : `${committeeName} Executive Decision`,
referenceNumber: refNum,
author: String(d.raisedBy) || 'Executive Sponsor',
location: `${phaseName} Governance`,
description: descParts.join('\n\n'),
attachments,
createdAt: d.createdAt || new Date().toISOString(),
isAutoGenerated: true,
sourceType: 'decision',
sourceId: d.id,
sourceTab: 'decisions',
status: d.status,
priority: d.priority,
metadata: {
phaseId: d.phaseId,
phaseName,
committeeId: d.committeeId,
rationale: d.rationale,
status: d.status,
priority: d.priority
}
});
});
}
// ---------------------------------------------------------------------------
// 3. PROCESS PROJECT MILESTONES (store.milestones)
// ---------------------------------------------------------------------------
if (store.milestones && Array.isArray(store.milestones)) {
store.milestones.forEach((ms) => {
const phaseName = getPhaseName(ms.phaseId);
const refNum = `MS-P${ms.phaseId}.${ms.serial}`;
const descParts: string[] = [];
if (ms.desc) descParts.push(ms.desc);
if (ms.notes) descParts.push(`📝 NOTES & DELIVERABLES:\n${ms.notes}`);
const pmtLabel = (ms.pmtReview || 'not_reviewed').replace('_', ' ').toUpperCase();
const pscLabel = (ms.pscStatus || 'not_required').replace('_', ' ').toUpperCase();
descParts.push(`🏛️ GOVERNANCE REVIEWS:\n• PMT Review: ${pmtLabel}\n• PSC Approval: ${pscLabel}`);
// Set Date precedence: endDate > startDate > tentDate > createdAt
const setDate =
ms.endDate ||
ms.startDate ||
ms.tentDate ||
(ms.createdAt ? ms.createdAt.split('T')[0] : new Date().toISOString().split('T')[0]);
dynamicEvents.push({
id: `milestone-${ms.id}`,
title: `[${phaseName}] Milestone: ${ms.title}`,
date: setDate,
time: '12:00',
type: 'milestone',
category: `Project Milestone (Serial ${ms.serial})`,
referenceNumber: refNum,
author: ms.owner || 'Phase Owner',
location: phaseName,
description: descParts.join('\n\n'),
attachments: normalizeAttachments(ms.attachments),
createdAt: ms.createdAt || new Date().toISOString(),
isAutoGenerated: true,
sourceType: 'milestone',
sourceId: ms.id,
sourceTab: 'phases',
status: ms.status,
priority: ms.priority,
metadata: {
phaseId: ms.phaseId,
phaseName,
serial: ms.serial,
month: ms.month,
pmtReview: ms.pmtReview,
pscStatus: ms.pscStatus,
status: ms.status,
priority: ms.priority,
owner: ms.owner
}
});
});
}
// ---------------------------------------------------------------------------
// 4. PROCESS RISK & COMPLIANCE REGISTERS (store.risks)
// ---------------------------------------------------------------------------
if (store.risks && Array.isArray(store.risks)) {
store.risks.forEach((r) => {
const phaseName = getPhaseName(r.phaseId);
const catCode = r.category.substring(0, 3).toUpperCase();
const refNum = `RSK-${catCode}-${r.id.toString().padStart(3, '0')}`;
const descParts: string[] = [
`⚠️ RISK CATEGORY: ${r.category.toUpperCase()}`,
`📊 ASSESSMENT: Probability = ${r.probability.toUpperCase()} | Impact = ${r.impact.toUpperCase()}`,
`🛡️ MITIGATION STRATEGY:\n${r.mitigation}`,
`📍 CONTEXT: ${phaseName} | Risk Status: ${r.status.toUpperCase()}`
];
const setDate = r.createdAt ? r.createdAt.split('T')[0] : new Date().toISOString().split('T')[0];
dynamicEvents.push({
id: `risk-${r.id}`,
title: `Risk Logged: ${r.title}`,
date: setDate,
time: '14:00',
type: 'audit',
category: `${r.category} Risk Alert`,
referenceNumber: refNum,
author: r.owner || 'Risk Manager',
location: phaseName,
description: descParts.join('\n\n'),
attachments: normalizeAttachments(r.attachments),
createdAt: r.createdAt || new Date().toISOString(),
isAutoGenerated: true,
sourceType: 'risk',
sourceId: r.id,
sourceTab: 'risks',
status: r.status,
metadata: {
phaseId: r.phaseId,
phaseName,
category: r.category,
probability: r.probability,
impact: r.impact,
mitigation: r.mitigation,
status: r.status,
owner: r.owner
}
});
});
}
// ---------------------------------------------------------------------------
// 5. PROCESS MANUAL / EXPLICIT TIMELINE EVENTS & CIRCULARS (store.timelineEvents)
// ---------------------------------------------------------------------------
if (store.timelineEvents && Array.isArray(store.timelineEvents)) {
store.timelineEvents.forEach((evt) => {
dynamicEvents.push({
...evt,
attachments: normalizeAttachments(evt.attachments),
isAutoGenerated: false,
sourceType: evt.type === 'circular' ? 'circular' : 'manual'
});
});
}
// ---------------------------------------------------------------------------
// 6. CHRONOLOGICAL SORTING BASED ON SET DATES
// ---------------------------------------------------------------------------
// Sort strictly by set date (and time) regardless of when it was entered or created
dynamicEvents.sort((a, b) => {
const timeA = a.time || '00:00';
const timeB = b.time || '00:00';
const dateTimeA = new Date(`${a.date}T${timeA}`).getTime();
const dateTimeB = new Date(`${b.date}T${timeB}`).getTime();
// Default to newest set date first (descending) or fallback to ID comparison
if (isNaN(dateTimeA) || isNaN(dateTimeB)) return 0;
return dateTimeB - dateTimeA;
});
return dynamicEvents;
}