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