Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f87e71d231 |
142
package-lock.json
generated
142
package-lock.json
generated
@@ -34,6 +34,7 @@
|
||||
"@radix-ui/react-toggle": "^1.1.2",
|
||||
"@radix-ui/react-toggle-group": "^1.1.2",
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
"@reduxjs/toolkit": "^2.9.0",
|
||||
"@tailwindcss/postcss": "^4.1.12",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "*",
|
||||
@@ -47,7 +48,9 @@
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.55.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"react-router-dom": "^6.30.1",
|
||||
"recharts": "^2.15.2",
|
||||
"sonner": "^2.0.3",
|
||||
"tailwind-merge": "*",
|
||||
@@ -1889,6 +1892,41 @@
|
||||
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.9.0.tgz",
|
||||
"integrity": "sha512-fSfQlSRu9Z5yBkvsNhYF2rPS8cGXn/TZVrlwN1948QyZ8xMZ0JvP50S2acZNaf+o63u6aEeMjipFyksjIcWrog==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@standard-schema/utils": "^0.3.0",
|
||||
"immer": "^10.0.3",
|
||||
"redux": "^5.0.1",
|
||||
"redux-thunk": "^3.1.0",
|
||||
"reselect": "^5.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@remix-run/router": {
|
||||
"version": "1.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz",
|
||||
"integrity": "sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
@@ -2176,6 +2214,18 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
|
||||
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/utils": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@swc/core": {
|
||||
"version": "1.13.5",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.13.5.tgz",
|
||||
@@ -2763,6 +2813,12 @@
|
||||
"@types/react": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react-swc": {
|
||||
"version": "3.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-3.11.0.tgz",
|
||||
@@ -3129,6 +3185,16 @@
|
||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "10.1.3",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.1.3.tgz",
|
||||
"integrity": "sha512-tmjF/k8QDKydUlm3mZU+tjM6zeq9/fFpPqH9SzWmBnVVKsPBg/V66qsMwb3/Bo90cgUN+ghdVBess+hPsxUyRw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/input-otp": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.4.2.tgz",
|
||||
@@ -3612,6 +3678,29 @@
|
||||
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.2.25 || ^19",
|
||||
"react": "^18.0 || ^19",
|
||||
"redux": "^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-remove-scroll": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz",
|
||||
@@ -3669,6 +3758,38 @@
|
||||
"react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "6.30.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.1.tgz",
|
||||
"integrity": "sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.23.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "6.30.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.1.tgz",
|
||||
"integrity": "sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.23.0",
|
||||
"react-router": "6.30.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8",
|
||||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/react-smooth": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
|
||||
@@ -3754,6 +3875,27 @@
|
||||
"decimal.js-light": "^2.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"redux": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reselect": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.49.0",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.49.0.tgz",
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"@radix-ui/react-toggle": "^1.1.2",
|
||||
"@radix-ui/react-toggle-group": "^1.1.2",
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
"@reduxjs/toolkit": "^2.9.0",
|
||||
"@tailwindcss/postcss": "^4.1.12",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "*",
|
||||
@@ -42,7 +43,9 @@
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.55.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"react-router-dom": "^6.30.1",
|
||||
"recharts": "^2.15.2",
|
||||
"sonner": "^2.0.3",
|
||||
"tailwind-merge": "*",
|
||||
|
||||
73
src/App.css
Normal file
73
src/App.css
Normal file
@@ -0,0 +1,73 @@
|
||||
/* Add this to your main global CSS file */
|
||||
@media (max-width: 640px) {
|
||||
.employee-card-mobile-view {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.employee-card-desktop-view {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 641px) {
|
||||
.employee-card-mobile-view {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.employee-card-desktop-view {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom styles for the compact table */
|
||||
.compact-table {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.compact-table th,
|
||||
.compact-table td {
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
/* Responsive table container */
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Ensure buttons are touch-friendly on mobile */
|
||||
.min-tap-44 {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
|
||||
/* Truncate text for small containers */
|
||||
.truncate {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Animation for card entrance */
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slideUp 0.5s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Reduced motion support */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.animate-slide-up {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
2158
src/App.tsx
2158
src/App.tsx
File diff suppressed because it is too large
Load Diff
37
src/components/BreadcrumbNav.tsx
Normal file
37
src/components/BreadcrumbNav.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "./ui/breadcrumb";
|
||||
|
||||
export const BreadcrumbNav: React.FC<{ currentScreen: string }> = ({ currentScreen }) => {
|
||||
const getBreadcrumbText = (screen: string) => {
|
||||
switch (screen) {
|
||||
case "home":
|
||||
return "HR Home";
|
||||
case "learners":
|
||||
return "Learners";
|
||||
case "analytics":
|
||||
return "Analytics & Reports";
|
||||
case "settings":
|
||||
return "HR Settings";
|
||||
case "testimonials":
|
||||
return "Testimonials";
|
||||
default:
|
||||
return "HR Portal";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Breadcrumb className="mb-6">
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link to="/hr">HR Portal</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator aria-hidden="true" />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>{getBreadcrumbText(currentScreen)}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
);
|
||||
};
|
||||
65
src/components/ChatBot.tsx
Normal file
65
src/components/ChatBot.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useState } from "react";
|
||||
import { Button } from "./ui/button";
|
||||
import { MessageSquare, X } from "lucide-react";
|
||||
|
||||
export const ChatBot: React.FC<{ currentScreen?: string }> = ({ currentScreen }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const getChipsForScreen = (screen?: string) => {
|
||||
if (screen === 'testimonials') {
|
||||
return [
|
||||
"How do I submit a testimonial?",
|
||||
"When will my testimonial be reviewed?",
|
||||
"Can I edit my testimonial?",
|
||||
"What makes a good testimonial?"
|
||||
];
|
||||
}
|
||||
return [
|
||||
"How do I upload a roster?",
|
||||
"How to assign courses?",
|
||||
"View progress reports",
|
||||
"Export learner data"
|
||||
];
|
||||
};
|
||||
|
||||
const chips = getChipsForScreen(currentScreen);
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-6 right-6 z-50">
|
||||
{isOpen && (
|
||||
<div className="mb-4 bg-card border border-chrome-divider rounded-lg shadow-lg p-4 w-80 animate-slide-up">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h3 className="font-semibold">HR Assistant</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="h-6 w-6"
|
||||
aria-label="Close chat"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{chips.map((chip, index) => (
|
||||
<button
|
||||
key={index}
|
||||
className="w-full text-left p-2 text-sm bg-muted hover:bg-accent rounded-md transition-colors min-tap-44"
|
||||
>
|
||||
{chip}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="rounded-full h-12 w-12 shadow-lg min-tap-44"
|
||||
aria-label="Open HR chat assistant"
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<MessageSquare className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
265
src/components/EmployeeTable.tsx
Normal file
265
src/components/EmployeeTable.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
import React from 'react';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../components/ui/table';
|
||||
import { Progress } from '../components/ui/progress';
|
||||
import { Badge } from '../components/ui/badge';
|
||||
import { Edit } from 'lucide-react';
|
||||
import type { Employee } from '../types';
|
||||
|
||||
export const EmployeeTable: React.FC<{
|
||||
employees: Employee[];
|
||||
onEdit?: (employee: Employee) => void;
|
||||
showProgress?: boolean;
|
||||
maxHeight?: string;
|
||||
compact?: any
|
||||
}> = ({ employees, onEdit, showProgress = true, maxHeight = '400px' }) => {
|
||||
return (
|
||||
<div className={`rounded-md border`} style={{ maxHeight }}>
|
||||
<Table>
|
||||
<TableHeader className="sticky-header">
|
||||
<TableRow>
|
||||
<TableHead className="w-[200px]">Employee</TableHead>
|
||||
<TableHead className="w-[250px]">Email</TableHead>
|
||||
<TableHead className="w-[150px]">Phone</TableHead>
|
||||
<TableHead className="w-[100px]">Status</TableHead>
|
||||
{showProgress && (
|
||||
<>
|
||||
<TableHead className="w-[200px]">Programme/Course</TableHead>
|
||||
<TableHead className="w-[100px]">Progress</TableHead>
|
||||
<TableHead className="w-[120px]">Last Activity</TableHead>
|
||||
</>
|
||||
)}
|
||||
<TableHead className="w-[80px]">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{employees.map((employee) => (
|
||||
<TableRow
|
||||
key={employee.id}
|
||||
className="min-h-[48px] cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => onEdit?.(employee)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onEdit?.(employee);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TableCell className="font-medium">{employee.name}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{employee.email}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{employee.phone}</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
employee.status === 'Active' ? 'default' :
|
||||
employee.status === 'Pending' ? 'secondary' : 'destructive'
|
||||
}
|
||||
aria-describedby={`status-${employee.id}`}
|
||||
>
|
||||
{employee.status}
|
||||
</Badge>
|
||||
<span id={`status-${employee.id}`} className="sr-only">
|
||||
Employee status is {employee.status}
|
||||
</span>
|
||||
</TableCell>
|
||||
{showProgress && (
|
||||
<>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className="font-medium text-sm">{employee.programme}</div>
|
||||
<div className="text-xs text-muted-foreground">{employee.course}</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{employee.progress !== undefined && (
|
||||
<div className="space-y-1">
|
||||
<Progress
|
||||
value={employee.progress}
|
||||
className="w-16"
|
||||
aria-describedby={`progress-${employee.id}`}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">{employee.progress}%</span>
|
||||
<span id={`progress-${employee.id}`} className="sr-only">
|
||||
Progress: {employee.progress} percent complete
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{employee.lastActivity}</TableCell>
|
||||
</>
|
||||
)}
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="min-tap-44"
|
||||
onClick={(e: any) => {
|
||||
e.stopPropagation();
|
||||
onEdit?.(employee);
|
||||
}}
|
||||
aria-label={`Edit ${employee.name}`}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
// import React from "react";
|
||||
// import { Button } from "../components/ui/button";
|
||||
// import {
|
||||
// Table,
|
||||
// TableBody,
|
||||
// TableCell,
|
||||
// TableHead,
|
||||
// TableHeader,
|
||||
// TableRow,
|
||||
// } from "../components/ui/table";
|
||||
// import { Progress } from "../components/ui/progress";
|
||||
// import { Badge } from "../components/ui/badge";
|
||||
// import { Edit } from "lucide-react";
|
||||
// import type { Employee } from "../types";
|
||||
|
||||
// export const EmployeeTable: React.FC<{
|
||||
// employees: Employee[];
|
||||
// onEdit?: (employee: Employee) => void;
|
||||
// showProgress?: boolean;
|
||||
// maxHeight?: string;
|
||||
// compact?: any
|
||||
// }> = ({ employees, onEdit, showProgress = true, maxHeight = "400px" }) => {
|
||||
// return (
|
||||
// <div
|
||||
// className="rounded-md border overflow-x-auto"
|
||||
// style={{ maxHeight }}
|
||||
// >
|
||||
// <Table className="min-w-[800px] w-full">
|
||||
// {/* sticky header */}
|
||||
// <TableHeader className="sticky top-0 bg-background z-10">
|
||||
// <TableRow>
|
||||
// <TableHead className="min-w-[180px]">Employee</TableHead>
|
||||
// <TableHead className="min-w-[220px]">Email</TableHead>
|
||||
// <TableHead className="min-w-[150px]">Phone</TableHead>
|
||||
// <TableHead className="min-w-[100px]">Status</TableHead>
|
||||
// {showProgress && (
|
||||
// <>
|
||||
// <TableHead className="min-w-[200px]">Programme/Course</TableHead>
|
||||
// <TableHead className="min-w-[120px]">Progress</TableHead>
|
||||
// <TableHead className="min-w-[140px]">Last Activity</TableHead>
|
||||
// </>
|
||||
// )}
|
||||
// <TableHead className="min-w-[80px]">Actions</TableHead>
|
||||
// </TableRow>
|
||||
// </TableHeader>
|
||||
|
||||
// {/* table body */}
|
||||
// <TableBody>
|
||||
// {employees.map((employee) => (
|
||||
// <TableRow
|
||||
// key={employee.id}
|
||||
// className="min-h-[48px] cursor-pointer hover:bg-muted/50"
|
||||
// onClick={() => onEdit?.(employee)}
|
||||
// role="button"
|
||||
// tabIndex={0}
|
||||
// onKeyDown={(e) => {
|
||||
// if (e.key === "Enter" || e.key === " ") {
|
||||
// e.preventDefault();
|
||||
// onEdit?.(employee);
|
||||
// }
|
||||
// }}
|
||||
// >
|
||||
// <TableCell className="font-medium text-sm sm:text-base">
|
||||
// {employee.name}
|
||||
// </TableCell>
|
||||
// <TableCell className="text-muted-foreground text-xs sm:text-sm">
|
||||
// {employee.email}
|
||||
// </TableCell>
|
||||
// <TableCell className="text-muted-foreground text-xs sm:text-sm">
|
||||
// {employee.phone}
|
||||
// </TableCell>
|
||||
// <TableCell>
|
||||
// <Badge
|
||||
// variant={
|
||||
// employee.status === "Active"
|
||||
// ? "default"
|
||||
// : employee.status === "Pending"
|
||||
// ? "secondary"
|
||||
// : "destructive"
|
||||
// }
|
||||
// aria-describedby={`status-${employee.id}`}
|
||||
// >
|
||||
// {employee.status}
|
||||
// </Badge>
|
||||
// <span id={`status-${employee.id}`} className="sr-only">
|
||||
// Employee status is {employee.status}
|
||||
// </span>
|
||||
// </TableCell>
|
||||
|
||||
// {showProgress && (
|
||||
// <>
|
||||
// <TableCell>
|
||||
// <div>
|
||||
// <div className="font-medium text-sm">
|
||||
// {employee.programme}
|
||||
// </div>
|
||||
// <div className="text-xs text-muted-foreground">
|
||||
// {employee.course}
|
||||
// </div>
|
||||
// </div>
|
||||
// </TableCell>
|
||||
// <TableCell>
|
||||
// {employee.progress !== undefined && (
|
||||
// <div className="space-y-1">
|
||||
// <Progress
|
||||
// value={employee.progress}
|
||||
// className="w-16"
|
||||
// aria-describedby={`progress-${employee.id}`}
|
||||
// />
|
||||
// <span className="text-xs text-muted-foreground">
|
||||
// {employee.progress}%
|
||||
// </span>
|
||||
// <span
|
||||
// id={`progress-${employee.id}`}
|
||||
// className="sr-only"
|
||||
// >
|
||||
// Progress: {employee.progress} percent complete
|
||||
// </span>
|
||||
// </div>
|
||||
// )}
|
||||
// </TableCell>
|
||||
// <TableCell className="text-xs sm:text-sm text-muted-foreground">
|
||||
// {employee.lastActivity}
|
||||
// </TableCell>
|
||||
// </>
|
||||
// )}
|
||||
|
||||
// <TableCell>
|
||||
// <Button
|
||||
// variant="ghost"
|
||||
// size="sm"
|
||||
// className="min-tap-44"
|
||||
// onClick={(e: any) => {
|
||||
// e.stopPropagation();
|
||||
// onEdit?.(employee);
|
||||
// }}
|
||||
// aria-label={`Edit ${employee.name}`}
|
||||
// >
|
||||
// <Edit className="h-4 w-4" />
|
||||
// </Button>
|
||||
// </TableCell>
|
||||
// </TableRow>
|
||||
// ))}
|
||||
// </TableBody>
|
||||
// </Table>
|
||||
// </div>
|
||||
// );
|
||||
// };
|
||||
|
||||
|
||||
153
src/components/HRSidebar.tsx
Normal file
153
src/components/HRSidebar.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { Home, Users, Settings, BarChart3, MessageSquare } from "lucide-react";
|
||||
import { useLocalStorage } from "../hooks/useLocalStorage";
|
||||
import { NavLink } from "react-router-dom";
|
||||
|
||||
export const HRSidebar: React.FC<{
|
||||
activePath: string;
|
||||
onNavigatePath?: (path: string) => void;
|
||||
className?: string;
|
||||
}> = ({ activePath, onNavigatePath, className = '' }) => {
|
||||
const [prefersReducedMotion] = useLocalStorage('prefersReducedMotion', false);
|
||||
|
||||
const menuItems = [
|
||||
{ id: 'home', label: 'Dashboard', icon: Home, path: '/hr/home' },
|
||||
{ id: 'learners', label: 'Learners', icon: Users, path: '/hr/learners' },
|
||||
{ id: 'analytics', label: 'Analytics', icon: BarChart3, path: '/hr/analytics' },
|
||||
{ id: 'testimonials', label: 'Testimonials', icon: MessageSquare, path: '/hr/testimonials' },
|
||||
{ id: 'settings', label: 'Settings', icon: Settings, path: '/hr/settings' }
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
<div
|
||||
className={` w-64 min-w-[248px] bg-sidebar flex flex-col ${className}`}
|
||||
|
||||
>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-brand-charcoal rounded-md flex items-center justify-center">
|
||||
<span className="text-brand-charcoal-foreground font-bold text-sm">AC</span>
|
||||
</div>
|
||||
<span className="font-semibold text-sidebar-foreground">Acme Corp</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 p-4" role="navigation" aria-label="HR Portal Navigation">
|
||||
<ul className="space-y-1">
|
||||
{menuItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = activePath.startsWith(item.path);
|
||||
|
||||
return (
|
||||
<li key={item.id}>
|
||||
<NavLink
|
||||
to={item.path}
|
||||
className={`
|
||||
w-full flex items-center gap-3 px-3 py-2 rounded-md text-sm min-tap-44
|
||||
transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-sidebar-ring focus:ring-offset-2 focus:ring-offset-sidebar
|
||||
${isActive
|
||||
? 'bg-sidebar-primary text-sidebar-primary-foreground shadow-sm'
|
||||
: 'text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground'
|
||||
}
|
||||
${prefersReducedMotion ? '' : 'animate-scale-hover'}
|
||||
`}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// import React, { useState } from "react";
|
||||
// import { Home, Users, Settings, BarChart3, MessageSquare, Menu } from "lucide-react";
|
||||
// import { useLocalStorage } from "../hooks/useLocalStorage";
|
||||
// import { NavLink } from "react-router-dom";
|
||||
|
||||
// export const HRSidebar: React.FC<{
|
||||
// activePath: string;
|
||||
// onNavigatePath?: (path: string) => void;
|
||||
// className?: string;
|
||||
// }> = ({ activePath, onNavigatePath, className = "" }) => {
|
||||
// const [prefersReducedMotion] = useLocalStorage("prefersReducedMotion", false);
|
||||
|
||||
// // 🔹 Collapsed state
|
||||
// const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
// const menuItems = [
|
||||
// { id: "home", label: "Dashboard", icon: Home, path: "/hr/home" },
|
||||
// { id: "learners", label: "Learners", icon: Users, path: "/hr/learners" },
|
||||
// { id: "analytics", label: "Analytics", icon: BarChart3, path: "/hr/analytics" },
|
||||
// { id: "testimonials", label: "Testimonials", icon: MessageSquare, path: "/hr/testimonials" },
|
||||
// { id: "settings", label: "Settings", icon: Settings, path: "/hr/settings" },
|
||||
// ];
|
||||
|
||||
// return (
|
||||
// <div
|
||||
// className={`bg-sidebar flex flex-col transition-all duration-300
|
||||
// ${collapsed ? "w-20 min-w-[80px]" : "w-64 min-w-[248px]"} ${className}`}
|
||||
// >
|
||||
// {/* Header */}
|
||||
// <div className="p-4 flex items-center justify-between">
|
||||
// <div className="flex items-center gap-2">
|
||||
// <div className="w-8 h-8 bg-brand-charcoal rounded-md flex items-center justify-center">
|
||||
// <span className="text-brand-charcoal-foreground font-bold text-sm">AC</span>
|
||||
// </div>
|
||||
// {!collapsed && (
|
||||
// <span className="font-semibold text-sidebar-foreground">Acme Corp</span>
|
||||
// )}
|
||||
// </div>
|
||||
|
||||
// {/* 🔹 Toggle button */}
|
||||
// <button
|
||||
// onClick={() => setCollapsed((prev) => !prev)}
|
||||
// className="text-sidebar-foreground hover:bg-sidebar-accent rounded-md p-1"
|
||||
// >
|
||||
// <Menu className="h-5 w-5" />
|
||||
// </button>
|
||||
// </div>
|
||||
|
||||
// {/* Navigation */}
|
||||
// <nav className="flex-1 p-2" role="navigation" aria-label="HR Portal Navigation">
|
||||
// <ul className="space-y-1">
|
||||
// {menuItems.map((item) => {
|
||||
// const Icon = item.icon;
|
||||
// const isActive = activePath.startsWith(item.path);
|
||||
|
||||
// return (
|
||||
// <li key={item.id}>
|
||||
// <NavLink
|
||||
// to={item.path}
|
||||
// className={`
|
||||
// flex items-center gap-3 px-3 py-2 rounded-md text-sm min-tap-44
|
||||
// transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-sidebar-ring focus:ring-offset-2 focus:ring-offset-sidebar
|
||||
// ${isActive
|
||||
// ? "bg-sidebar-primary text-sidebar-primary-foreground shadow-sm"
|
||||
// : "text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||
// }
|
||||
// ${prefersReducedMotion ? "" : "animate-scale-hover"}
|
||||
// `}
|
||||
// >
|
||||
// <Icon className="h-5 w-5" />
|
||||
// {!collapsed && <span>{item.label}</span>}
|
||||
// </NavLink>
|
||||
// </li>
|
||||
// );
|
||||
// })}
|
||||
// </ul>
|
||||
// </nav>
|
||||
// </div>
|
||||
// );
|
||||
// };
|
||||
85
src/components/KPICard.css
Normal file
85
src/components/KPICard.css
Normal file
@@ -0,0 +1,85 @@
|
||||
/* KPICard.css */
|
||||
.kpi-card {
|
||||
min-height: 96px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 640px) {
|
||||
.kpi-card {
|
||||
min-height: 88px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Compact mode */
|
||||
.kpi-card.compact {
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
/* Animation for counting up */
|
||||
@keyframes countUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(5px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-count-up {
|
||||
animation: countUp 0.6s ease-out forwards;
|
||||
}
|
||||
|
||||
/* High contrast mode support */
|
||||
@media (prefers-contrast: high) {
|
||||
.kpi-card {
|
||||
border: 1px solid;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reduced motion support */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.animate-count-up {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.kpi-card {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode support */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.kpi-card {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
/* Hover effects for non-touch devices */
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.kpi-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Touch device adjustments */
|
||||
@media (pointer: coarse) {
|
||||
.kpi-card {
|
||||
min-height: 96px;
|
||||
/* Larger tap target */
|
||||
}
|
||||
}
|
||||
|
||||
/* Very small screens (smartwatch size) */
|
||||
@media (max-width: 320px) {
|
||||
.kpi-card {
|
||||
min-height: 76px;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
}
|
||||
132
src/components/KPICard.tsx
Normal file
132
src/components/KPICard.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import React from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from './ui/Card';
|
||||
import { Badge } from '../components/ui/badge';
|
||||
import { useLocalStorage } from '../hooks/useLocalStorage';
|
||||
import { useCountUp } from '../hooks/useCountUp';
|
||||
import type { KPIData } from '../types';
|
||||
import './KPICard.css'; // We'll create this CSS file
|
||||
|
||||
export const KPICard: React.FC<{
|
||||
data: KPIData;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
style?: any;
|
||||
compact?: boolean;
|
||||
}> = ({ data, onClick, className = '', style = {}, compact = false }) => {
|
||||
const countedValue = useCountUp(data.value);
|
||||
const [prefersReducedMotion] = useLocalStorage('prefersReducedMotion', false);
|
||||
const [screenSize, setScreenSize] = React.useState(getScreenSize());
|
||||
|
||||
// Get initial screen size
|
||||
function getScreenSize() {
|
||||
const width = window.innerWidth;
|
||||
if (width < 640) return 'xs';
|
||||
if (width < 768) return 'sm';
|
||||
if (width < 1024) return 'md';
|
||||
if (width < 1280) return 'lg';
|
||||
return 'xl';
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setScreenSize(getScreenSize());
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
// Responsive values based on screen size
|
||||
const responsiveValues = {
|
||||
fontSize: compact || screenSize === 'xs' ? 'text-xl' : 'text-2xl',
|
||||
showFullTitle: !compact && screenSize !== 'xs',
|
||||
badgeSize: compact || screenSize === 'xs' ? 'text-xs' : 'text-xs',
|
||||
padding: compact || screenSize === 'xs' ? 'p-3' : 'p-4'
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
// className={`kpi-card cursor-pointer transition-all duration-200 hover:shadow-md min-tap-44 border border-gray-300 bg-green-500 ${responsiveValues.padding as string} ${className}`}
|
||||
className={`kpi-card cursor-pointer transition-all duration-200 hover:shadow-md min-h-[44px] border border-gray-300 bg-green-500 rounded-lg ${responsiveValues.padding as string} ${className}`}
|
||||
|
||||
// className={`kpi-card cursor-pointer transition-all duration-200 hover:shadow-md min-tap-44 border border-gray-300 rounded-lg ${responsiveValues.padding as string} ${className}`}
|
||||
|
||||
onClick={onClick}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${data.title}: ${data.value}${data.title.includes('Progress') ? '%' : ''}`}
|
||||
onKeyDown={(e: any) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onClick?.();
|
||||
}
|
||||
}}
|
||||
style={style}
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle
|
||||
className={`font-medium text-muted-foreground ${compact || screenSize === 'xs' ? 'text-xs' : 'text-sm'}`}
|
||||
title={responsiveValues.showFullTitle ? undefined : data.title}
|
||||
>
|
||||
{responsiveValues.showFullTitle
|
||||
? data.title
|
||||
: truncateTitle(data.title, screenSize, compact)
|
||||
}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<span className={`font-bold ${responsiveValues.fontSize} ${prefersReducedMotion ? '' : 'animate-count-up'}`}>
|
||||
{prefersReducedMotion ? data.value : countedValue}
|
||||
{data.title.includes('Progress') && '%'}
|
||||
</span>
|
||||
{data.change !== undefined && (
|
||||
<Badge
|
||||
variant={data.trend === 'up' ? 'default' : 'destructive'}
|
||||
className={`${responsiveValues.badgeSize} ${compact ? 'scale-90' : ''}`}
|
||||
>
|
||||
{data.trend === 'up' ? '+' : ''}{data.change}{data.title.includes('Progress') ? '%' : ''}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{!responsiveValues.showFullTitle && (
|
||||
<div className="mt-1 text-xs text-muted-foreground truncate" title={data.title}>
|
||||
{data.title}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
);
|
||||
};
|
||||
|
||||
// Helper function to truncate titles for small screens
|
||||
function truncateTitle(title: string, screenSize: string, compact: boolean): string {
|
||||
if (compact) {
|
||||
// For compact mode, use abbreviations
|
||||
const abbreviations: Record<string, string> = {
|
||||
'Total Learners': 'Learners',
|
||||
'Active Courses': 'Courses',
|
||||
'Completion Rate': 'Complete %',
|
||||
'Avg. Progress': 'Progress',
|
||||
'Completed Profilers': 'Profiles',
|
||||
'Satisfaction Score': 'Satisfaction'
|
||||
};
|
||||
|
||||
return abbreviations[title] || title.substring(0, 12) + (title.length > 12 ? '...' : '');
|
||||
}
|
||||
|
||||
// For different screen sizes
|
||||
const maxLengths: Record<string, number> = {
|
||||
'xs': 12,
|
||||
'sm': 16,
|
||||
'md': 20,
|
||||
'lg': 24,
|
||||
'xl': 30
|
||||
};
|
||||
|
||||
const maxLength = maxLengths[screenSize] || 20;
|
||||
if (title.length <= maxLength) return title;
|
||||
return title
|
||||
// return title.substring(0, maxLength - 3) + '...';
|
||||
}
|
||||
42
src/components/TopNav.tsx
Normal file
42
src/components/TopNav.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Bell, Menu } from "lucide-react";
|
||||
import { Button } from "./ui/button";
|
||||
// import logo from '../src/assets/klc-logo.png';
|
||||
import logo from '../assets/klc-logo.png'
|
||||
export const TopNav: React.FC<{
|
||||
onMenuToggle?: () => void;
|
||||
showMenuButton?: boolean;
|
||||
}> = ({ onMenuToggle, showMenuButton = false }) => {
|
||||
return (
|
||||
<header className="h-16 bg-background border-b border-chrome-divider flex items-center justify-between px-4 lg:px-6">
|
||||
<div className="flex items-center gap-4">
|
||||
{showMenuButton && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onMenuToggle}
|
||||
className="lg:hidden min-tap-44"
|
||||
aria-label="Toggle navigation menu"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src={logo}
|
||||
alt="Logo"
|
||||
className="h-8 md:h-12 lg:h-14 w-auto object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="icon" className="min-tap-44" aria-label="Notifications">
|
||||
<Bell className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="w-8 h-8 bg-muted rounded-full flex items-center justify-center">
|
||||
<span className="text-xs font-medium">HR</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
73
src/components/layout/layout.tsx
Normal file
73
src/components/layout/layout.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import React, { useState } from "react";
|
||||
import { Outlet, useLocation } from "react-router-dom";
|
||||
import { TopNav } from "../../components/TopNav";
|
||||
import { HRSidebar } from "../../components/HRSidebar";
|
||||
import { BreadcrumbNav } from "../../components/BreadcrumbNav";
|
||||
import { ChatBot } from "../../components/ChatBot";
|
||||
|
||||
|
||||
export default function Layout() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const location = useLocation();
|
||||
|
||||
const pathToScreen = (pathname: string) => {
|
||||
if (pathname.startsWith("/hr/learners")) return "learners";
|
||||
if (pathname.startsWith("/hr/analytics")) return "analytics";
|
||||
if (pathname.startsWith("/hr/testimonials")) return "testimonials";
|
||||
if (pathname.startsWith("/hr/settings")) return "settings";
|
||||
return "home";
|
||||
};
|
||||
|
||||
const currentScreen = pathToScreen(location.pathname);
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<TopNav onMenuToggle={() => setSidebarOpen(!sidebarOpen)} showMenuButton />
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Sidebar */}
|
||||
<div className="hidden lg:flex flex-shrink-0">
|
||||
<HRSidebar activePath={location.pathname} />
|
||||
</div>
|
||||
|
||||
{sidebarOpen && (
|
||||
<div className="fixed inset-0 z-50 lg:hidden">
|
||||
<div
|
||||
className="absolute inset-0 bg-background/80 backdrop-blur-sm"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
<HRSidebar activePath={location.pathname} className="relative z-10" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="flex-shrink-0 bg-background px-4 lg:px-8 flex items-center">
|
||||
<div className="max-w-7xl mx-auto w-full pt-4">
|
||||
<BreadcrumbNav currentScreen={currentScreen} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main className="flex-1 overflow-y-auto p-4 lg:p-0">
|
||||
{/* <div className="max-w-7xl mx-auto p-8">
|
||||
<Outlet />
|
||||
</div> */}
|
||||
<div className="max-w-7xl mx-auto p-4 sm:p-6 md:p-8">
|
||||
<Outlet />
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="bg-muted border-t border-chrome-divider p-4 text-center text-sm text-muted-foreground mt-8">
|
||||
<p>© 2024 Knowledge Learning Centre. All rights reserved.</p>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ChatBot */}
|
||||
<ChatBot currentScreen={currentScreen} />
|
||||
</div>
|
||||
|
||||
);
|
||||
}
|
||||
103
src/components/ui/Card.css
Normal file
103
src/components/ui/Card.css
Normal file
@@ -0,0 +1,103 @@
|
||||
/* Card.css */
|
||||
.responsive-card {
|
||||
/* transition: all 0.2s ease; */
|
||||
}
|
||||
|
||||
/* Mobile-first responsive adjustments */
|
||||
@media (max-width: 640px) {
|
||||
.responsive-card {
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tablet and larger screens */
|
||||
@media (min-width: 768px) {
|
||||
.responsive-card {
|
||||
border-radius: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Desktop screens */
|
||||
@media (min-width: 1024px) {
|
||||
.responsive-card {
|
||||
border-radius: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Card hover effects for non-touch devices */
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.responsive-card:hover {
|
||||
/* box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), */
|
||||
/* 0 8px 10px -6px rgba(0, 0, 0, 0.1); */
|
||||
/* transform: translateY(-2px); */
|
||||
}
|
||||
}
|
||||
|
||||
/* High contrast mode support */
|
||||
@media (prefers-contrast: high) {
|
||||
.responsive-card {
|
||||
border-width: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reduced motion support */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.responsive-card {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.responsive-card:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode adjustments */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.responsive-card {
|
||||
border-color: rgba(112, 112, 112, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Very small screens (smartwatch size) */
|
||||
@media (max-width: 320px) {
|
||||
.responsive-card {
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Container query support for card header */
|
||||
@container card-header (max-width: 300px) {
|
||||
[data-slot="card-header"] {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
|
||||
[data-slot="card-action"] {
|
||||
grid-column: 1;
|
||||
grid-row: 3;
|
||||
justify-self: start;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Touch device optimizations */
|
||||
@media (pointer: coarse) {
|
||||
.responsive-card {
|
||||
min-height: 44px;
|
||||
/* Minimum tap target size */
|
||||
}
|
||||
|
||||
[data-slot="card-action"] button,
|
||||
[data-slot="card-action"] a {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Print styles */
|
||||
@media print {
|
||||
.responsive-card {
|
||||
border: 1px solid #000;
|
||||
box-shadow: none;
|
||||
break-inside: avoid;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,46 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "./utils";
|
||||
import "./Card.css"; // We'll create this CSS file
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const [screenSize, setScreenSize] = React.useState(getScreenSize());
|
||||
|
||||
// Get initial screen size
|
||||
function getScreenSize() {
|
||||
const width = window.innerWidth;
|
||||
if (width < 640) return 'xs';
|
||||
if (width < 768) return 'sm';
|
||||
if (width < 1024) return 'md';
|
||||
if (width < 1280) return 'lg';
|
||||
return 'xl';
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setScreenSize(getScreenSize());
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
// Responsive values based on screen size
|
||||
const responsivePadding = {
|
||||
'xs': 'p-4',
|
||||
'sm': 'p-5',
|
||||
'md': 'p-5',
|
||||
'lg': 'p-6',
|
||||
'xl': 'p-6'
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border",
|
||||
// "responsive-card bg-card text-card-foreground flex flex-col gap-4 rounded-xl border",
|
||||
// responsivePadding[screenSize as keyof typeof responsivePadding],
|
||||
"responsive-card bg-card text-card-foreground flex flex-col gap-4 rounded-xl border border-gray-300",
|
||||
responsivePadding[screenSize as keyof typeof responsivePadding],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -16,11 +49,42 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const [screenSize] = React.useState(getScreenSize());
|
||||
|
||||
function getScreenSize() {
|
||||
const width = window.innerWidth;
|
||||
if (width < 640) return 'xs';
|
||||
if (width < 768) return 'sm';
|
||||
if (width < 1024) return 'md';
|
||||
if (width < 1280) return 'lg';
|
||||
return 'xl';
|
||||
}
|
||||
|
||||
// Responsive values based on screen size
|
||||
const responsiveGap = {
|
||||
'xs': 'gap-1',
|
||||
'sm': 'gap-1.5',
|
||||
'md': 'gap-1.5',
|
||||
'lg': 'gap-1.5',
|
||||
'xl': 'gap-1.5'
|
||||
};
|
||||
|
||||
const responsivePadding = {
|
||||
'xs': 'px-4 pt-4',
|
||||
'sm': 'px-5 pt-5',
|
||||
'md': 'px-5 pt-5',
|
||||
'lg': 'px-6 pt-6',
|
||||
'xl': 'px-6 pt-6'
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 pt-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start",
|
||||
responsiveGap[screenSize as keyof typeof responsiveGap],
|
||||
responsivePadding[screenSize as keyof typeof responsivePadding],
|
||||
"has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -29,20 +93,60 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const [screenSize] = React.useState(getScreenSize());
|
||||
|
||||
function getScreenSize() {
|
||||
const width = window.innerWidth;
|
||||
if (width < 640) return 'xs';
|
||||
if (width < 768) return 'sm';
|
||||
if (width < 1024) return 'md';
|
||||
if (width < 1280) return 'lg';
|
||||
return 'xl';
|
||||
}
|
||||
|
||||
// Responsive font sizes
|
||||
const responsiveFont = {
|
||||
'xs': 'text-lg',
|
||||
'sm': 'text-xl',
|
||||
'md': 'text-xl',
|
||||
'lg': 'text-2xl',
|
||||
'xl': 'text-2xl'
|
||||
};
|
||||
|
||||
return (
|
||||
<h4
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none", className)}
|
||||
className={cn("leading-none font-semibold", responsiveFont[screenSize as keyof typeof responsiveFont], className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const [screenSize] = React.useState(getScreenSize());
|
||||
|
||||
function getScreenSize() {
|
||||
const width = window.innerWidth;
|
||||
if (width < 640) return 'xs';
|
||||
if (width < 768) return 'sm';
|
||||
if (width < 1024) return 'md';
|
||||
if (width < 1280) return 'lg';
|
||||
return 'xl';
|
||||
}
|
||||
|
||||
// Responsive font sizes
|
||||
const responsiveFont = {
|
||||
'xs': 'text-sm',
|
||||
'sm': 'text-base',
|
||||
'md': 'text-base',
|
||||
'lg': 'text-base',
|
||||
'xl': 'text-base'
|
||||
};
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground", className)}
|
||||
className={cn("text-muted-foreground", responsiveFont[screenSize as keyof typeof responsiveFont], className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -62,20 +166,65 @@ function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const [screenSize] = React.useState(getScreenSize());
|
||||
|
||||
function getScreenSize() {
|
||||
const width = window.innerWidth;
|
||||
if (width < 640) return 'xs';
|
||||
if (width < 768) return 'sm';
|
||||
if (width < 1024) return 'md';
|
||||
if (width < 1280) return 'lg';
|
||||
return 'xl';
|
||||
}
|
||||
|
||||
// Responsive padding
|
||||
const responsivePadding = {
|
||||
'xs': 'px-4',
|
||||
'sm': 'px-5',
|
||||
'md': 'px-5',
|
||||
'lg': 'px-6',
|
||||
'xl': 'px-6'
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6 [&:last-child]:pb-6", className)}
|
||||
className={cn(responsivePadding[screenSize as keyof typeof responsivePadding], "[&:last-child]:pb-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const [screenSize] = React.useState(getScreenSize());
|
||||
|
||||
function getScreenSize() {
|
||||
const width = window.innerWidth;
|
||||
if (width < 640) return 'xs';
|
||||
if (width < 768) return 'sm';
|
||||
if (width < 1024) return 'md';
|
||||
if (width < 1280) return 'lg';
|
||||
return 'xl';
|
||||
}
|
||||
|
||||
// Responsive padding
|
||||
const responsivePadding = {
|
||||
'xs': 'px-4 pb-4',
|
||||
'sm': 'px-5 pb-5',
|
||||
'md': 'px-5 pb-5',
|
||||
'lg': 'px-6 pb-6',
|
||||
'xl': 'px-6 pb-6'
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 pb-6 [.border-t]:pt-6", className)}
|
||||
className={cn(
|
||||
"flex items-center",
|
||||
responsivePadding[screenSize as keyof typeof responsivePadding],
|
||||
"[.border-t]:pt-6",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
25
src/hooks/useCountUp.ts
Normal file
25
src/hooks/useCountUp.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export const useCountUp = (end: number, duration: number = 1200) => {
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let start = 0;
|
||||
const increment = end / (duration / 16);
|
||||
const timer = setInterval(() => {
|
||||
start += increment;
|
||||
if (start >= end) {
|
||||
setCount(end);
|
||||
clearInterval(timer);
|
||||
} else {
|
||||
setCount(Math.floor(start));
|
||||
}
|
||||
}, 16);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [end, duration]);
|
||||
|
||||
return count;
|
||||
};
|
||||
|
||||
|
||||
25
src/hooks/useLocalStorage.ts
Normal file
25
src/hooks/useLocalStorage.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
export const useLocalStorage = <T,>(key: string, initialValue: T) => {
|
||||
const [storedValue, setStoredValue] = useState<T>(() => {
|
||||
try {
|
||||
const item = window.localStorage.getItem(key);
|
||||
return item ? (JSON.parse(item) as T) : initialValue;
|
||||
} catch (error) {
|
||||
return initialValue;
|
||||
}
|
||||
});
|
||||
|
||||
const setValue = (value: T) => {
|
||||
try {
|
||||
setStoredValue(value);
|
||||
window.localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch (error) {
|
||||
console.error('Error saving to localStorage:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return [storedValue, setValue] as const;
|
||||
};
|
||||
|
||||
|
||||
18
src/main.tsx
18
src/main.tsx
@@ -1,7 +1,15 @@
|
||||
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App.tsx";
|
||||
import "./styles/globals.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(<App />);
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App";
|
||||
import "./styles/globals.css";
|
||||
import { Provider } from "react-redux";
|
||||
import { store } from "./redux/store";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<Provider store={store}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</Provider>
|
||||
);
|
||||
|
||||
37
src/mock.ts
Normal file
37
src/mock.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { KPIData, Employee, Announcement, Deadline } from './types';
|
||||
|
||||
export const mockKPIData: KPIData[] = [
|
||||
{ title: 'Total Learners', value: 1247, change: 12, trend: 'up' },
|
||||
{ title: 'Active Courses', value: 89, change: 5, trend: 'up' },
|
||||
{ title: 'Completed Profilers', value: 342, change: -8, trend: 'down' },
|
||||
{ title: 'Average Progress', value: 73, change: 7, trend: 'up' }
|
||||
];
|
||||
|
||||
export const mockEmployees: Employee[] = [
|
||||
{ id: '1', name: 'Sarah Chen', email: 'sarah.chen@company.com', phone: '+61 4XX XXX XXX', status: 'Active', programme: 'Leadership Development', course: 'Strategic Thinking', progress: 85, lastActivity: '2 hours ago' },
|
||||
{ id: '2', name: 'Michael Rodriguez', email: 'michael.r@company.com', phone: '+61 4XX XXX XXX', status: 'Active', programme: 'Technical Skills', course: 'Data Analysis', progress: 62, lastActivity: '1 day ago' },
|
||||
{ id: '3', name: 'Emma Thompson', email: 'emma.thompson@company.com', phone: '+61 4XX XXX XXX', status: 'Pending', programme: 'Communication', course: 'Public Speaking', progress: 0, lastActivity: 'Never' },
|
||||
{ id: '4', name: 'David Kim', email: 'david.kim@company.com', phone: '+61 4XX XXX XXX', status: 'Active', programme: 'Project Management', course: 'Agile Methodology', progress: 94, lastActivity: '3 hours ago' },
|
||||
{ id: '5', name: 'Lisa Wang', email: 'lisa.wang@company.com', phone: '+61 4XX XXX XXX', status: 'Active', programme: 'Leadership Development', course: 'Team Management', progress: 78, lastActivity: '5 hours ago' },
|
||||
{ id: '6', name: 'James Wilson', email: 'james.wilson@company.com', phone: '+61 4XX XXX XXX', status: 'Inactive', programme: 'Technical Skills', course: 'Programming Basics', progress: 34, lastActivity: '2 weeks ago' },
|
||||
{ id: '7', name: 'Maria Garcia', email: 'maria.garcia@company.com', phone: '+61 4XX XXX XXX', status: 'Active', programme: 'Sales Training', course: 'Customer Relations', progress: 56, lastActivity: '1 day ago' },
|
||||
{ id: '8', name: 'Robert Lee', email: 'robert.lee@company.com', phone: '+61 4XX XXX XXX', status: 'Active', programme: 'Leadership Development', course: 'Decision Making', progress: 89, lastActivity: '4 hours ago' },
|
||||
{ id: '9', name: 'Jennifer Davis', email: 'jennifer.davis@company.com', phone: '+61 4XX XXX XXX', status: 'Pending', programme: 'Communication', course: 'Written Communication', progress: 0, lastActivity: 'Never' },
|
||||
{ id: '10', name: 'Thomas Brown', email: 'thomas.brown@company.com', phone: '+61 4XX XXX XXX', status: 'Active', programme: 'Project Management', course: 'Risk Management', progress: 71, lastActivity: '6 hours ago' }
|
||||
];
|
||||
|
||||
export const mockAnnouncements: Announcement[] = [
|
||||
{ id: '1', title: 'New Learning Module Available', content: 'Advanced Analytics course is now live in the system.', type: 'announcement', timestamp: '2 hours ago', pinned: true },
|
||||
{ id: '2', title: 'Reminder: Quarterly Reviews Due', content: 'Please complete all quarterly progress reviews by Friday.', type: 'reminder', timestamp: '5 hours ago' },
|
||||
{ id: '3', title: 'System Maintenance Scheduled', content: 'Learning platform will be offline Saturday 2-4 AM for updates.', type: 'announcement', timestamp: '1 day ago' }
|
||||
];
|
||||
|
||||
export const mockDeadlines: Deadline[] = [
|
||||
{ id: '1', title: 'Leadership Webinar Series', type: 'webinar', dueDate: 'Today', dueTime: '2:00 PM' },
|
||||
{ id: '2', title: 'Communication Skills Assessment', type: 'profiler', dueDate: 'Tomorrow', dueTime: '11:59 PM' },
|
||||
{ id: '3', title: 'Project Management Workshop', type: 'webinar', dueDate: 'Dec 30', dueTime: '10:00 AM' },
|
||||
{ id: '4', title: 'Technical Skills Profiler', type: 'profiler', dueDate: 'Jan 2', dueTime: '5:00 PM' },
|
||||
{ id: '5', title: 'Team Building Session', type: 'webinar', dueDate: 'Jan 5', dueTime: '3:30 PM' }
|
||||
];
|
||||
|
||||
|
||||
218
src/pages/AnalyticsScreen.tsx
Normal file
218
src/pages/AnalyticsScreen.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
import { useState } from "react";
|
||||
import { Employee, KPIData } from "../types";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/Card";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../components/ui/select";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { Download } from "lucide-react";
|
||||
import { FileText } from "lucide-react";
|
||||
import { EmployeeTable } from "../components/EmployeeTable";
|
||||
import { BarChart3 } from "lucide-react";
|
||||
import { KPICard } from "../components/KPICard";
|
||||
// import { mockEmployees } from "../data/mockEmployees";
|
||||
|
||||
export default function AnalyticsScreen({ filters }: { filters?: any }) {
|
||||
const [dateRange, setDateRange] = useState('last-30-days');
|
||||
const [selectedProgrammes, setSelectedProgrammes] = useState<string[]>(['all']);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
const analyticsKPIData: KPIData[] = [
|
||||
{ title: 'Total Learners', value: 1247, change: 8.2, trend: 'up' },
|
||||
{ title: 'New Enrolments', value: 89, change: 15.3, trend: 'up' },
|
||||
{ title: 'Course Completions', value: 342, change: -2.1, trend: 'down' },
|
||||
{ title: 'Assessment Rates', value: 78, change: 5.7, trend: 'up' }
|
||||
];
|
||||
|
||||
const handleExport = async (format: 'csv' | 'pdf') => {
|
||||
setExporting(true);
|
||||
// Simulate export process
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
setExporting(false);
|
||||
console.log(`Exported as ${format.toUpperCase()}`);
|
||||
};
|
||||
|
||||
const handleRunReport = () => {
|
||||
setLoading(true);
|
||||
// Simulate report generation
|
||||
setTimeout(() => setLoading(false), 1500);
|
||||
};
|
||||
const mockEmployees: Employee[] = [
|
||||
{ id: '1', name: 'Sarah Chen', email: 'sarah.chen@company.com', phone: '+61 4XX XXX XXX', status: 'Active', programme: 'Leadership Development', course: 'Strategic Thinking', progress: 85, lastActivity: '2 hours ago' },
|
||||
{ id: '2', name: 'Michael Rodriguez', email: 'michael.r@company.com', phone: '+61 4XX XXX XXX', status: 'Active', programme: 'Technical Skills', course: 'Data Analysis', progress: 62, lastActivity: '1 day ago' },
|
||||
{ id: '3', name: 'Emma Thompson', email: 'emma.thompson@company.com', phone: '+61 4XX XXX XXX', status: 'Pending', programme: 'Communication', course: 'Public Speaking', progress: 0, lastActivity: 'Never' },
|
||||
{ id: '4', name: 'David Kim', email: 'david.kim@company.com', phone: '+61 4XX XXX XXX', status: 'Active', programme: 'Project Management', course: 'Agile Methodology', progress: 94, lastActivity: '3 hours ago' },
|
||||
{ id: '5', name: 'Lisa Wang', email: 'lisa.wang@company.com', phone: '+61 4XX XXX XXX', status: 'Active', programme: 'Leadership Development', course: 'Team Management', progress: 78, lastActivity: '5 hours ago' },
|
||||
{ id: '6', name: 'James Wilson', email: 'james.wilson@company.com', phone: '+61 4XX XXX XXX', status: 'Inactive', programme: 'Technical Skills', course: 'Programming Basics', progress: 34, lastActivity: '2 weeks ago' },
|
||||
{ id: '7', name: 'Maria Garcia', email: 'maria.garcia@company.com', phone: '+61 4XX XXX XXX', status: 'Active', programme: 'Sales Training', course: 'Customer Relations', progress: 56, lastActivity: '1 day ago' },
|
||||
{ id: '8', name: 'Robert Lee', email: 'robert.lee@company.com', phone: '+61 4XX XXX XXX', status: 'Active', programme: 'Leadership Development', course: 'Decision Making', progress: 89, lastActivity: '4 hours ago' },
|
||||
{ id: '9', name: 'Jennifer Davis', email: 'jennifer.davis@company.com', phone: '+61 4XX XXX XXX', status: 'Pending', programme: 'Communication', course: 'Written Communication', progress: 0, lastActivity: 'Never' },
|
||||
{ id: '10', name: 'Thomas Brown', email: 'thomas.brown@company.com', phone: '+61 4XX XXX XXX', status: 'Active', programme: 'Project Management', course: 'Risk Management', progress: 71, lastActivity: '6 hours ago' }
|
||||
];
|
||||
|
||||
const chartData = [
|
||||
{ month: 'Jan', enrolments: 45, completions: 38, assessments: 42 },
|
||||
{ month: 'Feb', enrolments: 52, completions: 41, assessments: 38 },
|
||||
{ month: 'Mar', enrolments: 48, completions: 44, assessments: 46 },
|
||||
{ month: 'Apr', enrolments: 61, completions: 49, assessments: 52 },
|
||||
{ month: 'May', enrolments: 55, completions: 52, assessments: 48 },
|
||||
{ month: 'Jun', enrolments: 67, completions: 58, assessments: 61 }
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Filter Bar */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col lg:flex-row gap-4 items-start lg:items-center justify-between">
|
||||
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center">
|
||||
<div>
|
||||
<label htmlFor="date-range" className="block text-sm font-medium mb-2">
|
||||
Date Range
|
||||
</label>
|
||||
<Select value={dateRange} onValueChange={setDateRange}>
|
||||
<SelectTrigger id="date-range" className="w-[180px]" aria-controls="kpi-charts">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="last-7-days">Last 7 days</SelectItem>
|
||||
<SelectItem value="last-30-days">Last 30 days</SelectItem>
|
||||
<SelectItem value="last-90-days">Last 90 days</SelectItem>
|
||||
<SelectItem value="custom">Custom range</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="programme-filter" className="block text-sm font-medium mb-2">
|
||||
Programmes
|
||||
</label>
|
||||
<Select>
|
||||
<SelectTrigger id="programme-filter" className="w-[200px]" aria-controls="kpi-charts">
|
||||
<SelectValue placeholder="Select programmes" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Programmes</SelectItem>
|
||||
<SelectItem value="leadership">Leadership Development</SelectItem>
|
||||
<SelectItem value="technical">Technical Skills</SelectItem>
|
||||
<SelectItem value="communication">Communication</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleRunReport} disabled={loading} className="min-tap-44">
|
||||
{loading && <RefreshCw className="h-4 w-4 mr-2 animate-spin" />}
|
||||
Run Report
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* KPI Cards */}
|
||||
<div id="kpi-charts" className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{analyticsKPIData.map((kpi, index) => (
|
||||
<KPICard
|
||||
key={index}
|
||||
data={kpi}
|
||||
className="animate-fade-in"
|
||||
style={{ animationDelay: `${index * 100}ms` }}
|
||||
aria-label={`${kpi.title}: ${kpi.value}${kpi.title.includes('Rates') ? '%' : ''}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Charts Panel */}
|
||||
<Card className="animate-slide-up" style={{ animationDelay: '400ms' }}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Learning Analytics Overview</CardTitle>
|
||||
<CardDescription>Key metrics over time</CardDescription>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Last refreshed: 10 minutes ago
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div
|
||||
className="h-80 flex items-center justify-center border border-dashed border-muted rounded-lg"
|
||||
role="img"
|
||||
aria-describedby="chart-description"
|
||||
>
|
||||
<div className="text-center">
|
||||
<BarChart3 className="h-12 w-12 mx-auto mb-2 text-muted-foreground" />
|
||||
<p className="text-muted-foreground">Interactive chart would be rendered here</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Line/Bar chart showing enrolments, completions, and assessments over time
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="chart-description" className="sr-only">
|
||||
Line and bar chart showing learning analytics over the selected time period.
|
||||
Displays new enrolments, course completions, and assessment completion rates.
|
||||
Chart includes interactive legend for toggling data series visibility.
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Detailed Table */}
|
||||
<Card className="animate-slide-up" style={{ animationDelay: '600ms' }}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Assignments & Progress Detail</CardTitle>
|
||||
<CardDescription>Complete learner progress breakdown</CardDescription>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleExport('csv')}
|
||||
disabled={exporting}
|
||||
className="min-tap-44"
|
||||
aria-live="polite"
|
||||
>
|
||||
{exporting ? (
|
||||
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Export CSV
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleExport('pdf')}
|
||||
disabled={exporting}
|
||||
className="min-tap-44"
|
||||
aria-live="polite"
|
||||
>
|
||||
{exporting ? (
|
||||
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Export PDF
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<EmployeeTable
|
||||
employees={mockEmployees}
|
||||
showProgress={true}
|
||||
maxHeight="500px"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Data Freshness Note */}
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
<span role="tooltip" title="Data is automatically refreshed every 15 minutes">
|
||||
Last refreshed: {new Date().toLocaleTimeString()} •
|
||||
Next refresh in 4 minutes
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
581
src/pages/HRHomeScreen.tsx
Normal file
581
src/pages/HRHomeScreen.tsx
Normal file
@@ -0,0 +1,581 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useLocalStorage } from "../hooks/useLocalStorage";
|
||||
import { Card } from "../components/ui/Card";
|
||||
import { CardHeader } from "../components/ui/Card";
|
||||
import { CardTitle } from "../components/ui/Card";
|
||||
import { CardDescription } from "../components/ui/Card";
|
||||
import { Skeleton } from "../components/ui/skeleton";
|
||||
import { CardContent } from "../components/ui/Card";
|
||||
import { KPICard } from "../components/KPICard";
|
||||
import { BarChart3, ChevronRight } from "lucide-react";
|
||||
import { Download } from "lucide-react";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { Badge } from "../components/ui/badge";
|
||||
import { Select } from "../components/ui/select";
|
||||
import { SelectItem } from "../components/ui/select";
|
||||
import { SelectTrigger } from "../components/ui/select";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { SelectValue } from "../components/ui/select";
|
||||
import { SelectContent } from "../components/ui/select";
|
||||
import { mockKPIData } from "../mock";
|
||||
import { mockDeadlines } from "../mock";
|
||||
import { Plus } from "lucide-react";
|
||||
import { BookOpen } from "lucide-react";
|
||||
import { MessageSquare } from "lucide-react";
|
||||
import { Calendar } from "lucide-react";
|
||||
import { FileText } from "lucide-react";
|
||||
import { EmployeeTable } from "../components/EmployeeTable";
|
||||
import { Announcement, Employee } from "../types";
|
||||
import { useGetPostsQuery } from "../redux/services/demo.services";
|
||||
|
||||
const mockEmployees: Employee[] = [
|
||||
{ id: '1', name: 'Sarah Chen', email: 'sarah.chen@company.com', phone: '+61 4XX XXX XXX', status: 'Active', programme: 'Leadership Development', course: 'Strategic Thinking', progress: 85, lastActivity: '2 hours ago' },
|
||||
{ id: '2', name: 'Michael Rodriguez', email: 'michael.r@company.com', phone: '+61 4XX XXX XXX', status: 'Active', programme: 'Technical Skills', course: 'Data Analysis', progress: 62, lastActivity: '1 day ago' },
|
||||
{ id: '3', name: 'Emma Thompson', email: 'emma.thompson@company.com', phone: '+61 4XX XXX XXX', status: 'Pending', programme: 'Communication', course: 'Public Speaking', progress: 0, lastActivity: 'Never' },
|
||||
{ id: '4', name: 'David Kim', email: 'david.kim@company.com', phone: '+61 4XX XXX XXX', status: 'Active', programme: 'Project Management', course: 'Agile Methodology', progress: 94, lastActivity: '3 hours ago' },
|
||||
{ id: '5', name: 'Lisa Wang', email: 'lisa.wang@company.com', phone: '+61 4XX XXX XXX', status: 'Active', programme: 'Leadership Development', course: 'Team Management', progress: 78, lastActivity: '5 hours ago' },
|
||||
{ id: '6', name: 'James Wilson', email: 'james.wilson@company.com', phone: '+61 4XX XXX XXX', status: 'Inactive', programme: 'Technical Skills', course: 'Programming Basics', progress: 34, lastActivity: '2 weeks ago' },
|
||||
{ id: '7', name: 'Maria Garcia', email: 'maria.garcia@company.com', phone: '+61 4XX XXX XXX', status: 'Active', programme: 'Sales Training', course: 'Customer Relations', progress: 56, lastActivity: '1 day ago' },
|
||||
{ id: '8', name: 'Robert Lee', email: 'robert.lee@company.com', phone: '+61 4XX XXX XXX', status: 'Active', programme: 'Leadership Development', course: 'Decision Making', progress: 89, lastActivity: '4 hours ago' },
|
||||
{ id: '9', name: 'Jennifer Davis', email: 'jennifer.davis@company.com', phone: '+61 4XX XXX XXX', status: 'Pending', programme: 'Communication', course: 'Written Communication', progress: 0, lastActivity: 'Never' },
|
||||
{ id: '10', name: 'Thomas Brown', email: 'thomas.brown@company.com', phone: '+61 4XX XXX XXX', status: 'Active', programme: 'Project Management', course: 'Risk Management', progress: 71, lastActivity: '6 hours ago' }
|
||||
];
|
||||
// Screen Components
|
||||
export default function HRHomeScreen({ onNavigate }: { onNavigate: (screen: string, filters?: any) => void }) {
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [prefersReducedMotion] = useLocalStorage('prefersReducedMotion', false);
|
||||
const { data: posts, isLoading, error } = useGetPostsQuery();
|
||||
console.log(posts, 'posts');
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setLoading(false), 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
const handleKPIClick = (kpiTitle: string) => {
|
||||
let filters = {};
|
||||
switch (kpiTitle) {
|
||||
case 'Total Learners':
|
||||
filters = { status: 'all' };
|
||||
break;
|
||||
case 'Active Courses':
|
||||
filters = { status: 'active' };
|
||||
break;
|
||||
case 'Completed Profilers':
|
||||
filters = { completed: true };
|
||||
break;
|
||||
default:
|
||||
filters = {};
|
||||
}
|
||||
onNavigate('learners', filters);
|
||||
};
|
||||
|
||||
const cohortData = [
|
||||
{ name: 'Leadership Development', notStarted: 15, inProgress: 28, completed: 42 },
|
||||
{ name: 'Technical Skills', notStarted: 22, inProgress: 35, completed: 38 },
|
||||
{ name: 'Communication', notStarted: 18, inProgress: 24, completed: 31 },
|
||||
{ name: 'Project Management', notStarted: 12, inProgress: 19, completed: 28 }
|
||||
];
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader className="pb-2">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-8 w-16" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-48" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const mockAnnouncements: Announcement[] = [
|
||||
{ id: '1', title: 'New Learning Module Available', content: 'Advanced Analytics course is now live in the system.', type: 'announcement', timestamp: '2 hours ago', pinned: true },
|
||||
{ id: '2', title: 'Reminder: Quarterly Reviews Due', content: 'Please complete all quarterly progress reviews by Friday.', type: 'reminder', timestamp: '5 hours ago' },
|
||||
{ id: '3', title: 'System Maintenance Scheduled', content: 'Learning platform will be offline Saturday 2-4 AM for updates.', type: 'announcement', timestamp: '1 day ago' }
|
||||
];
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Welcome Section */}
|
||||
<div className={`space-y-4 ${prefersReducedMotion ? '' : 'animate-fade-in'}`}>
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-3xl font-bold text-foreground">Hello HR Pooja 👋</h1>
|
||||
<p className="text-lg text-muted-foreground">See what's happening today at Acme Corp</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4"> */}
|
||||
|
||||
{mockKPIData.map((kpi, index) => (
|
||||
<KPICard
|
||||
key={index}
|
||||
data={kpi}
|
||||
onClick={() => handleKPIClick(kpi.title)}
|
||||
className={prefersReducedMotion ? '' : 'animate-fade-in'}
|
||||
style={{ animationDelay: prefersReducedMotion ? '0ms' : `${index * 100 + 200}ms`, }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Employee Assignment & Progress */}
|
||||
|
||||
<Card className={prefersReducedMotion ? '' : 'animate-slide-up'}
|
||||
style={{ animationDelay: '600ms' }}
|
||||
>
|
||||
<CardHeader >
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="text-lg sm:text-xl truncate">Employee Assignment & Progress</CardTitle>
|
||||
<CardDescription className="truncate">Snapshot of current learning activities</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select defaultValue="all">
|
||||
<SelectTrigger className="w-full sm:w-[140px] lg:w-[180px]">
|
||||
<SelectValue placeholder="Filter by programme" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Programmes</SelectItem>
|
||||
<SelectItem value="leadership">Leadership Development</SelectItem>
|
||||
<SelectItem value="technical">Technical Skills</SelectItem>
|
||||
<SelectItem value="communication">Communication</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="flex gap-2 w-full sm:w-auto">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onNavigate('analytics')}
|
||||
className="min-tap-44 flex-1 sm:flex-initial"
|
||||
size="sm"
|
||||
>
|
||||
<BarChart3 className="h-4 w-4 mr-1 sm:mr-2" />
|
||||
<span className="hidden sm:inline">View all in Analytics</span>
|
||||
<span className="sm:hidden">Analytics</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="min-tap-44 flex-1 sm:flex-initial"
|
||||
size="sm"
|
||||
>
|
||||
<Download className="h-4 w-4 mr-1 sm:mr-2" />
|
||||
<span className="hidden sm:inline">Download CSV</span>
|
||||
<span className="sm:hidden">CSV</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<EmployeeTable
|
||||
employees={mockEmployees.slice(0, 6)}
|
||||
onEdit={(employee: any) => onNavigate('learners', { editEmployee: employee.id })}
|
||||
maxHeight="360px"
|
||||
compact={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Mobile view alternative */}
|
||||
<div className="sm:hidden mt-4 space-y-3">
|
||||
<div className="text-center">
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={() => onNavigate('analytics')}
|
||||
className="text-sm"
|
||||
>
|
||||
View full employee table in Analytics
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
{/* {mockEmployees.slice(0, 3).map((employee) => (
|
||||
<div key={employee.id} className="p-3 border rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className="font-medium text-sm truncate">{employee.name}</h4>
|
||||
<p className="text-xs text-muted-foreground truncate">{employee.department}</p>
|
||||
</div>
|
||||
<Badge variant={employee.status === 'Active' ? 'default' :
|
||||
employee.status === 'Inactive' ? 'secondary' : 'destructive'}>
|
||||
{employee.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Progress: {employee.completion}%
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onNavigate('learners', { editEmployee: employee.id })}
|
||||
className="h-8 px-2"
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))} */}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Cohort Progress Chart */}
|
||||
{/* <Card className={prefersReducedMotion ? '' : 'animate-slide-up'} style={{ animationDelay: '800ms' }}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Cohort Progress</CardTitle>
|
||||
<CardDescription>Progress overview by programme</CardDescription>
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
<RefreshCw className="h-3 w-3 mr-1" />
|
||||
Auto-refresh
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div
|
||||
className="space-y-4"
|
||||
role="img"
|
||||
aria-describedby="cohort-chart-desc"
|
||||
>
|
||||
<div id="cohort-chart-desc" className="sr-only">
|
||||
Stacked bar chart showing progress across different learning programmes.
|
||||
Each bar represents not started, in progress, and completed learners.
|
||||
</div>
|
||||
{cohortData.map((cohort, index) => {
|
||||
const total = cohort.notStarted + cohort.inProgress + cohort.completed;
|
||||
const notStartedPercent = (cohort.notStarted / total) * 100;
|
||||
const inProgressPercent = (cohort.inProgress / total) * 100;
|
||||
const completedPercent = (cohort.completed / total) * 100;
|
||||
|
||||
return (
|
||||
<div key={index} className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium">{cohort.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{total} learners</span>
|
||||
</div>
|
||||
<div className="flex h-4 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className="bg-status-error"
|
||||
style={{ width: `${notStartedPercent}%` }}
|
||||
title={`Not Started: ${cohort.notStarted}`}
|
||||
/>
|
||||
<div
|
||||
className="bg-status-warn"
|
||||
style={{ width: `${inProgressPercent}%` }}
|
||||
title={`In Progress: ${cohort.inProgress}`}
|
||||
/>
|
||||
<div
|
||||
className="bg-status-success"
|
||||
style={{ width: `${completedPercent}%` }}
|
||||
title={`Completed: ${cohort.completed}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>Not Started: {cohort.notStarted}</span>
|
||||
<span>In Progress: {cohort.inProgress}</span>
|
||||
<span>Completed: {cohort.completed}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card> */}
|
||||
|
||||
{/* Upcoming Deadlines */}
|
||||
{/* <Card className={prefersReducedMotion ? '' : 'animate-slide-up'} style={{ animationDelay: '900ms' }}>
|
||||
<CardHeader>
|
||||
<CardTitle>Upcoming Deadlines</CardTitle>
|
||||
<CardDescription>Next 7 days</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{mockDeadlines.map((deadline) => (
|
||||
<div
|
||||
key={deadline.id}
|
||||
className="flex items-center justify-between p-3 bg-muted/50 rounded-lg hover:bg-muted transition-colors cursor-pointer min-tap-44"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${deadline.title} due ${deadline.dueDate} at ${deadline.dueTime}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-md ${deadline.type === 'webinar' ? 'bg-brand-primary text-brand-contrast' : 'bg-status-warn text-status-warn-foreground'}`}>
|
||||
{deadline.type === 'webinar' ?
|
||||
<Calendar className="h-4 w-4" /> :
|
||||
<FileText className="h-4 w-4" />
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-sm">{deadline.title}</p>
|
||||
<p className="text-xs text-muted-foreground capitalize">{deadline.type}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<Badge variant={deadline.dueDate === 'Today' ? 'destructive' : 'secondary'} className="text-xs">
|
||||
{deadline.dueDate}
|
||||
</Badge>
|
||||
<p className="text-xs text-muted-foreground mt-1">{deadline.dueTime}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card> */}
|
||||
{/* </div> */}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-6 lg:gap-8 w-full">
|
||||
{/* Cohort Progress Chart */}
|
||||
<Card
|
||||
className={`flex flex-col ${prefersReducedMotion ? '' : 'animate-slide-up'}`}
|
||||
style={{ animationDelay: '800ms' }}
|
||||
>
|
||||
<CardHeader className="px-4 sm:px-6 py-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="text-base sm:text-lg md:text-xl">Cohort Progress</CardTitle>
|
||||
<CardDescription className="text-sm md:text-base">
|
||||
Progress overview by programme
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] sm:text-xs flex items-center gap-1 self-start sm:self-center"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
Auto-refresh
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="px-4 sm:px-6 pb-4 sm:pb-6">
|
||||
<div
|
||||
className="space-y-5"
|
||||
role="img"
|
||||
aria-describedby="cohort-chart-desc"
|
||||
>
|
||||
<div id="cohort-chart-desc" className="sr-only">
|
||||
Stacked bar chart showing progress across different learning programmes.
|
||||
Each bar represents not started, in progress, and completed learners.
|
||||
</div>
|
||||
|
||||
{cohortData.map((cohort, index) => {
|
||||
const total = cohort.notStarted + cohort.inProgress + cohort.completed;
|
||||
const notStartedPercent = (cohort.notStarted / total) * 100;
|
||||
const inProgressPercent = (cohort.inProgress / total) * 100;
|
||||
const completedPercent = (cohort.completed / total) * 100;
|
||||
|
||||
return (
|
||||
<div key={index} className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm md:text-base font-medium">{cohort.name}</span>
|
||||
<span className="text-xs md:text-sm text-muted-foreground">{total} learners</span>
|
||||
</div>
|
||||
<div className="flex h-3 sm:h-4 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className="bg-status-error"
|
||||
style={{ width: `${notStartedPercent}%` }}
|
||||
title={`Not Started: ${cohort.notStarted}`}
|
||||
/>
|
||||
<div
|
||||
className="bg-status-warn"
|
||||
style={{ width: `${inProgressPercent}%` }}
|
||||
title={`In Progress: ${cohort.inProgress}`}
|
||||
/>
|
||||
<div
|
||||
className="bg-status-success"
|
||||
style={{ width: `${completedPercent}%` }}
|
||||
title={`Completed: ${cohort.completed}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-[11px] sm:text-xs text-muted-foreground">
|
||||
<span>Not Started: {cohort.notStarted}</span>
|
||||
<span>In Progress: {cohort.inProgress}</span>
|
||||
<span>Completed: {cohort.completed}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Upcoming Deadlines */}
|
||||
<Card
|
||||
className={`flex flex-col ${prefersReducedMotion ? '' : 'animate-slide-up'}`}
|
||||
style={{ animationDelay: '900ms' }}
|
||||
>
|
||||
<CardHeader className="px-4 sm:px-6 py-4">
|
||||
<CardTitle className="text-base sm:text-lg md:text-xl">Upcoming Deadlines</CardTitle>
|
||||
<CardDescription className="text-sm md:text-base">Next 7 days</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="px-4 sm:px-6 pb-4 sm:pb-6">
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
{mockDeadlines.map((deadline) => (
|
||||
<div
|
||||
key={deadline.id}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between p-3 sm:p-4 bg-muted/50 rounded-lg hover:bg-muted transition-colors cursor-pointer min-tap-44 gap-2 sm:gap-0"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${deadline.title} due ${deadline.dueDate} at ${deadline.dueTime}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`p-2 rounded-md ${deadline.type === 'webinar'
|
||||
? 'bg-brand-primary text-brand-contrast'
|
||||
: 'bg-status-warn text-status-warn-foreground'
|
||||
}`}
|
||||
>
|
||||
{deadline.type === 'webinar' ? (
|
||||
<Calendar className="h-4 w-4" />
|
||||
) : (
|
||||
<FileText className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-sm md:text-base">{deadline.title}</p>
|
||||
<p className="text-xs md:text-sm text-muted-foreground capitalize">
|
||||
{deadline.type}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-left sm:text-right">
|
||||
<Badge
|
||||
variant={deadline.dueDate === 'Today' ? 'destructive' : 'secondary'}
|
||||
className="text-[10px] sm:text-xs"
|
||||
>
|
||||
{deadline.dueDate}
|
||||
</Badge>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground mt-1">
|
||||
{deadline.dueTime}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
{/* Quick Links */}
|
||||
<Card className={prefersReducedMotion ? '' : 'animate-slide-up'} style={{ animationDelay: '1000ms' }}>
|
||||
<CardHeader>
|
||||
<CardTitle>Quick Actions</CardTitle>
|
||||
<CardDescription>Common HR tasks</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[
|
||||
{ title: 'Add Learners', icon: Plus, action: () => onNavigate('learners', { action: 'add' }) },
|
||||
{ title: 'Assign Courses', icon: BookOpen, action: () => onNavigate('learners', { action: 'assign' }) },
|
||||
{ title: 'Download Reports', icon: Download, action: () => onNavigate('analytics') },
|
||||
{ title: 'Testimonials Queue', icon: MessageSquare, action: () => onNavigate('testimonials') }
|
||||
].map((link, index) => {
|
||||
const Icon = link.icon;
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
onClick={link.action}
|
||||
className={`
|
||||
flex flex-col items-center justify-center p-6 bg-muted/50 hover:bg-muted rounded-lg
|
||||
transition-all duration-200 min-h-[120px] min-w-[120px] gap-3 min-tap-44
|
||||
${prefersReducedMotion ? '' : 'animate-scale-hover'}
|
||||
`}
|
||||
aria-label={link.title}
|
||||
aria-controls={link.title === 'Add Learners' ? 'learners-screen' : undefined}
|
||||
>
|
||||
<Icon className="h-6 w-6 text-brand-primary" />
|
||||
<span className="text-sm font-medium text-center">{link.title}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Announcements & Reminders */}
|
||||
<Card className={prefersReducedMotion ? '' : 'animate-slide-up'} style={{ animationDelay: '1100ms' }}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Announcements & Reminders</CardTitle>
|
||||
<CardDescription>Recent updates and notifications</CardDescription>
|
||||
</div>
|
||||
<Select defaultValue="all">
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All</SelectItem>
|
||||
<SelectItem value="announcements">Announcements</SelectItem>
|
||||
<SelectItem value="reminders">Reminders</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{mockAnnouncements.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`
|
||||
p-4 rounded-lg border transition-all duration-180
|
||||
${item.pinned ? 'bg-status-warn/10 border-status-warn/20' : 'bg-muted/50 border-transparent'}
|
||||
hover:border-chrome-divider cursor-pointer
|
||||
`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded="false"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h4 className="font-medium text-sm">{item.title}</h4>
|
||||
{item.pinned && (
|
||||
<Badge variant="secondary" className="text-xs">Pinned</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-2">{item.content}</p>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Badge variant="outline" className="text-xs capitalize">
|
||||
{item.type}
|
||||
</Badge>
|
||||
<span>{item.timestamp}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
593
src/pages/LearnersScreen.tsx
Normal file
593
src/pages/LearnersScreen.tsx
Normal file
@@ -0,0 +1,593 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Employee } from "../types";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/Card";
|
||||
|
||||
import { Badge } from "../components/ui/badge";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../components/ui/select";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "../components/ui/sheet";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "../components/ui/dialog";
|
||||
import { Tabs } from "../components/ui/tabs";
|
||||
import { TabsList } from "../components/ui/tabs";
|
||||
import { TabsTrigger } from "../components/ui/tabs";
|
||||
import { TabsContent } from "../components/ui/tabs";
|
||||
import { Progress } from "../components/ui/progress";
|
||||
import { Download, Edit, MoreHorizontal, Plus, Search, Upload } from "lucide-react";
|
||||
// import { mockEmployees } from "../data/mockEmployees";
|
||||
|
||||
export default function LearnersScreen({ filters }: { filters?: any }) {
|
||||
const mockEmployees: Employee[] = [
|
||||
{ id: '1', name: 'Sarah Chen', email: 'sarah.chen@company.com', phone: '+61 4XX XXX XXX', status: 'Active', programme: 'Leadership Development', course: 'Strategic Thinking', progress: 85, lastActivity: '2 hours ago' },
|
||||
{ id: '2', name: 'Michael Rodriguez', email: 'michael.r@company.com', phone: '+61 4XX XXX XXX', status: 'Active', programme: 'Technical Skills', course: 'Data Analysis', progress: 62, lastActivity: '1 day ago' },
|
||||
{ id: '3', name: 'Emma Thompson', email: 'emma.thompson@company.com', phone: '+61 4XX XXX XXX', status: 'Pending', programme: 'Communication', course: 'Public Speaking', progress: 0, lastActivity: 'Never' },
|
||||
{ id: '4', name: 'David Kim', email: 'david.kim@company.com', phone: '+61 4XX XXX XXX', status: 'Active', programme: 'Project Management', course: 'Agile Methodology', progress: 94, lastActivity: '3 hours ago' },
|
||||
{ id: '5', name: 'Lisa Wang', email: 'lisa.wang@company.com', phone: '+61 4XX XXX XXX', status: 'Active', programme: 'Leadership Development', course: 'Team Management', progress: 78, lastActivity: '5 hours ago' },
|
||||
{ id: '6', name: 'James Wilson', email: 'james.wilson@company.com', phone: '+61 4XX XXX XXX', status: 'Inactive', programme: 'Technical Skills', course: 'Programming Basics', progress: 34, lastActivity: '2 weeks ago' },
|
||||
{ id: '7', name: 'Maria Garcia', email: 'maria.garcia@company.com', phone: '+61 4XX XXX XXX', status: 'Active', programme: 'Sales Training', course: 'Customer Relations', progress: 56, lastActivity: '1 day ago' },
|
||||
{ id: '8', name: 'Robert Lee', email: 'robert.lee@company.com', phone: '+61 4XX XXX XXX', status: 'Active', programme: 'Leadership Development', course: 'Decision Making', progress: 89, lastActivity: '4 hours ago' },
|
||||
{ id: '9', name: 'Jennifer Davis', email: 'jennifer.davis@company.com', phone: '+61 4XX XXX XXX', status: 'Pending', programme: 'Communication', course: 'Written Communication', progress: 0, lastActivity: 'Never' },
|
||||
{ id: '10', name: 'Thomas Brown', email: 'thomas.brown@company.com', phone: '+61 4XX XXX XXX', status: 'Active', programme: 'Project Management', course: 'Risk Management', progress: 71, lastActivity: '6 hours ago' }
|
||||
];
|
||||
const [employees, setEmployees] = useState(mockEmployees);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [selectedEmployees, setSelectedEmployees] = useState<string[]>([]);
|
||||
const [showAddDrawer, setShowAddDrawer] = useState(false);
|
||||
const [showImportModal, setShowImportModal] = useState(false);
|
||||
const [showAssignModal, setShowAssignModal] = useState(false);
|
||||
const [showEditDrawer, setShowEditDrawer] = useState(false);
|
||||
const [editingEmployee, setEditingEmployee] = useState<Employee | null>(null);
|
||||
const [newEmployee, setNewEmployee] = useState({ name: '', email: '', phone: '' });
|
||||
const [bulkActionVisible, setBulkActionVisible] = useState(false);
|
||||
|
||||
const debouncedSearch = useCallback(
|
||||
(term: string) => {
|
||||
// Simulating search with 300ms delay
|
||||
const timer = setTimeout(() => {
|
||||
setSearchTerm(term);
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const filteredEmployees = employees.filter((emp: any) => {
|
||||
const matchesSearch = emp.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
emp.email.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesStatus = statusFilter === 'all' || emp.status === statusFilter;
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
|
||||
const handleEmployeeSelect = (employeeId: string, selected: boolean) => {
|
||||
if (selected) {
|
||||
setSelectedEmployees(prev => [...prev, employeeId]);
|
||||
} else {
|
||||
setSelectedEmployees(prev => prev.filter(id => id !== employeeId));
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkSelect = (selectAll: boolean) => {
|
||||
if (selectAll) {
|
||||
setSelectedEmployees(filteredEmployees.map((emp: any) => emp.id));
|
||||
} else {
|
||||
setSelectedEmployees([]);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setBulkActionVisible(selectedEmployees.length > 0);
|
||||
}, [selectedEmployees]);
|
||||
|
||||
const handleAddEmployee = () => {
|
||||
if (newEmployee.name && newEmployee.email) {
|
||||
const newEmp: Employee = {
|
||||
id: Date.now().toString(),
|
||||
name: newEmployee.name,
|
||||
email: newEmployee.email,
|
||||
phone: newEmployee.phone,
|
||||
status: 'Pending'
|
||||
};
|
||||
setEmployees((prev: any) => [...prev, newEmp]);
|
||||
setNewEmployee({ name: '', email: '', phone: '' });
|
||||
setShowAddDrawer(false);
|
||||
// Show success toast (simulated)
|
||||
console.log('Employee added successfully');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditEmployee = (employee: Employee) => {
|
||||
setEditingEmployee(employee);
|
||||
setShowEditDrawer(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Toolbar */}
|
||||
{/* <Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col lg:flex-row gap-4 items-start lg:items-center justify-between">
|
||||
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center flex-1">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search learners..."
|
||||
className="pl-10"
|
||||
onChange={(e) => debouncedSearch(e.target.value)}
|
||||
aria-label="Search learners by name or email"
|
||||
/>
|
||||
</div>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Status</SelectItem>
|
||||
<SelectItem value="Active">Active</SelectItem>
|
||||
<SelectItem value="Inactive">Inactive</SelectItem>
|
||||
<SelectItem value="Pending">Pending</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => setShowAddDrawer(true)}
|
||||
className="min-tap-44"
|
||||
aria-label="Add new learner"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Learner
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowImportModal(true)}
|
||||
className="min-tap-44"
|
||||
aria-label="Import learners from CSV"
|
||||
>
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
Import Learners
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card> */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col lg:flex-row gap-4 items-start lg:items-center justify-between">
|
||||
{/* Left side (Search + Filter) */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 items-stretch sm:items-center flex-1 w-full">
|
||||
<div className="relative flex-1 max-w-md w-full">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search learners..."
|
||||
className="pl-10 w-full"
|
||||
onChange={(e) => debouncedSearch(e.target.value)}
|
||||
aria-label="Search learners by name or email"
|
||||
/>
|
||||
</div>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-full sm:w-[150px]">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Status</SelectItem>
|
||||
<SelectItem value="Active">Active</SelectItem>
|
||||
<SelectItem value="Inactive">Inactive</SelectItem>
|
||||
<SelectItem value="Pending">Pending</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Right side (Buttons) */}
|
||||
<div className="flex flex-col sm:flex-row gap-2 w-full sm:w-auto">
|
||||
<Button
|
||||
onClick={() => setShowAddDrawer(true)}
|
||||
className="w-full sm:w-auto py-6"
|
||||
aria-label="Add new learner"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Learner
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowImportModal(true)}
|
||||
className="w-full sm:w-auto py-6"
|
||||
aria-label="Import learners from CSV"
|
||||
>
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
Import Learners
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
{/* Bulk Action Bar */}
|
||||
{bulkActionVisible && (
|
||||
<Card className="animate-slide-in-right">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground" aria-live="polite">
|
||||
{selectedEmployees.length} learner{selectedEmployees.length !== 1 ? 's' : ''} selected
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowAssignModal(true)}
|
||||
className="min-tap-44"
|
||||
>
|
||||
Assign to Programme/Course
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-tap-44"
|
||||
>
|
||||
Deactivate
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-tap-44"
|
||||
>
|
||||
Reactivate
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Learners Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Learners ({filteredEmployees.length})</CardTitle>
|
||||
<CardDescription>Manage learner accounts and assignments</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleBulkSelect(selectedEmployees.length !== filteredEmployees.length)}
|
||||
className="min-tap-44"
|
||||
>
|
||||
{selectedEmployees.length === filteredEmployees.length ? 'Deselect All' : 'Select All'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border min-h-[60vh]">
|
||||
<Table>
|
||||
<TableHeader className="sticky-header">
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px]">Select</TableHead>
|
||||
<TableHead className="w-[200px]">Name</TableHead>
|
||||
<TableHead className="w-[250px]">Email</TableHead>
|
||||
<TableHead className="w-[150px]">Phone</TableHead>
|
||||
<TableHead className="w-[100px]">Status</TableHead>
|
||||
<TableHead className="w-[80px]">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredEmployees.map((employee: any) => (
|
||||
<TableRow
|
||||
key={employee.id}
|
||||
className="min-h-[48px]"
|
||||
>
|
||||
<TableCell>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedEmployees.includes(employee.id)}
|
||||
onChange={(e) => handleEmployeeSelect(employee.id, e.target.checked)}
|
||||
className="min-tap-44"
|
||||
aria-label={`Select ${employee.name}`}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{employee.name}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{employee.email}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{employee.phone}</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={employee.status === 'Active' ? 'default' : employee.status === 'Pending' ? 'secondary' : 'destructive'}
|
||||
|
||||
|
||||
|
||||
>
|
||||
{employee.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEditEmployee(employee)}
|
||||
className="min-tap-44"
|
||||
aria-label={`Edit ${employee.name}`}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="min-tap-44"
|
||||
aria-label={`More actions for ${employee.name}`}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Add Learner Drawer */}
|
||||
<Sheet open={showAddDrawer} onOpenChange={setShowAddDrawer}>
|
||||
<SheetContent
|
||||
className="w-[480px] sm:w-[540px]"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="add-learner-title"
|
||||
>
|
||||
<SheetHeader>
|
||||
<SheetTitle id="add-learner-title">Add New Learner</SheetTitle>
|
||||
<SheetDescription>
|
||||
Add a new learner to the system. Email cannot be changed after saving.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="mt-6 space-y-4 p-4">
|
||||
<div>
|
||||
<label htmlFor="employee-name" className="block text-sm font-medium mb-2">
|
||||
Employee Name *
|
||||
</label>
|
||||
<Input
|
||||
id="employee-name"
|
||||
value={newEmployee.name}
|
||||
onChange={(e) => setNewEmployee(prev => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="Enter full name"
|
||||
required
|
||||
aria-required="true"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="employee-email" className="block text-sm font-medium mb-2">
|
||||
Email Address *
|
||||
</label>
|
||||
<Input
|
||||
id="employee-email"
|
||||
type="email"
|
||||
value={newEmployee.email}
|
||||
onChange={(e) => setNewEmployee(prev => ({ ...prev, email: e.target.value }))}
|
||||
placeholder="email@company.com"
|
||||
required
|
||||
aria-required="true"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="employee-phone" className="block text-sm font-medium mb-2">
|
||||
Phone Number
|
||||
</label>
|
||||
<Input
|
||||
id="employee-phone"
|
||||
type="tel"
|
||||
value={newEmployee.phone}
|
||||
onChange={(e) => setNewEmployee(prev => ({ ...prev, phone: e.target.value }))}
|
||||
placeholder="+61 4XX XXX XXX"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 pt-4">
|
||||
<Button onClick={handleAddEmployee} className="flex-1">
|
||||
Save Learner
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setShowAddDrawer(false)} className="flex-1">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
{/* Import Modal */}
|
||||
<Dialog open={showImportModal} onOpenChange={setShowImportModal}>
|
||||
<DialogContent
|
||||
className="sm:max-w-[600px]"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="import-title"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle id="import-title">Import Learners</DialogTitle>
|
||||
<DialogDescription>
|
||||
Upload a CSV file to import multiple learners. Maximum file size: 5MB.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium">Step 1: Download Template</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Download our CSV template with the required fields: Name, Email, Phone.
|
||||
</p>
|
||||
<Button variant="outline" className="min-tap-44">
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download CSV Template
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium">Step 2: Upload File</h4>
|
||||
<div className="border-2 border-dashed border-muted rounded-lg p-8 text-center">
|
||||
<Upload className="h-8 w-8 mx-auto mb-2 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Drag and drop your CSV file here, or click to browse
|
||||
</p>
|
||||
<Button variant="outline" className="mt-2 min-tap-44">
|
||||
Choose File
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button className="flex-1">Import Learners</Button>
|
||||
<Button variant="outline" onClick={() => setShowImportModal(false)} className="flex-1">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Assign Modal */}
|
||||
<Dialog open={showAssignModal} onOpenChange={setShowAssignModal}>
|
||||
<DialogContent
|
||||
className="sm:max-w-[500px]"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="assign-title"
|
||||
style={{ padding: "8px" }}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle id="assign-title">Assign to Programme/Course</DialogTitle>
|
||||
<DialogDescription>
|
||||
Assign {selectedEmployees.length} selected learner{selectedEmployees.length !== 1 ? 's' : ''} to a programme or course.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="programme-select" className="block text-sm font-medium mb-2">
|
||||
Select Programme/Course
|
||||
</label>
|
||||
<Select>
|
||||
<SelectTrigger id="programme-select">
|
||||
<SelectValue placeholder="Choose programme or course" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="leadership">Leadership Development</SelectItem>
|
||||
<SelectItem value="technical">Technical Skills</SelectItem>
|
||||
<SelectItem value="communication">Communication</SelectItem>
|
||||
<SelectItem value="project">Project Management</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="start-date" className="block text-sm font-medium mb-2">
|
||||
Start Date (Optional)
|
||||
</label>
|
||||
<Input id="start-date" type="date" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button className="flex-1">Assign</Button>
|
||||
<Button variant="outline" onClick={() => setShowAssignModal(false)} className="flex-1">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Edit/Assign Drawer */}
|
||||
<Sheet open={showEditDrawer} onOpenChange={setShowEditDrawer}>
|
||||
<SheetContent
|
||||
className="w-[600px] sm:w-[700px]"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="edit-learner-title"
|
||||
>
|
||||
<SheetHeader>
|
||||
<SheetTitle id="edit-learner-title">
|
||||
{editingEmployee?.name}
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
Edit learner details and manage course assignments.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
{editingEmployee && (
|
||||
<Tabs defaultValue="details" className="mt-6 px-4 lg:px-8">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="details">Details</TabsTrigger>
|
||||
<TabsTrigger value="enrolments">Enrolments</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="details" className="space-y-4 mt-4">
|
||||
<div>
|
||||
<label htmlFor="edit-name" className="block text-sm font-medium mb-2">
|
||||
Name
|
||||
</label>
|
||||
<Input
|
||||
id="edit-name"
|
||||
defaultValue={editingEmployee.name}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="edit-phone" className="block text-sm font-medium mb-2">
|
||||
Phone
|
||||
</label>
|
||||
<Input
|
||||
id="edit-phone"
|
||||
defaultValue={editingEmployee.phone}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="edit-status" className="block text-sm font-medium mb-2">
|
||||
Status
|
||||
</label>
|
||||
<Select defaultValue={editingEmployee.status}>
|
||||
<SelectTrigger id="edit-status">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Active">Active</SelectItem>
|
||||
<SelectItem value="Inactive">Inactive</SelectItem>
|
||||
<SelectItem value="Pending">Pending</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="enrolments" className="space-y-4 mt-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-medium">Current Enrolments</h4>
|
||||
<Button size="sm">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Assign Course
|
||||
</Button>
|
||||
</div>
|
||||
{editingEmployee.programme && (
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">{editingEmployee.programme}</p>
|
||||
<p className="text-sm text-muted-foreground">{editingEmployee.course}</p>
|
||||
{editingEmployee.progress !== undefined && (
|
||||
<Progress value={editingEmployee.progress} className="w-32 mt-2" />
|
||||
)}
|
||||
</div>
|
||||
<Button variant="outline" size="sm">
|
||||
Unassign
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
<div className="flex gap-2 mt-6">
|
||||
<Button className="flex-1">Save Changes</Button>
|
||||
<Button variant="outline" onClick={() => setShowEditDrawer(false)} className="flex-1">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</Tabs>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
109
src/pages/SettingsScreen.tsx
Normal file
109
src/pages/SettingsScreen.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { useState } from "react";
|
||||
import { Card, CardContent } from "../components/ui/Card";
|
||||
import { Tabs, TabsList, TabsContent } from "../components/ui/tabs";
|
||||
import { TabsTrigger } from "../components/ui/tabs";
|
||||
import { Building2 } from "lucide-react";
|
||||
import { CreditCard } from "lucide-react";
|
||||
import { Shield } from "lucide-react";
|
||||
import { CardHeader } from "../components/ui/Card";
|
||||
import { CardTitle } from "../components/ui/Card";
|
||||
import { CardDescription } from "../components/ui/Card";
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const [activeTab, setActiveTab] = useState('profile');
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Settings Tabs */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList
|
||||
className="grid w-full grid-cols-3 mb-6"
|
||||
role="tablist"
|
||||
aria-label="Settings navigation"
|
||||
>
|
||||
<TabsTrigger
|
||||
value="profile"
|
||||
role="tab"
|
||||
aria-controls="profile-content"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Building2 className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Org Profile</span>
|
||||
<span className="sm:hidden">Profile</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="billing"
|
||||
role="tab"
|
||||
aria-controls="billing-content"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<CreditCard className="h-4 w-4" />
|
||||
Billing
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="roles"
|
||||
role="tab"
|
||||
aria-controls="roles-content"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Shield className="h-4 w-4" />
|
||||
Roles
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="profile" role="tabpanel" id="profile-content">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Organisation Profile</CardTitle>
|
||||
<CardDescription>Manage your organisation's profile and settings</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<Building2 className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
|
||||
<p>Organisation profile settings would be displayed here</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="billing" role="tabpanel" id="billing-content">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Billing & Subscriptions</CardTitle>
|
||||
<CardDescription>Manage your billing information and subscription plans</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<CreditCard className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
|
||||
<p>Billing and subscription management would be displayed here</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="roles" role="tabpanel" id="roles-content">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Roles & Permissions</CardTitle>
|
||||
<CardDescription>Manage user roles and access permissions</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<Shield className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
|
||||
<p>Roles and permissions management would be displayed here</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
399
src/pages/TestimonialsScreen.tsx
Normal file
399
src/pages/TestimonialsScreen.tsx
Normal file
@@ -0,0 +1,399 @@
|
||||
import { useState } from "react";
|
||||
import { useLocalStorage } from "../hooks/useLocalStorage";
|
||||
import { useEffect } from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/Card";
|
||||
import { Skeleton } from "../components/ui/skeleton";
|
||||
import { Alert, AlertDescription } from "../components/ui/alert";
|
||||
import { CheckCircle } from "lucide-react";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Textarea } from "../components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../components/ui/select";
|
||||
import { Checkbox } from "../components/ui/checkbox";
|
||||
import { Info } from "lucide-react";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { TestimonialFormData } from "../types";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
|
||||
export default function TestimonialsScreen() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [formData, setFormData] = useState<TestimonialFormData>({
|
||||
name: 'Alex Sharma',
|
||||
email: 'alex.sharma@company.com',
|
||||
phone: '',
|
||||
organisation: 'Acme Corp',
|
||||
programme: '',
|
||||
testimonialText: '',
|
||||
consentToPublish: false
|
||||
});
|
||||
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [submitSuccess, setSubmitSuccess] = useState(false);
|
||||
const [submitError, setSubmitError] = useState('');
|
||||
const [charCount, setCharCount] = useState(0);
|
||||
const [prefersReducedMotion] = useLocalStorage('prefersReducedMotion', false);
|
||||
|
||||
// Simulate profile pre-fill loading
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setLoading(false), 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
const programmes = [
|
||||
'Leadership Development',
|
||||
'Technical Skills',
|
||||
'Communication',
|
||||
'Project Management',
|
||||
'Sales Training'
|
||||
];
|
||||
|
||||
const validateForm = () => {
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
if (!formData.testimonialText.trim()) {
|
||||
errors.testimonialText = 'Testimonial text is required';
|
||||
} else if (formData.testimonialText.length < 1) {
|
||||
errors.testimonialText = 'Testimonial must be at least 1 character';
|
||||
} else if (formData.testimonialText.length > 2000) {
|
||||
errors.testimonialText = 'Testimonial must be 2000 characters or less';
|
||||
}
|
||||
|
||||
if (!formData.consentToPublish) {
|
||||
errors.consentToPublish = 'You must consent to publish your testimonial';
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
const isFormValid = () => {
|
||||
const errors = validateForm();
|
||||
return Object.keys(errors).length === 0 && formData.testimonialText.trim().length > 0;
|
||||
};
|
||||
|
||||
const handleInputChange = (field: keyof TestimonialFormData, value: string | boolean) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
|
||||
if (field === 'testimonialText' && typeof value === 'string') {
|
||||
setCharCount(value.length);
|
||||
}
|
||||
|
||||
// Clear field error when user starts typing
|
||||
if (formErrors[field]) {
|
||||
setFormErrors(prev => {
|
||||
const newErrors = { ...prev };
|
||||
delete newErrors[field];
|
||||
return newErrors;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const errors = validateForm();
|
||||
if (Object.keys(errors).length > 0) {
|
||||
setFormErrors(errors);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setSubmitError('');
|
||||
|
||||
try {
|
||||
// Simulate API call
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
|
||||
// Simulate occasional error for testing
|
||||
if (Math.random() > 0.9) {
|
||||
throw new Error('Submission failed. Please try again.');
|
||||
}
|
||||
|
||||
setSubmitSuccess(true);
|
||||
setFormData(prev => ({ ...prev, testimonialText: '', programme: '', consentToPublish: false }));
|
||||
setCharCount(0);
|
||||
} catch (error) {
|
||||
setSubmitError(error instanceof Error ? error.message : 'An error occurred. Please try again.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setSubmitSuccess(false);
|
||||
setSubmitError('');
|
||||
setFormErrors({});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-96" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-10 w-full max-w-md" />
|
||||
<Skeleton className="h-10 w-full max-w-md" />
|
||||
<Skeleton className="h-32 w-full max-w-md" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Success State */}
|
||||
{submitSuccess && (
|
||||
<Alert
|
||||
className={`border-status-success/20 bg-status-success/10 ${prefersReducedMotion ? '' : 'animate-fade-in'}`}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 text-status-success" />
|
||||
<AlertDescription className="text-status-success">
|
||||
Thanks — your testimonial is pending review by KLC.
|
||||
<Button
|
||||
variant="link"
|
||||
className="p-0 ml-2 h-auto text-status-success underline"
|
||||
onClick={resetForm}
|
||||
>
|
||||
Submit another testimonial
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{submitError && (
|
||||
<Alert
|
||||
className="border-status-error/20 bg-status-error/10"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
<AlertCircle className="h-4 w-4 text-status-error" />
|
||||
<AlertDescription className="text-status-error">
|
||||
{submitError}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Testimonial Form */}
|
||||
<Card className="max-w-3xl mx-auto">
|
||||
<CardHeader>
|
||||
<CardTitle>Submit Testimonial</CardTitle>
|
||||
<CardDescription>
|
||||
Share your experience with KLC programmes to help others discover the value of our learning solutions.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-6 max-w-[720px] mx-auto">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Your Name */}
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium mb-2">
|
||||
Your Name
|
||||
</label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
readOnly
|
||||
className="bg-muted cursor-not-allowed"
|
||||
aria-describedby="name-help"
|
||||
/>
|
||||
<p id="name-help" className="text-xs text-muted-foreground mt-1">
|
||||
Pre-filled from your profile
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Work Email */}
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium mb-2">
|
||||
Work Email
|
||||
</label>
|
||||
<Input
|
||||
id="email"
|
||||
value={formData.email}
|
||||
readOnly
|
||||
className="bg-muted cursor-not-allowed"
|
||||
aria-describedby="email-help"
|
||||
/>
|
||||
<p id="email-help" className="text-xs text-muted-foreground mt-1">
|
||||
Pre-filled from your profile
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Phone */}
|
||||
<div>
|
||||
<label htmlFor="phone" className="block text-sm font-medium mb-2">
|
||||
Phone <span className="text-muted-foreground">(Optional)</span>
|
||||
</label>
|
||||
<Input
|
||||
id="phone"
|
||||
type="tel"
|
||||
value={formData.phone}
|
||||
onChange={(e) => handleInputChange('phone', e.target.value)}
|
||||
placeholder="+61 4XX XXX XXX"
|
||||
className="min-tap-44"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Organisation */}
|
||||
<div>
|
||||
<label htmlFor="organisation" className="block text-sm font-medium mb-2">
|
||||
Organisation
|
||||
</label>
|
||||
<Input
|
||||
id="organisation"
|
||||
value={formData.organisation}
|
||||
readOnly
|
||||
className="bg-muted cursor-not-allowed"
|
||||
aria-describedby="org-help"
|
||||
/>
|
||||
<p id="org-help" className="text-xs text-muted-foreground mt-1">
|
||||
Pre-filled from your profile
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Programme */}
|
||||
<div>
|
||||
<label htmlFor="programme" className="block text-sm font-medium mb-2">
|
||||
Programme <span className="text-muted-foreground">(Optional)</span>
|
||||
</label>
|
||||
<Select
|
||||
value={formData.programme}
|
||||
onValueChange={(value: any) => handleInputChange('programme', value)}
|
||||
>
|
||||
<SelectTrigger id="programme" className="min-tap-44">
|
||||
<SelectValue placeholder="Select a programme (optional)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{programmes.map((programme) => (
|
||||
<SelectItem key={programme} value={programme}>
|
||||
{programme}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Testimonial Text */}
|
||||
<div>
|
||||
<label htmlFor="testimonial" className="block text-sm font-medium mb-2">
|
||||
Testimonial Text *
|
||||
</label>
|
||||
<Textarea
|
||||
id="testimonial"
|
||||
value={formData.testimonialText}
|
||||
onChange={(e) => handleInputChange('testimonialText', e.target.value)}
|
||||
placeholder="Share your experience (1–2000 chars)…"
|
||||
className={`min-h-[120px] min-tap-44 ${formErrors.testimonialText ? 'border-status-error' : ''}`}
|
||||
aria-invalid={!!formErrors.testimonialText}
|
||||
aria-describedby="testimonial-help testimonial-counter testimonial-error"
|
||||
maxLength={2000}
|
||||
required
|
||||
/>
|
||||
<div className="flex justify-between items-center mt-1">
|
||||
<div>
|
||||
<p id="testimonial-help" className="text-xs text-muted-foreground">
|
||||
Share what you learned, how it helped, or what you'd recommend to others
|
||||
</p>
|
||||
{formErrors.testimonialText && (
|
||||
<p id="testimonial-error" className="text-xs text-status-error mt-1" role="alert">
|
||||
{formErrors.testimonialText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
id="testimonial-counter"
|
||||
className={`text-xs ${charCount > 2000 ? 'text-status-error' : 'text-muted-foreground'}`}
|
||||
aria-live="polite"
|
||||
>
|
||||
{charCount}/2000
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Consent Checkbox */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="consent"
|
||||
checked={formData.consentToPublish}
|
||||
onCheckedChange={(checked: any) => handleInputChange('consentToPublish', !!checked)}
|
||||
className={`min-tap-44 mt-1 ${formErrors.consentToPublish ? 'border-status-error' : ''}`}
|
||||
aria-invalid={!!formErrors.consentToPublish}
|
||||
aria-describedby="consent-label consent-error"
|
||||
required
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
htmlFor="consent"
|
||||
id="consent-label"
|
||||
className="text-sm font-medium cursor-pointer"
|
||||
>
|
||||
I consent to publish this testimonial *
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Your testimonial may be used in marketing materials and on our website.
|
||||
You can request removal at any time by contacting us.
|
||||
</p>
|
||||
{formErrors.consentToPublish && (
|
||||
<p id="consent-error" className="text-xs text-status-error" role="alert">
|
||||
{formErrors.consentToPublish}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="pt-4">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!isFormValid() || isSubmitting || submitSuccess}
|
||||
className="w-full md:w-auto min-tap-44"
|
||||
aria-describedby="submit-help"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
|
||||
Submitting...
|
||||
</>
|
||||
) : (
|
||||
'Submit Testimonial'
|
||||
)}
|
||||
</Button>
|
||||
<p id="submit-help" className="text-xs text-muted-foreground mt-2">
|
||||
Your testimonial will be reviewed before being published
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Status Notice */}
|
||||
<Card className="max-w-3xl mx-auto" role="note">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<Info className="h-5 w-5 text-brand-primary mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm">
|
||||
Submissions are reviewed by KLC Super-Admins before appearing publicly.
|
||||
<Button variant="link" className="p-0 ml-1 h-auto text-sm underline">
|
||||
View policy
|
||||
<ExternalLink className="h-3 w-3 ml-1" />
|
||||
</Button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
24
src/redux/services/demo.services.ts
Normal file
24
src/redux/services/demo.services.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
// src/services/demo.service.ts
|
||||
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
|
||||
|
||||
export const demoApi = createApi({
|
||||
reducerPath: "demoApi",
|
||||
baseQuery: fetchBaseQuery({ baseUrl: "https://jsonplaceholder.typicode.com" }),
|
||||
endpoints: (builder) => ({
|
||||
// GET example
|
||||
getPosts: builder.query<any[], void>({
|
||||
query: () => "/posts",
|
||||
}),
|
||||
|
||||
// POST example
|
||||
createPost: builder.mutation<any, { title: string; body: string; userId: number }>({
|
||||
query: (newPost) => ({
|
||||
url: "/posts",
|
||||
method: "POST",
|
||||
body: newPost,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const { useGetPostsQuery, useCreatePostMutation } = demoApi;
|
||||
13
src/redux/store.tsx
Normal file
13
src/redux/store.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { configureStore } from "@reduxjs/toolkit";
|
||||
import { demoApi } from "./services/demo.services";
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
[demoApi.reducerPath]: demoApi.reducer,
|
||||
},
|
||||
middleware: (getDefaultMiddleware) =>
|
||||
getDefaultMiddleware().concat(demoApi.middleware),
|
||||
});
|
||||
|
||||
// Types
|
||||
export type RootState = ReturnType<typeof store.getState>;
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
@@ -62,19 +62,30 @@
|
||||
--chart-5: #C0C0C0;
|
||||
|
||||
/* Spacing Scale (4-32px) */
|
||||
--spacing-4: 0.286rem; /* 4px */
|
||||
--spacing-8: 0.571rem; /* 8px */
|
||||
--spacing-12: 0.857rem; /* 12px */
|
||||
--spacing-16: 1.143rem; /* 16px */
|
||||
--spacing-20: 1.429rem; /* 20px */
|
||||
--spacing-24: 1.714rem; /* 24px */
|
||||
--spacing-28: 2rem; /* 28px */
|
||||
--spacing-32: 2.286rem; /* 32px */
|
||||
--spacing-4: 0.286rem;
|
||||
/* 4px */
|
||||
--spacing-8: 0.571rem;
|
||||
/* 8px */
|
||||
--spacing-12: 0.857rem;
|
||||
/* 12px */
|
||||
--spacing-16: 1.143rem;
|
||||
/* 16px */
|
||||
--spacing-20: 1.429rem;
|
||||
/* 20px */
|
||||
--spacing-24: 1.714rem;
|
||||
/* 24px */
|
||||
--spacing-28: 2rem;
|
||||
/* 28px */
|
||||
--spacing-32: 2.286rem;
|
||||
/* 32px */
|
||||
|
||||
/* Radius Scale */
|
||||
--radius-4: 0.286rem; /* 4px */
|
||||
--radius-8: 0.571rem; /* 8px */
|
||||
--radius-12: 0.857rem; /* 12px */
|
||||
--radius-4: 0.286rem;
|
||||
/* 4px */
|
||||
--radius-8: 0.571rem;
|
||||
/* 8px */
|
||||
--radius-12: 0.857rem;
|
||||
/* 12px */
|
||||
--radius: var(--radius-8);
|
||||
|
||||
/* Shadow Scale */
|
||||
@@ -83,14 +94,22 @@
|
||||
--shadow-3: 0 4px 6px rgba(0, 0, 0, 0.07), 0 2px 4px rgba(0, 0, 0, 0.06);
|
||||
|
||||
/* Type Scale (12-32px) */
|
||||
--text-12: 0.857rem; /* 12px */
|
||||
--text-14: 1rem; /* 14px */
|
||||
--text-16: 1.143rem; /* 16px */
|
||||
--text-18: 1.286rem; /* 18px */
|
||||
--text-20: 1.429rem; /* 20px */
|
||||
--text-24: 1.714rem; /* 24px */
|
||||
--text-28: 2rem; /* 28px */
|
||||
--text-32: 2.286rem; /* 32px */
|
||||
--text-12: 0.857rem;
|
||||
/* 12px */
|
||||
--text-14: 1rem;
|
||||
/* 14px */
|
||||
--text-16: 1.143rem;
|
||||
/* 16px */
|
||||
--text-18: 1.286rem;
|
||||
/* 18px */
|
||||
--text-20: 1.429rem;
|
||||
/* 20px */
|
||||
--text-24: 1.714rem;
|
||||
/* 24px */
|
||||
--text-28: 2rem;
|
||||
/* 28px */
|
||||
--text-32: 2.286rem;
|
||||
/* 32px */
|
||||
|
||||
--font-weight-medium: 500;
|
||||
--font-weight-normal: 400;
|
||||
@@ -396,28 +415,55 @@
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from { opacity: 0; transform: translateX(100%); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes countUp {
|
||||
from { transform: scale(0.8); opacity: 0; }
|
||||
to { transform: scale(1); opacity: 1; }
|
||||
from {
|
||||
transform: scale(0.8);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Accessibility: Reduce motion support */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
.animate-fade-in,
|
||||
.animate-slide-up,
|
||||
.animate-count-up,
|
||||
@@ -518,18 +564,96 @@
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--muted) 0%,
|
||||
var(--accent) 50%,
|
||||
var(--muted) 100%
|
||||
);
|
||||
background: linear-gradient(90deg,
|
||||
var(--muted) 0%,
|
||||
var(--accent) 50%,
|
||||
var(--muted) 100%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Employee Card Mobile View */
|
||||
/* Add this to your main global CSS file */
|
||||
@media (max-width: 640px) {
|
||||
.employee-card-mobile-view {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.employee-card-desktop-view {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 641px) {
|
||||
.employee-card-mobile-view {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.employee-card-desktop-view {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom styles for the compact table */
|
||||
.compact-table {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.compact-table th,
|
||||
.compact-table td {
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
/* Responsive table container */
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Ensure buttons are touch-friendly on mobile */
|
||||
.min-tap-44 {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
|
||||
/* Truncate text for small containers */
|
||||
.truncate {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Animation for card entrance */
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slideUp 0.5s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Reduced motion support */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.animate-slide-up {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
47
src/types.ts
Normal file
47
src/types.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
export interface KPIData {
|
||||
title: string;
|
||||
value: number;
|
||||
change?: number;
|
||||
trend?: 'up' | 'down' | 'neutral';
|
||||
}
|
||||
|
||||
export interface Employee {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
status: 'Active' | 'Inactive' | 'Pending';
|
||||
programme?: string;
|
||||
course?: string;
|
||||
progress?: number;
|
||||
lastActivity?: string;
|
||||
}
|
||||
|
||||
export interface Announcement {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
type: 'announcement' | 'reminder';
|
||||
timestamp: string;
|
||||
pinned?: boolean;
|
||||
}
|
||||
|
||||
export interface Deadline {
|
||||
id: string;
|
||||
title: string;
|
||||
type: 'webinar' | 'profiler';
|
||||
dueDate: string;
|
||||
dueTime: string;
|
||||
}
|
||||
|
||||
export interface TestimonialFormData {
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
organisation: string;
|
||||
programme: string;
|
||||
testimonialText: string;
|
||||
consentToPublish: boolean;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user