Replace src folder with new version

This commit is contained in:
priyanshuvish
2025-09-26 19:45:02 +05:30
parent d6be8abdec
commit d11112c8e9
65 changed files with 57577 additions and 4986 deletions

View File

@@ -1,5 +1,4 @@
import React, { useEffect, useState } from "react";
import { Routes, Route, Navigate, useNavigate } from "react-router-dom";
import React, { useState, useEffect } from "react";
import { Login } from "./components/auth/Login";
import { ForgotPassword } from "./components/auth/ForgotPassword";
import { TwoFactorAuth } from "./components/auth/TwoFactorAuth";
@@ -10,25 +9,76 @@ import { IndividualLearners } from "./components/pages/IndividualLearners";
import { Organizations } from "./components/pages/Organizations";
import { NewOrganization } from "./components/pages/NewOrganization";
import { ContentManager } from "./components/pages/ContentManager";
import { NewBlog } from "./components/pages/NewBlog";
import { NewFAQ } from "./components/pages/NewFAQ";
import { Courses } from "./components/pages/Courses";
import { CourseBuilder } from "./components/pages/CourseBuilder";
import { Profilers } from "./components/pages/Profilers";
import { Profilers } from "./components/pages/ProfilersEnhanced";
import { ProfilerBuilder } from "./components/pages/ProfilerBuilder";
import { LandingPages } from "./components/pages/LandingPages";
import { ProfilerMaster } from "./components/pages/ProfilerMaster";
import { ProfilerPreview } from "./components/pages/ProfilerPreview";
import { LandingPages } from "./components/pages/LandingPagesNew";
import { HomeEditor } from "./components/pages/HomeEditor";
import { ServicesEditor } from "./components/pages/ServicesEditor";
import { AboutUsEditor } from "./components/pages/AboutUsEditor";
import { Webinars } from "./components/pages/Webinars";
import { Programmes } from "./components/pages/Programmes";
import { ProgrammeComposer } from "./components/pages/ProgrammeComposer";
import { ProgrammeAssignment } from "./components/pages/ProgrammeAssignment";
import { CourseAssignment } from "./components/pages/CourseAssignment";
import { ClassScheduler } from "./components/pages/ClassScheduler";
import { OpenProgramme } from "./components/pages/OpenProgramme";
import { Facilities } from "./components/pages/Facilities";
import { Facilities360 } from "./components/pages/Facilities360";
import { Facilities360New } from "./components/pages/Facilities360New";
import { Facilities360Detail } from "./components/pages/Facilities360Detail";
import { Leads } from "./components/pages/Leads";
import { Roles } from "./components/pages/Roles";
import { Analytics } from "./components/pages/Analytics";
import { Toaster } from "./components/ui/sonner";
import { Settings } from "lucide-react";
import { SESSION_CONFIG, AutoSaveData } from "./data/mockData";
type Route =
| "/login"
| "/login/forget-password"
| "/login/2fa"
| "/dashboard"
| "/profile"
| "/profile/reset-password"
| "/users/individual"
| "/users/organizations"
| "/users/organizations/new"
| "/content"
| "/content/blogs/new"
| "/content/faqs/new"
| "/courses"
| "/courses/course-builder"
| "/courses/new"
| `/courses/${string}/assign`
| "/profilers"
| "/profilers/new"
| "/profilers/preview"
| "/landing-pages"
| "/landing-pages/edit/home"
| "/landing-pages/edit/services"
| "/landing-pages/edit/about-us"
| "/webinars"
| "/programmes"
| "/programmes/new"
| `/programmes/${string}/assign`
| "/class-scheduler"
| "/open-programme"
| "/facilities-360"
| "/facilities-360/new"
| `/facilities-360/${string}`
| "/admin/leads"
| "/admin/roles"
| "/admin/analytics"
| "/admin/profiler-master";
export default function App() {
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null); // null = loading
const [currentRoute, setCurrentRoute] = useState<Route>("/login");
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [user, setUser] = useState({
name: "Admin User",
email: "admin@klc.edu",
@@ -37,150 +87,189 @@ export default function App() {
lastLogin: "2024-01-15 14:30",
});
const navigate = useNavigate();
// Auto-save state management
const [autoSaveData, setAutoSaveData] = useState<{ [key: string]: any }>({});
// Simulate authentication check
useEffect(() => {
const authToken = localStorage.getItem("klc_auth_token");
setIsAuthenticated(!!authToken); // true if token exists, false otherwise
if (authToken) {
setIsAuthenticated(true);
if (currentRoute === "/login") {
setCurrentRoute("/dashboard");
}
}
}, []);
// Auto-save functionality
const handleAutoSave = (formData: any) => {
const routeKey = `${currentRoute}_${user.email}`;
setAutoSaveData(prev => ({
...prev,
[routeKey]: formData
}));
// Persist to localStorage
const autoSaveData: AutoSaveData = {
userId: user.email,
route: currentRoute,
formData,
timestamp: new Date().toISOString(),
isValid: true
};
localStorage.setItem(`autosave_${routeKey}`, JSON.stringify(autoSaveData));
};
// Get auto-saved data for current route
const getAutoSavedData = () => {
const routeKey = `${currentRoute}_${user.email}`;
return autoSaveData[routeKey] || null;
};
// Clear auto-saved data
const clearAutoSavedData = (route?: string) => {
const routeKey = route ? `${route}_${user.email}` : `${currentRoute}_${user.email}`;
setAutoSaveData(prev => {
const newData = { ...prev };
delete newData[routeKey];
return newData;
});
localStorage.removeItem(`autosave_${routeKey}`);
};
const navigate = (route: Route) => {
setCurrentRoute(route);
};
const login = () => {
localStorage.setItem("klc_auth_token", "mock_token");
// Set longer session timeout
const expirationTime = Date.now() + SESSION_CONFIG.LOGOUT_TIMEOUT;
localStorage.setItem("klc_auth_expiration", expirationTime.toString());
setIsAuthenticated(true);
navigate("/dashboard");
};
const logout = () => {
localStorage.removeItem("klc_auth_token");
localStorage.removeItem("klc_auth_expiration");
// Clear all auto-saved data for this user
Object.keys(localStorage).forEach(key => {
if (key.startsWith(`autosave_`) && key.includes(user.email)) {
localStorage.removeItem(key);
}
});
setIsAuthenticated(false);
setAutoSaveData({});
navigate("/login");
};
if (isAuthenticated === null) {
// while checking localStorage
return <div className="flex justify-center items-center h-screen">Loading...</div>;
}
const renderPage = () => {
if (!isAuthenticated && !currentRoute.startsWith("/login")) {
return <Login onLogin={login} onNavigate={navigate} />;
}
const commonProps = {
onNavigate: navigate,
onLogout: logout,
user,
formData: getAutoSavedData(),
onAutoSave: handleAutoSave,
onClearAutoSave: clearAutoSavedData
};
switch (currentRoute) {
case "/login":
return <Login onLogin={login} onNavigate={navigate} />;
case "/login/forget-password":
return <ForgotPassword onNavigate={navigate} />;
case "/login/2fa":
return <TwoFactorAuth onLogin={login} onNavigate={navigate} />;
case "/dashboard":
return <Dashboard {...commonProps} />;
case "/profile":
return <Profile {...commonProps} />;
case "/profile/reset-password":
return <ResetPassword {...commonProps} />;
case "/users/individual":
return <IndividualLearners {...commonProps} />;
case "/users/organizations":
return <Organizations {...commonProps} />;
case "/users/organizations/new":
return <NewOrganization {...commonProps} />;
case "/content":
return <ContentManager {...commonProps} />;
case "/content/blogs/new":
return <NewBlog {...commonProps} />;
case "/content/faqs/new":
return <NewFAQ {...commonProps} />;
case "/courses":
return <Courses {...commonProps} />;
case "/courses/course-builder":
case "/courses/new":
return <CourseBuilder {...commonProps} />;
case "/profilers":
return <Profilers {...commonProps} />;
case "/profilers/new":
return <ProfilerBuilder {...commonProps} />;
case "/profilers/preview":
return <ProfilerPreview onBack={() => navigate("/dashboard")} />;
case "/admin/profiler-master":
return <ProfilerMaster {...commonProps} />;
case "/landing-pages":
return <LandingPages {...commonProps} />;
case "/landing-pages/edit/home":
return <HomeEditor {...commonProps} />;
case "/landing-pages/edit/services":
return <ServicesEditor {...commonProps} />;
case "/landing-pages/edit/about-us":
return <AboutUsEditor {...commonProps} />;
case "/webinars":
return <Webinars {...commonProps} />;
case "/programmes":
return <Programmes {...commonProps} />;
case "/programmes/new":
return <ProgrammeComposer {...commonProps} />;
case "/class-scheduler":
return <ClassScheduler {...commonProps} />;
case "/open-programme":
return <OpenProgramme {...commonProps} />;
case "/facilities-360":
return <Facilities360 {...commonProps} />;
case "/facilities-360/new":
return <Facilities360New {...commonProps} />;
case "/admin/leads":
return <Leads {...commonProps} />;
case "/admin/roles":
return <Roles {...commonProps} />;
case "/admin/analytics":
return <Analytics {...commonProps} />;
default:
// Handle dynamic routes
if (currentRoute.startsWith("/programmes/") && currentRoute.endsWith("/assign")) {
const programmeId = currentRoute.split("/")[2];
return <ProgrammeAssignment programmeId={programmeId} {...commonProps} />;
}
if (currentRoute.startsWith("/courses/") && currentRoute.endsWith("/assign")) {
const courseId = currentRoute.split("/")[2];
return <CourseAssignment courseId={courseId} {...commonProps} />;
}
if (currentRoute.startsWith("/facilities-360/") && !currentRoute.endsWith("/new")) {
const tourId = currentRoute.split("/")[2];
return <Facilities360Detail tourId={tourId} {...commonProps} />;
}
return <Dashboard {...commonProps} />;
}
};
return (
<div className="min-h-screen bg-background">
<Routes>
{/* Public Routes */}
<Route path="/login" element={<Login onLogin={login} />} />
<Route
path="/forget-password"
element={<ForgotPassword />}
/>
<Route
path="/login/2fa"
element={<TwoFactorAuth onLogin={login} />}
/>
{/* Protected Routes */}
{isAuthenticated ? (
<>
<Route
path="/dashboard"
element={<Dashboard onLogout={logout} user={user} />}
/>
<Route
path="/profile"
element={<Profile onLogout={logout} user={user} />}
/>
<Route
path="/profile/reset-password"
element={<ResetPassword onLogout={logout} user={user} />}
/>
<Route
path="/users/individual"
element={<IndividualLearners onLogout={logout} user={user} />}
/>
<Route
path="/users/organizations"
element={<Organizations onLogout={logout} user={user} />}
/>
<Route
path="/users/organizations/new"
element={<NewOrganization onLogout={logout} user={user} />}
/>
<Route
path="/content"
element={<ContentManager onLogout={logout} user={user} />}
/>
<Route
path="/courses"
element={<Courses onLogout={logout} user={user} />}
/>
<Route
path="/courses/course-builder"
element={<CourseBuilder onLogout={logout} user={user} />}
/>
<Route
path="/courses/new"
element={<CourseBuilder onLogout={logout} user={user} />}
/>
<Route
path="/profilers"
element={<Profilers onLogout={logout} user={user} />}
/>
<Route
path="/profilers/new"
element={<ProfilerBuilder onLogout={logout} user={user} />}
/>
<Route
path="/landing-pages"
element={<LandingPages onLogout={logout} user={user} />}
/>
<Route
path="/webinars"
element={<Webinars onLogout={logout} user={user} />}
/>
<Route
path="/programmes"
element={<Programmes onLogout={logout} user={user} />}
/>
<Route
path="/programmes/new"
element={<ProgrammeComposer onLogout={logout} user={user} />}
/>
<Route
path="/class-scheduler"
element={<ClassScheduler onLogout={logout} user={user} />}
/>
<Route
path="/open-programme"
element={<OpenProgramme onLogout={logout} user={user} />}
/>
<Route
path="/admin/facilities"
element={<Facilities onLogout={logout} user={user} />}
/>
<Route
path="/admin/leads"
element={<Leads onLogout={logout} user={user} />}
/>
<Route
path="/admin/roles"
element={<Roles onLogout={logout} user={user} />}
/>
<Route
path="/admin/analytics"
element={<Analytics onLogout={logout} user={user} />}
/>
{/* <Route
path="/settings"
element={<Settings onLogout={logout} user={user} />}
/> */}
</>
) : (
// Redirect if not authenticated
<Route path="*" element={<Navigate to="/login" replace />} />
)}
{/* Default Redirects */}
<Route path="/" element={<Navigate to="/login" replace />} />
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
<Toaster />
<div className="h-screen bg-background overflow-hidden">
{renderPage()}
<Toaster position="top-right" />
</div>
);
}
}

View File

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -0,0 +1,461 @@
import React, { useState, useEffect } from 'react';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Textarea } from './ui/textarea';
import { Badge } from './ui/badge';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from './ui/select';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './ui/dialog';
import { Checkbox } from './ui/checkbox';
import { Alert, AlertDescription } from './ui/alert';
import { AlertCircle, ExternalLink } from 'lucide-react';
import { toast } from "sonner@2.0.3";
interface IpsativeItem {
selfText: string;
feedbackText: string;
subDimensionId: string;
order: number;
}
interface IpsativeQuestion {
id?: string;
ipsative?: {
question: string;
allowZeroSum: boolean;
statementCount?: number;
items: IpsativeItem[];
};
}
interface IpsativeQuestionEditorProps {
isOpen: boolean;
onClose: () => void;
question: IpsativeQuestion | null;
sectionDefaultN: number; // Section default statement count (1-5)
subDimensions: { id: string; name: string; description?: string }[];
onSave: (questionData: IpsativeQuestion) => void;
}
export function IpsativeQuestionEditor({
isOpen,
onClose,
question,
sectionDefaultN,
subDimensions,
onSave
}: IpsativeQuestionEditorProps) {
// Question-level state
const [questionText, setQuestionText] = useState('');
const [allowZeroSum, setAllowZeroSum] = useState(false);
const [statementCount, setStatementCount] = useState(sectionDefaultN);
const [items, setItems] = useState<IpsativeItem[]>([]);
const [showSyncPrompt, setShowSyncPrompt] = useState(false);
const [previousSectionDefault, setPreviousSectionDefault] = useState(sectionDefaultN);
// Initialize form when question or modal opens
useEffect(() => {
if (isOpen) {
if (question?.ipsative) {
setQuestionText(question.ipsative.question || '');
setAllowZeroSum(question.ipsative.allowZeroSum || false);
setStatementCount(question.ipsative.statementCount || sectionDefaultN);
setItems(question.ipsative.items || []);
} else {
// New question - use section defaults
setQuestionText('');
setAllowZeroSum(false);
setStatementCount(sectionDefaultN);
setItems(Array.from({ length: sectionDefaultN }, (_, i) => ({
selfText: '',
feedbackText: '',
subDimensionId: '',
order: i + 1
})));
}
setPreviousSectionDefault(sectionDefaultN);
}
}, [isOpen, question, sectionDefaultN]);
// Handle section default change while modal is open
useEffect(() => {
if (isOpen && sectionDefaultN !== previousSectionDefault) {
// Only show sync prompt if question's N equals the old section default
if (statementCount === previousSectionDefault) {
setShowSyncPrompt(true);
}
setPreviousSectionDefault(sectionDefaultN);
}
}, [sectionDefaultN, previousSectionDefault, statementCount, isOpen]);
// Update statement count and manage items
const updateStatementCount = (newN: number) => {
const oldN = statementCount;
setStatementCount(newN);
if (newN > oldN) {
// Add blank items for new statements
const newItems = [...items];
for (let i = oldN; i < newN; i++) {
newItems.push({
selfText: '',
feedbackText: '',
subDimensionId: '',
order: i + 1
});
}
setItems(newItems);
} else if (newN < oldN) {
// Trim items from the end
setItems(items.slice(0, newN));
}
// Live announcement for accessibility
const announcement = `Statements set to ${newN}`;
const liveRegion = document.createElement('div');
liveRegion.setAttribute('aria-live', 'polite');
liveRegion.setAttribute('aria-atomic', 'true');
liveRegion.className = 'sr-only';
liveRegion.textContent = announcement;
document.body.appendChild(liveRegion);
setTimeout(() => document.body.removeChild(liveRegion), 1000);
};
// Auto-fill order utility
const autoFillOrders = () => {
const updatedItems = items.map((item, index) => ({
...item,
order: index + 1
}));
setItems(updatedItems);
toast.success(`Auto-filled orders 1…${statementCount}`);
};
// Update item field
const updateItem = (index: number, field: keyof IpsativeItem, value: string | number) => {
const updatedItems = [...items];
updatedItems[index] = { ...updatedItems[index], [field]: value };
setItems(updatedItems);
};
// Validation
const validateForm = () => {
if (!questionText.trim()) {
toast.error('Question is required');
return false;
}
if (statementCount < 1 || statementCount > 5) {
toast.error('Number of statements must be between 1 and 5');
return false;
}
// Validate each statement
for (let i = 0; i < statementCount; i++) {
const item = items[i];
if (!item.subDimensionId) {
toast.error(`Sub Dimension is required for statement ${i + 1}`);
return false;
}
}
// Validate unique orders
const orders = items.slice(0, statementCount).map(item => item.order);
const uniqueOrders = new Set(orders);
if (uniqueOrders.size !== orders.length) {
toast.error('Order values must be unique');
return false;
}
return true;
};
// Handle save
const handleSave = () => {
if (!validateForm()) return;
const questionData: IpsativeQuestion = {
id: question?.id,
ipsative: {
question: questionText.trim(),
allowZeroSum,
statementCount,
items: items.slice(0, statementCount)
}
};
onSave(questionData);
onClose();
};
// Handle sync prompt
const handleSyncToSectionDefault = () => {
updateStatementCount(sectionDefaultN);
setShowSyncPrompt(false);
toast.success(`Updated to section default: ${sectionDefaultN} statements`);
};
return (
<>
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent
className="max-w-[1040px] min-w-[920px] max-h-[82vh] flex flex-col"
style={{ borderWidth: '2px' }}
>
{/* Sticky Header */}
<DialogHeader className="flex-shrink-0 pb-4 border-b">
<DialogTitle>
{question?.id ? 'Edit Ipsative Question' : 'Add Ipsative Question'}
</DialogTitle>
<DialogDescription>
Configure forced-choice statements for this question
</DialogDescription>
</DialogHeader>
{/* Scrollable Body */}
<div className="flex-1 px-1 overflow-y-auto">
<div className="space-y-6 py-4">
{/* Sub Dimensions Validation Banner */}
{(!subDimensions || subDimensions.length === 0) && (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription className="flex items-center justify-between">
<span>Add Sub Dimensions in Profiler Master to continue.</span>
<Button
variant="outline"
size="sm"
onClick={() => window.open('/admin/profiler-master', '_blank')}
className="ml-4"
>
<ExternalLink className="h-4 w-4 mr-2" />
Open Profiler Master
</Button>
</AlertDescription>
</Alert>
)}
{/* Top Area */}
<div className="space-y-4">
{/* Question Text */}
<div>
<Label htmlFor="question-text" className="text-sm font-medium">
Question <span className="text-red-500">*</span>
</Label>
<Textarea
id="question-text"
value={questionText}
onChange={(e) => setQuestionText(e.target.value)}
placeholder="Enter the question text..."
rows={3}
className="mt-1 min-h-[44px]"
style={{ borderWidth: '2px' }}
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
{/* Per-Question Statement Count */}
<div>
<Label className="text-sm font-medium">Number of Statements (15)</Label>
<Select
value={statementCount.toString()}
onValueChange={(value) => updateStatementCount(parseInt(value))}
>
<SelectTrigger className="min-h-[44px] mt-1" style={{ borderWidth: '2px' }}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1 Statement</SelectItem>
<SelectItem value="2">2 Statements</SelectItem>
<SelectItem value="3">3 Statements</SelectItem>
<SelectItem value="4">4 Statements</SelectItem>
<SelectItem value="5">5 Statements</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground mt-1">
Section default: {sectionDefaultN}
</p>
</div>
{/* Allow Zero Sum */}
<div className="flex items-center space-x-2 mt-6">
<Checkbox
id="allow-zero-sum"
checked={allowZeroSum}
onCheckedChange={setAllowZeroSum}
className="min-h-[44px] min-w-[44px]"
/>
<Label htmlFor="allow-zero-sum" className="text-sm font-medium">
Allow Sum of Responses to be Zero?
</Label>
</div>
</div>
</div>
{/* Statement Rows */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium">
Statement Configuration ({statementCount} statements)
</h4>
<Button
variant="link"
size="sm"
onClick={autoFillOrders}
className="text-xs"
style={{ color: 'var(--color-brand-primary)' }}
>
Auto-fill orders 1{statementCount}
</Button>
</div>
<div className="space-y-4">
{Array.from({ length: statementCount }, (_, index) => {
const item = items[index] || {
selfText: '',
feedbackText: '',
subDimensionId: '',
order: index + 1
};
return (
<div key={index} className="p-4 border rounded-lg space-y-3" style={{ borderWidth: '2px' }}>
<div className="flex items-center justify-between">
<Badge variant="outline">Statement {index + 1}</Badge>
<div className="w-24">
<Label className="text-xs">Order</Label>
<Input
type="number"
min="1"
max={statementCount}
value={item.order}
onChange={(e) => updateItem(index, 'order', parseInt(e.target.value) || 1)}
className="mt-1 text-center min-h-[36px]"
style={{ borderWidth: '2px' }}
/>
<p className="text-xs text-muted-foreground mt-1">Lower shows earlier</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
{/* Self Statement */}
<div>
<Label className="text-sm font-medium">Self Statement</Label>
<Textarea
value={item.selfText}
onChange={(e) => updateItem(index, 'selfText', e.target.value)}
placeholder="Enter self-assessment statement..."
rows={2}
className="mt-1 min-h-[44px]"
style={{ borderWidth: '2px' }}
/>
</div>
{/* Feedback Statement */}
<div>
<Label className="text-sm font-medium">Feedback Statement</Label>
<Textarea
value={item.feedbackText}
onChange={(e) => updateItem(index, 'feedbackText', e.target.value)}
placeholder="Enter feedback statement..."
rows={2}
className="mt-1 min-h-[44px]"
style={{ borderWidth: '2px' }}
/>
</div>
</div>
{/* Sub Dimension */}
<div>
<Label className="text-sm font-medium">
Sub Dimension <span className="text-red-500">*</span>
</Label>
<Select
value={item.subDimensionId}
onValueChange={(value) => updateItem(index, 'subDimensionId', value)}
disabled={!subDimensions || subDimensions.length === 0}
>
<SelectTrigger className="min-h-[44px] mt-1" style={{ borderWidth: '2px' }}>
<SelectValue placeholder="Select sub dimension..." />
</SelectTrigger>
<SelectContent>
{(subDimensions || []).map((subDim) => (
<SelectItem key={subDim.id} value={subDim.id}>
{subDim.name}
{subDim.description && (
<span className="text-muted-foreground"> {subDim.description}</span>
)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
);
})}
</div>
</div>
</div>
</div>
{/* Sticky Footer */}
<DialogFooter className="flex-shrink-0 pt-4 border-t">
<Button variant="outline" onClick={onClose} className="min-h-[44px]">
Cancel
</Button>
<Button
onClick={handleSave}
disabled={subDimensions.length === 0}
className="min-h-[44px]"
style={{ backgroundColor: 'var(--color-brand-primary)' }}
>
{question?.id ? 'Update Question' : 'Add Question'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Section Default Sync Prompt */}
<Dialog open={showSyncPrompt} onOpenChange={setShowSyncPrompt}>
<DialogContent>
<DialogHeader>
<DialogTitle>Section Default Changed</DialogTitle>
<DialogDescription>
Section default is now {sectionDefaultN}. Update this question to {sectionDefaultN} as well?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowSyncPrompt(false)}
className="min-h-[44px]"
>
Keep as is
</Button>
<Button
onClick={handleSyncToSectionDefault}
className="min-h-[44px]"
style={{ backgroundColor: 'var(--color-brand-primary)' }}
>
Update
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,425 @@
import React, { useState, useEffect } from 'react';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Badge } from './ui/badge';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from './ui/select';
import { Alert, AlertDescription } from './ui/alert';
import { Separator } from './ui/separator';
import { toast } from "sonner@2.0.3";
import { AlertCircle, Link as LinkIcon } from 'lucide-react';
interface LikertQuestionEditorProps {
isOpen: boolean;
onClose: () => void;
question: any;
sectionDefaultK: number;
options: Array<{ id: string; label: string; order: number }>;
subDimensions: Array<{ id: string; name: string; description: string }>;
onSave: (questionData: any) => void;
}
export function LikertQuestionEditor({
isOpen,
onClose,
question,
sectionDefaultK,
options,
subDimensions,
onSave
}: LikertQuestionEditorProps) {
const [formData, setFormData] = useState({
selfQuestion: '',
feedbackQuestion: '',
subDimensionId: '',
order: 1,
optionCount: sectionDefaultK,
options: [] as Array<{ optionId: string; order: number }>
});
const [errors, setErrors] = useState<string[]>([]);
useEffect(() => {
if (question) {
setFormData({
selfQuestion: question.selfQuestion || '',
feedbackQuestion: question.feedbackQuestion || '',
subDimensionId: question.subDimensionId || '',
order: question.order || 1,
optionCount: question.likert?.optionCount || sectionDefaultK,
options: question.likert?.options || []
});
} else {
// New question - initialize with section default
setFormData({
selfQuestion: '',
feedbackQuestion: '',
subDimensionId: '',
order: 1,
optionCount: sectionDefaultK,
options: Array.from({ length: sectionDefaultK }, (_, i) => ({ optionId: '', order: i + 1 }))
});
}
}, [question, sectionDefaultK]);
const updateOptionCount = (newK: number) => {
const currentOptions = [...formData.options];
if (newK > currentOptions.length) {
// Add blank option slots
for (let i = currentOptions.length; i < newK; i++) {
currentOptions.push({ optionId: '', order: i + 1 });
}
} else if (newK < currentOptions.length) {
// Trim extra options
currentOptions.splice(newK);
}
setFormData({
...formData,
optionCount: newK,
options: currentOptions
});
// Accessibility announcement
const announcement = `Options set to ${newK}`;
const liveRegion = document.createElement('div');
liveRegion.setAttribute('aria-live', 'polite');
liveRegion.setAttribute('aria-atomic', 'true');
liveRegion.className = 'sr-only';
liveRegion.textContent = announcement;
document.body.appendChild(liveRegion);
setTimeout(() => document.body.removeChild(liveRegion), 1000);
};
const autoFillOptionOrders = () => {
const K = formData.optionCount;
const updatedOptions = [...formData.options];
for (let i = 0; i < K; i++) {
updatedOptions[i] = {
...updatedOptions[i],
order: i + 1
};
}
setFormData({
...formData,
options: updatedOptions
});
toast.success(`Auto-filled option orders 1 to ${K}`);
};
const updateOptionSelection = (index: number, optionId: string) => {
const updatedOptions = [...formData.options];
updatedOptions[index] = {
...updatedOptions[index],
optionId
};
setFormData({
...formData,
options: updatedOptions
});
};
const updateOptionOrder = (index: number, order: number) => {
const updatedOptions = [...formData.options];
updatedOptions[index] = {
...updatedOptions[index],
order
};
setFormData({
...formData,
options: updatedOptions
});
};
const validateForm = () => {
const newErrors = [];
if (!formData.selfQuestion.trim()) {
newErrors.push('Self Question is required');
}
if (!formData.feedbackQuestion.trim()) {
newErrors.push('Feedback Question is required');
}
if (!formData.subDimensionId) {
newErrors.push('Sub Dimension is required');
}
if (formData.optionCount < 1 || formData.optionCount > 6) {
newErrors.push('Option count must be between 1 and 6');
}
const selectedOptions = formData.options.filter(opt => opt.optionId);
if (selectedOptions.length !== formData.optionCount) {
newErrors.push(`Exactly ${formData.optionCount} options must be selected`);
}
const orderValues = selectedOptions.map(opt => opt.order);
const uniqueOrders = new Set(orderValues);
if (uniqueOrders.size !== orderValues.length) {
newErrors.push('Option orders must be unique');
}
const validOrders = orderValues.every(order => order >= 1 && order <= formData.optionCount);
if (!validOrders) {
newErrors.push(`Option orders must be integers between 1 and ${formData.optionCount}`);
}
// Check if Profiler Master data is missing
if (options.length === 0) {
newErrors.push('No options available in Profiler Master. Please add options before creating questions.');
}
if (subDimensions.length === 0) {
newErrors.push('No sub dimensions available in Profiler Master. Please add sub dimensions before creating questions.');
}
setErrors(newErrors);
return newErrors.length === 0;
};
const handleSave = () => {
if (validateForm()) {
onSave({
...formData,
likert: {
optionCount: formData.optionCount,
options: formData.options
}
});
onClose();
toast.success('Likert question saved successfully');
}
};
const isDataMissing = options.length === 0 || subDimensions.length === 0;
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-[1040px] min-w-[920px] max-h-[82vh] flex flex-col">
<DialogHeader className="sticky top-0 bg-white z-10 pb-4 border-b">
<DialogTitle>Edit Question Likert</DialogTitle>
<DialogDescription>
Configure Likert scale question with options and settings
</DialogDescription>
</DialogHeader>
<div className="flex-1 px-1 overflow-y-auto">
<div className="space-y-6 py-4">
{/* Data Missing Banner */}
{isDataMissing && (
<Alert className="border-orange-200 bg-orange-50">
<AlertCircle className="h-4 w-4 text-orange-600" />
<AlertDescription className="text-orange-800">
Missing Profiler Master data. Please add {options.length === 0 ? 'Options' : ''}
{options.length === 0 && subDimensions.length === 0 ? ' and ' : ''}
{subDimensions.length === 0 ? 'Sub Dimensions' : ''} to Profiler Master before creating questions.
</AlertDescription>
</Alert>
)}
{/* Top Row Fields */}
<div className="grid grid-cols-4 gap-4">
<div>
<Label className="text-sm font-medium">
Self Question <span className="text-red-500">*</span>
</Label>
<Input
value={formData.selfQuestion}
onChange={(e) => setFormData({ ...formData, selfQuestion: e.target.value })}
placeholder="Enter self-assessment question"
className="min-h-[44px] mt-1"
style={{ borderWidth: '2px' }}
/>
</div>
<div>
<Label className="text-sm font-medium">
Feedback Question <span className="text-red-500">*</span>
</Label>
<Input
value={formData.feedbackQuestion}
onChange={(e) => setFormData({ ...formData, feedbackQuestion: e.target.value })}
placeholder="Enter feedback question"
className="min-h-[44px] mt-1"
style={{ borderWidth: '2px' }}
/>
</div>
<div>
<Label className="text-sm font-medium">
Sub Dimension <span className="text-red-500">*</span>
</Label>
<Select
value={formData.subDimensionId}
onValueChange={(value) => setFormData({ ...formData, subDimensionId: value })}
disabled={subDimensions.length === 0}
>
<SelectTrigger className="min-h-[44px] mt-1" style={{ borderWidth: '2px' }}>
<SelectValue placeholder="Select sub dimension" />
</SelectTrigger>
<SelectContent>
{subDimensions.map((subDim) => (
<SelectItem key={subDim.id} value={subDim.id}>
{subDim.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-sm font-medium">Order</Label>
<Input
type="number"
min="1"
value={formData.order}
onChange={(e) => setFormData({ ...formData, order: parseInt(e.target.value) || 1 })}
className="min-h-[44px] mt-1"
style={{ borderWidth: '2px' }}
/>
<p className="text-xs text-gray-500 mt-1">Lower shows earlier</p>
</div>
</div>
{/* Per-question Number of Options */}
<div className="flex justify-end">
<div className="w-64">
<Label className="text-sm font-medium">Number of Options (16)</Label>
<Select
value={formData.optionCount.toString()}
onValueChange={(value) => updateOptionCount(parseInt(value))}
>
<SelectTrigger className="min-h-[44px] mt-1" style={{ borderWidth: '2px' }}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1 Option</SelectItem>
<SelectItem value="2">2 Options</SelectItem>
<SelectItem value="3">3 Options</SelectItem>
<SelectItem value="4">4 Options</SelectItem>
<SelectItem value="5">5 Options</SelectItem>
<SelectItem value="6">6 Options</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-gray-500 mt-1">
Section default: {sectionDefaultK}
</p>
</div>
</div>
<Separator />
{/* Options List */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-medium">Options Configuration</h3>
<Button
variant="outline"
size="sm"
onClick={autoFillOptionOrders}
className="min-h-[44px] border-2"
style={{ borderColor: 'var(--color-brand-primary)', color: 'var(--color-brand-primary)' }}
disabled={options.length === 0}
>
<LinkIcon className="h-4 w-4 mr-2" />
Auto-fill option orders 1{formData.optionCount}
</Button>
</div>
<div className="space-y-3">
{Array.from({ length: formData.optionCount }, (_, index) => (
<div key={index} className="grid grid-cols-2 gap-4 p-3 border rounded-lg">
<div>
<Label className="text-sm font-medium">Option</Label>
<Select
value={formData.options[index]?.optionId || ''}
onValueChange={(value) => updateOptionSelection(index, value)}
disabled={options.length === 0}
>
<SelectTrigger className="min-h-[44px] mt-1" style={{ borderWidth: '2px' }}>
<SelectValue placeholder="Select option" />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.id} value={option.id}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-sm font-medium">Order</Label>
<Input
type="number"
min="1"
max={formData.optionCount}
value={formData.options[index]?.order || index + 1}
onChange={(e) => updateOptionOrder(index, parseInt(e.target.value) || index + 1)}
className="min-h-[44px] mt-1"
style={{ borderWidth: '2px' }}
/>
</div>
</div>
))}
</div>
</div>
{/* Validation Errors */}
{errors.length > 0 && (
<Alert className="border-red-200 bg-red-50">
<AlertCircle className="h-4 w-4 text-red-600" />
<AlertDescription>
<div className="space-y-1">
{errors.map((error, index) => (
<div key={index} className="text-red-800">{error}</div>
))}
</div>
</AlertDescription>
</Alert>
)}
</div>
</div>
<DialogFooter className="sticky bottom-0 bg-white border-t pt-4">
<Button
variant="outline"
onClick={onClose}
className="min-h-[44px]"
>
Cancel
</Button>
<Button
onClick={handleSave}
disabled={isDataMissing}
className="min-h-[44px]"
style={{ backgroundColor: 'var(--color-brand-primary)' }}
>
Update Question
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,22 +1,29 @@
import React, { useState } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
import { Alert, AlertDescription } from '../ui/alert';
import { Eye, EyeOff, ArrowLeft, CheckCircle } from 'lucide-react';
import klcLogoLight from '../../assets/klc-logo-white.png';
import { useNavigate } from 'react-router-dom';
import { Eye, EyeOff, CheckCircle2, Loader2, AlertCircle } from 'lucide-react';
import klcLogoLight from 'figma:asset/1e150e43f238df3e08fcbf5d8f4899c233264e9f.png';
interface ForgotPasswordProps {
// onNavigate: (route: string) => void;
onNavigate: (route: string) => void;
}
type Step = 'request' | 'verify' | 'newPassword' | 'done';
export function ForgotPassword({}: ForgotPasswordProps) {
interface FormErrors {
email?: string;
code?: string;
password?: string;
confirmPassword?: string;
}
export function ForgotPassword({ onNavigate }: ForgotPasswordProps) {
const [currentStep, setCurrentStep] = useState<Step>('request');
const [email, setEmail] = useState('');
const [maskedEmail, setMaskedEmail] = useState('');
const [verificationCode, setVerificationCode] = useState(['', '', '', '', '', '']);
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
@@ -24,45 +31,84 @@ export function ForgotPassword({}: ForgotPasswordProps) {
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [resendCooldown, setResendCooldown] = useState(0);
const [error, setError] = useState('');
const navigate = useNavigate();
const [errors, setErrors] = useState<FormErrors>({});
const [codeSent, setCodeSent] = useState(false);
const [resendAttempts, setResendAttempts] = useState(0);
const otpRefs = useRef<(HTMLInputElement | null)[]>([]);
React.useEffect(() => {
// Cooldown timer effect
useEffect(() => {
if (resendCooldown > 0) {
const timer = setTimeout(() => setResendCooldown(resendCooldown - 1), 1000);
return () => clearTimeout(timer);
}
}, [resendCooldown]);
// Focus management for OTP inputs
useEffect(() => {
if (currentStep === 'verify' && otpRefs.current[0]) {
otpRefs.current[0].focus();
}
}, [currentStep]);
const validateEmail = (email: string): boolean => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};
const maskEmail = (email: string): string => {
const [local, domain] = email.split('@');
if (local.length <= 2) return `${local}***@${domain}`;
return `${local.substring(0, 2)}***@${domain}`;
};
const handleSendCode = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setErrors({});
if (!email.trim()) {
setErrors({ email: 'Email is required' });
return;
}
if (!validateEmail(email)) {
setErrors({ email: 'Enter a valid email address' });
return;
}
setIsLoading(true);
// Simulate API call
setTimeout(() => {
setMaskedEmail(maskEmail(email));
setCodeSent(true);
setCurrentStep('verify');
setIsLoading(false);
setResendCooldown(60);
}, 1000);
setIsLoading(false);
}, 1200);
};
const handleVerifyCode = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
setErrors({});
const code = verificationCode.join('');
if (code.length !== 6) {
setError('Please enter the complete 6-digit code');
setIsLoading(false);
setErrors({ code: 'Please enter the complete 6-digit code' });
return;
}
setIsLoading(true);
// Simulate API call with different scenarios
setTimeout(() => {
if (code === '123456') {
if (code === '000000') {
setErrors({ code: 'Code expired.' });
} else if (code === '123456') {
setCurrentStep('newPassword');
} else {
setError('Invalid verification code. Please try again.');
setErrors({ code: 'Incorrect code.' });
}
setIsLoading(false);
}, 1000);
@@ -70,325 +116,432 @@ export function ForgotPassword({}: ForgotPasswordProps) {
const handleSetNewPassword = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setErrors({});
if (newPassword !== confirmPassword) {
setError('Passwords do not match');
if (!newPassword.trim()) {
setErrors({ password: 'New password is required' });
return;
}
if (newPassword.length < 8) {
setError('Password must be at least 8 characters long');
setErrors({ password: 'Password must be at least 8 characters' });
return;
}
if (!confirmPassword.trim()) {
setErrors({ confirmPassword: 'Please confirm your password' });
return;
}
if (newPassword !== confirmPassword) {
setErrors({ confirmPassword: 'Passwords do not match' });
return;
}
setIsLoading(true);
// Simulate API call
setTimeout(() => {
setCurrentStep('done');
setIsLoading(false);
}, 1000);
}, 1200);
};
const handleCodeInput = (index: number, value: string) => {
// Clear errors when user starts typing
if (errors.code) {
setErrors(prev => ({ ...prev, code: undefined }));
}
// Handle paste - distribute "123456" across all inputs
if (value.length > 1) {
// Handle paste
const pastedCode = value.slice(0, 6).split('');
const newCode = [...verificationCode];
pastedCode.forEach((char, i) => {
if (index + i < 6) newCode[index + i] = char;
if (index + i < 6 && /^\d$/.test(char)) {
newCode[index + i] = char;
}
});
setVerificationCode(newCode);
// Focus the last filled input or the next empty one
const lastIndex = Math.min(index + pastedCode.length - 1, 5);
const nextEmptyIndex = newCode.findIndex((char, i) => i > lastIndex && char === '');
const focusIndex = nextEmptyIndex !== -1 ? nextEmptyIndex : lastIndex;
if (otpRefs.current[focusIndex]) {
otpRefs.current[focusIndex]?.focus();
}
return;
}
const newCode = [...verificationCode];
newCode[index] = value;
setVerificationCode(newCode);
// Handle single character input
if (/^\d$/.test(value) || value === '') {
const newCode = [...verificationCode];
newCode[index] = value;
setVerificationCode(newCode);
// Auto-advance to next input
if (value && index < 5) {
const nextInput = document.getElementById(`code-${index + 1}`);
nextInput?.focus();
// Auto-advance to next input
if (value && index < 5 && otpRefs.current[index + 1]) {
otpRefs.current[index + 1]?.focus();
}
}
};
const handleCodeKeyDown = (index: number, e: React.KeyboardEvent) => {
if (e.key === 'Backspace' && !verificationCode[index] && index > 0) {
otpRefs.current[index - 1]?.focus();
}
};
const handleResendCode = () => {
if (resendCooldown === 0) {
if (resendAttempts >= 3) {
setErrors({ code: 'Too many requests. Please try again later.' });
return;
}
setResendAttempts(prev => prev + 1);
setResendCooldown(60);
setVerificationCode(['', '', '', '', '', '']);
// Simulate API call
setTimeout(() => {
// Show success message
// Success handling would go here
}, 1000);
}
};
const handleChangeEmail = () => {
setCurrentStep('request');
setVerificationCode(['', '', '', '', '', '']);
setResendCooldown(0);
setResendAttempts(0);
setErrors({});
setCodeSent(false);
};
const renderStepContent = () => {
switch (currentStep) {
case 'request':
return (
<form onSubmit={handleSendCode} className="space-y-4">
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="space-y-6">
<div className="space-y-2">
<Label htmlFor="email">Email Address</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Enter your email address"
required
className="min-h-[44px]"
/>
<h2 className="text-lg font-medium">Forgot your password?</h2>
</div>
<form onSubmit={handleSendCode} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => {
setEmail(e.target.value);
if (errors.email) {
setErrors(prev => ({ ...prev, email: undefined }));
}
}}
placeholder="name@company.com"
required
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
aria-describedby={errors.email ? "email-error" : undefined}
aria-invalid={!!errors.email}
/>
{errors.email && (
<p
id="email-error"
className="text-sm text-destructive flex items-center gap-1"
role="alert"
>
<AlertCircle className="h-3 w-3" />
{errors.email}
</p>
)}
</div>
<Button
type="submit"
className="w-full min-h-[44px]"
disabled={isLoading}
style={{ backgroundColor: 'var(--color-brand-primary)' }}
>
{isLoading ? 'Sending...' : 'Send Code'}
</Button>
</form>
<Button
type="submit"
className="w-full min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
disabled={isLoading}
style={{ backgroundColor: 'var(--color-brand-primary)' }}
>
{isLoading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Sending...
</>
) : (
'Send code'
)}
</Button>
</form>
{codeSent && !isLoading && (
<div className="p-3 bg-muted rounded-lg">
<p className="text-sm text-muted-foreground">
We've sent a 6-digit code if the email exists.
</p>
</div>
)}
</div>
);
case 'verify':
return (
<form onSubmit={handleVerifyCode} className="space-y-4">
<div className="text-center text-sm text-muted-foreground mb-4">
We sent a code to <strong>{email}</strong>. It expires in 10 minutes.
<div className="space-y-6">
<div className="text-center">
<p className="text-sm text-muted-foreground">
Enter the 6-digit code sent to <strong>{maskedEmail}</strong>. Code expires in <strong>10 minutes</strong>.
</p>
</div>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<form onSubmit={handleVerifyCode} className="space-y-4">
{errors.code && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{errors.code}</AlertDescription>
</Alert>
)}
<div className="space-y-2">
<Label>Enter the 6-digit code</Label>
<div className="flex justify-center space-x-2">
{verificationCode.map((digit, index) => (
<Input
key={index}
id={`code-${index}`}
type="text"
maxLength={6}
value={digit}
onChange={(e) => handleCodeInput(index, e.target.value)}
className="w-12 h-12 text-center font-mono text-lg"
onKeyDown={(e) => {
if (e.key === 'Backspace' && !digit && index > 0) {
document.getElementById(`code-${index - 1}`)?.focus();
}
}}
/>
))}
<div className="space-y-2">
<Label>6-digit code</Label>
<div className="flex justify-center gap-2">
{verificationCode.map((digit, index) => (
<Input
key={index}
ref={(el) => (otpRefs.current[index] = el)}
type="text"
inputMode="numeric"
maxLength={6}
value={digit}
onChange={(e) => handleCodeInput(index, e.target.value)}
onKeyDown={(e) => handleCodeKeyDown(index, e)}
className="w-12 h-12 text-center font-mono text-lg focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
aria-label={`Digit ${index + 1}`}
aria-describedby={errors.code ? "code-error" : undefined}
aria-invalid={!!errors.code}
/>
))}
</div>
</div>
</div>
<Button
type="submit"
className="w-full min-h-[44px]"
disabled={isLoading}
style={{ backgroundColor: 'var(--color-brand-primary)' }}
>
{isLoading ? 'Verifying...' : 'Verify'}
</Button>
<Button
type="submit"
className="w-full min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
disabled={isLoading || verificationCode.join('').length !== 6}
style={{ backgroundColor: 'var(--color-brand-primary)' }}
>
{isLoading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Verifying...
</>
) : (
'Verify'
)}
</Button>
<div className="flex justify-between text-sm">
<Button
type="button"
variant="link"
onClick={handleResendCode}
disabled={resendCooldown > 0}
className="p-0 h-auto"
style={{ color: 'var(--color-brand-primary)' }}
>
{resendCooldown > 0 ? `Resend in ${resendCooldown}s` : 'Resend code'}
</Button>
<Button
type="button"
variant="link"
onClick={() => setCurrentStep('request')}
className="p-0 h-auto"
style={{ color: 'var(--color-brand-primary)' }}
>
Change email
</Button>
</div>
</form>
<div className="flex justify-between text-sm">
<Button
type="button"
variant="link"
onClick={handleResendCode}
disabled={resendCooldown > 0}
className="p-0 h-auto min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
style={{ color: 'var(--color-brand-primary)' }}
aria-live="polite"
>
{resendCooldown > 0 ? `Resend code (${resendCooldown}s)` : 'Resend code'}
</Button>
<Button
type="button"
variant="link"
onClick={handleChangeEmail}
className="p-0 h-auto min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
style={{ color: 'var(--color-brand-primary)' }}
>
Change email
</Button>
</div>
</form>
</div>
);
case 'newPassword':
return (
<form onSubmit={handleSetNewPassword} className="space-y-4">
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="space-y-2">
<Label htmlFor="new-password">New Password</Label>
<div className="relative">
<Input
id="new-password"
type={showNewPassword ? 'text' : 'password'}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Enter new password"
required
className="min-h-[44px] pr-12"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() => setShowNewPassword(!showNewPassword)}
>
{showNewPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
<div className="space-y-6">
<form onSubmit={handleSetNewPassword} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="new-password">New password</Label>
<div className="relative">
<Input
id="new-password"
type={showNewPassword ? 'text' : 'password'}
value={newPassword}
onChange={(e) => {
setNewPassword(e.target.value);
if (errors.password) {
setErrors(prev => ({ ...prev, password: undefined }));
}
}}
placeholder="Enter new password"
required
className="min-h-[44px] pr-12 focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
aria-describedby={errors.password ? "password-error" : undefined}
aria-invalid={!!errors.password}
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
onClick={() => setShowNewPassword(!showNewPassword)}
aria-label={showNewPassword ? 'Hide password' : 'Show password'}
>
{showNewPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
{errors.password && (
<p
id="password-error"
className="text-sm text-destructive flex items-center gap-1"
role="alert"
>
<AlertCircle className="h-3 w-3" />
{errors.password}
</p>
)}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password">Confirm Password</Label>
<div className="relative">
<Input
id="confirm-password"
type={showConfirmPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Confirm new password"
required
className="min-h-[44px] pr-12"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
>
{showConfirmPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
<div className="space-y-2">
<Label htmlFor="confirm-password">Confirm password</Label>
<div className="relative">
<Input
id="confirm-password"
type={showConfirmPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={(e) => {
setConfirmPassword(e.target.value);
if (errors.confirmPassword) {
setErrors(prev => ({ ...prev, confirmPassword: undefined }));
}
}}
placeholder="Confirm new password"
required
className="min-h-[44px] pr-12 focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
aria-describedby={errors.confirmPassword ? "confirm-password-error" : undefined}
aria-invalid={!!errors.confirmPassword}
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
aria-label={showConfirmPassword ? 'Hide password' : 'Show password'}
>
{showConfirmPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
{errors.confirmPassword && (
<p
id="confirm-password-error"
className="text-sm text-destructive flex items-center gap-1"
role="alert"
>
<AlertCircle className="h-3 w-3" />
{errors.confirmPassword}
</p>
)}
</div>
</div>
<Button
type="submit"
className="w-full min-h-[44px]"
disabled={isLoading}
style={{ backgroundColor: 'var(--color-brand-primary)' }}
>
{isLoading ? 'Setting Password...' : 'Set New Password'}
</Button>
</form>
{/* Password Rules Text (muted) */}
<p className="text-xs text-muted-foreground">
</p>
<Button
type="submit"
className="w-full min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
disabled={isLoading}
style={{ backgroundColor: 'var(--color-brand-primary)' }}
>
{isLoading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Updating...
</>
) : (
'Update password'
)}
</Button>
</form>
</div>
);
case 'done':
return (
<div className="text-center space-y-4">
<CheckCircle className="h-16 w-16 mx-auto text-green-500" />
<div>
<h3 className="font-medium">Password Reset Successful</h3>
<p className="text-sm text-muted-foreground mt-2">
Your password has been reset successfully. For security, you have been signed out of all other sessions.
</p>
<p className="text-sm text-muted-foreground mt-2">
Please note that two-factor authentication may be required when you sign in.
</p>
<div className="text-center space-y-6">
<CheckCircle2 className="h-16 w-16 mx-auto text-green-500" />
<div className="space-y-2">
<h3 className="font-medium">Password updated.</h3>
</div>
<Button
onClick={() => navigate('/login')}
className="min-h-[44px]"
onClick={() => onNavigate('/login')}
className="w-full min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
style={{ backgroundColor: 'var(--color-brand-primary)' }}
>
Return to Sign-in
</Button>
<p className="text-sm text-muted-foreground">
You may be asked for a one-time code at sign-in.
</p>
</div>
);
}
};
const getStepTitle = () => {
switch (currentStep) {
case 'request': return 'Reset Your Password';
case 'verify': return 'Enter Verification Code';
case 'newPassword': return 'Set New Password';
case 'done': return 'Password Reset Complete';
}
};
const getStepDescription = () => {
switch (currentStep) {
case 'request': return 'Enter your email address and we\'ll send you a verification code';
case 'verify': return 'Check your email for the 6-digit verification code';
case 'newPassword': return 'Choose a strong new password for your account';
case 'done': return 'Your password has been successfully reset';
}
};
return (
<div className="min-h-screen flex flex-col">
{/* Brand Header */}
<header className="w-full" style={{ backgroundColor: 'var(--color-brand-primary)' }}>
<div className="max-w-screen-xl mx-auto px-20 py-6">
<div className="flex justify-center">
<img
src={klcLogoLight}
alt="Kautilya Leadership Centre"
className="h-12"
/>
</div>
<div className="min-h-screen flex flex-col bg-background">
{/* Brand Bar */}
<header className="w-full bg-background border-b" style={{ height: '64px' }}>
<div className="max-w-[1440px] mx-auto px-6 h-full flex items-center">
<img
src={klcLogoLight}
alt="KLC Admin"
className="h-8"
/>
</div>
</header>
{/* Main Content */}
<main className="flex-1 flex items-center justify-center p-8">
{/* Main Content - Centered Wizard Card */}
<main className="flex-1 flex items-center justify-center p-6">
<div className="w-full max-w-md">
<Card className="border-2">
<CardHeader>
<div className="flex items-center space-x-2 mb-2">
{currentStep !== 'request' && currentStep !== 'done' && (
<Button
variant="ghost"
size="sm"
onClick={() => {
if (currentStep === 'verify') setCurrentStep('request');
else if (currentStep === 'newPassword') setCurrentStep('verify');
}}
className="p-0 h-auto"
>
<ArrowLeft className="h-4 w-4" />
</Button>
)}
<CardTitle>{getStepTitle()}</CardTitle>
</div>
<CardDescription>{getStepDescription()}</CardDescription>
</CardHeader>
<CardContent>
{renderStepContent()}
{currentStep !== 'done' && (
<div className="mt-6 text-center">
<Card className="shadow-lg">
<CardHeader className="space-y-1 pb-6">
<div className="flex items-center justify-between">
<CardTitle className="text-left">—</CardTitle>
{currentStep !== 'done' && (
<Button
variant="link"
onClick={() => navigate('/login')}
className="text-sm"
onClick={() => onNavigate('/login')}
className="text-sm p-0 h-auto min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
style={{ color: 'var(--color-brand-primary)' }}
>
Back to Sign-in
</Button>
</div>
)}
)}
</div>
</CardHeader>
<CardContent>
{renderStepContent()}
</CardContent>
</Card>
</div>
@@ -396,14 +549,38 @@ export function ForgotPassword({}: ForgotPasswordProps) {
{/* Legal Footer */}
<footer className="border-t bg-muted/30">
<div className="max-w-screen-xl mx-auto px-20 py-4">
<div className="flex justify-center space-x-6 text-sm text-muted-foreground">
<span>© 2024 Kautilya Leadership Centre</span>
<button className="hover:text-foreground transition-colors">Privacy Policy</button>
<button className="hover:text-foreground transition-colors">Terms of Service</button>
<div className="max-w-[1440px] mx-auto px-6 py-4">
<div className="flex justify-center items-center space-x-6 text-sm text-muted-foreground">
<button
className="hover:text-foreground transition-colors focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50 rounded px-1"
onClick={() => {/* Handle privacy policy */}}
>
Privacy
</button>
<span></span>
<button
className="hover:text-foreground transition-colors focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50 rounded px-1"
onClick={() => {/* Handle terms */}}
>
Terms
</button>
<span></span>
<span>©</span>
</div>
</div>
</footer>
{/* Screen reader announcements */}
<div
role="status"
aria-live="polite"
aria-label="Form status"
className="sr-only"
>
{isLoading && `Loading ${currentStep} step`}
{errors.code && `Code error: ${errors.code}`}
{resendCooldown > 0 && `Resend available in ${resendCooldown} seconds`}
</div>
</div>
);
}

View File

@@ -1,115 +1,210 @@
import React, { use, useState } from 'react';
import React, { useState } from 'react';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
import { Alert, AlertDescription } from '../ui/alert';
import { Eye, EyeOff } from 'lucide-react';
import klcLogoLight from '../../assets/klc-logo-white.png';
import { useNavigate } from 'react-router-dom';
import { Eye, EyeOff, AlertCircle, Loader2 } from 'lucide-react';
import klcLogoLight from 'figma:asset/1e150e43f238df3e08fcbf5d8f4899c233264e9f.png';
interface LoginProps {
onLogin: () => void;
// onNavigate: (route: string) => void;
onNavigate: (route: string) => void;
}
export function Login({ onLogin }: LoginProps) {
interface FormErrors {
email?: string;
password?: string;
general?: string;
}
export function Login({ onLogin, onNavigate }: LoginProps) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState('');
const [errors, setErrors] = useState<FormErrors>({});
const [isLoading, setIsLoading] = useState(false);
const navigate =useNavigate();
const [isAccountLocked, setIsAccountLocked] = useState(false);
const validateEmail = (email: string): boolean => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setErrors({});
setIsAccountLocked(false);
// Client-side validation
const newErrors: FormErrors = {};
if (!email.trim()) {
newErrors.email = 'Email is required';
} else if (!validateEmail(email)) {
newErrors.email = 'Enter a valid email.';
}
if (!password.trim()) {
newErrors.password = 'Password is required';
}
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
return;
}
setIsLoading(true);
// Simulate API call
// Simulate API call with different scenarios
setTimeout(() => {
if (email === 'admin@klc.edu' && password === 'Admin@123') {
onLogin();
if (email === 'locked@klc.edu') {
// Simulate locked account
setIsAccountLocked(true);
setErrors({ general: 'Your account is locked.' });
} else if (email === 'admin@klc.edu' && password === 'admin123') {
// Simulate 2FA requirement check
const requires2FA = email.includes('secure') || Math.random() > 0.7;
if (requires2FA) {
// Navigate to 2FA with masked email context
const maskedEmail = email.replace(/(.{2})(.*)(@.*)/, '$1***$3');
sessionStorage.setItem('login_context', JSON.stringify({
email: maskedEmail,
originalEmail: email
}));
onNavigate('/login/2fa');
} else {
// Direct login to dashboard
onLogin();
}
} else {
setError('Invalid email or password. Please try again.');
// Wrong credentials
setErrors({ password: 'Incorrect email or password.' });
}
setIsLoading(false);
}, 1000);
}, 1200);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
if (e.key === 'Enter' && email.trim() && password.trim()) {
handleSubmit(e as any);
}
};
const isFormValid = email.trim() && password.trim() && validateEmail(email);
return (
<div className="min-h-screen flex flex-col">
{/* Brand Header */}
<header className="w-full" style={{ backgroundColor: 'var(--color-brand-primary)' }}>
<div className="max-w-screen-xl mx-auto px-20 py-6">
<div className="flex justify-center">
<img
src={klcLogoLight}
alt="Kautilya Leadership Centre"
className="h-12"
/>
</div>
<div className="min-h-screen flex flex-col bg-background">
{/* Brand Bar */}
<header className="w-full bg-background border-b" style={{ height: '64px' }}>
<div className="max-w-[1440px] mx-auto px-6 h-full flex items-center">
<img
src={klcLogoLight}
alt="KLC Admin"
className="h-8"
/>
</div>
</header>
{/* Main Content */}
<main className="flex-1 flex items-center justify-center p-8">
{/* Main Content - Centered Auth Card */}
<main className="flex-1 flex items-center justify-center p-6">
<div className="w-full max-w-md">
<Card className="border-2">
<CardHeader>
{/* Account Locked Banner */}
{isAccountLocked && (
<Alert variant="destructive" className="mb-6">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Your account is locked. Please contact support for assistance.
</AlertDescription>
</Alert>
)}
<Card className="shadow-lg">
<CardHeader className="space-y-1 pb-6">
<CardTitle className="text-center">Sign in to Admin</CardTitle>
<CardDescription className="text-center">
Enter your credentials to access the admin panel
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
{/* General Error */}
{errors.general && !isAccountLocked && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
<AlertCircle className="h-4 w-4" />
<AlertDescription>{errors.general}</AlertDescription>
</Alert>
)}
{/* Email Field */}
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Label
htmlFor="email"
className="text-sm font-medium"
>
Email
</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="admin@klc.edu"
onChange={(e) => {
setEmail(e.target.value);
if (errors.email) {
setErrors(prev => ({ ...prev, email: undefined }));
}
}}
placeholder="name@company.com"
required
className="min-h-[44px]"
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
onKeyDown={handleKeyDown}
aria-describedby={errors.email ? "email-error" : undefined}
aria-invalid={!!errors.email}
/>
{errors.email && (
<p
id="email-error"
className="text-sm text-destructive flex items-center gap-1"
role="alert"
>
<AlertCircle className="h-3 w-3" />
{errors.email}
</p>
)}
</div>
{/* Password Field */}
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Label
htmlFor="password"
className="text-sm font-medium"
>
Password
</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
onChange={(e) => {
setPassword(e.target.value);
if (errors.password) {
setErrors(prev => ({ ...prev, password: undefined }));
}
}}
placeholder="Enter your password"
required
className="min-h-[44px] pr-12"
className="min-h-[44px] pr-12 focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
onKeyDown={handleKeyDown}
aria-describedby={errors.password ? "password-error" : undefined}
aria-invalid={!!errors.password}
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
onClick={() => setShowPassword(!showPassword)}
aria-label={showPassword ? 'Hide password' : 'Show password'}
tabIndex={0}
>
{showPassword ? (
<EyeOff className="h-4 w-4" />
@@ -118,23 +213,42 @@ export function Login({ onLogin }: LoginProps) {
)}
</Button>
</div>
{errors.password && (
<p
id="password-error"
className="text-sm text-destructive flex items-center gap-1"
role="alert"
>
<AlertCircle className="h-3 w-3" />
{errors.password}
</p>
)}
</div>
{/* Sign In Button */}
<Button
type="submit"
className="w-full min-h-[44px]"
disabled={isLoading}
className="w-full min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
disabled={isLoading || !isFormValid}
style={{ backgroundColor: 'var(--color-brand-primary)' }}
>
{isLoading ? 'Signing in...' : 'Sign in'}
{isLoading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Signing in...
</>
) : (
'Sign in'
)}
</Button>
{/* Forgot Password Link */}
<div className="text-center">
<Button
type="button"
variant="link"
onClick={() => navigate('/forget-password')}
className="text-sm"
onClick={() => onNavigate('/login/forget-password')}
className="text-sm min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
style={{ color: 'var(--color-brand-primary)' }}
>
Forgot password?
@@ -142,15 +256,11 @@ export function Login({ onLogin }: LoginProps) {
</div>
</form>
{/* Security Hints */}
<div className="mt-6 p-4 bg-muted rounded-lg">
<h4 className="font-medium mb-2">Security Guidelines:</h4>
<ul className="text-sm text-muted-foreground space-y-1">
<li> Use a strong, unique password</li>
<li> Enable two-factor authentication when prompted</li>
<li> Do not share your login credentials</li>
<li> Always sign out when finished</li>
</ul>
{/* Security Hints Helper Row */}
<div className="mt-6 text-center">
<p className="text-sm text-muted-foreground">
</p>
</div>
</CardContent>
</Card>
@@ -159,14 +269,38 @@ export function Login({ onLogin }: LoginProps) {
{/* Legal Footer */}
<footer className="border-t bg-muted/30">
<div className="max-w-screen-xl mx-auto px-20 py-4">
<div className="flex justify-center space-x-6 text-sm text-muted-foreground">
<span>© 2024 Kautilya Leadership Centre</span>
<button className="hover:text-foreground transition-colors">Privacy Policy</button>
<button className="hover:text-foreground transition-colors">Terms of Service</button>
<div className="max-w-[1440px] mx-auto px-6 py-4">
<div className="flex justify-center items-center space-x-6 text-sm text-muted-foreground">
<button
className="hover:text-foreground transition-colors focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50 rounded px-1"
onClick={() => {/* Handle privacy policy */}}
>
Privacy
</button>
<span></span>
<button
className="hover:text-foreground transition-colors focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50 rounded px-1"
onClick={() => {/* Handle terms */}}
>
Terms
</button>
<span></span>
<span>©</span>
</div>
</div>
</footer>
{/* Screen reader announcements */}
<div
role="status"
aria-live="polite"
aria-label="Form status"
className="sr-only"
>
{isLoading && "Signing in, please wait"}
{errors.general && `Error: ${errors.general}`}
{isAccountLocked && "Account locked"}
</div>
</div>
);
}

View File

@@ -1,235 +1,304 @@
import React, { useState } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
import { Alert, AlertDescription } from '../ui/alert';
import { ArrowLeft } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
// import klcLogoLight from 'figma:asset/1e150e43f238df3e08fcbf5d8f4899c233264e9f.png';
const klcLogoLight = 'figma:asset/1e150e43f238df3e08fcbf5d8f4899c233264e9f.png';
import { AlertCircle, Loader2 } from 'lucide-react';
import klcLogoLight from 'figma:asset/1e150e43f238df3e08fcbf5d8f4899c233264e9f.png';
interface TwoFactorAuthProps {
onLogin: () => void;
// onNavigate: (route: string) => void;
onNavigate: (route: string) => void;
}
export function TwoFactorAuth({ onLogin }: TwoFactorAuthProps) {
export function TwoFactorAuth({ onLogin, onNavigate }: TwoFactorAuthProps) {
const [verificationCode, setVerificationCode] = useState(['', '', '', '', '', '']);
const [isLoading, setIsLoading] = useState(false);
const [resendCooldown, setResendCooldown] = useState(60);
const [error, setError] = useState('');
const [attempts, setAttempts] = useState(0);
const navigate = useNavigate();
const [maskedEmail, setMaskedEmail] = useState('');
const otpRefs = useRef<(HTMLInputElement | null)[]>([]);
React.useEffect(() => {
// Get masked email from login context
useEffect(() => {
const loginContext = sessionStorage.getItem('login_context');
if (loginContext) {
const { email } = JSON.parse(loginContext);
setMaskedEmail(email);
} else {
// Fallback masked email if no context
setMaskedEmail('ad***@klc.edu');
}
}, []);
// Cooldown timer effect
useEffect(() => {
if (resendCooldown > 0) {
const timer = setTimeout(() => setResendCooldown(resendCooldown - 1), 1000);
return () => clearTimeout(timer);
}
}, [resendCooldown]);
// Focus management - focus first input on mount
useEffect(() => {
if (otpRefs.current[0]) {
otpRefs.current[0].focus();
}
}, []);
const handleVerifyCode = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
const code = verificationCode.join('');
if (code.length !== 6) {
setError('Please enter the complete 6-digit code');
setIsLoading(false);
return;
}
setIsLoading(true);
// Simulate API call with different scenarios
setTimeout(() => {
if (code === '123456') {
if (code === '000000') {
// Simulate expired code
setError('Code expired. Request a new one.');
} else if (code === '999999') {
// Simulate too many attempts
setError('Too many attempts. Please wait before retrying.');
} else if (code === '123456') {
// Successful verification
// Clear session storage and redirect to dashboard
sessionStorage.removeItem('login_context');
onLogin();
} else {
// Incorrect code
const newAttempts = attempts + 1;
setAttempts(newAttempts);
if (newAttempts >= 3) {
setError('Too many incorrect attempts. Please request a new code.');
setError('Too many attempts. Please wait before retrying.');
} else {
setError('Incorrect verification code. Please check your email and try again.');
setError('Incorrect code.');
}
// Clear the code inputs
// Clear the code inputs and focus first input
setVerificationCode(['', '', '', '', '', '']);
// Focus first input
document.getElementById('code-0')?.focus();
if (otpRefs.current[0]) {
otpRefs.current[0].focus();
}
}
setIsLoading(false);
}, 1000);
}, 1200);
};
const handleCodeInput = (index: number, value: string) => {
// Clear errors when user starts typing
if (error) {
setError('');
}
// Handle paste - distribute "123456" across all inputs
if (value.length > 1) {
// Handle paste
const pastedCode = value.slice(0, 6).split('');
const newCode = [...verificationCode];
pastedCode.forEach((char, i) => {
if (index + i < 6) newCode[index + i] = char;
if (index + i < 6 && /^\d$/.test(char)) {
newCode[index + i] = char;
}
});
setVerificationCode(newCode);
// Focus the last filled input or next empty one
const nextIndex = Math.min(index + pastedCode.length, 5);
document.getElementById(`code-${nextIndex}`)?.focus();
// Focus the last filled input or the next empty one
const lastIndex = Math.min(index + pastedCode.length - 1, 5);
const nextEmptyIndex = newCode.findIndex((char, i) => i > lastIndex && char === '');
const focusIndex = nextEmptyIndex !== -1 ? nextEmptyIndex : lastIndex;
if (otpRefs.current[focusIndex]) {
otpRefs.current[focusIndex]?.focus();
}
return;
}
const newCode = [...verificationCode];
newCode[index] = value;
setVerificationCode(newCode);
// Handle single character input (digits only)
if (/^\d$/.test(value) || value === '') {
const newCode = [...verificationCode];
newCode[index] = value;
setVerificationCode(newCode);
// Auto-advance to next input
if (value && index < 5) {
const nextInput = document.getElementById(`code-${index + 1}`);
nextInput?.focus();
// Auto-advance to next input
if (value && index < 5 && otpRefs.current[index + 1]) {
otpRefs.current[index + 1]?.focus();
}
}
};
const handleCodeKeyDown = (index: number, e: React.KeyboardEvent) => {
// Backspace navigation
if (e.key === 'Backspace' && !verificationCode[index] && index > 0) {
otpRefs.current[index - 1]?.focus();
}
};
const handleResendCode = () => {
if (resendCooldown === 0) {
if (resendCooldown === 0 && attempts < 5) {
setResendCooldown(60);
setAttempts(0);
setError('');
setVerificationCode(['', '', '', '', '', '']);
// Focus first input after resend
if (otpRefs.current[0]) {
otpRefs.current[0].focus();
}
// Simulate API call
setTimeout(() => {
// Show success message via toast or inline
// Success handling would go here
}, 1000);
}
};
const maskedEmail = 'ad***@klc.edu'; // Simulated masked email
const handleFirstInputFocus = () => {
// Announce "6 fields" when first input is focused
const announcement = document.getElementById('otp-announcement');
if (announcement) {
announcement.textContent = '6 digit verification code input fields';
setTimeout(() => {
announcement.textContent = '';
}, 2000);
}
};
const isFormValid = verificationCode.join('').length === 6;
const tooManyAttempts = attempts >= 3;
return (
<div className="min-h-screen flex flex-col">
{/* Brand Header */}
<header className="w-full" style={{ backgroundColor: 'var(--color-brand-primary)' }}>
<div className="max-w-screen-xl mx-auto px-20 py-6">
<div className="flex justify-center">
<img
src={klcLogoLight}
alt="Kautilya Leadership Centre"
className="h-12"
/>
</div>
<div className="min-h-screen flex flex-col bg-background">
{/* Brand Bar */}
<header className="w-full bg-background border-b" style={{ height: '64px' }}>
<div className="max-w-[1440px] mx-auto px-6 h-full flex items-center">
<img
src={klcLogoLight}
alt="KLC Admin"
className="h-8"
/>
</div>
</header>
{/* Main Content */}
<main className="flex-1 flex items-center justify-center p-8">
{/* Main Content - Centered Auth Card */}
<main className="flex-1 flex items-center justify-center p-6">
<div className="w-full max-w-md">
<Card className="border-2">
<CardHeader>
<Card className="shadow-lg">
<CardHeader className="space-y-1 pb-6">
<CardTitle className="text-center">Enter the 6-digit code</CardTitle>
<CardDescription className="text-center">
We sent a code to <strong>{maskedEmail}</strong>. It expires in 10 minutes.
</CardDescription>
<div className="text-center text-sm text-muted-foreground">
We sent a code to <strong>{maskedEmail}</strong>. It expires in <strong>10 minutes</strong>.
</div>
</CardHeader>
<CardContent>
<form onSubmit={handleVerifyCode} className="space-y-6">
{/* Error Alert */}
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{/* OTP Input Fields */}
<div className="space-y-2">
<Label className="sr-only">Verification Code</Label>
<div className="flex justify-center space-x-2">
<Label className="sr-only">6-digit verification code</Label>
<div className="flex justify-center gap-2" role="group" aria-labelledby="otp-label">
<span id="otp-label" className="sr-only">Enter 6-digit verification code</span>
{verificationCode.map((digit, index) => (
<Input
key={index}
id={`code-${index}`}
ref={(el) => (otpRefs.current[index] = el)}
type="text"
inputMode="numeric"
maxLength={6}
value={digit}
onChange={(e) => handleCodeInput(index, e.target.value)}
className="w-12 h-12 text-center font-mono text-lg"
onKeyDown={(e) => {
if (e.key === 'Backspace' && !digit && index > 0) {
document.getElementById(`code-${index - 1}`)?.focus();
}
}}
aria-label={`Digit ${index + 1} of verification code`}
onKeyDown={(e) => handleCodeKeyDown(index, e)}
onFocus={index === 0 ? handleFirstInputFocus : undefined}
className="w-12 h-12 text-center font-mono text-lg focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
aria-label={`Digit ${index + 1} of 6`}
aria-describedby={error ? "code-error" : undefined}
aria-invalid={!!error}
disabled={tooManyAttempts}
/>
))}
</div>
<p className="text-xs text-muted-foreground text-center mt-2">
You can paste the complete code into any field
</p>
{/* Inline error display */}
{error && (
<div
id="code-error"
className="text-center"
role="alert"
aria-live="polite"
>
<p className="text-sm text-destructive flex items-center justify-center gap-1">
<AlertCircle className="h-3 w-3" />
{error}
</p>
</div>
)}
</div>
{/* Verify Button */}
<Button
type="submit"
className="w-full min-h-[44px]"
disabled={isLoading || attempts >= 3}
className="w-full min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
disabled={isLoading || !isFormValid || tooManyAttempts}
style={{ backgroundColor: 'var(--color-brand-primary)' }}
>
{isLoading ? 'Verifying...' : 'Verify'}
{isLoading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Verifying...
</>
) : (
'Verify'
)}
</Button>
{/* Action Links */}
<div className="flex justify-between items-center text-sm">
<Button
type="button"
variant="link"
onClick={handleResendCode}
disabled={resendCooldown > 0 || attempts >= 5}
className="p-0 h-auto"
className="p-0 h-auto min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
style={{ color: 'var(--color-brand-primary)' }}
aria-live="polite"
>
{resendCooldown > 0
? `Resend in ${resendCooldown}s`
? `Resend code (${resendCooldown}s)`
: attempts >= 5
? 'Too many resends'
? 'Too many requests'
: 'Resend code'
}
</Button>
<Button
type="button"
variant="link"
onClick={() => navigate('/login')}
className="p-0 h-auto"
onClick={() => onNavigate('/login')}
className="p-0 h-auto min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
style={{ color: 'var(--color-brand-primary)' }}
>
Back to sign-in
Back to Sign-in
</Button>
</div>
{attempts >= 2 && attempts < 3 && (
<Alert>
<AlertDescription>
If you're having trouble receiving codes, please contact your system administrator.
</AlertDescription>
</Alert>
)}
{resendCooldown > 50 && (
<div className="text-center">
<div
className="text-xs text-muted-foreground"
aria-live="polite"
aria-atomic="true"
>
Code expires in 10 minutes
</div>
</div>
)}
</form>
{/* Security Notice */}
<div className="mt-6 p-4 bg-muted rounded-lg">
<h4 className="font-medium mb-2">Security Notice:</h4>
<ul className="text-sm text-muted-foreground space-y-1">
<li> This code is single-use only</li>
<li> Never share verification codes with anyone</li>
<li> Codes expire after 10 minutes</li>
</ul>
</div>
</CardContent>
</Card>
</div>
@@ -237,14 +306,46 @@ export function TwoFactorAuth({ onLogin }: TwoFactorAuthProps) {
{/* Legal Footer */}
<footer className="border-t bg-muted/30">
<div className="max-w-screen-xl mx-auto px-20 py-4">
<div className="flex justify-center space-x-6 text-sm text-muted-foreground">
<span>© 2024 Kautilya Leadership Centre</span>
<button className="hover:text-foreground transition-colors">Privacy Policy</button>
<button className="hover:text-foreground transition-colors">Terms of Service</button>
<div className="max-w-[1440px] mx-auto px-6 py-4">
<div className="flex justify-center items-center space-x-6 text-sm text-muted-foreground">
<button
className="hover:text-foreground transition-colors focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50 rounded px-1"
onClick={() => {/* Handle privacy policy */}}
>
Privacy
</button>
<span></span>
<button
className="hover:text-foreground transition-colors focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50 rounded px-1"
onClick={() => {/* Handle terms */}}
>
Terms
</button>
<span></span>
<span>©</span>
</div>
</div>
</footer>
{/* Screen reader announcements */}
<div
id="otp-announcement"
role="status"
aria-live="polite"
aria-label="OTP field information"
className="sr-only"
/>
<div
role="status"
aria-live="polite"
aria-label="Form status"
className="sr-only"
>
{isLoading && "Verifying code, please wait"}
{error && `Error: ${error}`}
{resendCooldown > 0 && `Resend available in ${resendCooldown} seconds`}
</div>
</div>
);
}

View File

@@ -0,0 +1,198 @@
import React from 'react';
import { Badge } from '../ui/badge';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '../ui/sheet';
import { ScrollArea } from '../ui/scroll-area';
import { Separator } from '../ui/separator';
import { Clock, User, Edit, Eye, Send, Check, X, Globe, Archive } from 'lucide-react';
interface AuditDrawerProps {
isOpen: boolean;
onClose: () => void;
pageTitle: string;
}
interface AuditEvent {
id: string;
action: string;
timestamp: string;
user: string;
details?: string;
changes?: string[];
}
const mockAuditEvents: AuditEvent[] = [
{
id: '1',
action: 'edit',
timestamp: '2024-01-15 14:30',
user: 'Admin User',
details: 'Modified hero section',
changes: ['Updated headline text', 'Changed CTA button label']
},
{
id: '2',
action: 'save',
timestamp: '2024-01-15 14:28',
user: 'Admin User',
details: 'Saved as draft'
},
{
id: '3',
action: 'submit',
timestamp: '2024-01-15 10:20',
user: 'Admin User',
details: 'Submitted for approval'
},
{
id: '4',
action: 'approve',
timestamp: '2024-01-15 10:18',
user: 'Super Admin',
details: 'Approved for publication'
},
{
id: '5',
action: 'publish',
timestamp: '2024-01-15 10:15',
user: 'Super Admin',
details: 'Published to live site'
},
{
id: '6',
action: 'edit',
timestamp: '2024-01-14 16:45',
user: 'Content Team',
details: 'Updated stats section',
changes: ['Modified stat values', 'Updated labels']
},
{
id: '7',
action: 'restore',
timestamp: '2024-01-14 11:20',
user: 'Marketing Team',
details: 'Restored from version v1.8'
},
{
id: '8',
action: 'unpublish',
timestamp: '2024-01-13 09:30',
user: 'Super Admin',
details: 'Unpublished for major updates'
}
];
const getActionIcon = (action: string) => {
switch (action) {
case 'edit': return <Edit className="h-4 w-4" />;
case 'save': return <Edit className="h-4 w-4" />;
case 'preview': return <Eye className="h-4 w-4" />;
case 'submit': return <Send className="h-4 w-4" />;
case 'approve': return <Check className="h-4 w-4" />;
case 'reject': return <X className="h-4 w-4" />;
case 'publish': return <Globe className="h-4 w-4" />;
case 'unpublish': return <Archive className="h-4 w-4" />;
case 'restore': return <Clock className="h-4 w-4" />;
default: return <Edit className="h-4 w-4" />;
}
};
const getActionColor = (action: string) => {
switch (action) {
case 'publish': return 'text-green-600';
case 'approve': return 'text-green-600';
case 'submit': return 'text-blue-600';
case 'reject': return 'text-red-600';
case 'unpublish': return 'text-orange-600';
case 'restore': return 'text-purple-600';
default: return 'text-muted-foreground';
}
};
const getActionLabel = (action: string) => {
switch (action) {
case 'edit': return 'Edited';
case 'save': return 'Saved';
case 'preview': return 'Previewed';
case 'submit': return 'Submitted';
case 'approve': return 'Approved';
case 'reject': return 'Rejected';
case 'publish': return 'Published';
case 'unpublish': return 'Unpublished';
case 'restore': return 'Restored';
default: return action;
}
};
export function AuditDrawer({ isOpen, onClose, pageTitle }: AuditDrawerProps) {
return (
<Sheet open={isOpen} onOpenChange={onClose}>
<SheetContent className="w-[600px] max-w-[90vw]">
<SheetHeader>
<SheetTitle>Audit Trail - {pageTitle}</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100vh-120px)] mt-6">
<div className="space-y-4">
{mockAuditEvents.map((event, index) => (
<div key={event.id}>
<div className="flex items-start gap-3 p-4 border rounded-lg">
{/* Icon */}
<div className={`p-2 rounded-lg bg-muted/50 ${getActionColor(event.action)}`}>
{getActionIcon(event.action)}
</div>
{/* Content */}
<div className="flex-1 space-y-2">
{/* Action Header */}
<div className="flex items-center gap-2">
<span className="font-medium">{getActionLabel(event.action)}</span>
<Badge variant="outline" className="text-xs">
{event.action}
</Badge>
</div>
{/* Details */}
{event.details && (
<p className="text-sm text-muted-foreground">{event.details}</p>
)}
{/* Changes */}
{event.changes && event.changes.length > 0 && (
<div className="space-y-1">
<div className="text-xs font-medium text-muted-foreground">Changes:</div>
<ul className="text-xs space-y-1 text-muted-foreground">
{event.changes.map((change, changeIndex) => (
<li key={changeIndex} className="flex items-start gap-2">
<div className="w-1 h-1 rounded-full bg-muted-foreground mt-2 flex-shrink-0"></div>
{change}
</li>
))}
</ul>
</div>
)}
{/* Metadata */}
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{event.timestamp}
</div>
<div className="flex items-center gap-1">
<User className="h-3 w-3" />
{event.user}
</div>
</div>
</div>
</div>
{index < mockAuditEvents.length - 1 && (
<Separator className="my-4" />
)}
</div>
))}
</div>
</ScrollArea>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,186 @@
import React, { useState } from 'react';
import { Button } from '../ui/button';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui/dialog';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs';
import { Input } from '../ui/input';
import { Badge } from '../ui/badge';
import { Search, Link, X } from 'lucide-react';
interface InternalLinkPickerProps {
isOpen: boolean;
onClose: () => void;
onSelect: (link: { href: string; title: string }) => void;
currentLink?: { href: string; title: string } | null;
}
interface LinkOption {
href: string;
title: string;
status: 'published' | 'draft';
}
const mockProgrammes: LinkOption[] = [
{ href: '/programmes/executive-leadership', title: 'Executive Leadership Programme', status: 'published' },
{ href: '/programmes/digital-transformation', title: 'Digital Transformation Course', status: 'published' },
{ href: '/programmes/strategic-planning', title: 'Strategic Planning Workshop', status: 'published' },
];
const mockCourses: LinkOption[] = [
{ href: '/courses/leadership-fundamentals', title: 'Leadership Fundamentals', status: 'published' },
{ href: '/courses/change-management', title: 'Change Management Essentials', status: 'published' },
{ href: '/courses/team-dynamics', title: 'Team Dynamics & Communication', status: 'published' },
];
const mockOpenProgrammes: LinkOption[] = [
{ href: '/open/quarterly-leadership', title: 'Quarterly Leadership Series', status: 'published' },
{ href: '/open/innovation-summit', title: 'Innovation Summit 2024', status: 'published' },
];
const staticRoutes: LinkOption[] = [
{ href: '/contact', title: 'Contact Us', status: 'published' },
{ href: '/services', title: 'Our Services', status: 'published' },
{ href: '/about-us', title: 'About Us', status: 'published' },
{ href: '/resources', title: 'Resources', status: 'published' },
];
export function InternalLinkPicker({ isOpen, onClose, onSelect, currentLink }: InternalLinkPickerProps) {
const [searchTerm, setSearchTerm] = useState('');
const [activeTab, setActiveTab] = useState('programmes');
const filterOptions = (options: LinkOption[]) => {
if (!searchTerm) return options;
return options.filter(option =>
option.title.toLowerCase().includes(searchTerm.toLowerCase())
);
};
const handleSelect = (option: LinkOption) => {
onSelect({ href: option.href, title: option.title });
onClose();
};
const handleClear = () => {
onSelect({ href: '', title: '' });
onClose();
};
const renderOptions = (options: LinkOption[]) => {
const filteredOptions = filterOptions(options);
if (filteredOptions.length === 0) {
return (
<div className="text-center py-8 text-muted-foreground">
No matching results found
</div>
);
}
return (
<div className="space-y-2">
{filteredOptions.map((option) => (
<button
key={option.href}
onClick={() => handleSelect(option)}
className="w-full text-left p-3 rounded-lg border hover:bg-muted/50 focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
>
<div className="flex items-center justify-between">
<div>
<div className="font-medium">{option.title}</div>
<div className="text-sm text-muted-foreground font-mono">{option.href}</div>
</div>
<Badge variant={option.status === 'published' ? 'default' : 'secondary'}>
{option.status}
</Badge>
</div>
</button>
))}
</div>
);
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle>Select Internal Link</DialogTitle>
<DialogDescription>
Choose an internal page to link to from your content.
</DialogDescription>
</DialogHeader>
{/* Current Selection */}
{currentLink?.href && (
<div className="p-3 bg-muted/50 rounded-lg border">
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium">Current Selection:</div>
<div className="flex items-center gap-2 mt-1">
<Link className="h-4 w-4 text-[var(--color-brand-primary)]" />
<span className="font-medium">{currentLink.title}</span>
<span className="text-sm text-muted-foreground font-mono">
{currentLink.href}
</span>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={handleClear}
className="h-8 w-8 p-0"
>
<X className="h-4 w-4" />
<span className="sr-only">Clear selection</span>
</Button>
</div>
</div>
)}
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search links..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
{/* Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 overflow-hidden">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="programmes">Programmes</TabsTrigger>
<TabsTrigger value="courses">Courses</TabsTrigger>
<TabsTrigger value="open">Open Programmes</TabsTrigger>
<TabsTrigger value="static">Static Pages</TabsTrigger>
</TabsList>
<div className="mt-4 overflow-y-auto flex-1">
<TabsContent value="programmes" className="mt-0">
{renderOptions(mockProgrammes)}
</TabsContent>
<TabsContent value="courses" className="mt-0">
{renderOptions(mockCourses)}
</TabsContent>
<TabsContent value="open" className="mt-0">
{renderOptions(mockOpenProgrammes)}
</TabsContent>
<TabsContent value="static" className="mt-0">
{renderOptions(staticRoutes)}
</TabsContent>
</div>
</Tabs>
{/* Actions */}
<div className="flex justify-end gap-2 pt-4 border-t">
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,270 @@
import React, { useState } from 'react';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Badge } from '../ui/badge';
import { Upload, X, Image, File, FileType } from 'lucide-react';
import { toast } from "sonner@2.0.3";
interface MediaPickerProps {
type: 'image' | 'file' | 'icon';
value?: {
url: string;
alt?: string;
filename?: string;
size?: number;
};
onChange: (value: any) => void;
required?: boolean;
acceptedTypes?: string[];
recommendedSize?: string;
}
export function MediaPicker({
type,
value,
onChange,
required,
acceptedTypes,
recommendedSize
}: MediaPickerProps) {
const [dragOver, setDragOver] = useState(false);
const [altText, setAltText] = useState(value?.alt || '');
const getAcceptedTypes = () => {
if (acceptedTypes) return acceptedTypes.join(',');
switch (type) {
case 'image':
return '.jpg,.jpeg,.png,.webp';
case 'icon':
return '.svg,.png';
case 'file':
return '.pdf,.zip';
default:
return '';
}
};
const getTypeLabel = () => {
switch (type) {
case 'image':
return 'Image';
case 'icon':
return 'Icon';
case 'file':
return 'File';
default:
return 'Media';
}
};
const handleFileSelect = (file: File) => {
// Validate file type
const allowedTypes = getAcceptedTypes().split(',').map(t => t.trim());
const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase();
if (!allowedTypes.includes(fileExtension)) {
toast.error(`Invalid file type. Allowed: ${allowedTypes.join(', ')}`);
return;
}
// Create mock URL for the file (in real app, this would upload to storage)
const mockUrl = `https://example.com/uploads/${file.name}`;
const fileData = {
url: mockUrl,
filename: file.name,
size: file.size,
alt: type === 'image' || type === 'icon' ? altText : undefined
};
onChange(fileData);
toast.success(`${getTypeLabel()} uploaded successfully`);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
const files = Array.from(e.dataTransfer.files);
if (files.length > 0) {
handleFileSelect(files[0]);
}
};
const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length > 0) {
handleFileSelect(files[0]);
}
};
const handleRemove = () => {
onChange(null);
setAltText('');
};
const formatFileSize = (bytes: number) => {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
};
const handleAltTextChange = (newAltText: string) => {
setAltText(newAltText);
if (value) {
onChange({
...value,
alt: newAltText
});
}
};
if (value) {
return (
<div className="space-y-3">
{/* File Preview */}
<div className="p-4 border rounded-lg bg-muted/25">
<div className="flex items-start gap-3">
{type === 'image' || type === 'icon' ? (
<Image className="h-8 w-8 text-[var(--color-brand-primary)] flex-shrink-0 mt-1" />
) : (
<File className="h-8 w-8 text-[var(--color-brand-primary)] flex-shrink-0 mt-1" />
)}
<div className="flex-1 min-w-0">
<div className="font-medium truncate" title={value.filename}>
{value.filename}
</div>
{value.size && (
<div className="text-sm text-muted-foreground">
{formatFileSize(value.size)}
</div>
)}
{type === 'image' && (
<Badge variant="secondary" className="mt-1">
{type === 'icon' ? 'Icon' : 'Image'}
</Badge>
)}
</div>
<Button
variant="ghost"
size="sm"
onClick={handleRemove}
className="h-8 w-8 p-0 flex-shrink-0"
>
<X className="h-4 w-4" />
<span className="sr-only">Remove {type}</span>
</Button>
</div>
</div>
{/* Alt Text (for images and icons) */}
{(type === 'image' || type === 'icon') && (
<div className="space-y-2">
<Label htmlFor={`alt-text-${type}`}>
{type === 'icon' ? 'Accessible Label' : 'Alt Text'}
{required && <span className="text-destructive"> *</span>}
</Label>
<Input
id={`alt-text-${type}`}
value={altText}
onChange={(e) => handleAltTextChange(e.target.value)}
placeholder={type === 'icon' ? 'Describe the icon for screen readers' : 'Describe the image for accessibility'}
required={required}
/>
</div>
)}
{/* Replace Option */}
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => document.getElementById(`file-input-${type}`)?.click()}
>
Replace {getTypeLabel()}
</Button>
</div>
{/* Hidden file input */}
<input
id={`file-input-${type}`}
type="file"
accept={getAcceptedTypes()}
onChange={handleFileInput}
className="hidden"
/>
</div>
);
}
return (
<div className="space-y-3">
{/* Upload Area */}
<div
className={`border-2 border-dashed rounded-lg p-6 text-center transition-colors ${
dragOver
? 'border-[var(--color-brand-primary)] bg-[var(--color-brand-primary)]/5'
: 'border-muted-foreground/25 hover:border-[var(--color-brand-primary)]/50'
}`}
onDrop={handleDrop}
onDragOver={(e) => {
e.preventDefault();
setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
>
<div className="space-y-2">
<Upload className="h-8 w-8 mx-auto text-muted-foreground" />
<div>
<p className="font-medium">
Drop {type} here or{' '}
<button
type="button"
onClick={() => document.getElementById(`file-input-${type}`)?.click()}
className="text-[var(--color-brand-primary)] hover:underline focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50 rounded"
>
browse
</button>
</p>
<p className="text-sm text-muted-foreground">
Accepted: {getAcceptedTypes().replace(/\./g, '').toUpperCase()}
</p>
{recommendedSize && (
<p className="text-sm text-muted-foreground">
Recommended: {recommendedSize}
</p>
)}
</div>
</div>
</div>
{/* Alt Text Input (show even before upload for required fields) */}
{(type === 'image' || type === 'icon') && required && (
<div className="space-y-2">
<Label htmlFor={`alt-text-${type}`}>
{type === 'icon' ? 'Accessible Label' : 'Alt Text'}
<span className="text-destructive"> *</span>
</Label>
<Input
id={`alt-text-${type}`}
value={altText}
onChange={(e) => setAltText(e.target.value)}
placeholder={type === 'icon' ? 'Describe the icon for screen readers' : 'Describe the image for accessibility'}
required
/>
</div>
)}
{/* Hidden file input */}
<input
id={`file-input-${type}`}
type="file"
accept={getAcceptedTypes()}
onChange={handleFileInput}
className="hidden"
/>
</div>
);
}

View File

@@ -0,0 +1,191 @@
import React, { useState } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui/dialog';
import { Button } from '../ui/button';
import { Badge } from '../ui/badge';
import { Monitor, Tablet, Smartphone, X } from 'lucide-react';
interface PreviewModalProps {
isOpen: boolean;
onClose: () => void;
pageTitle: string;
pageData: any;
}
type DeviceType = 'desktop' | 'tablet' | 'mobile';
export function PreviewModal({ isOpen, onClose, pageTitle, pageData }: PreviewModalProps) {
const [device, setDevice] = useState<DeviceType>('desktop');
const getDeviceStyles = () => {
switch (device) {
case 'desktop':
return 'w-full h-full';
case 'tablet':
return 'w-[768px] h-[1024px] mx-auto border rounded-lg';
case 'mobile':
return 'w-[375px] h-[667px] mx-auto border rounded-lg';
default:
return 'w-full h-full';
}
};
const getDeviceLabel = () => {
switch (device) {
case 'desktop':
return '1440×1024';
case 'tablet':
return '768×1024';
case 'mobile':
return '375×667';
default:
return '';
}
};
const renderPreviewContent = () => {
// This would render the actual page content based on pageData
// For now, showing a placeholder
return (
<div className="h-full overflow-y-auto bg-white">
<div className="p-8 space-y-8">
{/* Hero Section Preview */}
{pageData?.hero && (
<div className="bg-gradient-to-r from-[var(--color-brand-primary)] to-[var(--color-brand-primary)]/80 text-white p-8 rounded-lg">
<h1 className="text-4xl font-bold mb-4">{pageData.hero.headline || 'Hero Headline'}</h1>
{pageData.hero.subtext && (
<p className="text-xl mb-6">{pageData.hero.subtext}</p>
)}
{pageData.hero.cta?.label && (
<button className="bg-[var(--color-brand-accent)] text-[var(--color-brand-black)] px-6 py-3 rounded-lg font-medium">
{pageData.hero.cta.label}
</button>
)}
</div>
)}
{/* Stats Section Preview */}
{pageData?.stats && pageData.stats.length > 0 && (
<div className="grid grid-cols-3 gap-8 py-8">
{pageData.stats.map((stat: any, index: number) => (
<div key={index} className="text-center">
<div className="text-3xl font-bold text-[var(--color-brand-primary)] mb-2">
{stat.value?.toLocaleString()}{stat.suffix}
</div>
<div className="text-muted-foreground">{stat.label}</div>
</div>
))}
</div>
)}
{/* Cards Section Preview */}
{pageData?.highlights && pageData.highlights.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{pageData.highlights.map((card: any, index: number) => (
<div key={index} className="p-6 border rounded-lg shadow-sm">
{card.iconUrl && (
<div className="w-12 h-12 bg-[var(--color-brand-primary)]/10 rounded-lg flex items-center justify-center mb-4">
<div className="w-6 h-6 bg-[var(--color-brand-primary)] rounded"></div>
</div>
)}
<h3 className="font-semibold mb-2">{card.title}</h3>
<p className="text-muted-foreground text-sm">{card.body}</p>
</div>
))}
</div>
)}
{/* CTA Band Preview */}
{pageData?.ctaBand && (
<div className="bg-[var(--color-brand-accent)] text-[var(--color-brand-black)] p-8 rounded-lg text-center">
<h2 className="text-2xl font-bold mb-4">{pageData.ctaBand.text}</h2>
{pageData.ctaBand.cta?.label && (
<button className="bg-[var(--color-brand-primary)] text-white px-6 py-3 rounded-lg font-medium">
{pageData.ctaBand.cta.label}
</button>
)}
</div>
)}
{/* Placeholder for empty state */}
{(!pageData || Object.keys(pageData).length === 0) && (
<div className="text-center py-12">
<div className="text-6xl mb-4">📄</div>
<h2 className="text-2xl font-bold mb-2">Preview Not Available</h2>
<p className="text-muted-foreground">
Save your changes to see a preview of the page
</p>
</div>
)}
</div>
</div>
);
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-[95vw] max-h-[95vh] w-full h-full p-0">
<DialogHeader className="p-6 pb-4 border-b">
<div className="flex items-center justify-between">
<div>
<DialogTitle>Preview: {pageTitle}</DialogTitle>
<DialogDescription>
Preview how your page will look across different devices.
</DialogDescription>
<div className="flex items-center gap-2 mt-2">
<Badge variant="secondary">{getDeviceLabel()}</Badge>
<Badge variant="outline">Draft</Badge>
</div>
</div>
<div className="flex items-center gap-2">
{/* Device Toggle */}
<div className="flex items-center border rounded-lg p-1">
<Button
variant={device === 'desktop' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => setDevice('desktop')}
className="h-8 w-8 p-0"
>
<Monitor className="h-4 w-4" />
<span className="sr-only">Desktop preview</span>
</Button>
<Button
variant={device === 'tablet' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => setDevice('tablet')}
className="h-8 w-8 p-0"
>
<Tablet className="h-4 w-4" />
<span className="sr-only">Tablet preview</span>
</Button>
<Button
variant={device === 'mobile' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => setDevice('mobile')}
className="h-8 w-8 p-0"
>
<Smartphone className="h-4 w-4" />
<span className="sr-only">Mobile preview</span>
</Button>
</div>
<Button
variant="ghost"
size="sm"
onClick={onClose}
className="h-8 w-8 p-0"
>
<X className="h-4 w-4" />
<span className="sr-only">Close preview</span>
</Button>
</div>
</div>
</DialogHeader>
<div className="flex-1 p-6 overflow-hidden">
<div className={`${getDeviceStyles()} ${device !== 'desktop' ? 'shadow-lg' : ''}`}>
{renderPreviewContent()}
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,148 @@
import React from 'react';
import { Button } from '../ui/button';
import { Badge } from '../ui/badge';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '../ui/sheet';
import { ScrollArea } from '../ui/scroll-area';
import { Separator } from '../ui/separator';
import { RotateCcw, Clock, User } from 'lucide-react';
import { toast } from "sonner@2.0.3";
interface VersionHistoryProps {
isOpen: boolean;
onClose: () => void;
pageTitle: string;
}
interface Version {
id: string;
version: string;
status: 'draft' | 'published' | 'archived';
timestamp: string;
author: string;
changes: string[];
}
const mockVersions: Version[] = [
{
id: '1',
version: 'v2.1',
status: 'draft',
timestamp: '2024-01-15 14:30',
author: 'Admin User',
changes: ['Updated hero headline', 'Modified CTA button text', 'Added new highlight card']
},
{
id: '2',
version: 'v2.0',
status: 'published',
timestamp: '2024-01-15 10:15',
author: 'Admin User',
changes: ['Redesigned stats section', 'Updated team photos', 'Changed CTA background']
},
{
id: '3',
version: 'v1.9',
status: 'archived',
timestamp: '2024-01-14 16:45',
author: 'Content Team',
changes: ['Minor text updates', 'Fixed accessibility issues']
},
{
id: '4',
version: 'v1.8',
status: 'archived',
timestamp: '2024-01-13 11:20',
author: 'Marketing Team',
changes: ['Added new service blocks', 'Updated approach section']
}
];
export function VersionHistory({ isOpen, onClose, pageTitle }: VersionHistoryProps) {
const handleRestore = (version: Version) => {
toast.success(`Restored to ${version.version} as new draft`);
};
const getStatusBadgeVariant = (status: string) => {
switch (status) {
case 'published': return 'default';
case 'draft': return 'secondary';
case 'archived': return 'outline';
default: return 'secondary';
}
};
return (
<Sheet open={isOpen} onOpenChange={onClose}>
<SheetContent className="w-[600px] max-w-[90vw]">
<SheetHeader>
<SheetTitle>Version History - {pageTitle}</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100vh-120px)] mt-6">
<div className="space-y-4">
{mockVersions.map((version, index) => (
<div key={version.id}>
<div className="flex items-start justify-between p-4 border rounded-lg">
<div className="flex-1 space-y-3">
{/* Version Header */}
<div className="flex items-center gap-3">
<h3 className="font-medium">{version.version}</h3>
<Badge variant={getStatusBadgeVariant(version.status)}>
{version.status}
</Badge>
{index === 0 && (
<Badge variant="outline">Current</Badge>
)}
</div>
{/* Metadata */}
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-1">
<Clock className="h-4 w-4" />
{version.timestamp}
</div>
<div className="flex items-center gap-1">
<User className="h-4 w-4" />
{version.author}
</div>
</div>
{/* Changes */}
<div className="space-y-2">
<div className="text-sm font-medium text-muted-foreground">Changes:</div>
<ul className="text-sm space-y-1">
{version.changes.map((change, changeIndex) => (
<li key={changeIndex} className="flex items-start gap-2">
<div className="w-1 h-1 rounded-full bg-muted-foreground mt-2 flex-shrink-0"></div>
{change}
</li>
))}
</ul>
</div>
</div>
{/* Actions */}
<div className="flex gap-2 ml-4">
<Button
variant="outline"
size="sm"
disabled={index === 0}
className="min-h-[44px]"
>
<RotateCcw className="h-4 w-4 mr-2" />
Restore
</Button>
</div>
</div>
{index < mockVersions.length - 1 && (
<Separator className="my-4" />
)}
</div>
))}
</div>
</ScrollArea>
</SheetContent>
</Sheet>
);
}

View File

@@ -1,15 +1,15 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { Button } from '../ui/button';
import {
Home,
FileText,
GraduationCap,
Users,
Calendar,
Globe,
BarChart3,
Settings,
Menu,
import {
Home,
FileText,
GraduationCap,
Users,
Calendar,
Globe,
BarChart3,
Settings,
Menu,
ChevronLeft,
LogOut,
User,
@@ -18,9 +18,10 @@ import {
UserPlus,
Webcam,
ClipboardList,
Target
ContactRound,
Cog
} from 'lucide-react';
import {
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
@@ -29,15 +30,16 @@ import {
BreadcrumbSeparator,
} from '../ui/breadcrumb';
import { Avatar, AvatarFallback } from '../ui/avatar';
import {
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '../ui/dropdown-menu';
import klcLogoDark from '../../assets/klc-logo-white.png';
import { useNavigate } from 'react-router-dom';
import { toast } from "sonner@2.0.3";
import klcLogoDark from 'figma:asset/af520440d0fb3ca587ea6a7b2e63956e028f6f37.png';
import { SESSION_CONFIG, AutoSaveData } from '../../data/mockData';
interface NavigationItem {
id: string;
@@ -50,7 +52,7 @@ interface NavigationItem {
interface AuthenticatedLayoutProps {
children: React.ReactNode;
currentRoute: string;
// onNavigate: (route: string) => void;
onNavigate: (route: string) => void;
onLogout: () => void;
user: {
name: string;
@@ -59,9 +61,12 @@ interface AuthenticatedLayoutProps {
avatar?: string;
lastLogin: string;
};
breadcrumbs?: Array<{ label: string; route?: string }>;
breadcrumbs?: Array<{ label: string; href?: string }>;
formData?: any;
onAutoSave?: (data: any) => void;
}
// Updated navigation structure per requirements
const navigationItems: NavigationItem[] = [
{
id: 'dashboard',
@@ -69,6 +74,26 @@ const navigationItems: NavigationItem[] = [
icon: Home,
route: '/dashboard'
},
{
id: 'users',
label: 'Users',
icon: Users,
route: '/users/individual',
children: [
{
id: 'individual',
label: 'Individual Learners',
icon: User,
route: '/users/individual'
},
{
id: 'organizations',
label: 'Organizations',
icon: Building,
route: '/users/organizations'
}
]
},
{
id: 'content',
label: 'Content',
@@ -81,12 +106,6 @@ const navigationItems: NavigationItem[] = [
icon: GraduationCap,
route: '/courses'
},
{
id: 'profilers',
label: 'Profilers',
icon: Target,
route: '/profilers'
},
{
id: 'programmes',
label: 'Programmes',
@@ -118,25 +137,24 @@ const navigationItems: NavigationItem[] = [
route: '/landing-pages'
},
{
id: 'users',
label: 'Users',
icon: Users,
route: '/users/individual',
children: [
{
id: 'individual',
label: 'Individual Learners',
icon: User,
route: '/users/individual'
},
{
id: 'organizations',
label: 'Organizations',
icon: Building,
route: '/users/organizations'
}
]
id: 'leads',
label: 'Leads',
icon: ContactRound,
route: '/admin/leads'
},
{
id: 'facilities-360',
label: 'Facilities 360 Tour',
icon: Building,
route: '/facilities-360'
},
{
id: 'profiler-master',
label: 'Profiler Master',
icon: Cog,
route: '/admin/profiler-master'
},
{
id: 'roles',
label: 'Roles',
@@ -148,42 +166,126 @@ const navigationItems: NavigationItem[] = [
label: 'Analytics',
icon: BarChart3,
route: '/admin/analytics'
},
{
id: 'settings',
label: 'Settings',
icon: Settings,
route: '/settings',
children: [
{
id: 'leads',
label: 'Leads',
icon: UserPlus,
route: '/admin/leads'
},
{
id: 'facilities',
label: 'Facilities',
icon: Building,
route: '/admin/facilities'
}
]
}
];
export function AuthenticatedLayout({
children,
currentRoute,
onLogout,
user,
breadcrumbs = []
// Auto-save hook
const useAutoSave = (
formData: any,
onAutoSave: ((data: any) => void) | undefined,
currentRoute: string,
userId: string
) => {
useEffect(() => {
if (!onAutoSave || !formData) return;
const interval = setInterval(() => {
const autoSaveData: AutoSaveData = {
userId,
route: currentRoute,
formData,
timestamp: new Date().toISOString(),
isValid: true
};
// Save to localStorage for persistence
localStorage.setItem(`autosave_${userId}_${currentRoute}`, JSON.stringify(autoSaveData));
onAutoSave(formData);
// Show subtle toast notification
toast.success("Draft saved", { duration: 1000 });
}, SESSION_CONFIG.AUTO_SAVE_INTERVAL);
return () => clearInterval(interval);
}, [formData, onAutoSave, currentRoute, userId]);
};
// Session timeout hook
const useSessionTimeout = (onLogout: () => void) => {
useEffect(() => {
let timeoutId: NodeJS.Timeout;
let warningId: NodeJS.Timeout;
const resetTimers = () => {
clearTimeout(timeoutId);
clearTimeout(warningId);
// Set warning timer
warningId = setTimeout(() => {
toast.warning("Session will expire in 10 minutes", {
duration: 5000,
action: {
label: "Extend Session",
onClick: () => resetTimers()
}
});
}, SESSION_CONFIG.LOGOUT_TIMEOUT - SESSION_CONFIG.WARNING_TIME);
// Set logout timer
timeoutId = setTimeout(() => {
toast.error("Session expired. Please log in again.");
onLogout();
}, SESSION_CONFIG.LOGOUT_TIMEOUT);
};
// Activity listeners
const resetOnActivity = () => resetTimers();
// Listen for user activity
const events = ['mousedown', 'mousemove', 'keypress', 'scroll', 'touchstart'];
events.forEach(event => {
document.addEventListener(event, resetOnActivity, true);
});
resetTimers();
return () => {
clearTimeout(timeoutId);
clearTimeout(warningId);
events.forEach(event => {
document.removeEventListener(event, resetOnActivity, true);
});
};
}, [onLogout]);
};
export function AuthenticatedLayout({
children,
currentRoute,
onNavigate,
onLogout,
user,
breadcrumbs = [],
formData,
onAutoSave
}: AuthenticatedLayoutProps) {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const navigate = useNavigate(); // Move useNavigate to the top level
// Initialize auto-save and session timeout
useAutoSave(formData, onAutoSave, currentRoute, user.email);
useSessionTimeout(onLogout);
// Load auto-saved data on mount
useEffect(() => {
const savedData = localStorage.getItem(`autosave_${user.email}_${currentRoute}`);
if (savedData && onAutoSave) {
try {
const parsedData = JSON.parse(savedData);
const timeDiff = Date.now() - new Date(parsedData.timestamp).getTime();
// Only restore if saved within last hour
if (timeDiff < 60 * 60 * 1000) {
toast.info("Restored auto-saved draft", { duration: 2000 });
}
} catch (error) {
console.error('Failed to parse auto-saved data:', error);
}
}
}, [currentRoute, user.email, onAutoSave]);
const isActiveRoute = (route: string) => {
return currentRoute === route ||
(route === '/users/individual' && currentRoute.startsWith('/users/'));
if (!currentRoute || !route) return false;
return currentRoute === route ||
(route === '/users/individual' && currentRoute.startsWith('/users/'));
};
const getActiveParent = (items: NavigationItem[]): string | null => {
@@ -209,20 +311,21 @@ export function AuthenticatedLayout({
<div key={item.id}>
<Button
variant="ghost"
className={`w-full justify-start min-h-[44px] ${isActive
? 'bg-primary text-primary-foreground'
className={`w-full justify-start min-h-[44px] ${
isActive
? 'bg-primary text-primary-foreground'
: isParentActive && !isChild
? 'bg-accent text-accent-foreground'
: 'hover:bg-accent hover:text-accent-foreground'
} ${isChild ? 'ml-4 text-sm' : ''}`}
onClick={() => navigate(item.route)} // Use the navigate from the top level
} ${isChild ? 'ml-4 text-sm' : ''}`}
onClick={() => onNavigate(item.route)}
>
<Icon className={`${sidebarCollapsed ? 'mx-auto' : 'mr-2'} h-4 w-4 flex-shrink-0`} />
{!sidebarCollapsed && (
<span className="truncate">{item.label}</span>
)}
</Button>
{/* Render children if parent is active and not collapsed */}
{item.children && isParentActive && !sidebarCollapsed && (
<div className="mt-1 space-y-1">
@@ -232,10 +335,50 @@ export function AuthenticatedLayout({
</div>
);
};
// Enhanced breadcrumb handling - Fixed to show proper navigation flow
const renderBreadcrumbs = () => {
if (breadcrumbs.length === 0) return null;
return (
<div className="border-b bg-muted/30 px-6 py-3">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink
onClick={() => onNavigate('/dashboard')}
className="cursor-pointer"
>
Admin
</BreadcrumbLink>
</BreadcrumbItem>
{breadcrumbs.map((crumb, index) => (
<React.Fragment key={index}>
<BreadcrumbSeparator />
<BreadcrumbItem>
{crumb.href && index < breadcrumbs.length - 1 ? (
<BreadcrumbLink
onClick={() => onNavigate(crumb.href!)}
className="cursor-pointer"
>
{crumb.label}
</BreadcrumbLink>
) : (
<BreadcrumbPage>{crumb.label}</BreadcrumbPage>
)}
</BreadcrumbItem>
</React.Fragment>
))}
</BreadcrumbList>
</Breadcrumb>
</div>
);
};
return (
<div className="flex h-screen bg-background">
{/* Left Sidebar */}
<div
<div
className={`${sidebarCollapsed ? 'w-[72px]' : 'w-[240px]'} bg-sidebar border-r border-sidebar-border flex flex-col transition-all duration-200`}
role="navigation"
aria-label="Main navigation"
@@ -244,9 +387,9 @@ export function AuthenticatedLayout({
<div className="p-4 border-b border-sidebar-border" style={{ backgroundColor: 'var(--color-brand-primary)' }}>
<div className="flex items-center justify-between">
{!sidebarCollapsed && (
<img
src={klcLogoDark}
alt="Kautilya Leadership Centre"
<img
src={klcLogoDark}
alt="Kautilya Leadership Centre"
className="h-8"
/>
)}
@@ -263,15 +406,14 @@ export function AuthenticatedLayout({
</div>
{/* Navigation Items */}
<div className="flex-1 overflow-y-auto p-4">
<div className="flex-1 overflow-y-auto navigation-scrollbar-hidden p-4">
<nav className="space-y-2" role="navigation">
{navigationItems.map(item => renderNavigationItem(item))}
</nav>
</div>
{/* User Section */}
<div className="p-4 border-t border-sidebar-border"
>
<div className="p-4 border-t border-sidebar-border">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
@@ -297,7 +439,7 @@ export function AuthenticatedLayout({
<div className="text-muted-foreground">{user.email}</div>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => navigate('/profile')}>
<DropdownMenuItem onClick={() => onNavigate('/profile')}>
<User className="mr-2 h-4 w-4" />
Profile
</DropdownMenuItem>
@@ -314,42 +456,10 @@ export function AuthenticatedLayout({
{/* Main Content */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Breadcrumb */}
{breadcrumbs.length > 0 && (
<div className="border-b bg-muted/30 px-6 py-3">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink
onClick={() => navigate('/dashboard')}
className="cursor-pointer"
>
Admin
</BreadcrumbLink>
</BreadcrumbItem>
{breadcrumbs.map((crumb, index) => (
<React.Fragment key={index}>
<BreadcrumbSeparator />
<BreadcrumbItem>
{crumb.route && index < breadcrumbs.length - 1 ? (
<BreadcrumbLink
onClick={() => navigate(crumb.route!)}
className="cursor-pointer"
>
{crumb.label}
</BreadcrumbLink>
) : (
<BreadcrumbPage>{crumb.label}</BreadcrumbPage>
)}
</BreadcrumbItem>
</React.Fragment>
))}
</BreadcrumbList>
</Breadcrumb>
</div>
)}
{renderBreadcrumbs()}
{/* Page Content */}
<main className="flex-1 overflow-y-auto">
<main className={`flex-1 overflow-y-auto ${currentRoute === '/dashboard' ? 'dashboard-scrollbar-hidden' : ''}`}>
{children}
</main>

View File

@@ -0,0 +1,553 @@
import React, { useState } from 'react';
import { AuthenticatedLayout } from '../layout/AuthenticatedLayout';
import { Button } from '../ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Textarea } from '../ui/textarea';
import { Badge } from '../ui/badge';
import { toast } from "sonner@2.0.3";
import {
Save,
Eye,
Send,
Check,
X,
History,
GripVertical,
Link
} from 'lucide-react';
import { InternalLinkPicker } from '../landing-pages/InternalLinkPicker';
import { MediaPicker } from '../landing-pages/MediaPicker';
import { PreviewModal } from '../landing-pages/PreviewModal';
import { VersionHistory } from '../landing-pages/VersionHistory';
import { AuditDrawer } from '../landing-pages/AuditDrawer';
interface AboutUsEditorProps {
onNavigate: (route: string) => void;
onLogout: () => void;
user: any;
}
interface HeroData {
imageUrl?: string;
imageAlt?: string;
headline: string;
subtext: string;
cta: {
label: string;
internalHref: string;
};
}
interface StatData {
value: number;
suffix: string;
label: string;
}
interface TeamMember {
title: string;
imageUrl?: string;
imageAlt?: string;
body: string;
}
export function AboutUsEditor({ onNavigate, onLogout, user }: AboutUsEditorProps) {
const [status, setStatus] = useState<'draft' | 'in_review' | 'changes_requested' | 'approved' | 'published'>('published');
const [isLinkPickerOpen, setIsLinkPickerOpen] = useState(false);
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
const [isVersionHistoryOpen, setIsVersionHistoryOpen] = useState(false);
const [isAuditOpen, setIsAuditOpen] = useState(false);
// Form data
const [hero, setHero] = useState<HeroData>({
headline: '',
subtext: '',
cta: { label: '', internalHref: '' }
});
const [stats, setStats] = useState<StatData[]>([
{ value: 27187, suffix: '+', label: 'LEADERS DEVELOPED' },
{ value: 15510, suffix: '+', label: 'CORPORATE CLIENTS' },
{ value: 1240, suffix: '+', label: 'COUNTRIES SERVED' }
]);
const [teamMembers, setTeamMembers] = useState<TeamMember[]>([
{ title: '', body: '' },
{ title: '', body: '' },
{ title: '', body: '' },
{ title: '', body: '' }
]);
const breadcrumbs = [
{ label: "Admin", href: "/dashboard" },
{ label: "Landing Pages", href: "/landing-pages" },
{ label: "About Us" }
];
const handleSaveDraft = () => {
toast.success("Saved as draft.");
};
const handleSubmitForApproval = () => {
setStatus('in_review');
toast.success("Submitted for approval.");
};
const handleApprove = () => {
setStatus('approved');
toast.success("Approved.");
};
const handleRequestChanges = () => {
setStatus('changes_requested');
toast.success("Changes requested.");
};
const handlePublish = () => {
setStatus('published');
toast.success("Published.");
};
const handleUnpublish = () => {
setStatus('draft');
toast.success("Unpublished.");
};
const handleLinkSelect = (link: { href: string; title: string }) => {
setHero(prev => ({
...prev,
cta: { ...prev.cta, internalHref: link.href }
}));
};
const validateCTA = (cta: { label: string; internalHref: string }) => {
if (cta.label && !cta.internalHref) return "CTA requires both text and destination.";
if (!cta.label && cta.internalHref) return "CTA requires both text and destination.";
return null;
};
const getStatusBadgeVariant = () => {
switch (status) {
case 'published': return 'default';
case 'approved': return 'secondary';
case 'in_review': return 'outline';
case 'changes_requested': return 'destructive';
default: return 'secondary';
}
};
const getStatusLabel = () => {
switch (status) {
case 'draft': return 'Draft';
case 'in_review': return 'In Review';
case 'changes_requested': return 'Changes Requested';
case 'approved': return 'Approved';
case 'published': return 'Published';
default: return 'Draft';
}
};
const canEdit = status !== 'in_review';
const canApprove = user.role === 'Super Admin' && status === 'in_review';
const canPublish = user.role === 'Super Admin' && status === 'approved';
const renderHeader = () => (
<div className="sticky top-0 bg-background border-b p-6 z-10">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<h1>About Us Page</h1>
<Badge variant={getStatusBadgeVariant()}>
{getStatusLabel()}
</Badge>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={handleSaveDraft}
disabled={!canEdit}
className="min-h-[44px]"
>
<Save className="h-4 w-4 mr-2" />
Save Draft
</Button>
<Button
variant="outline"
onClick={() => setIsPreviewOpen(true)}
className="min-h-[44px]"
>
<Eye className="h-4 w-4 mr-2" />
Preview
</Button>
{status === 'draft' && (
<Button
onClick={handleSubmitForApproval}
className="min-h-[44px]"
style={{ backgroundColor: "var(--color-brand-primary)" }}
>
<Send className="h-4 w-4 mr-2" />
Submit for Approval
</Button>
)}
{canApprove && (
<>
<Button
onClick={handleApprove}
className="min-h-[44px]"
style={{ backgroundColor: "var(--color-brand-primary)" }}
>
<Check className="h-4 w-4 mr-2" />
Approve
</Button>
<Button
variant="outline"
onClick={handleRequestChanges}
className="min-h-[44px]"
>
<X className="h-4 w-4 mr-2" />
Request Changes
</Button>
</>
)}
{canPublish && (
<Button
onClick={handlePublish}
className="min-h-[44px]"
style={{ backgroundColor: "var(--color-brand-primary)" }}
>
Publish
</Button>
)}
{status === 'published' && (
<Button
variant="outline"
onClick={handleUnpublish}
className="min-h-[44px]"
>
Unpublish
</Button>
)}
<Button
variant="ghost"
onClick={() => setIsAuditOpen(true)}
className="min-h-[44px] w-[44px] p-0"
>
<History className="h-4 w-4" />
<span className="sr-only">Audit</span>
</Button>
</div>
</div>
</div>
);
const renderRightRail = () => (
<div className="w-80 border-l bg-muted/25 p-6 space-y-6">
<div>
<h3 className="font-medium mb-3">Page Information</h3>
<div className="space-y-2 text-sm">
<div>
<span className="text-muted-foreground">URL:</span>
<span className="ml-2 font-mono">/about-us</span>
</div>
<div>
<span className="text-muted-foreground">Last published:</span>
<span className="ml-2">2024-01-13 16:45</span>
</div>
<div>
<span className="text-muted-foreground">Last editor:</span>
<span className="ml-2">Marketing Team</span>
</div>
</div>
</div>
<div>
<h3 className="font-medium mb-3">Actions</h3>
<div className="space-y-2">
<Button
variant="outline"
onClick={() => setIsVersionHistoryOpen(true)}
className="w-full justify-start min-h-[44px]"
>
<History className="h-4 w-4 mr-2" />
Version History
</Button>
</div>
</div>
</div>
);
const renderHeroSection = () => (
<Card>
<CardHeader>
<CardTitle>Hero Section</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* Background Image */}
<div className="space-y-2">
<Label>Background Image</Label>
<MediaPicker
type="image"
onChange={() => {}}
recommendedSize="1440×600px"
required
/>
</div>
{/* Headline */}
<div className="space-y-2">
<Label htmlFor="hero-headline">
Headline <span className="text-destructive">*</span>
</Label>
<Input
id="hero-headline"
value={hero.headline}
onChange={(e) => setHero(prev => ({ ...prev, headline: e.target.value }))}
placeholder="Enter hero headline"
disabled={!canEdit}
required
/>
</div>
{/* Subtext */}
<div className="space-y-2">
<Label htmlFor="hero-subtext">Subtext</Label>
<Textarea
id="hero-subtext"
value={hero.subtext}
onChange={(e) => setHero(prev => ({ ...prev, subtext: e.target.value }))}
placeholder="Enter hero subtext"
disabled={!canEdit}
rows={3}
/>
</div>
{/* CTA */}
<div className="space-y-4">
<Label>Call to Action</Label>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="hero-cta-text">CTA Text</Label>
<Input
id="hero-cta-text"
value={hero.cta.label}
onChange={(e) => setHero(prev => ({
...prev,
cta: { ...prev.cta, label: e.target.value }
}))}
placeholder="Enter CTA text"
disabled={!canEdit}
/>
</div>
<div className="space-y-2">
<Label>CTA Destination</Label>
<div className="flex gap-2">
<Input
value={hero.cta.internalHref}
placeholder="Select internal link"
readOnly
className="flex-1"
/>
<Button
variant="outline"
onClick={() => setIsLinkPickerOpen(true)}
disabled={!canEdit}
className="flex-shrink-0"
>
<Link className="h-4 w-4" />
<span className="sr-only">Select link</span>
</Button>
</div>
</div>
</div>
{validateCTA(hero.cta) && (
<p className="text-sm text-destructive">{validateCTA(hero.cta)}</p>
)}
</div>
</CardContent>
</Card>
);
const renderStatsSection = () => (
<Card>
<CardHeader>
<CardTitle>Stats Section</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{stats.map((stat, index) => (
<div key={index} className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor={`stat-${index}-value`}>Number</Label>
<Input
id={`stat-${index}-value`}
type="number"
value={stat.value}
onChange={(e) => {
const newStats = [...stats];
newStats[index].value = parseInt(e.target.value) || 0;
setStats(newStats);
}}
disabled={!canEdit}
min="0"
/>
</div>
<div className="space-y-2">
<Label htmlFor={`stat-${index}-suffix`}>Suffix</Label>
<Input
id={`stat-${index}-suffix`}
value={stat.suffix}
onChange={(e) => {
const newStats = [...stats];
newStats[index].suffix = e.target.value;
setStats(newStats);
}}
disabled={!canEdit}
placeholder="+"
/>
</div>
<div className="space-y-2">
<Label htmlFor={`stat-${index}-label`}>Label</Label>
<Input
id={`stat-${index}-label`}
value={stat.label}
onChange={(e) => {
const newStats = [...stats];
newStats[index].label = e.target.value;
setStats(newStats);
}}
disabled={!canEdit}
placeholder="Label"
required
/>
</div>
</div>
))}
</CardContent>
</Card>
);
const renderTeamSection = () => (
<Card>
<CardHeader>
<CardTitle>Our Team</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{teamMembers.map((member, index) => (
<div key={index} className="space-y-4 p-4 border rounded-lg">
<div className="flex items-center gap-2">
<GripVertical className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">Team Member {index + 1}</span>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor={`team-${index}-title`}>Name / Role</Label>
<Input
id={`team-${index}-title`}
value={member.title}
onChange={(e) => {
const newMembers = [...teamMembers];
newMembers[index].title = e.target.value;
setTeamMembers(newMembers);
}}
disabled={!canEdit}
placeholder="Enter name and role"
/>
</div>
<div className="space-y-2">
<Label>Photo</Label>
<MediaPicker
type="image"
acceptedTypes={['.jpg', '.jpeg', '.png', '.svg']}
onChange={() => {}}
recommendedSize="400×400px"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor={`team-${index}-body`}>Bio / Description</Label>
<Textarea
id={`team-${index}-body`}
value={member.body}
onChange={(e) => {
const newMembers = [...teamMembers];
newMembers[index].body = e.target.value;
setTeamMembers(newMembers);
}}
disabled={!canEdit}
placeholder="Enter bio or role description"
rows={4}
/>
</div>
</div>
</div>
))}
</CardContent>
</Card>
);
return (
<AuthenticatedLayout
user={user}
onLogout={onLogout}
breadcrumbs={breadcrumbs}
>
<div className="min-h-screen flex flex-col">
{renderHeader()}
<div className="flex-1 flex">
{/* Main Content */}
<div className="flex-1 p-6 space-y-6 overflow-y-auto">
{renderHeroSection()}
{renderStatsSection()}
{renderTeamSection()}
</div>
{/* Right Rail */}
{renderRightRail()}
</div>
</div>
{/* Modals */}
<InternalLinkPicker
isOpen={isLinkPickerOpen}
onClose={() => setIsLinkPickerOpen(false)}
onSelect={handleLinkSelect}
currentLink={{ href: hero.cta.internalHref, title: '' }}
/>
<PreviewModal
isOpen={isPreviewOpen}
onClose={() => setIsPreviewOpen(false)}
pageTitle="About Us"
pageData={{ hero, stats, teamMembers }}
/>
<VersionHistory
isOpen={isVersionHistoryOpen}
onClose={() => setIsVersionHistoryOpen(false)}
pageTitle="About Us"
/>
<AuditDrawer
isOpen={isAuditOpen}
onClose={() => setIsAuditOpen(false)}
pageTitle="About Us"
/>
</AuthenticatedLayout>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,806 @@
import React, { useState, useEffect } from 'react';
import { AuthenticatedLayout } from '../layout/AuthenticatedLayout';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
import { Button } from '../ui/button';
import { Badge } from '../ui/badge';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Textarea } from '../ui/textarea';
import { Checkbox } from '../ui/checkbox';
import { RadioGroup, RadioGroupItem } from '../ui/radio-group';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs';
import { Progress } from '../ui/progress';
import { Separator } from '../ui/separator';
import { toast } from "sonner@2.0.3";
import {
ChevronLeft,
ChevronRight,
Calendar,
Users,
FileUp,
Download,
AlertCircle,
Clock,
MapPin,
User,
Building2,
CheckCircle
} from 'lucide-react';
import { klcMockData } from '../../data/mockData';
interface CourseAssignmentProps {
courseId: string;
onNavigate: (route: string) => void;
onLogout: () => void;
user: any;
}
interface AssignmentData {
type: 'course';
itemId: string;
scope: 'organization' | 'individual';
organizationId?: string;
userId?: string;
hrContacts: string[];
startDate: string;
endDate: string;
completionDate?: string;
maxParticipants: number;
integrationId?: string;
flags: {
allowAddFeedbackGiver: boolean;
hrAccessReports: boolean;
hrAccessCertificate: boolean;
onlineProgram: boolean;
blockSystemEmails: boolean;
};
participantsSource: 'directory' | 'upload';
participants: string[];
participantsFile?: File;
feedbackGiversFile?: File;
}
// Mock integration options
const integrationOptions = [
{ id: 'int_001', name: 'Microsoft Teams' },
{ id: 'int_002', name: 'Zoom Workplace' },
{ id: 'int_003', name: 'Google Workspace' },
{ id: 'int_004', name: 'Slack Enterprise' },
{ id: 'int_005', name: 'Custom API' }
];
export function CourseAssignment({ courseId, onNavigate, onLogout, user }: CourseAssignmentProps) {
const [currentStep, setCurrentStep] = useState(1);
const [assignmentData, setAssignmentData] = useState<AssignmentData>({
type: 'course',
itemId: courseId,
scope: 'organization',
hrContacts: [],
startDate: '',
endDate: '',
maxParticipants: 25,
flags: {
allowAddFeedbackGiver: false,
hrAccessReports: false,
hrAccessCertificate: false,
onlineProgram: false,
blockSystemEmails: false,
},
participantsSource: 'directory',
participants: [],
});
// Get course data
const course = klcMockData.courses?.find(c => c.id === courseId);
// Mock organizations and users data
const organizations = klcMockData.users?.organizations || [];
const [filteredLearners, setFilteredLearners] = useState<any[]>([]);
const [selectedParticipants, setSelectedParticipants] = useState<string[]>([]);
const [participantsTab, setParticipantsTab] = useState('directory');
const [hrContactInput, setHrContactInput] = useState('');
// Load learners when organization changes
useEffect(() => {
if (assignmentData.organizationId) {
// Mock learners for selected organization
const mockLearners = [
{ id: 'usr_001', name: 'Ravi Kumar', email: 'ravi.kumar@org.example', department: 'Technology' },
{ id: 'usr_002', name: 'Priya Sharma', email: 'priya.sharma@org.example', department: 'Marketing' },
{ id: 'usr_003', name: 'Amit Singh', email: 'amit.singh@org.example', department: 'Operations' },
{ id: 'usr_004', name: 'Sneha Patel', email: 'sneha.patel@org.example', department: 'HR' },
{ id: 'usr_005', name: 'Rajesh Kumar', email: 'rajesh.kumar@org.example', department: 'Finance' }
];
setFilteredLearners(mockLearners);
}
}, [assignmentData.organizationId]);
const updateAssignmentData = (updates: Partial<AssignmentData>) => {
setAssignmentData(prev => ({ ...prev, ...updates }));
};
const validateStep = (step: number): boolean => {
switch (step) {
case 1:
if (assignmentData.scope === 'organization' && !assignmentData.organizationId) {
toast.error("Please select an organization");
return false;
}
if (assignmentData.scope === 'individual' && !assignmentData.userId) {
toast.error("Please select a user");
return false;
}
if (assignmentData.hrContacts.length === 0) {
toast.error("Please enter at least one HR contact");
return false;
}
if (assignmentData.maxParticipants <= 0) {
toast.error("Max participants must be greater than 0");
return false;
}
return true;
case 2:
if (!assignmentData.startDate) {
toast.error("Please select a start date");
return false;
}
if (!assignmentData.endDate) {
toast.error("Please select an end date");
return false;
}
if (new Date(assignmentData.endDate) < new Date(assignmentData.startDate)) {
toast.error("End date must be after start date");
return false;
}
if (assignmentData.completionDate && new Date(assignmentData.completionDate) < new Date(assignmentData.endDate)) {
toast.error("Completion date must be after end date");
return false;
}
return true;
case 3:
if (participantsTab === 'directory' && selectedParticipants.length === 0) {
toast.error("Please select at least one participant");
return false;
}
if (selectedParticipants.length > assignmentData.maxParticipants) {
toast.error(`Cannot exceed maximum of ${assignmentData.maxParticipants} participants`);
return false;
}
return true;
default:
return true;
}
};
const handleNext = () => {
if (validateStep(currentStep)) {
setCurrentStep(prev => Math.min(prev + 1, 4));
}
};
const handleBack = () => {
setCurrentStep(prev => Math.max(prev - 1, 1));
};
const handleAssign = () => {
if (validateStep(3)) {
toast.success("Assignment created.");
// Navigate to assignment details or back to courses
onNavigate('/courses');
}
};
const addHrContact = () => {
if (hrContactInput.trim()) {
updateAssignmentData({
hrContacts: [...assignmentData.hrContacts, hrContactInput.trim()]
});
setHrContactInput('');
}
};
const removeHrContact = (index: number) => {
const newContacts = assignmentData.hrContacts.filter((_, i) => i !== index);
updateAssignmentData({ hrContacts: newContacts });
};
const toggleParticipant = (participantId: string) => {
setSelectedParticipants(prev =>
prev.includes(participantId)
? prev.filter(id => id !== participantId)
: [...prev, participantId]
);
};
// Check if course has feedback components (profilers)
const hasFeedbackComponents = course?.assessments?.some((assessment: any) =>
assessment.type === 'Profiler' || assessment.title.toLowerCase().includes('360')
);
const breadcrumbItems = [
{ label: "Admin", href: "/dashboard" },
{ label: "Courses", href: "/courses" },
{ label: course?.title || "Course", href: `/courses/${courseId}` },
{ label: "Assign", current: true }
];
const renderStepIndicator = () => (
<div className="flex items-center justify-between mb-8">
{[1, 2, 3, 4].map((step) => (
<div key={step} className="flex items-center">
<div className={`
w-10 h-10 rounded-full flex items-center justify-center border-2 font-medium
${step <= currentStep
? 'bg-[var(--color-brand-primary)] text-white border-[var(--color-brand-primary)]'
: 'bg-background text-muted-foreground border-muted-foreground'}
`}>
{step < currentStep ? <CheckCircle className="h-5 w-5" /> : step}
</div>
{step < 4 && (
<div className={`
w-20 h-0.5 mx-2
${step < currentStep ? 'bg-[var(--color-brand-primary)]' : 'bg-muted-foreground'}
`} />
)}
</div>
))}
</div>
);
const renderSummaryCard = () => (
<Card className="w-80">
<CardHeader>
<CardTitle className="text-lg">Course Summary</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label className="text-sm font-medium text-muted-foreground">Title</Label>
<p className="font-medium">{course?.title}</p>
</div>
<div>
<Label className="text-sm font-medium text-muted-foreground">ID/Code</Label>
<p className="font-mono text-sm">{course?.id}</p>
</div>
<div>
<Label className="text-sm font-medium text-muted-foreground">Status</Label>
<Badge variant={course?.status === 'Published' ? 'default' : 'secondary'}>
{course?.status}
</Badge>
</div>
<div>
<Label className="text-sm font-medium text-muted-foreground">Owner</Label>
<p>{course?.instructor}</p>
</div>
<div>
<Label className="text-sm font-medium text-muted-foreground">Duration</Label>
<p>{course?.duration}</p>
</div>
{course?.linkedIntegrations && course.linkedIntegrations.length > 0 && (
<div>
<Label className="text-sm font-medium text-muted-foreground">Linked Integrations</Label>
<p className="text-sm text-muted-foreground">
{course.linkedIntegrations.join(', ')}
</p>
</div>
)}
</CardContent>
</Card>
);
const renderStep1 = () => (
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium mb-4">Who to assign</h3>
<div className="grid grid-cols-2 gap-6">
<div className="space-y-4">
<div>
<Label htmlFor="assign-to" className="text-base font-medium">Assign to*</Label>
<RadioGroup
value={assignmentData.scope}
onValueChange={(value: 'organization' | 'individual') =>
updateAssignmentData({ scope: value, organizationId: undefined, userId: undefined })
}
className="mt-2"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="organization" id="org" />
<Label htmlFor="org">Organization</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="individual" id="individual" />
<Label htmlFor="individual">Individual</Label>
</div>
</RadioGroup>
</div>
{assignmentData.scope === 'organization' ? (
<div>
<Label htmlFor="organization-select">Select Organisation*</Label>
<Select value={assignmentData.organizationId} onValueChange={(value) => updateAssignmentData({ organizationId: value })}>
<SelectTrigger className="min-h-[44px]">
<SelectValue placeholder="Choose organization..." />
</SelectTrigger>
<SelectContent>
{organizations.map((org: any) => (
<SelectItem key={org.id} value={org.id}>
{org.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : (
<div>
<Label htmlFor="user-select">Select User*</Label>
<Select value={assignmentData.userId} onValueChange={(value) => updateAssignmentData({ userId: value })}>
<SelectTrigger className="min-h-[44px]">
<SelectValue placeholder="Search and select user..." />
</SelectTrigger>
<SelectContent>
{filteredLearners.map((user: any) => (
<SelectItem key={user.id} value={user.id}>
{user.name} ({user.email})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div>
<Label htmlFor="hr-contacts">Enter HR Names*</Label>
<div className="space-y-2">
<div className="flex gap-2">
<Input
value={hrContactInput}
onChange={(e) => setHrContactInput(e.target.value)}
placeholder="Enter HR contact name"
className="min-h-[44px]"
onKeyPress={(e) => e.key === 'Enter' && addHrContact()}
/>
<Button type="button" onClick={addHrContact} className="min-h-[44px]">Add</Button>
</div>
<p className="text-sm text-muted-foreground">Primary, secondary HR contacts</p>
{assignmentData.hrContacts.length > 0 && (
<div className="flex flex-wrap gap-2">
{assignmentData.hrContacts.map((contact, index) => (
<Badge key={index} variant="secondary" className="px-2 py-1">
{contact}
<button
onClick={() => removeHrContact(index)}
className="ml-2 text-muted-foreground hover:text-foreground"
>
×
</button>
</Badge>
))}
</div>
)}
</div>
</div>
</div>
<div className="space-y-4">
<div>
<Label htmlFor="max-participants">Max. Number of Participants*</Label>
<Input
type="number"
value={assignmentData.maxParticipants}
onChange={(e) => updateAssignmentData({ maxParticipants: parseInt(e.target.value) || 0 })}
className="min-h-[44px]"
min="1"
/>
</div>
<div>
<Label htmlFor="integration">Select Integration</Label>
<Select value={assignmentData.integrationId} onValueChange={(value) => updateAssignmentData({ integrationId: value })}>
<SelectTrigger className="min-h-[44px]">
<SelectValue placeholder="Choose integration..." />
</SelectTrigger>
<SelectContent>
{integrationOptions.map((integration) => (
<SelectItem key={integration.id} value={integration.id}>
{integration.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
<div className="mt-6">
<Label className="text-base font-medium">Permissions</Label>
<div className="grid grid-cols-2 gap-4 mt-3">
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Checkbox
id="feedback-giver"
checked={assignmentData.flags.allowAddFeedbackGiver}
onCheckedChange={(checked) =>
updateAssignmentData({
flags: { ...assignmentData.flags, allowAddFeedbackGiver: !!checked }
})
}
/>
<Label htmlFor="feedback-giver" className="text-sm">Allow Participant to add Feedback Giver</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="hr-reports"
checked={assignmentData.flags.hrAccessReports}
onCheckedChange={(checked) =>
updateAssignmentData({
flags: { ...assignmentData.flags, hrAccessReports: !!checked }
})
}
/>
<Label htmlFor="hr-reports" className="text-sm">Allow HR to Access Reports</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="hr-certificate"
checked={assignmentData.flags.hrAccessCertificate}
onCheckedChange={(checked) =>
updateAssignmentData({
flags: { ...assignmentData.flags, hrAccessCertificate: !!checked }
})
}
/>
<Label htmlFor="hr-certificate" className="text-sm">Allow HR to Access Certificate</Label>
</div>
</div>
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Checkbox
id="online-program"
checked={assignmentData.flags.onlineProgram}
onCheckedChange={(checked) =>
updateAssignmentData({
flags: { ...assignmentData.flags, onlineProgram: !!checked }
})
}
/>
<Label htmlFor="online-program" className="text-sm">Online Program</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="block-emails"
checked={assignmentData.flags.blockSystemEmails}
onCheckedChange={(checked) =>
updateAssignmentData({
flags: { ...assignmentData.flags, blockSystemEmails: !!checked }
})
}
/>
<Label htmlFor="block-emails" className="text-sm">Block System Generated Emails</Label>
</div>
</div>
</div>
{assignmentData.flags.blockSystemEmails && (
<p className="text-sm text-muted-foreground mt-2">
No onboarding emails will be sent for this assignment.
</p>
)}
</div>
</div>
</div>
);
const renderStep2 = () => (
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium mb-4">Schedule & Completion</h3>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<div className="flex items-start gap-2">
<AlertCircle className="h-5 w-5 text-blue-600 mt-0.5" />
<p className="text-sm text-blue-800">
Dates apply to this assignment and are not set on the master course.
</p>
</div>
</div>
<div className="grid grid-cols-3 gap-6 max-w-2xl">
<div>
<Label htmlFor="start-date">Start Date*</Label>
<Input
type="date"
value={assignmentData.startDate}
onChange={(e) => updateAssignmentData({ startDate: e.target.value })}
className="min-h-[44px]"
/>
</div>
<div>
<Label htmlFor="end-date">End Date*</Label>
<Input
type="date"
value={assignmentData.endDate}
onChange={(e) => updateAssignmentData({ endDate: e.target.value })}
className="min-h-[44px]"
/>
</div>
<div>
<Label htmlFor="completion-date">Completion Date</Label>
<Input
type="date"
value={assignmentData.completionDate || ''}
onChange={(e) => updateAssignmentData({ completionDate: e.target.value })}
className="min-h-[44px]"
/>
</div>
</div>
</div>
</div>
);
const renderStep3 = () => (
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium mb-4">Participants (and Feedback Givers)</h3>
<div className="grid grid-cols-2 gap-6">
<div>
<Tabs value={participantsTab} onValueChange={setParticipantsTab}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="directory">Add from Directory</TabsTrigger>
<TabsTrigger value="upload">Upload</TabsTrigger>
</TabsList>
<TabsContent value="directory" className="space-y-4">
{assignmentData.scope === 'organization' ? (
assignmentData.organizationId ? (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Select participants from {organizations.find(o => o.id === assignmentData.organizationId)?.name}
</p>
{filteredLearners.map((learner) => (
<div key={learner.id} className="flex items-center space-x-2 p-2 border rounded">
<Checkbox
id={learner.id}
checked={selectedParticipants.includes(learner.id)}
onCheckedChange={() => toggleParticipant(learner.id)}
/>
<div className="flex-1">
<Label htmlFor={learner.id} className="font-medium">{learner.name}</Label>
<p className="text-sm text-muted-foreground">{learner.email} {learner.department}</p>
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground py-4">
Select an organization to browse learners.
</p>
)
) : (
<div className="py-4">
<p className="text-sm text-muted-foreground">Individual user will be automatically added as participant.</p>
{assignmentData.userId && (
<Badge variant="secondary" className="mt-2">
{filteredLearners.find(u => u.id === assignmentData.userId)?.name || 'Selected User'}
</Badge>
)}
</div>
)}
</TabsContent>
<TabsContent value="upload" className="space-y-4">
<Card className="border-dashed border-2">
<CardContent className="flex flex-col items-center justify-center py-6">
<FileUp className="h-8 w-8 text-muted-foreground mb-2" />
<Button variant="outline" className="mb-2">
Choose file
</Button>
<p className="text-sm text-muted-foreground">Upload participant list</p>
</CardContent>
</Card>
<Button variant="link" className="p-0 h-auto">
<Download className="h-4 w-4 mr-2" />
Download Participants Template
</Button>
</TabsContent>
</Tabs>
</div>
{hasFeedbackComponents && (
<div>
<Label className="text-base font-medium">Feedback Givers</Label>
<Card className="border-dashed border-2 mt-3">
<CardContent className="flex flex-col items-center justify-center py-6">
<FileUp className="h-8 w-8 text-muted-foreground mb-2" />
<Button variant="outline" className="mb-2">
Choose file
</Button>
<p className="text-sm text-muted-foreground">Upload feedback givers list</p>
</CardContent>
</Card>
<Button variant="link" className="p-0 h-auto mt-2">
<Download className="h-4 w-4 mr-2" />
Download Feedback Givers Template
</Button>
</div>
)}
</div>
<div className="flex items-center justify-between py-3 px-4 bg-muted/30 rounded-lg">
<span className="text-sm font-medium">
Participants selected: {selectedParticipants.length} (Max {assignmentData.maxParticipants})
</span>
</div>
</div>
</div>
);
const renderStep4 = () => (
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium mb-4">Review & Assign</h3>
<div className="space-y-6 max-w-4xl">
<div className="grid grid-cols-2 gap-6">
<div>
<Label className="text-sm font-medium text-muted-foreground">Course Name</Label>
<p className="font-medium">{course?.title}</p>
</div>
<div>
<Label className="text-sm font-medium text-muted-foreground">
{assignmentData.scope === 'organization' ? 'Select Organisation' : 'Select User'}
</Label>
<p className="font-medium">
{assignmentData.scope === 'organization'
? organizations.find(o => o.id === assignmentData.organizationId)?.name
: filteredLearners.find(u => u.id === assignmentData.userId)?.name
}
</p>
</div>
<div>
<Label className="text-sm font-medium text-muted-foreground">HR Names</Label>
<p className="font-medium">{assignmentData.hrContacts.join(', ')}</p>
</div>
<div>
<Label className="text-sm font-medium text-muted-foreground">Max. Number of Participants</Label>
<p className="font-medium">{assignmentData.maxParticipants}</p>
</div>
<div>
<Label className="text-sm font-medium text-muted-foreground">Start Date End Date</Label>
<p className="font-medium">
{assignmentData.startDate} {assignmentData.endDate}
{assignmentData.completionDate && `${assignmentData.completionDate}`}
</p>
</div>
<div>
<Label className="text-sm font-medium text-muted-foreground">Select Integration</Label>
<p className="font-medium">
{assignmentData.integrationId
? integrationOptions.find(i => i.id === assignmentData.integrationId)?.name
: 'None selected'
}
</p>
</div>
</div>
<Separator />
<div>
<Label className="text-sm font-medium text-muted-foreground">Permissions</Label>
<div className="grid grid-cols-2 gap-2 mt-2">
<p className="text-sm">Allow Participant to add Feedback Giver: <span className="font-medium">{assignmentData.flags.allowAddFeedbackGiver ? 'Yes' : 'No'}</span></p>
<p className="text-sm">Allow HR to Access Reports: <span className="font-medium">{assignmentData.flags.hrAccessReports ? 'Yes' : 'No'}</span></p>
<p className="text-sm">Allow HR to Access Certificate: <span className="font-medium">{assignmentData.flags.hrAccessCertificate ? 'Yes' : 'No'}</span></p>
<p className="text-sm">Online Program: <span className="font-medium">{assignmentData.flags.onlineProgram ? 'Yes' : 'No'}</span></p>
<p className="text-sm">Block System Generated Emails: <span className="font-medium">{assignmentData.flags.blockSystemEmails ? 'Yes' : 'No'}</span></p>
</div>
</div>
<Separator />
<div>
<Label className="text-sm font-medium text-muted-foreground">Participants</Label>
<p className="font-medium">
{participantsTab === 'directory'
? `${selectedParticipants.length} selected from directory`
: 'Via upload'
}
</p>
{hasFeedbackComponents && (
<>
<Label className="text-sm font-medium text-muted-foreground mt-2 block">Feedback Givers</Label>
<p className="font-medium">Via upload</p>
</>
)}
</div>
</div>
</div>
</div>
);
const stepTitles = [
'Who to assign',
'Schedule & Completion',
'Participants (and Feedback Givers)',
'Review & Assign'
];
return (
<AuthenticatedLayout
user={user}
onLogout={onLogout}
currentRoute={`/courses/${courseId}/assign`}
breadcrumbItems={breadcrumbItems}
>
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-medium">Assignment Course</h1>
<p className="text-muted-foreground">
Assign course to organizations or individuals
</p>
</div>
</div>
<div className="flex gap-6">
<div className="flex-1">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>{stepTitles[currentStep - 1]}</CardTitle>
<Badge variant="outline">Step {currentStep} of 4</Badge>
</div>
{renderStepIndicator()}
</CardHeader>
<CardContent>
{currentStep === 1 && renderStep1()}
{currentStep === 2 && renderStep2()}
{currentStep === 3 && renderStep3()}
{currentStep === 4 && renderStep4()}
</CardContent>
<div className="flex items-center justify-between px-6 py-4 border-t">
<Button
variant="outline"
onClick={handleBack}
disabled={currentStep === 1}
className="min-h-[44px]"
>
<ChevronLeft className="h-4 w-4 mr-2" />
Back
</Button>
<div className="flex gap-2">
{currentStep < 4 ? (
<Button
onClick={handleNext}
className="min-h-[44px]"
style={{ backgroundColor: "var(--color-brand-primary)" }}
>
Next
<ChevronRight className="h-4 w-4 ml-2" />
</Button>
) : (
<Button
onClick={handleAssign}
className="min-h-[44px]"
style={{ backgroundColor: "var(--color-brand-primary)" }}
>
Assign
</Button>
)}
</div>
</div>
</Card>
</div>
{renderSummaryCard()}
</div>
</div>
</AuthenticatedLayout>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,177 +0,0 @@
import React from 'react';
import { AuthenticatedLayout } from '../layout/AuthenticatedLayout';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
import { Button } from '../ui/button';
import { Badge } from '../ui/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../ui/table';
import { MapPin, Calendar, Users, Plus, Check, X } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
interface FacilitiesProps {
// onNavigate: (route: string) => void;
onLogout: () => void;
user: any;
}
const mockFacilities = [
{ id: 1, name: 'Conference Room A', capacity: 25, location: 'Ground Floor', status: 'Available' },
{ id: 2, name: 'Training Hall B', capacity: 50, location: 'First Floor', status: 'Booked' },
{ id: 3, name: 'Workshop Room C', capacity: 15, location: 'Second Floor', status: 'Maintenance' }
];
const mockBookings = [
{
id: 1,
facility: 'Conference Room A',
requester: 'Dr. Kumar',
event: 'Leadership Workshop',
date: '2024-01-25',
time: '09:00 AM - 12:00 PM',
status: 'Pending'
},
{
id: 2,
facility: 'Training Hall B',
requester: 'Prof. Sharma',
event: 'Strategic Planning Session',
date: '2024-01-26',
time: '02:00 PM - 05:00 PM',
status: 'Approved'
}
];
export function Facilities({ onLogout, user }: FacilitiesProps) {
const breadcrumbs = [
{ label: 'Admin', route: '/admin/analytics' },
{ label: 'Facilities' }
];
const navigate = useNavigate();
return (
<AuthenticatedLayout
currentRoute="/admin/facilities"
// onNavigate={onNavigate}
onLogout={onLogout}
user={user}
breadcrumbs={breadcrumbs}
>
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-semibold">Facilities Management</h1>
<p className="text-muted-foreground">Manage locations, rooms, and booking requests</p>
</div>
<Button style={{ backgroundColor: 'var(--color-brand-primary)' }}>
<Plus className="h-4 w-4 mr-2" />
Add Facility
</Button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Facilities Inventory */}
<Card>
<CardHeader>
<CardTitle>Facilities Inventory</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{mockFacilities.map((facility) => (
<Card key={facility.id} className="p-4">
<div className="flex items-center justify-between">
<div>
<h4 className="font-medium">{facility.name}</h4>
<div className="flex items-center space-x-4 mt-1 text-sm text-muted-foreground">
<div className="flex items-center">
<Users className="h-4 w-4 mr-1" />
{facility.capacity} capacity
</div>
<div className="flex items-center">
<MapPin className="h-4 w-4 mr-1" />
{facility.location}
</div>
</div>
</div>
<Badge variant={
facility.status === 'Available' ? 'default' :
facility.status === 'Booked' ? 'destructive' : 'secondary'
}>
{facility.status}
</Badge>
</div>
</Card>
))}
</div>
</CardContent>
</Card>
{/* Booking Queue */}
<Card>
<CardHeader>
<CardTitle>Booking Queue</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Request</TableHead>
<TableHead>Status</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{mockBookings.map((booking) => (
<TableRow key={booking.id}>
<TableCell>
<div>
<div className="font-medium">{booking.event}</div>
<div className="text-sm text-muted-foreground">
{booking.facility} {booking.requester}
</div>
<div className="text-sm text-muted-foreground">
{booking.date} {booking.time}
</div>
</div>
</TableCell>
<TableCell>
<Badge variant={booking.status === 'Approved' ? 'default' : 'secondary'}>
{booking.status}
</Badge>
</TableCell>
<TableCell>
{booking.status === 'Pending' && (
<div className="flex space-x-1">
<Button variant="outline" size="sm">
<Check className="h-4 w-4" />
</Button>
<Button variant="outline" size="sm">
<X className="h-4 w-4" />
</Button>
</div>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
{/* Calendar View */}
<Card>
<CardHeader>
<CardTitle>Facility Calendar</CardTitle>
</CardHeader>
<CardContent>
<div className="text-center py-8 text-muted-foreground">
<Calendar className="h-12 w-12 mx-auto mb-4" />
<p>Calendar view showing all facility bookings and availability</p>
<Button className="mt-4" onClick={() => navigate('/class-scheduler')}>
Open Class Scheduler
</Button>
</div>
</CardContent>
</Card>
</div>
</AuthenticatedLayout>
);
}

View File

@@ -0,0 +1,438 @@
import React, { useState } from 'react';
import { AuthenticatedLayout } from '../layout/AuthenticatedLayout';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
import { Button } from '../ui/button';
import { Badge } from '../ui/badge';
import { Input } from '../ui/input';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '../ui/table';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../ui/select';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '../ui/dropdown-menu';
import { Checkbox } from '../ui/checkbox';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '../ui/alert-dialog';
import { toast } from "sonner@2.0.3";
import {
Plus,
Download,
MoreHorizontal,
Eye,
ExternalLink,
Trash2,
Upload,
Camera,
MapPin,
Calendar,
Activity,
Filter
} from 'lucide-react';
interface Facilities360Props {
onNavigate: (route: string) => void;
onLogout: () => void;
user: any;
}
// Mock data for 360 tours
const mock360Tours = [
{
id: 'tour-1',
title: 'Main Campus Building',
photoCount: 24,
sequenceCount: 2,
lastUpdated: '2024-01-15',
publishStatus: 'Published',
views: 1247,
place: 'Kautilya Leadership Centre, New Delhi',
description: 'Complete tour of the main academic building including classrooms, library, and common areas'
},
{
id: 'tour-2',
title: 'Executive Training Center',
photoCount: 18,
sequenceCount: 1,
lastUpdated: '2024-01-12',
publishStatus: 'Pending',
views: 0,
place: 'KLC Executive Center, Gurugram',
description: 'Executive training facilities and boardrooms'
},
{
id: 'tour-3',
title: 'Library & Study Areas',
photoCount: 15,
sequenceCount: 0,
lastUpdated: '2024-01-10',
publishStatus: 'Rejected',
views: 45,
place: 'Kautilya Leadership Centre, New Delhi',
description: 'Digital library, reading rooms, and collaborative study spaces'
},
{
id: 'tour-4',
title: 'Innovation Lab',
photoCount: 12,
sequenceCount: 1,
lastUpdated: '2024-01-08',
publishStatus: 'Published',
views: 892,
place: 'KLC Innovation Hub, Bangalore',
description: 'Technology lab with latest equipment and collaboration spaces'
}
];
export function Facilities360({ onNavigate, onLogout, user }: Facilities360Props) {
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState('all');
const [selectedTours, setSelectedTours] = useState<string[]>([]);
const [showBulkActions, setShowBulkActions] = useState(false);
const breadcrumbs = [
{ label: 'Facilities 360 Tour' }
];
const filteredTours = mock360Tours.filter(tour => {
const matchesSearch = tour.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
tour.place.toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus = statusFilter === 'all' || tour.publishStatus.toLowerCase() === statusFilter;
return matchesSearch && matchesStatus;
});
const getStatusVariant = (status: string) => {
switch (status.toLowerCase()) {
case 'published': return 'default';
case 'pending': return 'secondary';
case 'rejected': return 'destructive';
default: return 'outline';
}
};
const getStatusColor = (status: string) => {
switch (status.toLowerCase()) {
case 'published': return 'text-green-600';
case 'pending': return 'text-yellow-600';
case 'rejected': return 'text-red-600';
default: return 'text-gray-600';
}
};
const handleSelectTour = (tourId: string, checked: boolean) => {
if (checked) {
setSelectedTours(prev => [...prev, tourId]);
} else {
setSelectedTours(prev => prev.filter(id => id !== tourId));
}
};
const handleSelectAll = (checked: boolean) => {
if (checked) {
setSelectedTours(filteredTours.map(tour => tour.id));
} else {
setSelectedTours([]);
}
};
const handleBulkPublish = () => {
toast.success(`Publishing ${selectedTours.length} tours to Google Maps`, {
duration: 3000
});
setSelectedTours([]);
setShowBulkActions(false);
};
const handleBulkUnpublish = () => {
toast.success(`Unpublishing ${selectedTours.length} tours from Google Maps`, {
duration: 3000
});
setSelectedTours([]);
setShowBulkActions(false);
};
const handleBulkDelete = () => {
toast.success(`Deleted ${selectedTours.length} tours`, {
duration: 3000
});
setSelectedTours([]);
setShowBulkActions(false);
};
const handleImportFromStreetView = () => {
toast.info('Import from Street View feature coming soon', {
duration: 2000
});
};
const renderEmptyState = () => (
<div className="text-center py-12">
<div className="mx-auto w-24 h-24 bg-muted rounded-full flex items-center justify-center mb-4">
<Camera className="h-12 w-12 text-muted-foreground" />
</div>
<h3 className="text-lg font-medium mb-2">No tours yet</h3>
<p className="text-muted-foreground mb-6">Create your first 360° facility tour to get started</p>
<Button
onClick={() => onNavigate('/facilities-360/new')}
className="min-h-[44px]"
style={{ backgroundColor: "var(--color-brand-primary)" }}
>
<Plus className="h-4 w-4 mr-2" />
New Tour
</Button>
</div>
);
return (
<AuthenticatedLayout
currentRoute="/facilities-360"
onNavigate={onNavigate}
onLogout={onLogout}
user={user}
breadcrumbs={breadcrumbs}
>
<div className="p-6 space-y-6 max-w-[1440px] mx-auto">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1>Facilities 360 Tour</h1>
<p className="text-muted-foreground mt-1">
Create, manage, and publish immersive 360° facility tours
</p>
</div>
<div className="flex items-center gap-3">
<Button
variant="outline"
onClick={handleImportFromStreetView}
className="min-h-[44px]"
>
<Download className="h-4 w-4 mr-2" />
Import from Street View
</Button>
<Button
onClick={() => onNavigate('/facilities-360/new')}
className="min-h-[44px]"
style={{ backgroundColor: "var(--color-brand-primary)" }}
>
<Plus className="h-4 w-4 mr-2" />
New Tour
</Button>
</div>
</div>
{filteredTours.length === 0 && searchTerm === '' && statusFilter === 'all' ? (
renderEmptyState()
) : (
<Card>
<CardHeader>
<CardTitle>Tours Library</CardTitle>
<div className="flex items-center gap-4 pt-4">
<div className="flex-1">
<Input
placeholder="Search tours or places..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="max-w-sm min-h-[44px]"
/>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[140px] min-h-[44px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="published">Published</SelectItem>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="rejected">Rejected</SelectItem>
</SelectContent>
</Select>
{selectedTours.length > 0 && (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handleBulkPublish}
className="min-h-[44px]"
>
Publish ({selectedTours.length})
</Button>
<Button
variant="outline"
size="sm"
onClick={handleBulkUnpublish}
className="min-h-[44px]"
>
Unpublish
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
size="sm"
className="min-h-[44px]"
>
<Trash2 className="h-4 w-4 mr-1" />
Delete
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Selected Tours</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete {selectedTours.length} selected tours?
This action cannot be undone and will remove the tours from Google Maps.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleBulkDelete}>
Delete Tours
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)}
</div>
</CardHeader>
<CardContent>
{filteredTours.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No tours found matching your criteria.
</div>
) : (
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader className="bg-muted/50">
<TableRow>
<TableHead className="w-12">
<Checkbox
checked={selectedTours.length === filteredTours.length}
onCheckedChange={handleSelectAll}
aria-label="Select all tours"
/>
</TableHead>
<TableHead className="min-w-[250px]">Tour Title</TableHead>
<TableHead>Photos Sequences</TableHead>
<TableHead>Last Updated</TableHead>
<TableHead>Publish Status</TableHead>
<TableHead>Views</TableHead>
<TableHead className="w-12">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredTours.map((tour) => (
<TableRow key={tour.id}>
<TableCell>
<Checkbox
checked={selectedTours.includes(tour.id)}
onCheckedChange={(checked) => handleSelectTour(tour.id, checked as boolean)}
aria-label={`Select ${tour.title}`}
/>
</TableCell>
<TableCell>
<div>
<div className="font-medium">{tour.title}</div>
<div className="text-sm text-muted-foreground flex items-center gap-1 mt-1">
<MapPin className="h-3 w-3" />
{tour.place}
</div>
<div className="text-xs text-muted-foreground mt-1">
{tour.description}
</div>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-4 text-sm">
<div className="flex items-center gap-1">
<Camera className="h-4 w-4 text-muted-foreground" />
{tour.photoCount}
</div>
<div className="flex items-center gap-1">
<Activity className="h-4 w-4 text-muted-foreground" />
{tour.sequenceCount}
</div>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-1 text-sm">
<Calendar className="h-4 w-4 text-muted-foreground" />
{tour.lastUpdated}
</div>
</TableCell>
<TableCell>
<Badge variant={getStatusVariant(tour.publishStatus)}>
{tour.publishStatus}
</Badge>
</TableCell>
<TableCell>
<div className="text-sm font-medium">
{tour.views.toLocaleString()}
</div>
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => onNavigate(`/facilities-360/${tour.id}`)}
>
<Eye className="h-4 w-4 mr-2" />
Open
</DropdownMenuItem>
<DropdownMenuItem>
<ExternalLink className="h-4 w-4 mr-2" />
Preview
</DropdownMenuItem>
<DropdownMenuItem>
<Upload className="h-4 w-4 mr-2" />
{tour.publishStatus === 'Published' ? 'Unpublish' : 'Publish'}
</DropdownMenuItem>
<DropdownMenuItem className="text-destructive">
<Trash2 className="h-4 w-4 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
)}
</div>
</AuthenticatedLayout>
);
}

View File

@@ -0,0 +1,791 @@
import React, { useState } from 'react';
import { AuthenticatedLayout } from '../layout/AuthenticatedLayout';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
import { Button } from '../ui/button';
import { Badge } from '../ui/badge';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Textarea } from '../ui/textarea';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '../ui/table';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '../ui/dialog';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '../ui/sheet';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '../ui/alert-dialog';
import { toast } from "sonner@2.0.3";
import {
Eye,
Upload,
Download,
Trash2,
Edit,
ExternalLink,
Copy,
MapPin,
Calendar,
Camera,
Activity,
Navigation,
RotateCcw,
Move,
Compass,
Mountain,
Target,
Link,
Info
} from 'lucide-react';
interface Facilities360DetailProps {
tourId: string;
onNavigate: (route: string) => void;
onLogout: () => void;
user: any;
}
interface Photo {
id: string;
fileName: string;
status: 'Published' | 'Processing' | 'Rejected';
pose: {
latitude: number;
longitude: number;
heading: number;
pitch: number;
roll: number;
altitude: number;
level: number;
accuracy: number;
};
captureTime: string;
placeId: string;
placeName: string;
connections: string[];
shareLink: string;
thumbnailUrl: string;
viewCount: number;
rejectionReason?: string;
}
interface Sequence {
id: string;
fileName: string;
status: 'Completed' | 'Processing' | 'Failed';
extractedPhotos: number;
captureTimeStart: string;
gpsSource: boolean;
imuSource: boolean;
}
const mockTourData = {
id: 'tour-1',
title: 'Main Campus Building',
description: 'Complete tour of the main academic building including classrooms, library, and common areas',
place: 'Kautilya Leadership Centre, New Delhi',
placeId: 'place-1',
primaryLocation: { lat: 28.6139, lng: 77.2090 },
publishStatus: 'Published',
totalViews: 1247,
lastUpdated: '2024-01-15T10:30:00Z',
defaultStartPhoto: 'photo-1'
};
const mockPhotos: Photo[] = [
{
id: 'photo-1',
fileName: 'main_entrance_360.jpg',
status: 'Published',
pose: {
latitude: 28.6139,
longitude: 77.2090,
heading: 180,
pitch: 0,
roll: 0,
altitude: 12,
level: 0,
accuracy: 5
},
captureTime: '2024-01-15T09:30:00Z',
placeId: 'place-1',
placeName: 'Main Entrance',
connections: ['photo-2', 'photo-3'],
shareLink: 'https://streetviewpublish.googleapis.com/v1/photo/photo-1',
thumbnailUrl: '/api/placeholder/400/300',
viewCount: 456
},
{
id: 'photo-2',
fileName: 'lobby_area_360.jpg',
status: 'Published',
pose: {
latitude: 28.61392,
longitude: 77.20902,
heading: 90,
pitch: -10,
roll: 0,
altitude: 12,
level: 0,
accuracy: 3
},
captureTime: '2024-01-15T09:35:00Z',
placeId: 'place-1',
placeName: 'Main Lobby',
connections: ['photo-1', 'photo-4'],
shareLink: 'https://streetviewpublish.googleapis.com/v1/photo/photo-2',
thumbnailUrl: '/api/placeholder/400/300',
viewCount: 234
},
{
id: 'photo-3',
fileName: 'auditorium_360.jpg',
status: 'Processing',
pose: {
latitude: 28.61395,
longitude: 77.20898,
heading: 270,
pitch: 5,
roll: 0,
altitude: 15,
level: 1,
accuracy: 4
},
captureTime: '2024-01-15T09:40:00Z',
placeId: 'place-1',
placeName: 'Main Auditorium',
connections: ['photo-1'],
shareLink: '',
thumbnailUrl: '/api/placeholder/400/300',
viewCount: 0
},
{
id: 'photo-4',
fileName: 'library_section_360.jpg',
status: 'Rejected',
pose: {
latitude: 28.61390,
longitude: 77.20905,
heading: 45,
pitch: 0,
roll: 0,
altitude: 12,
level: 0,
accuracy: 6
},
captureTime: '2024-01-15T09:45:00Z',
placeId: 'place-1',
placeName: 'Library',
connections: ['photo-2'],
shareLink: '',
thumbnailUrl: '/api/placeholder/400/300',
viewCount: 0,
rejectionReason: 'Image quality does not meet minimum requirements. Please ensure proper lighting and resolution.'
}
];
const mockSequences: Sequence[] = [
{
id: 'seq-1',
fileName: 'campus_walkthrough.mp4',
status: 'Completed',
extractedPhotos: 18,
captureTimeStart: '2024-01-15T08:00:00Z',
gpsSource: true,
imuSource: true
}
];
export function Facilities360Detail({ tourId, onNavigate, onLogout, user }: Facilities360DetailProps) {
const [selectedPhoto, setSelectedPhoto] = useState<string>(mockTourData.defaultStartPhoto);
const [editingPhoto, setEditingPhoto] = useState<Photo | null>(null);
const [showExportDialog, setShowExportDialog] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const breadcrumbs = [
{ label: 'Facilities 360 Tour', href: '/facilities-360' },
{ label: mockTourData.title }
];
const selectedPhotoData = mockPhotos.find(p => p.id === selectedPhoto);
const getStatusVariant = (status: string) => {
switch (status.toLowerCase()) {
case 'published': return 'default';
case 'processing': return 'secondary';
case 'rejected': return 'destructive';
default: return 'outline';
}
};
const handlePublishToggle = () => {
const action = mockTourData.publishStatus === 'Published' ? 'Unpublished' : 'Published';
toast.success(`Tour ${action.toLowerCase()} successfully`, { duration: 3000 });
};
const handleSavePose = (updatedPhoto: Photo) => {
toast.success('Pose updated', { duration: 2000 });
setEditingPhoto(null);
};
const handleUpdateConnections = (photoId: string, connections: string[]) => {
toast.success('Connections saved', { duration: 2000 });
};
const handleExportJSON = () => {
const exportData = {
tour: mockTourData,
photos: mockPhotos,
sequences: mockSequences,
connections: mockPhotos.reduce((acc, photo) => {
acc[photo.id] = photo.connections;
return acc;
}, {} as Record<string, string[]>),
defaultStartPhoto: mockTourData.defaultStartPhoto
};
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${mockTourData.title.replace(/\s+/g, '_')}_export.json`;
a.click();
URL.revokeObjectURL(url);
toast.success('Export downloaded', { duration: 2000 });
setShowExportDialog(false);
};
const handleDeleteTour = () => {
toast.success('Tour deleted successfully', { duration: 3000 });
setTimeout(() => onNavigate('/facilities-360'), 1500);
};
const renderPhotoEditor = () => {
if (!editingPhoto) return null;
return (
<Sheet open={!!editingPhoto} onOpenChange={() => setEditingPhoto(null)}>
<SheetContent className="w-[400px] sm:w-[540px]">
<SheetHeader>
<SheetTitle>Edit Photo Details</SheetTitle>
<SheetDescription>
Update pose, place association, and connections for {editingPhoto.fileName}
</SheetDescription>
</SheetHeader>
<div className="py-6 space-y-6">
{/* Pose Section */}
<div>
<h3 className="font-medium mb-4 flex items-center gap-2">
<Navigation className="h-4 w-4" />
Pose
</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="latitude">Latitude</Label>
<Input
id="latitude"
type="number"
step="0.000001"
value={editingPhoto.pose.latitude}
onChange={(e) => setEditingPhoto(prev => prev ? {
...prev,
pose: { ...prev.pose, latitude: parseFloat(e.target.value) }
} : null)}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="longitude">Longitude</Label>
<Input
id="longitude"
type="number"
step="0.000001"
value={editingPhoto.pose.longitude}
onChange={(e) => setEditingPhoto(prev => prev ? {
...prev,
pose: { ...prev.pose, longitude: parseFloat(e.target.value) }
} : null)}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="heading">Heading (0-360°)</Label>
<Input
id="heading"
type="number"
min="0"
max="360"
value={editingPhoto.pose.heading}
onChange={(e) => setEditingPhoto(prev => prev ? {
...prev,
pose: { ...prev.pose, heading: parseFloat(e.target.value) }
} : null)}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="pitch">Pitch (-180 to 180°)</Label>
<Input
id="pitch"
type="number"
min="-180"
max="180"
value={editingPhoto.pose.pitch}
onChange={(e) => setEditingPhoto(prev => prev ? {
...prev,
pose: { ...prev.pose, pitch: parseFloat(e.target.value) }
} : null)}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="roll">Roll (-180 to 180°)</Label>
<Input
id="roll"
type="number"
min="-180"
max="180"
value={editingPhoto.pose.roll}
onChange={(e) => setEditingPhoto(prev => prev ? {
...prev,
pose: { ...prev.pose, roll: parseFloat(e.target.value) }
} : null)}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="altitude">Altitude (meters)</Label>
<Input
id="altitude"
type="number"
value={editingPhoto.pose.altitude}
onChange={(e) => setEditingPhoto(prev => prev ? {
...prev,
pose: { ...prev.pose, altitude: parseFloat(e.target.value) }
} : null)}
className="mt-1"
/>
</div>
</div>
</div>
{/* Capture Time */}
<div>
<Label htmlFor="captureTime">Capture Time</Label>
<Input
id="captureTime"
type="datetime-local"
value={editingPhoto.captureTime.slice(0, 16)}
onChange={(e) => setEditingPhoto(prev => prev ? {
...prev,
captureTime: e.target.value + ':00Z'
} : null)}
className="mt-1"
/>
</div>
{/* Place Association */}
<div>
<Label htmlFor="place">Place Association</Label>
<Input
id="place"
value={editingPhoto.placeName}
onChange={(e) => setEditingPhoto(prev => prev ? {
...prev,
placeName: e.target.value
} : null)}
className="mt-1"
/>
</div>
{/* Connections */}
<div>
<h3 className="font-medium mb-2 flex items-center gap-2">
<Link className="h-4 w-4" />
Connections ({editingPhoto.connections.length})
</h3>
<div className="space-y-2 max-h-40 overflow-y-auto">
{mockPhotos.filter(p => p.id !== editingPhoto.id).map(photo => (
<label key={photo.id} className="flex items-center space-x-2 text-sm">
<input
type="checkbox"
checked={editingPhoto.connections.includes(photo.id)}
onChange={(e) => {
const connections = e.target.checked
? [...editingPhoto.connections, photo.id]
: editingPhoto.connections.filter(id => id !== photo.id);
setEditingPhoto(prev => prev ? { ...prev, connections } : null);
}}
/>
<span>{photo.fileName}</span>
</label>
))}
</div>
</div>
</div>
<div className="flex gap-2">
<Button
onClick={() => handleSavePose(editingPhoto)}
className="flex-1"
style={{ backgroundColor: "var(--color-brand-primary)" }}
>
Save Changes
</Button>
<Button variant="outline" onClick={() => setEditingPhoto(null)}>
Cancel
</Button>
</div>
</SheetContent>
</Sheet>
);
};
return (
<AuthenticatedLayout
currentRoute={`/facilities-360/${tourId}`}
onNavigate={onNavigate}
onLogout={onLogout}
user={user}
breadcrumbs={breadcrumbs}
>
<div className="p-6 space-y-6 max-w-[1440px] mx-auto">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<h1>{mockTourData.title}</h1>
<Badge variant={getStatusVariant(mockTourData.publishStatus)}>
{mockTourData.publishStatus}
</Badge>
</div>
<div className="flex items-center gap-3">
<Button variant="outline" className="min-h-[44px]">
<Eye className="h-4 w-4 mr-2" />
Preview
</Button>
<Button
variant="outline"
onClick={handlePublishToggle}
className="min-h-[44px]"
>
<Upload className="h-4 w-4 mr-2" />
{mockTourData.publishStatus === 'Published' ? 'Unpublish' : 'Publish'}
</Button>
<Button
variant="outline"
onClick={() => setShowExportDialog(true)}
className="min-h-[44px]"
>
<Download className="h-4 w-4 mr-2" />
Export JSON
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" className="min-h-[44px]">
<Trash2 className="h-4 w-4 mr-2" />
Delete
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Tour</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete "{mockTourData.title}"? This action cannot be undone and will remove the tour from Google Maps.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteTour}>
Delete Tour
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
{/* Tour Info */}
<Card>
<CardContent className="p-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<h3 className="font-medium mb-2">Description</h3>
<p className="text-sm text-muted-foreground">{mockTourData.description}</p>
</div>
<div>
<h3 className="font-medium mb-2">Location</h3>
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<MapPin className="h-4 w-4" />
{mockTourData.place}
</div>
</div>
<div>
<h3 className="font-medium mb-2">Statistics</h3>
<div className="space-y-1 text-sm text-muted-foreground">
<div>Total Views: {mockTourData.totalViews.toLocaleString()}</div>
<div>Photos: {mockPhotos.length}</div>
<div>Sequences: {mockSequences.length}</div>
</div>
</div>
</div>
</CardContent>
</Card>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left Panel - Photo/Sequence List */}
<div className="lg:col-span-1">
<Card className="h-fit">
<CardHeader>
<CardTitle>Content ({mockPhotos.length + mockSequences.length})</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Photos */}
<div>
<h3 className="font-medium mb-3 flex items-center gap-2">
<Camera className="h-4 w-4" />
Photos ({mockPhotos.length})
</h3>
<div className="space-y-2">
{mockPhotos.map((photo) => (
<div
key={photo.id}
className={`border rounded-lg p-3 cursor-pointer transition-all ${
selectedPhoto === photo.id
? 'border-brand-primary bg-brand-primary/5'
: 'border-border hover:border-brand-primary/50'
}`}
onClick={() => setSelectedPhoto(photo.id)}
>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium truncate">{photo.fileName}</span>
<Badge variant={getStatusVariant(photo.status)} className="text-xs">
{photo.status}
</Badge>
</div>
<div className="text-xs text-muted-foreground">
<div>Views: {photo.viewCount}</div>
<div>Connections: {photo.connections.length}</div>
</div>
{photo.status === 'Rejected' && photo.rejectionReason && (
<div className="mt-2 text-xs text-red-600 bg-red-50 p-2 rounded">
{photo.rejectionReason}
</div>
)}
</div>
))}
</div>
</div>
{/* Sequences */}
{mockSequences.length > 0 && (
<div>
<h3 className="font-medium mb-3 flex items-center gap-2">
<Activity className="h-4 w-4" />
Sequences ({mockSequences.length})
</h3>
<div className="space-y-2">
{mockSequences.map((sequence) => (
<div key={sequence.id} className="border rounded-lg p-3">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium truncate">{sequence.fileName}</span>
<Badge variant={getStatusVariant(sequence.status)} className="text-xs">
{sequence.status}
</Badge>
</div>
<div className="text-xs text-muted-foreground">
<div>Extracted: {sequence.extractedPhotos} photos</div>
<div>GPS: {sequence.gpsSource ? '✓' : '✗'} | IMU: {sequence.imuSource ? '✓' : '✗'}</div>
</div>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
</div>
{/* Right Panel - Preview and Info */}
<div className="lg:col-span-2 space-y-6">
{/* 360 Preview */}
<Card>
<CardHeader>
<CardTitle>360° Preview</CardTitle>
</CardHeader>
<CardContent>
<div className="aspect-video bg-muted rounded-lg flex items-center justify-center mb-4">
<div className="text-center">
<Eye className="h-12 w-12 mx-auto mb-3 text-muted-foreground" />
<p className="text-muted-foreground">360° Street View Preview</p>
{selectedPhotoData && (
<p className="text-sm text-muted-foreground mt-1">
{selectedPhotoData.fileName}
</p>
)}
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex gap-2">
<Button variant="outline" size="sm">
<Compass className="h-4 w-4 mr-1" />
Reset View
</Button>
<Button variant="outline" size="sm">
<ExternalLink className="h-4 w-4 mr-1" />
Open in Maps
</Button>
</div>
{selectedPhotoData?.shareLink && (
<Button
variant="outline"
size="sm"
onClick={() => {
navigator.clipboard.writeText(selectedPhotoData.shareLink);
toast.success('Share link copied', { duration: 2000 });
}}
>
<Copy className="h-4 w-4 mr-1" />
Copy Link
</Button>
)}
</div>
</CardContent>
</Card>
{/* Photo Details */}
{selectedPhotoData && (
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
Photo Details
<Button
variant="outline"
size="sm"
onClick={() => setEditingPhoto(selectedPhotoData)}
>
<Edit className="h-4 w-4 mr-1" />
Edit
</Button>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<div className="text-muted-foreground">Latitude</div>
<div className="font-medium">{selectedPhotoData.pose.latitude.toFixed(6)}</div>
</div>
<div>
<div className="text-muted-foreground">Longitude</div>
<div className="font-medium">{selectedPhotoData.pose.longitude.toFixed(6)}</div>
</div>
<div>
<div className="text-muted-foreground">Heading</div>
<div className="font-medium">{selectedPhotoData.pose.heading}°</div>
</div>
<div>
<div className="text-muted-foreground">Views</div>
<div className="font-medium">{selectedPhotoData.viewCount}</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<div className="text-muted-foreground">Capture Time</div>
<div className="font-medium">
{new Date(selectedPhotoData.captureTime).toLocaleString()}
</div>
</div>
<div>
<div className="text-muted-foreground">Place</div>
<div className="font-medium">{selectedPhotoData.placeName}</div>
</div>
</div>
<div>
<div className="text-muted-foreground mb-2">Connections ({selectedPhotoData.connections.length})</div>
<div className="flex flex-wrap gap-2">
{selectedPhotoData.connections.map(connId => {
const connPhoto = mockPhotos.find(p => p.id === connId);
return connPhoto ? (
<Badge key={connId} variant="outline" className="text-xs">
{connPhoto.fileName}
</Badge>
) : null;
})}
{selectedPhotoData.connections.length === 0 && (
<span className="text-sm text-muted-foreground">No connections</span>
)}
</div>
</div>
</CardContent>
</Card>
)}
</div>
</div>
{/* Export Dialog */}
<Dialog open={showExportDialog} onOpenChange={setShowExportDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Export Tour Data</DialogTitle>
<DialogDescription>
Download a JSON file containing all tour data including photos, connections, and metadata.
</DialogDescription>
</DialogHeader>
<div className="py-4">
<div className="bg-muted rounded-lg p-4 text-sm">
<div className="space-y-2">
<div> Tour information and settings</div>
<div> All photo metadata and poses</div>
<div> Photo connections graph</div>
<div> Sequence data</div>
<div> Default start photo configuration</div>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowExportDialog(false)}>
Cancel
</Button>
<Button onClick={handleExportJSON}>
<Download className="h-4 w-4 mr-2" />
Download JSON
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Photo Editor Sheet */}
{renderPhotoEditor()}
</div>
</AuthenticatedLayout>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,665 @@
import React, { useState } from 'react';
import { AuthenticatedLayout } from '../layout/AuthenticatedLayout';
import { Button } from '../ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Textarea } from '../ui/textarea';
import { Badge } from '../ui/badge';
import { Separator } from '../ui/separator';
import { toast } from "sonner@2.0.3";
import {
Save,
Eye,
Send,
Check,
X,
History,
GripVertical,
Plus,
Trash2,
Link
} from 'lucide-react';
import { InternalLinkPicker } from '../landing-pages/InternalLinkPicker';
import { MediaPicker } from '../landing-pages/MediaPicker';
import { PreviewModal } from '../landing-pages/PreviewModal';
import { VersionHistory } from '../landing-pages/VersionHistory';
import { AuditDrawer } from '../landing-pages/AuditDrawer';
interface HomeEditorProps {
onNavigate: (route: string) => void;
onLogout: () => void;
user: any;
}
interface HeroData {
imageUrl?: string;
imageAlt?: string;
headline: string;
subtext: string;
cta: {
label: string;
internalHref: string;
};
}
interface StatData {
value: number;
suffix: string;
label: string;
}
interface HighlightCard {
title: string;
iconUrl?: string;
iconAlt?: string;
body: string;
}
interface CtaBandData {
imageUrl?: string;
imageAlt?: string;
text: string;
cta: {
label: string;
internalHref: string;
};
}
export function HomeEditor({ onNavigate, onLogout, user }: HomeEditorProps) {
const [status, setStatus] = useState<'draft' | 'in_review' | 'changes_requested' | 'approved' | 'published'>('draft');
const [isLinkPickerOpen, setIsLinkPickerOpen] = useState(false);
const [activeLinkField, setActiveLinkField] = useState<'hero' | 'cta'>('hero');
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
const [isVersionHistoryOpen, setIsVersionHistoryOpen] = useState(false);
const [isAuditOpen, setIsAuditOpen] = useState(false);
// Form data
const [hero, setHero] = useState<HeroData>({
headline: '',
subtext: '',
cta: { label: '', internalHref: '' }
});
const [stats, setStats] = useState<StatData[]>([
{ value: 27187, suffix: '+', label: 'LEADERS DEVELOPED' },
{ value: 15510, suffix: '+', label: 'CORPORATE CLIENTS' },
{ value: 1240, suffix: '+', label: 'COUNTRIES SERVED' }
]);
const [highlights, setHighlights] = useState<HighlightCard[]>([
{ title: '', body: '' },
{ title: '', body: '' },
{ title: '', body: '' }
]);
const [ctaBand, setCtaBand] = useState<CtaBandData>({
text: '',
cta: { label: '', internalHref: '' }
});
const breadcrumbs = [
{ label: "Admin", href: "/dashboard" },
{ label: "Landing Pages", href: "/landing-pages" },
{ label: "Home" }
];
const handleSaveDraft = () => {
toast.success("Saved as draft.");
};
const handleSubmitForApproval = () => {
setStatus('in_review');
toast.success("Submitted for approval.");
};
const handleApprove = () => {
setStatus('approved');
toast.success("Approved.");
};
const handleRequestChanges = () => {
setStatus('changes_requested');
toast.success("Changes requested.");
};
const handlePublish = () => {
setStatus('published');
toast.success("Published.");
};
const handleUnpublish = () => {
setStatus('draft');
toast.success("Unpublished.");
};
const openLinkPicker = (field: 'hero' | 'cta') => {
setActiveLinkField(field);
setIsLinkPickerOpen(true);
};
const handleLinkSelect = (link: { href: string; title: string }) => {
if (activeLinkField === 'hero') {
setHero(prev => ({
...prev,
cta: { ...prev.cta, internalHref: link.href }
}));
} else if (activeLinkField === 'cta') {
setCtaBand(prev => ({
...prev,
cta: { ...prev.cta, internalHref: link.href }
}));
}
};
const validateCTA = (cta: { label: string; internalHref: string }) => {
if (cta.label && !cta.internalHref) return "CTA requires both text and destination.";
if (!cta.label && cta.internalHref) return "CTA requires both text and destination.";
return null;
};
const getStatusBadgeVariant = () => {
switch (status) {
case 'published': return 'default';
case 'approved': return 'secondary';
case 'in_review': return 'outline';
case 'changes_requested': return 'destructive';
default: return 'secondary';
}
};
const getStatusLabel = () => {
switch (status) {
case 'draft': return 'Draft';
case 'in_review': return 'In Review';
case 'changes_requested': return 'Changes Requested';
case 'approved': return 'Approved';
case 'published': return 'Published';
default: return 'Draft';
}
};
const canEdit = status !== 'in_review';
const canApprove = user.role === 'Super Admin' && status === 'in_review';
const canPublish = user.role === 'Super Admin' && status === 'approved';
const renderHeader = () => (
<div className="sticky top-0 bg-background border-b p-6 z-10">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<h1>Home Page</h1>
<Badge variant={getStatusBadgeVariant()}>
{getStatusLabel()}
</Badge>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={handleSaveDraft}
disabled={!canEdit}
className="min-h-[44px]"
>
<Save className="h-4 w-4 mr-2" />
Save Draft
</Button>
<Button
variant="outline"
onClick={() => setIsPreviewOpen(true)}
className="min-h-[44px]"
>
<Eye className="h-4 w-4 mr-2" />
Preview
</Button>
{status === 'draft' && (
<Button
onClick={handleSubmitForApproval}
className="min-h-[44px]"
style={{ backgroundColor: "var(--color-brand-primary)" }}
>
<Send className="h-4 w-4 mr-2" />
Submit for Approval
</Button>
)}
{canApprove && (
<>
<Button
onClick={handleApprove}
className="min-h-[44px]"
style={{ backgroundColor: "var(--color-brand-primary)" }}
>
<Check className="h-4 w-4 mr-2" />
Approve
</Button>
<Button
variant="outline"
onClick={handleRequestChanges}
className="min-h-[44px]"
>
<X className="h-4 w-4 mr-2" />
Request Changes
</Button>
</>
)}
{canPublish && (
<Button
onClick={handlePublish}
className="min-h-[44px]"
style={{ backgroundColor: "var(--color-brand-primary)" }}
>
Publish
</Button>
)}
{status === 'published' && (
<Button
variant="outline"
onClick={handleUnpublish}
className="min-h-[44px]"
>
Unpublish
</Button>
)}
<Button
variant="ghost"
onClick={() => setIsAuditOpen(true)}
className="min-h-[44px] w-[44px] p-0"
>
<History className="h-4 w-4" />
<span className="sr-only">Audit</span>
</Button>
</div>
</div>
</div>
);
const renderRightRail = () => (
<div className="w-80 border-l bg-muted/25 p-6 space-y-6">
<div>
<h3 className="font-medium mb-3">Page Information</h3>
<div className="space-y-2 text-sm">
<div>
<span className="text-muted-foreground">URL:</span>
<span className="ml-2 font-mono">/</span>
</div>
<div>
<span className="text-muted-foreground">Last published:</span>
<span className="ml-2">2024-01-15 14:30</span>
</div>
<div>
<span className="text-muted-foreground">Last editor:</span>
<span className="ml-2">Admin User</span>
</div>
</div>
</div>
<div>
<h3 className="font-medium mb-3">Actions</h3>
<div className="space-y-2">
<Button
variant="outline"
onClick={() => setIsVersionHistoryOpen(true)}
className="w-full justify-start min-h-[44px]"
>
<History className="h-4 w-4 mr-2" />
Version History
</Button>
</div>
</div>
</div>
);
const renderHeroSection = () => (
<Card>
<CardHeader>
<CardTitle>Hero Section</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* Background Image */}
<div className="space-y-2">
<Label>Background Image</Label>
<MediaPicker
type="image"
onChange={() => {}}
recommendedSize="1440×600px"
required
/>
</div>
{/* Headline */}
<div className="space-y-2">
<Label htmlFor="hero-headline">
Headline <span className="text-destructive">*</span>
</Label>
<Input
id="hero-headline"
value={hero.headline}
onChange={(e) => setHero(prev => ({ ...prev, headline: e.target.value }))}
placeholder="Enter hero headline"
disabled={!canEdit}
required
/>
</div>
{/* Subtext */}
<div className="space-y-2">
<Label htmlFor="hero-subtext">Subtext</Label>
<Textarea
id="hero-subtext"
value={hero.subtext}
onChange={(e) => setHero(prev => ({ ...prev, subtext: e.target.value }))}
placeholder="Enter hero subtext"
disabled={!canEdit}
rows={3}
/>
</div>
{/* CTA */}
<div className="space-y-4">
<Label>Call to Action</Label>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="hero-cta-text">CTA Text</Label>
<Input
id="hero-cta-text"
value={hero.cta.label}
onChange={(e) => setHero(prev => ({
...prev,
cta: { ...prev.cta, label: e.target.value }
}))}
placeholder="Enter CTA text"
disabled={!canEdit}
/>
</div>
<div className="space-y-2">
<Label>CTA Destination</Label>
<div className="flex gap-2">
<Input
value={hero.cta.internalHref}
placeholder="Select internal link"
readOnly
className="flex-1"
/>
<Button
variant="outline"
onClick={() => openLinkPicker('hero')}
disabled={!canEdit}
className="flex-shrink-0"
>
<Link className="h-4 w-4" />
<span className="sr-only">Select link</span>
</Button>
</div>
</div>
</div>
{validateCTA(hero.cta) && (
<p className="text-sm text-destructive">{validateCTA(hero.cta)}</p>
)}
</div>
</CardContent>
</Card>
);
const renderStatsSection = () => (
<Card>
<CardHeader>
<CardTitle>Stats Section</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{stats.map((stat, index) => (
<div key={index} className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor={`stat-${index}-value`}>Number</Label>
<Input
id={`stat-${index}-value`}
type="number"
value={stat.value}
onChange={(e) => {
const newStats = [...stats];
newStats[index].value = parseInt(e.target.value) || 0;
setStats(newStats);
}}
disabled={!canEdit}
min="0"
/>
</div>
<div className="space-y-2">
<Label htmlFor={`stat-${index}-suffix`}>Suffix</Label>
<Input
id={`stat-${index}-suffix`}
value={stat.suffix}
onChange={(e) => {
const newStats = [...stats];
newStats[index].suffix = e.target.value;
setStats(newStats);
}}
disabled={!canEdit}
placeholder="+"
/>
</div>
<div className="space-y-2">
<Label htmlFor={`stat-${index}-label`}>Label</Label>
<Input
id={`stat-${index}-label`}
value={stat.label}
onChange={(e) => {
const newStats = [...stats];
newStats[index].label = e.target.value;
setStats(newStats);
}}
disabled={!canEdit}
placeholder="Label"
required
/>
</div>
</div>
))}
</CardContent>
</Card>
);
const renderHighlightsSection = () => (
<Card>
<CardHeader>
<CardTitle>Highlights Cards</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{highlights.map((highlight, index) => (
<div key={index} className="space-y-4 p-4 border rounded-lg">
<div className="flex items-center gap-2">
<GripVertical className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">Card {index + 1}</span>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor={`highlight-${index}-title`}>Card Title</Label>
<Input
id={`highlight-${index}-title`}
value={highlight.title}
onChange={(e) => {
const newHighlights = [...highlights];
newHighlights[index].title = e.target.value;
setHighlights(newHighlights);
}}
disabled={!canEdit}
placeholder="Enter card title"
/>
</div>
<div className="space-y-2">
<Label>Icon</Label>
<MediaPicker
type="icon"
onChange={() => {}}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor={`highlight-${index}-body`}>Body Text</Label>
<Textarea
id={`highlight-${index}-body`}
value={highlight.body}
onChange={(e) => {
const newHighlights = [...highlights];
newHighlights[index].body = e.target.value;
setHighlights(newHighlights);
}}
disabled={!canEdit}
placeholder="Enter card body text"
rows={3}
/>
</div>
</div>
</div>
))}
</CardContent>
</Card>
);
const renderCtaBandSection = () => (
<Card>
<CardHeader>
<CardTitle>CTA Band</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* Background Image */}
<div className="space-y-2">
<Label>Background Image</Label>
<MediaPicker
type="image"
onChange={() => {}}
recommendedSize="1440×400px"
required
/>
</div>
{/* Text */}
<div className="space-y-2">
<Label htmlFor="cta-text">Text</Label>
<Input
id="cta-text"
value={ctaBand.text}
onChange={(e) => setCtaBand(prev => ({ ...prev, text: e.target.value }))}
placeholder="Enter CTA band text"
disabled={!canEdit}
/>
</div>
{/* CTA */}
<div className="space-y-4">
<Label>Call to Action</Label>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="cta-cta-text">CTA Text</Label>
<Input
id="cta-cta-text"
value={ctaBand.cta.label}
onChange={(e) => setCtaBand(prev => ({
...prev,
cta: { ...prev.cta, label: e.target.value }
}))}
placeholder="Enter CTA text"
disabled={!canEdit}
/>
</div>
<div className="space-y-2">
<Label>CTA Destination</Label>
<div className="flex gap-2">
<Input
value={ctaBand.cta.internalHref}
placeholder="Select internal link"
readOnly
className="flex-1"
/>
<Button
variant="outline"
onClick={() => openLinkPicker('cta')}
disabled={!canEdit}
className="flex-shrink-0"
>
<Link className="h-4 w-4" />
<span className="sr-only">Select link</span>
</Button>
</div>
</div>
</div>
{validateCTA(ctaBand.cta) && (
<p className="text-sm text-destructive">{validateCTA(ctaBand.cta)}</p>
)}
</div>
</CardContent>
</Card>
);
return (
<AuthenticatedLayout
currentRoute="/landing-pages/edit/home"
onNavigate={onNavigate}
user={user}
onLogout={onLogout}
breadcrumbs={breadcrumbs}
>
<div className="min-h-screen flex flex-col">
{renderHeader()}
<div className="flex-1 flex">
{/* Main Content */}
<div className="flex-1 p-6 space-y-6 overflow-y-auto">
{renderHeroSection()}
{renderStatsSection()}
{renderHighlightsSection()}
{renderCtaBandSection()}
</div>
{/* Right Rail */}
{renderRightRail()}
</div>
</div>
{/* Modals */}
<InternalLinkPicker
isOpen={isLinkPickerOpen}
onClose={() => setIsLinkPickerOpen(false)}
onSelect={handleLinkSelect}
currentLink={activeLinkField === 'hero' ?
{ href: hero.cta.internalHref, title: '' } :
{ href: ctaBand.cta.internalHref, title: '' }
}
/>
<PreviewModal
isOpen={isPreviewOpen}
onClose={() => setIsPreviewOpen(false)}
pageTitle="Home"
pageData={{ hero, stats, highlights, ctaBand }}
/>
<VersionHistory
isOpen={isVersionHistoryOpen}
onClose={() => setIsVersionHistoryOpen(false)}
pageTitle="Home"
/>
<AuditDrawer
isOpen={isAuditOpen}
onClose={() => setIsAuditOpen(false)}
pageTitle="Home"
/>
</AuthenticatedLayout>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,132 @@
import React from 'react';
import { AuthenticatedLayout } from '../layout/AuthenticatedLayout';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
import { Button } from '../ui/button';
import { Badge } from '../ui/badge';
import { Edit, Eye } from 'lucide-react';
interface LandingPagesProps {
onNavigate: (route: string) => void;
onLogout: () => void;
user: any;
}
interface PageInfo {
id: string;
title: string;
route: string;
status: 'draft' | 'published';
lastUpdated: string;
lastEditor: string;
}
const pages: PageInfo[] = [
{
id: 'home',
title: 'Home',
route: '/landing-pages/edit/home',
status: 'published',
lastUpdated: '2024-01-15 14:30',
lastEditor: 'Admin User'
},
{
id: 'services',
title: 'Services',
route: '/landing-pages/edit/services',
status: 'draft',
lastUpdated: '2024-01-14 11:20',
lastEditor: 'Content Team'
},
{
id: 'about-us',
title: 'About Us',
route: '/landing-pages/edit/about-us',
status: 'published',
lastUpdated: '2024-01-13 16:45',
lastEditor: 'Marketing Team'
}
];
export function LandingPages({ onNavigate, onLogout, user }: LandingPagesProps) {
const breadcrumbs = [
{ label: "Landing Pages" }
];
const getStatusBadgeVariant = (status: string) => {
return status === 'published' ? 'default' : 'secondary';
};
const handlePreview = (pageId: string, e: React.MouseEvent) => {
e.stopPropagation();
// In a real app, this would open the live page
window.open(`/${pageId === 'home' ? '' : pageId}`, '_blank');
};
return (
<AuthenticatedLayout
currentRoute="/landing-pages"
onNavigate={onNavigate}
user={user}
onLogout={onLogout}
breadcrumbs={breadcrumbs}
>
<div className="space-y-6 mt-[20px] mr-[40px] mb-[0px] ml-[40px]">
{/* Header */}
<div>
<h1>Landing Pages</h1>
<p className="text-muted-foreground">
Edit content for the main landing pages
</p>
</div>
{/* Page Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{pages.map((page) => (
<Card
key={page.id}
className="transition-shadow hover:shadow-md cursor-pointer"
onClick={() => onNavigate(page.route)}
>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-xl">{page.title}</CardTitle>
<Badge variant={getStatusBadgeVariant(page.status)}>
{page.status === 'published' ? 'Published' : 'Draft'}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Last Updated */}
<div className="text-sm text-muted-foreground">
<div>Last updated: {page.lastUpdated}</div>
<div>By: {page.lastEditor}</div>
</div>
{/* Actions */}
<div className="flex gap-2">
<Button
onClick={() => onNavigate(page.route)}
className="flex-1 min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
style={{ backgroundColor: "var(--color-brand-primary)" }}
>
<Edit className="h-4 w-4 mr-2" />
Edit
</Button>
<Button
variant="secondary"
onClick={(e) => handlePreview(page.id, e)}
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
>
<Eye className="h-4 w-4 mr-2" />
Preview
</Button>
</div>
</CardContent>
</Card>
))}
</div>
</div>
</AuthenticatedLayout>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,422 @@
import React, { useState } from 'react';
import { AuthenticatedLayout } from '../layout/AuthenticatedLayout';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Textarea } from '../ui/textarea';
import { Badge } from '../ui/badge';
import { toast } from "sonner@2.0.3";
import { ArrowLeft, Upload, X, Plus } from 'lucide-react';
interface NewBlogProps {
onNavigate: (route: string) => void;
onLogout: () => void;
user: any;
formData?: any;
onAutoSave?: (data: any) => void;
onClearAutoSave?: (route?: string) => void;
}
export function NewBlog({
onNavigate,
onLogout,
user,
formData,
onAutoSave,
onClearAutoSave
}: NewBlogProps) {
const [blogData, setBlogData] = useState(() => ({
title: formData?.title || '',
slug: formData?.slug || '',
metaTitle: formData?.metaTitle || '',
metaDescription: formData?.metaDescription || '',
body: formData?.body || '',
bannerImage: formData?.bannerImage || null,
bannerAltText: formData?.bannerAltText || '',
tags: formData?.tags || [],
category: formData?.category || '',
status: formData?.status || 'draft'
}));
const [newTag, setNewTag] = useState('');
const [isUploading, setIsUploading] = useState(false);
// Auto-save functionality
React.useEffect(() => {
if (onAutoSave) {
const timer = setTimeout(() => {
onAutoSave(blogData);
}, 1000);
return () => clearTimeout(timer);
}
}, [blogData, onAutoSave]);
const handleInputChange = (field: string, value: any) => {
setBlogData(prev => ({
...prev,
[field]: value
}));
// Auto-generate slug from title
if (field === 'title' && !blogData.slug) {
const slug = value.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim();
setBlogData(prev => ({
...prev,
slug
}));
}
};
const handleImageUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
toast.error('Please select a valid image file');
return;
}
setIsUploading(true);
try {
// Simulate upload
await new Promise(resolve => setTimeout(resolve, 1500));
const imageUrl = URL.createObjectURL(file);
handleInputChange('bannerImage', {
url: imageUrl,
name: file.name,
size: file.size
});
toast.success('Banner image uploaded successfully');
} catch (error) {
toast.error('Failed to upload image');
} finally {
setIsUploading(false);
}
};
const addTag = () => {
if (newTag.trim() && !blogData.tags.includes(newTag.trim())) {
handleInputChange('tags', [...blogData.tags, newTag.trim()]);
setNewTag('');
}
};
const removeTag = (tagToRemove: string) => {
handleInputChange('tags', blogData.tags.filter((tag: string) => tag !== tagToRemove));
};
const handleSave = async (status: 'draft' | 'published') => {
if (!blogData.title.trim()) {
toast.error('Please enter a blog title');
return;
}
if (!blogData.body.trim()) {
toast.error('Please enter blog content');
return;
}
try {
const blogToSave = {
...blogData,
status,
id: Date.now().toString(),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
author: user.name
};
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000));
if (onClearAutoSave) {
onClearAutoSave();
}
toast.success(`Blog ${status === 'draft' ? 'saved as draft' : 'published'} successfully`);
onNavigate('/content');
} catch (error) {
toast.error('Failed to save blog');
}
};
const categories = ['Technology', 'Business', 'Marketing', 'Design', 'Development', 'Other'];
return (
<AuthenticatedLayout
user={user}
onLogout={onLogout}
currentPath="/content/blogs/new"
>
<div className="space-y-6 mt-[20px] mr-[20px] mb-[0px] ml-[20px]">
<div className="flex items-center gap-4">
<Button
variant="ghost"
onClick={() => onNavigate('/content')}
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Content
</Button>
<div>
<h1>Create New Blog Post</h1>
<p className="text-muted-foreground mt-1">
Write and publish a new blog post for your audience
</p>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main Content */}
<div className="lg:col-span-2 space-y-6">
<Card>
<CardHeader>
<CardTitle>Blog Content</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="title">Title *</Label>
<Input
id="title"
value={blogData.title}
onChange={(e) => handleInputChange('title', e.target.value)}
placeholder="Enter blog title"
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
/>
</div>
<div className="space-y-2">
<Label htmlFor="slug">URL Slug</Label>
<Input
id="slug"
value={blogData.slug}
onChange={(e) => handleInputChange('slug', e.target.value)}
placeholder="blog-url-slug"
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
/>
<p className="text-sm text-muted-foreground">
URL: /blog/{blogData.slug || 'blog-url-slug'}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="body">Content *</Label>
<Textarea
id="body"
value={blogData.body}
onChange={(e) => handleInputChange('body', e.target.value)}
placeholder="Write your blog content here..."
rows={15}
className="min-h-[300px] resize-y focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
/>
</div>
</CardContent>
</Card>
{/* SEO Settings */}
<Card>
<CardHeader>
<CardTitle>SEO Settings</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="metaTitle">Meta Title</Label>
<Input
id="metaTitle"
value={blogData.metaTitle}
onChange={(e) => handleInputChange('metaTitle', e.target.value)}
placeholder="SEO optimized title"
maxLength={60}
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
/>
<p className="text-sm text-muted-foreground">
{blogData.metaTitle.length}/60 characters
</p>
</div>
<div className="space-y-2">
<Label htmlFor="metaDescription">Meta Description</Label>
<Textarea
id="metaDescription"
value={blogData.metaDescription}
onChange={(e) => handleInputChange('metaDescription', e.target.value)}
placeholder="Brief description for search engines"
maxLength={160}
rows={3}
className="resize-y focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
/>
<p className="text-sm text-muted-foreground">
{blogData.metaDescription.length}/160 characters
</p>
</div>
</CardContent>
</Card>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Banner Image */}
<Card>
<CardHeader>
<CardTitle>Banner Image</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{blogData.bannerImage ? (
<div className="space-y-2">
<div className="relative">
<img
src={blogData.bannerImage.url}
alt="Banner preview"
className="w-full h-32 object-cover rounded border"
/>
<Button
variant="destructive"
size="sm"
onClick={() => handleInputChange('bannerImage', null)}
className="absolute top-2 right-2 min-h-[32px] h-8 w-8 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="space-y-2">
<Label htmlFor="bannerAltText">Alt Text</Label>
<Input
id="bannerAltText"
value={blogData.bannerAltText}
onChange={(e) => handleInputChange('bannerAltText', e.target.value)}
placeholder="Describe the image"
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
/>
</div>
</div>
) : (
<div className="space-y-2">
<input
type="file"
accept="image/*"
onChange={handleImageUpload}
className="hidden"
id="banner-upload"
/>
<Label
htmlFor="banner-upload"
className="flex flex-col items-center justify-center w-full h-32 border-2 border-dashed border-muted-foreground/25 rounded cursor-pointer hover:border-muted-foreground/50 transition-colors"
>
{isUploading ? (
<div className="text-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto"></div>
<p className="text-sm text-muted-foreground mt-2">Uploading...</p>
</div>
) : (
<div className="text-center">
<Upload className="h-6 w-6 text-muted-foreground mx-auto" />
<p className="text-sm text-muted-foreground mt-2">Click to upload banner</p>
</div>
)}
</Label>
</div>
)}
</CardContent>
</Card>
{/* Category & Tags */}
<Card>
<CardHeader>
<CardTitle>Classification</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="category">Category</Label>
<select
id="category"
value={blogData.category}
onChange={(e) => handleInputChange('category', e.target.value)}
className="w-full min-h-[44px] px-3 py-2 border border-input bg-background rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
>
<option value="">Select category</option>
{categories.map(category => (
<option key={category} value={category}>{category}</option>
))}
</select>
</div>
<div className="space-y-2">
<Label>Tags</Label>
<div className="flex gap-2">
<Input
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
placeholder="Add tag"
onKeyPress={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addTag();
}
}}
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
/>
<Button
type="button"
onClick={addTag}
variant="outline"
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
>
<Plus className="h-4 w-4" />
</Button>
</div>
{blogData.tags.length > 0 && (
<div className="flex flex-wrap gap-2 mt-2">
{blogData.tags.map((tag: string) => (
<Badge
key={tag}
variant="secondary"
className="flex items-center gap-1"
>
{tag}
<X
className="h-3 w-3 cursor-pointer"
onClick={() => removeTag(tag)}
/>
</Badge>
))}
</div>
)}
</div>
</CardContent>
</Card>
{/* Actions */}
<Card>
<CardHeader>
<CardTitle>Publish</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<Button
onClick={() => handleSave('draft')}
variant="outline"
className="w-full min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
>
Save as Draft
</Button>
<Button
onClick={() => handleSave('published')}
className="w-full min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
style={{ backgroundColor: "var(--color-brand-primary)" }}
>
Publish Blog
</Button>
</CardContent>
</Card>
</div>
</div>
</div>
</AuthenticatedLayout>
);
}

View File

@@ -0,0 +1,481 @@
import React, { useState } from 'react';
import { AuthenticatedLayout } from '../layout/AuthenticatedLayout';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Textarea } from '../ui/textarea';
import { Badge } from '../ui/badge';
import { toast } from "sonner@2.0.3";
import { ArrowLeft, Plus, X, Save, GripVertical } from 'lucide-react';
interface FAQ {
id: string;
question: string;
answer: string;
category: string;
tags: string[];
order: number;
}
interface NewFAQProps {
onNavigate: (route: string) => void;
onLogout: () => void;
user: any;
formData?: any;
onAutoSave?: (data: any) => void;
onClearAutoSave?: (route?: string) => void;
}
export function NewFAQ({
onNavigate,
onLogout,
user,
formData,
onAutoSave,
onClearAutoSave
}: NewFAQProps) {
const [faqs, setFaqs] = useState<FAQ[]>(() =>
formData?.faqs || [
{
id: '1',
question: '',
answer: '',
category: '',
tags: [],
order: 0
}
]
);
const [globalTags, setGlobalTags] = useState<string[]>(formData?.globalTags || []);
const [newGlobalTag, setNewGlobalTag] = useState('');
// Auto-save functionality
React.useEffect(() => {
if (onAutoSave) {
const timer = setTimeout(() => {
onAutoSave({ faqs, globalTags });
}, 1000);
return () => clearTimeout(timer);
}
}, [faqs, globalTags, onAutoSave]);
const categories = ['General', 'Technical', 'Account', 'Billing', 'Support', 'Features', 'Other'];
const addFAQ = () => {
const newFAQ: FAQ = {
id: Date.now().toString(),
question: '',
answer: '',
category: '',
tags: [],
order: faqs.length
};
setFaqs([...faqs, newFAQ]);
};
const removeFAQ = (id: string) => {
if (faqs.length === 1) {
toast.error('At least one FAQ is required');
return;
}
setFaqs(faqs.filter(faq => faq.id !== id));
};
const updateFAQ = (id: string, field: keyof FAQ, value: any) => {
setFaqs(faqs.map(faq =>
faq.id === id ? { ...faq, [field]: value } : faq
));
};
const addTagToFAQ = (faqId: string, tag: string) => {
if (!tag.trim()) return;
const faq = faqs.find(f => f.id === faqId);
if (faq && !faq.tags.includes(tag.trim())) {
updateFAQ(faqId, 'tags', [...faq.tags, tag.trim()]);
}
};
const removeTagFromFAQ = (faqId: string, tagToRemove: string) => {
const faq = faqs.find(f => f.id === faqId);
if (faq) {
updateFAQ(faqId, 'tags', faq.tags.filter(tag => tag !== tagToRemove));
}
};
const addGlobalTag = () => {
if (newGlobalTag.trim() && !globalTags.includes(newGlobalTag.trim())) {
setGlobalTags([...globalTags, newGlobalTag.trim()]);
setNewGlobalTag('');
}
};
const removeGlobalTag = (tagToRemove: string) => {
setGlobalTags(globalTags.filter(tag => tag !== tagToRemove));
};
const applyGlobalTagsToAll = () => {
setFaqs(faqs.map(faq => ({
...faq,
tags: [...new Set([...faq.tags, ...globalTags])]
})));
toast.success('Global tags applied to all FAQs');
};
const moveFAQ = (id: string, direction: 'up' | 'down') => {
const currentIndex = faqs.findIndex(faq => faq.id === id);
if (
(direction === 'up' && currentIndex === 0) ||
(direction === 'down' && currentIndex === faqs.length - 1)
) {
return;
}
const newFaqs = [...faqs];
const targetIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
[newFaqs[currentIndex], newFaqs[targetIndex]] = [newFaqs[targetIndex], newFaqs[currentIndex]];
// Update order values
newFaqs.forEach((faq, index) => {
faq.order = index;
});
setFaqs(newFaqs);
};
const handleSave = async () => {
// Validate FAQs
const invalidFAQs = faqs.filter(faq => !faq.question.trim() || !faq.answer.trim());
if (invalidFAQs.length > 0) {
toast.error('All FAQs must have both question and answer');
return;
}
try {
const faqsToSave = faqs.map((faq, index) => ({
...faq,
order: index,
id: faq.id.startsWith('temp_') ? undefined : faq.id, // Remove temp IDs
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
author: user.name
}));
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1500));
if (onClearAutoSave) {
onClearAutoSave();
}
toast.success(`${faqs.length} FAQ(s) saved successfully`);
onNavigate('/content');
} catch (error) {
toast.error('Failed to save FAQs');
}
};
return (
<AuthenticatedLayout
user={user}
onLogout={onLogout}
currentPath="/content/faqs/new"
>
<div className="space-y-6 p-[0px] mt-[20px] mr-[20px] mb-[0px] ml-[20px]">
<div className="flex items-center gap-4">
<Button
variant="ghost"
onClick={() => onNavigate('/content')}
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Content
</Button>
<div className="flex-1">
<h1>Create FAQ(s)</h1>
<p className="text-muted-foreground mt-1">
Add multiple frequently asked questions and their answers
</p>
</div>
<Button
onClick={handleSave}
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
style={{ backgroundColor: "var(--color-brand-primary)" }}
>
<Save className="h-4 w-4 mr-2" />
Save All FAQs
</Button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Main Content */}
<div className="lg:col-span-3 space-y-6">
{/* Global Tags */}
<Card>
<CardHeader>
<CardTitle>Global Tags</CardTitle>
<p className="text-sm text-muted-foreground">
Tags that can be applied to all FAQs at once
</p>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex gap-2">
<Input
value={newGlobalTag}
onChange={(e) => setNewGlobalTag(e.target.value)}
placeholder="Add global tag"
onKeyPress={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addGlobalTag();
}
}}
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
/>
<Button
type="button"
onClick={addGlobalTag}
variant="outline"
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
>
<Plus className="h-4 w-4" />
</Button>
</div>
{globalTags.length > 0 && (
<div className="space-y-2">
<div className="flex flex-wrap gap-2">
{globalTags.map((tag) => (
<Badge
key={tag}
variant="secondary"
className="flex items-center gap-1"
>
{tag}
<X
className="h-3 w-3 cursor-pointer"
onClick={() => removeGlobalTag(tag)}
/>
</Badge>
))}
</div>
<Button
onClick={applyGlobalTagsToAll}
variant="outline"
size="sm"
className="min-h-[36px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
>
Apply to All FAQs
</Button>
</div>
)}
</CardContent>
</Card>
{/* FAQ List */}
<div className="space-y-4">
{faqs.map((faq, index) => (
<Card key={faq.id}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-lg">FAQ #{index + 1}</CardTitle>
<div className="flex items-center gap-2">
<div className="flex flex-col gap-1">
<Button
onClick={() => moveFAQ(faq.id, 'up')}
disabled={index === 0}
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
>
<GripVertical className="h-3 w-3" />
</Button>
<Button
onClick={() => moveFAQ(faq.id, 'down')}
disabled={index === faqs.length - 1}
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
>
<GripVertical className="h-3 w-3 rotate-180" />
</Button>
</div>
<Button
onClick={() => removeFAQ(faq.id)}
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-destructive hover:bg-destructive/10"
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor={`question-${faq.id}`}>Question *</Label>
<Textarea
id={`question-${faq.id}`}
value={faq.question}
onChange={(e) => updateFAQ(faq.id, 'question', e.target.value)}
placeholder="Enter the question"
rows={3}
className="resize-y focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
/>
</div>
<div className="space-y-2">
<Label htmlFor={`category-${faq.id}`}>Category</Label>
<select
id={`category-${faq.id}`}
value={faq.category}
onChange={(e) => updateFAQ(faq.id, 'category', e.target.value)}
className="w-full min-h-[44px] px-3 py-2 border border-input bg-background rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
>
<option value="">Select category</option>
{categories.map(category => (
<option key={category} value={category}>{category}</option>
))}
</select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor={`answer-${faq.id}`}>Answer *</Label>
<Textarea
id={`answer-${faq.id}`}
value={faq.answer}
onChange={(e) => updateFAQ(faq.id, 'answer', e.target.value)}
placeholder="Enter the answer"
rows={4}
className="resize-y focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
/>
</div>
<div className="space-y-2">
<Label>Tags</Label>
<div className="flex gap-2">
<Input
placeholder="Add tag"
onKeyPress={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addTagToFAQ(faq.id, e.currentTarget.value);
e.currentTarget.value = '';
}
}}
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
/>
<Button
type="button"
onClick={(e) => {
const input = e.currentTarget.parentElement?.querySelector('input') as HTMLInputElement;
if (input) {
addTagToFAQ(faq.id, input.value);
input.value = '';
}
}}
variant="outline"
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
>
<Plus className="h-4 w-4" />
</Button>
</div>
{faq.tags.length > 0 && (
<div className="flex flex-wrap gap-2 mt-2">
{faq.tags.map((tag) => (
<Badge
key={tag}
variant="secondary"
className="flex items-center gap-1"
>
{tag}
<X
className="h-3 w-3 cursor-pointer"
onClick={() => removeTagFromFAQ(faq.id, tag)}
/>
</Badge>
))}
</div>
)}
</div>
</CardContent>
</Card>
))}
</div>
<Button
onClick={addFAQ}
variant="outline"
className="w-full min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
>
<Plus className="h-4 w-4 mr-2" />
Add Another FAQ
</Button>
</div>
{/* Sidebar */}
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Summary</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Total FAQs:</span>
<span className="font-medium">{faqs.length}</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Completed:</span>
<span className="font-medium">
{faqs.filter(faq => faq.question.trim() && faq.answer.trim()).length}
</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">With Categories:</span>
<span className="font-medium">
{faqs.filter(faq => faq.category).length}
</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Global Tags:</span>
<span className="font-medium">{globalTags.length}</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Quick Actions</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<Button
onClick={addFAQ}
variant="outline"
className="w-full min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
>
<Plus className="h-4 w-4 mr-2" />
Add FAQ
</Button>
{globalTags.length > 0 && (
<Button
onClick={applyGlobalTagsToAll}
variant="outline"
className="w-full min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
>
Apply Global Tags
</Button>
)}
</CardContent>
</Card>
</div>
</div>
</div>
</AuthenticatedLayout>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,34 +1,20 @@
import React, { useState } from 'react';
import React, { useState, useRef } from 'react';
import { AuthenticatedLayout } from '../layout/AuthenticatedLayout';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
import { Button } from '../ui/button';
import { Badge } from '../ui/badge';
import { Avatar, AvatarFallback } from '../ui/avatar';
import { Avatar, AvatarFallback, AvatarImage } from '../ui/avatar';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Separator } from '../ui/separator';
import {
User,
Mail,
Shield,
Clock,
Edit,
Camera,
Key,
LogOut,
Bell,
Settings
} from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '../ui/dialog';
import {
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '../ui/sheet';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
@@ -37,12 +23,27 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '../ui/alert-dialog';
import { useNavigate } from 'react-router-dom';
import { toast } from "sonner@2.0.3";
import {
User,
Mail,
Shield,
Clock,
Edit,
Upload,
Key,
LogOut,
Bell,
Settings,
History,
CheckCircle,
AlertCircle,
Loader2
} from 'lucide-react';
interface ProfileProps {
// onNavigate: (route: string) => void;
onNavigate: (route: string) => void;
onLogout: () => void;
user: {
name: string;
@@ -53,34 +54,56 @@ interface ProfileProps {
};
}
export function Profile({ onLogout, user }: ProfileProps) {
interface AuditEntry {
id: number;
timestamp: string;
actor: string;
action: string;
target: string;
summary: string;
details?: any;
}
const mockAuditEntries: AuditEntry[] = [
{
id: 1,
timestamp: new Date().toISOString(),
actor: "Admin User",
action: "Profile Updated",
target: "Display Name",
summary: "Updated display name from 'John Doe' to 'Admin User'"
},
{
id: 2,
timestamp: new Date(Date.now() - 3600000).toISOString(),
actor: "Admin User",
action: "Avatar Changed",
target: "Profile Photo",
summary: "Uploaded new profile photo"
}
];
export function Profile({ onNavigate, onLogout, user }: ProfileProps) {
const [isEditingName, setIsEditingName] = useState(false);
const [editName, setEditName] = useState(user.name);
const [isLoading, setIsLoading] = useState(false);
const [avatarFile, setAvatarFile] = useState<File | null>(null);
const [avatarPreview, setAvatarPreview] = useState<string>('');
// Sheet/Dialog state
const [isAuditSheetOpen, setIsAuditSheetOpen] = useState(false);
const [isSignOutDialogOpen, setIsSignOutDialogOpen] = useState(false);
const [isPhotoDialogOpen, setIsPhotoDialogOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleSaveName = async () => {
setIsLoading(true);
// Simulate API call
setTimeout(() => {
setIsEditingName(false);
setIsLoading(false);
// Update user name in parent component would happen here
}, 1000);
};
const handleSignOutOtherSessions = async () => {
setIsLoading(true);
// Simulate API call
setTimeout(() => {
setIsLoading(false);
// Show success message
}, 1500);
};
const breadcrumbs = [{ label: 'My Profile' }];
const breadcrumbs = [
{ label: "Admin", href: "/dashboard" },
{ label: "Profile" }
];
const getUserId = () => {
// Simulate masked user ID
// Masked/obfuscated user ID as per spec
return 'usr_****7892';
};
@@ -88,289 +111,552 @@ export function Profile({ onLogout, user }: ProfileProps) {
return user.role === 'Super Admin' ? 'Required' : 'Not Required';
};
const navigate = useNavigate();
const handleSaveName = async () => {
if (!editName.trim()) {
toast.error("Display name cannot be empty");
return;
}
setIsLoading(true);
// Simulate API call
setTimeout(() => {
setIsEditingName(false);
setIsLoading(false);
toast.success("Profile updated.");
// In real app, would update user state in parent
}, 1200);
};
const handleCancelEdit = () => {
setIsEditingName(false);
setEditName(user.name);
};
const handleFileSelect = () => {
fileInputRef.current?.click();
};
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
// Validate file type
if (!file.type.startsWith('image/')) {
toast.error("Please select a valid image file (PNG/JPG)");
return;
}
// Validate file size (e.g., 5MB limit)
if (file.size > 5 * 1024 * 1024) {
toast.error("File size must be less than 5MB");
return;
}
setAvatarFile(file);
// Create preview
const reader = new FileReader();
reader.onload = (e) => {
setAvatarPreview(e.target?.result as string);
};
reader.readAsDataURL(file);
setIsPhotoDialogOpen(true);
}
};
const handleUploadPhoto = async () => {
if (!avatarFile) return;
setIsLoading(true);
// Simulate API call
setTimeout(() => {
setIsLoading(false);
setIsPhotoDialogOpen(false);
setAvatarFile(null);
setAvatarPreview('');
toast.success("Profile updated.");
}, 1500);
};
const handleSignOutOtherSessions = async () => {
setIsLoading(true);
// Simulate API call
setTimeout(() => {
setIsLoading(false);
setIsSignOutDialogOpen(false);
toast.success("Signed out of other sessions.");
}, 1500);
};
const openAuditTrail = () => {
setIsAuditSheetOpen(true);
};
const renderHeader = () => (
<div className="mb-6">
<h1>My Profile</h1>
<p className="text-muted-foreground">
Manage your account details and security.
</p>
</div>
);
const renderProfileHeaderCard = () => (
<Card className="mb-6">
<CardContent className="p-6">
<div className="flex items-start gap-6">
{/* Avatar Section */}
<div className="flex flex-col items-center gap-4">
<Avatar className="h-32 w-32">
{user.avatar ? (
<AvatarImage src={user.avatar} alt={user.name} />
) : (
<AvatarFallback
className="text-3xl"
style={{
backgroundColor: 'var(--color-brand-accent)',
color: 'var(--color-brand-primary)'
}}
>
{user.name.split(' ').map(n => n[0]).join('').substring(0, 2)}
</AvatarFallback>
)}
</Avatar>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={handleFileSelect}
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
>
<Upload className="h-4 w-4 mr-2" />
{user.avatar ? 'Replace' : 'Upload'}
</Button>
</div>
</div>
{/* Profile Details */}
<div className="flex-1 space-y-4">
{/* Display Name */}
<div className="space-y-2">
<Label>Display Name</Label>
{isEditingName ? (
<div className="flex items-center gap-2">
<Input
value={editName}
onChange={(e) => setEditName(e.target.value)}
className="max-w-sm min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveName();
if (e.key === 'Escape') handleCancelEdit();
}}
autoFocus
/>
<Button
size="sm"
onClick={handleSaveName}
disabled={isLoading}
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
style={{ backgroundColor: 'var(--color-brand-primary)' }}
>
{isLoading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Saving...
</>
) : (
'Save'
)}
</Button>
<Button
size="sm"
variant="outline"
onClick={handleCancelEdit}
disabled={isLoading}
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
>
Cancel
</Button>
</div>
) : (
<div className="flex items-center gap-2">
<span className="font-medium">{user.name}</span>
<Button
variant="ghost"
size="sm"
onClick={() => setIsEditingName(true)}
className="min-h-[44px] w-[44px] p-0 focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
aria-label="Edit name"
>
<Edit className="h-4 w-4" />
</Button>
</div>
)}
</div>
{/* Primary Email */}
<div className="space-y-2">
<Label>Primary Email</Label>
<div className="flex items-center gap-2">
<Mail className="h-4 w-4 text-muted-foreground" />
<span>{user.email}</span>
<Badge variant="secondary">Read Only</Badge>
</div>
</div>
{/* Role */}
<div className="space-y-2">
<Label>Role</Label>
<div className="flex items-center gap-2">
<Shield className="h-4 w-4 text-muted-foreground" />
<Badge style={{ backgroundColor: 'var(--color-brand-primary)', color: 'white' }}>
{user.role}
</Badge>
</div>
</div>
{/* Last Login */}
<div className="space-y-2">
<Label>Last Login</Label>
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">{user.lastLogin}</span>
</div>
</div>
</div>
</div>
{/* Actions Row */}
<div className="flex items-center gap-2 mt-6 pt-4 border-t">
<Button
variant="outline"
size="sm"
onClick={() => setIsEditingName(true)}
disabled={isEditingName}
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
>
<Edit className="h-4 w-4 mr-2" />
Edit Name
</Button>
<Button
variant="outline"
size="sm"
onClick={handleFileSelect}
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
>
<Upload className="h-4 w-4 mr-2" />
Change Photo
</Button>
</div>
</CardContent>
</Card>
);
const renderAccountDetailsCard = () => (
<Card className="mb-6">
<CardHeader>
<CardTitle>Account Details</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Left Column - Labels */}
<div className="space-y-6">
<div>
<Label className="font-medium">Email</Label>
</div>
<div>
<Label className="font-medium">User ID</Label>
</div>
<div>
<Label className="font-medium">Role</Label>
</div>
<div>
<Label className="font-medium">2-Factor Requirement</Label>
</div>
</div>
{/* Right Column - Values */}
<div className="space-y-6">
<div className="flex items-center gap-2">
<span>{user.email}</span>
<Badge variant="secondary">Read Only</Badge>
</div>
<div className="flex items-center gap-2">
<span className="font-mono text-sm">{getUserId()}</span>
<Badge variant="secondary">Masked</Badge>
</div>
<div className="flex items-center gap-2">
<span>{user.role}</span>
<Badge variant="secondary">Read Only</Badge>
</div>
<div className="flex items-center gap-2">
<Badge variant={getTwoFactorStatus() === 'Required' ? 'default' : 'secondary'}>
{getTwoFactorStatus()}
</Badge>
<Button
variant="link"
size="sm"
onClick={() => onNavigate('/admin/roles')}
className="p-0 h-auto text-sm min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
style={{ color: 'var(--color-brand-primary)' }}
>
Manage 2-Factor
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
);
const renderSecuritySessionsCard = () => (
<Card className="mb-6">
<CardHeader>
<CardTitle>Security & Sessions</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h4 className="font-medium">Password</h4>
<p className="text-sm text-muted-foreground">
Reset your password to maintain account security
</p>
</div>
<Button
variant="outline"
onClick={() => onNavigate('/profile/reset-password')}
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
>
<Key className="h-4 w-4 mr-2" />
Reset Password
</Button>
</div>
<Separator />
<div className="flex items-center justify-between">
<div>
<h4 className="font-medium">Active Sessions</h4>
<p className="text-sm text-muted-foreground">
Sign out of all other sessions for security
</p>
</div>
<Button
variant="outline"
onClick={() => setIsSignOutDialogOpen(true)}
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
>
<LogOut className="h-4 w-4 mr-2" />
Sign out of other sessions
</Button>
</div>
</CardContent>
</Card>
);
const renderNotificationsCard = () => (
<Card className="mb-6">
<CardHeader>
<CardTitle>Notifications (Read-only)</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-start gap-3">
<Bell className="h-5 w-5 text-muted-foreground mt-0.5" />
<div>
<h4 className="font-medium">Email Notifications</h4>
<p className="text-sm text-muted-foreground mt-1">
Your email address <strong>{user.email}</strong> receives:
</p>
<ul className="text-sm text-muted-foreground mt-2 space-y-1">
<li> Administrative notifications and one-time codes</li>
<li> System maintenance notifications</li>
<li> Security-related communications</li>
</ul>
<p className="text-xs text-muted-foreground mt-3">
</p>
</div>
</div>
</CardContent>
</Card>
);
const renderSignOutDialog = () => (
<AlertDialog open={isSignOutDialogOpen} onOpenChange={setIsSignOutDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Sign out of other sessions?</AlertDialogTitle>
<AlertDialogDescription>
This will sign you out from all other devices.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleSignOutOtherSessions}
disabled={isLoading}
style={{ backgroundColor: 'var(--color-brand-primary)' }}
>
{isLoading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Signing out...
</>
) : (
'Confirm'
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
const renderPhotoDialog = () => (
<AlertDialog open={isPhotoDialogOpen} onOpenChange={setIsPhotoDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Upload Profile Photo</AlertDialogTitle>
<AlertDialogDescription>
Review your new profile photo before uploading
</AlertDialogDescription>
</AlertDialogHeader>
{avatarPreview && (
<div className="flex justify-center py-4">
<div className="w-32 h-32 rounded-full overflow-hidden bg-muted">
<img
src={avatarPreview}
alt="Preview"
className="w-full h-full object-cover"
/>
</div>
</div>
)}
<AlertDialogFooter>
<AlertDialogCancel
onClick={() => {
setAvatarFile(null);
setAvatarPreview('');
}}
>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={handleUploadPhoto}
disabled={isLoading}
style={{ backgroundColor: 'var(--color-brand-primary)' }}
>
{isLoading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Uploading...
</>
) : (
'Upload Photo'
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
const renderAuditSheet = () => (
<Sheet open={isAuditSheetOpen} onOpenChange={setIsAuditSheetOpen}>
<SheetContent className="w-[480px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50">
<SheetHeader>
<SheetTitle>Audit Trail</SheetTitle>
<SheetDescription>
Complete history of profile activities
</SheetDescription>
</SheetHeader>
<div className="mt-6 space-y-4">
{/* Filters */}
<div className="flex gap-2">
<select className="flex-1 px-3 py-2 border rounded-md text-sm min-h-[44px]">
<option value="all">All Actions</option>
<option value="profile-updated">Profile Updated</option>
<option value="avatar-changed">Avatar Changed</option>
<option value="sessions-revoked">Sessions Revoked</option>
<option value="password-reset">Password Reset</option>
</select>
<select className="flex-1 px-3 py-2 border rounded-md text-sm min-h-[44px]">
<option value="all">All Users</option>
<option value="admin">Admin User</option>
</select>
</div>
{/* Audit Entries */}
<div className="space-y-3">
{mockAuditEntries.map((entry) => (
<div key={entry.id} className="border-l-2 border-muted pl-4 pb-4">
<div className="flex items-center gap-2 mb-1">
<Badge variant="outline">{entry.action}</Badge>
<span className="text-sm text-muted-foreground">by {entry.actor}</span>
</div>
<div className="text-xs text-muted-foreground mb-1">
{new Date(entry.timestamp).toLocaleString()}
</div>
<p className="text-sm">{entry.summary}</p>
<div className="text-xs text-muted-foreground">Target: {entry.target}</div>
</div>
))}
</div>
</div>
</SheetContent>
</Sheet>
);
return (
<AuthenticatedLayout
currentRoute="/profile"
// onNavigate={onNavigate}
onNavigate={onNavigate}
onLogout={onLogout}
user={user}
breadcrumbs={breadcrumbs}
>
<div className="p-6 space-y-6 max-w-4xl mx-auto">
{/* Profile Header */}
<Card>
<CardHeader>
<CardTitle>Profile Information</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-start space-x-6">
{/* Avatar Section */}
<div className="flex flex-col items-center space-y-4">
<Avatar className="h-32 w-32">
<AvatarFallback
className="text-3xl"
style={{
backgroundColor: 'var(--color-brand-accent)',
color: 'var(--color-brand-primary)'
}}
>
{user.name.split(' ').map(n => n[0]).join('')}
</AvatarFallback>
</Avatar>
<div className="flex space-x-2">
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Camera className="h-4 w-4 mr-2" />
Change Photo
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Update Profile Photo</DialogTitle>
<DialogDescription>
Upload a new profile photo. Recommended size: 128x128 pixels.
</DialogDescription>
</DialogHeader>
<div className="grid w-full max-w-sm items-center gap-1.5">
<Label htmlFor="picture">Picture</Label>
<Input id="picture" type="file" accept="image/*" />
</div>
<DialogFooter>
<Button variant="outline">Cancel</Button>
<Button style={{ backgroundColor: 'var(--color-brand-primary)' }}>
Upload Photo
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
{/* Header */}
{renderHeader()}
{/* Profile Details */}
<div className="flex-1 space-y-4">
{/* Display Name */}
<div className="space-y-2">
<Label>Display Name</Label>
{isEditingName ? (
<div className="flex items-center space-x-2">
<Input
value={editName}
onChange={(e) => setEditName(e.target.value)}
className="max-w-sm"
/>
<Button
size="sm"
onClick={handleSaveName}
disabled={isLoading}
style={{ backgroundColor: 'var(--color-brand-primary)' }}
>
{isLoading ? 'Saving...' : 'Save'}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
setIsEditingName(false);
setEditName(user.name);
}}
>
Cancel
</Button>
</div>
) : (
<div className="flex items-center space-x-2">
<span className="font-medium">{user.name}</span>
<Button
variant="ghost"
size="sm"
onClick={() => setIsEditingName(true)}
>
<Edit className="h-4 w-4" />
</Button>
</div>
)}
</div>
{/* Profile Header Card */}
{renderProfileHeaderCard()}
{/* Primary Email */}
<div className="space-y-2">
<Label>Primary Email</Label>
<div className="flex items-center space-x-2">
<Mail className="h-4 w-4 text-muted-foreground" />
<span>{user.email}</span>
<Badge variant="secondary">Read Only</Badge>
</div>
</div>
{/* Account Details Card */}
{renderAccountDetailsCard()}
{/* Role */}
<div className="space-y-2">
<Label>Role</Label>
<div className="flex items-center space-x-2">
<Shield className="h-4 w-4 text-muted-foreground" />
<Badge style={{ backgroundColor: 'var(--color-brand-primary)', color: 'white' }}>
{user.role}
</Badge>
</div>
</div>
{/* Security & Sessions Card */}
{renderSecuritySessionsCard()}
{/* Last Login */}
<div className="space-y-2">
<Label>Last Login</Label>
<div className="flex items-center space-x-2">
<Clock className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">{user.lastLogin}</span>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Notifications Card */}
{renderNotificationsCard()}
{/* Account Details */}
<Card>
<CardHeader>
<CardTitle>Account Details</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label>Email Address</Label>
<div className="flex items-center space-x-2">
<span>{user.email}</span>
<Badge variant="secondary">Read Only</Badge>
</div>
</div>
{/* Hidden File Input */}
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/jpg"
onChange={handleFileChange}
className="hidden"
/>
<div className="space-y-2">
<Label>User ID</Label>
<div className="flex items-center space-x-2">
<span className="font-mono text-sm">{getUserId()}</span>
<Badge variant="secondary">Masked</Badge>
</div>
</div>
{/* Dialogs and Sheets */}
{renderSignOutDialog()}
{renderPhotoDialog()}
{renderAuditSheet()}
<div className="space-y-2">
<Label>Role</Label>
<div className="flex items-center space-x-2">
<span>{user.role}</span>
<Badge variant="secondary">Read Only</Badge>
</div>
</div>
<div className="space-y-2">
<Label>2-Factor Authentication</Label>
<div className="flex items-center space-x-2">
<Badge variant={getTwoFactorStatus() === 'Required' ? 'default' : 'secondary'}>
{getTwoFactorStatus()}
</Badge>
<Button
variant="ghost"
size="sm"
onClick={() => navigate('/admin/roles')}
>
<Settings className="h-4 w-4 mr-1" />
Security Settings
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Password & Sessions */}
<Card>
<CardHeader>
<CardTitle>Password & Sessions</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h4 className="font-medium">Password</h4>
<p className="text-sm text-muted-foreground">
Reset your password to maintain account security
</p>
</div>
<Button
variant="outline"
onClick={() => navigate('/profile/reset-password')}
>
<Key className="h-4 w-4 mr-2" />
Reset Password
</Button>
</div>
<Separator />
<div className="flex items-center justify-between">
<div>
<h4 className="font-medium">Active Sessions</h4>
<p className="text-sm text-muted-foreground">
Sign out of all other sessions for security
</p>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline">
<LogOut className="h-4 w-4 mr-2" />
Sign out of other sessions
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Sign out of other sessions?</AlertDialogTitle>
<AlertDialogDescription>
This will sign you out of all other devices and browsers. You will remain
signed in on this device. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleSignOutOtherSessions}
disabled={isLoading}
style={{ backgroundColor: 'var(--color-brand-primary)' }}
>
{isLoading ? 'Signing out...' : 'Sign out other sessions'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</CardContent>
</Card>
{/* Notifications */}
<Card>
<CardHeader>
<CardTitle>Notifications</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex items-start space-x-3">
<Bell className="h-5 w-5 text-muted-foreground mt-0.5" />
<div>
<h4 className="font-medium">Email Notifications</h4>
<p className="text-sm text-muted-foreground mt-1">
Your email address <strong>{user.email}</strong> is used for:
</p>
<ul className="text-sm text-muted-foreground mt-2 space-y-1">
<li> Administrative notifications and alerts</li>
<li> Two-factor authentication codes</li>
<li> System maintenance notifications</li>
<li> Security-related communications</li>
</ul>
<p className="text-xs text-muted-foreground mt-3">
Note: These notification settings are managed by your system administrator
and cannot be changed from this interface.
</p>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Toast area for system messages */}
<div
role="status"
aria-live="polite"
aria-label="System status messages"
className="sr-only"
/>
</div>
</AuthenticatedLayout>
);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,591 @@
import React, { useState } from 'react';
import { AuthenticatedLayout } from '../layout/AuthenticatedLayout';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Textarea } from '../ui/textarea';
import { Badge } from '../ui/badge';
import { Separator } from '../ui/separator';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '../ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '../ui/table';
import { toast } from "sonner@2.0.3";
import {
Plus,
Edit,
Trash2,
Settings,
Target,
BarChart3,
Save,
X,
ChevronDown,
ChevronRight,
Lock
} from 'lucide-react';
import { mockProfilerTypes, ProfilerType, IpsativeSubDimension, LikertOption } from '../../data/mockData';
interface ProfilerMasterProps {
onNavigate: (route: string) => void;
onLogout: () => void;
user: any;
}
export function ProfilerMaster({ onNavigate, onLogout, user }: ProfilerMasterProps) {
const [profilerTypes, setProfilerTypes] = useState<ProfilerType[]>(mockProfilerTypes);
const [selectedType, setSelectedType] = useState<ProfilerType | null>(null);
const [expandedTypes, setExpandedTypes] = useState<Set<string>>(new Set(['pt_ipsative', 'pt_likert']));
// Dialog states
const [isSubDimensionDialogOpen, setIsSubDimensionDialogOpen] = useState(false);
const [isOptionDialogOpen, setIsOptionDialogOpen] = useState(false);
// Form states for Ipsative Sub-Dimension
const [subDimensionForm, setSubDimensionForm] = useState({
name: '',
condition: 'Calculated' as 'Calculated' | 'Null Value'
});
// Form states for Likert Option
const [optionForm, setOptionForm] = useState({
label: ''
});
const [editingId, setEditingId] = useState<string | null>(null);
const breadcrumbs = [
{ label: "Profiler Master" }
];
// Toggle expansion states
const toggleTypeExpansion = (typeId: string) => {
const newExpanded = new Set(expandedTypes);
if (newExpanded.has(typeId)) {
newExpanded.delete(typeId);
} else {
newExpanded.add(typeId);
}
setExpandedTypes(newExpanded);
};
// CRUD operations for Ipsative Sub-Dimensions
const handleCreateIpsativeSubDimension = () => {
if (!subDimensionForm.name || !selectedType) {
toast.error("Please fill in all required fields");
return;
}
const newSubDimension: IpsativeSubDimension = {
id: `isd_${Date.now()}`,
name: subDimensionForm.name,
description: '', // Auto-generated or empty
condition: subDimensionForm.condition
};
setProfilerTypes(profilerTypes.map(type =>
type.id === selectedType.id && type.structure.type === 'Ipsative'
? {
...type,
structure: {
...type.structure,
subDimensions: [...type.structure.subDimensions, newSubDimension]
}
}
: type
));
setSubDimensionForm({ name: '', condition: 'Calculated' });
setIsSubDimensionDialogOpen(false);
toast.success("Sub-dimension created successfully");
};
const handleUpdateIpsativeSubDimension = () => {
if (!editingId || !selectedType) return;
setProfilerTypes(profilerTypes.map(type =>
type.id === selectedType.id && type.structure.type === 'Ipsative'
? {
...type,
structure: {
...type.structure,
subDimensions: type.structure.subDimensions.map(sub =>
sub.id === editingId
? {
...sub,
name: subDimensionForm.name,
description: sub.description, // Keep existing description
condition: subDimensionForm.condition
}
: sub
)
}
}
: type
));
setSubDimensionForm({ name: '', condition: 'Calculated' });
setEditingId(null);
setIsSubDimensionDialogOpen(false);
toast.success("Sub-dimension updated successfully");
};
const handleDeleteIpsativeSubDimension = (typeId: string, subDimensionId: string) => {
setProfilerTypes(profilerTypes.map(type =>
type.id === typeId && type.structure.type === 'Ipsative'
? {
...type,
structure: {
...type.structure,
subDimensions: type.structure.subDimensions.filter(sub => sub.id !== subDimensionId)
}
}
: type
));
toast.success("Sub-dimension deleted successfully");
};
// CRUD operations for Likert Options
const handleCreateLikertOption = () => {
if (!optionForm.label || !selectedType) {
toast.error("Please fill in all required fields");
return;
}
const newOption: LikertOption = {
id: `lopt_${Date.now()}`,
label: optionForm.label,
value: 1, // Default value, will be auto-assigned
description: '' // Auto-generated or empty
};
setProfilerTypes(profilerTypes.map(type =>
type.id === selectedType.id && type.structure.type === 'Likert'
? {
...type,
structure: {
...type.structure,
options: [...type.structure.options, newOption]
}
}
: type
));
setOptionForm({ label: '' });
setIsOptionDialogOpen(false);
toast.success("Option created successfully");
};
const handleUpdateLikertOption = () => {
if (!editingId || !selectedType) return;
setProfilerTypes(profilerTypes.map(type =>
type.id === selectedType.id && type.structure.type === 'Likert'
? {
...type,
structure: {
...type.structure,
options: type.structure.options.map(opt =>
opt.id === editingId
? {
...opt,
label: optionForm.label,
value: opt.value, // Keep existing value
description: opt.description // Keep existing description
}
: opt
)
}
}
: type
));
setOptionForm({ label: '' });
setEditingId(null);
setIsOptionDialogOpen(false);
toast.success("Option updated successfully");
};
const handleDeleteLikertOption = (typeId: string, optionId: string) => {
setProfilerTypes(profilerTypes.map(type =>
type.id === typeId && type.structure.type === 'Likert'
? {
...type,
structure: {
...type.structure,
options: type.structure.options.filter(opt => opt.id !== optionId)
}
}
: type
));
toast.success("Option deleted successfully");
};
// Edit handlers
const handleEditIpsativeSubDimension = (subDimension: IpsativeSubDimension) => {
setSubDimensionForm({
name: subDimension.name,
condition: subDimension.condition
});
setEditingId(subDimension.id);
setIsSubDimensionDialogOpen(true);
};
const handleEditLikertOption = (option: LikertOption) => {
setOptionForm({
label: option.label
});
setEditingId(option.id);
setIsOptionDialogOpen(true);
};
// Render functions
const renderSubDimensionDialog = () => (
<Dialog open={isSubDimensionDialogOpen} onOpenChange={setIsSubDimensionDialogOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{editingId ? 'Edit Sub-Dimension' : 'Add New Sub-Dimension'}
</DialogTitle>
<DialogDescription>
{editingId ? 'Update the sub-dimension details' : `Add a new sub-dimension to "${selectedType?.name}"`}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="subdimension-name">Name *</Label>
<Input
id="subdimension-name"
value={subDimensionForm.name}
onChange={(e) => setSubDimensionForm({ ...subDimensionForm, name: e.target.value })}
placeholder="e.g., Leadership Style"
className="min-h-[44px]"
/>
</div>
<div className="space-y-2">
<Label htmlFor="condition-type">Condition *</Label>
<Select
value={subDimensionForm.condition}
onValueChange={(value: 'Calculated' | 'Null Value') => setSubDimensionForm({
...subDimensionForm,
condition: value
})}
>
<SelectTrigger className="min-h-[44px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Calculated">Calculated</SelectItem>
<SelectItem value="Null Value">Null Value</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setIsSubDimensionDialogOpen(false);
setSubDimensionForm({ name: '', condition: 'Calculated' });
setEditingId(null);
}}
>
Cancel
</Button>
<Button
onClick={editingId ? handleUpdateIpsativeSubDimension : handleCreateIpsativeSubDimension}
style={{ backgroundColor: 'var(--color-brand-primary)' }}
>
{editingId ? 'Update' : 'Create'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
const renderOptionDialog = () => (
<Dialog open={isOptionDialogOpen} onOpenChange={setIsOptionDialogOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{editingId ? 'Edit Option' : 'Add New Option'}
</DialogTitle>
<DialogDescription>
{editingId ? 'Update the option details' : `Add a new option to "${selectedType?.name}"`}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="option-label">Label *</Label>
<Input
id="option-label"
value={optionForm.label}
onChange={(e) => setOptionForm({ ...optionForm, label: e.target.value })}
placeholder="e.g., Strongly Agree"
className="min-h-[44px]"
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setIsOptionDialogOpen(false);
setOptionForm({ label: '' });
setEditingId(null);
}}
>
Cancel
</Button>
<Button
onClick={editingId ? handleUpdateLikertOption : handleCreateLikertOption}
style={{ backgroundColor: 'var(--color-brand-primary)' }}
>
{editingId ? 'Update' : 'Create'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
return (
<AuthenticatedLayout
currentRoute="/admin/profiler-master"
onNavigate={onNavigate}
onLogout={onLogout}
user={user}
breadcrumbs={breadcrumbs}
>
<div className="p-6 space-y-6 max-w-7xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1>Profiler Master</h1>
<p className="text-muted-foreground">
Manage profiler types with fixed Ipsative and Likert configurations
</p>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="bg-muted">
<Lock className="h-3 w-3 mr-1" />
Read-only Types
</Badge>
</div>
</div>
{/* Profiler Types List */}
<div className="space-y-4">
{profilerTypes.map((type) => (
<Card key={type.id}>
<CardContent className="p-6">
{/* Type Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="sm"
onClick={() => toggleTypeExpansion(type.id)}
className="p-1 h-8 w-8"
>
{expandedTypes.has(type.id) ?
<ChevronDown className="h-4 w-4" /> :
<ChevronRight className="h-4 w-4" />
}
</Button>
<Target className="h-5 w-5 text-muted-foreground" />
<div>
<div className="flex items-center gap-2">
<h3 className="font-medium">{type.name}</h3>
<Lock className="h-4 w-4 text-muted-foreground" />
</div>
<p className="text-sm text-muted-foreground">{type.description}</p>
</div>
</div>
<div className="flex items-center gap-2">
<Badge
variant="secondary"
style={{
backgroundColor: type.category === 'Ipsative' ? 'var(--color-brand-accent)' : 'var(--color-brand-primary)',
color: type.category === 'Ipsative' ? 'var(--color-brand-black)' : 'white'
}}
>
{type.category}
</Badge>
<Badge variant="outline">
{type.structure.type === 'Ipsative'
? `${type.structure.subDimensions.length} sub-dimensions`
: `${type.structure.options.length} options`
}
</Badge>
</div>
</div>
{/* Expanded Content */}
{expandedTypes.has(type.id) && (
<div className="ml-11 space-y-4">
{/* Ipsative Structure */}
{type.structure.type === 'Ipsative' && (
<div>
<div className="flex items-center justify-between mb-3">
<h4 className="font-medium text-sm">Sub-Dimensions</h4>
<Button
variant="outline"
size="sm"
onClick={() => {
setSelectedType(type);
setIsSubDimensionDialogOpen(true);
}}
className="h-8"
>
<Plus className="h-3 w-3 mr-1" />
Add Sub-Dimension
</Button>
</div>
{type.structure.subDimensions.length === 0 ? (
<p className="text-sm text-muted-foreground italic">No sub-dimensions defined</p>
) : (
<div className="space-y-2">
{type.structure.subDimensions.map((subDimension) => (
<div
key={subDimension.id}
className="flex items-center justify-between p-3 bg-muted/50 rounded-lg"
>
<div className="flex-1">
<p className="font-medium text-sm">{subDimension.name}</p>
<p className="text-xs text-muted-foreground">{subDimension.description}</p>
</div>
<div className="flex items-center gap-2">
<Badge
variant={subDimension.condition === 'Calculated' ? 'default' : 'secondary'}
className="text-xs"
>
{subDimension.condition}
</Badge>
<Button
variant="ghost"
size="sm"
onClick={() => handleEditIpsativeSubDimension(subDimension)}
className="h-6 w-6 p-0"
>
<Edit className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteIpsativeSubDimension(type.id, subDimension.id)}
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
>
<X className="h-3 w-3" />
</Button>
</div>
</div>
))}
</div>
)}
</div>
)}
{/* Likert Structure */}
{type.structure.type === 'Likert' && (
<div>
<div className="flex items-center justify-between mb-3">
<h4 className="font-medium text-sm">Options</h4>
<Button
variant="outline"
size="sm"
onClick={() => {
setSelectedType(type);
setIsOptionDialogOpen(true);
}}
className="h-8"
>
<Plus className="h-3 w-3 mr-1" />
Add Option
</Button>
</div>
{type.structure.options.length === 0 ? (
<p className="text-sm text-muted-foreground italic">No options defined</p>
) : (
<div className="space-y-2">
{type.structure.options.map((option) => (
<div
key={option.id}
className="flex items-center justify-between p-3 bg-muted/50 rounded-lg"
>
<div className="flex-1">
<p className="font-medium text-sm">{option.label}</p>
<p className="text-xs text-muted-foreground">{option.description}</p>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs">
Value: {option.value}
</Badge>
<Button
variant="ghost"
size="sm"
onClick={() => handleEditLikertOption(option)}
className="h-6 w-6 p-0"
>
<Edit className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteLikertOption(type.id, option.id)}
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
>
<X className="h-3 w-3" />
</Button>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
)}
</CardContent>
</Card>
))}
</div>
{/* Dialogs */}
{renderSubDimensionDialog()}
{renderOptionDialog()}
</div>
</AuthenticatedLayout>
);
}

View File

@@ -0,0 +1,627 @@
import React, { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
import { Button } from '../ui/button';
import { Progress } from '../ui/progress';
import { Badge } from '../ui/badge';
import { Separator } from '../ui/separator';
import { RadioGroup, RadioGroupItem } from '../ui/radio-group';
import { Label } from '../ui/label';
import { toast } from "sonner@2.0.3";
import {
ArrowLeft,
ArrowRight,
RotateCcw,
CheckCircle,
Circle,
AlertCircle,
TrendingUp,
Target,
Users,
Lightbulb,
BarChart3,
Award,
Clock,
Star,
Move,
Scale
} from 'lucide-react';
interface ProfilerPreviewProps {
onBack: () => void;
}
interface IpsativeOption {
id: string;
text: string;
value: string;
rank?: number;
}
interface LikertOption {
id: string;
text: string;
value: number;
}
interface Question {
id: string;
type: 'ipsative' | 'likert';
title: string;
instruction: string;
options: IpsativeOption[] | LikertOption[];
category: string;
}
interface Answer {
questionId: string;
type: 'ipsative' | 'likert';
value: any;
}
interface ProfileResult {
category: string;
score: number;
percentage: number;
description: string;
strengths: string[];
developmentAreas: string[];
}
const mockQuestions: Question[] = [
{
id: 'q1',
type: 'ipsative',
title: 'Leadership Priorities',
instruction: 'Rank these leadership qualities in order of importance to you (1 = Most Important, 4 = Least Important)',
category: 'Leadership Style',
options: [
{ id: 'opt1', text: 'Building strong team relationships', value: 'relationships' },
{ id: 'opt2', text: 'Achieving ambitious goals', value: 'results' },
{ id: 'opt3', text: 'Fostering innovation and creativity', value: 'innovation' },
{ id: 'opt4', text: 'Maintaining high standards and processes', value: 'standards' }
] as IpsativeOption[]
},
{
id: 'q2',
type: 'likert',
title: 'Communication Preferences',
instruction: 'Rate how much you agree with each statement',
category: 'Communication Style',
options: [
{ id: 'opt1', text: 'I prefer direct, straightforward communication', value: 0 },
{ id: 'opt2', text: 'I take time to consider all perspectives before responding', value: 0 },
{ id: 'opt3', text: 'I communicate best in small group settings', value: 0 },
{ id: 'opt4', text: 'I enjoy presenting ideas to large audiences', value: 0 }
] as LikertOption[]
},
{
id: 'q3',
type: 'ipsative',
title: 'Decision Making Approach',
instruction: 'Arrange these decision-making factors by how much you rely on them (1 = Rely on Most, 4 = Rely on Least)',
category: 'Decision Making',
options: [
{ id: 'opt1', text: 'Data and analytical evidence', value: 'data' },
{ id: 'opt2', text: 'Intuition and gut feeling', value: 'intuition' },
{ id: 'opt3', text: 'Team input and consensus', value: 'collaboration' },
{ id: 'opt4', text: 'Past experience and proven methods', value: 'experience' }
] as IpsativeOption[]
},
{
id: 'q4',
type: 'likert',
title: 'Work Environment Preferences',
instruction: 'Rate how much each statement describes your ideal work environment',
category: 'Work Style',
options: [
{ id: 'opt1', text: 'I thrive in fast-paced, high-pressure situations', value: 0 },
{ id: 'opt2', text: 'I prefer structured, predictable workflows', value: 0 },
{ id: 'opt3', text: 'I enjoy working independently on complex problems', value: 0 },
{ id: 'opt4', text: 'I perform best when collaborating with diverse teams', value: 0 }
] as LikertOption[]
},
{
id: 'q5',
type: 'ipsative',
title: 'Professional Growth Priorities',
instruction: 'Prioritize these professional development areas (1 = Highest Priority, 4 = Lowest Priority)',
category: 'Development Focus',
options: [
{ id: 'opt1', text: 'Technical expertise and specialization', value: 'technical' },
{ id: 'opt2', text: 'Leadership and people management', value: 'leadership' },
{ id: 'opt3', text: 'Strategic thinking and vision', value: 'strategic' },
{ id: 'opt4', text: 'Cross-functional collaboration', value: 'collaboration' }
] as IpsativeOption[]
}
];
const likertScale = [
{ value: 1, label: 'Strongly Disagree' },
{ value: 2, label: 'Disagree' },
{ value: 3, label: 'Neutral' },
{ value: 4, label: 'Agree' },
{ value: 5, label: 'Strongly Agree' }
];
export function ProfilerPreview({ onBack }: ProfilerPreviewProps) {
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
const [answers, setAnswers] = useState<Answer[]>([]);
const [isCompleted, setIsCompleted] = useState(false);
const [results, setResults] = useState<ProfileResult[]>([]);
const [draggedItem, setDraggedItem] = useState<string | null>(null);
const [timeSpent, setTimeSpent] = useState(0);
const currentQuestion = mockQuestions[currentQuestionIndex];
const progress = ((currentQuestionIndex + 1) / mockQuestions.length) * 100;
// Timer
useEffect(() => {
const timer = setInterval(() => {
setTimeSpent(prev => prev + 1);
}, 1000);
return () => clearInterval(timer);
}, []);
const getCurrentAnswer = () => {
return answers.find(a => a.questionId === currentQuestion.id);
};
const updateAnswer = (value: any) => {
setAnswers(prev => {
const existing = prev.findIndex(a => a.questionId === currentQuestion.id);
const newAnswer: Answer = {
questionId: currentQuestion.id,
type: currentQuestion.type,
value
};
if (existing >= 0) {
const updated = [...prev];
updated[existing] = newAnswer;
return updated;
} else {
return [...prev, newAnswer];
}
});
};
const handleIpsativeRanking = (optionId: string, rank: number) => {
const currentAnswer = getCurrentAnswer();
const currentRankings = currentAnswer?.value || {};
// Remove this rank from any other option
const updatedRankings = { ...currentRankings };
Object.keys(updatedRankings).forEach(key => {
if (updatedRankings[key] === rank) {
delete updatedRankings[key];
}
});
// Set the new ranking
updatedRankings[optionId] = rank;
updateAnswer(updatedRankings);
};
const handleLikertResponse = (optionId: string, value: number) => {
const currentAnswer = getCurrentAnswer();
const currentResponses = currentAnswer?.value || {};
updateAnswer({
...currentResponses,
[optionId]: value
});
};
const isQuestionComplete = () => {
const answer = getCurrentAnswer();
if (!answer) return false;
if (currentQuestion.type === 'ipsative') {
const rankings = answer.value;
const requiredRanks = currentQuestion.options.length;
return Object.keys(rankings).length === requiredRanks &&
Object.values(rankings).every((rank: any) => rank >= 1 && rank <= requiredRanks);
} else {
const responses = answer.value;
return Object.keys(responses).length === currentQuestion.options.length;
}
};
const handleNext = () => {
if (!isQuestionComplete()) {
toast.error("Please complete all parts of this question before continuing.");
return;
}
if (currentQuestionIndex < mockQuestions.length - 1) {
setCurrentQuestionIndex(prev => prev + 1);
} else {
// Complete the assessment
generateResults();
setIsCompleted(true);
}
};
const handlePrevious = () => {
if (currentQuestionIndex > 0) {
setCurrentQuestionIndex(prev => prev - 1);
}
};
const generateResults = () => {
// Mock result generation based on answers
const mockResults: ProfileResult[] = [
{
category: 'Leadership Style',
score: 78,
percentage: 78,
description: 'Results-oriented leader with strong focus on achievement',
strengths: ['Goal-oriented', 'Performance driven', 'Strategic thinking'],
developmentAreas: ['Team relationship building', 'Collaborative decision making']
},
{
category: 'Communication Style',
score: 85,
percentage: 85,
description: 'Direct communicator who values clarity and efficiency',
strengths: ['Clear communication', 'Confident presentation', 'Decisive'],
developmentAreas: ['Active listening', 'Empathetic communication']
},
{
category: 'Decision Making',
score: 72,
percentage: 72,
description: 'Balanced approach combining data analysis with experience',
strengths: ['Analytical thinking', 'Evidence-based decisions'],
developmentAreas: ['Inclusive decision processes', 'Risk tolerance']
}
];
setResults(mockResults);
};
const handleRestart = () => {
setCurrentQuestionIndex(0);
setAnswers([]);
setIsCompleted(false);
setResults([]);
setTimeSpent(0);
};
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const renderIpsativeQuestion = () => {
const answer = getCurrentAnswer();
const rankings = answer?.value || {};
return (
<div className="space-y-6">
<div className="grid gap-4">
{(currentQuestion.options as IpsativeOption[]).map((option, index) => {
const currentRank = rankings[option.id];
return (
<div
key={option.id}
className="p-4 border rounded-lg bg-card hover:bg-accent/50 transition-colors"
>
<div className="flex items-center justify-between">
<div className="flex-1">
<p className="font-medium">{option.text}</p>
</div>
<div className="flex gap-2 ml-4">
{[1, 2, 3, 4].map(rank => (
<Button
key={rank}
variant={currentRank === rank ? "default" : "outline"}
size="sm"
onClick={() => handleIpsativeRanking(option.id, rank)}
className="w-10 h-10 p-0"
style={currentRank === rank ? { backgroundColor: "var(--color-brand-primary)" } : {}}
>
{rank}
</Button>
))}
</div>
</div>
</div>
);
})}
</div>
<div className="text-center text-sm text-muted-foreground">
<div className="flex items-center justify-center gap-2 mb-2">
<Move className="h-4 w-4" />
<span>Ranking Instructions</span>
</div>
<p>1 = Most Important 4 = Least Important</p>
<p className="mt-1">Each ranking number can only be used once</p>
</div>
</div>
);
};
const renderLikertQuestion = () => {
const answer = getCurrentAnswer();
const responses = answer?.value || {};
return (
<div className="space-y-6">
<div className="space-y-6">
{(currentQuestion.options as LikertOption[]).map((option) => {
const currentValue = responses[option.id];
return (
<div key={option.id} className="space-y-4">
<p className="font-medium">{option.text}</p>
<RadioGroup
value={currentValue?.toString()}
onValueChange={(value) => handleLikertResponse(option.id, parseInt(value))}
className="flex justify-between"
>
{likertScale.map(scale => (
<div key={scale.value} className="flex flex-col items-center space-y-2">
<RadioGroupItem
value={scale.value.toString()}
id={`${option.id}-${scale.value}`}
className="w-5 h-5"
/>
<Label
htmlFor={`${option.id}-${scale.value}`}
className="text-xs text-center max-w-[80px] leading-tight cursor-pointer"
>
{scale.label}
</Label>
</div>
))}
</RadioGroup>
</div>
);
})}
</div>
<div className="text-center text-sm text-muted-foreground">
<div className="flex items-center justify-center gap-2 mb-2">
<Scale className="h-4 w-4" />
<span>Rating Scale</span>
</div>
<p>Rate each statement based on how much you agree</p>
</div>
</div>
);
};
const renderResults = () => {
return (
<div className="space-y-8">
{/* Header */}
<div className="text-center space-y-4">
<div className="flex items-center justify-center gap-2">
<Award className="h-8 w-8 text-yellow-500" />
<h2 className="text-2xl font-bold">Assessment Complete!</h2>
</div>
<p className="text-muted-foreground max-w-2xl mx-auto">
Your leadership profile has been generated based on your responses.
Review your results below to understand your strengths and development opportunities.
</p>
<div className="flex items-center justify-center gap-6 text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4" />
<span>Time taken: {formatTime(timeSpent)}</span>
</div>
<div className="flex items-center gap-2">
<BarChart3 className="h-4 w-4" />
<span>{mockQuestions.length} questions completed</span>
</div>
</div>
</div>
{/* Results Grid */}
<div className="grid gap-6">
{results.map((result, index) => (
<Card key={index} className="overflow-hidden">
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Target className="h-5 w-5" style={{ color: "var(--color-brand-primary)" }} />
{result.category}
</CardTitle>
<Badge
variant="secondary"
className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100"
>
{result.score}/100
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* Score Visualization */}
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span>Profile Score</span>
<span className="font-medium">{result.percentage}%</span>
</div>
<Progress value={result.percentage} className="h-3" />
</div>
{/* Description */}
<div>
<h4 className="font-medium mb-2">Your Profile</h4>
<p className="text-sm text-muted-foreground">{result.description}</p>
</div>
{/* Strengths & Development Areas */}
<div className="grid md:grid-cols-2 gap-6">
<div>
<h4 className="font-medium mb-3 flex items-center gap-2">
<TrendingUp className="h-4 w-4 text-green-600" />
Key Strengths
</h4>
<ul className="space-y-2">
{result.strengths.map((strength, idx) => (
<li key={idx} className="text-sm flex items-center gap-2">
<CheckCircle className="h-4 w-4 text-green-600 flex-shrink-0" />
{strength}
</li>
))}
</ul>
</div>
<div>
<h4 className="font-medium mb-3 flex items-center gap-2">
<Lightbulb className="h-4 w-4 text-blue-600" />
Development Areas
</h4>
<ul className="space-y-2">
{result.developmentAreas.map((area, idx) => (
<li key={idx} className="text-sm flex items-center gap-2">
<AlertCircle className="h-4 w-4 text-blue-600 flex-shrink-0" />
{area}
</li>
))}
</ul>
</div>
</div>
</CardContent>
</Card>
))}
</div>
{/* Action Buttons */}
<div className="flex justify-center gap-4">
<Button variant="outline" onClick={handleRestart}>
<RotateCcw className="h-4 w-4 mr-2" />
Retake Assessment
</Button>
<Button onClick={onBack} style={{ backgroundColor: "var(--color-brand-primary)" }}>
Return to Dashboard
</Button>
</div>
</div>
);
};
if (isCompleted) {
return (
<div className="min-h-screen bg-background">
<div className="container mx-auto px-6 py-8 max-w-4xl">
{renderResults()}
</div>
</div>
);
}
return (
<div className="min-h-screen bg-background">
<div className="container mx-auto px-6 py-8 max-w-4xl">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<Button variant="ghost" onClick={onBack} className="flex items-center gap-2">
<ArrowLeft className="h-4 w-4" />
Back to Dashboard
</Button>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4" />
<span>{formatTime(timeSpent)}</span>
</div>
<Badge variant="outline">
Leadership Profile Assessment
</Badge>
</div>
</div>
{/* Progress */}
<div className="mb-8">
<div className="flex justify-between items-center mb-4">
<h1 className="text-2xl font-bold">Question {currentQuestionIndex + 1} of {mockQuestions.length}</h1>
<span className="text-sm text-muted-foreground">{Math.round(progress)}% Complete</span>
</div>
<Progress value={progress} className="h-2" />
</div>
{/* Question Card */}
<Card className="mb-8">
<CardHeader>
<div className="flex items-center gap-3 mb-4">
<Badge variant="outline" className="flex items-center gap-1">
{currentQuestion.type === 'ipsative' ? (
<>
<Move className="h-3 w-3" />
Ranking Question
</>
) : (
<>
<Scale className="h-3 w-3" />
Rating Question
</>
)}
</Badge>
<Badge variant="secondary">{currentQuestion.category}</Badge>
</div>
<CardTitle className="text-xl">{currentQuestion.title}</CardTitle>
<p className="text-muted-foreground">{currentQuestion.instruction}</p>
</CardHeader>
<CardContent>
{currentQuestion.type === 'ipsative' ? renderIpsativeQuestion() : renderLikertQuestion()}
</CardContent>
</Card>
{/* Navigation */}
<div className="flex justify-between items-center">
<Button
variant="outline"
onClick={handlePrevious}
disabled={currentQuestionIndex === 0}
className="flex items-center gap-2"
>
<ArrowLeft className="h-4 w-4" />
Previous
</Button>
<div className="flex items-center gap-2">
{mockQuestions.map((_, index) => (
<Circle
key={index}
className={`h-3 w-3 ${
index < currentQuestionIndex
? 'fill-green-600 text-green-600'
: index === currentQuestionIndex
? 'fill-blue-600 text-blue-600'
: 'text-gray-300'
}`}
/>
))}
</div>
<Button
onClick={handleNext}
disabled={!isQuestionComplete()}
className="flex items-center gap-2"
style={isQuestionComplete() ? { backgroundColor: "var(--color-brand-primary)" } : {}}
>
{currentQuestionIndex === mockQuestions.length - 1 ? 'Complete Assessment' : 'Next'}
<ArrowRight className="h-4 w-4" />
</Button>
</div>
{/* Help Text */}
<div className="mt-8 text-center">
<p className="text-sm text-muted-foreground">
{!isQuestionComplete() && (
<span className="flex items-center justify-center gap-2">
<AlertCircle className="h-4 w-4" />
Please complete all parts of this question to continue
</span>
)}
</p>
</div>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,593 @@
import React, { useState, useRef } from 'react';
import { AuthenticatedLayout } from '../layout/AuthenticatedLayout';
import { LikertQuestionEditor } from '../LikertQuestionEditor';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
import { Button } from '../ui/button';
import { Badge } from '../ui/badge';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Textarea } from '../ui/textarea';
import { Avatar, AvatarFallback, AvatarImage } from '../ui/avatar';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '../ui/table';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../ui/select';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '../ui/dropdown-menu';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '../ui/sheet';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '../ui/dialog';
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '../ui/tabs';
import { Checkbox } from '../ui/checkbox';
import { Progress } from '../ui/progress';
import { Separator } from '../ui/separator';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip';
import { toast } from "sonner@2.0.3";
import {
Plus,
Search,
MoreHorizontal,
Eye,
Edit,
Archive,
RotateCcw,
Clock,
Target,
Filter,
Upload,
FileSpreadsheet,
CheckCircle,
XCircle,
AlertCircle,
Download,
Users,
Link,
Play,
History,
Send,
ThumbsUp,
ThumbsDown,
ExternalLink,
Copy,
Trash2,
Settings,
UserCheck,
FileText,
Calendar,
Briefcase
} from 'lucide-react';
interface ProfilersProps {
onNavigate: (route: string) => void;
onLogout: () => void;
user: any;
}
// Mock data for Profiler Master Options and Sub Dimensions
const mockProfilerMasterOptions = [
{ id: "opt_001", label: "Strongly Disagree", order: 1 },
{ id: "opt_002", label: "Disagree", order: 2 },
{ id: "opt_003", label: "Neutral", order: 3 },
{ id: "opt_004", label: "Agree", order: 4 },
{ id: "opt_005", label: "Strongly Agree", order: 5 },
{ id: "opt_006", label: "Not Applicable", order: 6 }
];
const mockProfilerMasterSubDimensions = [
{ id: "sd_001", name: "Vision Development", description: "Creating and communicating clear vision" },
{ id: "sd_002", name: "Long-term Planning", description: "Planning for future scenarios" },
{ id: "sd_004", name: "Verbal Communication", description: "Clear and effective speaking" },
{ id: "sd_005", name: "Active Listening", description: "Listening and understanding others" },
{ id: "sd_006", name: "Team Building", description: "Creating cohesive teams" },
{ id: "sd_007", name: "Delegation", description: "Effective task delegation" },
{ id: "sd_008", name: "Goal Achievement", description: "Consistently meeting objectives" },
{ id: "sd_009", name: "Quality Focus", description: "Maintaining high standards" }
];
// Enhanced profiler data with Likert section configuration
const mockProfilersData = [
{
id: "PRF-202509-002",
title: "Team Communication Assessment",
description: "Evaluate communication effectiveness within teams using Likert-scale questions.",
profilerType: "Individual",
questionType: "Likert",
reportType: "Development",
duration: 25,
tags: ["communication", "team-dynamics", "assessment"],
status: "Draft",
workflowStatus: "draft",
owner: "Prof. Priya Sinha",
sections: 3,
questions: 18,
sectionConfiguration: [
{
id: "sec_verbal_comm",
name: "Verbal Communication",
questionType: "Likert",
likert: {
optionCountDefault: 5 // Section-level control for Likert (1-6 scale)
},
order: 1
},
{
id: "sec_written_comm",
name: "Written Communication",
questionType: "Likert",
likert: {
optionCountDefault: 4 // Section-level control for Likert (1-6 scale)
},
order: 2
},
{
id: "sec_active_listening",
name: "Active Listening",
questionType: "Likert",
likert: {
optionCountDefault: 5 // Section-level control for Likert (1-6 scale)
},
order: 3
}
],
assignedTo: [],
integratedWith: {
courses: ["crs_comm"],
programmes: []
},
createdAt: "2025-09-01T09:15:00Z",
updatedAt: "2025-09-14T11:45:00Z"
}
];
export function Profilers({ onNavigate, onLogout, user }: ProfilersProps) {
// Search and filters
const [searchTerm, setSearchTerm] = useState("");
// Dialog and drawer states
const [isSectionConfigDrawerOpen, setIsSectionConfigDrawerOpen] = useState(false);
const [isLikertQuestionDialogOpen, setIsLikertQuestionDialogOpen] = useState(false);
// Data states
const [sectionConfigItem, setSectionConfigItem] = useState<any>(null);
const [sectionConfigData, setSectionConfigData] = useState<any[]>([]);
const [editingLikertQuestion, setEditingLikertQuestion] = useState<any>(null);
const breadcrumbs = [
{ label: "Admin", href: "/dashboard" },
{ label: "Content", href: "/content" },
{ label: "Profilers" }
];
// Section option count management functions
const updateSectionOptionCount = (sectionIndex: number, newK: number) => {
const updatedSections = [...sectionConfigData];
updatedSections[sectionIndex] = {
...updatedSections[sectionIndex],
likert: {
...updatedSections[sectionIndex].likert,
optionCountDefault: newK
}
};
setSectionConfigData(updatedSections);
};
const autoFillSectionOptionOrders = (sectionIndex: number) => {
const section = sectionConfigData[sectionIndex];
const K = section.likert?.optionCountDefault || 5;
toast.success(`Auto-filled option orders 1 to ${K} for all questions in "${section.name}"`);
};
// Likert Question Editor functions
const handleEditLikertQuestion = (question: any) => {
setEditingLikertQuestion(question);
setIsLikertQuestionDialogOpen(true);
};
const handleManageSectionConfiguration = (profiler: any) => {
setSectionConfigItem(profiler);
setSectionConfigData([...profiler.sectionConfiguration || []]);
setIsSectionConfigDrawerOpen(true);
};
return (
<AuthenticatedLayout
user={user}
onLogout={onLogout}
breadcrumbs={breadcrumbs}
>
<div className="p-6 max-w-7xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 style={{ color: 'var(--color-brand-black)' }}>Enhanced Profilers</h1>
<p className="text-gray-600 mt-1">
Manage assessment tools with enhanced Likert section configuration
</p>
</div>
<div className="flex items-center gap-3">
<Button
variant="outline"
onClick={() => onNavigate('/admin/section-configuration')}
className="min-h-[44px]"
>
<Settings className="h-4 w-4 mr-2" />
Section Templates
</Button>
<Button
onClick={() => onNavigate('/profilers/new')}
className="min-h-[44px]"
style={{ backgroundColor: 'var(--color-brand-primary)' }}
>
<Plus className="h-4 w-4 mr-2" />
New Profiler
</Button>
</div>
</div>
{/* Search and Filters */}
<Card>
<CardContent className="p-6">
<div className="flex items-center gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
placeholder="Search profilers by name, ID, or owner..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 min-h-[44px]"
style={{ borderWidth: '2px' }}
/>
</div>
<Button
variant="outline"
className="min-h-[44px]"
>
<Filter className="h-4 w-4 mr-2" />
Filters
</Button>
</div>
</CardContent>
</Card>
{/* Profilers Table */}
<Card>
<CardHeader>
<CardTitle>Profilers</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Profiler</TableHead>
<TableHead>Type</TableHead>
<TableHead>Status</TableHead>
<TableHead>Owner</TableHead>
<TableHead>Updated</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{mockProfilersData.map((profiler) => (
<TableRow key={profiler.id}>
<TableCell>
<div>
<div className="font-medium">{profiler.title}</div>
<div className="text-sm text-gray-500">{profiler.id}</div>
<div className="flex items-center gap-1 mt-1">
{profiler.tags.map((tag) => (
<Badge key={tag} variant="secondary" className="text-xs">
{tag}
</Badge>
))}
</div>
</div>
</TableCell>
<TableCell>
<div>
<Badge variant="outline">{profiler.questionType}</Badge>
<div className="text-sm text-gray-500 mt-1">
{profiler.sections} sections {profiler.questions} questions
</div>
</div>
</TableCell>
<TableCell>
<Badge
className={`${profiler.workflowStatus === 'draft' ? 'bg-gray-100 text-gray-800' : 'bg-green-100 text-green-800'}`}
>
{profiler.status}
</Badge>
</TableCell>
<TableCell>{profiler.owner}</TableCell>
<TableCell>
{new Date(profiler.updatedAt).toLocaleDateString('en-GB', {
day: '2-digit',
month: 'short',
year: 'numeric'
})}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleManageSectionConfiguration(profiler)}
className="min-h-[44px]"
style={{ color: 'var(--color-brand-primary)' }}
>
<Settings className="h-4 w-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="min-h-[44px]">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => onNavigate(`/profilers/new?id=${profiler.id}`)}>
<Edit className="h-4 w-4 mr-2" />
Edit in Builder
</DropdownMenuItem>
<DropdownMenuItem>
<Eye className="h-4 w-4 mr-2" />
Preview
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<Archive className="h-4 w-4 mr-2" />
Archive
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
{/* Enhanced Section Configuration Drawer */}
<Sheet open={isSectionConfigDrawerOpen} onOpenChange={setIsSectionConfigDrawerOpen}>
<SheetContent className="min-w-[700px] max-w-[90vw]">
<SheetHeader>
<SheetTitle>Section Configuration</SheetTitle>
<SheetDescription>
Manage section-level controls for {sectionConfigItem?.title}
</SheetDescription>
</SheetHeader>
<div className="mt-6 space-y-6">
{/* Section Configuration Table */}
<div className="border rounded-lg">
<div className="bg-gray-50 px-4 py-3 border-b">
<h3 className="font-medium">Section Settings</h3>
<p className="text-sm text-gray-600 mt-1">
Configure section-level controls for {sectionConfigItem?.questionType} questions
</p>
</div>
<div className="divide-y">
{sectionConfigData.map((section, index) => (
<div key={section.id} className="p-4 space-y-4">
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-2">
<h4 className="font-medium">{section.name}</h4>
{section.questionType === 'Likert' && (
<Badge
variant="secondary"
className="text-xs"
style={{
backgroundColor: 'var(--color-brand-accent)',
color: 'var(--color-brand-black)'
}}
>
Likert {section.likert?.optionCountDefault || 5} options
</Badge>
)}
</div>
<p className="text-sm text-gray-500">Order: {section.order}</p>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline">{section.questionType}</Badge>
{section.questionType === 'Likert' && (
<Button
variant="ghost"
size="sm"
onClick={() => handleEditLikertQuestion(null)}
className="min-h-[44px] text-xs border-2"
style={{ borderColor: 'var(--color-brand-primary)', color: 'var(--color-brand-primary)' }}
>
Add Question
</Button>
)}
</div>
</div>
{/* Section-level Controls */}
<div className="grid grid-cols-2 gap-4">
{section.questionType === 'Likert' && (
<div className="space-y-4">
<div>
<Label className="text-sm font-medium">Number of Options (16)</Label>
<Select
value={(section.likert?.optionCountDefault || 5).toString()}
onValueChange={(value) => {
const newK = parseInt(value);
const oldK = section.likert?.optionCountDefault || 5;
if (newK < oldK) {
// Show confirmation for decreasing K
if (confirm(`Reduce options from ${oldK} to ${newK}? This will trim extra options from all questions in this section.`)) {
updateSectionOptionCount(index, newK);
}
} else {
updateSectionOptionCount(index, newK);
// Announce live update for accessibility
const announcement = `Options set to ${newK}`;
const liveRegion = document.createElement('div');
liveRegion.setAttribute('aria-live', 'polite');
liveRegion.setAttribute('aria-atomic', 'true');
liveRegion.className = 'sr-only';
liveRegion.textContent = announcement;
document.body.appendChild(liveRegion);
setTimeout(() => document.body.removeChild(liveRegion), 1000);
}
}}
>
<SelectTrigger className="min-h-[44px] mt-1" style={{ borderWidth: '2px' }}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1 Option</SelectItem>
<SelectItem value="2">2 Options</SelectItem>
<SelectItem value="3">3 Options</SelectItem>
<SelectItem value="4">4 Options</SelectItem>
<SelectItem value="5">5 Options</SelectItem>
<SelectItem value="6">6 Options</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-gray-500 mt-1">
Default number of options for all questions in this section (1-6)
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => autoFillSectionOptionOrders(index)}
className="min-h-[44px] border-2"
style={{ borderColor: 'var(--color-brand-primary)', color: 'var(--color-brand-primary)' }}
>
Auto-fill option orders 1{section.likert?.optionCountDefault || 5} for all questions in this section
</Button>
</div>
)}
<div>
<Label className="text-sm font-medium">Section Order</Label>
<Input
type="number"
min="1"
value={section.order}
onChange={(e) => {
const updatedSections = [...sectionConfigData];
updatedSections[index] = {
...section,
order: parseInt(e.target.value) || 1
};
setSectionConfigData(updatedSections);
}}
className="min-h-[44px] mt-1"
style={{ borderWidth: '2px' }}
/>
<p className="text-xs text-gray-500 mt-1">
Display order in the profiler
</p>
</div>
</div>
</div>
))}
</div>
</div>
{/* Enhanced Summary Card */}
<Card>
<CardContent className="p-4">
<h4 className="font-medium mb-3">Configuration Summary</h4>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-gray-600">Total Sections:</span>
<span className="ml-2 font-medium">{sectionConfigData.length}</span>
</div>
<div>
<span className="text-gray-600">Question Type:</span>
<span className="ml-2 font-medium">{sectionConfigItem?.questionType}</span>
</div>
{sectionConfigItem?.questionType === 'Likert' && (
<div>
<span className="text-gray-600">Option Range:</span>
<span className="ml-2 font-medium">
{Math.min(...sectionConfigData.map(s => s.likert?.optionCountDefault || 5))}-{Math.max(...sectionConfigData.map(s => s.likert?.optionCountDefault || 5))} options
</span>
</div>
)}
</div>
</CardContent>
</Card>
<div className="flex justify-end space-x-2">
<Button
variant="outline"
onClick={() => setIsSectionConfigDrawerOpen(false)}
className="min-h-[44px]"
>
Cancel
</Button>
<Button
onClick={() => {
toast.success("Section configuration updated successfully");
setIsSectionConfigDrawerOpen(false);
}}
className="min-h-[44px]"
style={{ backgroundColor: 'var(--color-brand-primary)' }}
>
Save Configuration
</Button>
</div>
</div>
</SheetContent>
</Sheet>
{/* Likert Question Editor Dialog */}
<LikertQuestionEditor
isOpen={isLikertQuestionDialogOpen}
onClose={() => setIsLikertQuestionDialogOpen(false)}
question={editingLikertQuestion}
sectionDefaultK={sectionConfigItem?.sectionConfiguration?.[0]?.likert?.optionCountDefault || 5}
options={mockProfilerMasterOptions}
subDimensions={mockProfilerMasterSubDimensions}
onSave={(questionData) => {
console.log('Saving Likert question:', questionData);
toast.success('Likert question saved successfully');
setIsLikertQuestionDialogOpen(false);
}}
/>
</div>
</AuthenticatedLayout>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,809 @@
import React, { useState, useEffect } from 'react';
import { AuthenticatedLayout } from '../layout/AuthenticatedLayout';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
import { Button } from '../ui/button';
import { Badge } from '../ui/badge';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Textarea } from '../ui/textarea';
import { Checkbox } from '../ui/checkbox';
import { RadioGroup, RadioGroupItem } from '../ui/radio-group';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs';
import { Progress } from '../ui/progress';
import { Separator } from '../ui/separator';
import { toast } from "sonner@2.0.3";
import {
ChevronLeft,
ChevronRight,
Calendar,
Users,
FileUp,
Download,
AlertCircle,
Clock,
MapPin,
User,
Building2,
CheckCircle
} from 'lucide-react';
import { klcMockData } from '../../data/mockData';
interface ProgrammeAssignmentProps {
programmeId: string;
onNavigate: (route: string) => void;
onLogout: () => void;
user: any;
}
interface AssignmentData {
type: 'programme';
itemId: string;
scope: 'organization' | 'individual';
organizationId?: string;
userId?: string;
hrContacts: string[];
startDate: string;
endDate: string;
completionDate?: string;
maxParticipants: number;
integrationId?: string;
flags: {
allowAddFeedbackGiver: boolean;
hrAccessReports: boolean;
hrAccessCertificate: boolean;
onlineProgram: boolean;
blockSystemEmails: boolean;
};
participantsSource: 'directory' | 'upload';
participants: string[];
participantsFile?: File;
feedbackGiversFile?: File;
}
// Mock integration options
const integrationOptions = [
{ id: 'int_001', name: 'Microsoft Teams' },
{ id: 'int_002', name: 'Zoom Workplace' },
{ id: 'int_003', name: 'Google Workspace' },
{ id: 'int_004', name: 'Slack Enterprise' },
{ id: 'int_005', name: 'Custom API' }
];
export function ProgrammeAssignment({ programmeId, onNavigate, onLogout, user }: ProgrammeAssignmentProps) {
const [currentStep, setCurrentStep] = useState(1);
const [assignmentData, setAssignmentData] = useState<AssignmentData>({
type: 'programme',
itemId: programmeId,
scope: 'organization',
hrContacts: [],
startDate: '',
endDate: '',
maxParticipants: 25,
flags: {
allowAddFeedbackGiver: false,
hrAccessReports: false,
hrAccessCertificate: false,
onlineProgram: false,
blockSystemEmails: false,
},
participantsSource: 'directory',
participants: [],
});
// Get programme data
const programme = klcMockData.programmes?.find(p => p.id === programmeId);
// Mock organizations and users data
const organizations = klcMockData.users?.organizations || [];
const [filteredLearners, setFilteredLearners] = useState<any[]>([]);
const [selectedParticipants, setSelectedParticipants] = useState<string[]>([]);
const [participantsTab, setParticipantsTab] = useState('directory');
const [hrContactInput, setHrContactInput] = useState('');
// Load learners when organization changes
useEffect(() => {
if (assignmentData.organizationId) {
// Mock learners for selected organization
const mockLearners = [
{ id: 'usr_001', name: 'Ravi Kumar', email: 'ravi.kumar@org.example', department: 'Technology' },
{ id: 'usr_002', name: 'Priya Sharma', email: 'priya.sharma@org.example', department: 'Marketing' },
{ id: 'usr_003', name: 'Amit Singh', email: 'amit.singh@org.example', department: 'Operations' },
{ id: 'usr_004', name: 'Sneha Patel', email: 'sneha.patel@org.example', department: 'HR' },
{ id: 'usr_005', name: 'Rajesh Kumar', email: 'rajesh.kumar@org.example', department: 'Finance' }
];
setFilteredLearners(mockLearners);
}
}, [assignmentData.organizationId]);
const updateAssignmentData = (updates: Partial<AssignmentData>) => {
setAssignmentData(prev => ({ ...prev, ...updates }));
};
const validateStep = (step: number): boolean => {
switch (step) {
case 1:
if (assignmentData.scope === 'organization' && !assignmentData.organizationId) {
toast.error("Please select an organization");
return false;
}
if (assignmentData.scope === 'individual' && !assignmentData.userId) {
toast.error("Please select a user");
return false;
}
if (assignmentData.hrContacts.length === 0) {
toast.error("Please enter at least one HR contact");
return false;
}
if (assignmentData.maxParticipants <= 0) {
toast.error("Max participants must be greater than 0");
return false;
}
return true;
case 2:
if (!assignmentData.startDate) {
toast.error("Please select a start date");
return false;
}
if (!assignmentData.endDate) {
toast.error("Please select an end date");
return false;
}
if (new Date(assignmentData.endDate) < new Date(assignmentData.startDate)) {
toast.error("End date must be after start date");
return false;
}
if (assignmentData.completionDate && new Date(assignmentData.completionDate) < new Date(assignmentData.endDate)) {
toast.error("Completion date must be after end date");
return false;
}
return true;
case 3:
if (participantsTab === 'directory' && selectedParticipants.length === 0) {
toast.error("Please select at least one participant");
return false;
}
if (selectedParticipants.length > assignmentData.maxParticipants) {
toast.error(`Cannot exceed maximum of ${assignmentData.maxParticipants} participants`);
return false;
}
return true;
default:
return true;
}
};
const handleNext = () => {
if (validateStep(currentStep)) {
setCurrentStep(prev => Math.min(prev + 1, 4));
}
};
const handleBack = () => {
setCurrentStep(prev => Math.max(prev - 1, 1));
};
const handleAssign = () => {
if (validateStep(3)) {
toast.success("Assignment created.");
// Navigate to assignment details or back to programmes
onNavigate('/programmes');
}
};
const addHrContact = () => {
if (hrContactInput.trim()) {
updateAssignmentData({
hrContacts: [...assignmentData.hrContacts, hrContactInput.trim()]
});
setHrContactInput('');
}
};
const removeHrContact = (index: number) => {
const newContacts = assignmentData.hrContacts.filter((_, i) => i !== index);
updateAssignmentData({ hrContacts: newContacts });
};
const toggleParticipant = (participantId: string) => {
setSelectedParticipants(prev =>
prev.includes(participantId)
? prev.filter(id => id !== participantId)
: [...prev, participantId]
);
};
// Check if programme has feedback components
const hasFeedbackComponents = programme?.structure?.preAssessment?.some((item: any) =>
item.type === 'Profiler' && item.title.toLowerCase().includes('360')
) || programme?.structure?.finalAssessment?.some((item: any) =>
item.type === 'Profiler' && item.title.toLowerCase().includes('360')
);
const breadcrumbItems = [
{ label: "Admin", href: "/dashboard" },
{ label: "Programmes", href: "/programmes" },
{ label: programme?.title || "Programme", href: `/programmes/${programmeId}` },
{ label: "Assign", current: true }
];
const renderStepIndicator = () => (
<div className="flex items-center justify-between mb-8">
{[1, 2, 3, 4].map((step) => (
<div key={step} className="flex items-center">
<div className={`
w-10 h-10 rounded-full flex items-center justify-center border-2 font-medium
${step <= currentStep
? 'bg-[var(--color-brand-primary)] text-white border-[var(--color-brand-primary)]'
: 'bg-background text-muted-foreground border-muted-foreground'}
`}>
{step < currentStep ? <CheckCircle className="h-5 w-5" /> : step}
</div>
{step < 4 && (
<div className={`
w-20 h-0.5 mx-2
${step < currentStep ? 'bg-[var(--color-brand-primary)]' : 'bg-muted-foreground'}
`} />
)}
</div>
))}
</div>
);
const renderSummaryCard = () => (
<Card className="w-80">
<CardHeader>
<CardTitle className="text-lg">Programme Summary</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label className="text-sm font-medium text-muted-foreground">Title</Label>
<p className="font-medium">{programme?.title}</p>
</div>
<div>
<Label className="text-sm font-medium text-muted-foreground">ID/Code</Label>
<p className="font-mono text-sm">{programme?.id}</p>
</div>
<div>
<Label className="text-sm font-medium text-muted-foreground">Status</Label>
<Badge variant={programme?.status === 'Published' ? 'default' : 'secondary'}>
{programme?.status}
</Badge>
</div>
<div>
<Label className="text-sm font-medium text-muted-foreground">Owner</Label>
<p>{programme?.coordinator}</p>
</div>
<div>
<Label className="text-sm font-medium text-muted-foreground">Duration</Label>
<p>{programme?.duration}</p>
</div>
<div>
<Label className="text-sm font-medium text-muted-foreground">Structure</Label>
<p className="text-sm text-muted-foreground">
{programme?.structure?.preAssessment?.length || 0} Pre-assessments,
{programme?.structure?.preLearning?.length || 0} Pre-learning,
{programme?.structure?.classroomSessions?.length || 0} Sessions,
{programme?.structure?.postLearning?.length || 0} Post-learning
</p>
</div>
</CardContent>
</Card>
);
const renderStep1 = () => (
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium mb-4">Who to assign</h3>
<div className="grid grid-cols-2 gap-6">
<div className="space-y-4">
<div>
<Label htmlFor="assign-to" className="text-base font-medium">Assign to*</Label>
<RadioGroup
value={assignmentData.scope}
onValueChange={(value: 'organization' | 'individual') =>
updateAssignmentData({ scope: value, organizationId: undefined, userId: undefined })
}
className="mt-2"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="organization" id="org" />
<Label htmlFor="org">Organization</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="individual" id="individual" />
<Label htmlFor="individual">Individual</Label>
</div>
</RadioGroup>
</div>
{assignmentData.scope === 'organization' ? (
<div>
<Label htmlFor="organization-select">Select Organisation*</Label>
<Select value={assignmentData.organizationId} onValueChange={(value) => updateAssignmentData({ organizationId: value })}>
<SelectTrigger className="min-h-[44px]">
<SelectValue placeholder="Choose organization..." />
</SelectTrigger>
<SelectContent>
{organizations.map((org: any) => (
<SelectItem key={org.id} value={org.id}>
{org.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : (
<div>
<Label htmlFor="user-select">Select User*</Label>
<Select value={assignmentData.userId} onValueChange={(value) => updateAssignmentData({ userId: value })}>
<SelectTrigger className="min-h-[44px]">
<SelectValue placeholder="Search and select user..." />
</SelectTrigger>
<SelectContent>
{filteredLearners.map((user: any) => (
<SelectItem key={user.id} value={user.id}>
{user.name} ({user.email})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div>
<Label htmlFor="hr-contacts">Enter HR Names*</Label>
<div className="space-y-2">
<div className="flex gap-2">
<Input
value={hrContactInput}
onChange={(e) => setHrContactInput(e.target.value)}
placeholder="Enter HR contact name"
className="min-h-[44px]"
onKeyPress={(e) => e.key === 'Enter' && addHrContact()}
/>
<Button type="button" onClick={addHrContact} className="min-h-[44px]">Add</Button>
</div>
<p className="text-sm text-muted-foreground">Primary, secondary HR contacts</p>
{assignmentData.hrContacts.length > 0 && (
<div className="flex flex-wrap gap-2">
{assignmentData.hrContacts.map((contact, index) => (
<Badge key={index} variant="secondary" className="px-2 py-1">
{contact}
<button
onClick={() => removeHrContact(index)}
className="ml-2 text-muted-foreground hover:text-foreground"
>
×
</button>
</Badge>
))}
</div>
)}
</div>
</div>
</div>
<div className="space-y-4">
<div>
<Label htmlFor="max-participants">Max. Number of Participants*</Label>
<Input
type="number"
value={assignmentData.maxParticipants}
onChange={(e) => updateAssignmentData({ maxParticipants: parseInt(e.target.value) || 0 })}
className="min-h-[44px]"
min="1"
/>
</div>
<div>
<Label htmlFor="integration">Select Integration</Label>
<Select value={assignmentData.integrationId} onValueChange={(value) => updateAssignmentData({ integrationId: value })}>
<SelectTrigger className="min-h-[44px]">
<SelectValue placeholder="Choose integration..." />
</SelectTrigger>
<SelectContent>
{integrationOptions.map((integration) => (
<SelectItem key={integration.id} value={integration.id}>
{integration.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
<div className="mt-6">
<Label className="text-base font-medium">Permissions</Label>
<div className="grid grid-cols-2 gap-4 mt-3">
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Checkbox
id="feedback-giver"
checked={assignmentData.flags.allowAddFeedbackGiver}
onCheckedChange={(checked) =>
updateAssignmentData({
flags: { ...assignmentData.flags, allowAddFeedbackGiver: !!checked }
})
}
/>
<Label htmlFor="feedback-giver" className="text-sm">Allow Participant to add Feedback Giver</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="hr-reports"
checked={assignmentData.flags.hrAccessReports}
onCheckedChange={(checked) =>
updateAssignmentData({
flags: { ...assignmentData.flags, hrAccessReports: !!checked }
})
}
/>
<Label htmlFor="hr-reports" className="text-sm">Allow HR to Access Reports</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="hr-certificate"
checked={assignmentData.flags.hrAccessCertificate}
onCheckedChange={(checked) =>
updateAssignmentData({
flags: { ...assignmentData.flags, hrAccessCertificate: !!checked }
})
}
/>
<Label htmlFor="hr-certificate" className="text-sm">Allow HR to Access Certificate</Label>
</div>
</div>
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Checkbox
id="online-program"
checked={assignmentData.flags.onlineProgram}
onCheckedChange={(checked) =>
updateAssignmentData({
flags: { ...assignmentData.flags, onlineProgram: !!checked }
})
}
/>
<Label htmlFor="online-program" className="text-sm">Online Program</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="block-emails"
checked={assignmentData.flags.blockSystemEmails}
onCheckedChange={(checked) =>
updateAssignmentData({
flags: { ...assignmentData.flags, blockSystemEmails: !!checked }
})
}
/>
<Label htmlFor="block-emails" className="text-sm">Block System Generated Emails</Label>
</div>
</div>
</div>
{assignmentData.flags.blockSystemEmails && (
<p className="text-sm text-muted-foreground mt-2">
No onboarding emails will be sent for this assignment.
</p>
)}
</div>
</div>
</div>
);
const renderStep2 = () => (
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium mb-4">Schedule & Completion</h3>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<div className="flex items-start gap-2">
<AlertCircle className="h-5 w-5 text-blue-600 mt-0.5" />
<p className="text-sm text-blue-800">
Dates apply to this assignment and are not set on the master programme.
</p>
</div>
</div>
<div className="grid grid-cols-3 gap-6 max-w-2xl">
<div>
<Label htmlFor="start-date">Start Date*</Label>
<Input
type="date"
value={assignmentData.startDate}
onChange={(e) => updateAssignmentData({ startDate: e.target.value })}
className="min-h-[44px]"
/>
</div>
<div>
<Label htmlFor="end-date">End Date*</Label>
<Input
type="date"
value={assignmentData.endDate}
onChange={(e) => updateAssignmentData({ endDate: e.target.value })}
className="min-h-[44px]"
/>
</div>
<div>
<Label htmlFor="completion-date">Completion Date</Label>
<Input
type="date"
value={assignmentData.completionDate || ''}
onChange={(e) => updateAssignmentData({ completionDate: e.target.value })}
className="min-h-[44px]"
/>
</div>
</div>
</div>
</div>
);
const renderStep3 = () => (
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium mb-4">Participants (and Feedback Givers)</h3>
<div className="grid grid-cols-2 gap-6">
<div>
<Tabs value={participantsTab} onValueChange={setParticipantsTab}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="directory">Add from Directory</TabsTrigger>
<TabsTrigger value="upload">Upload</TabsTrigger>
</TabsList>
<TabsContent value="directory" className="space-y-4">
{assignmentData.scope === 'organization' ? (
assignmentData.organizationId ? (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Select participants from {organizations.find(o => o.id === assignmentData.organizationId)?.name}
</p>
{filteredLearners.map((learner) => (
<div key={learner.id} className="flex items-center space-x-2 p-2 border rounded">
<Checkbox
id={learner.id}
checked={selectedParticipants.includes(learner.id)}
onCheckedChange={() => toggleParticipant(learner.id)}
/>
<div className="flex-1">
<Label htmlFor={learner.id} className="font-medium">{learner.name}</Label>
<p className="text-sm text-muted-foreground">{learner.email} {learner.department}</p>
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground py-4">
Select an organization to browse learners.
</p>
)
) : (
<div className="py-4">
<p className="text-sm text-muted-foreground">Individual user will be automatically added as participant.</p>
{assignmentData.userId && (
<Badge variant="secondary" className="mt-2">
{filteredLearners.find(u => u.id === assignmentData.userId)?.name || 'Selected User'}
</Badge>
)}
</div>
)}
</TabsContent>
<TabsContent value="upload" className="space-y-4">
<Card className="border-dashed border-2">
<CardContent className="flex flex-col items-center justify-center py-6">
<FileUp className="h-8 w-8 text-muted-foreground mb-2" />
<Button variant="outline" className="mb-2">
Choose file
</Button>
<p className="text-sm text-muted-foreground">Upload participant list</p>
</CardContent>
</Card>
<Button variant="link" className="p-0 h-auto">
<Download className="h-4 w-4 mr-2" />
Download Participants Template
</Button>
</TabsContent>
</Tabs>
</div>
{hasFeedbackComponents && (
<div>
<Label className="text-base font-medium">Feedback Givers</Label>
<Card className="border-dashed border-2 mt-3">
<CardContent className="flex flex-col items-center justify-center py-6">
<FileUp className="h-8 w-8 text-muted-foreground mb-2" />
<Button variant="outline" className="mb-2">
Choose file
</Button>
<p className="text-sm text-muted-foreground">Upload feedback givers list</p>
</CardContent>
</Card>
<Button variant="link" className="p-0 h-auto mt-2">
<Download className="h-4 w-4 mr-2" />
Download Feedback Givers Template
</Button>
</div>
)}
</div>
<div className="flex items-center justify-between py-3 px-4 bg-muted/30 rounded-lg">
<span className="text-sm font-medium">
Participants selected: {selectedParticipants.length} (Max {assignmentData.maxParticipants})
</span>
</div>
</div>
</div>
);
const renderStep4 = () => (
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium mb-4">Review & Assign</h3>
<div className="space-y-6 max-w-4xl">
<div className="grid grid-cols-2 gap-6">
<div>
<Label className="text-sm font-medium text-muted-foreground">Programme Name</Label>
<p className="font-medium">{programme?.title}</p>
</div>
<div>
<Label className="text-sm font-medium text-muted-foreground">
{assignmentData.scope === 'organization' ? 'Select Organisation' : 'Select User'}
</Label>
<p className="font-medium">
{assignmentData.scope === 'organization'
? organizations.find(o => o.id === assignmentData.organizationId)?.name
: filteredLearners.find(u => u.id === assignmentData.userId)?.name
}
</p>
</div>
<div>
<Label className="text-sm font-medium text-muted-foreground">HR Names</Label>
<p className="font-medium">{assignmentData.hrContacts.join(', ')}</p>
</div>
<div>
<Label className="text-sm font-medium text-muted-foreground">Max. Number of Participants</Label>
<p className="font-medium">{assignmentData.maxParticipants}</p>
</div>
<div>
<Label className="text-sm font-medium text-muted-foreground">Start Date End Date</Label>
<p className="font-medium">
{assignmentData.startDate} {assignmentData.endDate}
{assignmentData.completionDate && `${assignmentData.completionDate}`}
</p>
</div>
<div>
<Label className="text-sm font-medium text-muted-foreground">Select Integration</Label>
<p className="font-medium">
{assignmentData.integrationId
? integrationOptions.find(i => i.id === assignmentData.integrationId)?.name
: 'None selected'
}
</p>
</div>
</div>
<Separator />
<div>
<Label className="text-sm font-medium text-muted-foreground">Permissions</Label>
<div className="grid grid-cols-2 gap-2 mt-2">
<p className="text-sm">Allow Participant to add Feedback Giver: <span className="font-medium">{assignmentData.flags.allowAddFeedbackGiver ? 'Yes' : 'No'}</span></p>
<p className="text-sm">Allow HR to Access Reports: <span className="font-medium">{assignmentData.flags.hrAccessReports ? 'Yes' : 'No'}</span></p>
<p className="text-sm">Allow HR to Access Certificate: <span className="font-medium">{assignmentData.flags.hrAccessCertificate ? 'Yes' : 'No'}</span></p>
<p className="text-sm">Online Program: <span className="font-medium">{assignmentData.flags.onlineProgram ? 'Yes' : 'No'}</span></p>
<p className="text-sm">Block System Generated Emails: <span className="font-medium">{assignmentData.flags.blockSystemEmails ? 'Yes' : 'No'}</span></p>
</div>
</div>
<Separator />
<div>
<Label className="text-sm font-medium text-muted-foreground">Participants</Label>
<p className="font-medium">
{participantsTab === 'directory'
? `${selectedParticipants.length} selected from directory`
: 'Via upload'
}
</p>
{hasFeedbackComponents && (
<>
<Label className="text-sm font-medium text-muted-foreground mt-2 block">Feedback Givers</Label>
<p className="font-medium">Via upload</p>
</>
)}
</div>
</div>
</div>
</div>
);
const stepTitles = [
'Who to assign',
'Schedule & Completion',
'Participants (and Feedback Givers)',
'Review & Assign'
];
return (
<AuthenticatedLayout
user={user}
onLogout={onLogout}
currentRoute={`/programmes/${programmeId}/assign`}
breadcrumbItems={breadcrumbItems}
>
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-medium">Assignment Programme</h1>
<p className="text-muted-foreground">
Assign programme to organizations or individuals
</p>
</div>
</div>
<div className="flex gap-6">
<div className="flex-1">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>{stepTitles[currentStep - 1]}</CardTitle>
<Badge variant="outline">Step {currentStep} of 4</Badge>
</div>
{renderStepIndicator()}
</CardHeader>
<CardContent>
{currentStep === 1 && renderStep1()}
{currentStep === 2 && renderStep2()}
{currentStep === 3 && renderStep3()}
{currentStep === 4 && renderStep4()}
</CardContent>
<div className="flex items-center justify-between px-6 py-4 border-t">
<Button
variant="outline"
onClick={handleBack}
disabled={currentStep === 1}
className="min-h-[44px]"
>
<ChevronLeft className="h-4 w-4 mr-2" />
Back
</Button>
<div className="flex gap-2">
{currentStep < 4 ? (
<Button
onClick={handleNext}
className="min-h-[44px]"
style={{ backgroundColor: "var(--color-brand-primary)" }}
>
Next
<ChevronRight className="h-4 w-4 ml-2" />
</Button>
) : (
<Button
onClick={handleAssign}
className="min-h-[44px]"
style={{ backgroundColor: "var(--color-brand-primary)" }}
>
Assign
</Button>
)}
</div>
</div>
</Card>
</div>
{renderSummaryCard()}
</div>
</div>
</AuthenticatedLayout>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,120 +1,566 @@
import React from 'react';
import React, { useState } from 'react';
import { AuthenticatedLayout } from '../layout/AuthenticatedLayout';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
import { Button } from '../ui/button';
import { Badge } from '../ui/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../ui/table';
import { Plus, Edit, Eye, Users } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { Input } from '../ui/input';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '../ui/table';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../ui/select';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '../ui/dropdown-menu';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '../ui/sheet';
import { Checkbox } from '../ui/checkbox';
import { toast } from "sonner@2.0.3";
import {
Plus,
Search,
MoreHorizontal,
ExternalLink,
Archive,
RotateCcw,
Clock,
Download,
ChevronLeft,
ChevronRight,
Calendar,
Filter,
Users
} from 'lucide-react';
interface ProgrammesProps {
// onNavigate: (route: string) => void;
onNavigate: (route: string) => void;
onLogout: () => void;
user: any;
}
// Mock data structure matching spec requirements
const mockProgrammes = [
{
id: 1,
name: 'Executive Leadership Programme',
status: 'Published',
updated: '2024-01-15',
owner: 'Dr. Kumar',
duration: '12 weeks',
enrolled: 45
{
id: 1,
name: "Executive Leadership Programme",
status: "Published",
owner: "Dr. Kumar",
enrolments: {
invited: "—",
active: "—",
pending: "—"
},
updated: "2024-01-15 14:30"
},
{
id: 2,
name: 'Digital Transformation Track',
status: 'Draft',
updated: '2024-01-12',
owner: 'Prof. Sharma',
duration: '8 weeks',
enrolled: 0
{
id: 2,
name: "Digital Transformation Track",
status: "Draft",
owner: "Prof. Sharma",
enrolments: {
invited: "—",
active: "—",
pending: "—"
},
updated: "2024-01-12 09:20"
}
];
export function Programmes({ onLogout, user }: ProgrammesProps) {
const breadcrumbs = [{ label: 'Programmes' }];
const navigate = useNavigate();
const statusOptions = [
{ value: "all", label: "All Status" },
{ value: "draft", label: "Draft" },
{ value: "published", label: "Published" },
{ value: "archived", label: "Archived" }
];
const ownerOptions = [
{ value: "all", label: "All Owners" },
{ value: "dr-kumar", label: "Dr. Kumar" },
{ value: "prof-sharma", label: "Prof. Sharma" }
];
export function Programmes({ onNavigate, onLogout, user }: ProgrammesProps) {
const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [ownerFilter, setOwnerFilter] = useState("all");
const [dateRangeFrom, setDateRangeFrom] = useState("");
const [dateRangeTo, setDateRangeTo] = useState("");
const [selectedItems, setSelectedItems] = useState<number[]>([]);
const [isAuditSheetOpen, setIsAuditSheetOpen] = useState(false);
const [auditItem, setAuditItem] = useState<any>(null);
const breadcrumbs = [
{ label: "Admin", href: "/dashboard" },
{ label: "Programmes" }
];
const getStatusBadgeVariant = (status: string) => {
switch (status) {
case "Published": return "default";
case "Draft": return "secondary";
case "Archived": return "destructive";
default: return "secondary";
}
};
const handleRowSelection = (id: number, checked: boolean) => {
if (checked) {
setSelectedItems([...selectedItems, id]);
} else {
setSelectedItems(selectedItems.filter(item => item !== id));
}
};
const handleSelectAll = (checked: boolean) => {
if (checked) {
const allIds = mockProgrammes.map(item => item.id);
setSelectedItems(allIds);
} else {
setSelectedItems([]);
}
};
const handlePublish = (item: any) => {
const action = item.status === "Published" ? "Unpublished" : "Published";
toast.success(`Programme ${action.toLowerCase()} successfully`);
};
const handleArchive = (item: any) => {
toast.success("Programme archived. You can restore it anytime.");
};
const handleRestore = (item: any) => {
toast.success("Programme restored successfully");
};
const handleAudit = (item: any) => {
setAuditItem(item);
setIsAuditSheetOpen(true);
};
const handleBulkAction = (action: string) => {
const count = selectedItems.length;
switch (action) {
case "publish":
toast.success(`${count} programmes published`);
break;
case "unpublish":
toast.success(`${count} programmes unpublished`);
break;
case "archive":
toast.success(`${count} programmes archived`);
break;
case "restore":
toast.success(`${count} programmes restored`);
break;
}
setSelectedItems([]);
};
const handleExport = () => {
toast.success("Export initiated. You'll receive an email when ready.");
};
const filteredProgrammes = mockProgrammes.filter(programme => {
const matchesSearch = programme.name.toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus = statusFilter === "all" || programme.status.toLowerCase() === statusFilter;
const matchesOwner = ownerFilter === "all" || programme.owner.toLowerCase().includes(ownerFilter.replace("-", " "));
return matchesSearch && matchesStatus && matchesOwner;
});
const renderToolbar = () => (
<div className="flex flex-wrap items-center gap-4 mb-6">
{/* Search */}
<div className="flex-1 min-w-[300px]">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search programme name"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
/>
</div>
</div>
{/* Filters */}
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[140px] min-h-[44px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
{statusOptions.map(option => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={ownerFilter} onValueChange={setOwnerFilter}>
<SelectTrigger className="w-[140px] min-h-[44px]">
<SelectValue placeholder="Owner" />
</SelectTrigger>
<SelectContent>
{ownerOptions.map(option => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Date Range */}
<div className="flex items-center gap-2">
<Input
type="date"
value={dateRangeFrom}
onChange={(e) => setDateRangeFrom(e.target.value)}
className="w-[140px] min-h-[44px]"
placeholder="From"
/>
<span className="text-muted-foreground">to</span>
<Input
type="date"
value={dateRangeTo}
onChange={(e) => setDateRangeTo(e.target.value)}
className="w-[140px] min-h-[44px]"
placeholder="To"
/>
</div>
</div>
);
const renderBulkActions = () => {
if (selectedItems.length === 0) return null;
return (
<div className="fixed bottom-6 left-1/2 transform -translate-x-1/2 bg-card border rounded-lg shadow-lg p-4 z-50">
<div className="flex items-center gap-4">
<span className="text-sm font-medium">{selectedItems.length} selected</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleBulkAction("publish")}
>
Bulk Publish
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleBulkAction("unpublish")}
>
Bulk Unpublish
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleBulkAction("archive")}
>
Bulk Archive
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleBulkAction("restore")}
>
Bulk Restore
</Button>
</div>
</div>
</div>
);
};
const renderTable = () => {
if (filteredProgrammes.length === 0) {
return (
<div className="text-center py-12">
<div className="mx-auto w-24 h-24 mb-4 rounded-full bg-muted flex items-center justify-center">
<Plus className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="text-lg font-medium mb-2">No programmes yet create your first one.</h3>
<Button
className="focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
style={{ backgroundColor: "var(--color-brand-primary)" }}
onClick={() => onNavigate("/programmes/new")}
>
<Plus className="h-4 w-4 mr-2" />
New Programme
</Button>
</div>
);
}
return (
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader className="bg-muted/50 sticky top-0">
<TableRow>
<TableHead className="w-12">
<Checkbox
checked={selectedItems.length === filteredProgrammes.length && filteredProgrammes.length > 0}
onCheckedChange={handleSelectAll}
aria-label="Select all programmes"
/>
</TableHead>
<TableHead className="min-w-[250px]">Programme</TableHead>
<TableHead>Status</TableHead>
<TableHead>Owner</TableHead>
<TableHead>Enrolments (Invited / Active / Pending)</TableHead>
<TableHead>Updated</TableHead>
<TableHead className="w-12">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredProgrammes.map((programme) => (
<TableRow key={programme.id} className="min-h-[44px]">
<TableCell>
<Checkbox
checked={selectedItems.includes(programme.id)}
onCheckedChange={(checked) => handleRowSelection(programme.id, checked as boolean)}
aria-label={`Select ${programme.name}`}
/>
</TableCell>
<TableCell>
<button
onClick={() => onNavigate(`/programmes/${programme.id}`)}
className="font-medium text-left hover:underline focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50 rounded"
style={{ color: "var(--color-brand-primary)" }}
>
{programme.name}
</button>
</TableCell>
<TableCell>
<Badge variant={getStatusBadgeVariant(programme.status)}>
{programme.status}
</Badge>
</TableCell>
<TableCell>{programme.owner}</TableCell>
<TableCell>
<span className="text-sm text-muted-foreground">
{programme.enrolments.invited} / {programme.enrolments.active} / {programme.enrolments.pending}
</span>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{programme.updated}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => onNavigate(`/programmes/${programme.id}`)}
>
<ExternalLink className="h-4 w-4 mr-2" />
Open
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onNavigate(`/programmes/${programme.id}/assign`)}
>
<Users className="h-4 w-4 mr-2" />
Assign
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handlePublish(programme)}>
<ExternalLink className="h-4 w-4 mr-2" />
{programme.status === "Published" ? "Unpublish" : "Publish"}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleArchive(programme)}>
<Archive className="h-4 w-4 mr-2" />
Archive
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleRestore(programme)}>
<RotateCcw className="h-4 w-4 mr-2" />
Restore
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleAudit(programme)}>
<Clock className="h-4 w-4 mr-2" />
Audit
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{/* Pagination Footer */}
<div className="flex items-center justify-between px-6 py-4 border-t bg-muted/25">
<div className="text-sm text-muted-foreground">
Showing {filteredProgrammes.length} of {mockProgrammes.length} programmes
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled
className="min-h-[44px] w-[44px] p-0"
>
<ChevronLeft className="h-4 w-4" />
<span className="sr-only">Previous page</span>
</Button>
<Button
variant="outline"
size="sm"
disabled
className="min-h-[44px] w-[44px] p-0"
>
<ChevronRight className="h-4 w-4" />
<span className="sr-only">Next page</span>
</Button>
</div>
</div>
</div>
);
};
const renderAuditSheet = () => {
const auditData = [
{
id: 1,
action: "Created",
actor: "Dr. Kumar",
timestamp: "2024-01-15 14:30:22",
details: "Programme created with initial configuration"
},
{
id: 2,
action: "Published",
actor: "Prof. Sharma",
timestamp: "2024-01-15 15:45:12",
details: "Programme published after review and approval"
},
{
id: 3,
action: "Updated",
actor: "Dr. Kumar",
timestamp: "2024-01-16 09:20:33",
details: "Course lineup modified and timeline adjusted"
}
];
return (
<Sheet open={isAuditSheetOpen} onOpenChange={setIsAuditSheetOpen}>
<SheetContent className="w-[480px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50">
<SheetHeader>
<SheetTitle>Audit Trail</SheetTitle>
<SheetDescription>
{auditItem ? `Complete history for "${auditItem.name}"` : "Programme audit history"}
</SheetDescription>
</SheetHeader>
<div className="mt-6 space-y-4">
{auditData.map((entry) => (
<div key={entry.id} className="border-l-2 border-muted pl-4 pb-4">
<div className="flex items-center gap-2 mb-1">
<Badge variant="outline">{entry.action}</Badge>
<span className="text-sm text-muted-foreground">
by {entry.actor}
</span>
</div>
<div className="text-xs text-muted-foreground mb-1">
{entry.timestamp}
</div>
<p className="text-sm">{entry.details}</p>
</div>
))}
</div>
</SheetContent>
</Sheet>
);
};
return (
<AuthenticatedLayout
currentRoute="/programmes"
// onNavigate={onNavigate}
onNavigate={onNavigate}
onLogout={onLogout}
user={user}
breadcrumbs={breadcrumbs}
>
<div className="p-6 space-y-6">
<div className="p-6 space-y-6 max-w-[1440px] mx-auto">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-semibold">Programmes</h1>
<p className="text-muted-foreground">Structured learning programmes with courses, webinars, and assessments</p>
<h1>Programmes</h1>
<p className="text-muted-foreground">
Manage learning programmes with courses, assessments, and scheduling
</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={handleExport}
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
>
<Download className="h-4 w-4 mr-2" />
Export
</Button>
<Button
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
style={{ backgroundColor: "var(--color-brand-primary)" }}
onClick={() => onNavigate("/programmes/new")}
>
<Plus className="h-4 w-4 mr-2" />
New Programme
</Button>
</div>
<Button
style={{ backgroundColor: 'var(--color-brand-primary)' }}
onClick={() => navigate('/programmes/new')}
>
<Plus className="h-4 w-4 mr-2" />
Create Programme
</Button>
</div>
{/* Filters/Toolbar */}
{renderToolbar()}
{/* Table Card */}
<Card>
<CardHeader>
<CardTitle>Programme List</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Programme Name</TableHead>
<TableHead>Duration</TableHead>
<TableHead>Status</TableHead>
<TableHead>Enrolled</TableHead>
<TableHead>Owner</TableHead>
<TableHead>Updated</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{mockProgrammes.map((programme) => (
<TableRow key={programme.id}>
<TableCell className="font-medium">{programme.name}</TableCell>
<TableCell>{programme.duration}</TableCell>
<TableCell>
<Badge variant={programme.status === 'Published' ? 'default' : 'secondary'}>
{programme.status}
</Badge>
</TableCell>
<TableCell>
<div className="flex items-center">
<Users className="h-4 w-4 mr-1 text-muted-foreground" />
{programme.enrolled}
</div>
</TableCell>
<TableCell>{programme.owner}</TableCell>
<TableCell>{programme.updated}</TableCell>
<TableCell>
<div className="flex space-x-2">
<Button variant="ghost" size="sm">
<Edit className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm">
<Eye className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm">
<Users className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<CardContent className="p-0">
{renderTable()}
</CardContent>
</Card>
{/* Bulk Actions */}
{renderBulkActions()}
{/* Audit Sheet */}
{renderAuditSheet()}
{/* Toast area for system messages */}
<div
role="status"
aria-live="polite"
aria-label="System status messages"
className="sr-only"
>
{/* Toast messages will appear here via the toast system */}
</div>
</div>
</AuthenticatedLayout>
);

View File

@@ -1,22 +1,37 @@
import React, { useState } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { AuthenticatedLayout } from '../layout/AuthenticatedLayout';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Alert, AlertDescription } from '../ui/alert';
import { Eye, EyeOff, CheckCircle, Clock } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { toast } from "sonner@2.0.3";
import {
Eye,
EyeOff,
CheckCircle,
ArrowLeft,
Mail,
Lock,
AlertCircle,
Loader2
} from 'lucide-react';
interface ResetPasswordProps {
// onNavigate: (route: string) => void;
onNavigate: (route: string) => void;
onLogout: () => void;
user: any;
user: {
name: string;
email: string;
role: string;
avatar?: string;
lastLogin: string;
};
}
type Step = 'confirm' | 'verify' | 'newPassword' | 'done';
type Step = 'confirm' | 'verify' | 'newPassword' | 'success';
export function ResetPassword({ onLogout, user }: ResetPasswordProps) {
export function ResetPassword({ onNavigate, onLogout, user }: ResetPasswordProps) {
const [currentStep, setCurrentStep] = useState<Step>('confirm');
const [verificationCode, setVerificationCode] = useState(['', '', '', '', '', '']);
const [newPassword, setNewPassword] = useState('');
@@ -26,94 +41,225 @@ export function ResetPassword({ onLogout, user }: ResetPasswordProps) {
const [isLoading, setIsLoading] = useState(false);
const [resendCooldown, setResendCooldown] = useState(0);
const [error, setError] = useState('');
const navigate = useNavigate();
const [attempts, setAttempts] = useState(0);
const otpRefs = useRef<(HTMLInputElement | null)[]>([]);
React.useEffect(() => {
const breadcrumbs = [
{ label: "Admin", href: "/dashboard" },
{ label: "Profile", href: "/profile" },
{ label: "Reset Password" }
];
// Cooldown timer effect
useEffect(() => {
if (resendCooldown > 0) {
const timer = setTimeout(() => setResendCooldown(resendCooldown - 1), 1000);
return () => clearTimeout(timer);
}
}, [resendCooldown]);
// Focus management for OTP inputs
useEffect(() => {
if (currentStep === 'verify' && otpRefs.current[0]) {
otpRefs.current[0].focus();
}
}, [currentStep]);
// Step 1: Send Code
const handleSendCode = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
// Simulate API call
setTimeout(() => {
setCurrentStep('verify');
setIsLoading(false);
setResendCooldown(60);
}, 1000);
toast.success("Code sent.");
}, 1200);
};
// Step 2: Verify Code
const handleVerifyCode = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
const code = verificationCode.join('');
if (code.length !== 6) {
setError('Please enter the complete 6-digit code');
setIsLoading(false);
return;
}
setIsLoading(true);
// Simulate API call with different scenarios
setTimeout(() => {
if (code === '123456') {
if (code === '000000') {
setError('Code expired.');
} else if (code === '999999') {
const newAttempts = attempts + 1;
setAttempts(newAttempts);
if (newAttempts >= 3) {
setError('Too many attempts. Please request a new code.');
} else {
setError('Incorrect code.');
}
setVerificationCode(['', '', '', '', '', '']);
if (otpRefs.current[0]) {
otpRefs.current[0].focus();
}
} else if (code === '123456') {
setCurrentStep('newPassword');
// Focus new password field
setTimeout(() => {
document.getElementById('new-password')?.focus();
}, 100);
} else {
setError('Invalid verification code. Please try again.');
const newAttempts = attempts + 1;
setAttempts(newAttempts);
setError('Incorrect code.');
setVerificationCode(['', '', '', '', '', '']);
if (otpRefs.current[0]) {
otpRefs.current[0].focus();
}
}
setIsLoading(false);
}, 1000);
}, 1200);
};
// Step 3: Set New Password
const handleSetNewPassword = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
// Password validation
if (newPassword.length < 8) {
setError('Password must be at least 8 characters long');
return;
}
if (newPassword !== confirmPassword) {
setError('Passwords do not match');
return;
}
if (newPassword.length < 8) {
setError('Password must be at least 8 characters long');
// Additional password strength validation
const hasUpperCase = /[A-Z]/.test(newPassword);
const hasLowerCase = /[a-z]/.test(newPassword);
const hasNumbers = /\d/.test(newPassword);
if (!hasUpperCase || !hasLowerCase || !hasNumbers) {
setError('Password must contain uppercase, lowercase, and numeric characters');
return;
}
setIsLoading(true);
// Simulate API call
setTimeout(() => {
setCurrentStep('done');
setCurrentStep('success');
setIsLoading(false);
}, 1000);
toast.success("Password updated.");
// Clear sensitive inputs
setNewPassword('');
setConfirmPassword('');
setVerificationCode(['', '', '', '', '', '']);
}, 1500);
};
// OTP Input handling
const handleCodeInput = (index: number, value: string) => {
// Clear errors when user starts typing
if (error) {
setError('');
}
// Handle paste - distribute "123456" across all inputs
if (value.length > 1) {
const pastedCode = value.slice(0, 6).split('');
const newCode = [...verificationCode];
pastedCode.forEach((char, i) => {
if (index + i < 6) newCode[index + i] = char;
if (index + i < 6 && /^\d$/.test(char)) {
newCode[index + i] = char;
}
});
setVerificationCode(newCode);
// Focus the last filled input or next empty one
const lastIndex = Math.min(index + pastedCode.length - 1, 5);
const nextEmptyIndex = newCode.findIndex((char, i) => i > lastIndex && char === '');
const focusIndex = nextEmptyIndex !== -1 ? nextEmptyIndex : lastIndex;
if (otpRefs.current[focusIndex]) {
otpRefs.current[focusIndex]?.focus();
}
return;
}
const newCode = [...verificationCode];
newCode[index] = value;
setVerificationCode(newCode);
// Handle single character input (digits only)
if (/^\d$/.test(value) || value === '') {
const newCode = [...verificationCode];
newCode[index] = value;
setVerificationCode(newCode);
if (value && index < 5) {
const nextInput = document.getElementById(`code-${index + 1}`);
nextInput?.focus();
// Auto-advance to next input
if (value && index < 5 && otpRefs.current[index + 1]) {
otpRefs.current[index + 1]?.focus();
}
}
};
const handleCodeKeyDown = (index: number, e: React.KeyboardEvent) => {
// Backspace navigation
if (e.key === 'Backspace' && !verificationCode[index] && index > 0) {
otpRefs.current[index - 1]?.focus();
}
};
const handleResendCode = () => {
if (resendCooldown === 0) {
if (resendCooldown === 0 && attempts < 5) {
setResendCooldown(60);
setAttempts(0);
setError('');
setVerificationCode(['', '', '', '', '', '']);
// Focus first input after resend
if (otpRefs.current[0]) {
otpRefs.current[0].focus();
}
toast.success("Code sent.");
}
};
const handleUseDifferentEmail = () => {
// Return to Step 1 (email is still read-only as per spec)
setCurrentStep('confirm');
setError('');
setVerificationCode(['', '', '', '', '', '']);
setAttempts(0);
setResendCooldown(0);
};
const getMaskedEmail = () => {
const [localPart, domain] = user.email.split('@');
const maskedLocal = localPart.length > 2
? localPart[0] + '***' + localPart.slice(-1)
: localPart[0] + '***';
return `${maskedLocal}@${domain}`;
};
const getStepTitle = () => {
switch (currentStep) {
case 'confirm': return 'Reset Your Password';
case 'verify': return 'Enter Verification Code';
case 'newPassword': return 'Set New Password';
case 'success': return 'Password Reset Complete';
}
};
@@ -121,62 +267,79 @@ export function ResetPassword({ onLogout, user }: ResetPasswordProps) {
switch (currentStep) {
case 'confirm':
return (
<form onSubmit={handleSendCode} className="space-y-4">
<div className="p-4 bg-muted rounded-lg">
<p className="text-sm text-muted-foreground">
We'll send a 10-minute verification code to your email address.
You can resend the code after 60 seconds if needed.
</p>
</div>
<form onSubmit={handleSendCode} className="space-y-6">
<div className="space-y-2">
<Label htmlFor="email">Confirm Email Address</Label>
<Input
id="email"
type="email"
value={user.email}
readOnly
className="bg-muted min-h-[44px]"
/>
<Label htmlFor="email">Email</Label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="email"
type="email"
value={user.email}
readOnly
className="pl-10 bg-muted min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
/>
</div>
<p className="text-sm text-muted-foreground">
A 6-digit code will be sent to your email.
</p>
</div>
<Button
type="submit"
className="w-full min-h-[44px]"
className="w-full min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
disabled={isLoading}
style={{ backgroundColor: 'var(--color-brand-primary)' }}
>
{isLoading ? 'Sending Code...' : 'Send Code'}
{isLoading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Sending code...
</>
) : (
'Send code'
)}
</Button>
</form>
);
case 'verify':
return (
<form onSubmit={handleVerifyCode} className="space-y-4">
<form onSubmit={handleVerifyCode} className="space-y-6">
<div className="text-center">
<p className="text-sm text-muted-foreground">
Enter the 6-digit code sent to <strong>{getMaskedEmail()}</strong>. Code expires in <strong>10 minutes</strong>.
</p>
</div>
{/* Error Alert */}
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{/* OTP Input Fields */}
<div className="space-y-2">
<Label>Enter the 6-digit code</Label>
<div className="flex justify-center space-x-2">
<Label className="sr-only">6-digit verification code</Label>
<div className="flex justify-center gap-2" role="group" aria-labelledby="otp-label">
<span id="otp-label" className="sr-only">Enter 6-digit verification code</span>
{verificationCode.map((digit, index) => (
<Input
key={index}
id={`code-${index}`}
ref={(el) => (otpRefs.current[index] = el)}
type="text"
inputMode="numeric"
maxLength={6}
value={digit}
onChange={(e) => handleCodeInput(index, e.target.value)}
className="w-12 h-12 text-center font-mono text-lg"
onKeyDown={(e) => {
if (e.key === 'Backspace' && !digit && index > 0) {
document.getElementById(`code-${index - 1}`)?.focus();
}
}}
onKeyDown={(e) => handleCodeKeyDown(index, e)}
className="w-12 h-12 text-center font-mono text-lg focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
aria-label={`Digit ${index + 1} of 6`}
aria-describedby={error ? "code-error" : undefined}
aria-invalid={!!error}
disabled={attempts >= 3}
/>
))}
</div>
@@ -184,23 +347,49 @@ export function ResetPassword({ onLogout, user }: ResetPasswordProps) {
<Button
type="submit"
className="w-full min-h-[44px]"
disabled={isLoading}
className="w-full min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
disabled={isLoading || verificationCode.join('').length !== 6 || attempts >= 3}
style={{ backgroundColor: 'var(--color-brand-primary)' }}
>
{isLoading ? 'Verifying...' : 'Verify Code'}
{isLoading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Verifying...
</>
) : (
'Verify'
)}
</Button>
<div className="text-center">
{/* Secondary Actions */}
<div className="flex justify-center gap-4 text-sm">
<Button
type="button"
variant="link"
onClick={handleResendCode}
disabled={resendCooldown > 0}
className="text-sm"
disabled={resendCooldown > 0 || attempts >= 5}
className="p-0 h-auto min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
style={{ color: 'var(--color-brand-primary)' }}
aria-live="polite"
>
{resendCooldown > 0
? `Resend code (${resendCooldown}s)`
: attempts >= 5
? 'Too many requests'
: 'Resend code'
}
</Button>
<span className="text-muted-foreground"></span>
<Button
type="button"
variant="link"
onClick={handleUseDifferentEmail}
className="p-0 h-auto min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
style={{ color: 'var(--color-brand-primary)' }}
>
{resendCooldown > 0 ? `Resend in ${resendCooldown}s` : 'Resend code'}
Use a different email
</Button>
</div>
</form>
@@ -208,89 +397,113 @@ export function ResetPassword({ onLogout, user }: ResetPasswordProps) {
case 'newPassword':
return (
<form onSubmit={handleSetNewPassword} className="space-y-4">
<form onSubmit={handleSetNewPassword} className="space-y-6">
{/* Error Alert */}
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{/* New Password */}
<div className="space-y-2">
<Label htmlFor="new-password">New Password</Label>
<Label htmlFor="new-password">New password</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="new-password"
type={showNewPassword ? 'text' : 'password'}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
onChange={(e) => {
setNewPassword(e.target.value);
if (error) setError('');
}}
placeholder="Enter new password"
required
className="min-h-[44px] pr-12"
className="pl-10 pr-12 min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
aria-describedby={error ? "password-error" : "password-help"}
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
onClick={() => setShowNewPassword(!showNewPassword)}
aria-label={showNewPassword ? "Hide password" : "Show password"}
>
{showNewPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
</div>
{/* Confirm Password */}
<div className="space-y-2">
<Label htmlFor="confirm-password">Confirm New Password</Label>
<Label htmlFor="confirm-password">Confirm password</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="confirm-password"
type={showConfirmPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
onChange={(e) => {
setConfirmPassword(e.target.value);
if (error) setError('');
}}
placeholder="Confirm new password"
required
className="min-h-[44px] pr-12"
className="pl-10 pr-12 min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
aria-describedby={error ? "password-error" : "password-help"}
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
aria-label={showConfirmPassword ? "Hide password" : "Show password"}
>
{showConfirmPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
</div>
{/* Password Rules */}
<div id="password-help" className="text-sm text-muted-foreground">
Password must be at least 8 characters with uppercase, lowercase, and numbers.
</div>
<Button
type="submit"
className="w-full min-h-[44px]"
disabled={isLoading}
className="w-full min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
disabled={isLoading || !newPassword || !confirmPassword}
style={{ backgroundColor: 'var(--color-brand-primary)' }}
>
{isLoading ? 'Setting Password...' : 'Set New Password'}
{isLoading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Updating password...
</>
) : (
'Update password'
)}
</Button>
</form>
);
case 'done':
case 'success':
return (
<div className="text-center space-y-4">
<div className="text-center space-y-6">
<CheckCircle className="h-16 w-16 mx-auto text-green-500" />
<div>
<h3 className="font-medium">Password Reset Successful</h3>
<p className="text-sm text-muted-foreground mt-2">
Your password has been reset successfully.
</p>
<p className="text-sm text-muted-foreground mt-2">
For security, you have been signed out of all other sessions.
Other sessions will need to sign in again with the new password.
<div className="space-y-2">
<h3 className="font-medium">Password updated.</h3>
<p className="text-sm text-muted-foreground">
All other sessions have been signed out.
</p>
</div>
<Button
onClick={() => navigate('/profile')}
className="min-h-[44px]"
onClick={() => onNavigate('/profile')}
className="min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
style={{ backgroundColor: 'var(--color-brand-primary)' }}
>
Return to Profile
@@ -300,103 +513,58 @@ export function ResetPassword({ onLogout, user }: ResetPasswordProps) {
}
};
const getStepTitle = () => {
switch (currentStep) {
case 'confirm': return 'Reset Your Password';
case 'verify': return 'Enter Verification Code';
case 'newPassword': return 'Set New Password';
case 'done': return 'Password Reset Complete';
}
};
const getStepDescription = () => {
switch (currentStep) {
case 'confirm': return 'We\'ll send you a verification code to reset your password';
case 'verify': return 'Check your email for the 6-digit verification code';
case 'newPassword': return 'Choose a strong new password for your account';
case 'done': return 'Your password has been successfully reset';
}
};
const breadcrumbs = [
{ label: 'My Profile', route: '/profile' },
{ label: 'Reset Password' }
];
return (
<AuthenticatedLayout
currentRoute="/profile/reset-password"
// onNavigate={onNavigate}
onNavigate={onNavigate}
onLogout={onLogout}
user={user}
breadcrumbs={breadcrumbs}
>
<div className="p-6 max-w-2xl mx-auto">
<Card className="border-2">
<CardHeader>
<CardTitle>{getStepTitle()}</CardTitle>
<CardDescription>{getStepDescription()}</CardDescription>
<div className="p-6 max-w-md mx-auto">
{/* Centered Security Card */}
<Card className="shadow-lg">
<CardHeader className="relative">
<div className="flex items-center justify-between">
<CardTitle>{getStepTitle()}</CardTitle>
{/* Back to Profile Link - Top-right of card */}
<Button
variant="ghost"
size="sm"
onClick={() => onNavigate('/profile')}
className="text-sm min-h-[44px] focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-opacity-50"
style={{ color: 'var(--color-brand-primary)' }}
>
<ArrowLeft className="h-4 w-4 mr-1" />
Back to Profile
</Button>
</div>
</CardHeader>
<CardContent>
{renderStepContent()}
{currentStep !== 'done' && (
<div className="mt-6 text-center">
<Button
variant="link"
onClick={() => navigate('/profile')}
className="text-sm"
style={{ color: 'var(--color-brand-primary)' }}
>
Back to Profile
</Button>
</div>
)}
</CardContent>
</Card>
{/* Security Guidelines */}
{currentStep === 'newPassword' && (
<Card className="mt-6">
<CardHeader>
<CardTitle className="text-base">Password Security Guidelines</CardTitle>
</CardHeader>
<CardContent>
<ul className="text-sm text-muted-foreground space-y-2">
<li className="flex items-start">
<CheckCircle className="h-4 w-4 text-green-500 mr-2 mt-0.5 flex-shrink-0" />
Use at least 8 characters
</li>
<li className="flex items-start">
<CheckCircle className="h-4 w-4 text-green-500 mr-2 mt-0.5 flex-shrink-0" />
Include uppercase and lowercase letters
</li>
<li className="flex items-start">
<CheckCircle className="h-4 w-4 text-green-500 mr-2 mt-0.5 flex-shrink-0" />
Include numbers and special characters
</li>
<li className="flex items-start">
<CheckCircle className="h-4 w-4 text-green-500 mr-2 mt-0.5 flex-shrink-0" />
Avoid using personal information
</li>
<li className="flex items-start">
<CheckCircle className="h-4 w-4 text-green-500 mr-2 mt-0.5 flex-shrink-0" />
Don't reuse passwords from other accounts
</li>
</ul>
</CardContent>
</Card>
)}
{/* Expiration Notice */}
{currentStep === 'verify' && (
<Alert className="mt-6">
<Clock className="h-4 w-4" />
<AlertDescription>
The verification code expires in 10 minutes. You can request a new code after 60 seconds.
</AlertDescription>
</Alert>
)}
{/* Toast area for system messages */}
<div
role="status"
aria-live="polite"
aria-label="System status messages"
className="sr-only"
/>
{/* Screen reader announcements */}
<div
role="status"
aria-live="polite"
aria-label="Form status"
className="sr-only"
>
{isLoading && "Processing request, please wait"}
{error && `Error: ${error}`}
{resendCooldown > 0 && `Resend available in ${resendCooldown} seconds`}
</div>
</div>
</AuthenticatedLayout>
);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,953 @@
import React, { useState, useEffect } from 'react';
import { AuthenticatedLayout } from '../layout/AuthenticatedLayout';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
import { Button } from '../ui/button';
import { Badge } from '../ui/badge';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Textarea } from '../ui/textarea';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '../ui/table';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../ui/select';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '../ui/dropdown-menu';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '../ui/dialog';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '../ui/sheet';
import { Checkbox } from '../ui/checkbox';
import { Separator } from '../ui/separator';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip';
import { toast } from "sonner@2.0.3";
import {
Plus,
Search,
MoreHorizontal,
Edit,
Settings,
Copy,
Trash2,
ArrowUpDown,
FileText,
Target,
CheckCircle,
XCircle,
AlertCircle,
Save,
X,
Info
} from 'lucide-react';
interface SectionConfigurationManagerProps {
onNavigate: (route: string) => void;
onLogout: () => void;
user: any;
}
// Mock section configuration templates
const sectionConfigTemplates = [
{
id: "template_leadership_360",
name: "Leadership 360 - Standard",
description: "Standard 360-degree leadership assessment configuration",
questionType: "Ipsative",
sections: [
{ name: "Strategic Thinking", numberOfStatements: 4, order: 1 },
{ name: "Communication Skills", numberOfStatements: 3, order: 2 },
{ name: "Team Leadership", numberOfStatements: 5, order: 3 },
{ name: "Results Orientation", numberOfStatements: 4, order: 4 }
],
usage: 12,
createdBy: "Dr. Rajesh Mehta",
createdAt: "2025-08-01T10:00:00Z"
},
{
id: "template_communication_likert",
name: "Communication Assessment - Likert",
description: "Comprehensive communication skills assessment using Likert scales",
questionType: "Likert",
sections: [
{ name: "Verbal Communication", numberOfOptions: 5, order: 1 },
{ name: "Written Communication", numberOfOptions: 7, order: 2 },
{ name: "Active Listening", numberOfOptions: 5, order: 3 },
{ name: "Non-verbal Communication", numberOfOptions: 5, order: 4 }
],
usage: 8,
createdBy: "Prof. Priya Sinha",
createdAt: "2025-08-05T14:30:00Z"
},
{
id: "template_innovation_tf",
name: "Innovation Mindset - True/False",
description: "Innovation capabilities assessment using binary choices",
questionType: "True/False",
sections: [
{ name: "Creative Thinking", order: 1 },
{ name: "Risk Taking", order: 2 },
{ name: "Problem Solving", order: 3 },
{ name: "Adaptability", order: 4 }
],
usage: 5,
createdBy: "Dr. Amit Sharma",
createdAt: "2025-08-10T09:15:00Z"
},
{
id: "template_strategy_matching",
name: "Strategic Thinking - Matching",
description: "Strategic capabilities assessment using matching exercises",
questionType: "Matching",
sections: [
{ name: "Strategic Analysis", order: 1 },
{ name: "Strategy Formulation", order: 2 },
{ name: "Strategy Implementation", order: 3 }
],
usage: 3,
createdBy: "Prof. Sunita Agarwal",
createdAt: "2025-08-12T16:45:00Z"
},
{
id: "template_culture_descriptive",
name: "Team Culture Survey - Descriptive",
description: "Open-ended team culture and engagement assessment",
questionType: "Descriptive",
sections: [
{ name: "Culture Anchors", order: 1 },
{ name: "Core Values", order: 2 },
{ name: "Team Communication", order: 3 },
{ name: "Collaboration", order: 4 },
{ name: "Employee Engagement", order: 5 }
],
usage: 7,
createdBy: "Dr. Neha Shah",
createdAt: "2025-08-15T11:20:00Z"
}
];
// Section configuration rules based on question type
const questionTypeConfig = {
"Ipsative": {
label: "Ipsative (Forced Choice)",
description: "Users choose between ranked statements",
configField: "numberOfStatements",
configLabel: "Number of Statements",
defaultValue: 4,
minValue: 2,
maxValue: 10,
helpText: "Statements users will rank in order of preference"
},
"Likert": {
label: "Likert Scale",
description: "Users rate items on a scale",
configField: "numberOfOptions",
configLabel: "Scale Points",
defaultValue: 5,
minValue: 3,
maxValue: 10,
helpText: "Number of points on the rating scale (e.g., 1-5, 1-7)"
},
"True/False": {
label: "True/False",
description: "Binary choice questions",
configField: null,
configLabel: "No additional configuration",
defaultValue: null,
helpText: "Binary questions don't require additional configuration"
},
"Matching": {
label: "Matching Exercise",
description: "Match items from different lists",
configField: null,
configLabel: "No additional configuration",
defaultValue: null,
helpText: "Matching exercises are configured per question"
},
"Descriptive": {
label: "Open Text",
description: "Free-form text responses",
configField: null,
configLabel: "No additional configuration",
defaultValue: null,
helpText: "Descriptive questions don't require section-level configuration"
}
};
export function SectionConfigurationManager({ onNavigate, onLogout, user }: SectionConfigurationManagerProps) {
// State management
const [searchTerm, setSearchTerm] = useState("");
const [questionTypeFilter, setQuestionTypeFilter] = useState("all");
const [selectedTemplates, setSelectedTemplates] = useState<string[]>([]);
// Dialog and drawer states
const [isCreateTemplateOpen, setIsCreateTemplateOpen] = useState(false);
const [isEditTemplateOpen, setIsEditTemplateOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isCloneTemplateOpen, setIsCloneTemplateOpen] = useState(false);
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
// Template management states
const [editingTemplate, setEditingTemplate] = useState<any>(null);
const [deletingTemplate, setDeletingTemplate] = useState<any>(null);
const [cloningTemplate, setCloningTemplate] = useState<any>(null);
// Form states
const [newTemplate, setNewTemplate] = useState({
name: "",
description: "",
questionType: "",
sections: []
});
const [editSections, setEditSections] = useState<any[]>([]);
const breadcrumbs = [
{ label: "Admin", href: "/dashboard" },
{ label: "Content", href: "/content" },
{ label: "Profilers", href: "/profilers" },
{ label: "Section Configuration" }
];
// Filter templates
const filteredTemplates = sectionConfigTemplates.filter(template => {
const matchesSearch =
template.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
template.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
template.createdBy.toLowerCase().includes(searchTerm.toLowerCase());
const matchesQuestionType = questionTypeFilter === "all" || template.questionType === questionTypeFilter;
return matchesSearch && matchesQuestionType;
});
// Question type filter options
const questionTypeOptions = [
{ value: "all", label: "All Question Types", count: sectionConfigTemplates.length },
{ value: "Ipsative", label: "Ipsative", count: sectionConfigTemplates.filter(t => t.questionType === "Ipsative").length },
{ value: "Likert", label: "Likert", count: sectionConfigTemplates.filter(t => t.questionType === "Likert").length },
{ value: "True/False", label: "True/False", count: sectionConfigTemplates.filter(t => t.questionType === "True/False").length },
{ value: "Matching", label: "Matching", count: sectionConfigTemplates.filter(t => t.questionType === "Matching").length },
{ value: "Descriptive", label: "Descriptive", count: sectionConfigTemplates.filter(t => t.questionType === "Descriptive").length }
];
// Utility functions
const getQuestionTypeConfig = (questionType: string) => {
return questionTypeConfig[questionType as keyof typeof questionTypeConfig] || questionTypeConfig["True/False"];
};
const addSection = () => {
const config = getQuestionTypeConfig(newTemplate.questionType);
const newSection: any = {
id: `section_${Date.now()}`,
name: "",
order: editSections.length + 1
};
if (config.configField) {
newSection[config.configField] = config.defaultValue;
}
setEditSections([...editSections, newSection]);
};
const updateSection = (index: number, field: string, value: any) => {
const updated = [...editSections];
updated[index] = { ...updated[index], [field]: value };
setEditSections(updated);
};
const removeSection = (index: number) => {
const updated = editSections.filter((_, i) => i !== index);
// Reorder sections
const reordered = updated.map((section, i) => ({ ...section, order: i + 1 }));
setEditSections(reordered);
};
const moveSection = (index: number, direction: 'up' | 'down') => {
if ((direction === 'up' && index === 0) || (direction === 'down' && index === editSections.length - 1)) {
return;
}
const updated = [...editSections];
const targetIndex = direction === 'up' ? index - 1 : index + 1;
// Swap sections
[updated[index], updated[targetIndex]] = [updated[targetIndex], updated[index]];
// Update order
updated[index].order = index + 1;
updated[targetIndex].order = targetIndex + 1;
setEditSections(updated);
};
// Action handlers
const handleCreateTemplate = () => {
setNewTemplate({
name: "",
description: "",
questionType: "",
sections: []
});
setEditSections([]);
setIsCreateTemplateOpen(true);
};
const handleEditTemplate = (template: any) => {
setEditingTemplate(template);
setNewTemplate({
name: template.name,
description: template.description,
questionType: template.questionType,
sections: template.sections
});
setEditSections([...template.sections]);
setIsEditTemplateOpen(true);
};
const handleCloneTemplate = (template: any) => {
setCloningTemplate(template);
setNewTemplate({
name: `${template.name} (Copy)`,
description: template.description,
questionType: template.questionType,
sections: template.sections
});
setEditSections([...template.sections]);
setIsCloneTemplateOpen(true);
};
const handleDeleteTemplate = (template: any) => {
setDeletingTemplate(template);
setIsDeleteDialogOpen(true);
};
const saveTemplate = () => {
if (!newTemplate.name.trim()) {
toast.error("Template name is required");
return;
}
if (!newTemplate.questionType) {
toast.error("Question type is required");
return;
}
if (editSections.length === 0) {
toast.error("At least one section is required");
return;
}
// Validate sections
for (const section of editSections) {
if (!section.name.trim()) {
toast.error("All sections must have names");
return;
}
}
const templateData = {
...newTemplate,
sections: editSections
};
if (isEditTemplateOpen) {
toast.success(`Template "${newTemplate.name}" updated successfully`);
setIsEditTemplateOpen(false);
} else {
toast.success(`Template "${newTemplate.name}" created successfully`);
setIsCreateTemplateOpen(false);
setIsCloneTemplateOpen(false);
}
// Reset form
setNewTemplate({ name: "", description: "", questionType: "", sections: [] });
setEditSections([]);
};
const confirmDelete = () => {
if (deletingTemplate) {
toast.success(`Template "${deletingTemplate.name}" deleted successfully`);
setIsDeleteDialogOpen(false);
setDeletingTemplate(null);
}
};
const handleBulkDelete = () => {
toast.success(`${selectedTemplates.length} templates deleted successfully`);
setSelectedTemplates([]);
setIsBulkDeleteDialogOpen(false);
};
// Filter chip component
const FilterChip = ({ label, count, active, onClick }: { label: string; count?: number; active: boolean; onClick: () => void }) => (
<button
onClick={onClick}
className={`inline-flex items-center gap-2 px-3 py-1.5 rounded-full border transition-colors min-h-[44px] ${
active
? 'bg-[var(--color-brand-primary)] text-white border-[var(--color-brand-primary)]'
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
}`}
>
{label}
{count !== undefined && (
<span className={`px-1.5 py-0.5 rounded-full text-xs ${
active ? 'bg-white/20 text-white' : 'bg-gray-100 text-gray-600'
}`}>
{count}
</span>
)}
</button>
);
return (
<AuthenticatedLayout
currentRoute="/admin/section-configuration"
user={user}
onNavigate={onNavigate}
onLogout={onLogout}
breadcrumbs={breadcrumbs}
>
<div className="p-6 max-w-7xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-[var(--color-brand-black)]">Section Configuration Templates</h1>
<p className="text-gray-600 mt-1">
Manage reusable section configurations for profiler assessments
</p>
</div>
<div className="flex items-center gap-3">
<Button
variant="outline"
onClick={() => onNavigate('/profilers')}
className="min-h-[44px]"
>
Back to Profilers
</Button>
<Button
onClick={handleCreateTemplate}
className="min-h-[44px]"
style={{ backgroundColor: 'var(--color-brand-primary)' }}
>
<Plus className="h-4 w-4 mr-2" />
New Template
</Button>
</div>
</div>
{/* Filter Bar */}
<Card>
<CardContent className="p-6 space-y-4">
{/* Search */}
<div className="flex items-center gap-4">
<div className="flex-1 min-w-[300px]">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
placeholder="Search templates by name, description, or creator"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 min-h-[44px]"
/>
</div>
</div>
</div>
{/* Filter Chips */}
<div>
<Label className="text-gray-700 mb-2 block">Question Type</Label>
<div className="flex flex-wrap gap-2">
{questionTypeOptions.map(option => (
<FilterChip
key={option.value}
label={option.label}
count={option.count}
active={questionTypeFilter === option.value}
onClick={() => setQuestionTypeFilter(option.value)}
/>
))}
</div>
</div>
</CardContent>
</Card>
{/* Bulk Action Bar */}
{selectedTemplates.length > 0 && (
<Card className="border-[var(--color-brand-primary)]">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span>
{selectedTemplates.length} template{selectedTemplates.length > 1 ? 's' : ''} selected
</span>
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedTemplates([])}
className="text-gray-500"
>
Clear selection
</Button>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setIsBulkDeleteDialogOpen(true)}
className="min-h-[44px] text-red-600 hover:text-red-700"
>
<Trash2 className="h-4 w-4 mr-2" />
Delete Selected
</Button>
</div>
</div>
</CardContent>
</Card>
)}
{/* Templates Table */}
{filteredTemplates.length === 0 ? (
<Card>
<CardContent className="p-12">
<div className="text-center">
<Settings className="h-12 w-12 mx-auto mb-4 text-gray-400" />
<h3 className="mb-2">No section configuration templates found</h3>
<p className="text-gray-600 mb-6">
Create reusable section configurations to standardize your profiler assessments.
</p>
<Button
onClick={handleCreateTemplate}
style={{ backgroundColor: 'var(--color-brand-primary)' }}
className="min-h-[44px]"
>
<Plus className="h-4 w-4 mr-2" />
Create First Template
</Button>
</div>
</CardContent>
</Card>
) : (
<Card>
<CardContent className="p-0">
<div className="overflow-x-auto">
<Table>
<TableHeader className="bg-gray-50 sticky top-0">
<TableRow className="border-b">
<TableHead className="w-12">
<Checkbox
checked={selectedTemplates.length === filteredTemplates.length}
onCheckedChange={(checked) => {
if (checked) {
setSelectedTemplates(filteredTemplates.map(t => t.id));
} else {
setSelectedTemplates([]);
}
}}
/>
</TableHead>
<TableHead>Template Name</TableHead>
<TableHead>Question Type</TableHead>
<TableHead>Sections</TableHead>
<TableHead>Usage</TableHead>
<TableHead>Created By</TableHead>
<TableHead>Created</TableHead>
<TableHead className="w-12">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredTemplates.map((template) => (
<TableRow key={template.id} className="border-b hover:bg-gray-50 min-h-[44px]">
<TableCell>
<Checkbox
checked={selectedTemplates.includes(template.id)}
onCheckedChange={(checked) => {
if (checked) {
setSelectedTemplates([...selectedTemplates, template.id]);
} else {
setSelectedTemplates(selectedTemplates.filter(id => id !== template.id));
}
}}
/>
</TableCell>
<TableCell>
<div>
<div className="font-medium">{template.name}</div>
<div className="text-sm text-gray-500">{template.description}</div>
</div>
</TableCell>
<TableCell>
<Badge variant="secondary" className="text-xs">
{template.questionType}
</Badge>
</TableCell>
<TableCell>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<div className="text-sm">
{template.sections.length} section{template.sections.length > 1 ? 's' : ''}
</div>
</TooltipTrigger>
<TooltipContent>
<div className="space-y-1">
{template.sections.map((section, index) => (
<div key={index} className="text-sm">
{section.name}
{section.numberOfStatements && ` (${section.numberOfStatements} statements)`}
{section.numberOfOptions && ` (${section.numberOfOptions}-point scale)`}
</div>
))}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</TableCell>
<TableCell>
<div className="text-sm">
Used in {template.usage} profiler{template.usage > 1 ? 's' : ''}
</div>
</TableCell>
<TableCell>{template.createdBy}</TableCell>
<TableCell className="text-gray-600">
{new Date(template.createdAt).toLocaleDateString('en-GB', {
day: '2-digit',
month: 'short',
year: 'numeric'
})}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onClick={() => handleEditTemplate(template)}>
<Edit className="h-4 w-4 mr-2" />
Edit Template
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleCloneTemplate(template)}>
<Copy className="h-4 w-4 mr-2" />
Clone Template
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => handleDeleteTemplate(template)}
className="text-red-600 hover:text-red-700"
>
<Trash2 className="h-4 w-4 mr-2" />
Delete Template
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
)}
{/* Create/Edit Template Dialog */}
<Dialog open={isCreateTemplateOpen || isEditTemplateOpen || isCloneTemplateOpen} onOpenChange={(open) => {
if (!open) {
setIsCreateTemplateOpen(false);
setIsEditTemplateOpen(false);
setIsCloneTemplateOpen(false);
setNewTemplate({ name: "", description: "", questionType: "", sections: [] });
setEditSections([]);
}
}}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{isEditTemplateOpen ? 'Edit Template' : isCloneTemplateOpen ? 'Clone Template' : 'Create New Template'}
</DialogTitle>
<DialogDescription>
{isEditTemplateOpen ? 'Modify the section configuration template' :
isCloneTemplateOpen ? 'Create a copy of the section configuration template' :
'Create a reusable section configuration template for profiler assessments'}
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
{/* Basic Information */}
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="template-name">Template Name</Label>
<Input
id="template-name"
value={newTemplate.name}
onChange={(e) => setNewTemplate({ ...newTemplate, name: e.target.value })}
placeholder="Enter template name"
className="min-h-[44px]"
/>
</div>
<div>
<Label htmlFor="question-type">Question Type</Label>
<Select
value={newTemplate.questionType}
onValueChange={(value) => {
setNewTemplate({ ...newTemplate, questionType: value });
// Reset sections when question type changes
setEditSections([]);
}}
disabled={isEditTemplateOpen}
>
<SelectTrigger className="min-h-[44px]">
<SelectValue placeholder="Select question type" />
</SelectTrigger>
<SelectContent>
{Object.entries(questionTypeConfig).map(([type, config]) => (
<SelectItem key={type} value={type}>
<div>
<div>{config.label}</div>
<div className="text-sm text-gray-500">{config.description}</div>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div>
<Label htmlFor="template-description">Description</Label>
<Textarea
id="template-description"
value={newTemplate.description}
onChange={(e) => setNewTemplate({ ...newTemplate, description: e.target.value })}
placeholder="Describe this template and its intended use"
rows={3}
/>
</div>
{/* Question Type Configuration Info */}
{newTemplate.questionType && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex items-start gap-3">
<Info className="h-5 w-5 text-blue-600 mt-0.5 flex-shrink-0" />
<div>
<h4 className="font-medium text-blue-900">
{getQuestionTypeConfig(newTemplate.questionType).label} Configuration
</h4>
<p className="text-sm text-blue-700 mt-1">
{getQuestionTypeConfig(newTemplate.questionType).helpText}
</p>
</div>
</div>
</div>
)}
{/* Sections Configuration */}
{newTemplate.questionType && (
<div>
<div className="flex items-center justify-between mb-4">
<Label>Sections Configuration</Label>
<Button
onClick={addSection}
size="sm"
className="min-h-[44px]"
>
<Plus className="h-4 w-4 mr-2" />
Add Section
</Button>
</div>
{editSections.length === 0 ? (
<div className="text-center py-8 border-2 border-dashed border-gray-300 rounded-lg">
<FileText className="h-8 w-8 mx-auto mb-2 text-gray-400" />
<p className="text-gray-500">No sections added yet</p>
<p className="text-sm text-gray-400">Click "Add Section" to get started</p>
</div>
) : (
<div className="space-y-3">
{editSections.map((section, index) => {
const typeConfig = getQuestionTypeConfig(newTemplate.questionType);
return (
<div key={section.id || index} className="border rounded-lg p-4 bg-gray-50">
<div className="flex items-center gap-3 mb-3">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => moveSection(index, 'up')}
disabled={index === 0}
className="h-8 w-8 p-0"
>
<ArrowUpDown className="h-4 w-4" />
</Button>
<span className="text-sm font-medium text-gray-500">
Section {section.order}
</span>
</div>
<div className="flex-1">
<Input
value={section.name}
onChange={(e) => updateSection(index, 'name', e.target.value)}
placeholder="Section name"
className="min-h-[44px]"
/>
</div>
{typeConfig.configField && (
<div className="w-32">
<Input
type="number"
value={section[typeConfig.configField] || typeConfig.defaultValue}
onChange={(e) => updateSection(index, typeConfig.configField!, parseInt(e.target.value))}
min={typeConfig.minValue}
max={typeConfig.maxValue}
placeholder={typeConfig.configLabel}
className="min-h-[44px]"
/>
</div>
)}
<Button
variant="ghost"
size="sm"
onClick={() => removeSection(index)}
className="h-8 w-8 p-0 text-red-600 hover:text-red-700"
>
<X className="h-4 w-4" />
</Button>
</div>
{typeConfig.configField && (
<div className="text-xs text-gray-500">
{typeConfig.configLabel}: {section[typeConfig.configField] || typeConfig.defaultValue}
</div>
)}
</div>
);
})}
</div>
)}
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setIsCreateTemplateOpen(false);
setIsEditTemplateOpen(false);
setIsCloneTemplateOpen(false);
}}
className="min-h-[44px]"
>
Cancel
</Button>
<Button
onClick={saveTemplate}
className="min-h-[44px]"
style={{ backgroundColor: 'var(--color-brand-primary)' }}
>
<Save className="h-4 w-4 mr-2" />
{isEditTemplateOpen ? 'Update Template' : 'Save Template'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Template</DialogTitle>
<DialogDescription>
Are you sure you want to delete "{deletingTemplate?.name}"? This action cannot be undone.
{deletingTemplate?.usage > 0 && (
<div className="mt-2 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<div className="flex items-center gap-2">
<AlertCircle className="h-4 w-4 text-yellow-600" />
<span className="text-sm text-yellow-800">
This template is currently used in {deletingTemplate.usage} profiler{deletingTemplate.usage > 1 ? 's' : ''}.
</span>
</div>
</div>
)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsDeleteDialogOpen(false)}
className="min-h-[44px]"
>
Cancel
</Button>
<Button
variant="destructive"
onClick={confirmDelete}
className="min-h-[44px]"
>
<Trash2 className="h-4 w-4 mr-2" />
Delete Template
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Bulk Delete Confirmation Dialog */}
<Dialog open={isBulkDeleteDialogOpen} onOpenChange={setIsBulkDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Selected Templates</DialogTitle>
<DialogDescription>
Are you sure you want to delete {selectedTemplates.length} selected template{selectedTemplates.length > 1 ? 's' : ''}?
This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsBulkDeleteDialogOpen(false)}
className="min-h-[44px]"
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleBulkDelete}
className="min-h-[44px]"
>
<Trash2 className="h-4 w-4 mr-2" />
Delete {selectedTemplates.length} Template{selectedTemplates.length > 1 ? 's' : ''}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</AuthenticatedLayout>
);
}

View File

@@ -0,0 +1,811 @@
import React, { useState } from 'react';
import { AuthenticatedLayout } from '../layout/AuthenticatedLayout';
import { Button } from '../ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Textarea } from '../ui/textarea';
import { Badge } from '../ui/badge';
import { Switch } from '../ui/switch';
import { toast } from "sonner@2.0.3";
import {
Save,
Eye,
Send,
Check,
X,
History,
GripVertical,
Link,
FileText
} from 'lucide-react';
import { InternalLinkPicker } from '../landing-pages/InternalLinkPicker';
import { MediaPicker } from '../landing-pages/MediaPicker';
import { PreviewModal } from '../landing-pages/PreviewModal';
import { VersionHistory } from '../landing-pages/VersionHistory';
import { AuditDrawer } from '../landing-pages/AuditDrawer';
interface ServicesEditorProps {
onNavigate: (route: string) => void;
onLogout: () => void;
user: any;
}
interface HeroData {
imageUrl?: string;
imageAlt?: string;
headline: string;
subtext: string;
cta?: {
label: string;
internalHref: string;
};
}
interface ServiceBlock {
title: string;
description: string;
iconUrl?: string;
iconAlt?: string;
download?: {
fileUrl: string;
label: string;
};
link?: {
internalHref: string;
label: string;
};
}
interface ApproachStep {
title: string;
text: string;
}
interface ApproachData {
sectionTitle: string;
blurb: string;
steps: ApproachStep[];
}
interface Logo {
url: string;
alt: string;
}
interface CtaData {
imageUrl?: string;
imageAlt?: string;
text: string;
cta: {
label: string;
internalHref: string;
};
}
export function ServicesEditor({ onNavigate, onLogout, user }: ServicesEditorProps) {
const [status, setStatus] = useState<'draft' | 'in_review' | 'changes_requested' | 'approved' | 'published'>('draft');
const [isLinkPickerOpen, setIsLinkPickerOpen] = useState(false);
const [activeLinkField, setActiveLinkField] = useState<string>('');
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
const [isVersionHistoryOpen, setIsVersionHistoryOpen] = useState(false);
const [isAuditOpen, setIsAuditOpen] = useState(false);
// Form data
const [hero, setHero] = useState<HeroData>({
headline: '',
subtext: '',
cta: { label: '', internalHref: '' }
});
const [serviceBlocks, setServiceBlocks] = useState<ServiceBlock[]>([
{ title: '', description: '' },
{ title: '', description: '' },
{ title: '', description: '' }
]);
const [approach, setApproach] = useState<ApproachData>({
sectionTitle: '',
blurb: '',
steps: [
{ title: '', text: '' },
{ title: '', text: '' },
{ title: '', text: '' }
]
});
const [logos, setLogos] = useState<Logo[]>([
{ url: '', alt: '' },
{ url: '', alt: '' },
{ url: '', alt: '' }
]);
const [cta, setCta] = useState<CtaData>({
text: '',
cta: { label: '', internalHref: '' }
});
const breadcrumbs = [
{ label: "Admin", href: "/dashboard" },
{ label: "Landing Pages", href: "/landing-pages" },
{ label: "Services" }
];
const handleSaveDraft = () => {
toast.success("Saved as draft.");
};
const handleSubmitForApproval = () => {
setStatus('in_review');
toast.success("Submitted for approval.");
};
const handleApprove = () => {
setStatus('approved');
toast.success("Approved.");
};
const handleRequestChanges = () => {
setStatus('changes_requested');
toast.success("Changes requested.");
};
const handlePublish = () => {
setStatus('published');
toast.success("Published.");
};
const handleUnpublish = () => {
setStatus('draft');
toast.success("Unpublished.");
};
const openLinkPicker = (field: string) => {
setActiveLinkField(field);
setIsLinkPickerOpen(true);
};
const handleLinkSelect = (link: { href: string; title: string }) => {
if (activeLinkField === 'hero-cta') {
setHero(prev => ({
...prev,
cta: { ...prev.cta!, internalHref: link.href }
}));
} else if (activeLinkField === 'cta-cta') {
setCta(prev => ({
...prev,
cta: { ...prev.cta, internalHref: link.href }
}));
} else if (activeLinkField.startsWith('service-')) {
const index = parseInt(activeLinkField.split('-')[1]);
const newServiceBlocks = [...serviceBlocks];
if (!newServiceBlocks[index].link) {
newServiceBlocks[index].link = { internalHref: '', label: '' };
}
newServiceBlocks[index].link!.internalHref = link.href;
setServiceBlocks(newServiceBlocks);
}
};
const validateServiceBlock = (block: ServiceBlock) => {
if (block.download?.fileUrl && block.link?.internalHref) {
return "Cannot have both downloadable file and internal link.";
}
return null;
};
const validateCTA = (cta: { label: string; internalHref: string }) => {
if (cta.label && !cta.internalHref) return "CTA requires both text and destination.";
if (!cta.label && cta.internalHref) return "CTA requires both text and destination.";
return null;
};
const getStatusBadgeVariant = () => {
switch (status) {
case 'published': return 'default';
case 'approved': return 'secondary';
case 'in_review': return 'outline';
case 'changes_requested': return 'destructive';
default: return 'secondary';
}
};
const getStatusLabel = () => {
switch (status) {
case 'draft': return 'Draft';
case 'in_review': return 'In Review';
case 'changes_requested': return 'Changes Requested';
case 'approved': return 'Approved';
case 'published': return 'Published';
default: return 'Draft';
}
};
const canEdit = status !== 'in_review';
const canApprove = user.role === 'Super Admin' && status === 'in_review';
const canPublish = user.role === 'Super Admin' && status === 'approved';
const renderHeader = () => (
<div className="sticky top-0 bg-background border-b p-6 z-10">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<h1>Services Page</h1>
<Badge variant={getStatusBadgeVariant()}>
{getStatusLabel()}
</Badge>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={handleSaveDraft}
disabled={!canEdit}
className="min-h-[44px]"
>
<Save className="h-4 w-4 mr-2" />
Save Draft
</Button>
<Button
variant="outline"
onClick={() => setIsPreviewOpen(true)}
className="min-h-[44px]"
>
<Eye className="h-4 w-4 mr-2" />
Preview
</Button>
{status === 'draft' && (
<Button
onClick={handleSubmitForApproval}
className="min-h-[44px]"
style={{ backgroundColor: "var(--color-brand-primary)" }}
>
<Send className="h-4 w-4 mr-2" />
Submit for Approval
</Button>
)}
{canApprove && (
<>
<Button
onClick={handleApprove}
className="min-h-[44px]"
style={{ backgroundColor: "var(--color-brand-primary)" }}
>
<Check className="h-4 w-4 mr-2" />
Approve
</Button>
<Button
variant="outline"
onClick={handleRequestChanges}
className="min-h-[44px]"
>
<X className="h-4 w-4 mr-2" />
Request Changes
</Button>
</>
)}
{canPublish && (
<Button
onClick={handlePublish}
className="min-h-[44px]"
style={{ backgroundColor: "var(--color-brand-primary)" }}
>
Publish
</Button>
)}
{status === 'published' && (
<Button
variant="outline"
onClick={handleUnpublish}
className="min-h-[44px]"
>
Unpublish
</Button>
)}
<Button
variant="ghost"
onClick={() => setIsAuditOpen(true)}
className="min-h-[44px] w-[44px] p-0"
>
<History className="h-4 w-4" />
<span className="sr-only">Audit</span>
</Button>
</div>
</div>
</div>
);
const renderRightRail = () => (
<div className="w-80 border-l bg-muted/25 p-6 space-y-6">
<div>
<h3 className="font-medium mb-3">Page Information</h3>
<div className="space-y-2 text-sm">
<div>
<span className="text-muted-foreground">URL:</span>
<span className="ml-2 font-mono">/services</span>
</div>
<div>
<span className="text-muted-foreground">Last published:</span>
<span className="ml-2">2024-01-14 11:20</span>
</div>
<div>
<span className="text-muted-foreground">Last editor:</span>
<span className="ml-2">Content Team</span>
</div>
</div>
</div>
<div>
<h3 className="font-medium mb-3">Actions</h3>
<div className="space-y-2">
<Button
variant="outline"
onClick={() => setIsVersionHistoryOpen(true)}
className="w-full justify-start min-h-[44px]"
>
<History className="h-4 w-4 mr-2" />
Version History
</Button>
</div>
</div>
</div>
);
const renderHeroSection = () => (
<Card>
<CardHeader>
<CardTitle>Hero Section</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* Background Image */}
<div className="space-y-2">
<Label>Background Image</Label>
<MediaPicker
type="image"
onChange={() => {}}
recommendedSize="1440×600px"
required
/>
</div>
{/* Headline */}
<div className="space-y-2">
<Label htmlFor="hero-headline">
Headline <span className="text-destructive">*</span>
</Label>
<Input
id="hero-headline"
value={hero.headline}
onChange={(e) => setHero(prev => ({ ...prev, headline: e.target.value }))}
placeholder="Enter hero headline"
disabled={!canEdit}
required
/>
</div>
{/* Subtext */}
<div className="space-y-2">
<Label htmlFor="hero-subtext">Subtext</Label>
<Textarea
id="hero-subtext"
value={hero.subtext}
onChange={(e) => setHero(prev => ({ ...prev, subtext: e.target.value }))}
placeholder="Enter hero subtext"
disabled={!canEdit}
rows={3}
/>
</div>
{/* Optional CTA */}
<div className="space-y-4">
<Label>Call to Action (Optional)</Label>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="hero-cta-text">CTA Text</Label>
<Input
id="hero-cta-text"
value={hero.cta?.label || ''}
onChange={(e) => setHero(prev => ({
...prev,
cta: { label: e.target.value, internalHref: prev.cta?.internalHref || '' }
}))}
placeholder="Optional CTA text"
disabled={!canEdit}
/>
</div>
<div className="space-y-2">
<Label>CTA Destination</Label>
<div className="flex gap-2">
<Input
value={hero.cta?.internalHref || ''}
placeholder="Select internal link"
readOnly
className="flex-1"
/>
<Button
variant="outline"
onClick={() => openLinkPicker('hero-cta')}
disabled={!canEdit}
className="flex-shrink-0"
>
<Link className="h-4 w-4" />
<span className="sr-only">Select link</span>
</Button>
</div>
</div>
</div>
{hero.cta && validateCTA(hero.cta) && (
<p className="text-sm text-destructive">{validateCTA(hero.cta)}</p>
)}
</div>
</CardContent>
</Card>
);
const renderServiceBlocksSection = () => (
<Card>
<CardHeader>
<CardTitle>Service Blocks</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{serviceBlocks.map((block, index) => (
<div key={index} className="space-y-4 p-4 border rounded-lg">
<div className="flex items-center gap-2">
<GripVertical className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">Service {index + 1}</span>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor={`service-${index}-title`}>Title</Label>
<Input
id={`service-${index}-title`}
value={block.title}
onChange={(e) => {
const newBlocks = [...serviceBlocks];
newBlocks[index].title = e.target.value;
setServiceBlocks(newBlocks);
}}
disabled={!canEdit}
placeholder="Enter service title"
/>
</div>
<div className="space-y-2">
<Label htmlFor={`service-${index}-description`}>Description</Label>
<Textarea
id={`service-${index}-description`}
value={block.description}
onChange={(e) => {
const newBlocks = [...serviceBlocks];
newBlocks[index].description = e.target.value;
setServiceBlocks(newBlocks);
}}
disabled={!canEdit}
placeholder="Enter service description"
rows={3}
/>
</div>
<div className="space-y-2">
<Label>Icon</Label>
<MediaPicker
type="icon"
onChange={() => {}}
required
/>
</div>
{/* Downloadable File */}
<div className="space-y-4">
<Label>Downloadable File (Optional)</Label>
<MediaPicker
type="file"
onChange={() => {}}
/>
{block.download && (
<div className="space-y-2">
<Label htmlFor={`service-${index}-download-label`}>Button Text</Label>
<Input
id={`service-${index}-download-label`}
value={block.download.label}
onChange={(e) => {
const newBlocks = [...serviceBlocks];
if (newBlocks[index].download) {
newBlocks[index].download!.label = e.target.value;
setServiceBlocks(newBlocks);
}
}}
disabled={!canEdit}
placeholder="Download button text"
/>
</div>
)}
</div>
{/* Internal Link */}
<div className="space-y-4">
<Label>Internal Link (Alternative to Download)</Label>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor={`service-${index}-link-label`}>Link Text</Label>
<Input
id={`service-${index}-link-label`}
value={block.link?.label || ''}
onChange={(e) => {
const newBlocks = [...serviceBlocks];
if (!newBlocks[index].link) {
newBlocks[index].link = { internalHref: '', label: '' };
}
newBlocks[index].link!.label = e.target.value;
setServiceBlocks(newBlocks);
}}
disabled={!canEdit}
placeholder="Link text"
/>
</div>
<div className="space-y-2">
<Label>Link Destination</Label>
<div className="flex gap-2">
<Input
value={block.link?.internalHref || ''}
placeholder="Select internal link"
readOnly
className="flex-1"
/>
<Button
variant="outline"
onClick={() => openLinkPicker(`service-${index}`)}
disabled={!canEdit}
className="flex-shrink-0"
>
<Link className="h-4 w-4" />
<span className="sr-only">Select link</span>
</Button>
</div>
</div>
</div>
</div>
{validateServiceBlock(block) && (
<p className="text-sm text-destructive">{validateServiceBlock(block)}</p>
)}
</div>
</div>
))}
</CardContent>
</Card>
);
const renderApproachSection = () => (
<Card>
<CardHeader>
<CardTitle>How We Work / Approach</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label htmlFor="approach-title">Section Title</Label>
<Input
id="approach-title"
value={approach.sectionTitle}
onChange={(e) => setApproach(prev => ({ ...prev, sectionTitle: e.target.value }))}
placeholder="Enter section title"
disabled={!canEdit}
/>
</div>
<div className="space-y-2">
<Label htmlFor="approach-blurb">Blurb</Label>
<Textarea
id="approach-blurb"
value={approach.blurb}
onChange={(e) => setApproach(prev => ({ ...prev, blurb: e.target.value }))}
placeholder="Enter section description"
disabled={!canEdit}
rows={3}
/>
</div>
<div className="space-y-4">
<Label>Steps</Label>
{approach.steps.map((step, index) => (
<div key={index} className="grid grid-cols-2 gap-4 p-4 border rounded-lg">
<div className="space-y-2">
<Label htmlFor={`step-${index}-title`}>Step {index + 1} Title</Label>
<Input
id={`step-${index}-title`}
value={step.title}
onChange={(e) => {
const newSteps = [...approach.steps];
newSteps[index].title = e.target.value;
setApproach(prev => ({ ...prev, steps: newSteps }));
}}
disabled={!canEdit}
placeholder="Enter step title"
/>
</div>
<div className="space-y-2">
<Label htmlFor={`step-${index}-text`}>Step {index + 1} Text</Label>
<Textarea
id={`step-${index}-text`}
value={step.text}
onChange={(e) => {
const newSteps = [...approach.steps];
newSteps[index].text = e.target.value;
setApproach(prev => ({ ...prev, steps: newSteps }));
}}
disabled={!canEdit}
placeholder="Enter step description"
rows={2}
/>
</div>
</div>
))}
</div>
</CardContent>
</Card>
);
const renderLogosSection = () => (
<Card>
<CardHeader>
<CardTitle>Case Studies / Logos</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{logos.map((logo, index) => (
<div key={index} className="space-y-2">
<Label>Logo {index + 1}</Label>
<MediaPicker
type="image"
onChange={() => {}}
acceptedTypes={['.svg', '.png']}
required
/>
</div>
))}
</CardContent>
</Card>
);
const renderCtaSection = () => (
<Card>
<CardHeader>
<CardTitle>CTA Section</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* Background Image */}
<div className="space-y-2">
<Label>Background Image</Label>
<MediaPicker
type="image"
onChange={() => {}}
recommendedSize="1440×400px"
/>
</div>
{/* Text */}
<div className="space-y-2">
<Label htmlFor="cta-text">Text</Label>
<Input
id="cta-text"
value={cta.text}
onChange={(e) => setCta(prev => ({ ...prev, text: e.target.value }))}
placeholder="Enter CTA section text"
disabled={!canEdit}
/>
</div>
{/* CTA */}
<div className="space-y-4">
<Label>Call to Action</Label>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="cta-cta-text">CTA Text</Label>
<Input
id="cta-cta-text"
value={cta.cta.label}
onChange={(e) => setCta(prev => ({
...prev,
cta: { ...prev.cta, label: e.target.value }
}))}
placeholder="Enter CTA text"
disabled={!canEdit}
/>
</div>
<div className="space-y-2">
<Label>CTA Destination</Label>
<div className="flex gap-2">
<Input
value={cta.cta.internalHref}
placeholder="Select internal link"
readOnly
className="flex-1"
/>
<Button
variant="outline"
onClick={() => openLinkPicker('cta-cta')}
disabled={!canEdit}
className="flex-shrink-0"
>
<Link className="h-4 w-4" />
<span className="sr-only">Select link</span>
</Button>
</div>
</div>
</div>
{validateCTA(cta.cta) && (
<p className="text-sm text-destructive">{validateCTA(cta.cta)}</p>
)}
</div>
</CardContent>
</Card>
);
return (
<AuthenticatedLayout
currentRoute="/landing-pages/edit/services"
onNavigate={onNavigate}
user={user}
onLogout={onLogout}
breadcrumbs={breadcrumbs}
>
<div className="min-h-screen flex flex-col">
{renderHeader()}
<div className="flex-1 flex">
{/* Main Content */}
<div className="flex-1 p-6 space-y-6 overflow-y-auto">
{renderHeroSection()}
{renderServiceBlocksSection()}
{renderApproachSection()}
{renderLogosSection()}
{renderCtaSection()}
</div>
{/* Right Rail */}
{renderRightRail()}
</div>
</div>
{/* Modals */}
<InternalLinkPicker
isOpen={isLinkPickerOpen}
onClose={() => setIsLinkPickerOpen(false)}
onSelect={handleLinkSelect}
/>
<PreviewModal
isOpen={isPreviewOpen}
onClose={() => setIsPreviewOpen(false)}
pageTitle="Services"
pageData={{ hero, serviceBlocks, approach, logos, cta }}
/>
<VersionHistory
isOpen={isVersionHistoryOpen}
onClose={() => setIsVersionHistoryOpen(false)}
pageTitle="Services"
/>
<AuditDrawer
isOpen={isAuditOpen}
onClose={() => setIsAuditOpen(false)}
pageTitle="Services"
/>
</AuthenticatedLayout>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -34,25 +34,31 @@ const buttonVariants = cva(
},
);
function Button({
const Button = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}
>(({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
}, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
});
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@@ -30,21 +30,21 @@ function DialogClose({
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
function DialogContent({
className,

View File

@@ -1,8 +1,8 @@
"use client";
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu@2.1.6";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react@0.487.0";
import { cn } from "./utils";

View File

@@ -28,21 +28,21 @@ function SheetPortal({
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
ref={ref}
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
));
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
function SheetContent({
className,

View File

@@ -1,9 +1,9 @@
"use client";
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { VariantProps, cva } from "class-variance-authority";
import { PanelLeftIcon } from "lucide-react";
import { Slot } from "@radix-ui/react-slot@1.1.2";
import { VariantProps, cva } from "class-variance-authority@0.7.1";
import { PanelLeftIcon } from "lucide-react@0.487.0";
import { useIsMobile } from "./use-mobile";
import { cn } from "./utils";

4194
src/data/mockData.ts Normal file

File diff suppressed because it is too large Load Diff

25
src/global.d.ts vendored
View File

@@ -1,26 +1,29 @@
// declarations.d.ts
declare module "*.png" {
declare module '*.webp' {
const src: string;
export default src;
}
declare module "*.jpg" {
declare module '*.png' {
const src: string;
export default src;
}
declare module "*.jpeg" {
declare module '*.jpg' {
const src: string;
export default src;
}
declare module "*.svg" {
import * as React from "react";
const ReactComponent: React.FunctionComponent<
React.SVGProps<SVGSVGElement> & { title?: string }
>;
export { ReactComponent };
declare module '*.jpeg' {
const src: string;
export default src;
}
declare module '*.svg' {
const src: string;
export default src;
}
declare module '*.gif' {
const src: string;
export default src;
}

4556
src/index.css Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,7 @@
import { createRoot } from "react-dom/client";
import App from "./App";
import "././styles/globals.css";
import { BrowserRouter } from "react-router-dom";
import { createRoot } from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
createRoot(document.getElementById("root")!).render(
<BrowserRouter>
<App />
</BrowserRouter>
);
createRoot(document.getElementById("root")!).render(<App />);

View File

@@ -1,4 +1,3 @@
@import "tailwindcss";
@custom-variant dark (&:is(.dark *));
:root {
@@ -198,3 +197,39 @@
html {
font-size: var(--font-size);
}
/* Hide scrollbar for navigation sidebar while keeping scroll functionality */
.navigation-scrollbar-hidden {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* Internet Explorer 10+ */
}
.navigation-scrollbar-hidden::-webkit-scrollbar {
width: 0;
height: 0;
display: none; /* Safari and Chrome */
}
/* Hide scrollbar for dashboard page while keeping scroll functionality */
.dashboard-scrollbar-hidden {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* Internet Explorer 10+ */
}
.dashboard-scrollbar-hidden::-webkit-scrollbar {
width: 0;
height: 0;
display: none; /* Safari and Chrome */
}
/* Hide scrollbar for content picker while keeping scroll functionality */
.content-picker-scrollbar-hidden {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* Internet Explorer 10+ */
}
.content-picker-scrollbar-hidden::-webkit-scrollbar {
width: 0;
height: 0;
display: none; /* Safari and Chrome */
}

View File

@@ -1,7 +1,7 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react-swc';
import path from 'path';
import * as path from 'path';
export default defineConfig({
plugins: [react()],
@@ -56,7 +56,7 @@
outDir: 'build',
},
server: {
port: 3000,
port: 3006,
open: true,
},
});