v0.1
This commit is contained in:
parent
5b2eafcbee
commit
1c865ef48f
7 changed files with 1380 additions and 474 deletions
28
server.ts
28
server.ts
|
|
@ -143,26 +143,33 @@ app.post("/api/auth/login", async (req, res) => {
|
|||
const { username, password } = req.body;
|
||||
|
||||
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 || [];
|
||||
const term = String(username).trim().toLowerCase();
|
||||
|
||||
// Search user in memory store or default
|
||||
let user = users.find((u: any) => u.username.toLowerCase() === username.toLowerCase());
|
||||
// Search user in memory store or default by username OR email
|
||||
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
|
||||
const defaults = [
|
||||
{ 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: 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) {
|
||||
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) {
|
||||
|
|
@ -172,7 +179,7 @@ app.post("/api/auth/login", async (req, res) => {
|
|||
const isValid = verifyPassword(password, user.password);
|
||||
|
||||
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
|
||||
|
|
@ -191,6 +198,7 @@ app.post("/api/auth/login", async (req, res) => {
|
|||
res.json({
|
||||
success: true,
|
||||
token,
|
||||
mustChangePassword: !!user.mustChangePassword,
|
||||
user: userWithoutPassword
|
||||
});
|
||||
});
|
||||
|
|
@ -303,7 +311,11 @@ app.post("/api/auth/reset-password", (req: any, res: any) => {
|
|||
}
|
||||
|
||||
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) {
|
||||
user.password = hashPassword(newPassword);
|
||||
|
|
|
|||
|
|
@ -251,7 +251,7 @@ export default function App() {
|
|||
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' && (
|
||||
<DashboardView
|
||||
store={store}
|
||||
|
|
@ -315,6 +315,7 @@ export default function App() {
|
|||
onUpdateStore={handleUpdateStore}
|
||||
userRole={currentUser?.role || 'viewer'}
|
||||
currentUserName={currentUser?.fullName || 'System User'}
|
||||
onNavigateTab={(tab) => setActiveTab(tab)}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ export const LoginModal: React.FC<LoginModalProps> = ({ users, onLoginSuccess, o
|
|||
clearMessages();
|
||||
|
||||
if (!username.trim() || !password.trim()) {
|
||||
setErrorMsg('Please enter both username and password.');
|
||||
setErrorMsg('Please enter your username or email address and password.');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -108,7 +108,7 @@ export const LoginModal: React.FC<LoginModalProps> = ({ users, onLoginSuccess, o
|
|||
} else if (found && found.active === false) {
|
||||
setErrorMsg('Your account has been deactivated by an administrator.');
|
||||
} else {
|
||||
setErrorMsg(err.message || 'Invalid username or password.');
|
||||
setErrorMsg(err.message || 'Invalid username/email or password.');
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
|
|
@ -172,6 +172,14 @@ export const LoginModal: React.FC<LoginModalProps> = ({ users, onLoginSuccess, o
|
|||
}, 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
|
||||
const handleRequestForgotCode = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
|
@ -193,12 +201,14 @@ export const LoginModal: React.FC<LoginModalProps> = ({ users, onLoginSuccess, o
|
|||
return;
|
||||
}
|
||||
|
||||
// Generate 6-digit code
|
||||
// Generate 6-digit verification code
|
||||
const code = Math.floor(100000 + Math.random() * 900000).toString();
|
||||
setGeneratedCode(code);
|
||||
setTargetUser(matched);
|
||||
|
||||
// Try sending notification via email endpoint
|
||||
setIsLoading(true);
|
||||
|
||||
// Dispatch notification via backend email service
|
||||
try {
|
||||
await fetch('/api/auth/forgot-password', {
|
||||
method: 'POST',
|
||||
|
|
@ -207,10 +217,38 @@ export const LoginModal: React.FC<LoginModalProps> = ({ users, onLoginSuccess, o
|
|||
});
|
||||
} catch (e) {
|
||||
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);
|
||||
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
|
||||
|
|
@ -219,7 +257,7 @@ export const LoginModal: React.FC<LoginModalProps> = ({ users, onLoginSuccess, o
|
|||
clearMessages();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -304,13 +342,13 @@ export const LoginModal: React.FC<LoginModalProps> = ({ users, onLoginSuccess, o
|
|||
<div>
|
||||
<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" />
|
||||
<span>Username or Email</span>
|
||||
<span>Username or Email Address</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
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
|
||||
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"
|
||||
|
|
@ -488,13 +526,23 @@ export const LoginModal: React.FC<LoginModalProps> = ({ users, onLoginSuccess, o
|
|||
<form onSubmit={handleResetPasswordSubmit} className="space-y-3.5">
|
||||
<div className="space-y-3 text-xs">
|
||||
<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
|
||||
type="text"
|
||||
maxLength={6}
|
||||
value={inputCode}
|
||||
onChange={(e) => setInputCode(e.target.value)}
|
||||
placeholder="Enter code (e.g. 123456)"
|
||||
placeholder="Enter 6-digit PIN"
|
||||
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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
PieChart,
|
||||
Layers,
|
||||
|
|
@ -12,9 +12,12 @@ import {
|
|||
FileText,
|
||||
Mail,
|
||||
Server,
|
||||
Terminal,
|
||||
Database,
|
||||
X
|
||||
X,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
PanelLeftClose,
|
||||
PanelLeft
|
||||
} from 'lucide-react';
|
||||
import { UserRole } from '../types';
|
||||
|
||||
|
|
@ -26,6 +29,7 @@ interface SidebarProps {
|
|||
openRisksCount: number;
|
||||
isOpenMobile?: boolean;
|
||||
onCloseMobile?: () => void;
|
||||
onCollapseChange?: (collapsed: boolean) => void;
|
||||
}
|
||||
|
||||
export const Sidebar: React.FC<SidebarProps> = ({
|
||||
|
|
@ -35,8 +39,35 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
|||
pendingDecisionsCount,
|
||||
openRisksCount,
|
||||
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 = [
|
||||
{ id: 'dashboard', label: 'Project Dashboard', icon: PieChart },
|
||||
{ id: 'phases', label: 'Phases & Milestones', icon: Layers },
|
||||
|
|
@ -77,27 +108,65 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
const sidebarContent = (
|
||||
const renderSidebarContent = (mobileView = false) => {
|
||||
const collapsed = mobileView ? false : isCollapsed;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Mobile Sidebar Close Button */}
|
||||
{isOpenMobile && (
|
||||
<div className="p-4 border-b border-slate-800 flex items-center justify-between md:hidden">
|
||||
{/* Mobile Header / Close Button */}
|
||||
{mobileView && isOpenMobile && (
|
||||
<div className="p-4 border-b border-slate-800 flex items-center justify-between">
|
||||
<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-lg text-slate-400 hover:text-white hover:bg-slate-800 transition"
|
||||
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>
|
||||
)}
|
||||
|
||||
<div className="p-4 flex-1 space-y-6 overflow-y-auto">
|
||||
{/* Navigation Group */}
|
||||
{/* Desktop Collapse / Expand Control Header */}
|
||||
{!mobileView && (
|
||||
<div
|
||||
className={`p-3 border-b border-slate-800/80 flex items-center ${
|
||||
collapsed ? 'justify-center' : 'justify-between'
|
||||
}`}
|
||||
>
|
||||
{!collapsed && (
|
||||
<span className="text-[11px] font-bold text-slate-400 uppercase tracking-wider pl-1.5 truncate">
|
||||
Portal Menu
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={toggleCollapse}
|
||||
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"
|
||||
title={collapsed ? 'Expand Sidebar (Ctrl+B)' : 'Collapse Sidebar (Ctrl+B)'}
|
||||
>
|
||||
{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>
|
||||
<div className="px-3 mb-2 text-[11px] font-semibold text-slate-500 uppercase tracking-wider">
|
||||
{!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;
|
||||
|
|
@ -106,18 +175,33 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
|||
<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 ${
|
||||
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 space-x-3">
|
||||
<Icon className={`w-4 h-4 ${isActive ? 'text-amber-400' : 'text-slate-400'}`} />
|
||||
<span>{item.label}</span>
|
||||
<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>
|
||||
{item.badge !== undefined && (
|
||||
<span className={`text-[10px] font-bold text-white px-2 py-0.5 rounded-full ${item.badgeColor}`}>
|
||||
{!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>
|
||||
)}
|
||||
|
|
@ -129,9 +213,13 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
|||
|
||||
{/* Administration Group */}
|
||||
<div>
|
||||
<div className="px-3 mb-2 text-[11px] font-semibold text-slate-500 uppercase tracking-wider">
|
||||
{!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;
|
||||
|
|
@ -140,14 +228,17 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
|||
<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 ${
|
||||
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 ${isActive ? 'text-amber-400' : 'text-slate-400'}`} />
|
||||
<span>{item.label}</span>
|
||||
<Icon className={`w-4 h-4 shrink-0 ${isActive ? 'text-amber-400' : 'text-slate-400'}`} />
|
||||
{!collapsed && <span className="truncate">{item.label}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
|
@ -156,9 +247,13 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
|||
|
||||
{/* Deployment & DevOps Group */}
|
||||
<div>
|
||||
<div className="px-3 mb-2 text-[11px] font-semibold text-slate-500 uppercase tracking-wider">
|
||||
{!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;
|
||||
|
|
@ -167,14 +262,17 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
|||
<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 ${
|
||||
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" />
|
||||
<span>{item.label}</span>
|
||||
<Icon className="w-4 h-4 text-emerald-400 shrink-0" />
|
||||
{!collapsed && <span className="truncate">{item.label}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
|
@ -182,35 +280,55 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Info Box */}
|
||||
<div className="p-4 border-t border-slate-800/80 bg-slate-950/50">
|
||||
{/* Footer Info / Toggle Shortcut Note */}
|
||||
<div className={`p-3 border-t border-slate-800/80 bg-slate-950/50 ${collapsed ? 'text-center' : ''}`}>
|
||||
{!collapsed ? (
|
||||
<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">
|
||||
<div className="flex items-center justify-between text-slate-200 font-medium">
|
||||
<div className="flex items-center space-x-2">
|
||||
<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>
|
||||
<kbd className="text-[10px] bg-slate-900 px-1.5 py-0.5 rounded text-slate-400 font-mono border border-slate-700">
|
||||
⌘B
|
||||
</kbd>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400 leading-tight">Automated SSL via Traefik reverse proxy.</p>
|
||||
</div>
|
||||
) : (
|
||||
<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"
|
||||
title="Expand Sidebar (Ctrl+B)"
|
||||
>
|
||||
<Database className="w-4 h-4 text-amber-400" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop 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)]">
|
||||
{sidebarContent}
|
||||
{/* Desktop Collapsible Sidebar */}
|
||||
<aside
|
||||
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>
|
||||
|
||||
{/* Mobile Drawer Backdrop & Sidebar */}
|
||||
{isOpenMobile && (
|
||||
<div className="fixed inset-0 z-40 md:hidden">
|
||||
<div className="fixed inset-0 z-50 md:hidden">
|
||||
<div
|
||||
className="fixed inset-0 bg-slate-950/80 backdrop-blur-sm transition-opacity"
|
||||
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">
|
||||
{sidebarContent}
|
||||
<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">
|
||||
{renderSidebarContent(true)}
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -218,3 +336,4 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
|||
);
|
||||
};
|
||||
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -209,7 +209,7 @@ export interface EmailNotificationLog {
|
|||
export type TimelineEventType = 'meeting' | 'circular' | 'milestone' | 'decision' | 'release' | 'audit' | 'event' | 'other';
|
||||
|
||||
export interface TimelineEvent {
|
||||
id: number;
|
||||
id: number | string;
|
||||
title: string;
|
||||
date: string; // YYYY-MM-DD
|
||||
time?: string; // HH:mm
|
||||
|
|
@ -221,6 +221,13 @@ export interface TimelineEvent {
|
|||
referenceNumber?: string; // e.g. Circular PIN-2026-001
|
||||
attachments?: Attachment[];
|
||||
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 {
|
||||
|
|
|
|||
291
src/utils/timelineGenerator.ts
Normal file
291
src/utils/timelineGenerator.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue