Initial Commit
This commit is contained in:
parent
d7eccee7b8
commit
5b2eafcbee
9 changed files with 1431 additions and 135 deletions
80
server.ts
80
server.ts
|
|
@ -229,10 +229,90 @@ app.post("/api/auth/change-password", authenticateToken, (req: any, res: any) =>
|
||||||
}
|
}
|
||||||
|
|
||||||
user.password = hashPassword(newPassword);
|
user.password = hashPassword(newPassword);
|
||||||
|
user.mustChangePassword = false;
|
||||||
|
|
||||||
res.json({ success: true, message: "Password updated successfully." });
|
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: `
|
||||||
|
<div style="font-family: sans-serif; padding: 20px; color: #333;">
|
||||||
|
<h2>Password Reset Code</h2>
|
||||||
|
<p>Dear ${user.fullName},</p>
|
||||||
|
<p>You requested a password reset for your Pinnacle Executive Portal account.</p>
|
||||||
|
<p>Your 6-digit verification code is: <strong style="font-size: 20px; color: #f59e0b;">${resetCode}</strong></p>
|
||||||
|
<p>If you did not request this, please contact your administrator immediately.</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
});
|
||||||
|
} 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
|
// Backend Secure SMTP Config Store
|
||||||
let backendSmtpConfig = {
|
let backendSmtpConfig = {
|
||||||
host: process.env.SMTP_HOST || 'smtp.mailtrap.io',
|
host: process.env.SMTP_HOST || 'smtp.mailtrap.io',
|
||||||
|
|
|
||||||
16
src/App.tsx
16
src/App.tsx
|
|
@ -11,6 +11,7 @@ import { RisksView } from './components/RisksView';
|
||||||
import { UsersView } from './components/UsersView';
|
import { UsersView } from './components/UsersView';
|
||||||
import { SmtpSettingsView } from './components/SmtpSettingsView';
|
import { SmtpSettingsView } from './components/SmtpSettingsView';
|
||||||
import { LogsView } from './components/LogsView';
|
import { LogsView } from './components/LogsView';
|
||||||
|
import { TimelineView } from './components/TimelineView';
|
||||||
import { DeploymentView } from './components/DeploymentView';
|
import { DeploymentView } from './components/DeploymentView';
|
||||||
import { LoginModal } from './components/LoginModal';
|
import { LoginModal } from './components/LoginModal';
|
||||||
import { ProfileModal } from './components/ProfileModal';
|
import { ProfileModal } from './components/ProfileModal';
|
||||||
|
|
@ -308,6 +309,15 @@ export default function App() {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'timeline' && (
|
||||||
|
<TimelineView
|
||||||
|
store={store}
|
||||||
|
onUpdateStore={handleUpdateStore}
|
||||||
|
userRole={currentUser?.role || 'viewer'}
|
||||||
|
currentUserName={currentUser?.fullName || 'System User'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeTab === 'users' && (
|
{activeTab === 'users' && (
|
||||||
<UsersView
|
<UsersView
|
||||||
store={store}
|
store={store}
|
||||||
|
|
@ -362,6 +372,12 @@ export default function App() {
|
||||||
setAuthToken(token);
|
setAuthToken(token);
|
||||||
setIsLoginModalOpen(false);
|
setIsLoginModalOpen(false);
|
||||||
}}
|
}}
|
||||||
|
onUpdateUsers={(updatedUsers) => {
|
||||||
|
handleUpdateStore({
|
||||||
|
...store,
|
||||||
|
users: updatedUsers
|
||||||
|
});
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,7 @@ export const Header: React.FC<HeaderProps> = ({
|
||||||
d.title.toLowerCase().includes(q) ||
|
d.title.toLowerCase().includes(q) ||
|
||||||
d.description?.toLowerCase().includes(q) ||
|
d.description?.toLowerCase().includes(q) ||
|
||||||
d.decisionBy?.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 =>
|
const matchedRisks = store && q ? store.risks.filter(r =>
|
||||||
|
|
@ -83,7 +83,7 @@ export const Header: React.FC<HeaderProps> = ({
|
||||||
r.mitigation?.toLowerCase().includes(q) ||
|
r.mitigation?.toLowerCase().includes(q) ||
|
||||||
r.category?.toLowerCase().includes(q) ||
|
r.category?.toLowerCase().includes(q) ||
|
||||||
r.owner?.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 =>
|
const matchedMeetings = store && q ? store.meetings.filter(m =>
|
||||||
|
|
|
||||||
|
|
@ -1,51 +1,112 @@
|
||||||
import React, { useState } from 'react';
|
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';
|
import { User as UserType } from '../types';
|
||||||
|
|
||||||
interface LoginModalProps {
|
interface LoginModalProps {
|
||||||
users: UserType[];
|
users: UserType[];
|
||||||
onLoginSuccess: (user: UserType, token: string) => void;
|
onLoginSuccess: (user: UserType, token: string) => void;
|
||||||
|
onUpdateUsers?: (updatedUsers: UserType[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LoginModal: React.FC<LoginModalProps> = ({ users, onLoginSuccess }) => {
|
type Mode = 'login' | 'forgot_password' | 'first_login_change_password';
|
||||||
const [username, setUsername] = useState('admin');
|
|
||||||
const [password, setPassword] = useState('Admin');
|
export const LoginModal: React.FC<LoginModalProps> = ({ users, onLoginSuccess, onUpdateUsers }) => {
|
||||||
|
const [mode, setMode] = useState<Mode>('login');
|
||||||
|
|
||||||
|
// Login form state
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
|
// General feedback
|
||||||
const [errorMsg, setErrorMsg] = useState('');
|
const [errorMsg, setErrorMsg] = useState('');
|
||||||
|
const [successMsg, setSuccessMsg] = useState('');
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const performLogin = async (un: string, pw: string) => {
|
// Pending target user for first login password change
|
||||||
setIsLoading(true);
|
const [targetUser, setTargetUser] = useState<UserType | null>(null);
|
||||||
|
const [targetToken, setTargetToken] = useState<string>('');
|
||||||
|
|
||||||
|
// 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('');
|
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 {
|
try {
|
||||||
const res = await fetch('/api/auth/login', {
|
const res = await fetch('/api/auth/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ username: un, password: pw })
|
body: JSON.stringify({ username: username.trim(), password })
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
if (!res.ok || !data.success) {
|
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
|
const foundUser: UserType = data.user;
|
||||||
if (data.token) {
|
const token: string = data.token;
|
||||||
localStorage.setItem('pinnacle_auth_token', 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) {
|
} catch (err: any) {
|
||||||
// Fallback offline handler if backend API isn't reached
|
// Offline / fallback handler matching stored user objects
|
||||||
const found = users.find(
|
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);
|
localStorage.setItem('pinnacle_auth_token', dummyToken);
|
||||||
onLoginSuccess(found, dummyToken);
|
onLoginSuccess(found, dummyToken);
|
||||||
|
} else if (found && found.active === false) {
|
||||||
|
setErrorMsg('Your account has been deactivated by an administrator.');
|
||||||
} else {
|
} else {
|
||||||
setErrorMsg(err.message || 'Invalid username or password.');
|
setErrorMsg(err.message || 'Invalid username or password.');
|
||||||
}
|
}
|
||||||
|
|
@ -54,134 +115,428 @@ export const LoginModal: React.FC<LoginModalProps> = ({ users, onLoginSuccess })
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCustomLogin = (e: React.FormEvent) => {
|
// Handle Password Change on First Login
|
||||||
|
const handleFirstLoginPasswordSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!username.trim() || !password.trim()) {
|
clearMessages();
|
||||||
setErrorMsg('Please enter both username and password.');
|
|
||||||
|
if (!newPassword || newPassword.length < 6) {
|
||||||
|
setErrorMsg('New password must be at least 6 characters long.');
|
||||||
return;
|
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) => {
|
// Step 1: Request Forgot Password Verification Code
|
||||||
const defaultPwd = user.role === 'admin' ? 'Admin' : user.role === 'editor' ? 'Editor' : 'Viewer';
|
const handleRequestForgotCode = async (e: React.FormEvent) => {
|
||||||
setUsername(user.username);
|
e.preventDefault();
|
||||||
setPassword(defaultPwd);
|
clearMessages();
|
||||||
performLogin(user.username, defaultPwd);
|
|
||||||
|
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 (
|
return (
|
||||||
<div className="fixed inset-0 bg-slate-950/90 backdrop-blur-md z-50 flex items-center justify-center p-4">
|
<div className="fixed inset-0 bg-slate-950/95 backdrop-blur-md z-50 flex items-center justify-center p-4">
|
||||||
<div className="bg-slate-900 border border-slate-800 rounded-3xl max-w-md w-full p-8 shadow-2xl space-y-6">
|
<div className="bg-slate-900 border border-slate-800 rounded-3xl max-w-md w-full p-8 shadow-2xl space-y-6 relative overflow-hidden">
|
||||||
<div className="text-center space-y-2">
|
{/* Glow Accent Header */}
|
||||||
<div className="w-12 h-12 rounded-2xl bg-amber-500/10 border border-amber-500/30 text-amber-400 mx-auto flex items-center justify-center shadow-inner">
|
<div className="absolute top-0 left-0 right-0 h-1.5 bg-gradient-to-r from-amber-500 via-amber-400 to-amber-600" />
|
||||||
<Crown className="w-7 h-7" />
|
|
||||||
|
<div className="text-center space-y-2 pt-2">
|
||||||
|
<div className="w-14 h-14 rounded-2xl bg-amber-500/10 border border-amber-500/30 text-amber-400 mx-auto flex items-center justify-center shadow-inner">
|
||||||
|
<Crown className="w-8 h-8" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl font-bold text-slate-100 tracking-tight">Pinnacle Portal</h2>
|
<h2 className="text-2xl font-bold text-slate-100 tracking-tight">Pinnacle Executive Portal</h2>
|
||||||
<div className="flex items-center justify-center space-x-1.5 text-xs text-amber-400 font-semibold">
|
<div className="flex items-center justify-center space-x-1.5 text-xs text-amber-400 font-semibold">
|
||||||
<ShieldCheck className="w-4 h-4 text-emerald-400" />
|
<ShieldCheck className="w-4 h-4 text-emerald-400" />
|
||||||
<span>Production Grade JWT Authentication</span>
|
<span>Mandatory Authentication & Single Sign-On</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Demo Profile Selector */}
|
{/* Global Feedback Banner */}
|
||||||
<div className="space-y-2">
|
{errorMsg && (
|
||||||
<label className="block text-[11px] font-bold text-slate-400 uppercase tracking-wider">
|
<div className="p-3 bg-rose-500/10 border border-rose-500/30 text-rose-300 text-xs rounded-xl flex items-center space-x-2">
|
||||||
Quick Sign In Profile
|
<AlertCircle className="w-4 h-4 shrink-0 text-rose-400" />
|
||||||
</label>
|
<span>{errorMsg}</span>
|
||||||
<div className="grid grid-cols-3 gap-2">
|
|
||||||
{users.map((u) => (
|
|
||||||
<button
|
|
||||||
key={u.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleQuickSelect(u)}
|
|
||||||
disabled={isLoading}
|
|
||||||
className="p-3 bg-slate-950 hover:bg-slate-800/80 border border-slate-800 hover:border-amber-500/40 rounded-xl text-left transition group space-y-1 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<div className="font-bold text-slate-200 text-xs capitalize group-hover:text-amber-400 flex items-center justify-between">
|
|
||||||
<span>{u.role}</span>
|
|
||||||
<KeyRound className="w-3 h-3 text-slate-500 group-hover:text-amber-400" />
|
|
||||||
</div>
|
|
||||||
<div className="text-[10px] text-slate-400 truncate">{u.fullName}</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
<div className="relative my-4">
|
{successMsg && (
|
||||||
<div className="absolute inset-0 flex items-center">
|
<div className="p-3 bg-emerald-500/10 border border-emerald-500/30 text-emerald-300 text-xs rounded-xl flex items-center space-x-2">
|
||||||
<div className="w-full border-t border-slate-800" />
|
<CheckCircle2 className="w-4 h-4 shrink-0 text-emerald-400" />
|
||||||
|
<span>{successMsg}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative flex justify-center text-[11px] uppercase">
|
)}
|
||||||
<span className="bg-slate-900 px-3 text-slate-500 font-semibold">Or Sign In With Credentials</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Authentication Form */}
|
{/* MODE 1: Standard Mandatory Login */}
|
||||||
<form onSubmit={handleCustomLogin} className="space-y-4">
|
{mode === 'login' && (
|
||||||
{errorMsg && (
|
<form onSubmit={handleCustomLogin} className="space-y-4">
|
||||||
<div className="p-3 bg-rose-500/10 border border-rose-500/30 text-rose-300 text-xs rounded-xl flex items-center space-x-2">
|
<div className="space-y-3.5 text-xs">
|
||||||
<AlertCircle className="w-4 h-4 shrink-0 text-rose-400" />
|
<div>
|
||||||
<span>{errorMsg}</span>
|
<label className="block text-slate-400 mb-1.5 font-medium flex items-center space-x-1.5">
|
||||||
</div>
|
<User className="w-3.5 h-3.5 text-amber-400" />
|
||||||
)}
|
<span>Username or Email</span>
|
||||||
|
</label>
|
||||||
<div className="space-y-3 text-xs">
|
|
||||||
<div>
|
|
||||||
<label className="block text-slate-400 mb-1 font-medium flex items-center space-x-1.5">
|
|
||||||
<User className="w-3.5 h-3.5 text-slate-400" />
|
|
||||||
<span>Username</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={username}
|
|
||||||
onChange={(e) => 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"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-slate-400 mb-1 font-medium flex items-center space-x-1.5">
|
|
||||||
<Lock className="w-3.5 h-3.5 text-slate-400" />
|
|
||||||
<span>Password</span>
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<input
|
<input
|
||||||
type={showPassword ? 'text' : 'password'}
|
type="text"
|
||||||
value={password}
|
value={username}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
placeholder="Enter password"
|
placeholder="Enter username (e.g. admin, pm, observer)"
|
||||||
required
|
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"
|
||||||
/>
|
/>
|
||||||
<button
|
</div>
|
||||||
type="button"
|
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
<div>
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-200"
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
>
|
<label className="text-slate-400 font-medium flex items-center space-x-1.5">
|
||||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
<Lock className="w-3.5 h-3.5 text-amber-400" />
|
||||||
</button>
|
<span>Password</span>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
clearMessages();
|
||||||
|
setMode('forgot_password');
|
||||||
|
setResetStep(1);
|
||||||
|
}}
|
||||||
|
className="text-[11px] text-amber-400 hover:text-amber-300 font-semibold hover:underline cursor-pointer"
|
||||||
|
>
|
||||||
|
Forgot Password?
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-200"
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="w-full py-3 bg-amber-500 hover:bg-amber-400 disabled:bg-slate-800 text-slate-950 disabled:text-slate-500 font-bold rounded-xl text-xs transition flex items-center justify-center space-x-2 shadow-lg cursor-pointer"
|
className="w-full py-3.5 bg-amber-500 hover:bg-amber-400 disabled:bg-slate-800 text-slate-950 disabled:text-slate-500 font-bold rounded-xl text-xs transition flex items-center justify-center space-x-2 shadow-lg cursor-pointer"
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<span>Authenticating JWT...</span>
|
<span>Authenticating Credentials...</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span>Sign In to Executive Portal</span>
|
||||||
|
<ArrowRight className="w-4 h-4" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* MODE 2: First Login Enforced Password Change */}
|
||||||
|
{mode === 'first_login_change_password' && (
|
||||||
|
<form onSubmit={handleFirstLoginPasswordSubmit} className="space-y-4">
|
||||||
|
<div className="p-3 bg-amber-500/10 border border-amber-500/30 rounded-2xl text-xs text-amber-200 space-y-1">
|
||||||
|
<div className="font-bold flex items-center space-x-1.5 text-amber-400">
|
||||||
|
<Lock className="w-4 h-4" />
|
||||||
|
<span>Action Required: First Login Password Update</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-slate-300">
|
||||||
|
Welcome <strong className="text-white">{targetUser?.fullName}</strong>. Your account administrator requires you to set a custom secure password before accessing the system.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 text-xs">
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 mb-1 font-medium">New Password</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type={showNewPassword ? 'text' : 'password'}
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowNewPassword(!showNewPassword)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-200"
|
||||||
|
>
|
||||||
|
{showNewPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 mb-1 font-medium">Confirm New Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full py-3.5 bg-amber-500 hover:bg-amber-400 disabled:bg-slate-800 text-slate-950 font-bold rounded-xl text-xs transition flex items-center justify-center space-x-2 shadow"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<span>Updating Password...</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span>Save New Password & Enter Portal</span>
|
||||||
|
<CheckCircle2 className="w-4 h-4" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* MODE 3: Forgot Password Flow */}
|
||||||
|
{mode === 'forgot_password' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-bold text-slate-100 flex items-center space-x-2">
|
||||||
|
<KeyRound className="w-4 h-4 text-amber-400" />
|
||||||
|
<span>Reset Portal Password</span>
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
clearMessages();
|
||||||
|
setMode('login');
|
||||||
|
}}
|
||||||
|
className="text-xs text-slate-400 hover:text-slate-200 flex items-center space-x-1"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-3.5 h-3.5" />
|
||||||
|
<span>Back to Sign In</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{resetStep === 1 ? (
|
||||||
|
<form onSubmit={handleRequestForgotCode} className="space-y-4">
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
Enter your registered username or email address below to receive password recovery instructions and a 6-digit verification pin.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-1 text-xs">
|
||||||
|
<label className="block text-slate-400 font-medium">Registered Username or Email</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={forgotIdentifier}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full py-3 bg-amber-500 hover:bg-amber-400 text-slate-950 font-bold rounded-xl text-xs transition flex items-center justify-center space-x-2 shadow cursor-pointer"
|
||||||
|
>
|
||||||
|
<Mail className="w-4 h-4" />
|
||||||
|
<span>Send Reset Instructions & PIN</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<form onSubmit={handleResetPasswordSubmit} className="space-y-3.5">
|
||||||
<span>Sign In to Portal</span>
|
<div className="space-y-3 text-xs">
|
||||||
<ArrowRight className="w-4 h-4" />
|
<div>
|
||||||
</>
|
<label className="block text-slate-400 mb-1 font-medium">6-Digit Verification PIN</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
maxLength={6}
|
||||||
|
value={inputCode}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 mb-1 font-medium">New Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={resetNewPass}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 mb-1 font-medium">Confirm New Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={resetConfirmPass}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full py-3 bg-amber-500 hover:bg-amber-400 text-slate-950 font-bold rounded-xl text-xs transition flex items-center justify-center space-x-2 shadow cursor-pointer"
|
||||||
|
>
|
||||||
|
<span>Set New Password & Return to Login</span>
|
||||||
|
<CheckCircle2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
)}
|
)}
|
||||||
</button>
|
</div>
|
||||||
</form>
|
)}
|
||||||
|
|
||||||
<div className="text-center text-[10px] text-slate-500 border-t border-slate-800/60 pt-3">
|
<div className="text-center text-[10px] text-slate-500 border-t border-slate-800/60 pt-3">
|
||||||
Protected by bcrypt password hashing & 24-hour signed JWT session tokens.
|
Protected by bcrypt password hashing & 24-hour signed JWT session tokens.
|
||||||
|
|
@ -190,4 +545,3 @@ export const LoginModal: React.FC<LoginModalProps> = ({ users, onLoginSuccess })
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
CalendarCheck,
|
CalendarCheck,
|
||||||
Handshake,
|
Handshake,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
|
Clock,
|
||||||
UserCog,
|
UserCog,
|
||||||
FileText,
|
FileText,
|
||||||
Mail,
|
Mail,
|
||||||
|
|
@ -49,6 +50,7 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
||||||
{ id: 'committees', label: 'PMT & PSC Committees', icon: Users },
|
{ id: 'committees', label: 'PMT & PSC Committees', icon: Users },
|
||||||
{ id: 'meetings', label: 'Meetings Tracker', icon: CalendarCheck },
|
{ id: 'meetings', label: 'Meetings Tracker', icon: CalendarCheck },
|
||||||
{ id: 'stakeholders', label: 'Stakeholder Matrix', icon: Handshake },
|
{ id: 'stakeholders', label: 'Stakeholder Matrix', icon: Handshake },
|
||||||
|
{ id: 'timeline', label: 'Timeline & Events', icon: Clock },
|
||||||
{
|
{
|
||||||
id: 'risks',
|
id: 'risks',
|
||||||
label: 'Risk Register',
|
label: 'Risk Register',
|
||||||
|
|
|
||||||
726
src/components/TimelineView.tsx
Normal file
726
src/components/TimelineView.tsx
Normal file
|
|
@ -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<TimelineViewProps> = ({
|
||||||
|
store,
|
||||||
|
onUpdateStore,
|
||||||
|
userRole = 'viewer',
|
||||||
|
currentUserName = 'System User'
|
||||||
|
}) => {
|
||||||
|
const events = store.timelineEvents || [];
|
||||||
|
const canEdit = userRole === 'admin' || userRole === 'editor';
|
||||||
|
|
||||||
|
// Search & Filter State
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [typeFilter, setTypeFilter] = useState<string>('all');
|
||||||
|
const [sortOrder, setSortOrder] = useState<'desc' | 'asc'>('desc');
|
||||||
|
|
||||||
|
// Modal State
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [editingEvent, setEditingEvent] = useState<Partial<TimelineEvent> | null>(null);
|
||||||
|
|
||||||
|
// Modal Form Inputs
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [eventType, setEventType] = useState<TimelineEventType>('circular');
|
||||||
|
const [category, setCategory] = useState('');
|
||||||
|
const [date, setDate] = useState(new Date().toISOString().split('T')[0]);
|
||||||
|
const [time, setTime] = useState('10:00');
|
||||||
|
const [referenceNumber, setReferenceNumber] = useState('');
|
||||||
|
const [location, setLocation] = useState('');
|
||||||
|
const [author, setAuthor] = useState(currentUserName);
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||||
|
|
||||||
|
// Open modal to create a new event
|
||||||
|
const handleOpenCreateModal = () => {
|
||||||
|
setEditingEvent(null);
|
||||||
|
setTitle('');
|
||||||
|
setEventType('circular');
|
||||||
|
setCategory('Official Circular');
|
||||||
|
setDate(new Date().toISOString().split('T')[0]);
|
||||||
|
setTime('10:00');
|
||||||
|
setReferenceNumber(`PIN-${new Date().getFullYear()}-${Math.floor(100 + Math.random() * 900)}`);
|
||||||
|
setLocation('Executive Office');
|
||||||
|
setAuthor(currentUserName);
|
||||||
|
setDescription('');
|
||||||
|
setAttachments([]);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Open modal to edit existing event
|
||||||
|
const handleOpenEditModal = (event: TimelineEvent) => {
|
||||||
|
setEditingEvent(event);
|
||||||
|
setTitle(event.title);
|
||||||
|
setEventType(event.type);
|
||||||
|
setCategory(event.category || '');
|
||||||
|
setDate(event.date);
|
||||||
|
setTime(event.time || '');
|
||||||
|
setReferenceNumber(event.referenceNumber || '');
|
||||||
|
setLocation(event.location || '');
|
||||||
|
setAuthor(event.author || currentUserName);
|
||||||
|
setDescription(event.description);
|
||||||
|
setAttachments(event.attachments || []);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save event (Create or Update)
|
||||||
|
const handleSaveEvent = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!title.trim() || !date) return;
|
||||||
|
|
||||||
|
let updatedEvents = [...events];
|
||||||
|
|
||||||
|
if (editingEvent && editingEvent.id) {
|
||||||
|
// Update existing
|
||||||
|
updatedEvents = updatedEvents.map((evt) =>
|
||||||
|
evt.id === editingEvent.id
|
||||||
|
? {
|
||||||
|
...evt,
|
||||||
|
title: title.trim(),
|
||||||
|
type: eventType,
|
||||||
|
category: category.trim() || undefined,
|
||||||
|
date,
|
||||||
|
time: time.trim() || undefined,
|
||||||
|
referenceNumber: referenceNumber.trim() || undefined,
|
||||||
|
location: location.trim() || undefined,
|
||||||
|
author: author.trim() || currentUserName,
|
||||||
|
description: description.trim(),
|
||||||
|
attachments
|
||||||
|
}
|
||||||
|
: evt
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Create new
|
||||||
|
const nextId = (store.ids.timelineEvent || events.length + 1) + 1;
|
||||||
|
const newEvt: TimelineEvent = {
|
||||||
|
id: nextId,
|
||||||
|
title: title.trim(),
|
||||||
|
type: eventType,
|
||||||
|
category: category.trim() || getTypeLabel(eventType),
|
||||||
|
date,
|
||||||
|
time: time.trim() || undefined,
|
||||||
|
referenceNumber: referenceNumber.trim() || undefined,
|
||||||
|
location: location.trim() || undefined,
|
||||||
|
author: author.trim() || currentUserName,
|
||||||
|
description: description.trim(),
|
||||||
|
attachments,
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
updatedEvents.push(newEvt);
|
||||||
|
}
|
||||||
|
|
||||||
|
onUpdateStore({
|
||||||
|
...store,
|
||||||
|
timelineEvents: updatedEvents,
|
||||||
|
ids: {
|
||||||
|
...store.ids,
|
||||||
|
timelineEvent: (store.ids.timelineEvent || events.length + 1) + 1
|
||||||
|
},
|
||||||
|
logs: [
|
||||||
|
{
|
||||||
|
id: store.ids.log || Date.now(),
|
||||||
|
action: editingEvent ? 'TIMELINE_EVENT_UPDATED' : 'TIMELINE_EVENT_CREATED',
|
||||||
|
type: 'timeline',
|
||||||
|
details: `${editingEvent ? 'Updated' : 'Created'} timeline event: ${title.trim()} (${referenceNumber || eventType})`,
|
||||||
|
user: currentUserName,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
},
|
||||||
|
...store.logs
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsModalOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Delete Event
|
||||||
|
const handleDeleteEvent = (id: number) => {
|
||||||
|
if (!window.confirm('Are you sure you want to delete this timeline event?')) return;
|
||||||
|
|
||||||
|
const updatedEvents = events.filter((e) => e.id !== id);
|
||||||
|
onUpdateStore({
|
||||||
|
...store,
|
||||||
|
timelineEvents: updatedEvents,
|
||||||
|
logs: [
|
||||||
|
{
|
||||||
|
id: store.ids.log || Date.now(),
|
||||||
|
action: 'TIMELINE_EVENT_DELETED',
|
||||||
|
type: 'timeline',
|
||||||
|
details: `Deleted timeline event ID #${id}`,
|
||||||
|
user: currentUserName,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
},
|
||||||
|
...store.logs
|
||||||
|
]
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter & Sort Events
|
||||||
|
const filteredEvents = events
|
||||||
|
.filter((e) => {
|
||||||
|
if (typeFilter !== 'all' && e.type !== typeFilter) return false;
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
const q = searchQuery.toLowerCase();
|
||||||
|
return (
|
||||||
|
e.title.toLowerCase().includes(q) ||
|
||||||
|
e.description.toLowerCase().includes(q) ||
|
||||||
|
(e.referenceNumber && e.referenceNumber.toLowerCase().includes(q)) ||
|
||||||
|
(e.category && e.category.toLowerCase().includes(q)) ||
|
||||||
|
(e.author && e.author.toLowerCase().includes(q))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
const dateA = new Date(`${a.date}T${a.time || '00:00'}`).getTime();
|
||||||
|
const dateB = new Date(`${b.date}T${b.time || '00:00'}`).getTime();
|
||||||
|
return sortOrder === 'desc' ? dateB - dateA : dateA - dateB;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helpers for Type Colors & Icons
|
||||||
|
function getTypeBadge(type: TimelineEventType) {
|
||||||
|
switch (type) {
|
||||||
|
case 'circular':
|
||||||
|
return 'bg-cyan-500/10 text-cyan-400 border-cyan-500/30';
|
||||||
|
case 'meeting':
|
||||||
|
return 'bg-amber-500/10 text-amber-400 border-amber-500/30';
|
||||||
|
case 'milestone':
|
||||||
|
return 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30';
|
||||||
|
case 'decision':
|
||||||
|
return 'bg-purple-500/10 text-purple-400 border-purple-500/30';
|
||||||
|
case 'release':
|
||||||
|
case 'audit':
|
||||||
|
return 'bg-rose-500/10 text-rose-400 border-rose-500/30';
|
||||||
|
default:
|
||||||
|
return 'bg-slate-800 text-slate-300 border-slate-700';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTypeIcon(type: TimelineEventType) {
|
||||||
|
switch (type) {
|
||||||
|
case 'circular':
|
||||||
|
return <FileText className="w-4 h-4 text-cyan-400" />;
|
||||||
|
case 'meeting':
|
||||||
|
return <Users className="w-4 h-4 text-amber-400" />;
|
||||||
|
case 'milestone':
|
||||||
|
return <Award className="w-4 h-4 text-emerald-400" />;
|
||||||
|
case 'decision':
|
||||||
|
return <Gavel className="w-4 h-4 text-purple-400" />;
|
||||||
|
case 'release':
|
||||||
|
case 'audit':
|
||||||
|
return <ShieldAlert className="w-4 h-4 text-rose-400" />;
|
||||||
|
default:
|
||||||
|
return <Clock className="w-4 h-4 text-slate-400" />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTypeLabel(type: TimelineEventType) {
|
||||||
|
switch (type) {
|
||||||
|
case 'circular':
|
||||||
|
return 'Circular Published';
|
||||||
|
case 'meeting':
|
||||||
|
return 'Committee Meeting';
|
||||||
|
case 'milestone':
|
||||||
|
return 'Milestone Reached';
|
||||||
|
case 'decision':
|
||||||
|
return 'Key Decision';
|
||||||
|
case 'release':
|
||||||
|
return 'Official Release';
|
||||||
|
case 'audit':
|
||||||
|
return 'Audit Review';
|
||||||
|
default:
|
||||||
|
return 'Custom Event';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count Statistics
|
||||||
|
const circularsCount = events.filter((e) => e.type === 'circular').length;
|
||||||
|
const meetingsCount = events.filter((e) => e.type === 'meeting').length;
|
||||||
|
const milestonesCount = events.filter((e) => e.type === 'milestone').length;
|
||||||
|
const docsCount = events.reduce((acc, e) => acc + (e.attachments ? e.attachments.length : 0), 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Page Header */}
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 bg-slate-900 border border-slate-800/80 p-6 rounded-3xl shadow-xl">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="p-2.5 bg-amber-500/10 border border-amber-500/30 rounded-2xl text-amber-400">
|
||||||
|
<Clock className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-slate-100">Interactive Timeline & Circular Registry</h1>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
Chronological record of official meetings, circulars published, milestone achievements, and executive directives.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canEdit && (
|
||||||
|
<button
|
||||||
|
onClick={handleOpenCreateModal}
|
||||||
|
className="px-4 py-2.5 bg-amber-500 hover:bg-amber-400 text-slate-950 font-bold rounded-xl text-xs flex items-center justify-center space-x-2 shadow-lg transition cursor-pointer self-start md:self-auto shrink-0"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
<span>Create Timeline Event</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary Analytics Cards */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div className="bg-slate-900 border border-slate-800 p-4 rounded-2xl space-y-1">
|
||||||
|
<div className="flex items-center justify-between text-xs text-slate-400">
|
||||||
|
<span>Total Timeline Events</span>
|
||||||
|
<Clock className="w-4 h-4 text-amber-400" />
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-extrabold text-slate-100">{events.length}</div>
|
||||||
|
<div className="text-[10px] text-slate-500">Historical & scheduled entries</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-slate-900 border border-slate-800 p-4 rounded-2xl space-y-1">
|
||||||
|
<div className="flex items-center justify-between text-xs text-slate-400">
|
||||||
|
<span>Circulars Issued</span>
|
||||||
|
<FileText className="w-4 h-4 text-cyan-400" />
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-extrabold text-cyan-400">{circularsCount}</div>
|
||||||
|
<div className="text-[10px] text-slate-500">Official governance publications</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-slate-900 border border-slate-800 p-4 rounded-2xl space-y-1">
|
||||||
|
<div className="flex items-center justify-between text-xs text-slate-400">
|
||||||
|
<span>Committee Meetings</span>
|
||||||
|
<Users className="w-4 h-4 text-amber-400" />
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-extrabold text-amber-400">{meetingsCount}</div>
|
||||||
|
<div className="text-[10px] text-slate-500">PMT & PSC sessions logged</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-slate-900 border border-slate-800 p-4 rounded-2xl space-y-1">
|
||||||
|
<div className="flex items-center justify-between text-xs text-slate-400">
|
||||||
|
<span>Supporting Documents</span>
|
||||||
|
<Paperclip className="w-4 h-4 text-emerald-400" />
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-extrabold text-emerald-400">{docsCount}</div>
|
||||||
|
<div className="text-[10px] text-slate-500">Attached files & circular PDFs</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter and Search Bar */}
|
||||||
|
<div className="bg-slate-900 border border-slate-800 p-4 rounded-2xl space-y-3">
|
||||||
|
<div className="flex flex-col md:flex-row gap-3 items-center justify-between">
|
||||||
|
{/* Search Box */}
|
||||||
|
<div className="relative w-full md:w-80">
|
||||||
|
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Search timeline, circular ref, title..."
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl pl-9 pr-3 py-2 text-xs text-slate-200 focus:outline-none focus:border-amber-500 transition"
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSearchQuery('')}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-500 hover:text-slate-300"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Type Filter Tabs */}
|
||||||
|
<div className="flex items-center space-x-1 overflow-x-auto w-full md:w-auto pb-1 md:pb-0 text-xs">
|
||||||
|
{[
|
||||||
|
{ id: 'all', label: 'All Events' },
|
||||||
|
{ id: 'circular', label: 'Circulars' },
|
||||||
|
{ id: 'meeting', label: 'Meetings' },
|
||||||
|
{ id: 'milestone', label: 'Milestones' },
|
||||||
|
{ id: 'decision', label: 'Decisions' }
|
||||||
|
].map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setTypeFilter(tab.id)}
|
||||||
|
className={`px-3 py-1.5 rounded-lg font-medium whitespace-nowrap transition cursor-pointer ${
|
||||||
|
typeFilter === tab.id
|
||||||
|
? 'bg-amber-500 text-slate-950 font-bold shadow'
|
||||||
|
: 'bg-slate-950 text-slate-400 hover:bg-slate-800 hover:text-slate-200 border border-slate-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Sort Toggle */}
|
||||||
|
<button
|
||||||
|
onClick={() => setSortOrder(sortOrder === 'desc' ? 'asc' : 'desc')}
|
||||||
|
className="px-3 py-1.5 bg-slate-950 hover:bg-slate-800 text-slate-300 rounded-lg border border-slate-800 flex items-center space-x-1 font-medium transition cursor-pointer ml-auto"
|
||||||
|
title="Toggle Date Sort Order"
|
||||||
|
>
|
||||||
|
<ArrowUpDown className="w-3.5 h-3.5 text-amber-400" />
|
||||||
|
<span className="text-[11px]">{sortOrder === 'desc' ? 'Newest First' : 'Oldest First'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Vertical Interactive Timeline */}
|
||||||
|
<div className="bg-slate-900 border border-slate-800 rounded-3xl p-6 shadow-xl relative">
|
||||||
|
{filteredEvents.length === 0 ? (
|
||||||
|
<div className="py-16 text-center space-y-3">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-slate-800 text-slate-500 mx-auto flex items-center justify-center">
|
||||||
|
<Clock className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-semibold text-slate-300">No timeline events match your search criteria</div>
|
||||||
|
<p className="text-xs text-slate-500 max-w-sm mx-auto">
|
||||||
|
Try adjusting your search query, selecting "All Events", or creating a new timeline entry.
|
||||||
|
</p>
|
||||||
|
{canEdit && (
|
||||||
|
<button
|
||||||
|
onClick={handleOpenCreateModal}
|
||||||
|
className="px-4 py-2 bg-amber-500 hover:bg-amber-400 text-slate-950 font-bold rounded-xl text-xs inline-flex items-center space-x-1.5 shadow mt-2 cursor-pointer"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
<span>Create Timeline Event</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="relative pl-6 md:pl-8 border-l-2 border-slate-800 space-y-8 my-2">
|
||||||
|
{filteredEvents.map((evt) => (
|
||||||
|
<div key={evt.id} className="relative group">
|
||||||
|
{/* Pulsing Timeline Node */}
|
||||||
|
<div
|
||||||
|
className={`absolute -left-[31px] md:-left-[39px] top-1.5 w-6 h-6 rounded-full border-2 flex items-center justify-center shadow-lg transition-transform group-hover:scale-110 ${
|
||||||
|
evt.type === 'circular'
|
||||||
|
? 'bg-slate-900 border-cyan-400 text-cyan-400 shadow-cyan-500/20'
|
||||||
|
: evt.type === 'meeting'
|
||||||
|
? 'bg-slate-900 border-amber-400 text-amber-400 shadow-amber-500/20'
|
||||||
|
: evt.type === 'milestone'
|
||||||
|
? 'bg-slate-900 border-emerald-400 text-emerald-400 shadow-emerald-500/20'
|
||||||
|
: evt.type === 'decision'
|
||||||
|
? 'bg-slate-900 border-purple-400 text-purple-400 shadow-purple-500/20'
|
||||||
|
: 'bg-slate-900 border-rose-400 text-rose-400 shadow-rose-500/20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="w-2 h-2 rounded-full bg-current" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Event Card */}
|
||||||
|
<div className="bg-slate-950/80 border border-slate-800/90 rounded-2xl p-5 hover:border-slate-700 transition shadow-md space-y-3">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-2 border-b border-slate-800/60 pb-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{/* Type Badge */}
|
||||||
|
<span
|
||||||
|
className={`px-2.5 py-0.5 rounded-lg text-[10px] font-bold border inline-flex items-center space-x-1 ${getTypeBadge(
|
||||||
|
evt.type
|
||||||
|
)}`}
|
||||||
|
>
|
||||||
|
{getTypeIcon(evt.type)}
|
||||||
|
<span>{evt.category || getTypeLabel(evt.type)}</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Reference Number if present */}
|
||||||
|
{evt.referenceNumber && (
|
||||||
|
<span className="px-2 py-0.5 bg-slate-800 text-amber-300 font-mono text-[10px] rounded border border-slate-700 font-bold inline-flex items-center space-x-1">
|
||||||
|
<Tag className="w-3 h-3 text-amber-400" />
|
||||||
|
<span>Ref: {evt.referenceNumber}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="text-base font-bold text-slate-100 group-hover:text-amber-400 transition">
|
||||||
|
{evt.title}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls for Admin / Editor */}
|
||||||
|
<div className="flex items-center space-x-1 opacity-90 sm:opacity-0 group-hover:opacity-100 transition">
|
||||||
|
{canEdit && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => handleOpenEditModal(evt)}
|
||||||
|
className="p-1.5 text-slate-400 hover:text-amber-400 hover:bg-slate-800 rounded-lg transition"
|
||||||
|
title="Edit Event"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteEvent(evt.id)}
|
||||||
|
className="p-1.5 text-slate-400 hover:text-rose-400 hover:bg-slate-800 rounded-lg transition"
|
||||||
|
title="Delete Event"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metadata Bar */}
|
||||||
|
<div className="flex flex-wrap items-center gap-y-2 gap-x-4 text-xs text-slate-400">
|
||||||
|
<div className="flex items-center space-x-1.5 text-slate-300 font-medium">
|
||||||
|
<Calendar className="w-3.5 h-3.5 text-amber-400" />
|
||||||
|
<span>{evt.date}</span>
|
||||||
|
{evt.time && <span className="text-slate-500">at {evt.time}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{evt.location && (
|
||||||
|
<div className="flex items-center space-x-1 text-slate-400">
|
||||||
|
<MapPin className="w-3.5 h-3.5 text-slate-500" />
|
||||||
|
<span>{evt.location}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{evt.author && (
|
||||||
|
<div className="flex items-center space-x-1 text-slate-400">
|
||||||
|
<User className="w-3.5 h-3.5 text-slate-500" />
|
||||||
|
<span>Published by: <strong className="text-slate-300 font-normal">{evt.author}</strong></span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description Body */}
|
||||||
|
<p className="text-xs text-slate-300 leading-relaxed whitespace-pre-line">{evt.description}</p>
|
||||||
|
|
||||||
|
{/* Supporting Attachments Display */}
|
||||||
|
{evt.attachments && evt.attachments.length > 0 && (
|
||||||
|
<div className="pt-2 border-t border-slate-800/60">
|
||||||
|
<div className="text-[11px] font-bold text-slate-400 mb-2 flex items-center space-x-1.5">
|
||||||
|
<Paperclip className="w-3.5 h-3.5 text-amber-400" />
|
||||||
|
<span>Supporting Documents & Circular Files ({evt.attachments.length})</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{evt.attachments.map((att) => (
|
||||||
|
<a
|
||||||
|
key={att.id}
|
||||||
|
href={att.url}
|
||||||
|
download={att.name}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="px-3 py-1.5 bg-slate-900 hover:bg-slate-800 border border-slate-800 hover:border-amber-500/40 text-slate-200 rounded-xl text-xs flex items-center space-x-2 transition group/file"
|
||||||
|
>
|
||||||
|
<FileText className="w-3.5 h-3.5 text-amber-400 shrink-0" />
|
||||||
|
<span className="font-medium truncate max-w-[180px]">{att.name}</span>
|
||||||
|
<span className="text-[10px] text-slate-500">
|
||||||
|
({(att.size / 1024).toFixed(0)} KB)
|
||||||
|
</span>
|
||||||
|
<Download className="w-3.5 h-3.5 text-slate-500 group-hover/file:text-amber-400 transition shrink-0 ml-1" />
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CREATE / EDIT TIMELINE EVENT MODAL */}
|
||||||
|
{isModalOpen && (
|
||||||
|
<div className="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-50 flex items-center justify-center p-4 overflow-y-auto">
|
||||||
|
<div className="bg-slate-900 border border-slate-800 rounded-3xl max-w-xl w-full p-6 shadow-2xl space-y-5 my-8 max-h-[90vh] overflow-y-auto">
|
||||||
|
<div className="flex items-center justify-between border-b border-slate-800 pb-3">
|
||||||
|
<h2 className="text-lg font-bold text-slate-100 flex items-center space-x-2">
|
||||||
|
<Clock className="w-5 h-5 text-amber-400" />
|
||||||
|
<span>{editingEvent ? 'Edit Timeline Event' : 'Create Custom Timeline Event'}</span>
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsModalOpen(false)}
|
||||||
|
className="p-1 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSaveEvent} className="space-y-4 text-xs">
|
||||||
|
{/* Event Title */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 mb-1 font-medium">Event Title *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="e.g. Published Circular PIN-2026-003 or Mid-Year Strategy Review"
|
||||||
|
required
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-2.5 text-slate-200 focus:outline-none focus:border-amber-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Event Type & Category */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 mb-1 font-medium">Event Type</label>
|
||||||
|
<select
|
||||||
|
value={eventType}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newT = e.target.value as TimelineEventType;
|
||||||
|
setEventType(newT);
|
||||||
|
if (!category || category === getTypeLabel(eventType)) {
|
||||||
|
setCategory(getTypeLabel(newT));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-2.5 text-slate-200 focus:outline-none focus:border-amber-500"
|
||||||
|
>
|
||||||
|
<option value="circular">Circular Published</option>
|
||||||
|
<option value="meeting">Committee Meeting</option>
|
||||||
|
<option value="milestone">Milestone Reached</option>
|
||||||
|
<option value="decision">Executive Decision</option>
|
||||||
|
<option value="release">Official Release / Launch</option>
|
||||||
|
<option value="audit">Audit & Compliance Review</option>
|
||||||
|
<option value="other">Other Event</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 mb-1 font-medium">Category / Badge Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={category}
|
||||||
|
onChange={(e) => setCategory(e.target.value)}
|
||||||
|
placeholder="e.g. Official Circular, Board Directive"
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-2.5 text-slate-200 focus:outline-none focus:border-amber-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date, Time & Reference Number */}
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 mb-1 font-medium">Date *</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={date}
|
||||||
|
onChange={(e) => setDate(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-2.5 text-slate-200 focus:outline-none focus:border-amber-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 mb-1 font-medium">Time (Optional)</label>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={time}
|
||||||
|
onChange={(e) => setTime(e.target.value)}
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-2.5 text-slate-200 focus:outline-none focus:border-amber-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 mb-1 font-medium">Reference Number</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={referenceNumber}
|
||||||
|
onChange={(e) => setReferenceNumber(e.target.value)}
|
||||||
|
placeholder="e.g. PIN-2026-003"
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-2.5 text-amber-300 font-mono focus:outline-none focus:border-amber-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Location & Author */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 mb-1 font-medium">Location / Venue</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={location}
|
||||||
|
onChange={(e) => setLocation(e.target.value)}
|
||||||
|
placeholder="e.g. Main Boardroom / Portal"
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-2.5 text-slate-200 focus:outline-none focus:border-amber-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 mb-1 font-medium">Author / Published By</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={author}
|
||||||
|
onChange={(e) => setAuthor(e.target.value)}
|
||||||
|
placeholder="e.g. PMO Office"
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-2.5 text-slate-200 focus:outline-none focus:border-amber-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 mb-1 font-medium">Description / Details</label>
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Provide comprehensive details, agenda, rationale or context for this timeline event..."
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-2.5 text-slate-200 focus:outline-none focus:border-amber-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Attachment Uploader */}
|
||||||
|
<div className="pt-2 border-t border-slate-800">
|
||||||
|
<AttachmentUploader
|
||||||
|
attachments={attachments}
|
||||||
|
onChange={setAttachments}
|
||||||
|
label="Upload Supporting Documents (PDF, Circulars, Reports)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-3 pt-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsModalOpen(false)}
|
||||||
|
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-xl font-medium cursor-pointer"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="px-5 py-2 bg-amber-500 hover:bg-amber-400 text-slate-950 font-bold rounded-xl shadow cursor-pointer"
|
||||||
|
>
|
||||||
|
{editingEvent ? 'Save Changes' : 'Create Event'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -18,7 +18,8 @@ export const UsersView: React.FC<UsersViewProps> = ({
|
||||||
const [newGeneratedPass, setNewGeneratedPass] = useState('');
|
const [newGeneratedPass, setNewGeneratedPass] = useState('');
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [roleFilter, setRoleFilter] = useState<string>('all');
|
const [roleFilter, setRoleFilter] = useState<string>('all');
|
||||||
const [editingUser, setEditingUser] = useState<Partial<User>>({ role: 'viewer', active: true });
|
const [editingUser, setEditingUser] = useState<Partial<User>>({ role: 'viewer', active: true, mustChangePassword: true });
|
||||||
|
const [enforcePassChangeOnReset, setEnforcePassChangeOnReset] = useState(true);
|
||||||
|
|
||||||
const isAdmin = userRole === 'admin';
|
const isAdmin = userRole === 'admin';
|
||||||
|
|
||||||
|
|
@ -55,6 +56,7 @@ export const UsersView: React.FC<UsersViewProps> = ({
|
||||||
email: newUserEmail,
|
email: newUserEmail,
|
||||||
dept: editingUser.dept || 'Strategy Unit',
|
dept: editingUser.dept || 'Strategy Unit',
|
||||||
active: editingUser.active !== undefined ? editingUser.active : true,
|
active: editingUser.active !== undefined ? editingUser.active : true,
|
||||||
|
mustChangePassword: editingUser.mustChangePassword !== undefined ? editingUser.mustChangePassword : true,
|
||||||
lastLogin: null
|
lastLogin: null
|
||||||
};
|
};
|
||||||
updatedUsers.push(newU);
|
updatedUsers.push(newU);
|
||||||
|
|
@ -128,7 +130,7 @@ export const UsersView: React.FC<UsersViewProps> = ({
|
||||||
const handleResetPassword = () => {
|
const handleResetPassword = () => {
|
||||||
if (!resetPassUser || !newGeneratedPass) return;
|
if (!resetPassUser || !newGeneratedPass) return;
|
||||||
const updatedUsers = store.users.map(u =>
|
const updatedUsers = store.users.map(u =>
|
||||||
u.id === resetPassUser.id ? { ...u, password: newGeneratedPass } : u
|
u.id === resetPassUser.id ? { ...u, password: newGeneratedPass, mustChangePassword: enforcePassChangeOnReset } : u
|
||||||
);
|
);
|
||||||
onUpdateStore({ ...store, users: updatedUsers });
|
onUpdateStore({ ...store, users: updatedUsers });
|
||||||
setResetPassUser(null);
|
setResetPassUser(null);
|
||||||
|
|
@ -274,17 +276,27 @@ export const UsersView: React.FC<UsersViewProps> = ({
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-4 text-slate-300">{u.dept}</td>
|
<td className="p-4 text-slate-300">{u.dept}</td>
|
||||||
<td className="p-4">
|
<td className="p-4 space-y-1">
|
||||||
{u.active !== false ? (
|
<div>
|
||||||
<span className="text-[11px] font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1 rounded-lg border border-emerald-500/20 inline-flex items-center space-x-1">
|
{u.active !== false ? (
|
||||||
<CheckCircle className="w-3 h-3" />
|
<span className="text-[11px] font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1 rounded-lg border border-emerald-500/20 inline-flex items-center space-x-1">
|
||||||
<span>Active</span>
|
<CheckCircle className="w-3 h-3" />
|
||||||
</span>
|
<span>Active</span>
|
||||||
) : (
|
</span>
|
||||||
<span className="text-[11px] font-semibold text-rose-400 bg-rose-500/10 px-2.5 py-1 rounded-lg border border-rose-500/20 inline-flex items-center space-x-1">
|
) : (
|
||||||
<XCircle className="w-3 h-3" />
|
<span className="text-[11px] font-semibold text-rose-400 bg-rose-500/10 px-2.5 py-1 rounded-lg border border-rose-500/20 inline-flex items-center space-x-1">
|
||||||
<span>Disabled</span>
|
<XCircle className="w-3 h-3" />
|
||||||
</span>
|
<span>Disabled</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{u.mustChangePassword && (
|
||||||
|
<div>
|
||||||
|
<span className="text-[10px] font-bold text-amber-300 bg-amber-500/10 px-2 py-0.5 rounded border border-amber-500/30 inline-flex items-center space-x-1">
|
||||||
|
<Lock className="w-3 h-3" />
|
||||||
|
<span>Must Change Password</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
|
|
@ -425,6 +437,18 @@ export const UsersView: React.FC<UsersViewProps> = ({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-1">
|
||||||
|
<label className="flex items-center space-x-2 text-slate-300 font-medium cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={editingUser.mustChangePassword !== false}
|
||||||
|
onChange={(e) => setEditingUser({ ...editingUser, mustChangePassword: e.target.checked })}
|
||||||
|
className="rounded bg-slate-950 border-slate-700 text-amber-500 focus:ring-amber-500"
|
||||||
|
/>
|
||||||
|
<span>Enforce password change on user's first login</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end space-x-3 pt-3">
|
<div className="flex justify-end space-x-3 pt-3">
|
||||||
|
|
@ -476,6 +500,18 @@ export const UsersView: React.FC<UsersViewProps> = ({
|
||||||
<RefreshCw className="w-4 h-4" />
|
<RefreshCw className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-2">
|
||||||
|
<label className="flex items-center space-x-2 text-xs text-slate-300 font-medium cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={enforcePassChangeOnReset}
|
||||||
|
onChange={(e) => setEnforcePassChangeOnReset(e.target.checked)}
|
||||||
|
className="rounded bg-slate-950 border-slate-700 text-amber-500 focus:ring-amber-500"
|
||||||
|
/>
|
||||||
|
<span>Require user to change password on next login</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end space-x-2 pt-2">
|
<div className="flex justify-end space-x-2 pt-2">
|
||||||
|
|
|
||||||
|
|
@ -461,6 +461,67 @@ export function getDefaultData(): ProjectStore {
|
||||||
triggerEvent: 'SYSTEM_INIT'
|
triggerEvent: 'SYSTEM_INIT'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
timelineEvents: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: 'Project Kick-off & Charter Sign-off',
|
||||||
|
date: '2026-05-15',
|
||||||
|
time: '10:00',
|
||||||
|
type: 'meeting',
|
||||||
|
category: 'Committee Meeting',
|
||||||
|
description: 'Formal kickoff meeting with PMT and PSC stakeholders to review governance, deliverables, and timeline milestones.',
|
||||||
|
author: 'System Administrator',
|
||||||
|
location: 'Executive Boardroom & Virtual Teams',
|
||||||
|
createdAt: now
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: 'Published Governance Circular PIN-2026-001',
|
||||||
|
date: '2026-05-20',
|
||||||
|
time: '09:00',
|
||||||
|
type: 'circular',
|
||||||
|
category: 'Official Circular',
|
||||||
|
referenceNumber: 'PIN-2026-001',
|
||||||
|
description: 'Official governance baseline circular issued to all business unit leads detailing reporting protocols and escalation thresholds.',
|
||||||
|
author: 'Project Management Office',
|
||||||
|
createdAt: now
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
title: 'Inception Report Approved & Milestone M1 Reached',
|
||||||
|
date: '2026-06-01',
|
||||||
|
time: '14:30',
|
||||||
|
type: 'milestone',
|
||||||
|
category: 'Project Milestone',
|
||||||
|
description: 'Inception report and quantitative research methodology validated and endorsed by the Steering Committee.',
|
||||||
|
author: 'Sarah Chen',
|
||||||
|
createdAt: now
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
title: 'Published Survey Instructions Circular PIN-2026-002',
|
||||||
|
date: '2026-06-22',
|
||||||
|
time: '11:00',
|
||||||
|
type: 'circular',
|
||||||
|
category: 'Official Circular',
|
||||||
|
referenceNumber: 'PIN-2026-002',
|
||||||
|
description: 'Guidelines issued for department heads regarding the execution of qualitative and quantitative stakeholder surveys.',
|
||||||
|
author: 'Strategy Advisory Unit',
|
||||||
|
createdAt: now
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
title: 'Mid-Year Strategic Diagnostic Review',
|
||||||
|
date: '2026-08-05',
|
||||||
|
time: '15:00',
|
||||||
|
type: 'meeting',
|
||||||
|
category: 'Strategic Review',
|
||||||
|
description: 'Executive committee alignment session reviewing market diagnostic findings, PESTEL analysis, and peer benchmarks.',
|
||||||
|
author: 'Elena Rostova',
|
||||||
|
location: 'Virtual Governance Portal',
|
||||||
|
createdAt: now
|
||||||
|
}
|
||||||
|
],
|
||||||
ids: {
|
ids: {
|
||||||
user: 4,
|
user: 4,
|
||||||
phase: 5,
|
phase: 5,
|
||||||
|
|
@ -470,7 +531,8 @@ export function getDefaultData(): ProjectStore {
|
||||||
risk: 4,
|
risk: 4,
|
||||||
log: 2,
|
log: 2,
|
||||||
meeting: 3,
|
meeting: 3,
|
||||||
stakeholder: 5
|
stakeholder: 5,
|
||||||
|
timelineEvent: 6
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
20
src/types.ts
20
src/types.ts
|
|
@ -9,6 +9,7 @@ export interface User {
|
||||||
email: string;
|
email: string;
|
||||||
dept: string;
|
dept: string;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
|
mustChangePassword?: boolean;
|
||||||
lastLogin?: string | null;
|
lastLogin?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -205,6 +206,23 @@ export interface EmailNotificationLog {
|
||||||
triggerEvent: string;
|
triggerEvent: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TimelineEventType = 'meeting' | 'circular' | 'milestone' | 'decision' | 'release' | 'audit' | 'event' | 'other';
|
||||||
|
|
||||||
|
export interface TimelineEvent {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
date: string; // YYYY-MM-DD
|
||||||
|
time?: string; // HH:mm
|
||||||
|
type: TimelineEventType;
|
||||||
|
category?: string;
|
||||||
|
description: string;
|
||||||
|
author?: string;
|
||||||
|
location?: string;
|
||||||
|
referenceNumber?: string; // e.g. Circular PIN-2026-001
|
||||||
|
attachments?: Attachment[];
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProjectStore {
|
export interface ProjectStore {
|
||||||
users: User[];
|
users: User[];
|
||||||
projectStart: string;
|
projectStart: string;
|
||||||
|
|
@ -221,6 +239,7 @@ export interface ProjectStore {
|
||||||
stakeholders: Stakeholder[];
|
stakeholders: Stakeholder[];
|
||||||
decisions: Decision[];
|
decisions: Decision[];
|
||||||
risks: Risk[];
|
risks: Risk[];
|
||||||
|
timelineEvents?: TimelineEvent[];
|
||||||
logs: ActivityLog[];
|
logs: ActivityLog[];
|
||||||
smtpConfig?: SmtpConfig;
|
smtpConfig?: SmtpConfig;
|
||||||
emailLogs?: EmailNotificationLog[];
|
emailLogs?: EmailNotificationLog[];
|
||||||
|
|
@ -235,6 +254,7 @@ export interface ProjectStore {
|
||||||
log: number;
|
log: number;
|
||||||
meeting: number;
|
meeting: number;
|
||||||
stakeholder: number;
|
stakeholder: number;
|
||||||
|
timelineEvent?: number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue