1 Commits

Author SHA1 Message Date
f87e71d231 worked on the responsive and fixed the components 2025-09-04 11:14:40 +05:30
27 changed files with 3611 additions and 2196 deletions

142
package-lock.json generated
View File

@@ -34,6 +34,7 @@
"@radix-ui/react-toggle": "^1.1.2", "@radix-ui/react-toggle": "^1.1.2",
"@radix-ui/react-toggle-group": "^1.1.2", "@radix-ui/react-toggle-group": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8", "@radix-ui/react-tooltip": "^1.1.8",
"@reduxjs/toolkit": "^2.9.0",
"@tailwindcss/postcss": "^4.1.12", "@tailwindcss/postcss": "^4.1.12",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "*", "clsx": "*",
@@ -47,7 +48,9 @@
"react-day-picker": "^8.10.1", "react-day-picker": "^8.10.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-hook-form": "^7.55.0", "react-hook-form": "^7.55.0",
"react-redux": "^9.2.0",
"react-resizable-panels": "^2.1.7", "react-resizable-panels": "^2.1.7",
"react-router-dom": "^6.30.1",
"recharts": "^2.15.2", "recharts": "^2.15.2",
"sonner": "^2.0.3", "sonner": "^2.0.3",
"tailwind-merge": "*", "tailwind-merge": "*",
@@ -1889,6 +1892,41 @@
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
"license": "MIT" "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": { "node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27", "version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@@ -2176,6 +2214,18 @@
"win32" "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": { "node_modules/@swc/core": {
"version": "1.13.5", "version": "1.13.5",
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.13.5.tgz", "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.13.5.tgz",
@@ -2763,6 +2813,12 @@
"@types/react": "^19.0.0" "@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": { "node_modules/@vitejs/plugin-react-swc": {
"version": "3.11.0", "version": "3.11.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-3.11.0.tgz", "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==", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC" "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": { "node_modules/input-otp": {
"version": "1.4.2", "version": "1.4.2",
"resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.4.2.tgz", "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==", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT" "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": { "node_modules/react-remove-scroll": {
"version": "2.7.1", "version": "2.7.1",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz", "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" "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": { "node_modules/react-smooth": {
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
@@ -3754,6 +3875,27 @@
"decimal.js-light": "^2.4.1" "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": { "node_modules/rollup": {
"version": "4.49.0", "version": "4.49.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.49.0.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.49.0.tgz",

View File

@@ -29,6 +29,7 @@
"@radix-ui/react-toggle": "^1.1.2", "@radix-ui/react-toggle": "^1.1.2",
"@radix-ui/react-toggle-group": "^1.1.2", "@radix-ui/react-toggle-group": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8", "@radix-ui/react-tooltip": "^1.1.8",
"@reduxjs/toolkit": "^2.9.0",
"@tailwindcss/postcss": "^4.1.12", "@tailwindcss/postcss": "^4.1.12",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "*", "clsx": "*",
@@ -42,7 +43,9 @@
"react-day-picker": "^8.10.1", "react-day-picker": "^8.10.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-hook-form": "^7.55.0", "react-hook-form": "^7.55.0",
"react-redux": "^9.2.0",
"react-resizable-panels": "^2.1.7", "react-resizable-panels": "^2.1.7",
"react-router-dom": "^6.30.1",
"recharts": "^2.15.2", "recharts": "^2.15.2",
"sonner": "^2.0.3", "sonner": "^2.0.3",
"tailwind-merge": "*", "tailwind-merge": "*",

73
src/App.css Normal file
View 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;
}
}

File diff suppressed because it is too large Load Diff

View 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>
);
};

View 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>
);
};

View 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>
// );
// };

View 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>
// );
// };

View 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
View 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
View 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>
);
};

View 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>&copy; 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
View 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;
}
}

View File

@@ -1,13 +1,46 @@
import * as React from "react"; import * as React from "react";
import { cn } from "./utils"; import { cn } from "./utils";
import "./Card.css"; // We'll create this CSS file
function Card({ className, ...props }: React.ComponentProps<"div">) { 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 ( return (
<div <div
data-slot="card" data-slot="card"
className={cn( 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, className,
)} )}
{...props} {...props}
@@ -16,11 +49,42 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
} }
function CardHeader({ 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 ( return (
<div <div
data-slot="card-header" data-slot="card-header"
className={cn( 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, className,
)} )}
{...props} {...props}
@@ -29,20 +93,60 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
} }
function CardTitle({ 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 ( return (
<h4 <h4
data-slot="card-title" data-slot="card-title"
className={cn("leading-none", className)} className={cn("leading-none font-semibold", responsiveFont[screenSize as keyof typeof responsiveFont], className)}
{...props} {...props}
/> />
); );
} }
function CardDescription({ className, ...props }: React.ComponentProps<"div">) { 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 ( return (
<p <p
data-slot="card-description" data-slot="card-description"
className={cn("text-muted-foreground", className)} className={cn("text-muted-foreground", responsiveFont[screenSize as keyof typeof responsiveFont], className)}
{...props} {...props}
/> />
); );
@@ -62,20 +166,65 @@ function CardAction({ className, ...props }: React.ComponentProps<"div">) {
} }
function CardContent({ 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 ( return (
<div <div
data-slot="card-content" 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} {...props}
/> />
); );
} }
function CardFooter({ className, ...props }: React.ComponentProps<"div">) { 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 ( return (
<div <div
data-slot="card-footer" 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} {...props}
/> />
); );

25
src/hooks/useCountUp.ts Normal file
View 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;
};

View 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;
};

View File

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

37
src/mock.ts Normal file
View 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' }
];

View 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
View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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 (12000 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>
);
};

View 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
View 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;

View File

@@ -62,19 +62,30 @@
--chart-5: #C0C0C0; --chart-5: #C0C0C0;
/* Spacing Scale (4-32px) */ /* Spacing Scale (4-32px) */
--spacing-4: 0.286rem; /* 4px */ --spacing-4: 0.286rem;
--spacing-8: 0.571rem; /* 8px */ /* 4px */
--spacing-12: 0.857rem; /* 12px */ --spacing-8: 0.571rem;
--spacing-16: 1.143rem; /* 16px */ /* 8px */
--spacing-20: 1.429rem; /* 20px */ --spacing-12: 0.857rem;
--spacing-24: 1.714rem; /* 24px */ /* 12px */
--spacing-28: 2rem; /* 28px */ --spacing-16: 1.143rem;
--spacing-32: 2.286rem; /* 32px */ /* 16px */
--spacing-20: 1.429rem;
/* 20px */
--spacing-24: 1.714rem;
/* 24px */
--spacing-28: 2rem;
/* 28px */
--spacing-32: 2.286rem;
/* 32px */
/* Radius Scale */ /* Radius Scale */
--radius-4: 0.286rem; /* 4px */ --radius-4: 0.286rem;
--radius-8: 0.571rem; /* 8px */ /* 4px */
--radius-12: 0.857rem; /* 12px */ --radius-8: 0.571rem;
/* 8px */
--radius-12: 0.857rem;
/* 12px */
--radius: var(--radius-8); --radius: var(--radius-8);
/* Shadow Scale */ /* 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); --shadow-3: 0 4px 6px rgba(0, 0, 0, 0.07), 0 2px 4px rgba(0, 0, 0, 0.06);
/* Type Scale (12-32px) */ /* Type Scale (12-32px) */
--text-12: 0.857rem; /* 12px */ --text-12: 0.857rem;
--text-14: 1rem; /* 14px */ /* 12px */
--text-16: 1.143rem; /* 16px */ --text-14: 1rem;
--text-18: 1.286rem; /* 18px */ /* 14px */
--text-20: 1.429rem; /* 20px */ --text-16: 1.143rem;
--text-24: 1.714rem; /* 24px */ /* 16px */
--text-28: 2rem; /* 28px */ --text-18: 1.286rem;
--text-32: 2.286rem; /* 32px */ /* 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-medium: 500;
--font-weight-normal: 400; --font-weight-normal: 400;
@@ -396,28 +415,55 @@
} }
@keyframes fadeIn { @keyframes fadeIn {
from { opacity: 0; } from {
to { opacity: 1; } opacity: 0;
}
to {
opacity: 1;
}
} }
@keyframes slideUp { @keyframes slideUp {
from { opacity: 0; transform: translateY(20px); } from {
to { opacity: 1; transform: translateY(0); } opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
} }
@keyframes slideInRight { @keyframes slideInRight {
from { opacity: 0; transform: translateX(100%); } from {
to { opacity: 1; transform: translateX(0); } opacity: 0;
transform: translateX(100%);
}
to {
opacity: 1;
transform: translateX(0);
}
} }
@keyframes countUp { @keyframes countUp {
from { transform: scale(0.8); opacity: 0; } from {
to { transform: scale(1); opacity: 1; } transform: scale(0.8);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
} }
} }
/* Accessibility: Reduce motion support */ /* Accessibility: Reduce motion support */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.animate-fade-in, .animate-fade-in,
.animate-slide-up, .animate-slide-up,
.animate-count-up, .animate-count-up,
@@ -518,18 +564,96 @@
} }
.skeleton { .skeleton {
background: linear-gradient( background: linear-gradient(90deg,
90deg,
var(--muted) 0%, var(--muted) 0%,
var(--accent) 50%, var(--accent) 50%,
var(--muted) 100% var(--muted) 100%);
);
background-size: 200% 100%; background-size: 200% 100%;
animation: shimmer 1.5s infinite; animation: shimmer 1.5s infinite;
} }
@keyframes shimmer { @keyframes shimmer {
0% { background-position: -200% 0; } 0% {
100% { background-position: 200% 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
View 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;
}