Initial import
This commit is contained in:
commit
c2eef3fd2a
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
3
README.md
Normal file
3
README.md
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
20
components.json
Normal file
20
components.json
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "default",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.ts",
|
||||||
|
"css": "src/index.css",
|
||||||
|
"baseColor": "slate",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
}
|
||||||
|
}
|
||||||
29
eslint.config.js
Normal file
29
eslint.config.js
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import js from "@eslint/js";
|
||||||
|
import globals from "globals";
|
||||||
|
import reactHooks from "eslint-plugin-react-hooks";
|
||||||
|
import reactRefresh from "eslint-plugin-react-refresh";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ["dist"] },
|
||||||
|
{
|
||||||
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
|
files: ["**/*.{ts,tsx}"],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
"react-hooks": reactHooks,
|
||||||
|
"react-refresh": reactRefresh,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
"react-refresh/only-export-components": [
|
||||||
|
"warn",
|
||||||
|
{ allowConstantExport: true },
|
||||||
|
],
|
||||||
|
"@typescript-eslint/no-unused-vars": "off",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
24
index.html
Normal file
24
index.html
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>FRAMEworks School Manager</title>
|
||||||
|
<meta name="description" content="A role-based app for schools with modules focusing on autism support, emotional intelligence, and effective communication." />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/placeholder.svg" />
|
||||||
|
|
||||||
|
<meta property="og:title" content="FRAMEworks School Manager" />
|
||||||
|
<meta property="og:description" content="A role-based app for schools with modules focusing on autism support, emotional intelligence, and effective communication." />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:image" content="/og.jpg" />
|
||||||
|
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:image" content="/og.jpg" />
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
6564
package-lock.json
generated
Normal file
6564
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
87
package.json
Normal file
87
package.json
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
{
|
||||||
|
"name": "vite_react_shadcn_ts",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"build:dev": "vite build --mode development",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@hookform/resolvers": "^3.9.0",
|
||||||
|
"@radix-ui/react-accordion": "^1.2.0",
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.1.1",
|
||||||
|
"@radix-ui/react-aspect-ratio": "^1.1.0",
|
||||||
|
"@radix-ui/react-avatar": "^1.1.0",
|
||||||
|
"@radix-ui/react-checkbox": "^1.1.1",
|
||||||
|
"@radix-ui/react-collapsible": "^1.1.0",
|
||||||
|
"@radix-ui/react-context-menu": "^2.2.1",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.2",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
||||||
|
"@radix-ui/react-hover-card": "^1.1.1",
|
||||||
|
"@radix-ui/react-label": "^2.1.0",
|
||||||
|
"@radix-ui/react-menubar": "^1.1.1",
|
||||||
|
"@radix-ui/react-navigation-menu": "^1.2.0",
|
||||||
|
"@radix-ui/react-popover": "^1.1.1",
|
||||||
|
"@radix-ui/react-progress": "^1.1.0",
|
||||||
|
"@radix-ui/react-radio-group": "^1.2.0",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.1.0",
|
||||||
|
"@radix-ui/react-select": "^2.1.1",
|
||||||
|
"@radix-ui/react-separator": "^1.1.0",
|
||||||
|
"@radix-ui/react-slider": "^1.2.0",
|
||||||
|
"@radix-ui/react-slot": "^1.1.0",
|
||||||
|
"@radix-ui/react-switch": "^1.1.0",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.0",
|
||||||
|
"@radix-ui/react-toast": "^1.2.1",
|
||||||
|
"@radix-ui/react-toggle": "^1.1.0",
|
||||||
|
"@radix-ui/react-toggle-group": "^1.1.0",
|
||||||
|
"@radix-ui/react-tooltip": "^1.1.4",
|
||||||
|
"@supabase/supabase-js": "^2.49.4",
|
||||||
|
"@tanstack/react-query": "^5.56.2",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.0.0",
|
||||||
|
"date-fns": "^3.6.0",
|
||||||
|
"embla-carousel-react": "^8.3.0",
|
||||||
|
"highlight.js": "^11.9.0",
|
||||||
|
"input-otp": "^1.2.4",
|
||||||
|
"lucide-react": "^0.462.0",
|
||||||
|
"marked": "^12.0.1",
|
||||||
|
"next-themes": "^0.3.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-day-picker": "^8.10.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-hook-form": "^7.53.0",
|
||||||
|
"react-resizable-panels": "^2.1.3",
|
||||||
|
"react-router-dom": "^6.26.2",
|
||||||
|
"recharts": "^2.12.7",
|
||||||
|
"sonner": "^1.5.0",
|
||||||
|
"tailwind-merge": "^2.5.2",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"uuid": "^11.1.0",
|
||||||
|
"vaul": "^0.9.3",
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.9.0",
|
||||||
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
|
"@types/node": "^22.5.5",
|
||||||
|
"@types/react": "^18.3.3",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react-swc": "^3.5.0",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"eslint": "^9.9.0",
|
||||||
|
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.9",
|
||||||
|
"globals": "^15.9.0",
|
||||||
|
"postcss": "^8.4.47",
|
||||||
|
"tailwindcss": "^3.4.11",
|
||||||
|
"typescript": "^5.5.3",
|
||||||
|
"typescript-eslint": "^8.0.1",
|
||||||
|
"vite": "^5.4.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
43
public/placeholder.svg
Normal file
43
public/placeholder.svg
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||||
|
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||||
|
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="1024.000000pt" height="1024.000000pt" viewBox="0 0 1024.000000 1024.000000"
|
||||||
|
preserveAspectRatio="xMidYMid meet">
|
||||||
|
|
||||||
|
<g transform="translate(0.000000,1024.000000) scale(0.100000,-0.100000)"
|
||||||
|
fill="#000000" stroke="none">
|
||||||
|
<path d="M1790 10229 c-267 -28 -544 -118 -788 -259 -557 -319 -927 -892 -992
|
||||||
|
-1539 -14 -143 -14 -6479 0 -6622 96 -955 845 -1704 1799 -1799 143 -14 6479
|
||||||
|
-14 6622 0 693 69 1301 491 1604 1114 107 218 170 442 195 685 14 143 14 6479
|
||||||
|
0 6622 -96 955 -846 1704 -1799 1799 -129 13 -6516 11 -6641 -1z m6588 -1291
|
||||||
|
c17 -17 15 -65 -8 -198 -49 -295 -79 -421 -149 -635 -96 -293 -188 -519 -319
|
||||||
|
-783 -154 -309 -300 -548 -495 -812 -297 -401 -487 -606 -914 -988 -236 -211
|
||||||
|
-637 -475 -929 -613 -160 -75 -163 -75 -232 -1 -31 33 -93 89 -137 123 -44 34
|
||||||
|
-111 93 -150 129 -38 37 -140 127 -225 200 -148 127 -190 173 -190 212 0 23
|
||||||
|
59 176 182 473 30 72 71 173 92 225 77 194 92 229 167 387 183 384 343 613
|
||||||
|
586 841 248 231 400 353 733 585 268 187 551 338 950 507 128 54 344 137 495
|
||||||
|
190 50 17 131 47 180 65 271 101 338 118 363 93z m-3327 -1925 c19 -19 2 -75
|
||||||
|
-45 -149 -91 -146 -212 -370 -348 -644 -174 -351 -236 -488 -300 -655 -27 -71
|
||||||
|
-56 -146 -63 -165 -8 -19 -14 -57 -15 -85 0 -62 -17 -124 -35 -131 -7 -3 -36
|
||||||
|
6 -64 20 -54 28 -100 33 -134 14 -20 -10 -283 -249 -562 -510 -66 -62 -172
|
||||||
|
-152 -235 -199 -124 -93 -429 -288 -600 -383 -58 -33 -150 -87 -205 -121 -147
|
||||||
|
-92 -273 -158 -290 -151 -22 8 -19 64 6 111 11 22 44 76 73 120 30 44 84 130
|
||||||
|
121 190 38 61 101 160 140 220 40 61 85 133 100 160 15 28 35 61 45 75 17 24
|
||||||
|
180 316 259 464 22 39 69 127 106 196 76 141 136 255 322 615 283 546 456 802
|
||||||
|
568 845 17 6 112 25 213 41 100 16 238 40 305 53 67 14 145 27 172 31 44 6
|
||||||
|
233 36 305 49 44 8 150 0 161 -11z m1809 -1548 c9 -11 11 -49 6 -147 -4 -73
|
||||||
|
-11 -227 -16 -343 -22 -463 -48 -828 -70 -962 -19 -115 -49 -192 -92 -239 -83
|
||||||
|
-90 -251 -231 -726 -608 -415 -329 -485 -385 -723 -586 -446 -373 -1019 -912
|
||||||
|
-1278 -1200 -124 -138 -141 -153 -163 -145 -36 14 -40 52 -33 290 21 759 70
|
||||||
|
941 378 1383 249 359 612 755 1052 1148 110 98 202 181 205 184 3 3 32 27 65
|
||||||
|
55 33 27 107 92 165 145 58 52 184 165 280 250 95 85 237 214 314 285 78 72
|
||||||
|
179 162 226 199 47 38 108 90 135 116 79 73 235 190 255 190 4 0 13 -7 20 -15z
|
||||||
|
m-2219 -661 c39 -19 95 -86 130 -156 43 -85 52 -193 20 -255 -72 -141 -514
|
||||||
|
-582 -1031 -1028 -96 -82 -197 -170 -225 -195 -28 -25 -104 -90 -170 -145
|
||||||
|
-127 -105 -310 -262 -385 -331 -25 -23 -63 -57 -85 -75 -22 -19 -51 -44 -65
|
||||||
|
-57 -14 -13 -42 -32 -63 -43 -34 -18 -38 -18 -44 -3 -7 19 44 119 130 254 97
|
||||||
|
151 298 456 376 568 214 309 263 378 377 535 276 381 483 638 612 758 61 57
|
||||||
|
108 90 184 130 140 71 171 77 239 43z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.7 KiB |
14
public/robots.txt
Normal file
14
public/robots.txt
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
User-agent: Googlebot
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: Bingbot
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: Twitterbot
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: facebookexternalhit
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
53
src/App.css
Normal file
53
src/App.css
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
#root {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 6em;
|
||||||
|
padding: 1.5em;
|
||||||
|
will-change: filter;
|
||||||
|
transition: filter 300ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo:hover {
|
||||||
|
filter: drop-shadow(0 0 2em rgba(20, 184, 166, 0.6));
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo.react:hover {
|
||||||
|
filter: drop-shadow(0 0 2em rgba(20, 184, 166, 0.6));
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes logo-spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
a:nth-of-type(2) .logo {
|
||||||
|
animation: logo-spin infinite 20s linear;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 2em;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: 1px solid rgba(20, 184, 166, 0.1);
|
||||||
|
background-color: rgba(20, 184, 166, 0.02);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
border-color: rgba(20, 184, 166, 0.3);
|
||||||
|
box-shadow: 0 4px 12px rgba(20, 184, 166, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-the-docs {
|
||||||
|
color: #5f7676;
|
||||||
|
}
|
||||||
31
src/App.tsx
Normal file
31
src/App.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
|
||||||
|
import { Toaster } from "@/components/ui/toaster";
|
||||||
|
import { Toaster as Sonner } from "@/components/ui/sonner";
|
||||||
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||||
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
|
import Index from "./pages/Index";
|
||||||
|
import NotFound from "./pages/NotFound";
|
||||||
|
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
|
const App = () => (
|
||||||
|
<ThemeProvider defaultTheme="dark">
|
||||||
|
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<TooltipProvider>
|
||||||
|
<Toaster />
|
||||||
|
<Sonner />
|
||||||
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Index />} />
|
||||||
|
<Route path="*" element={<NotFound />} />
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
</TooltipProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default App;
|
||||||
336
src/components/AppLayout.tsx
Normal file
336
src/components/AppLayout.tsx
Normal file
@ -0,0 +1,336 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useAppContext } from '@/contexts/AppContext';
|
||||||
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
import { useIsMobile } from '@/hooks/use-mobile';
|
||||||
|
import { UserRole, ModuleId } from '@/lib/types';
|
||||||
|
import { MODULES } from '@/lib/appData';
|
||||||
|
|
||||||
|
import Sidebar from '@/components/frameworks/Sidebar';
|
||||||
|
import TopBar from '@/components/frameworks/TopBar';
|
||||||
|
import Dashboard from '@/components/frameworks/Dashboard';
|
||||||
|
import FrameModule from '@/components/frameworks/FrameModule';
|
||||||
|
import ClassroomSupport from '@/components/frameworks/ClassroomSupport';
|
||||||
|
import ClassroomTimer from '@/components/frameworks/ClassroomTimer';
|
||||||
|
import QBSSafety from '@/components/frameworks/QBSSafety';
|
||||||
|
import EmotionalIntelligence from '@/components/frameworks/EmotionalIntelligence';
|
||||||
|
import ZonesOfRegulation from '@/components/frameworks/ZonesOfRegulation';
|
||||||
|
import SignLanguage from '@/components/frameworks/SignLanguage';
|
||||||
|
import DirectorDashboard from '@/components/frameworks/DirectorDashboard';
|
||||||
|
import HandbookPolicy from '@/components/frameworks/HandbookPolicy';
|
||||||
|
import SignInModal from '@/components/frameworks/SignInModal';
|
||||||
|
import CommunityService from '@/components/frameworks/CommunityService';
|
||||||
|
import VocationalOpportunities from '@/components/frameworks/VocationalOpportunities';
|
||||||
|
import ESAFunding from '@/components/frameworks/ESAFunding';
|
||||||
|
import WalkThroughCheckIn from '@/components/frameworks/WalkThroughCheckIn';
|
||||||
|
import CampusAttendance from '@/components/frameworks/CampusAttendance';
|
||||||
|
|
||||||
|
|
||||||
|
import {
|
||||||
|
ParentCommModule,
|
||||||
|
InternalCommModule,
|
||||||
|
SafetyProtocolsModule,
|
||||||
|
} from '@/components/frameworks/MoreModules';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Loader2, LogIn, CheckCircle2, Eye, ChevronDown
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
const AppLayout: React.FC = () => {
|
||||||
|
const { sidebarOpen, toggleSidebar } = useAppContext();
|
||||||
|
const { isAuthenticated, profile, loading: authLoading } = useAuth();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
|
const [currentModule, setCurrentModule] = useState<ModuleId>('dashboard');
|
||||||
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||||
|
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||||
|
const [zoneCheckIn, setZoneCheckIn] = useState<string | null>(null);
|
||||||
|
const [showSignInModal, setShowSignInModal] = useState(false);
|
||||||
|
|
||||||
|
// Demo role switcher for unauthenticated users
|
||||||
|
const [demoRole, setDemoRole] = useState<UserRole>('teacher');
|
||||||
|
const [showDemoRolePicker, setShowDemoRolePicker] = useState(false);
|
||||||
|
|
||||||
|
// Use real role from profile when authenticated, otherwise use demo role
|
||||||
|
const userRole: UserRole = isAuthenticated && profile?.role ? profile.role : demoRole;
|
||||||
|
const userName = isAuthenticated && profile?.full_name ? profile.full_name : 'Guest User';
|
||||||
|
const userCampus = isAuthenticated && profile?.campus ? profile.campus : 'Tigers';
|
||||||
|
|
||||||
|
|
||||||
|
const hasAccess = MODULES.find(m => m.id === currentModule)?.roles.includes(userRole);
|
||||||
|
const activeModule = hasAccess ? currentModule : 'dashboard';
|
||||||
|
|
||||||
|
const handleSetModule = (id: ModuleId) => {
|
||||||
|
setCurrentModule(id);
|
||||||
|
if (isMobile) setMobileSidebarOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// When demo role changes, check if current module is still accessible
|
||||||
|
useEffect(() => {
|
||||||
|
const mod = MODULES.find(m => m.id === currentModule);
|
||||||
|
if (mod && !mod.roles.includes(userRole)) {
|
||||||
|
setCurrentModule('dashboard');
|
||||||
|
}
|
||||||
|
}, [userRole, currentModule]);
|
||||||
|
|
||||||
|
const renderModule = () => {
|
||||||
|
switch (activeModule) {
|
||||||
|
case 'dashboard':
|
||||||
|
return <Dashboard userRole={userRole} userName={userName} setCurrentModule={handleSetModule} zoneCheckIn={zoneCheckIn} setZoneCheckIn={setZoneCheckIn} />;
|
||||||
|
case 'frame':
|
||||||
|
return <FrameModule userRole={userRole} />;
|
||||||
|
case 'classroom':
|
||||||
|
return <ClassroomSupport />;
|
||||||
|
case 'timer':
|
||||||
|
return <ClassroomTimer />;
|
||||||
|
case 'qbs':
|
||||||
|
return <QBSSafety userRole={userRole} />;
|
||||||
|
case 'ei':
|
||||||
|
return <EmotionalIntelligence userRole={userRole} userName={userName} userCampus={userCampus} />;
|
||||||
|
|
||||||
|
case 'zones':
|
||||||
|
return <ZonesOfRegulation />;
|
||||||
|
case 'signs':
|
||||||
|
return <SignLanguage />;
|
||||||
|
case 'attendance':
|
||||||
|
return <CampusAttendance userRole={userRole} userCampus={userCampus} userName={userName} />;
|
||||||
|
|
||||||
|
case 'parent-comm':
|
||||||
|
return <ParentCommModule />;
|
||||||
|
case 'internal-comm':
|
||||||
|
return <InternalCommModule userRole={userRole} />;
|
||||||
|
case 'safety':
|
||||||
|
return <SafetyProtocolsModule />;
|
||||||
|
case 'handbook':
|
||||||
|
return <HandbookPolicy userRole={userRole} />;
|
||||||
|
case 'director':
|
||||||
|
return <DirectorDashboard setCurrentModule={handleSetModule} />;
|
||||||
|
case 'community':
|
||||||
|
return <CommunityService />;
|
||||||
|
case 'vocational':
|
||||||
|
return <VocationalOpportunities />;
|
||||||
|
case 'esa':
|
||||||
|
return <ESAFunding />;
|
||||||
|
case 'walkthrough':
|
||||||
|
return <WalkThroughCheckIn />;
|
||||||
|
default:
|
||||||
|
|
||||||
|
return <Dashboard userRole={userRole} userName={userName} setCurrentModule={handleSetModule} zoneCheckIn={zoneCheckIn} setZoneCheckIn={setZoneCheckIn} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (authLoading) {
|
||||||
|
return (
|
||||||
|
<div className="h-screen w-full flex items-center justify-center bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-violet-500 to-amber-400 flex items-center justify-center mx-auto mb-4 shadow-lg shadow-violet-500/30 animate-pulse">
|
||||||
|
<span className="text-white font-bold text-2xl">F</span>
|
||||||
|
</div>
|
||||||
|
<Loader2 size={24} className="animate-spin text-violet-400 mx-auto mb-3" />
|
||||||
|
<p className="text-slate-400 text-sm">Loading FRAMEworks...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const demoRoles: { value: UserRole; label: string; color: string }[] = [
|
||||||
|
{ value: 'teacher', label: 'Teacher', color: 'text-emerald-400 bg-emerald-500/15 border-emerald-500/30' },
|
||||||
|
{ value: 'para', label: 'Support Staff', color: 'text-blue-400 bg-blue-500/15 border-blue-500/30' },
|
||||||
|
{ value: 'office', label: 'Office Manager', color: 'text-amber-400 bg-amber-500/15 border-amber-500/30' },
|
||||||
|
{ value: 'director', label: 'Director', color: 'text-purple-400 bg-purple-500/15 border-purple-500/30' },
|
||||||
|
{ value: 'superintendent', label: 'Superintendent', color: 'text-rose-400 bg-rose-500/15 border-rose-500/30' },
|
||||||
|
];
|
||||||
|
|
||||||
|
|
||||||
|
const currentDemoRole = demoRoles.find(r => r.value === demoRole)!;
|
||||||
|
|
||||||
|
// Always show the full app — authenticated or guest
|
||||||
|
return (
|
||||||
|
<div className="h-screen w-full flex bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950 overflow-hidden">
|
||||||
|
{/* Mobile sidebar overlay */}
|
||||||
|
{mobileSidebarOpen && isMobile && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-30" onClick={() => setMobileSidebarOpen(false)} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className={`${isMobile ? `fixed left-0 top-0 h-full z-40 transform transition-transform duration-300 ${mobileSidebarOpen ? 'translate-x-0' : '-translate-x-full'}` : 'relative'}`}>
|
||||||
|
<Sidebar currentModule={activeModule} setCurrentModule={handleSetModule} userRole={userRole} collapsed={isMobile ? false : sidebarCollapsed} setCollapsed={setSidebarCollapsed} userCampus={userCampus} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
{/* Main content area */}
|
||||||
|
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||||
|
<TopBar
|
||||||
|
userRole={userRole}
|
||||||
|
userName={userName}
|
||||||
|
userCampus={userCampus}
|
||||||
|
toggleSidebar={() => isMobile ? setMobileSidebarOpen(!mobileSidebarOpen) : setSidebarCollapsed(!sidebarCollapsed)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<main className="flex-1 overflow-y-auto p-4 md:p-6 lg:p-8">
|
||||||
|
{/* Guest Mode Banner — only when NOT authenticated */}
|
||||||
|
{!isAuthenticated && (
|
||||||
|
<div className="max-w-7xl mx-auto mb-5">
|
||||||
|
<div className="bg-gradient-to-r from-violet-600/10 via-amber-500/10 to-emerald-500/10 rounded-2xl border border-violet-500/20 px-4 py-3 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-violet-500/20 to-amber-500/20 border border-violet-500/30 flex items-center justify-center flex-shrink-0">
|
||||||
|
<Eye size={18} className="text-violet-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-white">
|
||||||
|
You're browsing as a Guest
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
Explore all modules freely. Sign in to save your progress and get a personalized experience.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
{/* Demo Role Picker */}
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDemoRolePicker(!showDemoRolePicker)}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-semibold border transition-all ${currentDemoRole.color}`}
|
||||||
|
>
|
||||||
|
<span>View as: {currentDemoRole.label}</span>
|
||||||
|
<ChevronDown size={12} />
|
||||||
|
</button>
|
||||||
|
{showDemoRolePicker && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-30" onClick={() => setShowDemoRolePicker(false)} />
|
||||||
|
<div className="absolute right-0 top-full mt-1.5 w-48 bg-slate-800 rounded-xl shadow-2xl shadow-black/40 border border-slate-700/50 py-1.5 z-40">
|
||||||
|
<p className="px-3 py-1.5 text-[10px] text-slate-500 uppercase tracking-wider font-semibold">Switch Demo Role</p>
|
||||||
|
{demoRoles.map(r => (
|
||||||
|
<button
|
||||||
|
key={r.value}
|
||||||
|
onClick={() => {
|
||||||
|
setDemoRole(r.value);
|
||||||
|
setShowDemoRolePicker(false);
|
||||||
|
}}
|
||||||
|
className={`w-full text-left px-3 py-2 text-sm transition-colors flex items-center justify-between ${
|
||||||
|
demoRole === r.value
|
||||||
|
? 'bg-violet-500/10 text-white'
|
||||||
|
: 'text-slate-300 hover:bg-slate-700/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="font-medium">{r.label}</span>
|
||||||
|
{demoRole === r.value && <CheckCircle2 size={14} className="text-violet-400" />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setShowSignInModal(true)}
|
||||||
|
className="flex items-center gap-1.5 px-4 py-1.5 bg-gradient-to-r from-violet-500 to-amber-500 hover:from-violet-600 hover:to-amber-600 text-white font-semibold rounded-xl transition-all duration-200 shadow-lg shadow-violet-500/25 hover:shadow-violet-500/40 text-xs"
|
||||||
|
>
|
||||||
|
<LogIn size={14} />
|
||||||
|
Sign In
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Module Content */}
|
||||||
|
<div className="max-w-7xl mx-auto">{renderModule()}</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="mt-12 pb-6">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
<div className="bg-slate-800/40 backdrop-blur-sm rounded-2xl border border-slate-700/40 p-6 md:p-8">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||||
|
<div className="md:col-span-1">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-violet-500 to-amber-400 flex items-center justify-center shadow-lg shadow-violet-500/20">
|
||||||
|
<span className="text-white font-bold text-sm">F</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-bold text-lg bg-gradient-to-r from-violet-400 to-amber-400 bg-clip-text text-transparent">FRAMEworks</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500 leading-relaxed">A modular, role-based school operations platform designed for autism-focused educational environments.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold text-sm text-slate-300 mb-3">Core Modules</h4>
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{[
|
||||||
|
{ label: 'Classroom Support', mod: 'classroom' as ModuleId },
|
||||||
|
{ label: 'Classroom Timer', mod: 'timer' as ModuleId },
|
||||||
|
{ label: 'De-escalation Strategies', mod: 'qbs' as ModuleId },
|
||||||
|
{ label: 'Regulate your Zone', mod: 'zones' as ModuleId },
|
||||||
|
{ label: 'Sign Language', mod: 'signs' as ModuleId },
|
||||||
|
].map(item => (
|
||||||
|
<li key={item.label}>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSetModule(item.mod)}
|
||||||
|
className="text-xs text-slate-500 hover:text-violet-400 transition-colors"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold text-sm text-slate-300 mb-3">Operations</h4>
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{[
|
||||||
|
{ label: 'F.R.A.M.E. Weekly', mod: 'frame' as ModuleId },
|
||||||
|
{ label: 'Attendance', mod: 'attendance' as ModuleId },
|
||||||
|
{ label: 'Handbook & Policies', mod: 'handbook' as ModuleId },
|
||||||
|
{ label: 'Safety Protocols', mod: 'safety' as ModuleId },
|
||||||
|
{ label: 'Community & Partnerships', mod: 'community' as ModuleId },
|
||||||
|
{ label: 'Vocational Opportunities', mod: 'vocational' as ModuleId },
|
||||||
|
{ label: 'ESA Funding Info', mod: 'esa' as ModuleId },
|
||||||
|
].map(item => (
|
||||||
|
<li key={item.label}>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSetModule(item.mod)}
|
||||||
|
className="text-xs text-slate-500 hover:text-violet-400 transition-colors"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold text-sm text-slate-300 mb-3">Platform</h4>
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{['Role-Based Access', 'Secure Auth', 'ADP Integration', 'FERPA Compliant'].map(item => (
|
||||||
|
<li key={item} className="text-xs text-slate-500">{item}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 pt-4 border-t border-slate-700/40 flex flex-col md:flex-row items-center justify-between gap-2">
|
||||||
|
<p className="text-[10px] text-slate-600">FRAMEworks © 2026 — Built for autism-focused school communities</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CheckCircle2 size={10} className={isAuthenticated ? 'text-emerald-500' : 'text-slate-600'} />
|
||||||
|
<span className="text-[10px] text-slate-600">
|
||||||
|
{isAuthenticated
|
||||||
|
? `Signed in as ${userName} (${userRole})`
|
||||||
|
: `Browsing as Guest (${currentDemoRole.label})`
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sign In Modal */}
|
||||||
|
<SignInModal isOpen={showSignInModal} onClose={() => setShowSignInModal(false)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AppLayout;
|
||||||
842
src/components/frameworks/CampusAttendance.tsx
Normal file
842
src/components/frameworks/CampusAttendance.tsx
Normal file
@ -0,0 +1,842 @@
|
|||||||
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { UserRole } from '@/lib/types';
|
||||||
|
import { CAMPUSES, getCampusByMascot } from '@/lib/appData';
|
||||||
|
import {
|
||||||
|
fetchCampusAttendanceConfig,
|
||||||
|
updateCampusAttendanceLink,
|
||||||
|
fetchCampusAttendanceData,
|
||||||
|
upsertCampusAttendanceData,
|
||||||
|
} from '@/lib/db';
|
||||||
|
import {
|
||||||
|
Link2, Save, CheckCircle, Loader2, ExternalLink,
|
||||||
|
Users, UserCheck, UserX, Printer,
|
||||||
|
BarChart3, Calendar, ChevronDown, ChevronUp,
|
||||||
|
ClipboardList, Globe, Edit3, X, Plus, FileText
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface CampusAttendanceProps {
|
||||||
|
userRole: UserRole;
|
||||||
|
userCampus: string;
|
||||||
|
userName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWeekStart(date: Date): string {
|
||||||
|
const d = new Date(date);
|
||||||
|
const day = d.getDay();
|
||||||
|
const diff = d.getDate() - day + (day === 0 ? -6 : 1);
|
||||||
|
d.setDate(diff);
|
||||||
|
return d.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWeekEnd(date: Date): string {
|
||||||
|
const d = new Date(date);
|
||||||
|
const day = d.getDay();
|
||||||
|
const diff = d.getDate() - day + (day === 0 ? 0 : 5);
|
||||||
|
d.setDate(diff);
|
||||||
|
return d.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
const d = new Date(dateStr + 'T12:00:00');
|
||||||
|
return d.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function getToday(): string {
|
||||||
|
return new Date().toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const CampusAttendance: React.FC<CampusAttendanceProps> = ({ userRole, userCampus, userName }) => {
|
||||||
|
const isSuperintendent = userRole === 'superintendent';
|
||||||
|
const isDirector = userRole === 'director';
|
||||||
|
const isOfficeManager = userRole === 'office';
|
||||||
|
// ONLY Superintendent sees all campuses; Director and Office see only their own campus
|
||||||
|
const canSeeAllCampuses = isSuperintendent;
|
||||||
|
const canEnterData = isOfficeManager;
|
||||||
|
const canPrint = isSuperintendent || isDirector || isOfficeManager;
|
||||||
|
|
||||||
|
const campusInfo = getCampusByMascot(userCampus);
|
||||||
|
const campusId = campusInfo?.id || 'tigers';
|
||||||
|
|
||||||
|
const [configs, setConfigs] = useState<any[]>([]);
|
||||||
|
const [attendanceData, setAttendanceData] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [showSuccess, setShowSuccess] = useState('');
|
||||||
|
const [editingLink, setEditingLink] = useState<string | null>(null);
|
||||||
|
const [linkValue, setLinkValue] = useState('');
|
||||||
|
const [showEntryForm, setShowEntryForm] = useState(false);
|
||||||
|
const [expandedCampus, setExpandedCampus] = useState<string | null>(null);
|
||||||
|
const printRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const [entryDate, setEntryDate] = useState(getToday());
|
||||||
|
const [entryEnrolled, setEntryEnrolled] = useState('');
|
||||||
|
const [entryPresent, setEntryPresent] = useState('');
|
||||||
|
const [entryAbsent, setEntryAbsent] = useState('');
|
||||||
|
const [entryTardy, setEntryTardy] = useState('');
|
||||||
|
const [entryNotes, setEntryNotes] = useState('');
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const [cfgs, data] = await Promise.all([
|
||||||
|
fetchCampusAttendanceConfig(),
|
||||||
|
fetchCampusAttendanceData(),
|
||||||
|
]);
|
||||||
|
setConfigs(cfgs);
|
||||||
|
setAttendanceData(data);
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { loadData(); }, [loadData]);
|
||||||
|
|
||||||
|
const handleSaveLink = async (cId: string) => {
|
||||||
|
setSaving(true);
|
||||||
|
const success = await updateCampusAttendanceLink(cId, linkValue, userName);
|
||||||
|
if (success) {
|
||||||
|
setShowSuccess('Link saved successfully!');
|
||||||
|
setTimeout(() => setShowSuccess(''), 3000);
|
||||||
|
setEditingLink(null);
|
||||||
|
await loadData();
|
||||||
|
}
|
||||||
|
setSaving(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmitEntry = async () => {
|
||||||
|
const enrolled = parseInt(entryEnrolled);
|
||||||
|
const present = parseInt(entryPresent);
|
||||||
|
const absent = parseInt(entryAbsent);
|
||||||
|
const tardy = parseInt(entryTardy);
|
||||||
|
if (isNaN(enrolled) || isNaN(present) || isNaN(absent) || enrolled <= 0) return;
|
||||||
|
const percentage = parseFloat(((present / enrolled) * 100).toFixed(2));
|
||||||
|
setSaving(true);
|
||||||
|
const success = await upsertCampusAttendanceData({
|
||||||
|
campus_id: campusId, date: entryDate, total_enrolled: enrolled,
|
||||||
|
total_present: present, total_absent: absent,
|
||||||
|
total_tardy: isNaN(tardy) ? 0 : tardy,
|
||||||
|
attendance_percentage: percentage, recorded_by: userName, notes: entryNotes,
|
||||||
|
});
|
||||||
|
if (success) {
|
||||||
|
setShowSuccess('Attendance data saved!');
|
||||||
|
setTimeout(() => setShowSuccess(''), 3000);
|
||||||
|
setShowEntryForm(false);
|
||||||
|
setEntryEnrolled(''); setEntryPresent(''); setEntryAbsent(''); setEntryTardy(''); setEntryNotes('');
|
||||||
|
await loadData();
|
||||||
|
}
|
||||||
|
setSaving(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const today = getToday();
|
||||||
|
const weekStart = getWeekStart(new Date());
|
||||||
|
const weekEnd = getWeekEnd(new Date());
|
||||||
|
|
||||||
|
const getTodayData = (cId: string) => attendanceData.filter(d => d.campus_id === cId && d.date === today);
|
||||||
|
const getWeekData = (cId: string) => attendanceData.filter(d => d.campus_id === cId && d.date >= weekStart && d.date <= weekEnd);
|
||||||
|
|
||||||
|
const getWeeklyAvg = (cId: string) => {
|
||||||
|
const weekData = getWeekData(cId);
|
||||||
|
if (weekData.length === 0) return null;
|
||||||
|
const avg = weekData.reduce((sum: number, d: any) => sum + parseFloat(d.attendance_percentage), 0) / weekData.length;
|
||||||
|
return parseFloat(avg.toFixed(2));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTodayPercentage = (cId: string) => {
|
||||||
|
const todayData = getTodayData(cId);
|
||||||
|
if (todayData.length === 0) return null;
|
||||||
|
return parseFloat(todayData[0].attendance_percentage);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get all campuses with their stats (for superintendent view)
|
||||||
|
const campusStats = CAMPUSES.map(c => {
|
||||||
|
const todayPct = getTodayPercentage(c.id);
|
||||||
|
const weekAvg = getWeeklyAvg(c.id);
|
||||||
|
const config = configs.find((cfg: any) => cfg.campus_id === c.id);
|
||||||
|
const recentData = attendanceData.filter(d => d.campus_id === c.id).slice(0, 10);
|
||||||
|
const todayRecord = getTodayData(c.id)[0] || null;
|
||||||
|
return { ...c, todayPct, weekAvg, config, recentData, todayRecord };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Overall stats across all campuses for today (superintendent only)
|
||||||
|
const allTodayRecords = attendanceData.filter(d => d.date === today);
|
||||||
|
const overallTodayEnrolled = allTodayRecords.reduce((s: number, d: any) => s + d.total_enrolled, 0);
|
||||||
|
const overallTodayPresent = allTodayRecords.reduce((s: number, d: any) => s + d.total_present, 0);
|
||||||
|
const overallTodayPct = overallTodayEnrolled > 0 ? parseFloat(((overallTodayPresent / overallTodayEnrolled) * 100).toFixed(2)) : null;
|
||||||
|
|
||||||
|
const allWeekRecords = attendanceData.filter(d => d.date >= weekStart && d.date <= weekEnd);
|
||||||
|
const weekDays = [...new Set(allWeekRecords.map(d => d.date))];
|
||||||
|
const overallWeekPct = weekDays.length > 0
|
||||||
|
? parseFloat((weekDays.reduce((sum, day) => {
|
||||||
|
const dayRecords = allWeekRecords.filter(d => d.date === day);
|
||||||
|
const dayEnrolled = dayRecords.reduce((s: number, d: any) => s + d.total_enrolled, 0);
|
||||||
|
const dayPresent = dayRecords.reduce((s: number, d: any) => s + d.total_present, 0);
|
||||||
|
return sum + (dayEnrolled > 0 ? (dayPresent / dayEnrolled) * 100 : 0);
|
||||||
|
}, 0) / weekDays.length).toFixed(2))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Print PDF — Superintendent prints all campuses; Director/Office prints only their campus
|
||||||
|
const handlePrint = () => {
|
||||||
|
const printWindow = window.open('', '_blank');
|
||||||
|
if (!printWindow) return;
|
||||||
|
|
||||||
|
const campusesToPrint = canSeeAllCampuses ? campusStats : campusStats.filter(cs => cs.id === campusId);
|
||||||
|
const reportTitle = canSeeAllCampuses
|
||||||
|
? 'All Campuses Attendance Report'
|
||||||
|
: `${campusInfo?.fullName || userCampus} Attendance Report`;
|
||||||
|
|
||||||
|
// Calculate summary stats for the campuses being printed
|
||||||
|
const printTodayRecords = canSeeAllCampuses
|
||||||
|
? allTodayRecords
|
||||||
|
: attendanceData.filter(d => d.campus_id === campusId && d.date === today);
|
||||||
|
const printEnrolled = printTodayRecords.reduce((s: number, d: any) => s + d.total_enrolled, 0);
|
||||||
|
const printPresent = printTodayRecords.reduce((s: number, d: any) => s + d.total_present, 0);
|
||||||
|
const printTodayPct = printEnrolled > 0 ? parseFloat(((printPresent / printEnrolled) * 100).toFixed(2)) : null;
|
||||||
|
|
||||||
|
const printWeekRecords = canSeeAllCampuses
|
||||||
|
? allWeekRecords
|
||||||
|
: attendanceData.filter(d => d.campus_id === campusId && d.date >= weekStart && d.date <= weekEnd);
|
||||||
|
const printWeekDays = [...new Set(printWeekRecords.map(d => d.date))];
|
||||||
|
const printWeekPct = printWeekDays.length > 0
|
||||||
|
? parseFloat((printWeekDays.reduce((sum, day) => {
|
||||||
|
const dayRecords = printWeekRecords.filter(d => d.date === day);
|
||||||
|
const dayEnrolled = dayRecords.reduce((s: number, d: any) => s + d.total_enrolled, 0);
|
||||||
|
const dayPresent = dayRecords.reduce((s: number, d: any) => s + d.total_present, 0);
|
||||||
|
return sum + (dayEnrolled > 0 ? (dayPresent / dayEnrolled) * 100 : 0);
|
||||||
|
}, 0) / printWeekDays.length).toFixed(2))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
printWindow.document.write(`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>${reportTitle} - ${formatDate(today)}</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; padding: 32px; color: #1e293b; }
|
||||||
|
.header { text-align: center; margin-bottom: 32px; border-bottom: 3px solid #7c3aed; padding-bottom: 16px; }
|
||||||
|
.header h1 { font-size: 24px; color: #7c3aed; margin-bottom: 4px; }
|
||||||
|
.header p { font-size: 13px; color: #64748b; }
|
||||||
|
.summary-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 32px; }
|
||||||
|
.summary-box { border: 2px solid #e2e8f0; border-radius: 12px; padding: 16px; text-align: center; }
|
||||||
|
.summary-box .label { font-size: 11px; color: #64748b; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; }
|
||||||
|
.summary-box .value { font-size: 28px; font-weight: 700; }
|
||||||
|
.summary-box .value.green { color: #16a34a; }
|
||||||
|
.summary-box .value.amber { color: #d97706; }
|
||||||
|
.summary-box .value.red { color: #dc2626; }
|
||||||
|
.campus-section { margin-bottom: 24px; page-break-inside: avoid; }
|
||||||
|
.campus-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; padding: 8px 12px; background: #f8fafc; border-radius: 8px; border-left: 4px solid #7c3aed; }
|
||||||
|
.campus-header h3 { font-size: 15px; font-weight: 600; }
|
||||||
|
.campus-header .stats { margin-left: auto; display: flex; gap: 16px; font-size: 12px; }
|
||||||
|
.campus-header .stats span { font-weight: 600; }
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 12px; margin-bottom: 8px; }
|
||||||
|
th { background: #f1f5f9; text-align: left; padding: 8px 10px; font-weight: 600; color: #475569; border-bottom: 2px solid #e2e8f0; }
|
||||||
|
td { padding: 6px 10px; border-bottom: 1px solid #f1f5f9; }
|
||||||
|
tr:nth-child(even) { background: #fafafa; }
|
||||||
|
.pct-good { color: #16a34a; font-weight: 600; }
|
||||||
|
.pct-warn { color: #d97706; font-weight: 600; }
|
||||||
|
.pct-bad { color: #dc2626; font-weight: 600; }
|
||||||
|
.history-table { margin-top: 16px; }
|
||||||
|
.history-table th { font-size: 11px; }
|
||||||
|
.history-table td { font-size: 11px; }
|
||||||
|
.footer { margin-top: 32px; padding-top: 16px; border-top: 2px solid #e2e8f0; text-align: center; font-size: 11px; color: #94a3b8; }
|
||||||
|
@media print { body { padding: 16px; } .no-print { display: none; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h1>FRAMEworks ${reportTitle}</h1>
|
||||||
|
<p>Generated on ${new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })} at ${new Date().toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })}</p>
|
||||||
|
<p style="margin-top:4px;font-size:12px;color:#7c3aed;">Printed by: ${userName} (${userRole.charAt(0).toUpperCase() + userRole.slice(1)})</p>
|
||||||
|
</div>
|
||||||
|
<div class="summary-grid">
|
||||||
|
<div class="summary-box">
|
||||||
|
<div class="label">Today's ${canSeeAllCampuses ? 'Overall ' : ''}Attendance</div>
|
||||||
|
<div class="value ${printTodayPct !== null ? (printTodayPct >= 90 ? 'green' : printTodayPct >= 80 ? 'amber' : 'red') : ''}">${printTodayPct !== null ? printTodayPct + '%' : 'No data'}</div>
|
||||||
|
<div class="label" style="margin-top:4px">${formatDate(today)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-box">
|
||||||
|
<div class="label">This Week's Average Attendance</div>
|
||||||
|
<div class="value ${printWeekPct !== null ? (printWeekPct >= 90 ? 'green' : printWeekPct >= 80 ? 'amber' : 'red') : ''}">${printWeekPct !== null ? printWeekPct + '%' : 'No data'}</div>
|
||||||
|
<div class="label" style="margin-top:4px">Week of ${formatDate(weekStart)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${campusesToPrint.map(cs => `
|
||||||
|
<div class="campus-section">
|
||||||
|
<div class="campus-header">
|
||||||
|
<h3>${cs.fullName}</h3>
|
||||||
|
<div class="stats">
|
||||||
|
<span>Today: <span class="${cs.todayPct !== null ? (cs.todayPct >= 90 ? 'pct-good' : cs.todayPct >= 80 ? 'pct-warn' : 'pct-bad') : ''}">${cs.todayPct !== null ? cs.todayPct + '%' : 'N/A'}</span></span>
|
||||||
|
<span>Week Avg: <span class="${cs.weekAvg !== null ? (cs.weekAvg >= 90 ? 'pct-good' : cs.weekAvg >= 80 ? 'pct-warn' : 'pct-bad') : ''}">${cs.weekAvg !== null ? cs.weekAvg + '%' : 'N/A'}</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${cs.todayRecord ? `
|
||||||
|
<table>
|
||||||
|
<tr><td style="width:25%;font-weight:600">Enrolled</td><td>${cs.todayRecord.total_enrolled}</td><td style="width:25%;font-weight:600">Present</td><td>${cs.todayRecord.total_present}</td></tr>
|
||||||
|
<tr><td style="font-weight:600">Absent</td><td>${cs.todayRecord.total_absent}</td><td style="font-weight:600">Tardy</td><td>${cs.todayRecord.total_tardy}</td></tr>
|
||||||
|
${cs.todayRecord.notes ? `<tr><td style="font-weight:600">Notes</td><td colspan="3">${cs.todayRecord.notes}</td></tr>` : ''}
|
||||||
|
</table>
|
||||||
|
` : '<p style="font-size:12px;color:#94a3b8;padding:8px 0;">No attendance data recorded for today.</p>'}
|
||||||
|
${cs.recentData.length > 0 ? `
|
||||||
|
<table class="history-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th><th>Enrolled</th><th>Present</th><th>Absent</th><th>Tardy</th><th>Attendance %</th><th>Notes</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${cs.recentData.map((d: any) => `
|
||||||
|
<tr>
|
||||||
|
<td>${formatDate(d.date)}</td>
|
||||||
|
<td>${d.total_enrolled}</td>
|
||||||
|
<td>${d.total_present}</td>
|
||||||
|
<td>${d.total_absent}</td>
|
||||||
|
<td>${d.total_tardy}</td>
|
||||||
|
<td class="${parseFloat(d.attendance_percentage) >= 90 ? 'pct-good' : parseFloat(d.attendance_percentage) >= 80 ? 'pct-warn' : 'pct-bad'}">${parseFloat(d.attendance_percentage).toFixed(1)}%</td>
|
||||||
|
<td>${d.notes || '—'}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
<div class="footer">
|
||||||
|
<p>FRAMEworks Education Support Platform • Confidential • Printed by ${userName}</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`);
|
||||||
|
printWindow.document.close();
|
||||||
|
printWindow.focus();
|
||||||
|
setTimeout(() => { printWindow.print(); }, 500);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Color helpers
|
||||||
|
const pctColor = (pct: number | null) => {
|
||||||
|
if (pct === null) return 'text-slate-400';
|
||||||
|
if (pct >= 95) return 'text-emerald-500';
|
||||||
|
if (pct >= 90) return 'text-emerald-400';
|
||||||
|
if (pct >= 85) return 'text-amber-400';
|
||||||
|
if (pct >= 80) return 'text-amber-500';
|
||||||
|
return 'text-red-500';
|
||||||
|
};
|
||||||
|
|
||||||
|
const pctBg = (pct: number | null) => {
|
||||||
|
if (pct === null) return 'bg-slate-500/10';
|
||||||
|
if (pct >= 90) return 'bg-emerald-500/10';
|
||||||
|
if (pct >= 80) return 'bg-amber-500/10';
|
||||||
|
return 'bg-red-500/10';
|
||||||
|
};
|
||||||
|
|
||||||
|
const pctBorder = (pct: number | null) => {
|
||||||
|
if (pct === null) return 'border-slate-500/20';
|
||||||
|
if (pct >= 90) return 'border-emerald-500/20';
|
||||||
|
if (pct >= 80) return 'border-amber-500/20';
|
||||||
|
return 'border-red-500/20';
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<div className="text-center">
|
||||||
|
<Loader2 size={32} className="animate-spin text-orange-500 mx-auto mb-3" />
|
||||||
|
<p className="text-sm text-slate-400">Loading attendance data...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// My campus data for individual campus views (Director, Office, Teacher, Para)
|
||||||
|
const myCampusConfig = configs.find((c: any) => c.campus_id === campusId);
|
||||||
|
const myCampusData = attendanceData.filter(d => d.campus_id === campusId);
|
||||||
|
const myTodayPct = getTodayPercentage(campusId);
|
||||||
|
const myWeekAvg = getWeeklyAvg(campusId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-white flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-orange-400 to-orange-600 flex items-center justify-center shadow-lg shadow-orange-500/20">
|
||||||
|
<ClipboardList size={20} className="text-white" />
|
||||||
|
</div>
|
||||||
|
Campus Attendance
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-slate-400 mt-1">
|
||||||
|
{isSuperintendent
|
||||||
|
? 'Organization-wide attendance dashboard — all campuses'
|
||||||
|
: isDirector
|
||||||
|
? `${campusInfo?.fullName || userCampus} — Director attendance dashboard`
|
||||||
|
: isOfficeManager
|
||||||
|
? `Manage ${campusInfo?.fullName || userCampus} attendance link and enter daily data`
|
||||||
|
: `${campusInfo?.fullName || userCampus} attendance overview`
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{canPrint && (
|
||||||
|
<button
|
||||||
|
onClick={handlePrint}
|
||||||
|
className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-violet-500 to-purple-600 text-white rounded-xl font-medium text-sm hover:shadow-lg hover:shadow-violet-500/25 transition-all"
|
||||||
|
>
|
||||||
|
<Printer size={16} />
|
||||||
|
Print {canSeeAllCampuses ? 'All Campuses' : 'Campus'} Report
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{canEnterData && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowEntryForm(!showEntryForm)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-orange-500 to-amber-500 text-white rounded-xl font-medium text-sm hover:shadow-lg hover:shadow-orange-500/25 transition-all"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
Enter Today's Attendance
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Success Banner */}
|
||||||
|
{showSuccess && (
|
||||||
|
<div className="bg-emerald-500/10 border border-emerald-500/20 rounded-xl p-4 flex items-center gap-3">
|
||||||
|
<CheckCircle size={18} className="text-emerald-400" />
|
||||||
|
<p className="text-sm text-emerald-300 font-medium">{showSuccess}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ===== OFFICE MANAGER: Entry Form ===== */}
|
||||||
|
{isOfficeManager && showEntryForm && (
|
||||||
|
<div className="bg-slate-800/60 backdrop-blur-sm rounded-2xl border border-orange-500/20 p-6">
|
||||||
|
<div className="flex items-center justify-between mb-5">
|
||||||
|
<h3 className="font-semibold text-white flex items-center gap-2">
|
||||||
|
<Calendar size={18} className="text-orange-400" />
|
||||||
|
Enter Attendance for {campusInfo?.fullName}
|
||||||
|
</h3>
|
||||||
|
<button onClick={() => setShowEntryForm(false)} className="text-slate-400 hover:text-white transition-colors">
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-slate-400 mb-1.5 font-medium">Date</label>
|
||||||
|
<input type="date" value={entryDate} onChange={e => setEntryDate(e.target.value)}
|
||||||
|
className="w-full px-4 py-2.5 bg-slate-700/50 border border-slate-600/50 rounded-xl text-sm text-white focus:ring-2 focus:ring-orange-500/50 outline-none" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-slate-400 mb-1.5 font-medium">Total Enrolled</label>
|
||||||
|
<input type="number" value={entryEnrolled} onChange={e => setEntryEnrolled(e.target.value)} placeholder="e.g. 45"
|
||||||
|
className="w-full px-4 py-2.5 bg-slate-700/50 border border-slate-600/50 rounded-xl text-sm text-white focus:ring-2 focus:ring-orange-500/50 outline-none placeholder:text-slate-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-slate-400 mb-1.5 font-medium">Total Present</label>
|
||||||
|
<input type="number" value={entryPresent} onChange={e => setEntryPresent(e.target.value)} placeholder="e.g. 42"
|
||||||
|
className="w-full px-4 py-2.5 bg-slate-700/50 border border-slate-600/50 rounded-xl text-sm text-white focus:ring-2 focus:ring-orange-500/50 outline-none placeholder:text-slate-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-slate-400 mb-1.5 font-medium">Total Absent</label>
|
||||||
|
<input type="number" value={entryAbsent} onChange={e => setEntryAbsent(e.target.value)} placeholder="e.g. 2"
|
||||||
|
className="w-full px-4 py-2.5 bg-slate-700/50 border border-slate-600/50 rounded-xl text-sm text-white focus:ring-2 focus:ring-orange-500/50 outline-none placeholder:text-slate-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-slate-400 mb-1.5 font-medium">Total Tardy</label>
|
||||||
|
<input type="number" value={entryTardy} onChange={e => setEntryTardy(e.target.value)} placeholder="e.g. 1"
|
||||||
|
className="w-full px-4 py-2.5 bg-slate-700/50 border border-slate-600/50 rounded-xl text-sm text-white focus:ring-2 focus:ring-orange-500/50 outline-none placeholder:text-slate-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-slate-400 mb-1.5 font-medium">Notes (optional)</label>
|
||||||
|
<input type="text" value={entryNotes} onChange={e => setEntryNotes(e.target.value)} placeholder="Any notes..."
|
||||||
|
className="w-full px-4 py-2.5 bg-slate-700/50 border border-slate-600/50 rounded-xl text-sm text-white focus:ring-2 focus:ring-orange-500/50 outline-none placeholder:text-slate-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{entryEnrolled && entryPresent && (
|
||||||
|
<div className="mt-4 p-3 rounded-xl bg-slate-700/30 border border-slate-600/30">
|
||||||
|
<p className="text-sm text-slate-300">
|
||||||
|
Calculated Attendance: <span className={`font-bold text-lg ${pctColor(parseInt(entryEnrolled) > 0 ? (parseInt(entryPresent) / parseInt(entryEnrolled)) * 100 : null)}`}>
|
||||||
|
{parseInt(entryEnrolled) > 0 ? ((parseInt(entryPresent) / parseInt(entryEnrolled)) * 100).toFixed(1) : '0'}%
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mt-4 flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={handleSubmitEntry}
|
||||||
|
disabled={saving || !entryEnrolled || !entryPresent || !entryAbsent}
|
||||||
|
className="flex items-center gap-2 px-6 py-2.5 bg-gradient-to-r from-orange-500 to-amber-500 text-white rounded-xl font-medium text-sm hover:shadow-lg transition-all disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
||||||
|
{saving ? 'Saving...' : 'Save Attendance'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ===== OFFICE MANAGER: Attendance Link Config ===== */}
|
||||||
|
{isOfficeManager && (
|
||||||
|
<div className="bg-slate-800/60 backdrop-blur-sm rounded-2xl border border-slate-700/40 p-6">
|
||||||
|
<h3 className="font-semibold text-white flex items-center gap-2 mb-4">
|
||||||
|
<Link2 size={18} className="text-orange-400" />
|
||||||
|
Campus Attendance System Link
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-slate-400 mb-4">
|
||||||
|
Enter the URL to your campus attendance system. This link will be accessible to all staff on your campus for quick access.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3">
|
||||||
|
{editingLink === campusId ? (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={linkValue}
|
||||||
|
onChange={e => setLinkValue(e.target.value)}
|
||||||
|
placeholder="https://your-attendance-system.com/campus-link"
|
||||||
|
className="flex-1 px-4 py-2.5 bg-slate-700/50 border border-orange-500/30 rounded-xl text-sm text-white focus:ring-2 focus:ring-orange-500/50 outline-none placeholder:text-slate-500"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleSaveLink(campusId)}
|
||||||
|
disabled={saving}
|
||||||
|
className="flex items-center gap-2 px-4 py-2.5 bg-emerald-500/20 text-emerald-400 border border-emerald-500/30 rounded-xl text-sm font-medium hover:bg-emerald-500/30 transition-all disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setEditingLink(null)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2.5 bg-slate-700/50 text-slate-400 border border-slate-600/50 rounded-xl text-sm font-medium hover:bg-slate-700 transition-all"
|
||||||
|
>
|
||||||
|
<X size={14} /> Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex-1 px-4 py-2.5 bg-slate-700/30 border border-slate-600/30 rounded-xl text-sm flex items-center gap-2">
|
||||||
|
{myCampusConfig?.attendance_link ? (
|
||||||
|
<>
|
||||||
|
<Globe size={14} className="text-orange-400 flex-shrink-0" />
|
||||||
|
<a href={myCampusConfig.attendance_link} target="_blank" rel="noopener noreferrer"
|
||||||
|
className="text-orange-400 hover:text-orange-300 truncate transition-colors">
|
||||||
|
{myCampusConfig.attendance_link}
|
||||||
|
</a>
|
||||||
|
<ExternalLink size={12} className="text-slate-500 flex-shrink-0" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-slate-500 italic">No attendance link configured yet</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => { setEditingLink(campusId); setLinkValue(myCampusConfig?.attendance_link || ''); }}
|
||||||
|
className="flex items-center gap-2 px-4 py-2.5 bg-orange-500/15 text-orange-400 border border-orange-500/30 rounded-xl text-sm font-medium hover:bg-orange-500/25 transition-all"
|
||||||
|
>
|
||||||
|
<Edit3 size={14} />
|
||||||
|
{myCampusConfig?.attendance_link ? 'Update Link' : 'Add Link'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{myCampusConfig?.updated_by && (
|
||||||
|
<p className="text-[10px] text-slate-500 mt-2">
|
||||||
|
Last updated by {myCampusConfig.updated_by} on {new Date(myCampusConfig.updated_at).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ===== SUPERINTENDENT ONLY: All Campuses Overview ===== */}
|
||||||
|
{isSuperintendent && (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<div className={`rounded-2xl border p-5 ${pctBg(overallTodayPct)} ${pctBorder(overallTodayPct)}`}>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Calendar size={16} className="text-slate-400" />
|
||||||
|
<span className="text-xs text-slate-400 font-medium">Today's Overall Attendance</span>
|
||||||
|
</div>
|
||||||
|
<p className={`text-3xl font-bold ${pctColor(overallTodayPct)}`}>
|
||||||
|
{overallTodayPct !== null ? `${overallTodayPct}%` : 'N/A'}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-slate-500 mt-1">{formatDate(today)}</p>
|
||||||
|
</div>
|
||||||
|
<div className={`rounded-2xl border p-5 ${pctBg(overallWeekPct)} ${pctBorder(overallWeekPct)}`}>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<BarChart3 size={16} className="text-slate-400" />
|
||||||
|
<span className="text-xs text-slate-400 font-medium">Weekly Average</span>
|
||||||
|
</div>
|
||||||
|
<p className={`text-3xl font-bold ${pctColor(overallWeekPct)}`}>
|
||||||
|
{overallWeekPct !== null ? `${overallWeekPct}%` : 'N/A'}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-slate-500 mt-1">Week of {formatDate(weekStart)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border p-5 bg-blue-500/10 border-blue-500/20">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Users size={16} className="text-blue-400" />
|
||||||
|
<span className="text-xs text-slate-400 font-medium">Total Enrolled Today</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-3xl font-bold text-blue-400">{overallTodayEnrolled || 'N/A'}</p>
|
||||||
|
<p className="text-[10px] text-slate-500 mt-1">Across all campuses</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border p-5 bg-violet-500/10 border-violet-500/20">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<UserCheck size={16} className="text-violet-400" />
|
||||||
|
<span className="text-xs text-slate-400 font-medium">Present Today</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-3xl font-bold text-violet-400">{overallTodayPresent || 'N/A'}</p>
|
||||||
|
<p className="text-[10px] text-slate-500 mt-1">Across all campuses</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Campus Cards Grid */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{campusStats.map(cs => (
|
||||||
|
<div key={cs.id} className="bg-slate-800/60 backdrop-blur-sm rounded-2xl border border-slate-700/40 overflow-hidden hover:border-slate-600/50 transition-all">
|
||||||
|
{/* Campus Header */}
|
||||||
|
<div className={`bg-gradient-to-r ${cs.bgGradient} p-4`}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-white text-lg">{cs.mascot}</h3>
|
||||||
|
<p className="text-white/70 text-xs">{cs.fullName}</p>
|
||||||
|
</div>
|
||||||
|
{cs.isOnline && (
|
||||||
|
<span className="px-2 py-0.5 bg-white/20 text-white text-[10px] rounded-full font-medium">Online</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="p-4 space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className={`rounded-xl p-3 text-center ${pctBg(cs.todayPct)} border ${pctBorder(cs.todayPct)}`}>
|
||||||
|
<p className="text-[10px] text-slate-400 mb-1">Today</p>
|
||||||
|
<p className={`text-xl font-bold ${pctColor(cs.todayPct)}`}>
|
||||||
|
{cs.todayPct !== null ? `${cs.todayPct}%` : 'N/A'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className={`rounded-xl p-3 text-center ${pctBg(cs.weekAvg)} border ${pctBorder(cs.weekAvg)}`}>
|
||||||
|
<p className="text-[10px] text-slate-400 mb-1">Week Avg</p>
|
||||||
|
<p className={`text-xl font-bold ${pctColor(cs.weekAvg)}`}>
|
||||||
|
{cs.weekAvg !== null ? `${cs.weekAvg}%` : 'N/A'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{cs.todayRecord && (
|
||||||
|
<div className="grid grid-cols-3 gap-2 text-center">
|
||||||
|
<div className="bg-slate-700/30 rounded-lg p-2">
|
||||||
|
<p className="text-xs text-slate-400">Enrolled</p>
|
||||||
|
<p className="text-sm font-bold text-white">{cs.todayRecord.total_enrolled}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-700/30 rounded-lg p-2">
|
||||||
|
<p className="text-xs text-slate-400">Present</p>
|
||||||
|
<p className="text-sm font-bold text-emerald-400">{cs.todayRecord.total_present}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-700/30 rounded-lg p-2">
|
||||||
|
<p className="text-xs text-slate-400">Absent</p>
|
||||||
|
<p className="text-sm font-bold text-red-400">{cs.todayRecord.total_absent}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Attendance Link */}
|
||||||
|
{cs.config?.attendance_link ? (
|
||||||
|
<a href={cs.config.attendance_link} target="_blank" rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-2 px-3 py-2 bg-slate-700/30 rounded-lg text-xs text-orange-400 hover:text-orange-300 hover:bg-slate-700/50 transition-all truncate">
|
||||||
|
<Link2 size={12} className="flex-shrink-0" />
|
||||||
|
<span className="truncate">{cs.config.attendance_link}</span>
|
||||||
|
<ExternalLink size={10} className="flex-shrink-0 ml-auto" />
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 bg-slate-700/20 rounded-lg text-xs text-slate-500 italic">
|
||||||
|
<Link2 size={12} /> No link configured
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Expand to see history */}
|
||||||
|
<button
|
||||||
|
onClick={() => setExpandedCampus(expandedCampus === cs.id ? null : cs.id)}
|
||||||
|
className="w-full flex items-center justify-center gap-1 text-xs text-slate-400 hover:text-white py-1 transition-colors"
|
||||||
|
>
|
||||||
|
{expandedCampus === cs.id ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||||
|
{expandedCampus === cs.id ? 'Hide History' : 'View History'}
|
||||||
|
</button>
|
||||||
|
{expandedCampus === cs.id && cs.recentData.length > 0 && (
|
||||||
|
<div className="space-y-1.5 max-h-48 overflow-y-auto">
|
||||||
|
{cs.recentData.map((d: any, i: number) => (
|
||||||
|
<div key={i} className="flex items-center justify-between px-3 py-2 bg-slate-700/20 rounded-lg text-xs">
|
||||||
|
<span className="text-slate-400">{formatDate(d.date)}</span>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-slate-500">{d.total_present}/{d.total_enrolled}</span>
|
||||||
|
<span className={`font-bold ${pctColor(parseFloat(d.attendance_percentage))}`}>
|
||||||
|
{parseFloat(d.attendance_percentage).toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Superintendent: Attendance Links Overview */}
|
||||||
|
<div className="bg-slate-800/60 backdrop-blur-sm rounded-2xl border border-slate-700/40 p-6">
|
||||||
|
<h3 className="font-semibold text-white flex items-center gap-2 mb-4">
|
||||||
|
<Link2 size={18} className="text-orange-400" />
|
||||||
|
Campus Attendance System Links
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{campusStats.map(cs => (
|
||||||
|
<div key={cs.id} className="flex items-center justify-between p-3 bg-slate-700/20 rounded-xl">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`w-8 h-8 rounded-lg bg-gradient-to-br ${cs.bgGradient} flex items-center justify-center`}>
|
||||||
|
<span className="text-white text-xs font-bold">{cs.mascot[0]}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-white">{cs.fullName}</p>
|
||||||
|
{cs.config?.updated_by && (
|
||||||
|
<p className="text-[10px] text-slate-500">Updated by {cs.config.updated_by}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{cs.config?.attendance_link ? (
|
||||||
|
<a href={cs.config.attendance_link} target="_blank" rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5 bg-orange-500/10 text-orange-400 border border-orange-500/20 rounded-lg text-xs hover:bg-orange-500/20 transition-all">
|
||||||
|
<Globe size={12} />
|
||||||
|
Open Link
|
||||||
|
<ExternalLink size={10} />
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-slate-500 italic px-3 py-1.5">Not configured</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ===== INDIVIDUAL CAMPUS VIEW: Director, Office, Teacher, Para ===== */}
|
||||||
|
{!isSuperintendent && (
|
||||||
|
<>
|
||||||
|
{/* Today & Week Summary */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<div className={`rounded-2xl border p-5 ${pctBg(myTodayPct)} ${pctBorder(myTodayPct)}`}>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Calendar size={16} className="text-slate-400" />
|
||||||
|
<span className="text-xs text-slate-400 font-medium">Today's Attendance</span>
|
||||||
|
</div>
|
||||||
|
<p className={`text-3xl font-bold ${pctColor(myTodayPct)}`}>
|
||||||
|
{myTodayPct !== null ? `${myTodayPct}%` : 'N/A'}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-slate-500 mt-1">{formatDate(today)}</p>
|
||||||
|
</div>
|
||||||
|
<div className={`rounded-2xl border p-5 ${pctBg(myWeekAvg)} ${pctBorder(myWeekAvg)}`}>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<BarChart3 size={16} className="text-slate-400" />
|
||||||
|
<span className="text-xs text-slate-400 font-medium">Weekly Average</span>
|
||||||
|
</div>
|
||||||
|
<p className={`text-3xl font-bold ${pctColor(myWeekAvg)}`}>
|
||||||
|
{myWeekAvg !== null ? `${myWeekAvg}%` : 'N/A'}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-slate-500 mt-1">Week of {formatDate(weekStart)}</p>
|
||||||
|
</div>
|
||||||
|
{myCampusData.length > 0 && myCampusData[0].date === today && (
|
||||||
|
<>
|
||||||
|
<div className="rounded-2xl border p-5 bg-blue-500/10 border-blue-500/20">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Users size={16} className="text-blue-400" />
|
||||||
|
<span className="text-xs text-slate-400 font-medium">Enrolled</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-3xl font-bold text-blue-400">{myCampusData[0].total_enrolled}</p>
|
||||||
|
<p className="text-[10px] text-slate-500 mt-1">{myCampusData[0].total_present} present</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border p-5 bg-red-500/10 border-red-500/20">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<UserX size={16} className="text-red-400" />
|
||||||
|
<span className="text-xs text-slate-400 font-medium">Absent Today</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-3xl font-bold text-red-400">{myCampusData[0].total_absent}</p>
|
||||||
|
<p className="text-[10px] text-slate-500 mt-1">{myCampusData[0].total_tardy} tardy</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Attendance Link for my campus */}
|
||||||
|
{myCampusConfig?.attendance_link && !isOfficeManager && (
|
||||||
|
<div className="bg-slate-800/60 backdrop-blur-sm rounded-2xl border border-slate-700/40 p-5">
|
||||||
|
<h3 className="font-semibold text-white flex items-center gap-2 mb-3">
|
||||||
|
<Link2 size={16} className="text-orange-400" />
|
||||||
|
Campus Attendance System
|
||||||
|
</h3>
|
||||||
|
<a href={myCampusConfig.attendance_link} target="_blank" rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-2 px-4 py-3 bg-orange-500/10 border border-orange-500/20 rounded-xl text-sm text-orange-400 hover:bg-orange-500/20 transition-all">
|
||||||
|
<Globe size={16} />
|
||||||
|
<span className="truncate">{myCampusConfig.attendance_link}</span>
|
||||||
|
<ExternalLink size={14} className="ml-auto flex-shrink-0" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recent Attendance History */}
|
||||||
|
<div className="bg-slate-800/60 backdrop-blur-sm rounded-2xl border border-slate-700/40 overflow-hidden">
|
||||||
|
<div className="p-5 border-b border-slate-700/40">
|
||||||
|
<h3 className="font-semibold text-white flex items-center gap-2">
|
||||||
|
<FileText size={16} className="text-slate-400" />
|
||||||
|
Recent Attendance History — {campusInfo?.fullName}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
{myCampusData.length > 0 ? (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-slate-700/30">
|
||||||
|
<th className="text-left p-3 font-medium text-slate-400 text-xs">Date</th>
|
||||||
|
<th className="text-center p-3 font-medium text-slate-400 text-xs">Enrolled</th>
|
||||||
|
<th className="text-center p-3 font-medium text-slate-400 text-xs">Present</th>
|
||||||
|
<th className="text-center p-3 font-medium text-slate-400 text-xs">Absent</th>
|
||||||
|
<th className="text-center p-3 font-medium text-slate-400 text-xs">Tardy</th>
|
||||||
|
<th className="text-center p-3 font-medium text-slate-400 text-xs">Attendance %</th>
|
||||||
|
<th className="text-left p-3 font-medium text-slate-400 text-xs">Notes</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{myCampusData.slice(0, 15).map((d: any, i: number) => (
|
||||||
|
<tr key={i} className="border-t border-slate-700/20 hover:bg-slate-700/20 transition-colors">
|
||||||
|
<td className="p-3 text-slate-300 font-medium">{formatDate(d.date)}</td>
|
||||||
|
<td className="p-3 text-center text-slate-300">{d.total_enrolled}</td>
|
||||||
|
<td className="p-3 text-center">
|
||||||
|
<span className="px-2 py-0.5 rounded-lg text-xs font-semibold bg-emerald-500/10 text-emerald-400">{d.total_present}</span>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-center">
|
||||||
|
<span className={`px-2 py-0.5 rounded-lg text-xs font-semibold ${d.total_absent > 3 ? 'bg-red-500/10 text-red-400' : 'bg-slate-700/30 text-slate-400'}`}>{d.total_absent}</span>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-center">
|
||||||
|
<span className={`px-2 py-0.5 rounded-lg text-xs font-semibold ${d.total_tardy > 2 ? 'bg-amber-500/10 text-amber-400' : 'bg-slate-700/30 text-slate-400'}`}>{d.total_tardy}</span>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-center">
|
||||||
|
<span className={`font-bold ${pctColor(parseFloat(d.attendance_percentage))}`}>
|
||||||
|
{parseFloat(d.attendance_percentage).toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-xs text-slate-500 max-w-[200px] truncate">{d.notes || '—'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-10 text-center">
|
||||||
|
<ClipboardList size={40} className="text-slate-600 mx-auto mb-3" />
|
||||||
|
<p className="text-slate-400 text-sm">No attendance data recorded yet for this campus.</p>
|
||||||
|
{isOfficeManager && (
|
||||||
|
<button onClick={() => setShowEntryForm(true)}
|
||||||
|
className="mt-3 text-orange-400 hover:text-orange-300 text-sm font-medium transition-colors">
|
||||||
|
Enter today's attendance
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Hidden print content ref */}
|
||||||
|
<div ref={printRef} className="hidden" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CampusAttendance;
|
||||||
256
src/components/frameworks/ClassroomSupport.tsx
Normal file
256
src/components/frameworks/ClassroomSupport.tsx
Normal file
@ -0,0 +1,256 @@
|
|||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import { Strategy } from '@/lib/types';
|
||||||
|
import { STRATEGIES } from '@/lib/appData';
|
||||||
|
import {
|
||||||
|
Search, Filter, BookmarkPlus, Bookmark, Star, Lightbulb,
|
||||||
|
ChevronDown, X, Sparkles
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
const ClassroomSupport: React.FC = () => {
|
||||||
|
const [strategies, setStrategies] = useState<(Strategy & { isFavorite?: boolean })[]>(STRATEGIES);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [categoryFilter, setCategoryFilter] = useState<string>('all');
|
||||||
|
const [ageFilter, setAgeFilter] = useState<string>('all');
|
||||||
|
const [zoneFilter, setZoneFilter] = useState<string>('all');
|
||||||
|
const [showFavoritesOnly, setShowFavoritesOnly] = useState(false);
|
||||||
|
const [selectedStrategy, setSelectedStrategy] = useState<Strategy | null>(null);
|
||||||
|
|
||||||
|
const categories = [
|
||||||
|
{ value: 'all', label: 'All Categories' },
|
||||||
|
{ value: 'visual-support', label: 'Visual Supports' },
|
||||||
|
{ value: 'transition', label: 'Transitions' },
|
||||||
|
{ value: 'sensory', label: 'Sensory' },
|
||||||
|
{ value: 'communication', label: 'Communication' },
|
||||||
|
{ value: 'behavior', label: 'Behavior' },
|
||||||
|
{ value: 'social', label: 'Social Skills' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ages = [
|
||||||
|
{ value: 'all', label: 'All Ages' },
|
||||||
|
{ value: 'K-2', label: 'K-2' },
|
||||||
|
{ value: '3-5', label: '3-5' },
|
||||||
|
{ value: '6-8', label: '6-8' },
|
||||||
|
{ value: 'All', label: 'Universal' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const zoneColors = [
|
||||||
|
{ value: 'all', label: 'All Zones', bg: 'bg-slate-700/50', text: 'text-slate-300', activeBg: 'bg-slate-600/50', activeText: 'text-white' },
|
||||||
|
{ value: 'blue', label: 'Blue', bg: 'bg-blue-500/10', text: 'text-blue-400', activeBg: 'bg-blue-500/20', activeText: 'text-blue-300' },
|
||||||
|
{ value: 'green', label: 'Green', bg: 'bg-emerald-500/10', text: 'text-emerald-400', activeBg: 'bg-emerald-500/20', activeText: 'text-emerald-300' },
|
||||||
|
{ value: 'yellow', label: 'Yellow', bg: 'bg-yellow-500/10', text: 'text-yellow-400', activeBg: 'bg-yellow-500/20', activeText: 'text-yellow-300' },
|
||||||
|
{ value: 'red', label: 'Red', bg: 'bg-red-500/10', text: 'text-red-400', activeBg: 'bg-red-500/20', activeText: 'text-red-300' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const categoryColors: Record<string, string> = {
|
||||||
|
'visual-support': 'bg-violet-500/15 text-violet-400 border-violet-500/20',
|
||||||
|
'transition': 'bg-amber-500/15 text-amber-400 border-amber-500/20',
|
||||||
|
'sensory': 'bg-teal-500/15 text-teal-400 border-teal-500/20',
|
||||||
|
'communication': 'bg-blue-500/15 text-blue-400 border-blue-500/20',
|
||||||
|
'behavior': 'bg-rose-500/15 text-rose-400 border-rose-500/20',
|
||||||
|
'social': 'bg-emerald-500/15 text-emerald-400 border-emerald-500/20',
|
||||||
|
};
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
return strategies.filter(s => {
|
||||||
|
if (searchQuery && !s.title.toLowerCase().includes(searchQuery.toLowerCase()) && !s.description.toLowerCase().includes(searchQuery.toLowerCase())) return false;
|
||||||
|
if (categoryFilter !== 'all' && s.category !== categoryFilter) return false;
|
||||||
|
if (ageFilter !== 'all' && s.ageGroup !== ageFilter) return false;
|
||||||
|
if (zoneFilter !== 'all' && s.zone !== zoneFilter) return false;
|
||||||
|
if (showFavoritesOnly && !s.isFavorite) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [strategies, searchQuery, categoryFilter, ageFilter, zoneFilter, showFavoritesOnly]);
|
||||||
|
|
||||||
|
const toggleFavorite = (id: string) => {
|
||||||
|
setStrategies(strategies.map(s => s.id === id ? { ...s, isFavorite: !s.isFavorite } : s));
|
||||||
|
};
|
||||||
|
|
||||||
|
const favCount = strategies.filter(s => s.isFavorite).length;
|
||||||
|
|
||||||
|
// Random "Try This Today"
|
||||||
|
const tryToday = strategies[Math.floor(Date.now() / 86400000) % strategies.length];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-white flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-emerald-500 to-emerald-600 flex items-center justify-center shadow-lg shadow-emerald-500/30">
|
||||||
|
<Lightbulb size={20} className="text-white" />
|
||||||
|
</div>
|
||||||
|
Classroom Support Library
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-slate-400 mt-1">Practical, autism-focused strategies — not theory. Search, filter, and save your favorites.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Try This Today */}
|
||||||
|
<div className="relative overflow-hidden bg-gradient-to-r from-emerald-500/10 via-teal-500/10 to-emerald-500/5 rounded-2xl border border-emerald-500/20 p-5 flex gap-4 items-start">
|
||||||
|
<div className="absolute top-0 right-0 w-40 h-40 bg-gradient-to-bl from-emerald-400/5 to-transparent rounded-bl-full" />
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-emerald-500 to-teal-500 flex items-center justify-center flex-shrink-0 shadow-lg shadow-emerald-500/30">
|
||||||
|
<Sparkles size={22} className="text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-emerald-400 flex items-center gap-2">
|
||||||
|
Try This Today
|
||||||
|
</h3>
|
||||||
|
<p className="font-semibold text-sm text-white mt-1">{tryToday.title}</p>
|
||||||
|
<p className="text-sm text-slate-400 mt-0.5">{tryToday.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search & Filters */}
|
||||||
|
<div className="bg-slate-800/40 backdrop-blur-sm rounded-2xl border border-slate-700/40 p-4 space-y-3">
|
||||||
|
<div className="relative">
|
||||||
|
<Search size={18} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={e => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Search strategies by name, behavior, or keyword..."
|
||||||
|
className="w-full pl-10 pr-4 py-2.5 bg-slate-700/50 border border-slate-600/50 rounded-xl text-sm text-white placeholder-slate-500 focus:ring-2 focus:ring-emerald-500/50 focus:border-emerald-500/50 outline-none"
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<button onClick={() => setSearchQuery('')} className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-500 hover:text-white">
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2 items-center">
|
||||||
|
<select
|
||||||
|
value={categoryFilter}
|
||||||
|
onChange={e => setCategoryFilter(e.target.value)}
|
||||||
|
className="px-3 py-2 bg-slate-700/50 border border-slate-600/50 rounded-xl text-sm text-white focus:ring-2 focus:ring-emerald-500/50 outline-none"
|
||||||
|
>
|
||||||
|
{categories.map(c => <option key={c.value} value={c.value}>{c.label}</option>)}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={ageFilter}
|
||||||
|
onChange={e => setAgeFilter(e.target.value)}
|
||||||
|
className="px-3 py-2 bg-slate-700/50 border border-slate-600/50 rounded-xl text-sm text-white focus:ring-2 focus:ring-emerald-500/50 outline-none"
|
||||||
|
>
|
||||||
|
{ages.map(a => <option key={a.value} value={a.value}>{a.label}</option>)}
|
||||||
|
</select>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
{zoneColors.map(z => (
|
||||||
|
<button
|
||||||
|
key={z.value}
|
||||||
|
onClick={() => setZoneFilter(z.value === zoneFilter ? 'all' : z.value)}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all border ${
|
||||||
|
zoneFilter === z.value ? `${z.activeBg} ${z.activeText} ring-2 ring-offset-1 ring-offset-slate-900 border-transparent` : `${z.bg} ${z.text} border-transparent hover:opacity-80`
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{z.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowFavoritesOnly(!showFavoritesOnly)}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all ml-auto border ${
|
||||||
|
showFavoritesOnly ? 'bg-amber-500/20 text-amber-400 ring-2 ring-amber-500/30 ring-offset-1 ring-offset-slate-900 border-amber-500/20' : 'bg-slate-700/30 text-slate-400 border-slate-600/30 hover:bg-slate-700/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Bookmark size={12} />
|
||||||
|
Favorites ({favCount})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results Count */}
|
||||||
|
<p className="text-sm text-slate-500">{filtered.length} strategies found</p>
|
||||||
|
|
||||||
|
{/* Strategy Grid */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{filtered.map(strategy => (
|
||||||
|
<div
|
||||||
|
key={strategy.id}
|
||||||
|
className="bg-slate-800/40 backdrop-blur-sm rounded-2xl border border-slate-700/40 overflow-hidden hover:border-slate-600/50 hover:shadow-xl hover:shadow-black/20 transition-all duration-300 hover:-translate-y-1 group cursor-pointer"
|
||||||
|
onClick={() => setSelectedStrategy(strategy)}
|
||||||
|
>
|
||||||
|
<div className="h-40 overflow-hidden relative">
|
||||||
|
<img src={strategy.image} alt={strategy.title} className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500" />
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-slate-900/80 via-transparent to-transparent" />
|
||||||
|
<button
|
||||||
|
onClick={e => { e.stopPropagation(); toggleFavorite(strategy.id); }}
|
||||||
|
className="absolute top-2 right-2 w-8 h-8 bg-slate-900/60 backdrop-blur-sm rounded-full flex items-center justify-center hover:bg-slate-900/80 transition-colors"
|
||||||
|
>
|
||||||
|
{strategy.isFavorite ? (
|
||||||
|
<Bookmark size={16} className="text-amber-400 fill-amber-400" />
|
||||||
|
) : (
|
||||||
|
<BookmarkPlus size={16} className="text-slate-300" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<div className="absolute bottom-2 left-2 flex gap-1.5">
|
||||||
|
<span className={`px-2 py-0.5 rounded-lg text-[10px] font-semibold border ${categoryColors[strategy.category]}`}>
|
||||||
|
{strategy.category.replace('-', ' ')}
|
||||||
|
</span>
|
||||||
|
<span className={`px-2 py-0.5 rounded-lg text-[10px] font-semibold border ${
|
||||||
|
strategy.zone === 'blue' ? 'bg-blue-500/15 text-blue-400 border-blue-500/20' :
|
||||||
|
strategy.zone === 'green' ? 'bg-emerald-500/15 text-emerald-400 border-emerald-500/20' :
|
||||||
|
strategy.zone === 'yellow' ? 'bg-yellow-500/15 text-yellow-400 border-yellow-500/20' :
|
||||||
|
'bg-red-500/15 text-red-400 border-red-500/20'
|
||||||
|
}`}>
|
||||||
|
{strategy.zone} zone
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
<h4 className="font-semibold text-white text-sm">{strategy.title}</h4>
|
||||||
|
<p className="text-xs text-slate-400 mt-1 line-clamp-2">{strategy.description}</p>
|
||||||
|
<div className="flex items-center justify-between mt-3">
|
||||||
|
<span className="text-[10px] font-medium text-slate-500 bg-slate-700/50 px-2 py-1 rounded-lg border border-slate-600/30">{strategy.ageGroup}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<Search size={40} className="mx-auto text-slate-600 mb-3" />
|
||||||
|
<p className="text-slate-400 font-medium">No strategies found</p>
|
||||||
|
<p className="text-sm text-slate-500 mt-1">Try adjusting your filters or search terms</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Strategy Detail Modal */}
|
||||||
|
{selectedStrategy && (
|
||||||
|
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4" onClick={() => setSelectedStrategy(null)}>
|
||||||
|
<div className="bg-slate-800 rounded-2xl max-w-lg w-full max-h-[80vh] overflow-y-auto shadow-2xl shadow-black/40 border border-slate-700/50" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="h-52 overflow-hidden relative rounded-t-2xl">
|
||||||
|
<img src={selectedStrategy.image} alt={selectedStrategy.title} className="w-full h-full object-cover" />
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-slate-800/90 via-transparent to-transparent" />
|
||||||
|
<button onClick={() => setSelectedStrategy(null)} className="absolute top-3 right-3 w-8 h-8 bg-slate-900/60 backdrop-blur-sm rounded-full flex items-center justify-center hover:bg-slate-900/80 text-white">
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<span className={`px-2.5 py-1 rounded-lg text-xs font-semibold border ${categoryColors[selectedStrategy.category]}`}>
|
||||||
|
{selectedStrategy.category.replace('-', ' ')}
|
||||||
|
</span>
|
||||||
|
<span className="px-2.5 py-1 rounded-lg text-xs font-semibold bg-slate-700/50 text-slate-300 border border-slate-600/30">{selectedStrategy.ageGroup}</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-bold text-white">{selectedStrategy.title}</h3>
|
||||||
|
<p className="text-sm text-slate-300 leading-relaxed">{selectedStrategy.description}</p>
|
||||||
|
<div className="bg-emerald-500/10 rounded-xl p-4 border border-emerald-500/20">
|
||||||
|
<h4 className="font-semibold text-emerald-400 text-sm mb-1">Implementation Tip</h4>
|
||||||
|
<p className="text-sm text-slate-300">Start with one student or one transition period. Once comfortable, expand to the full classroom. Consistency is key — use the same visual and verbal cues each time.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => { toggleFavorite(selectedStrategy.id); setSelectedStrategy(null); }}
|
||||||
|
className="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 bg-amber-500/15 text-amber-400 rounded-xl font-medium text-sm hover:bg-amber-500/25 transition-colors border border-amber-500/20"
|
||||||
|
>
|
||||||
|
<Bookmark size={16} />
|
||||||
|
{strategies.find(s => s.id === selectedStrategy.id)?.isFavorite ? 'Remove from Favorites' : 'Save to Favorites'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ClassroomSupport;
|
||||||
1023
src/components/frameworks/ClassroomTimer.tsx
Normal file
1023
src/components/frameworks/ClassroomTimer.tsx
Normal file
File diff suppressed because it is too large
Load Diff
399
src/components/frameworks/CommunityService.tsx
Normal file
399
src/components/frameworks/CommunityService.tsx
Normal file
@ -0,0 +1,399 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Globe, Search, MapPin, Users, Heart, Building2, Phone, Mail,
|
||||||
|
ExternalLink, ChevronDown, ChevronUp, CheckCircle, Star, Filter,
|
||||||
|
Handshake, TreePine, BookOpen, Utensils, Paintbrush
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
|
||||||
|
interface CommunityOrg {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
category: string;
|
||||||
|
description: string;
|
||||||
|
address: string;
|
||||||
|
phone: string;
|
||||||
|
email: string;
|
||||||
|
website: string;
|
||||||
|
distance: string;
|
||||||
|
opportunities: string[];
|
||||||
|
partnershipType: 'community-service' | 'school-partnership' | 'both';
|
||||||
|
ageGroups: string[];
|
||||||
|
rating: number;
|
||||||
|
featured: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COMMUNITY_DATA: CommunityOrg[] = [
|
||||||
|
{
|
||||||
|
id: '1', name: 'Sunshine Food Bank', category: 'Food & Nutrition',
|
||||||
|
description: 'Local food bank providing meals and groceries to families in need. Students can help sort donations, pack meal kits, and assist with distribution events.',
|
||||||
|
address: '1420 Community Blvd, Phoenix, AZ 85001', phone: '(602) 555-0142', email: 'volunteer@sunshinefoodbank.org', website: 'sunshinefoodbank.org',
|
||||||
|
distance: '2.3 mi', opportunities: ['Food sorting & packing', 'Distribution day volunteers', 'Holiday meal drive', 'Garden maintenance'],
|
||||||
|
partnershipType: 'both', ageGroups: ['K-2', '3-5', '6-8'], rating: 5, featured: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2', name: 'Desert Bloom Animal Shelter', category: 'Animal Welfare',
|
||||||
|
description: 'No-kill animal shelter that welcomes student volunteers for socialization programs. Great sensory-friendly environment for students with autism.',
|
||||||
|
address: '890 Paw Print Lane, Phoenix, AZ 85003', phone: '(602) 555-0198', email: 'education@desertbloom.org', website: 'desertbloomshelter.org',
|
||||||
|
distance: '3.1 mi', opportunities: ['Animal socialization visits', 'Pet supply drives', 'Kennel decoration projects', 'Reading to animals program'],
|
||||||
|
partnershipType: 'both', ageGroups: ['3-5', '6-8'], rating: 5, featured: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3', name: 'Valley Senior Living Center', category: 'Senior Services',
|
||||||
|
description: 'Assisted living facility offering intergenerational programs. Students visit weekly to share crafts, music, and conversation with residents.',
|
||||||
|
address: '2100 Golden Years Dr, Phoenix, AZ 85004', phone: '(602) 555-0167', email: 'activities@valleysenior.com', website: 'valleyseniorliving.com',
|
||||||
|
distance: '1.8 mi', opportunities: ['Weekly craft sessions', 'Music & performance visits', 'Holiday card making', 'Garden buddies program'],
|
||||||
|
partnershipType: 'community-service', ageGroups: ['K-2', '3-5', '6-8'], rating: 4, featured: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4', name: 'Phoenix Public Library — East Branch', category: 'Education & Literacy',
|
||||||
|
description: 'Local library branch with dedicated programs for special needs students. Offers sensory-friendly story times and adaptive technology workshops.',
|
||||||
|
address: '3500 E McDowell Rd, Phoenix, AZ 85008', phone: '(602) 555-0134', email: 'eastbranch@phoenixlib.org', website: 'phoenixpubliclibrary.org',
|
||||||
|
distance: '4.2 mi', opportunities: ['Book drive coordination', 'Reading buddy program', 'Library shelf organization', 'Story time assistants'],
|
||||||
|
partnershipType: 'school-partnership', ageGroups: ['K-2', '3-5'], rating: 4, featured: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '5', name: 'Habitat for Humanity — Phoenix Chapter', category: 'Housing & Construction',
|
||||||
|
description: 'Building homes for families in need. Age-appropriate volunteer tasks available including painting, landscaping, and supply organization.',
|
||||||
|
address: '780 Builder Way, Phoenix, AZ 85006', phone: '(602) 555-0189', email: 'volunteer@habitatphx.org', website: 'habitatphoenix.org',
|
||||||
|
distance: '5.6 mi', opportunities: ['Supply sorting', 'Painting projects', 'Landscaping days', 'Fundraiser events'],
|
||||||
|
partnershipType: 'community-service', ageGroups: ['6-8'], rating: 5, featured: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '6', name: 'Desert Botanical Garden', category: 'Environment & Nature',
|
||||||
|
description: 'Beautiful botanical garden offering educational partnerships and volunteer opportunities focused on desert ecology and conservation.',
|
||||||
|
address: '1201 N Galvin Pkwy, Phoenix, AZ 85008', phone: '(602) 555-0156', email: 'education@dbg.org', website: 'dbg.org',
|
||||||
|
distance: '6.1 mi', opportunities: ['Trail cleanup days', 'Seed planting workshops', 'Nature journaling', 'Butterfly garden maintenance'],
|
||||||
|
partnershipType: 'both', ageGroups: ['K-2', '3-5', '6-8'], rating: 5, featured: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '7', name: 'Special Olympics Arizona', category: 'Sports & Recreation',
|
||||||
|
description: 'Year-round sports training and athletic competition for individuals with intellectual disabilities. Partnership opportunities for unified sports.',
|
||||||
|
address: '2100 S 75th Ave, Phoenix, AZ 85043', phone: '(602) 555-0145', email: 'programs@soaz.org', website: 'specialolympicsarizona.org',
|
||||||
|
distance: '8.4 mi', opportunities: ['Unified sports events', 'Cheer squad support', 'Event setup volunteers', 'Athlete buddy program'],
|
||||||
|
partnershipType: 'school-partnership', ageGroups: ['3-5', '6-8'], rating: 5, featured: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '8', name: 'Community Arts Center', category: 'Arts & Culture',
|
||||||
|
description: 'Inclusive arts center offering adaptive art classes and exhibition opportunities. Students can participate in collaborative murals and gallery shows.',
|
||||||
|
address: '456 Creative Ave, Phoenix, AZ 85007', phone: '(602) 555-0178', email: 'info@communityarts.org', website: 'communityartsphx.org',
|
||||||
|
distance: '3.7 mi', opportunities: ['Collaborative mural projects', 'Art supply drives', 'Gallery exhibition setup', 'Adaptive art workshops'],
|
||||||
|
partnershipType: 'both', ageGroups: ['K-2', '3-5', '6-8'], rating: 4, featured: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '9', name: 'Ronald McDonald House', category: 'Family Support',
|
||||||
|
description: 'Provides housing and support for families with children receiving medical treatment. Students can help with meal preparation and activity kits.',
|
||||||
|
address: '501 E Thomas Rd, Phoenix, AZ 85012', phone: '(602) 555-0123', email: 'volunteer@rmhcphx.org', website: 'rmhcphoenix.org',
|
||||||
|
distance: '4.9 mi', opportunities: ['Meal preparation', 'Activity kit assembly', 'Holiday decoration', 'Card writing campaigns'],
|
||||||
|
partnershipType: 'community-service', ageGroups: ['3-5', '6-8'], rating: 5, featured: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '10', name: 'Neighborhood Cleanup Coalition', category: 'Environment & Nature',
|
||||||
|
description: 'Organizes monthly neighborhood beautification projects. Great for teaching environmental responsibility and community pride.',
|
||||||
|
address: 'Various locations, Phoenix, AZ', phone: '(602) 555-0190', email: 'join@cleanupcoalition.org', website: 'phxcleanup.org',
|
||||||
|
distance: '1.0 mi', opportunities: ['Monthly park cleanups', 'Recycling drives', 'Community garden planting', 'Mural painting'],
|
||||||
|
partnershipType: 'community-service', ageGroups: ['K-2', '3-5', '6-8'], rating: 4, featured: false
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const categoryIcons: Record<string, React.ReactNode> = {
|
||||||
|
'Food & Nutrition': <Utensils size={16} />,
|
||||||
|
'Animal Welfare': <Heart size={16} />,
|
||||||
|
'Senior Services': <Users size={16} />,
|
||||||
|
'Education & Literacy': <BookOpen size={16} />,
|
||||||
|
'Housing & Construction': <Building2 size={16} />,
|
||||||
|
'Environment & Nature': <TreePine size={16} />,
|
||||||
|
'Sports & Recreation': <Star size={16} />,
|
||||||
|
'Arts & Culture': <Paintbrush size={16} />,
|
||||||
|
'Family Support': <Handshake size={16} />,
|
||||||
|
};
|
||||||
|
|
||||||
|
const CommunityService: React.FC = () => {
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [categoryFilter, setCategoryFilter] = useState('all');
|
||||||
|
const [typeFilter, setTypeFilter] = useState<'all' | 'community-service' | 'school-partnership' | 'both'>('all');
|
||||||
|
const [ageFilter, setAgeFilter] = useState('all');
|
||||||
|
const [expandedOrg, setExpandedOrg] = useState<string | null>(null);
|
||||||
|
const [savedOrgs, setSavedOrgs] = useState<Set<string>>(new Set());
|
||||||
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
|
|
||||||
|
const categories = [...new Set(COMMUNITY_DATA.map(o => o.category))];
|
||||||
|
|
||||||
|
const filteredOrgs = COMMUNITY_DATA.filter(org => {
|
||||||
|
const matchesSearch = searchQuery === '' ||
|
||||||
|
org.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
org.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
org.category.toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
|
const matchesCategory = categoryFilter === 'all' || org.category === categoryFilter;
|
||||||
|
const matchesType = typeFilter === 'all' || org.partnershipType === typeFilter || org.partnershipType === 'both';
|
||||||
|
const matchesAge = ageFilter === 'all' || org.ageGroups.includes(ageFilter);
|
||||||
|
return matchesSearch && matchesCategory && matchesType && matchesAge;
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleSave = (id: string) => {
|
||||||
|
const next = new Set(savedOrgs);
|
||||||
|
if (next.has(id)) next.delete(id); else next.add(id);
|
||||||
|
setSavedOrgs(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const typeColors: Record<string, string> = {
|
||||||
|
'community-service': 'bg-emerald-500/15 text-emerald-400 border-emerald-500/30',
|
||||||
|
'school-partnership': 'bg-blue-500/15 text-blue-400 border-blue-500/30',
|
||||||
|
'both': 'bg-violet-500/15 text-violet-400 border-violet-500/30',
|
||||||
|
};
|
||||||
|
|
||||||
|
const typeLabels: Record<string, string> = {
|
||||||
|
'community-service': 'Community Service',
|
||||||
|
'school-partnership': 'School Partnership',
|
||||||
|
'both': 'Service & Partnership',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-white flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-green-400 to-emerald-600 flex items-center justify-center shadow-lg shadow-green-500/30">
|
||||||
|
<Globe size={20} className="text-white" />
|
||||||
|
</div>
|
||||||
|
Community Service & School Partnerships
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-slate-400 mt-1">Discover local organizations for community service projects and school partnership opportunities</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Banner */}
|
||||||
|
<div className="bg-gradient-to-r from-green-500/10 via-emerald-500/10 to-teal-500/10 rounded-2xl border border-green-500/20 p-5">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-green-400 to-emerald-500 flex items-center justify-center flex-shrink-0 shadow-lg shadow-green-500/30">
|
||||||
|
<Handshake size={22} className="text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-green-400 text-sm uppercase tracking-wider">Building Community Connections</h3>
|
||||||
|
<p className="text-slate-300 text-sm mt-1 leading-relaxed">
|
||||||
|
Explore local organizations that welcome school partnerships and community service projects. These opportunities help students develop social skills, build confidence, and contribute meaningfully to their community.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search & Filters */}
|
||||||
|
<div className="bg-slate-800/40 backdrop-blur-sm rounded-2xl border border-slate-700/40 p-5">
|
||||||
|
<div className="flex flex-col md:flex-row gap-3">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={e => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Search organizations, categories, or keywords..."
|
||||||
|
className="w-full pl-9 pr-4 py-2.5 bg-slate-700/50 border border-slate-600/50 rounded-xl text-sm text-white placeholder-slate-500 focus:ring-2 focus:ring-green-500/50 focus:border-green-500/50 outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowFilters(!showFilters)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2.5 bg-slate-700/50 border border-slate-600/50 rounded-xl text-sm text-slate-300 hover:bg-slate-700 transition-colors"
|
||||||
|
>
|
||||||
|
<Filter size={14} />
|
||||||
|
Filters
|
||||||
|
{showFilters ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showFilters && (
|
||||||
|
<div className="mt-4 pt-4 border-t border-slate-700/40 grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2 block">Category</label>
|
||||||
|
<select
|
||||||
|
value={categoryFilter}
|
||||||
|
onChange={e => setCategoryFilter(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-slate-700/50 border border-slate-600/50 rounded-xl text-sm text-white focus:ring-2 focus:ring-green-500/50 outline-none"
|
||||||
|
>
|
||||||
|
<option value="all">All Categories</option>
|
||||||
|
{categories.map(cat => <option key={cat} value={cat}>{cat}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2 block">Type</label>
|
||||||
|
<select
|
||||||
|
value={typeFilter}
|
||||||
|
onChange={e => setTypeFilter(e.target.value as any)}
|
||||||
|
className="w-full px-3 py-2 bg-slate-700/50 border border-slate-600/50 rounded-xl text-sm text-white focus:ring-2 focus:ring-green-500/50 outline-none"
|
||||||
|
>
|
||||||
|
<option value="all">All Types</option>
|
||||||
|
<option value="community-service">Community Service</option>
|
||||||
|
<option value="school-partnership">School Partnership</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2 block">Age Group</label>
|
||||||
|
<select
|
||||||
|
value={ageFilter}
|
||||||
|
onChange={e => setAgeFilter(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-slate-700/50 border border-slate-600/50 rounded-xl text-sm text-white focus:ring-2 focus:ring-green-500/50 outline-none"
|
||||||
|
>
|
||||||
|
<option value="all">All Ages</option>
|
||||||
|
<option value="K-2">K-2</option>
|
||||||
|
<option value="3-5">3-5</option>
|
||||||
|
<option value="6-8">6-8</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Bar */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
{[
|
||||||
|
{ label: 'Organizations', value: COMMUNITY_DATA.length.toString(), color: 'from-green-500 to-emerald-600', icon: <Building2 size={18} /> },
|
||||||
|
{ label: 'Service Projects', value: COMMUNITY_DATA.filter(o => o.partnershipType !== 'school-partnership').length.toString(), color: 'from-emerald-500 to-teal-600', icon: <Heart size={18} /> },
|
||||||
|
{ label: 'School Partners', value: COMMUNITY_DATA.filter(o => o.partnershipType !== 'community-service').length.toString(), color: 'from-blue-500 to-indigo-600', icon: <Handshake size={18} /> },
|
||||||
|
{ label: 'Saved', value: savedOrgs.size.toString(), color: 'from-amber-500 to-orange-600', icon: <Star size={18} /> },
|
||||||
|
].map((stat, i) => (
|
||||||
|
<div key={i} className="bg-slate-800/40 backdrop-blur-sm rounded-2xl border border-slate-700/40 p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`w-10 h-10 rounded-xl bg-gradient-to-br ${stat.color} flex items-center justify-center text-white shadow-lg`}>{stat.icon}</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold text-white">{stat.value}</p>
|
||||||
|
<p className="text-xs text-slate-500">{stat.label}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm text-slate-400">{filteredOrgs.length} organization{filteredOrgs.length !== 1 ? 's' : ''} found</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredOrgs.map(org => (
|
||||||
|
<div key={org.id} className={`bg-slate-800/40 backdrop-blur-sm rounded-2xl border transition-all duration-200 overflow-hidden ${
|
||||||
|
expandedOrg === org.id ? 'border-green-500/30' : 'border-slate-700/40 hover:border-slate-600/50'
|
||||||
|
}`}>
|
||||||
|
<div
|
||||||
|
className="p-5 cursor-pointer"
|
||||||
|
onClick={() => setExpandedOrg(expandedOrg === org.id ? null : org.id)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex items-start gap-4 min-w-0 flex-1">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-green-500/20 to-emerald-500/20 border border-green-500/20 flex items-center justify-center flex-shrink-0 text-green-400">
|
||||||
|
{categoryIcons[org.category] || <Globe size={16} />}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<h3 className="font-semibold text-white text-base">{org.name}</h3>
|
||||||
|
{org.featured && (
|
||||||
|
<span className="px-2 py-0.5 bg-amber-500/15 text-amber-400 border border-amber-500/30 rounded-lg text-[10px] font-semibold">FEATURED</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 mt-1 flex-wrap">
|
||||||
|
<span className={`px-2 py-0.5 rounded-lg text-[10px] font-semibold border ${typeColors[org.partnershipType]}`}>
|
||||||
|
{typeLabels[org.partnershipType]}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-500 flex items-center gap-1">
|
||||||
|
<MapPin size={10} /> {org.distance}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-500">{org.category}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-400 mt-2 line-clamp-2">{org.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={e => { e.stopPropagation(); toggleSave(org.id); }}
|
||||||
|
className={`p-2 rounded-xl transition-all ${savedOrgs.has(org.id) ? 'bg-amber-500/15 text-amber-400' : 'bg-slate-700/50 text-slate-500 hover:text-amber-400'}`}
|
||||||
|
>
|
||||||
|
<Star size={16} fill={savedOrgs.has(org.id) ? 'currentColor' : 'none'} />
|
||||||
|
</button>
|
||||||
|
{expandedOrg === org.id ? <ChevronUp size={18} className="text-slate-500" /> : <ChevronDown size={18} className="text-slate-500" />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expandedOrg === org.id && (
|
||||||
|
<div className="px-5 pb-5 space-y-4 border-t border-slate-700/40 pt-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{/* Contact Info */}
|
||||||
|
<div className="bg-slate-700/30 rounded-xl p-4 space-y-3">
|
||||||
|
<h4 className="font-semibold text-sm text-white flex items-center gap-2">
|
||||||
|
<Building2 size={14} className="text-green-400" /> Contact Information
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-slate-300">
|
||||||
|
<MapPin size={14} className="text-slate-500 flex-shrink-0" />
|
||||||
|
<span>{org.address}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-slate-300">
|
||||||
|
<Phone size={14} className="text-slate-500 flex-shrink-0" />
|
||||||
|
<span>{org.phone}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-slate-300">
|
||||||
|
<Mail size={14} className="text-slate-500 flex-shrink-0" />
|
||||||
|
<a href={`mailto:${org.email}`} className="text-green-400 hover:text-green-300">{org.email}</a>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-slate-300">
|
||||||
|
<ExternalLink size={14} className="text-slate-500 flex-shrink-0" />
|
||||||
|
<a href={`https://${org.website}`} target="_blank" rel="noopener noreferrer" className="text-green-400 hover:text-green-300">{org.website}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Opportunities */}
|
||||||
|
<div className="bg-slate-700/30 rounded-xl p-4">
|
||||||
|
<h4 className="font-semibold text-sm text-white flex items-center gap-2 mb-3">
|
||||||
|
<CheckCircle size={14} className="text-emerald-400" /> Available Opportunities
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{org.opportunities.map((opp, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2">
|
||||||
|
<div className="w-1.5 h-1.5 rounded-full bg-emerald-400 flex-shrink-0" />
|
||||||
|
<span className="text-sm text-slate-300">{opp}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Age Groups & Actions */}
|
||||||
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 pt-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-slate-500">Age Groups:</span>
|
||||||
|
{org.ageGroups.map(age => (
|
||||||
|
<span key={age} className="px-2 py-0.5 bg-slate-700/50 text-slate-300 rounded-lg text-xs font-medium border border-slate-600/50">{age}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button className="px-4 py-2 bg-gradient-to-r from-green-500 to-emerald-600 text-white rounded-xl text-sm font-medium hover:shadow-lg hover:shadow-green-500/25 transition-all">
|
||||||
|
Contact Organization
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleSave(org.id)}
|
||||||
|
className={`px-4 py-2 rounded-xl text-sm font-medium border transition-all ${
|
||||||
|
savedOrgs.has(org.id)
|
||||||
|
? 'bg-amber-500/15 text-amber-400 border-amber-500/30'
|
||||||
|
: 'bg-slate-700/50 text-slate-300 border-slate-600/50 hover:border-amber-500/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{savedOrgs.has(org.id) ? 'Saved' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{filteredOrgs.length === 0 && (
|
||||||
|
<div className="bg-slate-800/40 backdrop-blur-sm rounded-2xl border border-slate-700/40 p-12 text-center">
|
||||||
|
<Globe size={40} className="text-slate-600 mx-auto mb-3" />
|
||||||
|
<h3 className="font-semibold text-white mb-1">No organizations found</h3>
|
||||||
|
<p className="text-sm text-slate-400">Try adjusting your search or filters to find more opportunities.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CommunityService;
|
||||||
235
src/components/frameworks/Dashboard.tsx
Normal file
235
src/components/frameworks/Dashboard.tsx
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { UserRole, ModuleId, FrameEntry } from '@/lib/types';
|
||||||
|
import { FRAME_ENTRIES, EVENTS, HERO_IMAGE, ENCOURAGING_QUOTES } from '@/lib/appData';
|
||||||
|
import { fetchFrameEntries, saveProgress } from '@/lib/db';
|
||||||
|
import {
|
||||||
|
Calendar, CheckCircle, Clock, AlertTriangle, BookOpen,
|
||||||
|
Shield, Heart, TrendingUp, ArrowRight, Sparkles, HandMetal, Database,
|
||||||
|
Quote, Sun, FileText, Timer
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
|
||||||
|
interface DashboardProps {
|
||||||
|
userRole: UserRole;
|
||||||
|
userName: string;
|
||||||
|
setCurrentModule: (id: ModuleId) => void;
|
||||||
|
zoneCheckIn: string | null;
|
||||||
|
setZoneCheckIn: (z: string | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Dashboard: React.FC<DashboardProps> = ({ userRole, userName, setCurrentModule, zoneCheckIn, setZoneCheckIn }) => {
|
||||||
|
const [latestFrame, setLatestFrame] = useState<FrameEntry>(FRAME_ENTRIES[0]);
|
||||||
|
const [dbLoaded, setDbLoaded] = useState(false);
|
||||||
|
const upcomingEvents = EVENTS.filter(e => e.roles.includes(userRole)).slice(0, 4);
|
||||||
|
|
||||||
|
// Get today's encouraging quote
|
||||||
|
const dayOfYear = Math.floor((Date.now() - new Date(new Date().getFullYear(), 0, 0).getTime()) / 86400000);
|
||||||
|
const todayQuote = ENCOURAGING_QUOTES[dayOfYear % ENCOURAGING_QUOTES.length];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadLatestFrame();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadLatestFrame = async () => {
|
||||||
|
try {
|
||||||
|
const entries = await fetchFrameEntries();
|
||||||
|
if (entries.length > 0) {
|
||||||
|
setLatestFrame(entries[0]);
|
||||||
|
setDbLoaded(true);
|
||||||
|
}
|
||||||
|
} catch { /* fallback to mock */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleZoneCheckIn = async (zone: string) => {
|
||||||
|
setZoneCheckIn(zone);
|
||||||
|
await saveProgress({ userName, userRole, progressType: 'zone_checkin', value: zone });
|
||||||
|
};
|
||||||
|
|
||||||
|
const getGreeting = () => {
|
||||||
|
const hour = new Date().getHours();
|
||||||
|
if (hour < 12) return 'Good Morning';
|
||||||
|
if (hour < 17) return 'Good Afternoon';
|
||||||
|
return 'Good Evening';
|
||||||
|
};
|
||||||
|
|
||||||
|
const zones = [
|
||||||
|
{ color: 'blue', label: 'Blue', desc: 'Low energy', bg: 'bg-blue-500/15 hover:bg-blue-500/25', border: 'border-blue-500/30', text: 'text-blue-400', ring: 'ring-blue-500/40' },
|
||||||
|
{ color: 'green', label: 'Green', desc: 'Ready to go', bg: 'bg-emerald-500/15 hover:bg-emerald-500/25', border: 'border-emerald-500/30', text: 'text-emerald-400', ring: 'ring-emerald-500/40' },
|
||||||
|
{ color: 'yellow', label: 'Yellow', desc: 'Heightened', bg: 'bg-yellow-500/15 hover:bg-yellow-500/25', border: 'border-yellow-500/30', text: 'text-yellow-400', ring: 'ring-yellow-500/40' },
|
||||||
|
{ color: 'red', label: 'Red', desc: 'Intense', bg: 'bg-red-500/15 hover:bg-red-500/25', border: 'border-red-500/30', text: 'text-red-400', ring: 'ring-red-500/40' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const eventTypeColors: Record<string, string> = {
|
||||||
|
meeting: 'bg-violet-500/15 text-violet-400 border-violet-500/20',
|
||||||
|
drill: 'bg-red-500/15 text-red-400 border-red-500/20',
|
||||||
|
event: 'bg-amber-500/15 text-amber-400 border-amber-500/20',
|
||||||
|
deadline: 'bg-blue-500/15 text-blue-400 border-blue-500/20'
|
||||||
|
};
|
||||||
|
|
||||||
|
const quickActions = [
|
||||||
|
{ label: 'Classroom Tips', icon: <BookOpen size={18} />, module: 'classroom' as ModuleId, color: 'from-emerald-500 to-emerald-600', shadow: 'shadow-emerald-500/30' },
|
||||||
|
{ label: 'Class Timer', icon: <Timer size={18} />, module: 'timer' as ModuleId, color: 'from-cyan-500 to-blue-600', shadow: 'shadow-cyan-500/30' },
|
||||||
|
{ label: 'De-escalation', icon: <Shield size={18} />, module: 'qbs' as ModuleId, color: 'from-blue-500 to-blue-600', shadow: 'shadow-blue-500/30' },
|
||||||
|
|
||||||
|
{ label: 'Zones', icon: <Heart size={18} />, module: 'zones' as ModuleId, color: 'from-teal-500 to-teal-600', shadow: 'shadow-teal-500/30' },
|
||||||
|
{ label: 'Handbook', icon: <FileText size={18} />, module: 'handbook' as ModuleId, color: 'from-slate-500 to-slate-600', shadow: 'shadow-slate-500/30' },
|
||||||
|
{ label: 'Safety Protocols', icon: <AlertTriangle size={18} />, module: 'safety' as ModuleId, color: 'from-red-500 to-red-600', shadow: 'shadow-red-500/30' },
|
||||||
|
];
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Hero Banner */}
|
||||||
|
<div className="relative overflow-hidden rounded-2xl h-52 md:h-60">
|
||||||
|
<img src={HERO_IMAGE} alt="Classroom" className="w-full h-full object-cover" />
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-slate-900/90 via-violet-900/70 to-amber-900/50" />
|
||||||
|
<div className="absolute inset-0 flex items-center px-6 md:px-8">
|
||||||
|
<div className="relative z-10">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Sparkles size={18} className="text-amber-400" />
|
||||||
|
<span className="text-amber-400 text-sm font-medium">Week of {latestFrame.weekOf}</span>
|
||||||
|
{dbLoaded && <span className="inline-flex items-center gap-1 px-2 py-0.5 bg-white/10 backdrop-blur-sm rounded-lg text-[10px] font-semibold text-emerald-400 border border-emerald-500/20"><Database size={10} /> Live Data</span>}
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl md:text-4xl font-bold text-white mb-1">{getGreeting()}, {userName}!</h1>
|
||||||
|
<p className="text-slate-300 text-sm md:text-base max-w-xl">Welcome to FRAMEworks — your campus operations hub. Here's what's happening today.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Encouraging Word of the Day */}
|
||||||
|
<div className="relative overflow-hidden bg-gradient-to-r from-violet-600/10 via-amber-500/10 to-emerald-500/10 rounded-2xl border border-violet-500/20 p-5 md:p-6">
|
||||||
|
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-bl from-amber-400/10 to-transparent rounded-bl-full" />
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-amber-400 to-orange-500 flex items-center justify-center flex-shrink-0 shadow-lg shadow-amber-500/30">
|
||||||
|
<Sun size={22} className="text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h3 className="font-bold text-amber-400 text-sm uppercase tracking-wider flex items-center gap-2">
|
||||||
|
<Quote size={14} /> Encouraging Word of the Day
|
||||||
|
</h3>
|
||||||
|
<p className="text-white text-base md:text-lg font-medium mt-2 leading-relaxed italic">"{todayQuote.quote}"</p>
|
||||||
|
<p className="text-slate-400 text-sm mt-2">— {todayQuote.author}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Zone Check-In */}
|
||||||
|
<div className="bg-slate-800/40 backdrop-blur-sm rounded-2xl border border-slate-700/40 p-5">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-white">Today's Zone Check-In</h3>
|
||||||
|
<p className="text-xs text-slate-400">How are you feeling right now? (Saved to your profile)</p>
|
||||||
|
</div>
|
||||||
|
{zoneCheckIn && <button onClick={() => setZoneCheckIn(null)} className="text-xs text-violet-400 hover:text-violet-300">Reset</button>}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
{zones.map(z => (
|
||||||
|
<button key={z.color} onClick={() => handleZoneCheckIn(z.color)}
|
||||||
|
className={`p-3 rounded-xl border-2 transition-all duration-200 ${z.bg} ${zoneCheckIn === z.color ? `${z.border} ring-2 ${z.ring} scale-105` : 'border-transparent'}`}>
|
||||||
|
<div className={`font-bold text-sm ${z.text}`}>{z.label}</div>
|
||||||
|
<div className="text-[10px] text-slate-500">{z.desc}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content Grid */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* F.R.A.M.E. Card */}
|
||||||
|
<div className="lg:col-span-2 bg-slate-800/40 backdrop-blur-sm rounded-2xl border border-slate-700/40 overflow-hidden">
|
||||||
|
<div className="bg-gradient-to-r from-amber-500/10 to-violet-500/10 px-5 py-4 border-b border-slate-700/40 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-white flex items-center gap-2">
|
||||||
|
<span className="w-7 h-7 rounded-lg bg-gradient-to-br from-amber-400 to-violet-500 flex items-center justify-center text-white text-xs font-bold shadow-lg shadow-amber-500/20">F</span>
|
||||||
|
This Week's F.R.A.M.E.
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-slate-500 mt-0.5">Posted {latestFrame.postedDate} by {latestFrame.author}</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setCurrentModule('frame')} className="text-sm text-violet-400 hover:text-violet-300 font-medium flex items-center gap-1">View All <ArrowRight size={14} /></button>
|
||||||
|
</div>
|
||||||
|
<div className="p-5 space-y-3">
|
||||||
|
{[
|
||||||
|
{ letter: 'F', label: 'Formal Observation', text: latestFrame.formal, color: 'from-violet-500 to-violet-600' },
|
||||||
|
{ letter: 'R', label: 'Recognition', text: latestFrame.recognition, color: 'from-amber-500 to-amber-600' },
|
||||||
|
{ letter: 'A', label: 'Application', text: latestFrame.application, color: 'from-emerald-500 to-emerald-600' },
|
||||||
|
{ letter: 'M', label: 'Management', text: latestFrame.management, color: 'from-blue-500 to-blue-600' },
|
||||||
|
{ letter: 'E', label: 'Emotional Intelligence', text: latestFrame.emotional, color: 'from-pink-500 to-pink-600' },
|
||||||
|
].map(item => (
|
||||||
|
<div key={item.letter} className="flex gap-3">
|
||||||
|
<div className={`w-8 h-8 rounded-lg bg-gradient-to-br ${item.color} flex items-center justify-center text-white font-bold text-sm flex-shrink-0 mt-0.5 shadow-lg`}>{item.letter}</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wider">{item.label}</p>
|
||||||
|
<p className="text-sm text-slate-300 leading-relaxed">{item.text}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Upcoming Events */}
|
||||||
|
<div className="bg-slate-800/40 backdrop-blur-sm rounded-2xl border border-slate-700/40 p-5">
|
||||||
|
<h3 className="font-semibold text-white mb-3 flex items-center gap-2"><Calendar size={18} className="text-violet-400" /> Upcoming Events</h3>
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
{upcomingEvents.map(event => (
|
||||||
|
<div key={event.id} className="flex items-center gap-3 p-2.5 rounded-xl bg-slate-700/30 hover:bg-slate-700/50 transition-colors border border-slate-700/30">
|
||||||
|
<span className={`text-[10px] font-semibold px-2 py-1 rounded-lg border ${eventTypeColors[event.type]}`}>{event.type.toUpperCase()}</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium text-slate-200 truncate">{event.title}</p>
|
||||||
|
<p className="text-[10px] text-slate-500">{new Date(event.date).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Weekly Progress */}
|
||||||
|
<div className="bg-slate-800/40 backdrop-blur-sm rounded-2xl border border-slate-700/40 p-5">
|
||||||
|
<h3 className="font-semibold text-white mb-3 flex items-center gap-2"><TrendingUp size={18} className="text-emerald-400" /> Weekly Progress</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[{ label: 'De-escalation Quiz', status: 'Pending', color: 'text-blue-400', bar: 'bg-blue-500', w: 'w-0' },
|
||||||
|
|
||||||
|
{ label: 'EI Assessment', status: 'In Progress', color: 'text-pink-400', bar: 'bg-pink-500', w: 'w-1/2' },
|
||||||
|
{ label: 'Safety Acknowledgment', status: 'Complete', color: 'text-emerald-400', bar: 'bg-emerald-500', w: 'w-full' }
|
||||||
|
].map((item, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
<div className="flex justify-between text-xs mb-1"><span className="text-slate-400">{item.label}</span><span className={`font-semibold ${item.color}`}>{item.status}</span></div>
|
||||||
|
<div className="h-2 bg-slate-700/50 rounded-full overflow-hidden"><div className={`h-full ${item.bar} rounded-full ${item.w} transition-all`}></div></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sign of the Week */}
|
||||||
|
<div className="bg-gradient-to-br from-indigo-500/10 to-violet-500/10 rounded-2xl border border-indigo-500/20 p-5">
|
||||||
|
<h3 className="font-semibold text-white mb-2 flex items-center gap-2"><HandMetal size={18} className="text-indigo-400" /> Sign of the Week</h3>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-20 h-20 mx-auto rounded-2xl overflow-hidden mb-2 border-2 border-indigo-500/30 shadow-lg shadow-indigo-500/10">
|
||||||
|
<img src="https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770656618075_ea1c15a9.png" alt="Help sign" className="w-full h-full object-cover" />
|
||||||
|
</div>
|
||||||
|
<p className="font-bold text-indigo-400 text-lg">"Help"</p>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">Flat hand on top of fist, lift both up together</p>
|
||||||
|
<button onClick={() => setCurrentModule('signs')} className="mt-2 text-xs text-indigo-400 hover:text-indigo-300 font-medium">Learn more signs →</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Actions */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-6 gap-4">
|
||||||
|
|
||||||
|
{quickActions.filter(a => !(a.module === 'classroom' && userRole === 'office')).map(action => (
|
||||||
|
<button key={action.module} onClick={() => setCurrentModule(action.module)}
|
||||||
|
className="bg-slate-800/40 backdrop-blur-sm rounded-2xl border border-slate-700/40 p-4 hover:border-slate-600/50 transition-all duration-200 hover:-translate-y-0.5 text-left group">
|
||||||
|
<div className={`w-10 h-10 rounded-xl bg-gradient-to-br ${action.color} flex items-center justify-center text-white mb-3 group-hover:scale-110 transition-transform shadow-lg ${action.shadow}`}>{action.icon}</div>
|
||||||
|
<p className="font-semibold text-sm text-white">{action.label}</p>
|
||||||
|
<p className="text-xs text-slate-500 mt-0.5">Quick access</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Dashboard;
|
||||||
197
src/components/frameworks/DirectorDashboard.tsx
Normal file
197
src/components/frameworks/DirectorDashboard.tsx
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { ModuleId } from '@/lib/types';
|
||||||
|
import { FRAME_ENTRIES } from '@/lib/appData';
|
||||||
|
import { fetchFrameEntries, fetchQuizResults, fetchAttendance, fetchStaffUsers } from '@/lib/db';
|
||||||
|
import {
|
||||||
|
BarChart3, Users, Shield, Heart, Clock, TrendingUp,
|
||||||
|
TrendingDown, AlertTriangle, CheckCircle, Calendar,
|
||||||
|
BookOpen, Eye, ArrowRight, Loader2, Database, ClipboardCheck
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
|
||||||
|
interface DirectorDashboardProps {
|
||||||
|
setCurrentModule: (id: ModuleId) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DirectorDashboard: React.FC<DirectorDashboardProps> = ({ setCurrentModule }) => {
|
||||||
|
const [timeRange, setTimeRange] = useState<'week' | 'month' | 'quarter'>('month');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [frameEntries, setFrameEntries] = useState(FRAME_ENTRIES);
|
||||||
|
const [quizResults, setQuizResults] = useState<any[]>([]);
|
||||||
|
const [attendanceRecords, setAttendanceRecords] = useState<any[]>([]);
|
||||||
|
const [staffCount, setStaffCount] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => { loadAllData(); }, []);
|
||||||
|
|
||||||
|
const loadAllData = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [frames, quizzes, attendance, staff] = await Promise.all([
|
||||||
|
fetchFrameEntries(), fetchQuizResults(), fetchAttendance(), fetchStaffUsers()
|
||||||
|
]);
|
||||||
|
if (frames.length > 0) setFrameEntries(frames);
|
||||||
|
setQuizResults(quizzes);
|
||||||
|
setAttendanceRecords(attendance);
|
||||||
|
setStaffCount(staff.length || 10);
|
||||||
|
} catch { /* fallback to defaults */ }
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const presentCount = attendanceRecords.filter(r => r.status === 'present').length;
|
||||||
|
const totalAttendance = attendanceRecords.length || 1;
|
||||||
|
const attendanceRate = Math.round((presentCount / totalAttendance) * 100);
|
||||||
|
const quizCompletionRate = staffCount > 0 ? Math.round((quizResults.length / staffCount) * 100) : 0;
|
||||||
|
|
||||||
|
const overviewCards = [
|
||||||
|
{ label: 'Staff Attendance', value: `${attendanceRate}%`, change: `${attendanceRecords.length} records`, trend: 'up', icon: <Clock size={20} />, color: 'from-orange-400 to-orange-600', module: 'attendance' as ModuleId },
|
||||||
|
{ label: 'De-escalation Completion', value: `${quizResults.length}/${staffCount}`, change: `${quizCompletionRate}%`, trend: quizCompletionRate > 50 ? 'up' : 'down', icon: <Shield size={20} />, color: 'from-blue-400 to-blue-600', module: 'qbs' as ModuleId },
|
||||||
|
|
||||||
|
{ label: 'F.R.A.M.E. Entries', value: frameEntries.length.toString(), change: 'Total entries', trend: 'up', icon: <Eye size={20} />, color: 'from-amber-400 to-amber-600', module: 'frame' as ModuleId },
|
||||||
|
{ label: 'Staff Members', value: staffCount.toString(), change: 'Active', trend: 'up', icon: <Users size={20} />, color: 'from-purple-400 to-purple-600', module: 'attendance' as ModuleId },
|
||||||
|
];
|
||||||
|
|
||||||
|
const riskAreas = [
|
||||||
|
{ issue: `${staffCount - quizResults.length} staff haven't completed de-escalation quiz`, severity: staffCount - quizResults.length > 3 ? 'high' : 'medium', module: 'qbs' as ModuleId },
|
||||||
|
|
||||||
|
{ issue: 'Fire drill response time needs improvement (Bldg B)', severity: 'medium', module: 'safety' as ModuleId },
|
||||||
|
{ issue: `${attendanceRecords.filter(r => r.status === 'absent').length} absences recorded this period`, severity: attendanceRecords.filter(r => r.status === 'absent').length > 3 ? 'high' : 'low', module: 'attendance' as ModuleId },
|
||||||
|
];
|
||||||
|
|
||||||
|
const severityColors: Record<string, string> = { high: 'bg-red-100 text-red-700 border-red-200', medium: 'bg-amber-100 text-amber-700 border-amber-200', low: 'bg-blue-100 text-blue-700 border-blue-200' };
|
||||||
|
|
||||||
|
const weeklyFrameThemes = frameEntries.slice(0, 3).map(entry => ({
|
||||||
|
week: entry.weekOf,
|
||||||
|
fTheme: entry.formal.slice(0, 60) + '...', rTheme: entry.recognition.slice(0, 60) + '...',
|
||||||
|
aTheme: entry.application.slice(0, 60) + '...', mTheme: entry.management.slice(0, 60) + '...',
|
||||||
|
eTheme: entry.emotional.slice(0, 60) + '...',
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<div className="text-center"><Loader2 size={32} className="animate-spin text-purple-500 mx-auto mb-3" /><p className="text-sm text-gray-500">Loading director dashboard...</p></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-800 flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-purple-500 to-purple-700 flex items-center justify-center"><BarChart3 size={20} className="text-white" /></div>
|
||||||
|
Director Dashboard
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-500 mt-1 flex items-center gap-2">
|
||||||
|
Campus oversight, compliance tracking, and risk management
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-emerald-100 text-emerald-700 rounded-lg text-[10px] font-semibold"><Database size={10} /> Live Database</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 bg-white rounded-xl border border-violet-100 p-1">
|
||||||
|
{(['week', 'month', 'quarter'] as const).map(range => (
|
||||||
|
<button key={range} onClick={() => setTimeRange(range)} className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${timeRange === range ? 'bg-purple-500 text-white shadow-sm' : 'text-gray-500 hover:text-gray-700'}`}>
|
||||||
|
{range.charAt(0).toUpperCase() + range.slice(1)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{overviewCards.map((card, i) => (
|
||||||
|
<button key={i} onClick={() => setCurrentModule(card.module)} className="bg-white rounded-2xl border border-violet-100 shadow-sm p-5 text-left hover:shadow-md transition-all hover:-translate-y-0.5 group">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className={`w-10 h-10 rounded-xl bg-gradient-to-br ${card.color} flex items-center justify-center text-white`}>{card.icon}</div>
|
||||||
|
<div className={`flex items-center gap-1 text-xs font-semibold ${card.trend === 'up' ? 'text-emerald-600' : 'text-red-600'}`}>
|
||||||
|
{card.trend === 'up' ? <TrendingUp size={12} /> : <TrendingDown size={12} />} {card.change}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-gray-800">{card.value}</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5">{card.label}</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
<div className="bg-white rounded-2xl border border-violet-100 shadow-sm p-5">
|
||||||
|
<h3 className="font-semibold text-gray-800 mb-4 flex items-center gap-2"><AlertTriangle size={18} className="text-amber-500" /> Risk Areas</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{riskAreas.map((risk, i) => (
|
||||||
|
<button key={i} onClick={() => setCurrentModule(risk.module)} className={`w-full text-left p-4 rounded-xl border ${severityColors[risk.severity]} flex items-center justify-between hover:shadow-sm transition-all`}>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className={`px-2 py-1 rounded-lg text-[10px] font-bold uppercase ${severityColors[risk.severity]}`}>{risk.severity}</span>
|
||||||
|
<p className="text-sm font-medium text-gray-700">{risk.issue}</p>
|
||||||
|
</div>
|
||||||
|
<ArrowRight size={14} className="text-gray-400" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-2xl border border-violet-100 shadow-sm p-5">
|
||||||
|
<h3 className="font-semibold text-gray-800 mb-4 flex items-center gap-2"><Users size={18} className="text-violet-500" /> Quiz Results from Database</h3>
|
||||||
|
{quizResults.length > 0 ? (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead><tr className="bg-gray-50"><th className="text-left p-3 font-medium text-gray-500">Staff</th><th className="text-center p-3 font-medium text-gray-500">Role</th><th className="text-center p-3 font-medium text-gray-500">Score</th><th className="text-center p-3 font-medium text-gray-500">Date</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{quizResults.map((r: any, i: number) => (
|
||||||
|
<tr key={i} className="border-t border-gray-50">
|
||||||
|
<td className="p-3 font-medium text-gray-700">{r.user_name}</td>
|
||||||
|
<td className="p-3 text-center text-xs text-gray-500 capitalize">{r.user_role}</td>
|
||||||
|
<td className="p-3 text-center"><span className={`px-2 py-0.5 rounded-lg text-xs font-semibold ${r.score === r.total_questions ? 'bg-emerald-100 text-emerald-700' : 'bg-amber-100 text-amber-700'}`}>{r.score}/{r.total_questions}</span></td>
|
||||||
|
<td className="p-3 text-center text-xs text-gray-400">{new Date(r.completed_at).toLocaleDateString()}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-gray-400 text-center py-4">No quiz results yet. Staff will appear here after completing quizzes.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="bg-white rounded-2xl border border-amber-100 shadow-sm p-5">
|
||||||
|
<h3 className="font-semibold text-gray-800 mb-3 flex items-center gap-2"><Eye size={16} className="text-amber-500" /> F.R.A.M.E. Tracker</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{weeklyFrameThemes.map((week, i) => (
|
||||||
|
<div key={i} className={`p-3 rounded-xl ${i === 0 ? 'bg-amber-50 border border-amber-200' : 'bg-gray-50'}`}>
|
||||||
|
<p className="text-xs font-semibold text-gray-700 mb-1.5">{week.week}</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{[{ letter: 'F', text: week.fTheme, color: 'text-violet-600' }, { letter: 'R', text: week.rTheme, color: 'text-amber-600' }, { letter: 'A', text: week.aTheme, color: 'text-emerald-600' }, { letter: 'M', text: week.mTheme, color: 'text-blue-600' }, { letter: 'E', text: week.eTheme, color: 'text-pink-600' }].map(item => (
|
||||||
|
<div key={item.letter} className="flex items-start gap-1.5"><span className={`font-bold text-[10px] ${item.color} w-3`}>{item.letter}</span><span className="text-[10px] text-gray-500 line-clamp-1">{item.text}</span></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setCurrentModule('frame')} className="w-full mt-3 text-sm text-amber-600 hover:text-amber-800 font-medium flex items-center justify-center gap-1">View All Entries <ArrowRight size={14} /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-2xl border border-violet-100 shadow-sm p-5">
|
||||||
|
<h3 className="font-semibold text-gray-800 mb-3">Quick Actions</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[
|
||||||
|
{ label: 'Walk-Through Check-In', module: 'walkthrough' as ModuleId, color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200', icon: <ClipboardCheck size={14} /> },
|
||||||
|
{ label: 'Create F.R.A.M.E. Entry', module: 'frame' as ModuleId, color: 'bg-amber-100 text-amber-700 hover:bg-amber-200', icon: null },
|
||||||
|
{ label: 'View QBS Compliance', module: 'qbs' as ModuleId, color: 'bg-blue-100 text-blue-700 hover:bg-blue-200', icon: null },
|
||||||
|
{ label: 'Check Attendance', module: 'attendance' as ModuleId, color: 'bg-orange-100 text-orange-700 hover:bg-orange-200', icon: null },
|
||||||
|
{ label: 'Schedule Alert', module: 'internal-comm' as ModuleId, color: 'bg-rose-100 text-rose-700 hover:bg-rose-200', icon: null },
|
||||||
|
{ label: 'Review Safety', module: 'safety' as ModuleId, color: 'bg-red-100 text-red-700 hover:bg-red-200', icon: null },
|
||||||
|
].map((action, i) => (
|
||||||
|
<button key={i} onClick={() => setCurrentModule(action.module)} className={`w-full text-left px-4 py-2.5 rounded-xl text-sm font-medium transition-all flex items-center gap-2 ${action.color}`}>
|
||||||
|
{action.icon}
|
||||||
|
{action.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DirectorDashboard;
|
||||||
370
src/components/frameworks/ESAFunding.tsx
Normal file
370
src/components/frameworks/ESAFunding.tsx
Normal file
@ -0,0 +1,370 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Wallet, DollarSign, GraduationCap, BookOpen, Heart, Users,
|
||||||
|
ChevronDown, ChevronUp, CheckCircle2, Info, ArrowRight,
|
||||||
|
HelpCircle, FileText, ExternalLink, Lightbulb, Shield,
|
||||||
|
School, Puzzle, Star
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface FAQItem {
|
||||||
|
question: string;
|
||||||
|
answer: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const faqs: FAQItem[] = [
|
||||||
|
{
|
||||||
|
question: 'What is an ESA (Empowerment Scholarship Account)?',
|
||||||
|
answer: 'An ESA is a state-funded account that provides education dollars directly to families. Instead of funding going only to a single public school, ESA money follows the child — allowing parents to choose the learning environment and services that best fit their child\'s unique needs.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: 'Who is eligible for ESA funding?',
|
||||||
|
answer: 'Eligibility varies by state, but ESA programs typically prioritize students with disabilities (including autism), students from low-income families, students in underperforming schools, foster children, and children of active-duty military. In many states, all K-12 students are now eligible.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: 'What can ESA funds be used for?',
|
||||||
|
answer: 'ESA funds can be used for a wide range of approved educational expenses including: private school tuition, specialized therapies (speech, occupational, behavioral), tutoring services, curriculum and textbooks, educational technology, online learning programs, and other approved educational services.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: 'How does ESA funding affect our school?',
|
||||||
|
answer: 'ESA funding is a positive opportunity for our school community. Families who choose our programs can use ESA funds to pay for tuition and specialized services we offer. This means more families can access the autism-focused education and therapies we provide, regardless of their financial situation.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: 'How much funding does each student receive?',
|
||||||
|
answer: 'The amount varies by state and student need. Typically, ESA amounts range from $5,000 to $7,000+ per year for general education students, with higher amounts (up to $25,000+) for students with disabilities who require specialized services and therapies.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: 'How do families apply for ESA funding?',
|
||||||
|
answer: 'Families apply through their state\'s ESA program (often through the Department of Education website). The process typically involves: submitting an application, providing proof of eligibility, receiving approval, and then directing funds to their chosen educational providers. Our office staff can help guide families through this process.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: 'Can ESA funds be used for therapies at our school?',
|
||||||
|
answer: 'Yes! ESA funds can cover many of the specialized services we offer, including speech therapy, occupational therapy, behavioral therapy (ABA), social skills groups, and other therapeutic interventions. This makes our comprehensive support services accessible to more families.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: 'What is our role as staff in the ESA process?',
|
||||||
|
answer: 'As staff, your role is to: (1) Be informed about ESA so you can answer basic parent questions, (2) Direct families to the office for detailed ESA guidance, (3) Document student services accurately for ESA reporting, and (4) Continue providing excellent, individualized education regardless of how it\'s funded.'
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const approvedUses = [
|
||||||
|
{ icon: <School size={20} />, title: 'Private School Tuition', description: 'Full or partial tuition at approved private schools, including autism-focused programs', color: 'from-violet-500 to-violet-600' },
|
||||||
|
{ icon: <Heart size={20} />, title: 'Specialized Therapies', description: 'Speech therapy, occupational therapy, ABA, physical therapy, and counseling', color: 'from-pink-500 to-rose-600' },
|
||||||
|
{ icon: <BookOpen size={20} />, title: 'Tutoring Services', description: 'One-on-one or small group tutoring from approved educational providers', color: 'from-blue-500 to-blue-600' },
|
||||||
|
{ icon: <Puzzle size={20} />, title: 'Curriculum & Materials', description: 'Textbooks, workbooks, educational software, and approved learning materials', color: 'from-amber-500 to-orange-600' },
|
||||||
|
{ icon: <GraduationCap size={20} />, title: 'Online Learning', description: 'Approved online courses, virtual tutoring, and digital learning platforms', color: 'from-emerald-500 to-green-600' },
|
||||||
|
{ icon: <Users size={20} />, title: 'Educational Services', description: 'Social skills groups, life skills training, vocational preparation, and more', color: 'from-cyan-500 to-teal-600' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const keyPoints = [
|
||||||
|
{ label: 'Education money that follows the child', icon: <ArrowRight size={16} /> },
|
||||||
|
{ label: 'Instead of staying with one school', icon: <School size={16} /> },
|
||||||
|
{ label: 'Parents choose the best fit for their child', icon: <Users size={16} /> },
|
||||||
|
{ label: 'Covers tuition, therapies, tutoring & more', icon: <CheckCircle2 size={16} /> },
|
||||||
|
{ label: 'More flexibility and control for families', icon: <Star size={16} /> },
|
||||||
|
{ label: 'Especially impactful for students with disabilities', icon: <Heart size={16} /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ESAFunding: React.FC = () => {
|
||||||
|
const [expandedFAQ, setExpandedFAQ] = useState<number | null>(0);
|
||||||
|
const [acknowledged, setAcknowledged] = useState(false);
|
||||||
|
|
||||||
|
const toggleFAQ = (index: number) => {
|
||||||
|
setExpandedFAQ(expandedFAQ === index ? null : index);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3 mb-1">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-emerald-500 to-emerald-600 flex items-center justify-center shadow-lg shadow-emerald-500/30">
|
||||||
|
<Wallet size={22} className="text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">ESA Funding Information</h1>
|
||||||
|
<p className="text-sm text-slate-400">For All Staff — Understanding Empowerment Scholarship Accounts</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative overflow-hidden rounded-2xl bg-gradient-to-br from-emerald-600 via-emerald-500 to-teal-500 p-6 md:p-8 shadow-xl shadow-emerald-500/20">
|
||||||
|
<div className="absolute top-0 right-0 w-64 h-64 bg-white/5 rounded-full -translate-y-1/2 translate-x-1/2" />
|
||||||
|
<div className="absolute bottom-0 left-0 w-48 h-48 bg-white/5 rounded-full translate-y-1/2 -translate-x-1/2" />
|
||||||
|
<div className="relative z-10">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<DollarSign size={24} className="text-emerald-100" />
|
||||||
|
<span className="text-emerald-100 font-semibold text-sm uppercase tracking-wider">What is ESA?</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl md:text-2xl font-bold text-white mb-4 leading-relaxed max-w-3xl">
|
||||||
|
ESA funding is money set aside by the state for a child's education that parents can use to choose the learning environment and services that best fit their child's needs.
|
||||||
|
</h2>
|
||||||
|
<div className="bg-white/15 backdrop-blur-sm rounded-xl p-4 border border-white/20 max-w-2xl">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Lightbulb size={22} className="text-yellow-200 flex-shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-white font-semibold text-base mb-1">Put Simply:</p>
|
||||||
|
<p className="text-emerald-50 text-base leading-relaxed">
|
||||||
|
It's <span className="font-bold text-white">education money that follows the child</span> instead of staying with one school.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-emerald-100 mt-4 text-sm md:text-base leading-relaxed max-w-3xl">
|
||||||
|
It allows families to use funds for tuition, therapies, tutoring, curriculum, or other approved educational services — giving them <span className="font-semibold text-white">more flexibility and control</span> over how their child learns.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* IMPORTANT: ESA Varies by State */}
|
||||||
|
<div className="relative overflow-hidden rounded-2xl bg-gradient-to-br from-amber-500/15 to-orange-500/10 border-2 border-amber-500/30 p-6">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-amber-500 to-orange-500 flex items-center justify-center flex-shrink-0 shadow-lg shadow-amber-500/20">
|
||||||
|
<Info size={24} className="text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold text-white mb-2 flex items-center gap-2">
|
||||||
|
Important: ESA Varies by State
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-slate-300 leading-relaxed mb-3">
|
||||||
|
ESA (Empowerment Scholarship Account) programs <span className="font-semibold text-amber-400">vary significantly from state to state</span>. Not all states offer ESA programs, and those that do may have different eligibility requirements, funding amounts, and approved uses for the funds.
|
||||||
|
</p>
|
||||||
|
<div className="bg-slate-800/60 rounded-xl p-4 border border-slate-700/40 space-y-2">
|
||||||
|
<p className="text-sm text-slate-200 font-semibold">Before advising families, please check:</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{[
|
||||||
|
'Whether your state offers an ESA or similar school choice program',
|
||||||
|
'What the specific eligibility requirements are in your state',
|
||||||
|
'What expenses and services ESA funds can be applied toward in your state',
|
||||||
|
'The application process and deadlines for your state\'s program',
|
||||||
|
'Any reporting or documentation requirements unique to your state',
|
||||||
|
].map((item, i) => (
|
||||||
|
<div key={i} className="flex items-start gap-2">
|
||||||
|
<CheckCircle2 size={14} className="text-amber-400 flex-shrink-0 mt-0.5" />
|
||||||
|
<span className="text-xs text-slate-400 leading-relaxed">{item}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500 mt-3 italic">
|
||||||
|
Contact your state's Department of Education or visit their official website for the most up-to-date information on ESA funding availability and guidelines in your area.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
{/* Key Points Grid */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
|
{keyPoints.map((point, i) => (
|
||||||
|
<div key={i} className="bg-slate-800/60 backdrop-blur-sm rounded-xl p-4 border border-slate-700/50 flex items-center gap-3 hover:border-emerald-500/30 transition-all">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-emerald-500/15 flex items-center justify-center text-emerald-400 flex-shrink-0">
|
||||||
|
{point.icon}
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-slate-200">{point.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* What Can ESA Funds Be Used For */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<CheckCircle2 size={20} className="text-emerald-400" />
|
||||||
|
<h2 className="text-lg font-bold text-white">What Can ESA Funds Be Used For?</h2>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{approvedUses.map((use, i) => (
|
||||||
|
<div key={i} className="bg-slate-800/60 backdrop-blur-sm rounded-xl p-5 border border-slate-700/50 hover:border-emerald-500/30 transition-all group">
|
||||||
|
<div className={`w-11 h-11 rounded-xl bg-gradient-to-br ${use.color} flex items-center justify-center mb-3 shadow-lg group-hover:scale-105 transition-transform`}>
|
||||||
|
<span className="text-white">{use.icon}</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="font-semibold text-white text-sm mb-1.5">{use.title}</h3>
|
||||||
|
<p className="text-xs text-slate-400 leading-relaxed">{use.description}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Why This Matters for Our School */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||||
|
<div className="bg-gradient-to-br from-violet-500/10 to-violet-600/5 rounded-2xl p-6 border border-violet-500/20">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<Shield size={20} className="text-violet-400" />
|
||||||
|
<h3 className="font-bold text-white">Why This Matters for Our School</h3>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[
|
||||||
|
'ESA makes our specialized autism-focused programs accessible to more families',
|
||||||
|
'Families can use ESA funds to cover tuition at our school',
|
||||||
|
'Therapeutic services we provide (speech, OT, ABA) are ESA-eligible',
|
||||||
|
'It removes financial barriers for families seeking the best fit for their child',
|
||||||
|
'More enrolled students means we can expand programs and hire more staff',
|
||||||
|
].map((item, i) => (
|
||||||
|
<div key={i} className="flex items-start gap-2.5">
|
||||||
|
<CheckCircle2 size={16} className="text-violet-400 flex-shrink-0 mt-0.5" />
|
||||||
|
<span className="text-sm text-slate-300 leading-relaxed">{item}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gradient-to-br from-amber-500/10 to-amber-600/5 rounded-2xl p-6 border border-amber-500/20">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<Info size={20} className="text-amber-400" />
|
||||||
|
<h3 className="font-bold text-white">Your Role as Staff</h3>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[
|
||||||
|
{ title: 'Be Informed', desc: 'Understand the basics of ESA so you can answer general questions from parents' },
|
||||||
|
{ title: 'Direct to Office', desc: 'For detailed ESA questions, guide families to our office team for personalized support' },
|
||||||
|
{ title: 'Document Accurately', desc: 'Ensure student services and progress are documented properly for ESA reporting' },
|
||||||
|
{ title: 'Stay Focused', desc: 'Continue providing excellent, individualized education — ESA is about access, not changing what we do' },
|
||||||
|
].map((item, i) => (
|
||||||
|
<div key={i} className="flex items-start gap-2.5">
|
||||||
|
<div className="w-6 h-6 rounded-full bg-amber-500/20 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||||
|
<span className="text-xs font-bold text-amber-400">{i + 1}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-semibold text-white">{item.title}</span>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5">{item.desc}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* FAQ Section */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<HelpCircle size={20} className="text-emerald-400" />
|
||||||
|
<h2 className="text-lg font-bold text-white">Frequently Asked Questions</h2>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{faqs.map((faq, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`bg-slate-800/60 backdrop-blur-sm rounded-xl border transition-all ${
|
||||||
|
expandedFAQ === i ? 'border-emerald-500/30 shadow-lg shadow-emerald-500/5' : 'border-slate-700/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleFAQ(i)}
|
||||||
|
className="w-full flex items-center justify-between p-4 text-left"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`w-7 h-7 rounded-lg flex items-center justify-center flex-shrink-0 transition-colors ${
|
||||||
|
expandedFAQ === i ? 'bg-emerald-500/20 text-emerald-400' : 'bg-slate-700/50 text-slate-500'
|
||||||
|
}`}>
|
||||||
|
<span className="text-xs font-bold">{i + 1}</span>
|
||||||
|
</div>
|
||||||
|
<span className={`text-sm font-semibold transition-colors ${expandedFAQ === i ? 'text-white' : 'text-slate-300'}`}>
|
||||||
|
{faq.question}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{expandedFAQ === i ? (
|
||||||
|
<ChevronUp size={18} className="text-emerald-400 flex-shrink-0" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown size={18} className="text-slate-500 flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{expandedFAQ === i && (
|
||||||
|
<div className="px-4 pb-4 pt-0">
|
||||||
|
<div className="ml-10 text-sm text-slate-400 leading-relaxed border-t border-slate-700/50 pt-3">
|
||||||
|
{faq.answer}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Reference Card */}
|
||||||
|
<div className="bg-gradient-to-r from-slate-800/80 to-slate-800/60 rounded-2xl p-6 border border-slate-700/50">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<FileText size={20} className="text-emerald-400" />
|
||||||
|
<h3 className="font-bold text-white">Quick Reference for Parent Conversations</h3>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-900/60 rounded-xl p-5 border border-slate-700/40">
|
||||||
|
<p className="text-sm text-slate-300 italic leading-relaxed mb-4">
|
||||||
|
"If a parent asks about ESA, here's a simple way to explain it:"
|
||||||
|
</p>
|
||||||
|
<div className="bg-emerald-500/10 rounded-xl p-4 border border-emerald-500/20">
|
||||||
|
<p className="text-sm text-emerald-100 leading-relaxed">
|
||||||
|
"ESA stands for Empowerment Scholarship Account. It's state funding that's set aside for your child's education. Instead of the money only going to one school, it follows your child — so you can use it to pay for the school, therapies, tutoring, and services that work best for them. Our office team can walk you through the application process and help you understand what's covered."
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex items-center gap-2 text-xs text-slate-500">
|
||||||
|
<Info size={14} />
|
||||||
|
<span>For detailed questions, always direct families to the front office or administration.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Helpful Links */}
|
||||||
|
<div className="bg-slate-800/60 backdrop-blur-sm rounded-2xl p-6 border border-slate-700/50">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<ExternalLink size={20} className="text-emerald-400" />
|
||||||
|
<h3 className="font-bold text-white">Helpful Resources</h3>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
|
{[
|
||||||
|
{ title: 'AZ ESA Program', desc: 'Arizona Empowerment Scholarship Account official page', url: '#' },
|
||||||
|
{ title: 'ESA Eligible Expenses', desc: 'Full list of approved uses for ESA funds', url: '#' },
|
||||||
|
{ title: 'Family Application Guide', desc: 'Step-by-step guide for families applying', url: '#' },
|
||||||
|
{ title: 'Provider Registration', desc: 'How schools register as ESA providers', url: '#' },
|
||||||
|
{ title: 'ESA FAQ (State)', desc: 'Official state FAQ for families and schools', url: '#' },
|
||||||
|
{ title: 'Internal ESA Procedures', desc: 'Our school\'s ESA documentation process', url: '#' },
|
||||||
|
].map((link, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => {/* In production, would open link */}}
|
||||||
|
className="bg-slate-900/60 rounded-xl p-4 border border-slate-700/40 hover:border-emerald-500/30 transition-all text-left group"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
|
<span className="text-sm font-semibold text-white group-hover:text-emerald-400 transition-colors">{link.title}</span>
|
||||||
|
<ExternalLink size={14} className="text-slate-600 group-hover:text-emerald-400 transition-colors" />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500">{link.desc}</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Acknowledgment */}
|
||||||
|
<div className={`rounded-2xl p-6 border transition-all ${
|
||||||
|
acknowledged
|
||||||
|
? 'bg-emerald-500/10 border-emerald-500/30'
|
||||||
|
: 'bg-slate-800/60 border-slate-700/50'
|
||||||
|
}`}>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setAcknowledged(!acknowledged)}
|
||||||
|
className={`w-6 h-6 rounded-lg border-2 flex items-center justify-center flex-shrink-0 mt-0.5 transition-all ${
|
||||||
|
acknowledged
|
||||||
|
? 'bg-emerald-500 border-emerald-500'
|
||||||
|
: 'border-slate-600 hover:border-emerald-500/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{acknowledged && <CheckCircle2 size={16} className="text-white" />}
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<h3 className={`font-semibold text-sm ${acknowledged ? 'text-emerald-400' : 'text-white'}`}>
|
||||||
|
{acknowledged ? 'Thank you for reviewing the ESA Funding Information!' : 'I have read and understand the ESA Funding Information'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">
|
||||||
|
{acknowledged
|
||||||
|
? 'Your acknowledgment has been recorded. You can revisit this page anytime for reference.'
|
||||||
|
: 'Click the checkbox to acknowledge that you have reviewed this information. This helps us track staff awareness for compliance purposes.'
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ESAFunding;
|
||||||
723
src/components/frameworks/EmotionalIntelligence.tsx
Normal file
723
src/components/frameworks/EmotionalIntelligence.tsx
Normal file
@ -0,0 +1,723 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { UserRole } from '@/lib/types';
|
||||||
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
import {
|
||||||
|
Heart, Brain, ArrowRight, CheckCircle, RotateCcw,
|
||||||
|
TrendingUp, Users, Shield, Sparkles, Eye, Fingerprint, BookOpen,
|
||||||
|
Loader2, BarChart3, PieChart, RefreshCw, Save
|
||||||
|
} from 'lucide-react';
|
||||||
|
import PersonalityQuiz from './PersonalityQuiz';
|
||||||
|
import PersonalityDirectory from './PersonalityDirectory';
|
||||||
|
import {
|
||||||
|
savePersonalityQuizResult,
|
||||||
|
fetchPersonalityQuizResult,
|
||||||
|
fetchPersonalityDistribution,
|
||||||
|
updateStaffProfilePersonalityType,
|
||||||
|
} from '@/lib/db';
|
||||||
|
import { getPersonalityType } from '@/lib/personalityTypes';
|
||||||
|
|
||||||
|
|
||||||
|
interface EIProps {
|
||||||
|
userRole: UserRole;
|
||||||
|
userName?: string;
|
||||||
|
userCampus?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type EITab = 'assessment' | 'personality' | 'directory';
|
||||||
|
|
||||||
|
const EmotionalIntelligence: React.FC<EIProps> = ({ userRole, userName = 'Guest User', userCampus = 'Tigers' }) => {
|
||||||
|
const { user: authUser, isAuthenticated } = useAuth();
|
||||||
|
const [activeTab, setActiveTab] = useState<EITab>('assessment');
|
||||||
|
const [personalityResult, setPersonalityResult] = useState<string | null>(null);
|
||||||
|
const [savedAnswers, setSavedAnswers] = useState<Record<number, string> | null>(null);
|
||||||
|
const [savedDate, setSavedDate] = useState<string | null>(null);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [isLoadingSaved, setIsLoadingSaved] = useState(true);
|
||||||
|
|
||||||
|
// Director distribution state
|
||||||
|
const [distribution, setDistribution] = useState<{ type: string; count: number }[]>([]);
|
||||||
|
const [distributionLoading, setDistributionLoading] = useState(false);
|
||||||
|
const [distributionTotal, setDistributionTotal] = useState(0);
|
||||||
|
|
||||||
|
// EI Assessment state
|
||||||
|
const [assessmentStarted, setAssessmentStarted] = useState(false);
|
||||||
|
const [currentQ, setCurrentQ] = useState(0);
|
||||||
|
const [answers, setAnswers] = useState<number[]>([]);
|
||||||
|
const [complete, setComplete] = useState(false);
|
||||||
|
|
||||||
|
// Load saved personality type on mount
|
||||||
|
const loadSavedResult = useCallback(async () => {
|
||||||
|
setIsLoadingSaved(true);
|
||||||
|
try {
|
||||||
|
const saved = await fetchPersonalityQuizResult(userName);
|
||||||
|
if (saved) {
|
||||||
|
setPersonalityResult(saved.personalityType);
|
||||||
|
setSavedAnswers(saved.quizAnswers);
|
||||||
|
setSavedDate(saved.updatedAt);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading saved personality result:', err);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingSaved(false);
|
||||||
|
}
|
||||||
|
}, [userName]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSavedResult();
|
||||||
|
}, [loadSavedResult]);
|
||||||
|
|
||||||
|
// Load distribution for directors
|
||||||
|
const loadDistribution = useCallback(async () => {
|
||||||
|
if (userRole !== 'director') return;
|
||||||
|
setDistributionLoading(true);
|
||||||
|
try {
|
||||||
|
// Fetch all campus distribution (directors see all)
|
||||||
|
const dist = await fetchPersonalityDistribution();
|
||||||
|
setDistribution(dist);
|
||||||
|
setDistributionTotal(dist.reduce((sum, d) => sum + d.count, 0));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading distribution:', err);
|
||||||
|
} finally {
|
||||||
|
setDistributionLoading(false);
|
||||||
|
}
|
||||||
|
}, [userRole]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadDistribution();
|
||||||
|
}, [loadDistribution]);
|
||||||
|
|
||||||
|
// Handle quiz result
|
||||||
|
const handlePersonalityResult = async (code: string, quizAnswers: Record<number, string>) => {
|
||||||
|
setPersonalityResult(code);
|
||||||
|
setSavedAnswers(quizAnswers);
|
||||||
|
setIsSaving(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Save to personality_quiz_results table
|
||||||
|
await savePersonalityQuizResult({
|
||||||
|
userName,
|
||||||
|
userRole,
|
||||||
|
campus: userCampus,
|
||||||
|
personalityType: code,
|
||||||
|
quizAnswers,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also update staff_profiles if authenticated
|
||||||
|
if (isAuthenticated && authUser?.id) {
|
||||||
|
await updateStaffProfilePersonalityType(authUser.id, code);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSavedDate(new Date().toISOString());
|
||||||
|
|
||||||
|
// Refresh distribution for directors
|
||||||
|
if (userRole === 'director') {
|
||||||
|
loadDistribution();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error saving personality result:', err);
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const questions = [
|
||||||
|
{ q: 'When a student escalates, my first internal reaction is usually:', options: ['Frustration or irritation', 'Anxiety or nervousness', 'Calm assessment', 'Desire to fix it immediately'], scores: [1, 2, 4, 2] },
|
||||||
|
{ q: 'When I receive critical feedback from a supervisor, I tend to:', options: ['Feel defensive', 'Take it personally', 'Listen and reflect', 'Ask for specific examples'], scores: [1, 1, 4, 3] },
|
||||||
|
{ q: 'I can usually identify what emotion I\'m feeling in the moment:', options: ['Rarely', 'Sometimes', 'Often', 'Almost always'], scores: [1, 2, 3, 4] },
|
||||||
|
{ q: 'When a colleague is visibly stressed, I typically:', options: ['Avoid them', 'Feel stressed too', 'Check in briefly', 'Offer specific support'], scores: [1, 2, 3, 4] },
|
||||||
|
{ q: 'My stress regulation strategy at work is:', options: ['I don\'t have one', 'I push through it', 'I take brief breaks', 'I have multiple strategies I rotate'], scores: [1, 2, 3, 4] },
|
||||||
|
{ q: 'When there\'s a conflict between colleagues, I usually:', options: ['Stay out of it completely', 'Pick a side', 'Try to understand both perspectives', 'Help facilitate resolution'], scores: [2, 1, 3, 4] },
|
||||||
|
{ q: 'I recognize early signs of burnout in myself:', options: ['Only when it\'s severe', 'Sometimes too late', 'Usually in time to adjust', 'I proactively monitor my wellbeing'], scores: [1, 2, 3, 4] },
|
||||||
|
{ q: 'When communicating difficult information to parents, I:', options: ['Avoid it when possible', 'Get anxious beforehand', 'Prepare and stay factual', 'Balance empathy with clarity'], scores: [1, 2, 3, 4] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleAnswer = (scoreIdx: number) => {
|
||||||
|
const newAnswers = [...answers, questions[currentQ].scores[scoreIdx]];
|
||||||
|
setAnswers(newAnswers);
|
||||||
|
if (currentQ < questions.length - 1) {
|
||||||
|
setCurrentQ(c => c + 1);
|
||||||
|
} else {
|
||||||
|
setComplete(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalScore = answers.reduce((a, b) => a + b, 0);
|
||||||
|
const maxScore = questions.length * 4;
|
||||||
|
const percentage = Math.round((totalScore / maxScore) * 100);
|
||||||
|
|
||||||
|
const getLevel = () => {
|
||||||
|
if (percentage >= 80) return { label: 'Strong EI Foundation', color: 'text-emerald-400', bg: 'bg-emerald-500/15 border-emerald-500/20', desc: 'You demonstrate strong emotional awareness and regulation. Focus on mentoring others and deepening your practice.' };
|
||||||
|
if (percentage >= 60) return { label: 'Growing EI Skills', color: 'text-blue-400', bg: 'bg-blue-500/15 border-blue-500/20', desc: 'You have a good foundation. Focus on consistency in high-stress moments and building your regulation toolkit.' };
|
||||||
|
if (percentage >= 40) return { label: 'Developing Awareness', color: 'text-amber-400', bg: 'bg-amber-500/15 border-amber-500/20', desc: 'You\'re building awareness. Focus on identifying your triggers and practicing one regulation strategy daily.' };
|
||||||
|
return { label: 'Beginning Journey', color: 'text-rose-400', bg: 'bg-rose-500/15 border-rose-500/20', desc: 'This is a great starting point. Focus on naming your emotions throughout the day and noticing patterns.' };
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetAssessment = () => {
|
||||||
|
setAssessmentStarted(false); setCurrentQ(0); setAnswers([]); setComplete(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const weeklyTopics = [
|
||||||
|
{ title: 'Stress Regulation', desc: 'Identify your stress triggers and build a personal regulation toolkit', icon: <Shield size={18} />, color: 'bg-blue-500/10 text-blue-400 border-blue-500/20' },
|
||||||
|
{ title: 'Conflict Response', desc: 'Move from reactive to responsive in difficult conversations', icon: <Brain size={18} />, color: 'bg-violet-500/10 text-violet-400 border-violet-500/20' },
|
||||||
|
{ title: 'Burnout Prevention', desc: 'Recognize early warning signs and take proactive steps', icon: <Heart size={18} />, color: 'bg-rose-500/10 text-rose-400 border-rose-500/20' },
|
||||||
|
{ title: 'Empathy in Communication', desc: 'Listen to understand, not to respond', icon: <Eye size={18} />, color: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const growthTips = [
|
||||||
|
'Pause for 3 breaths before responding to any escalation',
|
||||||
|
'Name your emotion silently: "I notice I\'m feeling frustrated"',
|
||||||
|
'Check your zone before entering a student\'s space',
|
||||||
|
'End each day with one thing you handled well',
|
||||||
|
'Ask a colleague "How are you really doing?" this week',
|
||||||
|
];
|
||||||
|
|
||||||
|
const isDirector = userRole === 'director';
|
||||||
|
|
||||||
|
const tabs: { id: EITab; label: string; icon: React.ReactNode; desc: string }[] = [
|
||||||
|
{ id: 'assessment', label: 'EI Self-Assessment', icon: <Heart size={16} />, desc: 'Emotional awareness & regulation' },
|
||||||
|
{ id: 'personality', label: 'Personality Type Quiz', icon: <Fingerprint size={16} />, desc: 'Discover your MBTI type' },
|
||||||
|
{ id: 'directory', label: 'Personality Directory', icon: <BookOpen size={16} />, desc: 'All 16 types & profiles' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Helper to get group from code
|
||||||
|
const getGroup = (code: string) => {
|
||||||
|
const second = code[1];
|
||||||
|
const third = code[2];
|
||||||
|
if (second === 'N' && third === 'T') return 'Analysts';
|
||||||
|
if (second === 'N' && third === 'F') return 'Diplomats';
|
||||||
|
if (second === 'S' && third === 'J') return 'Sentinels';
|
||||||
|
if (second === 'S' && third === 'P') return 'Explorers';
|
||||||
|
return 'Unknown';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getGroupColor = (group: string) => {
|
||||||
|
switch (group) {
|
||||||
|
case 'Analysts': return { bg: 'bg-purple-500/15', text: 'text-purple-400', border: 'border-purple-500/20' };
|
||||||
|
case 'Diplomats': return { bg: 'bg-emerald-500/15', text: 'text-emerald-400', border: 'border-emerald-500/20' };
|
||||||
|
case 'Sentinels': return { bg: 'bg-blue-500/15', text: 'text-blue-400', border: 'border-blue-500/20' };
|
||||||
|
case 'Explorers': return { bg: 'bg-amber-500/15', text: 'text-amber-400', border: 'border-amber-500/20' };
|
||||||
|
default: return { bg: 'bg-slate-500/15', text: 'text-slate-400', border: 'border-slate-500/20' };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build group distribution for directors
|
||||||
|
const groupDistribution = distribution.reduce((acc, d) => {
|
||||||
|
const group = getGroup(d.type);
|
||||||
|
acc[group] = (acc[group] || 0) + d.count;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, number>);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Page Header */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-white flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-pink-500 to-pink-600 flex items-center justify-center shadow-lg shadow-pink-500/30">
|
||||||
|
<Heart size={20} className="text-white" />
|
||||||
|
</div>
|
||||||
|
Emotional Intelligence & Self-Awareness
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-slate-400 mt-1">Private, self-paced learning for personal and professional growth</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Saved Personality Type Banner */}
|
||||||
|
{personalityResult && !isLoadingSaved && activeTab === 'assessment' && (
|
||||||
|
<div className="bg-gradient-to-r from-violet-500/10 to-indigo-500/10 rounded-2xl border border-violet-500/20 p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`w-12 h-12 rounded-xl bg-gradient-to-br ${getPersonalityType(personalityResult)?.color || 'from-violet-500 to-indigo-600'} flex items-center justify-center shadow-lg`}>
|
||||||
|
<span className="text-sm font-black text-white tracking-wider">{personalityResult}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="font-bold text-white text-sm">Your Personality Type: {personalityResult}</h3>
|
||||||
|
<span className="text-[10px] bg-emerald-500/20 text-emerald-400 border border-emerald-500/30 px-2 py-0.5 rounded-full flex items-center gap-1">
|
||||||
|
<Save size={8} /> Saved
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
{getPersonalityType(personalityResult)?.name} — {getPersonalityType(personalityResult)?.nickname}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('personality')}
|
||||||
|
className="px-3 py-1.5 bg-violet-500/15 text-violet-400 rounded-lg text-xs font-medium hover:bg-violet-500/25 border border-violet-500/20 transition-all flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<Eye size={12} /> View Details
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tab Navigation */}
|
||||||
|
<div className="bg-slate-800/40 backdrop-blur-sm rounded-2xl border border-slate-700/40 p-1.5">
|
||||||
|
<div className="grid grid-cols-3 gap-1.5">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setActiveTab(tab.id)}
|
||||||
|
className={`relative py-3 px-4 rounded-xl text-sm font-medium transition-all flex items-center justify-center gap-2 ${
|
||||||
|
activeTab === tab.id
|
||||||
|
? 'bg-gradient-to-r from-pink-500/20 to-violet-500/20 text-white border border-pink-500/30 shadow-lg shadow-pink-500/10'
|
||||||
|
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-700/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={activeTab === tab.id ? 'text-pink-400' : ''}>{tab.icon}</span>
|
||||||
|
<span className="hidden sm:inline">{tab.label}</span>
|
||||||
|
<span className="sm:hidden text-xs">{tab.id === 'assessment' ? 'EI Quiz' : tab.id === 'personality' ? 'MBTI' : 'Directory'}</span>
|
||||||
|
{tab.id === 'personality' && personalityResult && (
|
||||||
|
<span className="hidden sm:inline text-[10px] bg-violet-500/20 text-violet-400 px-1.5 py-0.5 rounded-full font-bold">
|
||||||
|
{personalityResult}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Weekly Focus Banner */}
|
||||||
|
{activeTab === 'assessment' && (
|
||||||
|
<div className="bg-gradient-to-r from-pink-500/10 to-violet-500/10 rounded-2xl border border-pink-500/20 p-5">
|
||||||
|
<h3 className="font-bold text-pink-400 mb-1 flex items-center gap-2"><Sparkles size={16} /> This Week's EI Focus</h3>
|
||||||
|
<p className="text-sm text-slate-300">Emotional regulation during escalations — pause, breathe, respond instead of react. Notice your own zone before intervening with a student.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* TAB: EI Self-Assessment */}
|
||||||
|
{activeTab === 'assessment' && (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
{!assessmentStarted && !complete ? (
|
||||||
|
<div className="bg-slate-800/40 backdrop-blur-sm rounded-2xl border border-slate-700/40 p-6">
|
||||||
|
<div className="text-center mb-6">
|
||||||
|
<div className="w-20 h-20 rounded-2xl bg-gradient-to-br from-pink-500 to-violet-600 flex items-center justify-center mx-auto mb-4 shadow-xl shadow-pink-500/20">
|
||||||
|
<Brain size={36} className="text-white" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-bold text-white">EI Self-Assessment</h3>
|
||||||
|
<p className="text-sm text-slate-400 mt-1">{questions.length} reflective questions · Private results · Takes 5 minutes</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setAssessmentStarted(true)}
|
||||||
|
className="w-full py-3 bg-gradient-to-r from-pink-500 to-violet-600 text-white rounded-xl font-semibold hover:shadow-lg hover:shadow-pink-500/25 transition-all">
|
||||||
|
Begin Assessment
|
||||||
|
</button>
|
||||||
|
<div className="mt-6 grid grid-cols-2 gap-3">
|
||||||
|
{weeklyTopics.map((topic, i) => (
|
||||||
|
<div key={i} className={`${topic.color} border rounded-xl p-4`}>
|
||||||
|
<div className="mb-2">{topic.icon}</div>
|
||||||
|
<h4 className="font-semibold text-sm">{topic.title}</h4>
|
||||||
|
<p className="text-[10px] mt-1 opacity-80">{topic.desc}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : complete ? (
|
||||||
|
<div className="bg-slate-800/40 backdrop-blur-sm rounded-2xl border border-slate-700/40 p-6">
|
||||||
|
<div className="text-center mb-6">
|
||||||
|
<div className={`w-24 h-24 rounded-full ${getLevel().bg} border flex items-center justify-center mx-auto mb-4`}>
|
||||||
|
<span className={`text-3xl font-bold ${getLevel().color}`}>{percentage}%</span>
|
||||||
|
</div>
|
||||||
|
<h3 className={`text-xl font-bold ${getLevel().color}`}>{getLevel().label}</h3>
|
||||||
|
<p className="text-sm text-slate-400 mt-2 max-w-md mx-auto">{getLevel().desc}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-700/30 rounded-xl p-4 mb-4 border border-slate-600/30">
|
||||||
|
<div className="flex justify-between text-sm mb-2">
|
||||||
|
<span className="text-slate-400">Your Score</span>
|
||||||
|
<span className="font-bold text-white">{totalScore}/{maxScore}</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-3 bg-slate-700/50 rounded-full overflow-hidden">
|
||||||
|
<div className="h-full bg-gradient-to-r from-pink-500 to-violet-500 rounded-full transition-all duration-1000" style={{ width: `${percentage}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-pink-500/10 rounded-xl p-4 mb-4 border border-pink-500/20">
|
||||||
|
<h4 className="font-semibold text-pink-400 text-sm mb-2">Your Growth Path</h4>
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{growthTips.slice(0, 3).map((tip, i) => (
|
||||||
|
<li key={i} className="flex items-start gap-2">
|
||||||
|
<CheckCircle size={14} className="text-pink-400 mt-0.5 flex-shrink-0" />
|
||||||
|
<span className="text-xs text-slate-300">{tip}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 justify-center">
|
||||||
|
<button onClick={resetAssessment} className="flex items-center gap-2 px-5 py-2.5 bg-pink-500/15 text-pink-400 rounded-xl font-medium text-sm hover:bg-pink-500/25 border border-pink-500/20">
|
||||||
|
<RotateCcw size={14} /> Retake Assessment
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setActiveTab('personality')} className="flex items-center gap-2 px-5 py-2.5 bg-violet-500/15 text-violet-400 rounded-xl font-medium text-sm hover:bg-violet-500/25 border border-violet-500/20">
|
||||||
|
<Fingerprint size={14} /> Take Personality Quiz
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-slate-800/40 backdrop-blur-sm rounded-2xl border border-slate-700/40 p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<span className="text-sm font-medium text-slate-400">Question {currentQ + 1} of {questions.length}</span>
|
||||||
|
<span className="text-xs text-slate-500">Private & Confidential</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-slate-700/50 rounded-full mb-6">
|
||||||
|
<div className="h-full bg-gradient-to-r from-pink-500 to-violet-500 rounded-full transition-all duration-500" style={{ width: `${((currentQ + 1) / questions.length) * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-4">{questions[currentQ].q}</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{questions[currentQ].options.map((opt, idx) => (
|
||||||
|
<button key={idx} onClick={() => handleAnswer(idx)}
|
||||||
|
className="w-full text-left p-4 rounded-xl border-2 border-slate-600/40 bg-slate-700/30 hover:bg-pink-500/10 hover:border-pink-500/30 transition-all">
|
||||||
|
<span className="text-sm text-slate-200">{opt}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Personality Quiz CTA Card */}
|
||||||
|
<div className="bg-gradient-to-br from-violet-500/10 to-indigo-500/10 backdrop-blur-sm rounded-2xl border border-violet-500/20 p-5">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-violet-500 to-indigo-600 flex items-center justify-center shadow-lg">
|
||||||
|
<Fingerprint size={18} className="text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-white text-sm">Personality Type Quiz</h3>
|
||||||
|
<p className="text-[10px] text-slate-400">
|
||||||
|
{personalityResult ? `Your type: ${personalityResult}` : 'Discover your MBTI type'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{personalityResult ? (
|
||||||
|
<>
|
||||||
|
<div className="bg-violet-500/10 border border-violet-500/20 rounded-xl p-3 mb-3">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="text-lg font-black text-violet-400">{personalityResult}</span>
|
||||||
|
<span className="text-xs text-slate-300">{getPersonalityType(personalityResult)?.name}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-slate-500">{getPersonalityType(personalityResult)?.nickname}</p>
|
||||||
|
{savedDate && (
|
||||||
|
<p className="text-[9px] text-slate-600 mt-1 flex items-center gap-1">
|
||||||
|
<Save size={8} /> Last taken: {new Date(savedDate).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('personality')}
|
||||||
|
className="flex-1 py-2 bg-violet-500/15 text-violet-400 rounded-xl font-medium text-xs hover:bg-violet-500/25 border border-violet-500/20 transition-all flex items-center justify-center gap-1"
|
||||||
|
>
|
||||||
|
<Eye size={12} /> View
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('personality')}
|
||||||
|
className="flex-1 py-2 bg-slate-700/50 text-slate-300 rounded-xl font-medium text-xs hover:bg-slate-700/70 border border-slate-600/30 transition-all flex items-center justify-center gap-1"
|
||||||
|
>
|
||||||
|
<RefreshCw size={12} /> Retake
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-xs text-slate-400 mb-3">Find out if you're an INFJ, ESTP, or one of 16 personality types. Learn how your type shapes your work relationships and communication style.</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('personality')}
|
||||||
|
className="w-full py-2.5 bg-gradient-to-r from-violet-500 to-indigo-600 text-white rounded-xl font-semibold text-sm hover:shadow-lg hover:shadow-violet-500/25 transition-all flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
Take the Quiz <ArrowRight size={14} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-slate-800/40 backdrop-blur-sm rounded-2xl border border-slate-700/40 p-5">
|
||||||
|
<h3 className="font-semibold text-white mb-3 flex items-center gap-2"><Sparkles size={16} className="text-pink-400" /> Daily Growth Tips</h3>
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
{growthTips.map((tip, i) => (
|
||||||
|
<div key={i} className="flex items-start gap-2 p-2 rounded-lg bg-pink-500/5 border border-pink-500/10">
|
||||||
|
<CheckCircle size={14} className="text-pink-400 mt-0.5 flex-shrink-0" />
|
||||||
|
<p className="text-xs text-slate-300">{tip}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isDirector && (
|
||||||
|
<div className="bg-slate-800/40 backdrop-blur-sm rounded-2xl border border-slate-700/40 p-5">
|
||||||
|
<h3 className="font-semibold text-white mb-3 flex items-center gap-2"><Users size={16} className="text-violet-400" /> Team Wellness (Aggregated)</h3>
|
||||||
|
<p className="text-[10px] text-slate-500 mb-3">No individual emotional data shown</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[
|
||||||
|
{ label: 'Participation Rate', value: '78%', color: 'text-emerald-400', bar: 'bg-emerald-500', w: '78%' },
|
||||||
|
{ label: 'Average EI Score', value: '72%', color: 'text-blue-400', bar: 'bg-blue-500', w: '72%' },
|
||||||
|
{ label: 'Growth Trend', value: '+8%', color: 'text-pink-400', bar: 'bg-pink-500', w: '65%' },
|
||||||
|
].map((item, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
<div className="flex justify-between text-xs mb-1">
|
||||||
|
<span className="text-slate-400">{item.label}</span>
|
||||||
|
<span className={`font-semibold ${item.color} flex items-center gap-1`}>{i === 2 && <TrendingUp size={12} />}{item.value}</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-slate-700/50 rounded-full"><div className={`h-full ${item.bar} rounded-full`} style={{ width: item.w }}></div></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* TAB: Personality Type Quiz */}
|
||||||
|
{activeTab === 'personality' && (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
{isLoadingSaved ? (
|
||||||
|
<div className="bg-slate-800/40 backdrop-blur-sm rounded-2xl border border-slate-700/40 p-12 flex flex-col items-center justify-center">
|
||||||
|
<Loader2 size={32} className="animate-spin text-violet-400 mb-3" />
|
||||||
|
<p className="text-sm text-slate-400">Loading your saved results...</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<PersonalityQuiz
|
||||||
|
onViewDirectory={() => setActiveTab('directory')}
|
||||||
|
onResult={handlePersonalityResult}
|
||||||
|
savedType={personalityResult}
|
||||||
|
savedAnswers={savedAnswers}
|
||||||
|
savedDate={savedDate}
|
||||||
|
isSaving={isSaving}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* What is MBTI */}
|
||||||
|
<div className="bg-slate-800/40 backdrop-blur-sm rounded-2xl border border-slate-700/40 p-5">
|
||||||
|
<h3 className="font-semibold text-white mb-3 flex items-center gap-2">
|
||||||
|
<Brain size={16} className="text-violet-400" /> What is MBTI?
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-slate-400 leading-relaxed mb-3">
|
||||||
|
The Myers-Briggs Type Indicator identifies personality preferences across four dimensions, creating 16 unique personality types. Each type has distinct strengths in the workplace.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[
|
||||||
|
{ dim: 'E / I', label: 'Extraversion vs. Introversion', desc: 'Where you get your energy' },
|
||||||
|
{ dim: 'S / N', label: 'Sensing vs. Intuition', desc: 'How you gather information' },
|
||||||
|
{ dim: 'T / F', label: 'Thinking vs. Feeling', desc: 'How you make decisions' },
|
||||||
|
{ dim: 'J / P', label: 'Judging vs. Perceiving', desc: 'How you structure your work' },
|
||||||
|
].map((item, i) => (
|
||||||
|
<div key={i} className="bg-violet-500/5 border border-violet-500/10 rounded-lg p-2.5">
|
||||||
|
<div className="flex items-center gap-2 mb-0.5">
|
||||||
|
<span className="text-xs font-bold text-violet-400 bg-violet-500/15 px-2 py-0.5 rounded">{item.dim}</span>
|
||||||
|
<span className="text-xs font-medium text-white">{item.label}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-slate-500 ml-[52px]">{item.desc}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick link to directory */}
|
||||||
|
<div className="bg-gradient-to-br from-indigo-500/10 to-blue-500/10 backdrop-blur-sm rounded-2xl border border-indigo-500/20 p-5">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-indigo-500 to-blue-600 flex items-center justify-center shadow-lg">
|
||||||
|
<BookOpen size={18} className="text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-white text-sm">Type Directory</h3>
|
||||||
|
<p className="text-[10px] text-slate-400">Explore all 16 types</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400 mb-3">Browse all personality types to understand your colleagues' work styles, communication preferences, and relationship needs.</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('directory')}
|
||||||
|
className="w-full py-2.5 bg-slate-700/50 text-slate-300 rounded-xl font-medium text-sm hover:bg-slate-700/70 transition-all border border-slate-600/30 flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<BookOpen size={14} /> Browse All 16 Types
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Workplace Application Tips */}
|
||||||
|
<div className="bg-slate-800/40 backdrop-blur-sm rounded-2xl border border-slate-700/40 p-5">
|
||||||
|
<h3 className="font-semibold text-white mb-3 flex items-center gap-2">
|
||||||
|
<Sparkles size={16} className="text-amber-400" /> Why This Matters at Work
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
{[
|
||||||
|
'Understand why some colleagues prefer email while others prefer face-to-face',
|
||||||
|
'Recognize that different communication styles aren\'t personal — they\'re personality-driven',
|
||||||
|
'Build stronger teams by leveraging diverse personality strengths',
|
||||||
|
'Reduce workplace conflict by understanding different decision-making approaches',
|
||||||
|
'Improve your own self-awareness and professional growth',
|
||||||
|
].map((tip, i) => (
|
||||||
|
<div key={i} className="flex items-start gap-2 p-2 rounded-lg bg-amber-500/5 border border-amber-500/10">
|
||||||
|
<CheckCircle size={14} className="text-amber-400 mt-0.5 flex-shrink-0" />
|
||||||
|
<p className="text-xs text-slate-300">{tip}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* TAB: Personality Directory */}
|
||||||
|
{activeTab === 'directory' && (
|
||||||
|
<PersonalityDirectory
|
||||||
|
onBackToQuiz={() => setActiveTab('personality')}
|
||||||
|
highlightType={personalityResult}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Director: Aggregated Personality Distribution Panel */}
|
||||||
|
{isDirector && (activeTab === 'personality' || activeTab === 'directory') && (
|
||||||
|
<div className="bg-slate-800/40 backdrop-blur-sm rounded-2xl border border-slate-700/40 overflow-hidden">
|
||||||
|
<div className="p-5 border-b border-slate-700/40">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-purple-500 to-indigo-600 flex items-center justify-center shadow-lg shadow-purple-500/20">
|
||||||
|
<BarChart3 size={18} className="text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-white">Team Personality Distribution</h3>
|
||||||
|
<p className="text-xs text-slate-400">Anonymized, aggregated view across all campuses</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={loadDistribution}
|
||||||
|
disabled={distributionLoading}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-slate-700/50 text-slate-300 rounded-lg text-xs font-medium hover:bg-slate-700/70 border border-slate-600/30 transition-all"
|
||||||
|
>
|
||||||
|
{distributionLoading ? <Loader2 size={12} className="animate-spin" /> : <RefreshCw size={12} />}
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-5">
|
||||||
|
{distributionLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 size={24} className="animate-spin text-violet-400" />
|
||||||
|
</div>
|
||||||
|
) : distribution.length === 0 ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<PieChart size={32} className="mx-auto mb-3 text-slate-600" />
|
||||||
|
<p className="text-sm text-slate-500">No personality quiz results yet.</p>
|
||||||
|
<p className="text-xs text-slate-600 mt-1">Results will appear here as staff members complete the quiz.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Summary Stats */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
<div className="bg-slate-700/30 rounded-xl p-4 border border-slate-600/30 text-center">
|
||||||
|
<div className="text-2xl font-black text-white">{distributionTotal}</div>
|
||||||
|
<div className="text-[10px] text-slate-400 mt-1">Total Assessed</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-700/30 rounded-xl p-4 border border-slate-600/30 text-center">
|
||||||
|
<div className="text-2xl font-black text-violet-400">{distribution.length}</div>
|
||||||
|
<div className="text-[10px] text-slate-400 mt-1">Unique Types</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-700/30 rounded-xl p-4 border border-slate-600/30 text-center">
|
||||||
|
<div className="text-2xl font-black text-emerald-400">{distribution[0]?.type || '—'}</div>
|
||||||
|
<div className="text-[10px] text-slate-400 mt-1">Most Common</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-700/30 rounded-xl p-4 border border-slate-600/30 text-center">
|
||||||
|
<div className="text-2xl font-black text-amber-400">{Object.keys(groupDistribution).length}</div>
|
||||||
|
<div className="text-[10px] text-slate-400 mt-1">Groups Represented</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Group Distribution */}
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold text-white text-sm mb-3 flex items-center gap-2">
|
||||||
|
<PieChart size={14} className="text-violet-400" /> Distribution by Group
|
||||||
|
</h4>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
{['Analysts', 'Diplomats', 'Sentinels', 'Explorers'].map((group) => {
|
||||||
|
const count = groupDistribution[group] || 0;
|
||||||
|
const pct = distributionTotal > 0 ? Math.round((count / distributionTotal) * 100) : 0;
|
||||||
|
const colors = getGroupColor(group);
|
||||||
|
return (
|
||||||
|
<div key={group} className={`${colors.bg} border ${colors.border} rounded-xl p-4`}>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className={`text-xs font-semibold ${colors.text}`}>{group}</span>
|
||||||
|
<span className="text-xs font-bold text-white">{count}</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-slate-700/50 rounded-full overflow-hidden mb-1">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full transition-all duration-500 ${
|
||||||
|
group === 'Analysts' ? 'bg-purple-500' :
|
||||||
|
group === 'Diplomats' ? 'bg-emerald-500' :
|
||||||
|
group === 'Sentinels' ? 'bg-blue-500' : 'bg-amber-500'
|
||||||
|
}`}
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-slate-500">{pct}% of team</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Individual Type Distribution */}
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold text-white text-sm mb-3 flex items-center gap-2">
|
||||||
|
<BarChart3 size={14} className="text-violet-400" /> Distribution by Type
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{distribution.map((d) => {
|
||||||
|
const typeInfo = getPersonalityType(d.type);
|
||||||
|
const pct = distributionTotal > 0 ? Math.round((d.count / distributionTotal) * 100) : 0;
|
||||||
|
const group = getGroup(d.type);
|
||||||
|
const colors = getGroupColor(group);
|
||||||
|
return (
|
||||||
|
<div key={d.type} className="flex items-center gap-3 p-2.5 bg-slate-700/20 rounded-xl border border-slate-700/30">
|
||||||
|
<div className={`w-12 h-10 rounded-lg bg-gradient-to-br ${typeInfo?.color || 'from-slate-500 to-slate-600'} flex items-center justify-center flex-shrink-0`}>
|
||||||
|
<span className="text-[10px] font-black text-white">{d.type}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-semibold text-white">{typeInfo?.name || d.type}</span>
|
||||||
|
<span className={`text-[9px] ${colors.text} ${colors.bg} border ${colors.border} px-1.5 py-0.5 rounded`}>
|
||||||
|
{group}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-bold text-slate-300">{d.count} staff ({pct}%)</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 bg-slate-700/50 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full transition-all duration-500 ${
|
||||||
|
group === 'Analysts' ? 'bg-purple-500' :
|
||||||
|
group === 'Diplomats' ? 'bg-emerald-500' :
|
||||||
|
group === 'Sentinels' ? 'bg-blue-500' : 'bg-amber-500'
|
||||||
|
}`}
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Privacy Notice */}
|
||||||
|
<div className="bg-slate-700/20 rounded-xl p-4 border border-slate-600/20">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<Shield size={14} className="text-slate-500 mt-0.5 flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] text-slate-500 font-medium">Privacy Notice</p>
|
||||||
|
<p className="text-[10px] text-slate-600 mt-0.5">
|
||||||
|
This view shows anonymized, aggregated data only. Individual staff members' personality types are not identified.
|
||||||
|
Results are grouped to show team composition patterns and help inform professional development planning.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EmotionalIntelligence;
|
||||||
251
src/components/frameworks/FrameModule.tsx
Normal file
251
src/components/frameworks/FrameModule.tsx
Normal file
@ -0,0 +1,251 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { UserRole, FrameEntry } from '@/lib/types';
|
||||||
|
import { FRAME_ENTRIES } from '@/lib/appData';
|
||||||
|
import { fetchFrameEntries, createFrameEntry, updateFrameEntry } from '@/lib/db';
|
||||||
|
import {
|
||||||
|
Eye, Edit3, Save, ChevronDown, ChevronUp, Calendar,
|
||||||
|
User, CheckCircle, Plus, Search, Loader2, Database
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface FrameModuleProps {
|
||||||
|
userRole: UserRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FrameModule: React.FC<FrameModuleProps> = ({ userRole }) => {
|
||||||
|
const [entries, setEntries] = useState<FrameEntry[]>(FRAME_ENTRIES);
|
||||||
|
const [expandedId, setExpandedId] = useState<string>('');
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [editEntry, setEditEntry] = useState<FrameEntry | null>(null);
|
||||||
|
const [showNewForm, setShowNewForm] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [dbConnected, setDbConnected] = useState(false);
|
||||||
|
const [newEntry, setNewEntry] = useState<Omit<FrameEntry, 'id'>>({
|
||||||
|
weekOf: '',
|
||||||
|
postedDate: new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }),
|
||||||
|
formal: '', recognition: '', application: '', management: '', emotional: '',
|
||||||
|
author: 'Dr. Williams',
|
||||||
|
});
|
||||||
|
|
||||||
|
const isDirector = userRole === 'director';
|
||||||
|
|
||||||
|
useEffect(() => { loadEntries(); }, []);
|
||||||
|
|
||||||
|
const loadEntries = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const dbEntries = await fetchFrameEntries();
|
||||||
|
if (dbEntries.length > 0) {
|
||||||
|
setEntries(dbEntries);
|
||||||
|
setExpandedId(dbEntries[0]?.id || '');
|
||||||
|
setDbConnected(true);
|
||||||
|
} else {
|
||||||
|
setEntries(FRAME_ENTRIES);
|
||||||
|
setExpandedId(FRAME_ENTRIES[0]?.id || '');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setEntries(FRAME_ENTRIES);
|
||||||
|
setExpandedId(FRAME_ENTRIES[0]?.id || '');
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const frameLabels = [
|
||||||
|
{ key: 'formal', letter: 'F', label: 'Formal Observation', color: 'from-violet-500 to-violet-600', lightBg: 'bg-violet-500/10 border-violet-500/20', desc: 'Key themes from walkthroughs or observations' },
|
||||||
|
{ key: 'recognition', letter: 'R', label: 'Recognition', color: 'from-amber-500 to-amber-600', lightBg: 'bg-amber-500/10 border-amber-500/20', desc: 'Shout-outs to teams, roles, or behaviors' },
|
||||||
|
{ key: 'application', letter: 'A', label: 'Application', color: 'from-emerald-500 to-emerald-600', lightBg: 'bg-emerald-500/10 border-emerald-500/20', desc: 'What staff should try, apply, or practice this week' },
|
||||||
|
{ key: 'management', letter: 'M', label: 'Management', color: 'from-blue-500 to-blue-600', lightBg: 'bg-blue-500/10 border-blue-500/20', desc: 'What needs fixing, tightening, or better management' },
|
||||||
|
{ key: 'emotional', letter: 'E', label: 'Emotional Intelligence', color: 'from-pink-500 to-pink-600', lightBg: 'bg-pink-500/10 border-pink-500/20', desc: 'One EI skill for the week' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleSaveNew = async () => {
|
||||||
|
if (!newEntry.weekOf || !newEntry.formal) return;
|
||||||
|
setSaving(true);
|
||||||
|
const created = await createFrameEntry(newEntry);
|
||||||
|
if (created) {
|
||||||
|
setEntries([created, ...entries]);
|
||||||
|
setExpandedId(created.id);
|
||||||
|
setDbConnected(true);
|
||||||
|
} else {
|
||||||
|
const entry: FrameEntry = { ...newEntry, id: Date.now().toString() };
|
||||||
|
setEntries([entry, ...entries]);
|
||||||
|
setExpandedId(entry.id);
|
||||||
|
}
|
||||||
|
setShowNewForm(false);
|
||||||
|
setNewEntry({ weekOf: '', postedDate: new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }), formal: '', recognition: '', application: '', management: '', emotional: '', author: 'Dr. Williams' });
|
||||||
|
setSaving(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveEdit = async () => {
|
||||||
|
if (!editEntry) return;
|
||||||
|
setSaving(true);
|
||||||
|
await updateFrameEntry(editEntry);
|
||||||
|
setEntries(entries.map(e => e.id === editEntry.id ? editEntry : e));
|
||||||
|
setIsEditing(false);
|
||||||
|
setEditEntry(null);
|
||||||
|
setSaving(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<div className="text-center">
|
||||||
|
<Loader2 size={32} className="animate-spin text-violet-500 mx-auto mb-3" />
|
||||||
|
<p className="text-sm text-slate-400">Loading F.R.A.M.E. entries...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-white flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-amber-400 to-violet-500 flex items-center justify-center shadow-lg shadow-amber-500/20">
|
||||||
|
<span className="text-white font-bold text-lg">F</span>
|
||||||
|
</div>
|
||||||
|
F.R.A.M.E. Weekly Focus
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-slate-400 mt-1 flex items-center gap-2">
|
||||||
|
{isDirector ? 'Create and manage weekly campus focus areas' : 'View this week\'s campus focus and past entries'}
|
||||||
|
{dbConnected && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-emerald-500/15 text-emerald-400 rounded-lg text-[10px] font-semibold border border-emerald-500/20">
|
||||||
|
<Database size={10} /> Synced
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button onClick={loadEntries} className="p-2 rounded-xl bg-slate-800/60 hover:bg-slate-700/60 text-slate-400 transition-colors border border-slate-700/40" title="Refresh">
|
||||||
|
<Loader2 size={16} className={loading ? 'animate-spin' : ''} />
|
||||||
|
</button>
|
||||||
|
{isDirector && (
|
||||||
|
<button onClick={() => setShowNewForm(!showNewForm)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-amber-500 to-violet-500 text-white rounded-xl font-medium text-sm hover:shadow-lg hover:shadow-amber-500/20 transition-all">
|
||||||
|
<Plus size={16} /> New Weekly Entry
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="bg-slate-800/40 backdrop-blur-sm rounded-2xl border border-slate-700/40 p-5">
|
||||||
|
<h3 className="text-sm font-semibold text-slate-300 mb-3">What F.R.A.M.E. Stands For</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-5 gap-3">
|
||||||
|
{frameLabels.map(f => (
|
||||||
|
<div key={f.key} className={`${f.lightBg} border rounded-xl p-3 text-center`}>
|
||||||
|
<div className={`w-8 h-8 bg-gradient-to-br ${f.color} rounded-lg flex items-center justify-center text-white font-bold text-sm mx-auto mb-1.5 shadow-lg`}>
|
||||||
|
{f.letter}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs font-semibold text-slate-200">{f.label}</p>
|
||||||
|
<p className="text-[10px] text-slate-500 mt-0.5">{f.desc}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* New Entry Form */}
|
||||||
|
{showNewForm && isDirector && (
|
||||||
|
<div className="bg-slate-800/60 backdrop-blur-sm rounded-2xl border-2 border-amber-500/30 shadow-lg shadow-amber-500/10 p-6 space-y-4">
|
||||||
|
<h3 className="font-bold text-lg text-white flex items-center gap-2">
|
||||||
|
<Edit3 size={18} className="text-amber-400" /> Create New F.R.A.M.E. Entry
|
||||||
|
</h3>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-slate-300">Week Of</label>
|
||||||
|
<input type="text" value={newEntry.weekOf} onChange={e => setNewEntry({ ...newEntry, weekOf: e.target.value })}
|
||||||
|
placeholder="e.g., February 16, 2026"
|
||||||
|
className="w-full mt-1 px-4 py-2.5 bg-slate-700/50 border border-slate-600/50 rounded-xl text-sm text-white placeholder-slate-500 focus:ring-2 focus:ring-violet-500 outline-none" />
|
||||||
|
</div>
|
||||||
|
{frameLabels.map(f => (
|
||||||
|
<div key={f.key}>
|
||||||
|
<label className="text-sm font-medium text-slate-300 flex items-center gap-2">
|
||||||
|
<span className={`w-5 h-5 bg-gradient-to-br ${f.color} rounded text-white text-[10px] font-bold flex items-center justify-center`}>{f.letter}</span>
|
||||||
|
{f.label}
|
||||||
|
</label>
|
||||||
|
<textarea value={(newEntry as any)[f.key]} onChange={e => setNewEntry({ ...newEntry, [f.key]: e.target.value })}
|
||||||
|
placeholder={f.desc} rows={2}
|
||||||
|
className="w-full mt-1 px-4 py-2.5 bg-slate-700/50 border border-slate-600/50 rounded-xl text-sm text-white placeholder-slate-500 focus:ring-2 focus:ring-violet-500 outline-none resize-none" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button onClick={handleSaveNew} disabled={saving}
|
||||||
|
className="flex items-center gap-2 px-5 py-2.5 bg-gradient-to-r from-emerald-500 to-emerald-600 text-white rounded-xl font-medium text-sm hover:shadow-lg transition-all disabled:opacity-50">
|
||||||
|
{saving ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
|
||||||
|
{saving ? 'Saving...' : 'Publish Entry'}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setShowNewForm(false)} className="px-5 py-2.5 bg-slate-700 text-slate-300 rounded-xl font-medium text-sm hover:bg-slate-600 transition-colors">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Entries List */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{entries.map((entry, idx) => {
|
||||||
|
const isExpanded = expandedId === entry.id;
|
||||||
|
const isCurrent = idx === 0;
|
||||||
|
return (
|
||||||
|
<div key={entry.id} className={`bg-slate-800/40 backdrop-blur-sm rounded-2xl border overflow-hidden transition-all ${isCurrent ? 'border-amber-500/30' : 'border-slate-700/40'}`}>
|
||||||
|
<button onClick={() => setExpandedId(isExpanded ? '' : entry.id)}
|
||||||
|
className={`w-full px-5 py-4 flex items-center justify-between ${isCurrent ? 'bg-gradient-to-r from-amber-500/10 to-violet-500/10' : 'bg-slate-700/20'} hover:bg-slate-700/30 transition-colors`}>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{isCurrent && <span className="px-2.5 py-1 bg-gradient-to-r from-amber-500 to-amber-600 text-white text-[10px] font-bold rounded-lg uppercase shadow-lg shadow-amber-500/20">Current</span>}
|
||||||
|
<div className="text-left">
|
||||||
|
<h4 className="font-semibold text-white">Week of {entry.weekOf}</h4>
|
||||||
|
<p className="text-xs text-slate-500 flex items-center gap-1 mt-0.5"><User size={10} /> {entry.author} · Posted {entry.postedDate}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isExpanded ? <ChevronUp size={18} className="text-slate-400" /> : <ChevronDown size={18} className="text-slate-400" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="p-5 space-y-4">
|
||||||
|
{isEditing && editEntry?.id === entry.id ? (
|
||||||
|
<>
|
||||||
|
{frameLabels.map(f => (
|
||||||
|
<div key={f.key}>
|
||||||
|
<label className="text-sm font-medium text-slate-300 flex items-center gap-2">
|
||||||
|
<span className={`w-6 h-6 bg-gradient-to-br ${f.color} rounded-lg text-white text-xs font-bold flex items-center justify-center`}>{f.letter}</span>
|
||||||
|
{f.label}
|
||||||
|
</label>
|
||||||
|
<textarea value={(editEntry as any)[f.key]} onChange={e => setEditEntry({ ...editEntry, [f.key]: e.target.value })}
|
||||||
|
rows={2} className="w-full mt-1 px-4 py-2.5 bg-slate-700/50 border border-slate-600/50 rounded-xl text-sm text-white focus:ring-2 focus:ring-violet-500 outline-none resize-none" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button onClick={handleSaveEdit} disabled={saving} className="flex items-center gap-2 px-4 py-2 bg-emerald-500 text-white rounded-xl text-sm font-medium hover:bg-emerald-600 disabled:opacity-50">
|
||||||
|
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />} {saving ? 'Saving...' : 'Save Changes'}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => { setIsEditing(false); setEditEntry(null); }} className="px-4 py-2 bg-slate-700 text-slate-300 rounded-xl text-sm font-medium hover:bg-slate-600">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{frameLabels.map(f => (
|
||||||
|
<div key={f.key} className={`${f.lightBg} border rounded-xl p-4`}>
|
||||||
|
<div className="flex items-center gap-2 mb-1.5">
|
||||||
|
<span className={`w-7 h-7 bg-gradient-to-br ${f.color} rounded-lg text-white text-sm font-bold flex items-center justify-center shadow-lg`}>{f.letter}</span>
|
||||||
|
<span className="text-sm font-semibold text-slate-200">{f.label}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-300 leading-relaxed pl-9">{(entry as any)[f.key]}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{isDirector && (
|
||||||
|
<button onClick={() => { setIsEditing(true); setEditEntry({ ...entry }); }}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-violet-500/15 text-violet-400 rounded-xl text-sm font-medium hover:bg-violet-500/25 transition-colors border border-violet-500/20">
|
||||||
|
<Edit3 size={14} /> Edit Entry
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FrameModule;
|
||||||
284
src/components/frameworks/HandbookPolicy.tsx
Normal file
284
src/components/frameworks/HandbookPolicy.tsx
Normal file
@ -0,0 +1,284 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { UserRole } from '@/lib/types';
|
||||||
|
import { DEFAULT_HANDBOOK_POLICIES, HandbookPolicy as HandbookPolicyType, HANDBOOK_IMAGE } from '@/lib/appData';
|
||||||
|
import {
|
||||||
|
FileText, Plus, Search, ChevronDown, ChevronUp, CheckCircle,
|
||||||
|
Edit3, Trash2, X, Save, BookOpen, Shield, Users, Scale, Briefcase, Tag
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface HandbookPolicyProps {
|
||||||
|
userRole: UserRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryIcons: Record<string, React.ReactNode> = {
|
||||||
|
'Behavior': <Shield size={16} />,
|
||||||
|
'Operations': <Briefcase size={16} />,
|
||||||
|
'Communication': <Users size={16} />,
|
||||||
|
'Safety': <Shield size={16} />,
|
||||||
|
'Professional Growth': <BookOpen size={16} />,
|
||||||
|
'Legal': <Scale size={16} />,
|
||||||
|
};
|
||||||
|
|
||||||
|
const categoryColors: Record<string, string> = {
|
||||||
|
'Behavior': 'bg-rose-500/10 text-rose-400 border-rose-500/20',
|
||||||
|
'Operations': 'bg-amber-500/10 text-amber-400 border-amber-500/20',
|
||||||
|
'Communication': 'bg-cyan-500/10 text-cyan-400 border-cyan-500/20',
|
||||||
|
'Safety': 'bg-red-500/10 text-red-400 border-red-500/20',
|
||||||
|
'Professional Growth': 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',
|
||||||
|
'Legal': 'bg-violet-500/10 text-violet-400 border-violet-500/20',
|
||||||
|
};
|
||||||
|
|
||||||
|
const HandbookPolicy: React.FC<HandbookPolicyProps> = ({ userRole }) => {
|
||||||
|
const isDirector = userRole === 'director';
|
||||||
|
const [policies, setPolicies] = useState<HandbookPolicyType[]>(DEFAULT_HANDBOOK_POLICIES);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [categoryFilter, setCategoryFilter] = useState('all');
|
||||||
|
const [expandedPolicy, setExpandedPolicy] = useState<string | null>(null);
|
||||||
|
const [acknowledged, setAcknowledged] = useState<Set<string>>(new Set());
|
||||||
|
const [showAddForm, setShowAddForm] = useState(false);
|
||||||
|
const [editingPolicy, setEditingPolicy] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// New policy form state
|
||||||
|
const [newTitle, setNewTitle] = useState('');
|
||||||
|
const [newCategory, setNewCategory] = useState('Operations');
|
||||||
|
const [newContent, setNewContent] = useState('');
|
||||||
|
const [editTitle, setEditTitle] = useState('');
|
||||||
|
const [editCategory, setEditCategory] = useState('');
|
||||||
|
const [editContent, setEditContent] = useState('');
|
||||||
|
|
||||||
|
const categories = ['all', ...new Set(policies.map(p => p.category))];
|
||||||
|
|
||||||
|
const filtered = policies.filter(p => {
|
||||||
|
if (searchQuery && !p.title.toLowerCase().includes(searchQuery.toLowerCase()) && !p.content.toLowerCase().includes(searchQuery.toLowerCase())) return false;
|
||||||
|
if (categoryFilter !== 'all' && p.category !== categoryFilter) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
if (!newTitle.trim() || !newContent.trim()) return;
|
||||||
|
const newPolicy: HandbookPolicyType = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
title: newTitle,
|
||||||
|
category: newCategory,
|
||||||
|
content: newContent,
|
||||||
|
lastUpdated: new Date().toISOString().split('T')[0],
|
||||||
|
updatedBy: 'Dr. Williams',
|
||||||
|
};
|
||||||
|
setPolicies([newPolicy, ...policies]);
|
||||||
|
setNewTitle(''); setNewCategory('Operations'); setNewContent('');
|
||||||
|
setShowAddForm(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (id: string) => {
|
||||||
|
const policy = policies.find(p => p.id === id);
|
||||||
|
if (!policy) return;
|
||||||
|
setEditTitle(policy.title);
|
||||||
|
setEditCategory(policy.category);
|
||||||
|
setEditContent(policy.content);
|
||||||
|
setEditingPolicy(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveEdit = (id: string) => {
|
||||||
|
setPolicies(policies.map(p => p.id === id ? {
|
||||||
|
...p, title: editTitle, category: editCategory, content: editContent,
|
||||||
|
lastUpdated: new Date().toISOString().split('T')[0],
|
||||||
|
} : p));
|
||||||
|
setEditingPolicy(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (id: string) => {
|
||||||
|
setPolicies(policies.filter(p => p.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleAck = (id: string) => {
|
||||||
|
const next = new Set(acknowledged);
|
||||||
|
if (next.has(id)) next.delete(id); else next.add(id);
|
||||||
|
setAcknowledged(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ackCount = acknowledged.size;
|
||||||
|
const totalCount = policies.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header with hero image */}
|
||||||
|
<div className="relative overflow-hidden rounded-2xl h-48 md:h-56">
|
||||||
|
<img src={HANDBOOK_IMAGE} alt="Handbook" className="w-full h-full object-cover" />
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-slate-900/90 via-slate-800/80 to-slate-900/60" />
|
||||||
|
<div className="absolute inset-0 flex items-center px-6 md:px-8">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-white/10 backdrop-blur-sm border border-white/20 flex items-center justify-center">
|
||||||
|
<FileText size={24} className="text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl md:text-3xl font-bold text-white">Handbook & Policies</h2>
|
||||||
|
<p className="text-sm text-slate-300">Campus policies, procedures, and expectations — always accessible</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 mt-4">
|
||||||
|
<div className="bg-white/10 backdrop-blur-sm rounded-lg px-3 py-1.5 border border-white/10">
|
||||||
|
<span className="text-xs text-slate-300">{totalCount} Policies</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-emerald-500/20 backdrop-blur-sm rounded-lg px-3 py-1.5 border border-emerald-400/20">
|
||||||
|
<span className="text-xs text-emerald-300">{ackCount}/{totalCount} Acknowledged</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Director: Add Policy */}
|
||||||
|
{isDirector && (
|
||||||
|
<div>
|
||||||
|
{!showAddForm ? (
|
||||||
|
<button onClick={() => setShowAddForm(true)}
|
||||||
|
className="flex items-center gap-2 px-5 py-3 bg-gradient-to-r from-violet-600 to-indigo-600 text-white rounded-xl font-medium text-sm hover:shadow-lg hover:shadow-violet-500/25 transition-all">
|
||||||
|
<Plus size={16} /> Add New Policy
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="bg-slate-800/50 backdrop-blur-sm rounded-2xl border border-slate-700/50 p-6 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="font-bold text-white text-lg">Add New Policy</h3>
|
||||||
|
<button onClick={() => setShowAddForm(false)} className="text-slate-400 hover:text-white"><X size={18} /></button>
|
||||||
|
</div>
|
||||||
|
<input type="text" value={newTitle} onChange={e => setNewTitle(e.target.value)} placeholder="Policy title..."
|
||||||
|
className="w-full px-4 py-3 bg-slate-700/50 border border-slate-600/50 rounded-xl text-sm text-white placeholder-slate-400 focus:ring-2 focus:ring-violet-500 outline-none" />
|
||||||
|
<select value={newCategory} onChange={e => setNewCategory(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 bg-slate-700/50 border border-slate-600/50 rounded-xl text-sm text-white focus:ring-2 focus:ring-violet-500 outline-none">
|
||||||
|
{['Operations', 'Behavior', 'Communication', 'Safety', 'Professional Growth', 'Legal'].map(c => (
|
||||||
|
<option key={c} value={c}>{c}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<textarea value={newContent} onChange={e => setNewContent(e.target.value)} placeholder="Policy content..."
|
||||||
|
rows={6} className="w-full px-4 py-3 bg-slate-700/50 border border-slate-600/50 rounded-xl text-sm text-white placeholder-slate-400 focus:ring-2 focus:ring-violet-500 outline-none resize-none" />
|
||||||
|
<button onClick={handleAdd} disabled={!newTitle.trim() || !newContent.trim()}
|
||||||
|
className="flex items-center gap-2 px-5 py-3 bg-gradient-to-r from-emerald-500 to-teal-500 text-white rounded-xl font-medium text-sm hover:shadow-lg transition-all disabled:opacity-50">
|
||||||
|
<Save size={14} /> Save Policy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Search & Filters */}
|
||||||
|
<div className="bg-slate-800/40 backdrop-blur-sm rounded-2xl border border-slate-700/40 p-4 space-y-3">
|
||||||
|
<div className="relative">
|
||||||
|
<Search size={18} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||||
|
<input type="text" value={searchQuery} onChange={e => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Search policies by title or content..."
|
||||||
|
className="w-full pl-10 pr-4 py-2.5 bg-slate-700/50 border border-slate-600/50 rounded-xl text-sm text-white placeholder-slate-400 focus:ring-2 focus:ring-violet-500 outline-none" />
|
||||||
|
{searchQuery && (
|
||||||
|
<button onClick={() => setSearchQuery('')} className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white"><X size={16} /></button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{categories.map(cat => (
|
||||||
|
<button key={cat} onClick={() => setCategoryFilter(cat)}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all border ${
|
||||||
|
categoryFilter === cat
|
||||||
|
? 'bg-violet-500/20 text-violet-300 border-violet-500/30'
|
||||||
|
: 'bg-slate-700/30 text-slate-400 border-slate-600/30 hover:bg-slate-700/50'
|
||||||
|
}`}>
|
||||||
|
{cat === 'all' ? 'All Categories' : cat}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-slate-400">{filtered.length} {filtered.length === 1 ? 'policy' : 'policies'} found</p>
|
||||||
|
|
||||||
|
{/* Policy List */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{filtered.map(policy => (
|
||||||
|
<div key={policy.id} className="bg-slate-800/40 backdrop-blur-sm rounded-2xl border border-slate-700/40 overflow-hidden hover:border-slate-600/50 transition-all">
|
||||||
|
{editingPolicy === policy.id ? (
|
||||||
|
<div className="p-5 space-y-3">
|
||||||
|
<input type="text" value={editTitle} onChange={e => setEditTitle(e.target.value)}
|
||||||
|
className="w-full px-4 py-2.5 bg-slate-700/50 border border-slate-600/50 rounded-xl text-sm text-white focus:ring-2 focus:ring-violet-500 outline-none" />
|
||||||
|
<select value={editCategory} onChange={e => setEditCategory(e.target.value)}
|
||||||
|
className="w-full px-4 py-2.5 bg-slate-700/50 border border-slate-600/50 rounded-xl text-sm text-white focus:ring-2 focus:ring-violet-500 outline-none">
|
||||||
|
{['Operations', 'Behavior', 'Communication', 'Safety', 'Professional Growth', 'Legal'].map(c => (
|
||||||
|
<option key={c} value={c}>{c}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<textarea value={editContent} onChange={e => setEditContent(e.target.value)} rows={5}
|
||||||
|
className="w-full px-4 py-2.5 bg-slate-700/50 border border-slate-600/50 rounded-xl text-sm text-white focus:ring-2 focus:ring-violet-500 outline-none resize-none" />
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={() => handleSaveEdit(policy.id)} className="flex items-center gap-1.5 px-4 py-2 bg-emerald-500 text-white rounded-lg text-xs font-medium hover:bg-emerald-600 transition-colors">
|
||||||
|
<Save size={12} /> Save
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setEditingPolicy(null)} className="px-4 py-2 bg-slate-600 text-slate-300 rounded-lg text-xs font-medium hover:bg-slate-500 transition-colors">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button onClick={() => setExpandedPolicy(expandedPolicy === policy.id ? null : policy.id)}
|
||||||
|
className="w-full px-5 py-4 flex items-center justify-between hover:bg-slate-700/20 transition-colors">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`w-10 h-10 rounded-xl flex items-center justify-center border ${categoryColors[policy.category] || 'bg-slate-500/10 text-slate-400 border-slate-500/20'}`}>
|
||||||
|
{categoryIcons[policy.category] || <Tag size={16} />}
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<h3 className="font-semibold text-white text-sm">{policy.title}</h3>
|
||||||
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
|
<span className={`px-2 py-0.5 rounded text-[10px] font-semibold border ${categoryColors[policy.category] || 'bg-slate-500/10 text-slate-400 border-slate-500/20'}`}>{policy.category}</span>
|
||||||
|
<span className="text-[10px] text-slate-500">Updated {policy.lastUpdated} by {policy.updatedBy}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{acknowledged.has(policy.id) && (
|
||||||
|
<span className="px-2.5 py-1 bg-emerald-500/20 text-emerald-400 rounded-lg text-[10px] font-semibold border border-emerald-500/20">ACKNOWLEDGED</span>
|
||||||
|
)}
|
||||||
|
{expandedPolicy === policy.id ? <ChevronUp size={18} className="text-slate-400" /> : <ChevronDown size={18} className="text-slate-400" />}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{expandedPolicy === policy.id && (
|
||||||
|
<div className="px-5 pb-5 space-y-4">
|
||||||
|
<div className="bg-slate-700/30 rounded-xl p-4 border border-slate-600/30">
|
||||||
|
<p className="text-sm text-slate-300 leading-relaxed whitespace-pre-wrap">{policy.content}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<button onClick={() => toggleAck(policy.id)}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2.5 rounded-xl text-sm font-medium transition-all ${
|
||||||
|
acknowledged.has(policy.id)
|
||||||
|
? 'bg-emerald-500/20 text-emerald-400 border border-emerald-500/30 hover:bg-emerald-500/30'
|
||||||
|
: 'bg-violet-500/20 text-violet-300 border border-violet-500/30 hover:bg-violet-500/30'
|
||||||
|
}`}>
|
||||||
|
<CheckCircle size={14} />
|
||||||
|
{acknowledged.has(policy.id) ? 'Acknowledged' : 'I Have Read and Understand This Policy'}
|
||||||
|
</button>
|
||||||
|
{isDirector && (
|
||||||
|
<>
|
||||||
|
<button onClick={() => handleEdit(policy.id)}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-2.5 bg-slate-700/50 text-slate-300 rounded-xl text-xs font-medium hover:bg-slate-600/50 transition-colors border border-slate-600/30">
|
||||||
|
<Edit3 size={12} /> Edit
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleDelete(policy.id)}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-2.5 bg-red-500/10 text-red-400 rounded-xl text-xs font-medium hover:bg-red-500/20 transition-colors border border-red-500/20">
|
||||||
|
<Trash2 size={12} /> Delete
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<FileText size={40} className="mx-auto text-slate-600 mb-3" />
|
||||||
|
<p className="text-slate-400 font-medium">No policies found</p>
|
||||||
|
<p className="text-sm text-slate-500 mt-1">Try adjusting your search or filters</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HandbookPolicy;
|
||||||
366
src/components/frameworks/MoreModules.tsx
Normal file
366
src/components/frameworks/MoreModules.tsx
Normal file
@ -0,0 +1,366 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { UserRole, ModuleId } from '@/lib/types';
|
||||||
|
import { EVENTS, PARENT_TEMPLATES } from '@/lib/appData';
|
||||||
|
import { saveMessage, fetchMessages, fetchAttendance, fetchEvents, createEvent } from '@/lib/db';
|
||||||
|
import {
|
||||||
|
Clock, MessageSquare, Bell, AlertTriangle, Send, Copy,
|
||||||
|
CheckCircle, Calendar, Users, TrendingDown, TrendingUp,
|
||||||
|
Shield, FileText, ChevronDown, ChevronUp, X, Check, Heart, Loader2, Database
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
// ==================== ATTENDANCE MODULE ====================
|
||||||
|
export const AttendanceModule: React.FC<{ userRole: UserRole }> = ({ userRole }) => {
|
||||||
|
const isDirector = userRole === 'director';
|
||||||
|
const [records, setRecords] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => { loadAttendance(); }, []);
|
||||||
|
|
||||||
|
const loadAttendance = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await fetchAttendance(isDirector ? undefined : 'Ms. Rodriguez');
|
||||||
|
if (data.length > 0) setRecords(data);
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const personalRecords = records.filter(r => r.user_name === 'Ms. Rodriguez').slice(0, 8);
|
||||||
|
const presentCount = personalRecords.filter(r => r.status === 'present').length;
|
||||||
|
const lateCount = personalRecords.filter(r => r.status === 'late').length;
|
||||||
|
const absentCount = personalRecords.filter(r => r.status === 'absent').length;
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = { present: 'bg-emerald-100 text-emerald-700', late: 'bg-amber-100 text-amber-700', absent: 'bg-red-100 text-red-700' };
|
||||||
|
|
||||||
|
// Aggregate for director
|
||||||
|
const staffNames = [...new Set(records.map(r => r.user_name))];
|
||||||
|
const staffAttendance = staffNames.map(name => {
|
||||||
|
const staffRecords = records.filter(r => r.user_name === name);
|
||||||
|
return {
|
||||||
|
name, present: staffRecords.filter(r => r.status === 'present').length,
|
||||||
|
late: staffRecords.filter(r => r.status === 'late').length,
|
||||||
|
absent: staffRecords.filter(r => r.status === 'absent').length,
|
||||||
|
trend: staffRecords.filter(r => r.status === 'absent').length > 2 ? 'down' : 'up',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-800 flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-orange-400 to-orange-600 flex items-center justify-center"><Clock size={20} className="text-white" /></div>
|
||||||
|
Attendance
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">{isDirector ? 'Campus-level attendance dashboard with trends' : 'Your personal attendance snapshot'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-orange-50 rounded-2xl border border-orange-200 p-4 flex items-center gap-3">
|
||||||
|
<Shield size={18} className="text-orange-600 flex-shrink-0" />
|
||||||
|
<p className="text-sm text-orange-700">Attendance data synced securely via ADP integration.
|
||||||
|
{records.length > 0 && <span className="ml-1 inline-flex items-center gap-1 text-emerald-600"><Database size={10} /> {records.length} records loaded</span>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-10"><Loader2 size={24} className="animate-spin text-orange-500" /></div>
|
||||||
|
) : !isDirector ? (
|
||||||
|
<div className="bg-white rounded-2xl border border-violet-100 shadow-sm p-5">
|
||||||
|
<h3 className="font-semibold text-gray-800 mb-4">Recent Attendance</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{personalRecords.map((day, i) => (
|
||||||
|
<div key={i} className="flex items-center justify-between p-3 rounded-xl bg-gray-50">
|
||||||
|
<span className="text-sm text-gray-700 font-medium">{day.date}</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{day.note && <span className="text-[10px] text-gray-400">{day.note}</span>}
|
||||||
|
<span className={`px-2.5 py-1 rounded-lg text-xs font-semibold ${statusColors[day.status]}`}>{day.status.charAt(0).toUpperCase() + day.status.slice(1)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 pt-4 border-t border-gray-100 grid grid-cols-3 gap-3 text-center">
|
||||||
|
<div className="bg-emerald-50 rounded-xl p-3"><p className="text-2xl font-bold text-emerald-600">{presentCount}</p><p className="text-[10px] text-gray-500">Present</p></div>
|
||||||
|
<div className="bg-amber-50 rounded-xl p-3"><p className="text-2xl font-bold text-amber-600">{lateCount}</p><p className="text-[10px] text-gray-500">Late</p></div>
|
||||||
|
<div className="bg-red-50 rounded-xl p-3"><p className="text-2xl font-bold text-red-600">{absentCount}</p><p className="text-[10px] text-gray-500">Absent</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-4 gap-4">
|
||||||
|
{[
|
||||||
|
{ label: 'Total Records', value: records.length.toString(), color: 'bg-blue-50 text-blue-600' },
|
||||||
|
{ label: 'Present', value: records.filter(r => r.status === 'present').length.toString(), color: 'bg-emerald-50 text-emerald-600' },
|
||||||
|
{ label: 'Late', value: records.filter(r => r.status === 'late').length.toString(), color: 'bg-amber-50 text-amber-600' },
|
||||||
|
{ label: 'Absent', value: records.filter(r => r.status === 'absent').length.toString(), color: 'bg-red-50 text-red-600' },
|
||||||
|
].map((stat, i) => (
|
||||||
|
<div key={i} className={`${stat.color} rounded-2xl p-4 text-center`}><p className="text-2xl font-bold">{stat.value}</p><p className="text-xs mt-1 opacity-70">{stat.label}</p></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{staffAttendance.length > 0 && (
|
||||||
|
<div className="bg-white rounded-2xl border border-violet-100 shadow-sm overflow-hidden">
|
||||||
|
<div className="p-4 border-b border-gray-100"><h3 className="font-semibold text-gray-800">Staff Attendance</h3></div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead><tr className="bg-gray-50"><th className="text-left p-3 font-medium text-gray-500">Staff</th><th className="text-center p-3 font-medium text-gray-500">Present</th><th className="text-center p-3 font-medium text-gray-500">Late</th><th className="text-center p-3 font-medium text-gray-500">Absent</th><th className="text-center p-3 font-medium text-gray-500">Trend</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{staffAttendance.map((staff, i) => (
|
||||||
|
<tr key={i} className="border-t border-gray-50 hover:bg-violet-50/30">
|
||||||
|
<td className="p-3 font-medium text-gray-700">{staff.name}</td>
|
||||||
|
<td className="p-3 text-center"><span className="bg-emerald-100 text-emerald-700 px-2 py-0.5 rounded-lg text-xs font-semibold">{staff.present}</span></td>
|
||||||
|
<td className="p-3 text-center"><span className={`px-2 py-0.5 rounded-lg text-xs font-semibold ${staff.late > 2 ? 'bg-amber-100 text-amber-700' : 'bg-gray-100 text-gray-500'}`}>{staff.late}</span></td>
|
||||||
|
<td className="p-3 text-center"><span className={`px-2 py-0.5 rounded-lg text-xs font-semibold ${staff.absent > 2 ? 'bg-red-100 text-red-700' : 'bg-gray-100 text-gray-500'}`}>{staff.absent}</span></td>
|
||||||
|
<td className="p-3 text-center">{staff.trend === 'up' ? <TrendingUp size={16} className="text-emerald-500 mx-auto" /> : <TrendingDown size={16} className="text-red-500 mx-auto" />}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== PARENT COMMUNICATION MODULE ====================
|
||||||
|
export const ParentCommModule: React.FC = () => {
|
||||||
|
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null);
|
||||||
|
const [messageText, setMessageText] = useState('');
|
||||||
|
const [recipientName, setRecipientName] = useState('');
|
||||||
|
const [sentMessages, setSentMessages] = useState<{ text: string; to: string; date: string }[]>([]);
|
||||||
|
const [showSuccess, setShowSuccess] = useState(false);
|
||||||
|
const [categoryFilter, setCategoryFilter] = useState('all');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => { loadMessages(); }, []);
|
||||||
|
|
||||||
|
const loadMessages = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const msgs = await fetchMessages('Ms. Rodriguez');
|
||||||
|
if (msgs.length > 0) setSentMessages(msgs);
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredTemplates = PARENT_TEMPLATES.filter(t => categoryFilter === 'all' || t.category === categoryFilter);
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
if (!messageText.trim() || !recipientName.trim()) return;
|
||||||
|
setSaving(true);
|
||||||
|
const success = await saveMessage({ senderName: 'Ms. Rodriguez', recipientName, messageText, category: selectedTemplate ? PARENT_TEMPLATES.find(t => t.id === selectedTemplate)?.category || 'general' : 'general' });
|
||||||
|
setSentMessages([{ text: messageText, to: recipientName, date: new Date().toLocaleString() }, ...sentMessages]);
|
||||||
|
setMessageText(''); setRecipientName('');
|
||||||
|
setShowSuccess(true); setTimeout(() => setShowSuccess(false), 3000);
|
||||||
|
setSaving(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const categoryColors: Record<string, string> = { behavior: 'bg-rose-100 text-rose-700', event: 'bg-amber-100 text-amber-700', progress: 'bg-emerald-100 text-emerald-700', general: 'bg-blue-100 text-blue-700' };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-800 flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-cyan-400 to-cyan-600 flex items-center justify-center"><MessageSquare size={20} className="text-white" /></div>
|
||||||
|
Parent Communication
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">FERPA-compliant messaging with pre-approved templates and communication logs</p>
|
||||||
|
</div>
|
||||||
|
{showSuccess && (
|
||||||
|
<div className="bg-emerald-50 border border-emerald-200 rounded-xl p-4 flex items-center gap-3">
|
||||||
|
<CheckCircle size={18} className="text-emerald-600" />
|
||||||
|
<p className="text-sm text-emerald-700 font-medium">Message sent and saved to database!</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<div className="bg-white rounded-2xl border border-violet-100 shadow-sm p-5">
|
||||||
|
<h3 className="font-semibold text-gray-800 mb-3">Pre-Approved Templates</h3>
|
||||||
|
<div className="flex gap-2 mb-3 flex-wrap">
|
||||||
|
{['all', 'behavior', 'event', 'progress', 'general'].map(cat => (
|
||||||
|
<button key={cat} onClick={() => setCategoryFilter(cat)} className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${categoryFilter === cat ? 'bg-cyan-500 text-white' : 'bg-gray-100 text-gray-500 hover:bg-gray-200'}`}>
|
||||||
|
{cat.charAt(0).toUpperCase() + cat.slice(1)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 max-h-80 overflow-y-auto">
|
||||||
|
{filteredTemplates.map(tmpl => (
|
||||||
|
<button key={tmpl.id} onClick={() => { setSelectedTemplate(tmpl.id); setMessageText(tmpl.template); }}
|
||||||
|
className={`w-full text-left p-3 rounded-xl border transition-all ${selectedTemplate === tmpl.id ? 'border-cyan-300 bg-cyan-50' : 'border-gray-100 bg-gray-50 hover:bg-cyan-50/50'}`}>
|
||||||
|
<div className="flex items-center gap-2 mb-1"><span className={`px-2 py-0.5 rounded text-[10px] font-semibold ${categoryColors[tmpl.category]}`}>{tmpl.category}</span></div>
|
||||||
|
<p className="text-xs text-gray-600 line-clamp-2">{tmpl.template}</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="bg-white rounded-2xl border border-violet-100 shadow-sm p-5">
|
||||||
|
<h3 className="font-semibold text-gray-800 mb-3">Compose Message</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<input type="text" value={recipientName} onChange={e => setRecipientName(e.target.value)} placeholder="Parent/Guardian name..." className="w-full px-4 py-2.5 border border-gray-200 rounded-xl text-sm focus:ring-2 focus:ring-cyan-300 outline-none" />
|
||||||
|
<textarea value={messageText} onChange={e => setMessageText(e.target.value)} placeholder="Type or select a template..." rows={5} className="w-full px-4 py-2.5 border border-gray-200 rounded-xl text-sm focus:ring-2 focus:ring-cyan-300 outline-none resize-none" />
|
||||||
|
<button onClick={handleSend} disabled={!messageText.trim() || !recipientName.trim() || saving}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-gradient-to-r from-cyan-500 to-blue-500 text-white rounded-xl font-medium text-sm hover:shadow-lg transition-all disabled:opacity-50">
|
||||||
|
{saving ? <Loader2 size={14} className="animate-spin" /> : <Send size={14} />} {saving ? 'Sending...' : 'Send Message'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{sentMessages.length > 0 && (
|
||||||
|
<div className="bg-white rounded-2xl border border-violet-100 shadow-sm p-5">
|
||||||
|
<h3 className="font-semibold text-gray-800 mb-3 flex items-center gap-2"><FileText size={16} className="text-gray-400" /> Communication Log <span className="text-[10px] text-emerald-600 flex items-center gap-1"><Database size={10} /> Persisted</span></h3>
|
||||||
|
<div className="space-y-2 max-h-48 overflow-y-auto">
|
||||||
|
{sentMessages.map((msg, i) => (
|
||||||
|
<div key={i} className="p-3 rounded-xl bg-gray-50 border border-gray-100">
|
||||||
|
<div className="flex justify-between items-center mb-1"><span className="text-xs font-medium text-gray-700">To: {msg.to}</span><span className="text-[10px] text-gray-400">{msg.date}</span></div>
|
||||||
|
<p className="text-xs text-gray-500 line-clamp-2">{msg.text}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== INTERNAL COMMUNICATION MODULE ====================
|
||||||
|
export const InternalCommModule: React.FC<{ userRole: UserRole }> = ({ userRole }) => {
|
||||||
|
const [acknowledged, setAcknowledged] = useState<Set<string>>(new Set());
|
||||||
|
const isDirector = userRole === 'director';
|
||||||
|
const [newEventTitle, setNewEventTitle] = useState('');
|
||||||
|
const [newEventDate, setNewEventDate] = useState('');
|
||||||
|
const [newEventType, setNewEventType] = useState('meeting');
|
||||||
|
const [allEvents, setAllEvents] = useState<any[]>(EVENTS);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => { loadEvents(); }, []);
|
||||||
|
|
||||||
|
const loadEvents = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const dbEvents = await fetchEvents();
|
||||||
|
if (dbEvents.length > 0) setAllEvents(dbEvents);
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredEvents = allEvents.filter(e => e.roles?.includes(userRole));
|
||||||
|
|
||||||
|
const toggleAck = (id: string) => { const next = new Set(acknowledged); if (next.has(id)) next.delete(id); else next.add(id); setAcknowledged(next); };
|
||||||
|
|
||||||
|
const addEvent = async () => {
|
||||||
|
if (!newEventTitle || !newEventDate) return;
|
||||||
|
const success = await createEvent({ title: newEventTitle, date: newEventDate, type: newEventType, roles: ['teacher', 'para', 'office', 'director'] });
|
||||||
|
if (success) await loadEvents();
|
||||||
|
else setAllEvents([{ id: Date.now().toString(), title: newEventTitle, date: newEventDate, type: newEventType, roles: ['teacher', 'para', 'office', 'director'] }, ...allEvents]);
|
||||||
|
setNewEventTitle(''); setNewEventDate('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const typeIcons: Record<string, React.ReactNode> = { meeting: <Users size={14} />, drill: <AlertTriangle size={14} />, event: <Calendar size={14} />, deadline: <Clock size={14} /> };
|
||||||
|
const typeColors: Record<string, string> = { meeting: 'bg-violet-100 text-violet-700 border-violet-200', drill: 'bg-red-100 text-red-700 border-red-200', event: 'bg-amber-100 text-amber-700 border-amber-200', deadline: 'bg-blue-100 text-blue-700 border-blue-200' };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-800 flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-rose-400 to-rose-600 flex items-center justify-center"><Bell size={20} className="text-white" /></div>
|
||||||
|
Internal Communication & Alerts
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">Smart notifications, event reminders, and acknowledgment tracking</p>
|
||||||
|
</div>
|
||||||
|
{isDirector && (
|
||||||
|
<div className="bg-white rounded-2xl border border-violet-100 shadow-sm p-5">
|
||||||
|
<h3 className="font-semibold text-gray-800 mb-3">Schedule New Alert</h3>
|
||||||
|
<div className="flex flex-col md:flex-row gap-3">
|
||||||
|
<input type="text" value={newEventTitle} onChange={e => setNewEventTitle(e.target.value)} placeholder="Event title..." className="flex-1 px-4 py-2.5 border border-gray-200 rounded-xl text-sm focus:ring-2 focus:ring-rose-300 outline-none" />
|
||||||
|
<input type="date" value={newEventDate} onChange={e => setNewEventDate(e.target.value)} className="px-4 py-2.5 border border-gray-200 rounded-xl text-sm focus:ring-2 focus:ring-rose-300 outline-none" />
|
||||||
|
<select value={newEventType} onChange={e => setNewEventType(e.target.value)} className="px-4 py-2.5 border border-gray-200 rounded-xl text-sm bg-white focus:ring-2 focus:ring-rose-300 outline-none">
|
||||||
|
<option value="meeting">Meeting</option><option value="drill">Drill</option><option value="event">Event</option><option value="deadline">Deadline</option>
|
||||||
|
</select>
|
||||||
|
<button onClick={addEvent} className="px-5 py-2.5 bg-gradient-to-r from-rose-500 to-pink-500 text-white rounded-xl font-medium text-sm hover:shadow-lg transition-all">Add Alert</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-10"><Loader2 size={24} className="animate-spin text-rose-500" /></div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{filteredEvents.map(event => (
|
||||||
|
<div key={event.id} className={`bg-white rounded-2xl border shadow-sm p-4 flex items-center justify-between ${typeColors[event.type] || 'border-gray-200'}`}>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`w-10 h-10 rounded-xl flex items-center justify-center ${event.type === 'meeting' ? 'bg-violet-200' : event.type === 'drill' ? 'bg-red-200' : event.type === 'event' ? 'bg-amber-200' : 'bg-blue-200'}`}>
|
||||||
|
{typeIcons[event.type]}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold text-sm text-gray-800">{event.title}</h4>
|
||||||
|
<p className="text-xs text-gray-500">{new Date(event.date).toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => toggleAck(event.id)} className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${acknowledged.has(event.id) ? 'bg-emerald-100 text-emerald-700' : 'bg-gray-100 text-gray-500 hover:bg-emerald-50'}`}>
|
||||||
|
{acknowledged.has(event.id) ? <Check size={12} /> : <CheckCircle size={12} />}
|
||||||
|
{acknowledged.has(event.id) ? 'Acknowledged' : 'Acknowledge'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== SAFETY PROTOCOLS MODULE ====================
|
||||||
|
export const SafetyProtocolsModule: React.FC = () => {
|
||||||
|
const [expandedProtocol, setExpandedProtocol] = useState<string | null>('fire');
|
||||||
|
const [acknowledged, setAcknowledged] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const protocols = [
|
||||||
|
{ id: 'fire', title: 'Fire Drill Procedures', icon: <AlertTriangle size={20} />, color: 'from-red-400 to-orange-500',
|
||||||
|
steps: ['Hear alarm — stop all activities immediately', 'Grab class roster and emergency kit', 'Line students up at classroom door', 'Pre-teach Yellow Zone strategies for anxious students', 'Walk (do not run) to designated assembly point', 'Take attendance at assembly point', 'Wait for all-clear signal from administration', 'Return to classroom in orderly fashion'],
|
||||||
|
autismConsiderations: ['Use visual "fire drill" card to prepare students in advance', 'Provide noise-canceling headphones for sound-sensitive students', 'Assign a buddy for students who may elope', 'Use "Stop" and "Wait" signs during the drill', 'Allow extra processing time at each step'] },
|
||||||
|
{ id: 'lockdown', title: 'Lockdown Drill Steps', icon: <Shield size={20} />, color: 'from-blue-500 to-indigo-600',
|
||||||
|
steps: ['Hear announcement — initiate lockdown immediately', 'Lock classroom door and cover window', 'Move students away from doors and windows', 'Turn off lights and silence all devices', 'Take attendance silently', 'Maintain silence until all-clear', 'Do NOT open door for anyone except verified admin', 'Resume normal activities after debriefing'],
|
||||||
|
autismConsiderations: ['Pre-teach lockdown with social story and visuals', 'Have comfort items accessible (weighted blanket, fidget)', 'Use "Calm" and "Wait" signs throughout', 'Designate a quiet corner for students in distress', 'Practice in low-stakes settings first'] },
|
||||||
|
{ id: 'medical', title: 'Medical Emergency Response', icon: <Heart size={20} />, color: 'from-emerald-400 to-teal-500',
|
||||||
|
steps: ['Assess the situation — is the student conscious and breathing?', 'Call for help immediately (office, nurse, 911 if needed)', 'Do NOT move the student unless in immediate danger', 'Clear the area of other students', 'Stay with the student and provide reassurance', 'Document everything after the event'],
|
||||||
|
autismConsiderations: ['Some students may not verbally report pain', 'Watch for behavioral changes as pain indicators', 'Use visual pain scale if available', 'Communicate clearly and simply'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-800 flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-red-400 to-red-600 flex items-center justify-center"><AlertTriangle size={20} className="text-white" /></div>
|
||||||
|
Safety Protocols
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">Clear, visual safety standards — always accessible, always current</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{protocols.map(protocol => (
|
||||||
|
<div key={protocol.id} className="bg-white rounded-2xl border border-violet-100 shadow-sm overflow-hidden">
|
||||||
|
<button onClick={() => setExpandedProtocol(expandedProtocol === protocol.id ? null : protocol.id)} className="w-full px-5 py-4 flex items-center justify-between hover:bg-gray-50 transition-colors">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`w-10 h-10 rounded-xl bg-gradient-to-br ${protocol.color} flex items-center justify-center text-white`}>{protocol.icon}</div>
|
||||||
|
<div className="text-left"><h3 className="font-semibold text-gray-800">{protocol.title}</h3><p className="text-xs text-gray-400">{protocol.steps.length} steps · {protocol.autismConsiderations.length} autism considerations</p></div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{acknowledged.has(protocol.id) && <span className="px-2.5 py-1 bg-emerald-100 text-emerald-700 rounded-lg text-[10px] font-semibold">ACKNOWLEDGED</span>}
|
||||||
|
{expandedProtocol === protocol.id ? <ChevronUp size={18} className="text-gray-400" /> : <ChevronDown size={18} className="text-gray-400" />}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{expandedProtocol === protocol.id && (
|
||||||
|
<div className="px-5 pb-5 space-y-4">
|
||||||
|
<div><h4 className="font-semibold text-sm text-gray-700 mb-3">Procedure Steps</h4>
|
||||||
|
<div className="space-y-2">{protocol.steps.map((step, i) => (
|
||||||
|
<div key={i} className="flex items-start gap-3 p-3 rounded-xl bg-gray-50"><span className="w-7 h-7 rounded-full bg-gradient-to-br from-gray-200 to-gray-300 flex items-center justify-center text-xs font-bold text-gray-600 flex-shrink-0">{i + 1}</span><p className="text-sm text-gray-700">{step}</p></div>
|
||||||
|
))}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-violet-50 rounded-xl p-4"><h4 className="font-semibold text-sm text-violet-700 mb-3">Autism-Specific Considerations</h4>
|
||||||
|
<div className="space-y-2">{protocol.autismConsiderations.map((tip, i) => (
|
||||||
|
<div key={i} className="flex items-start gap-2"><CheckCircle size={14} className="text-violet-500 mt-0.5 flex-shrink-0" /><p className="text-xs text-violet-700">{tip}</p></div>
|
||||||
|
))}</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => { const next = new Set(acknowledged); if (next.has(protocol.id)) next.delete(protocol.id); else next.add(protocol.id); setAcknowledged(next); }}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2.5 rounded-xl text-sm font-medium transition-all ${acknowledged.has(protocol.id) ? 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200' : 'bg-violet-100 text-violet-700 hover:bg-violet-200'}`}>
|
||||||
|
<CheckCircle size={14} /> {acknowledged.has(protocol.id) ? 'Acknowledged' : 'I Have Read and Understand This Protocol'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
275
src/components/frameworks/PersonalityDirectory.tsx
Normal file
275
src/components/frameworks/PersonalityDirectory.tsx
Normal file
@ -0,0 +1,275 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Users, MessageSquare, ChevronDown, ChevronUp, Search,
|
||||||
|
Briefcase, CheckCircle, Filter, BookOpen, ArrowLeft
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { PERSONALITY_TYPES, PersonalityType } from '@/lib/personalityTypes';
|
||||||
|
|
||||||
|
interface PersonalityDirectoryProps {
|
||||||
|
onBackToQuiz: () => void;
|
||||||
|
highlightType?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type FilterGroup = 'all' | 'analysts' | 'diplomats' | 'sentinels' | 'explorers';
|
||||||
|
|
||||||
|
const PersonalityDirectory: React.FC<PersonalityDirectoryProps> = ({ onBackToQuiz, highlightType }) => {
|
||||||
|
const [expandedType, setExpandedType] = useState<string | null>(highlightType || null);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [filterGroup, setFilterGroup] = useState<FilterGroup>('all');
|
||||||
|
const [expandedSection, setExpandedSection] = useState<Record<string, 'overview' | 'relationships' | 'language'>>({});
|
||||||
|
|
||||||
|
const getGroup = (code: string): FilterGroup => {
|
||||||
|
const second = code[1];
|
||||||
|
const third = code[2];
|
||||||
|
if (second === 'N' && third === 'T') return 'analysts';
|
||||||
|
if (second === 'N' && third === 'F') return 'diplomats';
|
||||||
|
if (second === 'S' && third === 'J') return 'sentinels';
|
||||||
|
if (second === 'S' && third === 'P') return 'explorers';
|
||||||
|
return 'all';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getGroupLabel = (group: FilterGroup) => {
|
||||||
|
switch (group) {
|
||||||
|
case 'analysts': return 'Analysts (NT)';
|
||||||
|
case 'diplomats': return 'Diplomats (NF)';
|
||||||
|
case 'sentinels': return 'Sentinels (SJ)';
|
||||||
|
case 'explorers': return 'Explorers (SP)';
|
||||||
|
default: return 'All Types';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getGroupDescription = (group: FilterGroup) => {
|
||||||
|
switch (group) {
|
||||||
|
case 'analysts': return 'Strategic, logical thinkers who value competence and innovation';
|
||||||
|
case 'diplomats': return 'Empathetic, values-driven individuals who seek meaning and harmony';
|
||||||
|
case 'sentinels': return 'Reliable, practical organizers who value stability and tradition';
|
||||||
|
case 'explorers': return 'Adaptable, hands-on doers who thrive on action and spontaneity';
|
||||||
|
default: return 'All 16 personality types and their workplace profiles';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredTypes = PERSONALITY_TYPES.filter((type) => {
|
||||||
|
const matchesSearch =
|
||||||
|
searchQuery === '' ||
|
||||||
|
type.code.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
type.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
type.nickname.toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
|
|
||||||
|
const matchesGroup = filterGroup === 'all' || getGroup(type.code) === filterGroup;
|
||||||
|
|
||||||
|
return matchesSearch && matchesGroup;
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleExpand = (code: string) => {
|
||||||
|
setExpandedType(expandedType === code ? null : code);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getActiveSection = (code: string) => expandedSection[code] || 'overview';
|
||||||
|
const setActiveSection = (code: string, section: 'overview' | 'relationships' | 'language') => {
|
||||||
|
setExpandedSection((prev) => ({ ...prev, [code]: section }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const groups: FilterGroup[] = ['all', 'analysts', 'diplomats', 'sentinels', 'explorers'];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={onBackToQuiz}
|
||||||
|
className="flex items-center gap-1.5 text-sm text-violet-400 hover:text-violet-300 transition-colors mb-2"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={14} /> Back to Quiz
|
||||||
|
</button>
|
||||||
|
<h3 className="text-lg font-bold text-white flex items-center gap-2">
|
||||||
|
<BookOpen size={18} className="text-violet-400" />
|
||||||
|
Personality Type Directory
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5">{getGroupDescription(filterGroup)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search & Filters */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search by type code, name, or nickname..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="w-full pl-9 pr-4 py-2.5 bg-slate-800/60 border border-slate-700/40 rounded-xl text-sm text-white placeholder-slate-500 focus:outline-none focus:border-violet-500/50 focus:ring-1 focus:ring-violet-500/20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1.5 flex-wrap">
|
||||||
|
{groups.map((group) => (
|
||||||
|
<button
|
||||||
|
key={group}
|
||||||
|
onClick={() => setFilterGroup(group)}
|
||||||
|
className={`px-3 py-2 rounded-lg text-xs font-medium transition-all whitespace-nowrap ${
|
||||||
|
filterGroup === group
|
||||||
|
? 'bg-violet-500/20 text-violet-400 border border-violet-500/30'
|
||||||
|
: 'bg-slate-800/40 text-slate-400 border border-slate-700/30 hover:bg-slate-700/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{group === 'all' ? (
|
||||||
|
<span className="flex items-center gap-1"><Filter size={12} /> All</span>
|
||||||
|
) : (
|
||||||
|
getGroupLabel(group)
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results count */}
|
||||||
|
<div className="text-xs text-slate-500">
|
||||||
|
Showing {filteredTypes.length} of {PERSONALITY_TYPES.length} types
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Type Cards */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{filteredTypes.map((type) => {
|
||||||
|
const isExpanded = expandedType === type.code;
|
||||||
|
const isHighlighted = highlightType === type.code;
|
||||||
|
const section = getActiveSection(type.code);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={type.code}
|
||||||
|
className={`bg-slate-800/40 backdrop-blur-sm rounded-2xl border overflow-hidden transition-all ${
|
||||||
|
isHighlighted
|
||||||
|
? 'border-violet-500/40 ring-1 ring-violet-500/20'
|
||||||
|
: 'border-slate-700/40'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Card Header - Always visible */}
|
||||||
|
<button
|
||||||
|
onClick={() => toggleExpand(type.code)}
|
||||||
|
className="w-full p-4 flex items-center gap-4 text-left hover:bg-slate-700/20 transition-all"
|
||||||
|
>
|
||||||
|
<div className={`w-14 h-14 rounded-xl bg-gradient-to-br ${type.color} flex items-center justify-center shadow-lg flex-shrink-0`}>
|
||||||
|
<span className="text-lg font-black text-white tracking-wider">{type.code}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h4 className="font-bold text-white text-sm">{type.name}</h4>
|
||||||
|
{isHighlighted && (
|
||||||
|
<span className="text-[10px] font-semibold bg-violet-500/20 text-violet-400 border border-violet-500/30 px-2 py-0.5 rounded-full">
|
||||||
|
Your Type
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400">{type.nickname}</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5 mt-1.5">
|
||||||
|
{type.strengths.slice(0, 3).map((s, i) => (
|
||||||
|
<span key={i} className="text-[10px] bg-slate-700/50 text-slate-400 px-2 py-0.5 rounded-md">
|
||||||
|
{s}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-shrink-0 text-slate-500">
|
||||||
|
{isExpanded ? <ChevronUp size={18} /> : <ChevronDown size={18} />}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Expanded Content */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="border-t border-slate-700/40">
|
||||||
|
{/* Section tabs */}
|
||||||
|
<div className="flex border-b border-slate-700/30">
|
||||||
|
{[
|
||||||
|
{ id: 'overview' as const, label: 'Overview', icon: <Briefcase size={12} /> },
|
||||||
|
{ id: 'relationships' as const, label: 'Work Relationships', icon: <Users size={12} /> },
|
||||||
|
{ id: 'language' as const, label: 'Workplace Language', icon: <MessageSquare size={12} /> },
|
||||||
|
].map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setActiveSection(type.code, tab.id)}
|
||||||
|
className={`flex-1 py-2.5 px-3 text-[11px] font-medium flex items-center justify-center gap-1 transition-all border-b-2 ${
|
||||||
|
section === tab.id
|
||||||
|
? 'text-violet-400 border-violet-400 bg-violet-500/5'
|
||||||
|
: 'text-slate-500 border-transparent hover:text-slate-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.icon} {tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-5">
|
||||||
|
{section === 'overview' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-slate-300 leading-relaxed">{type.description}</p>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className={`${type.bgColor} ${type.borderColor} border rounded-xl p-3`}>
|
||||||
|
<h5 className="text-xs font-semibold text-white mb-2 flex items-center gap-1.5">
|
||||||
|
<CheckCircle size={12} className="text-emerald-400" /> Strengths
|
||||||
|
</h5>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{type.strengths.map((s, i) => (
|
||||||
|
<div key={i} className="text-[11px] text-slate-300 flex items-center gap-1.5">
|
||||||
|
<div className="w-1 h-1 rounded-full bg-slate-500" />
|
||||||
|
{s}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-700/30 border border-slate-600/30 rounded-xl p-3">
|
||||||
|
<h5 className="text-xs font-semibold text-white mb-2 flex items-center gap-1.5">
|
||||||
|
<Briefcase size={12} className="text-blue-400" /> Ideal Environment
|
||||||
|
</h5>
|
||||||
|
<p className="text-[11px] text-slate-400 leading-relaxed">{type.idealWorkEnvironment}</p>
|
||||||
|
<h5 className="text-xs font-semibold text-white mt-3 mb-1 flex items-center gap-1.5">
|
||||||
|
<MessageSquare size={12} className="text-amber-400" /> Style
|
||||||
|
</h5>
|
||||||
|
<p className="text-[11px] text-slate-400">{type.communicationStyle}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{section === 'relationships' && (
|
||||||
|
<div className="bg-gradient-to-r from-blue-500/5 to-cyan-500/5 rounded-xl p-4 border border-blue-500/15">
|
||||||
|
<h5 className="font-semibold text-blue-400 text-sm mb-3 flex items-center gap-2">
|
||||||
|
<Users size={14} /> Professional Relationships
|
||||||
|
</h5>
|
||||||
|
<p className="text-sm text-slate-300 leading-relaxed">{type.workRelationships}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{section === 'language' && (
|
||||||
|
<div className="bg-gradient-to-r from-emerald-500/5 to-teal-500/5 rounded-xl p-4 border border-emerald-500/15">
|
||||||
|
<h5 className="font-semibold text-emerald-400 text-sm mb-3 flex items-center gap-2">
|
||||||
|
<MessageSquare size={14} /> Workplace Language
|
||||||
|
</h5>
|
||||||
|
<p className="text-sm text-slate-300 leading-relaxed">{type.workplaceLanguage}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{filteredTypes.length === 0 && (
|
||||||
|
<div className="text-center py-12 text-slate-500">
|
||||||
|
<Search size={32} className="mx-auto mb-3 opacity-50" />
|
||||||
|
<p className="text-sm">No personality types match your search.</p>
|
||||||
|
<button
|
||||||
|
onClick={() => { setSearchQuery(''); setFilterGroup('all'); }}
|
||||||
|
className="mt-2 text-xs text-violet-400 hover:text-violet-300"
|
||||||
|
>
|
||||||
|
Clear filters
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PersonalityDirectory;
|
||||||
551
src/components/frameworks/PersonalityQuiz.tsx
Normal file
551
src/components/frameworks/PersonalityQuiz.tsx
Normal file
@ -0,0 +1,551 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Brain, ArrowRight, ArrowLeft, RotateCcw, CheckCircle,
|
||||||
|
Sparkles, Target, Users, MessageSquare, Briefcase, BookOpen,
|
||||||
|
Save, Loader2, Clock, RefreshCw
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
PERSONALITY_QUIZ_QUESTIONS,
|
||||||
|
calculateMBTI,
|
||||||
|
getPersonalityType,
|
||||||
|
PersonalityType,
|
||||||
|
} from '@/lib/personalityTypes';
|
||||||
|
|
||||||
|
|
||||||
|
interface PersonalityQuizProps {
|
||||||
|
onViewDirectory: () => void;
|
||||||
|
onResult?: (code: string, answers: Record<number, string>) => void;
|
||||||
|
savedType?: string | null;
|
||||||
|
savedAnswers?: Record<number, string> | null;
|
||||||
|
savedDate?: string | null;
|
||||||
|
isSaving?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PersonalityQuiz: React.FC<PersonalityQuizProps> = ({
|
||||||
|
onViewDirectory,
|
||||||
|
onResult,
|
||||||
|
savedType,
|
||||||
|
savedAnswers,
|
||||||
|
savedDate,
|
||||||
|
isSaving = false,
|
||||||
|
}) => {
|
||||||
|
|
||||||
|
const [started, setStarted] = useState(false);
|
||||||
|
const [currentQ, setCurrentQ] = useState(0);
|
||||||
|
const [answers, setAnswers] = useState<Record<number, string>>({});
|
||||||
|
const [result, setResult] = useState<PersonalityType | null>(null);
|
||||||
|
const [showResult, setShowResult] = useState(false);
|
||||||
|
const [selectedAnswer, setSelectedAnswer] = useState<string | null>(null);
|
||||||
|
const [activeResultTab, setActiveResultTab] = useState<'overview' | 'relationships' | 'language'>('overview');
|
||||||
|
const [showSavedResult, setShowSavedResult] = useState(false);
|
||||||
|
|
||||||
|
// On mount, if there's a saved type, show it
|
||||||
|
useEffect(() => {
|
||||||
|
if (savedType) {
|
||||||
|
const type = getPersonalityType(savedType);
|
||||||
|
if (type) {
|
||||||
|
setResult(type);
|
||||||
|
setShowSavedResult(true);
|
||||||
|
setShowResult(true);
|
||||||
|
if (savedAnswers) {
|
||||||
|
setAnswers(savedAnswers);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [savedType, savedAnswers]);
|
||||||
|
|
||||||
|
const questions = PERSONALITY_QUIZ_QUESTIONS;
|
||||||
|
const totalQuestions = questions.length;
|
||||||
|
|
||||||
|
const handleSelectAnswer = (value: string) => {
|
||||||
|
setSelectedAnswer(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
if (!selectedAnswer) return;
|
||||||
|
const newAnswers = { ...answers, [questions[currentQ].id]: selectedAnswer };
|
||||||
|
setAnswers(newAnswers);
|
||||||
|
setSelectedAnswer(null);
|
||||||
|
|
||||||
|
if (currentQ < totalQuestions - 1) {
|
||||||
|
setCurrentQ((c) => c + 1);
|
||||||
|
} else {
|
||||||
|
const code = calculateMBTI(newAnswers);
|
||||||
|
const type = getPersonalityType(code);
|
||||||
|
setResult(type || null);
|
||||||
|
setShowResult(true);
|
||||||
|
setShowSavedResult(false);
|
||||||
|
if (onResult) onResult(code, newAnswers);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
if (currentQ > 0) {
|
||||||
|
setCurrentQ((c) => c - 1);
|
||||||
|
const prevQ = questions[currentQ - 1];
|
||||||
|
setSelectedAnswer(answers[prevQ.id] || null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetQuiz = () => {
|
||||||
|
setStarted(true);
|
||||||
|
setCurrentQ(0);
|
||||||
|
setAnswers({});
|
||||||
|
setResult(null);
|
||||||
|
setShowResult(false);
|
||||||
|
setSelectedAnswer(null);
|
||||||
|
setActiveResultTab('overview');
|
||||||
|
setShowSavedResult(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDimensionLabel = (dim: string) => {
|
||||||
|
switch (dim) {
|
||||||
|
case 'EI': return 'Energy Source';
|
||||||
|
case 'SN': return 'Information Style';
|
||||||
|
case 'TF': return 'Decision Making';
|
||||||
|
case 'JP': return 'Work Structure';
|
||||||
|
default: return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDimensionIcon = (dim: string) => {
|
||||||
|
switch (dim) {
|
||||||
|
case 'EI': return <Sparkles size={14} />;
|
||||||
|
case 'SN': return <BookOpen size={14} />;
|
||||||
|
case 'TF': return <Brain size={14} />;
|
||||||
|
case 'JP': return <Target size={14} />;
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatSavedDate = (dateStr: string) => {
|
||||||
|
try {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||||
|
} catch {
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Intro screen (no saved result)
|
||||||
|
if (!started && !showResult) {
|
||||||
|
return (
|
||||||
|
<div className="bg-slate-800/40 backdrop-blur-sm rounded-2xl border border-slate-700/40 overflow-hidden">
|
||||||
|
<div className="bg-gradient-to-r from-violet-600/20 to-indigo-600/20 border-b border-violet-500/20 p-6 text-center">
|
||||||
|
<div className="w-20 h-20 rounded-2xl bg-gradient-to-br from-violet-500 to-indigo-600 flex items-center justify-center mx-auto mb-4 shadow-xl shadow-violet-500/25">
|
||||||
|
<Brain size={36} className="text-white" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-bold text-white">Workplace Personality Assessment</h3>
|
||||||
|
<p className="text-sm text-slate-400 mt-2 max-w-lg mx-auto">
|
||||||
|
Discover your MBTI personality type and learn how it shapes your professional relationships,
|
||||||
|
communication style, and workplace language.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="grid grid-cols-2 gap-3 mb-6">
|
||||||
|
{[
|
||||||
|
{ icon: <Target size={18} />, label: '12 Questions', desc: 'Thoughtfully designed for educators', color: 'text-violet-400 bg-violet-500/10 border-violet-500/20' },
|
||||||
|
{ icon: <Users size={18} />, label: 'Work Relationships', desc: 'How you connect with colleagues', color: 'text-blue-400 bg-blue-500/10 border-blue-500/20' },
|
||||||
|
{ icon: <MessageSquare size={18} />, label: 'Workplace Language', desc: 'Your communication patterns', color: 'text-emerald-400 bg-emerald-500/10 border-emerald-500/20' },
|
||||||
|
{ icon: <Briefcase size={18} />, label: 'Professional Growth', desc: 'Leverage your natural strengths', color: 'text-amber-400 bg-amber-500/10 border-amber-500/20' },
|
||||||
|
].map((item, i) => (
|
||||||
|
<div key={i} className={`${item.color} border rounded-xl p-4`}>
|
||||||
|
<div className="mb-2">{item.icon}</div>
|
||||||
|
<h4 className="font-semibold text-sm">{item.label}</h4>
|
||||||
|
<p className="text-[10px] mt-1 opacity-70">{item.desc}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-violet-500/10 border border-violet-500/20 rounded-xl p-4 mb-6">
|
||||||
|
<h4 className="font-semibold text-violet-400 text-sm mb-1">How it works</h4>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
Answer 12 scenario-based questions about how you naturally respond in workplace situations.
|
||||||
|
There are no right or wrong answers — each choice reveals a different aspect of your personality.
|
||||||
|
Your results are saved to your profile and persist across sessions.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setStarted(true)}
|
||||||
|
className="flex-1 py-3 bg-gradient-to-r from-violet-500 to-indigo-600 text-white rounded-xl font-semibold hover:shadow-lg hover:shadow-violet-500/25 transition-all flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
Start Assessment <ArrowRight size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onViewDirectory}
|
||||||
|
className="px-5 py-3 bg-slate-700/50 text-slate-300 rounded-xl font-medium hover:bg-slate-700/70 transition-all border border-slate-600/30 text-sm"
|
||||||
|
>
|
||||||
|
View All Types
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Results screen (either saved or fresh)
|
||||||
|
if (showResult && result) {
|
||||||
|
return (
|
||||||
|
<div className="bg-slate-800/40 backdrop-blur-sm rounded-2xl border border-slate-700/40 overflow-hidden">
|
||||||
|
{/* Result Header */}
|
||||||
|
<div className={`bg-gradient-to-r ${result.color} p-6 text-center relative overflow-hidden`}>
|
||||||
|
<div className="absolute inset-0 bg-black/30" />
|
||||||
|
<div className="relative z-10">
|
||||||
|
{/* Saved indicator */}
|
||||||
|
{showSavedResult && (
|
||||||
|
<div className="inline-flex items-center gap-1.5 bg-white/15 backdrop-blur-sm rounded-full px-3 py-1 mb-2">
|
||||||
|
<CheckCircle size={12} className="text-emerald-300" />
|
||||||
|
<span className="text-[10px] font-medium text-white/90">
|
||||||
|
Saved Result {savedDate ? `· ${formatSavedDate(savedDate)}` : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isSaving && (
|
||||||
|
<div className="inline-flex items-center gap-1.5 bg-white/15 backdrop-blur-sm rounded-full px-3 py-1 mb-2">
|
||||||
|
<Loader2 size={12} className="text-white animate-spin" />
|
||||||
|
<span className="text-[10px] font-medium text-white/90">Saving to your profile...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!showSavedResult && !isSaving && (
|
||||||
|
<div className="inline-flex items-center gap-1.5 bg-emerald-500/30 backdrop-blur-sm rounded-full px-3 py-1 mb-2">
|
||||||
|
<Save size={12} className="text-emerald-300" />
|
||||||
|
<span className="text-[10px] font-medium text-white/90">Result saved to your profile</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="inline-flex items-center gap-2 bg-white/20 backdrop-blur-sm rounded-full px-4 py-1.5 mb-3">
|
||||||
|
<Sparkles size={14} className="text-white" />
|
||||||
|
<span className="text-xs font-semibold text-white">Your Personality Type</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-4xl font-black text-white tracking-wider mb-1">{result.code}</h2>
|
||||||
|
<h3 className="text-lg font-semibold text-white/90">{result.name}</h3>
|
||||||
|
<p className="text-sm text-white/70 mt-1">{result.nickname}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Result Tabs */}
|
||||||
|
<div className="border-b border-slate-700/40">
|
||||||
|
<div className="flex">
|
||||||
|
{[
|
||||||
|
{ id: 'overview' as const, label: 'Overview', icon: <Target size={14} /> },
|
||||||
|
{ id: 'relationships' as const, label: 'Work Relationships', icon: <Users size={14} /> },
|
||||||
|
{ id: 'language' as const, label: 'Workplace Language', icon: <MessageSquare size={14} /> },
|
||||||
|
].map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setActiveResultTab(tab.id)}
|
||||||
|
className={`flex-1 py-3 px-4 text-xs font-medium flex items-center justify-center gap-1.5 transition-all border-b-2 ${
|
||||||
|
activeResultTab === tab.id
|
||||||
|
? 'text-violet-400 border-violet-400 bg-violet-500/5'
|
||||||
|
: 'text-slate-500 border-transparent hover:text-slate-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.icon} {tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6">
|
||||||
|
{activeResultTab === 'overview' && (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-slate-300 leading-relaxed">{result.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-slate-700/30 rounded-xl p-4 border border-slate-600/30">
|
||||||
|
<h4 className="font-semibold text-white text-sm mb-3 flex items-center gap-2">
|
||||||
|
<CheckCircle size={14} className="text-emerald-400" /> Core Strengths
|
||||||
|
</h4>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{result.strengths.map((s, i) => (
|
||||||
|
<span key={i} className={`${result.bgColor} ${result.borderColor} border text-xs font-medium px-3 py-1.5 rounded-lg text-slate-200`}>
|
||||||
|
{s}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-slate-700/30 rounded-xl p-4 border border-slate-600/30">
|
||||||
|
<h4 className="font-semibold text-white text-sm mb-2 flex items-center gap-2">
|
||||||
|
<Briefcase size={14} className="text-blue-400" /> Ideal Work Environment
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-slate-400">{result.idealWorkEnvironment}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-slate-700/30 rounded-xl p-4 border border-slate-600/30">
|
||||||
|
<h4 className="font-semibold text-white text-sm mb-2 flex items-center gap-2">
|
||||||
|
<MessageSquare size={14} className="text-amber-400" /> Communication Style
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-slate-400">{result.communicationStyle}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* MBTI Breakdown */}
|
||||||
|
<div className="bg-slate-700/30 rounded-xl p-4 border border-slate-600/30">
|
||||||
|
<h4 className="font-semibold text-white text-sm mb-3">Your Type Breakdown</h4>
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{result.code.split('').map((letter, i) => {
|
||||||
|
const labels = ['Energy', 'Info', 'Decisions', 'Structure'];
|
||||||
|
const fullLabels = [
|
||||||
|
letter === 'E' ? 'Extraversion' : 'Introversion',
|
||||||
|
letter === 'S' ? 'Sensing' : 'Intuition',
|
||||||
|
letter === 'T' ? 'Thinking' : 'Feeling',
|
||||||
|
letter === 'J' ? 'Judging' : 'Perceiving',
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div key={i} className={`${result.bgColor} ${result.borderColor} border rounded-xl p-3 text-center`}>
|
||||||
|
<div className="text-2xl font-black text-white">{letter}</div>
|
||||||
|
<div className="text-[10px] text-slate-400 mt-1">{labels[i]}</div>
|
||||||
|
<div className="text-[10px] font-medium text-slate-300">{fullLabels[i]}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeResultTab === 'relationships' && (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="bg-gradient-to-r from-blue-500/10 to-cyan-500/10 rounded-xl p-5 border border-blue-500/20">
|
||||||
|
<h4 className="font-bold text-blue-400 mb-3 flex items-center gap-2">
|
||||||
|
<Users size={16} /> How You Build Professional Relationships
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-slate-300 leading-relaxed">{result.workRelationships}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-slate-700/30 rounded-xl p-4 border border-slate-600/30">
|
||||||
|
<h4 className="font-semibold text-white text-sm mb-3">Tips for Working with Others</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[
|
||||||
|
{ type: 'Analysts (NT types)', tip: result.code.includes('T') ? 'You naturally connect through shared logic — remember to also acknowledge emotions.' : 'Bridge the gap by framing your ideas with logical reasoning alongside your values.' },
|
||||||
|
{ type: 'Diplomats (NF types)', tip: result.code.includes('F') ? 'You share a values-driven approach — collaborate on vision and purpose.' : 'Show appreciation for their emotional insights and make space for personal connection.' },
|
||||||
|
{ type: 'Sentinels (SJ types)', tip: result.code.includes('J') ? 'You share a love of structure — partner on organizational projects.' : 'Respect their need for procedures and provide clear timelines when collaborating.' },
|
||||||
|
{ type: 'Explorers (SP types)', tip: result.code.includes('P') ? 'You share adaptability — team up for creative, hands-on projects.' : 'Appreciate their spontaneity and give them room to approach tasks their own way.' },
|
||||||
|
].map((item, i) => (
|
||||||
|
<div key={i} className="flex items-start gap-3 p-3 bg-slate-700/20 rounded-lg">
|
||||||
|
<div className={`w-8 h-8 rounded-lg ${result.bgColor} ${result.borderColor} border flex items-center justify-center flex-shrink-0`}>
|
||||||
|
<Users size={14} className="text-slate-300" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-xs font-semibold text-white">{item.type}</span>
|
||||||
|
<p className="text-[11px] text-slate-400 mt-0.5">{item.tip}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeResultTab === 'language' && (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="bg-gradient-to-r from-emerald-500/10 to-teal-500/10 rounded-xl p-5 border border-emerald-500/20">
|
||||||
|
<h4 className="font-bold text-emerald-400 mb-3 flex items-center gap-2">
|
||||||
|
<MessageSquare size={16} /> Your Workplace Language Style
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-slate-300 leading-relaxed">{result.workplaceLanguage}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-slate-700/30 rounded-xl p-4 border border-slate-600/30">
|
||||||
|
<h4 className="font-semibold text-white text-sm mb-3">Communication Strengths</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(() => {
|
||||||
|
const strengths = [];
|
||||||
|
if (result.code[0] === 'E') {
|
||||||
|
strengths.push('Engaging others in open dialogue and brainstorming');
|
||||||
|
strengths.push('Energizing team meetings with your verbal contributions');
|
||||||
|
} else {
|
||||||
|
strengths.push('Providing thoughtful, well-considered responses');
|
||||||
|
strengths.push('Creating space for deeper, one-on-one conversations');
|
||||||
|
}
|
||||||
|
if (result.code[2] === 'T') {
|
||||||
|
strengths.push('Delivering clear, logical arguments and analysis');
|
||||||
|
} else {
|
||||||
|
strengths.push('Reading emotional cues and responding with empathy');
|
||||||
|
}
|
||||||
|
if (result.code[3] === 'J') {
|
||||||
|
strengths.push('Keeping discussions organized and action-oriented');
|
||||||
|
} else {
|
||||||
|
strengths.push('Adapting your message flexibly to the situation');
|
||||||
|
}
|
||||||
|
return strengths.map((s, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2 p-2 bg-emerald-500/5 border border-emerald-500/10 rounded-lg">
|
||||||
|
<CheckCircle size={14} className="text-emerald-400 flex-shrink-0" />
|
||||||
|
<span className="text-xs text-slate-300">{s}</span>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-amber-500/10 border border-amber-500/20 rounded-xl p-4">
|
||||||
|
<h4 className="font-semibold text-amber-400 text-sm mb-2">Growth Area</h4>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
{result.code[0] === 'E'
|
||||||
|
? 'Practice active listening — pause before responding and ask clarifying questions to ensure you fully understand before sharing your perspective.'
|
||||||
|
: 'Practice sharing your ideas earlier in discussions — your insights are valuable and the team benefits when you speak up sooner.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-3 mt-6 pt-4 border-t border-slate-700/40">
|
||||||
|
<button
|
||||||
|
onClick={resetQuiz}
|
||||||
|
className="flex items-center gap-2 px-5 py-2.5 bg-violet-500/15 text-violet-400 rounded-xl font-medium text-sm hover:bg-violet-500/25 border border-violet-500/20 transition-all"
|
||||||
|
>
|
||||||
|
<RefreshCw size={14} /> {showSavedResult ? 'Retake Quiz' : 'Take Again'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onViewDirectory}
|
||||||
|
className="flex items-center gap-2 px-5 py-2.5 bg-slate-700/50 text-slate-300 rounded-xl font-medium text-sm hover:bg-slate-700/70 border border-slate-600/30 transition-all"
|
||||||
|
>
|
||||||
|
<BookOpen size={14} /> View All 16 Types
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quiz in progress
|
||||||
|
const question = questions[currentQ];
|
||||||
|
const progress = ((currentQ + 1) / totalQuestions) * 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-slate-800/40 backdrop-blur-sm rounded-2xl border border-slate-700/40 overflow-hidden">
|
||||||
|
{/* Progress Header */}
|
||||||
|
<div className="p-4 border-b border-slate-700/40">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-medium text-violet-400 bg-violet-500/10 border border-violet-500/20 px-2.5 py-1 rounded-lg">
|
||||||
|
{getDimensionIcon(question.dimension)} {getDimensionLabel(question.dimension)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-slate-500">
|
||||||
|
Question {currentQ + 1} of {totalQuestions}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-slate-700/50 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-gradient-to-r from-violet-500 to-indigo-500 rounded-full transition-all duration-500 ease-out"
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* Dimension indicators */}
|
||||||
|
<div className="flex gap-1.5 mt-3">
|
||||||
|
{['EI', 'SN', 'TF', 'JP'].map((dim) => {
|
||||||
|
const dimQuestions = questions.filter((q) => q.dimension === dim);
|
||||||
|
const dimAnswered = dimQuestions.filter((q) => answers[q.id]).length;
|
||||||
|
const isCurrent = question.dimension === dim;
|
||||||
|
return (
|
||||||
|
<div key={dim} className="flex-1">
|
||||||
|
<div className={`text-[10px] text-center mb-1 font-medium ${isCurrent ? 'text-violet-400' : 'text-slate-500'}`}>
|
||||||
|
{dim === 'EI' ? 'E/I' : dim === 'SN' ? 'S/N' : dim === 'TF' ? 'T/F' : 'J/P'}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-0.5">
|
||||||
|
{dimQuestions.map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`h-1 flex-1 rounded-full ${
|
||||||
|
i < dimAnswered
|
||||||
|
? 'bg-violet-500'
|
||||||
|
: isCurrent && i === dimAnswered
|
||||||
|
? 'bg-violet-500/40'
|
||||||
|
: 'bg-slate-700/50'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Question */}
|
||||||
|
<div className="p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-5 leading-relaxed">{question.question}</h3>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<button
|
||||||
|
onClick={() => handleSelectAnswer(question.optionA.value)}
|
||||||
|
className={`w-full text-left p-4 rounded-xl border-2 transition-all ${
|
||||||
|
selectedAnswer === question.optionA.value
|
||||||
|
? 'border-violet-500 bg-violet-500/15 shadow-lg shadow-violet-500/10'
|
||||||
|
: 'border-slate-600/40 bg-slate-700/30 hover:bg-violet-500/5 hover:border-violet-500/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className={`w-6 h-6 rounded-full border-2 flex items-center justify-center flex-shrink-0 mt-0.5 transition-all ${
|
||||||
|
selectedAnswer === question.optionA.value
|
||||||
|
? 'border-violet-500 bg-violet-500'
|
||||||
|
: 'border-slate-500'
|
||||||
|
}`}>
|
||||||
|
{selectedAnswer === question.optionA.value && (
|
||||||
|
<CheckCircle size={14} className="text-white" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-slate-200 leading-relaxed">{question.optionA.text}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleSelectAnswer(question.optionB.value)}
|
||||||
|
className={`w-full text-left p-4 rounded-xl border-2 transition-all ${
|
||||||
|
selectedAnswer === question.optionB.value
|
||||||
|
? 'border-violet-500 bg-violet-500/15 shadow-lg shadow-violet-500/10'
|
||||||
|
: 'border-slate-600/40 bg-slate-700/30 hover:bg-violet-500/5 hover:border-violet-500/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className={`w-6 h-6 rounded-full border-2 flex items-center justify-center flex-shrink-0 mt-0.5 transition-all ${
|
||||||
|
selectedAnswer === question.optionB.value
|
||||||
|
? 'border-violet-500 bg-violet-500'
|
||||||
|
: 'border-slate-500'
|
||||||
|
}`}>
|
||||||
|
{selectedAnswer === question.optionB.value && (
|
||||||
|
<CheckCircle size={14} className="text-white" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-slate-200 leading-relaxed">{question.optionB.text}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<div className="flex items-center justify-between mt-6 pt-4 border-t border-slate-700/40">
|
||||||
|
<button
|
||||||
|
onClick={handleBack}
|
||||||
|
disabled={currentQ === 0}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2.5 rounded-xl text-sm font-medium transition-all ${
|
||||||
|
currentQ === 0
|
||||||
|
? 'text-slate-600 cursor-not-allowed'
|
||||||
|
: 'text-slate-400 hover:text-white hover:bg-slate-700/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ArrowLeft size={14} /> Back
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleNext}
|
||||||
|
disabled={!selectedAnswer}
|
||||||
|
className={`flex items-center gap-2 px-6 py-2.5 rounded-xl text-sm font-semibold transition-all ${
|
||||||
|
selectedAnswer
|
||||||
|
? 'bg-gradient-to-r from-violet-500 to-indigo-600 text-white hover:shadow-lg hover:shadow-violet-500/25'
|
||||||
|
: 'bg-slate-700/50 text-slate-500 cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{currentQ === totalQuestions - 1 ? 'See My Results' : 'Continue'} <ArrowRight size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PersonalityQuiz;
|
||||||
258
src/components/frameworks/QBSSafety.tsx
Normal file
258
src/components/frameworks/QBSSafety.tsx
Normal file
@ -0,0 +1,258 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { UserRole } from '@/lib/types';
|
||||||
|
import { QBS_QUIZ } from '@/lib/appData';
|
||||||
|
import { saveQuizResult, fetchQuizResults } from '@/lib/db';
|
||||||
|
import {
|
||||||
|
Shield, CheckCircle, XCircle, ArrowRight, RotateCcw,
|
||||||
|
Trophy, AlertTriangle, Users, TrendingUp, Clock, Loader2, Database
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface QBSSafetyProps {
|
||||||
|
userRole: UserRole;
|
||||||
|
userName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QBSSafety: React.FC<QBSSafetyProps> = ({ userRole, userName = 'Ms. Rodriguez' }) => {
|
||||||
|
const [quizStarted, setQuizStarted] = useState(false);
|
||||||
|
const [currentQuestion, setCurrentQuestion] = useState(0);
|
||||||
|
const [selectedAnswer, setSelectedAnswer] = useState<number | null>(null);
|
||||||
|
const [showExplanation, setShowExplanation] = useState(false);
|
||||||
|
const [score, setScore] = useState(0);
|
||||||
|
const [quizComplete, setQuizComplete] = useState(false);
|
||||||
|
const [answers, setAnswers] = useState<(number | null)[]>([]);
|
||||||
|
const [complianceData, setComplianceData] = useState<any[]>([]);
|
||||||
|
const [loadingCompliance, setLoadingCompliance] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const quiz = QBS_QUIZ;
|
||||||
|
const question = quiz.questions[currentQuestion];
|
||||||
|
const isDirector = userRole === 'director';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isDirector) loadCompliance();
|
||||||
|
}, [isDirector]);
|
||||||
|
|
||||||
|
const loadCompliance = async () => {
|
||||||
|
setLoadingCompliance(true);
|
||||||
|
const results = await fetchQuizResults(quiz.weekOf);
|
||||||
|
if (results.length > 0) {
|
||||||
|
setComplianceData(results.map((r: any) => ({
|
||||||
|
name: r.user_name, role: r.user_role === 'teacher' ? 'Teacher' : 'Para',
|
||||||
|
status: 'complete', score: `${r.score}/${r.total_questions}`, date: new Date(r.completed_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
|
||||||
|
})));
|
||||||
|
} else {
|
||||||
|
setComplianceData([
|
||||||
|
{ name: 'Ms. Rodriguez', role: 'Teacher', status: 'complete', score: '5/5', date: 'Feb 7' },
|
||||||
|
{ name: 'Mr. Thompson', role: 'Para', status: 'complete', score: '4/5', date: 'Feb 6' },
|
||||||
|
{ name: 'Ms. Garcia', role: 'Teacher', status: 'pending', score: '-', date: '-' },
|
||||||
|
{ name: 'Mr. Lee', role: 'Para', status: 'complete', score: '5/5', date: 'Feb 8' },
|
||||||
|
{ name: 'Ms. Johnson', role: 'Teacher', status: 'overdue', score: '-', date: '-' },
|
||||||
|
{ name: 'Mr. Davis', role: 'Para', status: 'complete', score: '3/5', date: 'Feb 5' },
|
||||||
|
{ name: 'Ms. Brown', role: 'Teacher', status: 'complete', score: '5/5', date: 'Feb 7' },
|
||||||
|
{ name: 'Mr. Wilson', role: 'Para', status: 'pending', score: '-', date: '-' },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
setLoadingCompliance(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAnswer = (idx: number) => {
|
||||||
|
if (showExplanation) return;
|
||||||
|
setSelectedAnswer(idx);
|
||||||
|
setShowExplanation(true);
|
||||||
|
if (idx === question.correctIndex) setScore(s => s + 1);
|
||||||
|
setAnswers([...answers, idx]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextQuestion = async () => {
|
||||||
|
if (currentQuestion < quiz.questions.length - 1) {
|
||||||
|
setCurrentQuestion(c => c + 1);
|
||||||
|
setSelectedAnswer(null);
|
||||||
|
setShowExplanation(false);
|
||||||
|
} else {
|
||||||
|
setQuizComplete(true);
|
||||||
|
setSaving(true);
|
||||||
|
const finalScore = answers.filter((a, i) => a === quiz.questions[i]?.correctIndex).length + (selectedAnswer === question.correctIndex ? 1 : 0);
|
||||||
|
await saveQuizResult({
|
||||||
|
userName, userRole, quizId: quiz.id, quizTitle: quiz.title,
|
||||||
|
score: finalScore, totalQuestions: quiz.questions.length,
|
||||||
|
answers: [...answers, selectedAnswer].filter(a => a !== null) as number[],
|
||||||
|
weekOf: quiz.weekOf,
|
||||||
|
});
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetQuiz = () => {
|
||||||
|
setQuizStarted(false); setCurrentQuestion(0); setSelectedAnswer(null);
|
||||||
|
setShowExplanation(false); setScore(0); setQuizComplete(false); setAnswers([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const completedCount = complianceData.filter(s => s.status === 'complete').length;
|
||||||
|
const totalStaff = complianceData.length || 8;
|
||||||
|
const completionRate = totalStaff > 0 ? Math.round((completedCount / totalStaff) * 100) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-white flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center shadow-lg shadow-blue-500/30">
|
||||||
|
<Shield size={20} className="text-white" />
|
||||||
|
</div>
|
||||||
|
De-Escalation Strategies
|
||||||
|
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-slate-400 mt-1">Weekly safety training, micro-quizzes, and compliance tracking</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gradient-to-r from-blue-500/10 to-indigo-500/10 rounded-2xl border border-blue-500/20 p-5">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center flex-shrink-0 shadow-lg shadow-blue-500/30">
|
||||||
|
<AlertTriangle size={20} className="text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-blue-400">This Week's Focus: De-Escalation Techniques</h3>
|
||||||
|
<p className="text-sm text-slate-400 mt-1">Remember: Reduce demands first. Use low, slow, calm voice. Minimal words. Give space. Do NOT process during crisis.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
{!quizStarted && !quizComplete ? (
|
||||||
|
<div className="bg-slate-800/40 backdrop-blur-sm rounded-2xl border border-slate-700/40 p-6 text-center">
|
||||||
|
<div className="w-20 h-20 rounded-2xl bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center mx-auto mb-4 shadow-xl shadow-blue-500/20">
|
||||||
|
<Shield size={36} className="text-white" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-bold text-white mb-2">{quiz.title}</h3>
|
||||||
|
<p className="text-sm text-slate-400 mb-1">Week of {quiz.weekOf}</p>
|
||||||
|
<p className="text-sm text-slate-500 mb-6">{quiz.questions.length} scenario-based questions · Results saved to database</p>
|
||||||
|
<button onClick={() => setQuizStarted(true)} className="px-6 py-3 bg-gradient-to-r from-blue-500 to-indigo-600 text-white rounded-xl font-semibold hover:shadow-lg hover:shadow-blue-500/25 transition-all">
|
||||||
|
Start Weekly Quiz
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : quizComplete ? (
|
||||||
|
<div className="bg-slate-800/40 backdrop-blur-sm rounded-2xl border border-slate-700/40 p-6 text-center">
|
||||||
|
<div className="w-20 h-20 rounded-2xl bg-gradient-to-br from-emerald-500 to-emerald-600 flex items-center justify-center mx-auto mb-4 shadow-xl shadow-emerald-500/20">
|
||||||
|
<Trophy size={36} className="text-white" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-bold text-white mb-2">Quiz Complete!</h3>
|
||||||
|
{saving && <p className="text-sm text-blue-400 flex items-center justify-center gap-2 mb-2"><Loader2 size={14} className="animate-spin" /> Saving results...</p>}
|
||||||
|
{!saving && <p className="text-xs text-emerald-400 flex items-center justify-center gap-1 mb-2"><Database size={12} /> Results saved to database</p>}
|
||||||
|
<p className="text-4xl font-bold bg-gradient-to-r from-emerald-400 to-blue-400 bg-clip-text text-transparent mb-2">{score}/{quiz.questions.length}</p>
|
||||||
|
<p className="text-sm text-slate-400 mb-6">
|
||||||
|
{score === quiz.questions.length ? 'Perfect score! Outstanding safety knowledge.' :
|
||||||
|
score >= 3 ? 'Great job! Review the explanations for any missed questions.' :
|
||||||
|
'Please review the material and retake when ready.'}
|
||||||
|
</p>
|
||||||
|
<div className="text-left space-y-3 mb-6">
|
||||||
|
{quiz.questions.map((q, idx) => (
|
||||||
|
<div key={q.id} className={`p-3 rounded-xl border ${answers[idx] === q.correctIndex ? 'bg-emerald-500/10 border-emerald-500/20' : 'bg-red-500/10 border-red-500/20'}`}>
|
||||||
|
<p className="text-sm font-medium text-slate-200">{idx + 1}. {q.question}</p>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">{q.explanation}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button onClick={resetQuiz} className="flex items-center gap-2 mx-auto px-5 py-2.5 bg-blue-500/15 text-blue-400 rounded-xl font-medium text-sm hover:bg-blue-500/25 border border-blue-500/20">
|
||||||
|
<RotateCcw size={14} /> Retake Quiz
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-slate-800/40 backdrop-blur-sm rounded-2xl border border-slate-700/40 p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<span className="text-sm font-medium text-slate-400">Question {currentQuestion + 1} of {quiz.questions.length}</span>
|
||||||
|
<span className="text-sm font-medium text-blue-400">Score: {score}</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-slate-700/50 rounded-full mb-6">
|
||||||
|
<div className="h-full bg-gradient-to-r from-blue-500 to-indigo-500 rounded-full transition-all duration-500" style={{ width: `${((currentQuestion + 1) / quiz.questions.length) * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-4">{question.question}</h3>
|
||||||
|
<div className="space-y-3 mb-4">
|
||||||
|
{question.options.map((opt, idx) => {
|
||||||
|
let optClass = 'bg-slate-700/30 border-slate-600/40 hover:bg-slate-700/50 hover:border-violet-500/30 cursor-pointer';
|
||||||
|
if (showExplanation) {
|
||||||
|
if (idx === question.correctIndex) optClass = 'bg-emerald-500/15 border-emerald-500/30 ring-2 ring-emerald-500/20';
|
||||||
|
else if (idx === selectedAnswer) optClass = 'bg-red-500/15 border-red-500/30 ring-2 ring-red-500/20';
|
||||||
|
else optClass = 'bg-slate-700/20 border-slate-700/30 opacity-50';
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<button key={idx} onClick={() => handleAnswer(idx)} className={`w-full text-left p-4 rounded-xl border-2 transition-all ${optClass}`}>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="w-7 h-7 rounded-full bg-slate-600/50 border-2 border-slate-500/50 flex items-center justify-center text-xs font-bold text-slate-300 flex-shrink-0">{String.fromCharCode(65 + idx)}</span>
|
||||||
|
<span className="text-sm text-slate-200">{opt}</span>
|
||||||
|
{showExplanation && idx === question.correctIndex && <CheckCircle size={18} className="text-emerald-400 ml-auto flex-shrink-0" />}
|
||||||
|
{showExplanation && idx === selectedAnswer && idx !== question.correctIndex && <XCircle size={18} className="text-red-400 ml-auto flex-shrink-0" />}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{showExplanation && (
|
||||||
|
<div className="bg-blue-500/10 rounded-xl p-4 mb-4 border border-blue-500/20">
|
||||||
|
<p className="text-sm text-blue-400 font-medium">Explanation:</p>
|
||||||
|
<p className="text-sm text-slate-300 mt-1">{question.explanation}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{showExplanation && (
|
||||||
|
<button onClick={nextQuestion} className="flex items-center gap-2 px-5 py-2.5 bg-gradient-to-r from-blue-500 to-indigo-600 text-white rounded-xl font-medium text-sm hover:shadow-lg hover:shadow-blue-500/25 transition-all">
|
||||||
|
{currentQuestion < quiz.questions.length - 1 ? 'Next Question' : 'See Results'} <ArrowRight size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="bg-slate-800/40 backdrop-blur-sm rounded-2xl border border-slate-700/40 p-5">
|
||||||
|
<h3 className="font-semibold text-white mb-3 flex items-center gap-2">
|
||||||
|
<AlertTriangle size={16} className="text-amber-400" /> Key Reminders
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
{['Physical management is a LAST resort', 'Always reduce demands before escalation', 'Use low, slow, calm voice', 'Do NOT process during crisis', 'Document within 24 hours'].map((tip, i) => (
|
||||||
|
<div key={i} className="flex items-start gap-2">
|
||||||
|
<CheckCircle size={14} className="text-emerald-400 mt-0.5 flex-shrink-0" />
|
||||||
|
<p className="text-xs text-slate-300">{tip}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isDirector && (
|
||||||
|
<div className="bg-slate-800/40 backdrop-blur-sm rounded-2xl border border-slate-700/40 p-5">
|
||||||
|
<h3 className="font-semibold text-white mb-3 flex items-center gap-2">
|
||||||
|
<Users size={16} className="text-violet-400" /> Staff Completion
|
||||||
|
{loadingCompliance && <Loader2 size={12} className="animate-spin text-slate-400" />}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{complianceData.map((staff, i) => (
|
||||||
|
<div key={i} className="flex items-center justify-between p-2 rounded-lg bg-slate-700/30 border border-slate-700/30">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-slate-200">{staff.name}</p>
|
||||||
|
<p className="text-[10px] text-slate-500">{staff.role}</p>
|
||||||
|
</div>
|
||||||
|
<span className={`text-[10px] font-semibold px-2 py-0.5 rounded-lg border ${
|
||||||
|
staff.status === 'complete' ? 'bg-emerald-500/15 text-emerald-400 border-emerald-500/20' :
|
||||||
|
staff.status === 'pending' ? 'bg-amber-500/15 text-amber-400 border-amber-500/20' : 'bg-red-500/15 text-red-400 border-red-500/20'
|
||||||
|
}`}>
|
||||||
|
{staff.status === 'complete' ? staff.score : staff.status.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 pt-3 border-t border-slate-700/40">
|
||||||
|
<div className="flex justify-between text-xs">
|
||||||
|
<span className="text-slate-400">Completion Rate</span>
|
||||||
|
<span className="font-bold text-emerald-400">{completionRate}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-slate-700/50 rounded-full mt-1">
|
||||||
|
<div className="h-full bg-emerald-500 rounded-full transition-all" style={{ width: `${completionRate}%` }}></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default QBSSafety;
|
||||||
145
src/components/frameworks/Sidebar.tsx
Normal file
145
src/components/frameworks/Sidebar.tsx
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { ModuleId, UserRole, Module } from '@/lib/types';
|
||||||
|
import { MODULES, CAMPUSES, getCampusByMascot } from '@/lib/appData';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Home, LayoutGrid, BookOpen, Shield, Heart, Layers, HandMetal,
|
||||||
|
Clock, MessageSquare, Bell, AlertTriangle, BarChart3,
|
||||||
|
ChevronLeft, ChevronRight, FileText, Timer, Globe, Briefcase, Wallet,
|
||||||
|
ClipboardCheck, Wifi
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
const iconMap: Record<string, React.ReactNode> = {
|
||||||
|
home: <Home size={20} />,
|
||||||
|
frame: <LayoutGrid size={20} />,
|
||||||
|
book: <BookOpen size={20} />,
|
||||||
|
timer: <Timer size={20} />,
|
||||||
|
shield: <Shield size={20} />,
|
||||||
|
heart: <Heart size={20} />,
|
||||||
|
layers: <Layers size={20} />,
|
||||||
|
hand: <HandMetal size={20} />,
|
||||||
|
clock: <Clock size={20} />,
|
||||||
|
message: <MessageSquare size={20} />,
|
||||||
|
bell: <Bell size={20} />,
|
||||||
|
alert: <AlertTriangle size={20} />,
|
||||||
|
chart: <BarChart3 size={20} />,
|
||||||
|
file: <FileText size={20} />,
|
||||||
|
globe: <Globe size={20} />,
|
||||||
|
briefcase: <Briefcase size={20} />,
|
||||||
|
wallet: <Wallet size={20} />,
|
||||||
|
clipboard: <ClipboardCheck size={20} />,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SidebarProps {
|
||||||
|
currentModule: ModuleId;
|
||||||
|
setCurrentModule: (id: ModuleId) => void;
|
||||||
|
userRole: UserRole;
|
||||||
|
collapsed: boolean;
|
||||||
|
setCollapsed: (v: boolean) => void;
|
||||||
|
userCampus?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Sidebar: React.FC<SidebarProps> = ({ currentModule, setCurrentModule, userRole, collapsed, setCollapsed, userCampus }) => {
|
||||||
|
const availableModules = MODULES.filter(m => m.roles.includes(userRole));
|
||||||
|
const campusInfo = userCampus ? getCampusByMascot(userCampus) : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className={`${collapsed ? 'w-16' : 'w-64'} bg-gradient-to-b from-slate-900 via-slate-900 to-slate-800 h-full flex flex-col transition-all duration-300 shadow-2xl shadow-black/20`}>
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="p-4 border-b border-slate-700/50 flex items-center gap-3">
|
||||||
|
{!collapsed && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-violet-500 to-amber-400 flex items-center justify-center shadow-lg shadow-violet-500/30">
|
||||||
|
<span className="text-white font-bold text-sm">F</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="font-bold text-lg bg-gradient-to-r from-violet-400 to-amber-400 bg-clip-text text-transparent leading-tight">FRAMEworks</h1>
|
||||||
|
<p className="text-[10px] text-slate-500 leading-tight">School Operations Platform</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{collapsed && (
|
||||||
|
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-violet-500 to-amber-400 flex items-center justify-center mx-auto shadow-lg shadow-violet-500/30">
|
||||||
|
<span className="text-white font-bold text-sm">F</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Toggle */}
|
||||||
|
<button
|
||||||
|
onClick={() => setCollapsed(!collapsed)}
|
||||||
|
className="absolute top-4 -right-3 w-6 h-6 bg-gradient-to-r from-violet-500 to-violet-600 text-white rounded-full flex items-center justify-center shadow-lg shadow-violet-500/40 hover:shadow-violet-500/60 transition-all z-10"
|
||||||
|
>
|
||||||
|
{collapsed ? <ChevronRight size={14} /> : <ChevronLeft size={14} />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<nav className="flex-1 py-3 overflow-y-auto scrollbar-thin scrollbar-thumb-slate-700">
|
||||||
|
<div className="px-3 space-y-0.5">
|
||||||
|
{availableModules.map((mod) => {
|
||||||
|
const isActive = currentModule === mod.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={mod.id}
|
||||||
|
onClick={() => setCurrentModule(mod.id)}
|
||||||
|
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-200 ${
|
||||||
|
isActive
|
||||||
|
? 'bg-gradient-to-r from-violet-500/20 to-amber-500/10 text-white shadow-sm border border-violet-500/20'
|
||||||
|
: 'text-slate-400 hover:bg-slate-800/80 hover:text-slate-200'
|
||||||
|
}`}
|
||||||
|
title={collapsed ? mod.name : undefined}
|
||||||
|
>
|
||||||
|
<span className={`flex-shrink-0 ${isActive ? 'text-violet-400' : 'text-slate-500'}`}>
|
||||||
|
{iconMap[mod.icon]}
|
||||||
|
</span>
|
||||||
|
{!collapsed && <span className="truncate">{mod.name}</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Campus & Role Badge */}
|
||||||
|
{!collapsed && (
|
||||||
|
<div className="p-3 border-t border-slate-700/50 space-y-2">
|
||||||
|
{/* Campus Badge */}
|
||||||
|
{campusInfo && (
|
||||||
|
<div className={`${campusInfo.bgLight} rounded-xl p-3 border ${campusInfo.borderColor}`}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className={`w-8 h-8 rounded-lg bg-gradient-to-br ${campusInfo.bgGradient} flex items-center justify-center shadow-md`}>
|
||||||
|
<span className="text-white font-bold text-xs">{campusInfo.mascot[0]}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<p className={`text-sm font-bold ${campusInfo.textColor}`}>{campusInfo.mascot}</p>
|
||||||
|
{campusInfo.isOnline && <Wifi size={10} className={campusInfo.textColor} />}
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-slate-500 truncate">{campusInfo.fullName}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Role Badge */}
|
||||||
|
<div className="bg-gradient-to-r from-violet-500/10 to-amber-500/10 rounded-xl p-3 border border-violet-500/10">
|
||||||
|
<p className="text-[10px] text-slate-500 uppercase tracking-wider">Your Role</p>
|
||||||
|
<p className="text-sm font-semibold text-violet-400 capitalize">{userRole === 'para' ? 'Support Staff' : userRole === 'office' ? 'Office Manager' : userRole === 'superintendent' ? 'Superintendent' : userRole}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
{/* Collapsed campus indicator */}
|
||||||
|
{collapsed && campusInfo && (
|
||||||
|
<div className="p-2 border-t border-slate-700/50 flex justify-center">
|
||||||
|
<div className={`w-9 h-9 rounded-lg bg-gradient-to-br ${campusInfo.bgGradient} flex items-center justify-center shadow-md`} title={campusInfo.fullName}>
|
||||||
|
<span className="text-white font-bold text-xs">{campusInfo.mascot[0]}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Sidebar;
|
||||||
|
|
||||||
633
src/components/frameworks/SignInModal.tsx
Normal file
633
src/components/frameworks/SignInModal.tsx
Normal file
@ -0,0 +1,633 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
import { UserRole, CampusId } from '@/lib/types';
|
||||||
|
import { CAMPUSES } from '@/lib/appData';
|
||||||
|
import {
|
||||||
|
X, Mail, Lock, User, Eye, EyeOff,
|
||||||
|
LogIn, UserPlus, Loader2, AlertCircle, CheckCircle2,
|
||||||
|
Shield, ArrowLeft, ArrowRight, Wifi
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface SignInModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom SVG mascot icons
|
||||||
|
const MascotIcon: React.FC<{ mascot: CampusId; size?: number; className?: string }> = ({ mascot, size = 32, className = '' }) => {
|
||||||
|
const s = size;
|
||||||
|
switch (mascot) {
|
||||||
|
case 'tigers':
|
||||||
|
return (
|
||||||
|
<svg width={s} height={s} viewBox="0 0 48 48" fill="none" className={className}>
|
||||||
|
<circle cx="24" cy="24" r="20" fill="currentColor" fillOpacity="0.15" />
|
||||||
|
<path d="M14 14c2-4 6-6 10-6s8 2 10 6" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
|
||||||
|
<circle cx="18" cy="22" r="2.5" fill="currentColor" />
|
||||||
|
<circle cx="30" cy="22" r="2.5" fill="currentColor" />
|
||||||
|
<path d="M20 30c1.5 1.5 6.5 1.5 8 0" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
<path d="M24 26v3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
<path d="M10 12l4 6M38 12l-4 6" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
|
||||||
|
<path d="M16 18l-2-1M32 18l2-1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||||
|
<path d="M15 26l-4 2M33 26l4 2" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||||
|
<path d="M15 28l-3 3M33 28l3 3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case 'gators':
|
||||||
|
return (
|
||||||
|
<svg width={s} height={s} viewBox="0 0 48 48" fill="none" className={className}>
|
||||||
|
<circle cx="24" cy="24" r="20" fill="currentColor" fillOpacity="0.15" />
|
||||||
|
<path d="M10 22c0-6 6-12 14-12s14 6 14 12" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
|
||||||
|
<path d="M10 26c0 6 6 12 14 12s14-6 14-12" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
|
||||||
|
<path d="M10 22h28M10 26h28" stroke="currentColor" strokeWidth="2" />
|
||||||
|
<path d="M14 22v4M18 22v4M22 22v4M26 22v4M30 22v4M34 22v4" stroke="currentColor" strokeWidth="1.5" />
|
||||||
|
<circle cx="17" cy="16" r="2" fill="currentColor" />
|
||||||
|
<circle cx="31" cy="16" r="2" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case 'hawks':
|
||||||
|
return (
|
||||||
|
<svg width={s} height={s} viewBox="0 0 48 48" fill="none" className={className}>
|
||||||
|
<circle cx="24" cy="24" r="20" fill="currentColor" fillOpacity="0.15" />
|
||||||
|
<path d="M24 8c-8 4-12 12-12 18h24c0-6-4-14-12-18z" stroke="currentColor" strokeWidth="2.5" fill="none" />
|
||||||
|
<circle cx="20" cy="20" r="2" fill="currentColor" />
|
||||||
|
<circle cx="28" cy="20" r="2" fill="currentColor" />
|
||||||
|
<path d="M22 26l2 4 2-4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
<path d="M8 20l8 4M40 20l-8 4" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
|
||||||
|
<path d="M6 18l10 6M42 18l-10 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case 'owls':
|
||||||
|
return (
|
||||||
|
<svg width={s} height={s} viewBox="0 0 48 48" fill="none" className={className}>
|
||||||
|
<circle cx="24" cy="24" r="20" fill="currentColor" fillOpacity="0.15" />
|
||||||
|
<circle cx="18" cy="22" r="5" stroke="currentColor" strokeWidth="2" />
|
||||||
|
<circle cx="30" cy="22" r="5" stroke="currentColor" strokeWidth="2" />
|
||||||
|
<circle cx="18" cy="22" r="2" fill="currentColor" />
|
||||||
|
<circle cx="30" cy="22" r="2" fill="currentColor" />
|
||||||
|
<path d="M22 30l2 3 2-3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
<path d="M14 14l4 4M34 14l-4 4" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
|
||||||
|
<path d="M16 34c2 2 6 4 8 4s6-2 8-4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case 'wildcats':
|
||||||
|
return (
|
||||||
|
<svg width={s} height={s} viewBox="0 0 48 48" fill="none" className={className}>
|
||||||
|
<circle cx="24" cy="24" r="20" fill="currentColor" fillOpacity="0.15" />
|
||||||
|
<path d="M12 16l4 8-2 4M36 16l-4 8 2 4" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
<circle cx="19" cy="22" r="2.5" fill="currentColor" />
|
||||||
|
<circle cx="29" cy="22" r="2.5" fill="currentColor" />
|
||||||
|
<path d="M21 28c1 1.5 5 1.5 6 0" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
<path d="M24 25v2.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
<path d="M16 26l-5 2M32 26l5 2M16 28l-4 3M32 28l4 3M16 30l-3 4M32 30l3 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case 'grizzlies':
|
||||||
|
return (
|
||||||
|
<svg width={s} height={s} viewBox="0 0 48 48" fill="none" className={className}>
|
||||||
|
<circle cx="24" cy="24" r="20" fill="currentColor" fillOpacity="0.15" />
|
||||||
|
<circle cx="15" cy="12" r="4" stroke="currentColor" strokeWidth="2" />
|
||||||
|
<circle cx="33" cy="12" r="4" stroke="currentColor" strokeWidth="2" />
|
||||||
|
<path d="M12 20c0-4 5-8 12-8s12 4 12 8v8c0 6-5 12-12 12S12 34 12 28v-8z" stroke="currentColor" strokeWidth="2.5" fill="none" />
|
||||||
|
<circle cx="19" cy="24" r="2" fill="currentColor" />
|
||||||
|
<circle cx="29" cy="24" r="2" fill="currentColor" />
|
||||||
|
<ellipse cx="24" cy="30" rx="3" ry="2" stroke="currentColor" strokeWidth="2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const SignInModal: React.FC<SignInModalProps> = ({ isOpen, onClose }) => {
|
||||||
|
const { signIn, signUp } = useAuth();
|
||||||
|
const [mode, setMode] = useState<'signin' | 'signup'>('signin');
|
||||||
|
const [signupStep, setSignupStep] = useState<1 | 2 | 3>(1);
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [fullName, setFullName] = useState('');
|
||||||
|
const [role, setRole] = useState<UserRole>('teacher');
|
||||||
|
const [campus, setCampus] = useState<CampusId | ''>('');
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleSignIn = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setSuccess(null);
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const result = await signIn(email, password);
|
||||||
|
setLoading(false);
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
setError(result.error);
|
||||||
|
} else {
|
||||||
|
setSuccess('Signed in successfully!');
|
||||||
|
setTimeout(() => {
|
||||||
|
onClose();
|
||||||
|
resetForm();
|
||||||
|
}, 800);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSignUp = async () => {
|
||||||
|
setError(null);
|
||||||
|
setSuccess(null);
|
||||||
|
|
||||||
|
if (!campus) {
|
||||||
|
setError('Please select your campus mascot');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
const selectedCampus = CAMPUSES.find(c => c.id === campus);
|
||||||
|
const campusName = selectedCampus?.mascot || campus;
|
||||||
|
const result = await signUp(email, password, fullName, role, campusName);
|
||||||
|
setLoading(false);
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
setError(result.error);
|
||||||
|
} else {
|
||||||
|
setSuccess('Account created! You are now signed in.');
|
||||||
|
setTimeout(() => {
|
||||||
|
onClose();
|
||||||
|
resetForm();
|
||||||
|
}, 1200);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateStep1 = (): boolean => {
|
||||||
|
if (!fullName.trim()) {
|
||||||
|
setError('Please enter your full name');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!email.trim() || !email.includes('@')) {
|
||||||
|
setError('Please enter a valid email address');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (password.length < 6) {
|
||||||
|
setError('Password must be at least 6 characters');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
setError('Passwords do not match');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToNextStep = () => {
|
||||||
|
setError(null);
|
||||||
|
if (signupStep === 1) {
|
||||||
|
if (validateStep1()) setSignupStep(2);
|
||||||
|
} else if (signupStep === 2) {
|
||||||
|
setSignupStep(3);
|
||||||
|
} else if (signupStep === 3) {
|
||||||
|
handleSignUp();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToPrevStep = () => {
|
||||||
|
setError(null);
|
||||||
|
if (signupStep === 2) setSignupStep(1);
|
||||||
|
else if (signupStep === 3) setSignupStep(2);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setEmail('');
|
||||||
|
setPassword('');
|
||||||
|
setConfirmPassword('');
|
||||||
|
setFullName('');
|
||||||
|
setRole('teacher');
|
||||||
|
setCampus('');
|
||||||
|
setError(null);
|
||||||
|
setSuccess(null);
|
||||||
|
setMode('signin');
|
||||||
|
setSignupStep(1);
|
||||||
|
setShowPassword(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
resetForm();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const roleOptions: { value: UserRole; label: string; desc: string; color: string; icon: React.ReactNode }[] = [
|
||||||
|
{ value: 'teacher', label: 'Teacher', desc: 'Classroom educator', color: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400', icon: <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg> },
|
||||||
|
{ value: 'para', label: 'Support Staff', desc: 'Paraprofessional', color: 'border-blue-500/30 bg-blue-500/10 text-blue-400', icon: <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg> },
|
||||||
|
{ value: 'office', label: 'Office Manager', desc: 'Administrative staff', color: 'border-amber-500/30 bg-amber-500/10 text-amber-400', icon: <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg> },
|
||||||
|
{ value: 'director', label: 'Director', desc: 'Campus leadership', color: 'border-purple-500/30 bg-purple-500/10 text-purple-400', icon: <Shield size={24} /> },
|
||||||
|
{ value: 'superintendent', label: 'Superintendent', desc: 'District-wide oversight', color: 'border-rose-500/30 bg-rose-500/10 text-rose-400', icon: <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg> },
|
||||||
|
];
|
||||||
|
|
||||||
|
|
||||||
|
const selectedCampusInfo = campus ? CAMPUSES.find(c => c.id === campus) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||||
|
onClick={handleClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal */}
|
||||||
|
<div className="relative w-full max-w-lg bg-slate-900 rounded-2xl shadow-2xl shadow-black/50 border border-slate-700/50 overflow-hidden animate-in fade-in zoom-in-95 duration-200">
|
||||||
|
{/* Header gradient */}
|
||||||
|
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-violet-500 via-amber-400 to-emerald-500" />
|
||||||
|
|
||||||
|
{/* Close button */}
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className="absolute top-4 right-4 p-1.5 rounded-lg hover:bg-slate-800 text-slate-400 hover:text-white transition-colors z-10"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-6 pt-8">
|
||||||
|
{/* Logo & Title */}
|
||||||
|
<div className="text-center mb-6">
|
||||||
|
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-violet-500 to-amber-400 flex items-center justify-center mx-auto mb-3 shadow-lg shadow-violet-500/30">
|
||||||
|
<span className="text-white font-bold text-xl">F</span>
|
||||||
|
</div>
|
||||||
|
{mode === 'signin' ? (
|
||||||
|
<>
|
||||||
|
<h2 className="text-xl font-bold text-white">Welcome Back</h2>
|
||||||
|
<p className="text-sm text-slate-400 mt-1">Sign in to access your campus dashboard</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<h2 className="text-xl font-bold text-white">
|
||||||
|
{signupStep === 1 && 'Create Your Account'}
|
||||||
|
{signupStep === 2 && 'Select Your Role'}
|
||||||
|
{signupStep === 3 && 'Choose Your Campus'}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-slate-400 mt-1">
|
||||||
|
{signupStep === 1 && 'Enter your credentials to get started'}
|
||||||
|
{signupStep === 2 && 'What is your position?'}
|
||||||
|
{signupStep === 3 && 'Select your campus mascot'}
|
||||||
|
</p>
|
||||||
|
{/* Step indicator */}
|
||||||
|
<div className="flex items-center justify-center gap-2 mt-4">
|
||||||
|
{[1, 2, 3].map(step => (
|
||||||
|
<div key={step} className="flex items-center gap-2">
|
||||||
|
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold transition-all duration-300 ${
|
||||||
|
signupStep >= step
|
||||||
|
? 'bg-gradient-to-r from-violet-500 to-amber-500 text-white shadow-lg shadow-violet-500/30'
|
||||||
|
: 'bg-slate-800 text-slate-500 border border-slate-700'
|
||||||
|
}`}>
|
||||||
|
{signupStep > step ? <CheckCircle2 size={14} /> : step}
|
||||||
|
</div>
|
||||||
|
{step < 3 && (
|
||||||
|
<div className={`w-8 h-0.5 rounded-full transition-all duration-300 ${
|
||||||
|
signupStep > step ? 'bg-violet-500' : 'bg-slate-700'
|
||||||
|
}`} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error/Success Messages */}
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 rounded-xl bg-red-500/10 border border-red-500/20 flex items-start gap-2">
|
||||||
|
<AlertCircle size={16} className="text-red-400 mt-0.5 flex-shrink-0" />
|
||||||
|
<p className="text-sm text-red-400">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{success && (
|
||||||
|
<div className="mb-4 p-3 rounded-xl bg-emerald-500/10 border border-emerald-500/20 flex items-start gap-2">
|
||||||
|
<CheckCircle2 size={16} className="text-emerald-400 mt-0.5 flex-shrink-0" />
|
||||||
|
<p className="text-sm text-emerald-400">{success}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ========== SIGN IN MODE ========== */}
|
||||||
|
{mode === 'signin' && (
|
||||||
|
<form onSubmit={handleSignIn} className="space-y-4">
|
||||||
|
{/* Email */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1.5">
|
||||||
|
Email Address
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Mail size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="you@school.edu"
|
||||||
|
className="w-full pl-10 pr-4 py-2.5 bg-slate-800/80 border border-slate-700/50 rounded-xl text-sm text-white placeholder-slate-500 focus:ring-2 focus:ring-violet-500/50 focus:border-violet-500/50 outline-none transition-all"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Password */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1.5">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
|
||||||
|
<input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="Enter your password"
|
||||||
|
className="w-full pl-10 pr-10 py-2.5 bg-slate-800/80 border border-slate-700/50 rounded-xl text-sm text-white placeholder-slate-500 focus:ring-2 focus:ring-violet-500/50 focus:border-violet-500/50 outline-none transition-all"
|
||||||
|
required
|
||||||
|
minLength={6}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-500 hover:text-slate-300 transition-colors"
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit Button */}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full py-3 bg-gradient-to-r from-violet-500 to-amber-500 hover:from-violet-600 hover:to-amber-600 text-white font-semibold rounded-xl transition-all duration-200 shadow-lg shadow-violet-500/25 hover:shadow-violet-500/40 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 size={18} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<LogIn size={18} />
|
||||||
|
)}
|
||||||
|
{loading ? 'Signing in...' : 'Sign In'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ========== SIGN UP MODE ========== */}
|
||||||
|
{mode === 'signup' && (
|
||||||
|
<div>
|
||||||
|
{/* Step 1: Credentials */}
|
||||||
|
{signupStep === 1 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Full Name */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1.5">
|
||||||
|
Full Name
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<User size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={fullName}
|
||||||
|
onChange={(e) => setFullName(e.target.value)}
|
||||||
|
placeholder="Dr. Jane Smith"
|
||||||
|
className="w-full pl-10 pr-4 py-2.5 bg-slate-800/80 border border-slate-700/50 rounded-xl text-sm text-white placeholder-slate-500 focus:ring-2 focus:ring-violet-500/50 focus:border-violet-500/50 outline-none transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1.5">
|
||||||
|
Email Address
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Mail size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="you@school.edu"
|
||||||
|
className="w-full pl-10 pr-4 py-2.5 bg-slate-800/80 border border-slate-700/50 rounded-xl text-sm text-white placeholder-slate-500 focus:ring-2 focus:ring-violet-500/50 focus:border-violet-500/50 outline-none transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Password */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1.5">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
|
||||||
|
<input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="Min. 6 characters"
|
||||||
|
className="w-full pl-10 pr-10 py-2.5 bg-slate-800/80 border border-slate-700/50 rounded-xl text-sm text-white placeholder-slate-500 focus:ring-2 focus:ring-violet-500/50 focus:border-violet-500/50 outline-none transition-all"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-500 hover:text-slate-300 transition-colors"
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Confirm Password */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1.5">
|
||||||
|
Confirm Password
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
|
||||||
|
<input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
placeholder="Re-enter your password"
|
||||||
|
className="w-full pl-10 pr-4 py-2.5 bg-slate-800/80 border border-slate-700/50 rounded-xl text-sm text-white placeholder-slate-500 focus:ring-2 focus:ring-violet-500/50 focus:border-violet-500/50 outline-none transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 2: Role Selection */}
|
||||||
|
{signupStep === 2 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-xs text-slate-500 mb-3">Choose the role that best describes your position. This determines which modules and features you'll have access to.</p>
|
||||||
|
<div className="grid grid-cols-1 gap-3">
|
||||||
|
{roleOptions.map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRole(opt.value)}
|
||||||
|
className={`p-4 rounded-xl border-2 text-left transition-all duration-200 flex items-center gap-4 ${
|
||||||
|
role === opt.value
|
||||||
|
? `${opt.color} ring-1 ring-current scale-[1.01] shadow-lg`
|
||||||
|
: 'border-slate-700/50 bg-slate-800/50 text-slate-400 hover:border-slate-600/50 hover:bg-slate-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className={`w-12 h-12 rounded-xl flex items-center justify-center flex-shrink-0 ${
|
||||||
|
role === opt.value ? 'bg-current/20' : 'bg-slate-700/50'
|
||||||
|
}`}>
|
||||||
|
{opt.icon}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-bold">{opt.label}</p>
|
||||||
|
<p className="text-xs opacity-70 mt-0.5">{opt.desc}</p>
|
||||||
|
</div>
|
||||||
|
{role === opt.value && (
|
||||||
|
<CheckCircle2 size={20} className="flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 3: Campus/Mascot Selection */}
|
||||||
|
{signupStep === 3 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-xs text-slate-500 mb-3">Select your campus mascot. Your dashboard will be customized to show only information relevant to your campus site.</p>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{CAMPUSES.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCampus(c.id)}
|
||||||
|
className={`relative p-4 rounded-xl border-2 text-center transition-all duration-300 group ${
|
||||||
|
campus === c.id
|
||||||
|
? `${c.borderColor} ${c.bgLight} ${c.textColor} ring-1 ring-current scale-[1.02] shadow-lg`
|
||||||
|
: 'border-slate-700/50 bg-slate-800/50 text-slate-400 hover:border-slate-600/50 hover:bg-slate-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{c.isOnline && (
|
||||||
|
<div className="absolute top-2 right-2">
|
||||||
|
<Wifi size={12} className={campus === c.id ? c.textColor : 'text-slate-600'} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={`mx-auto mb-2 ${campus === c.id ? c.textColor : 'text-slate-500'} transition-colors`}>
|
||||||
|
<MascotIcon mascot={c.id} size={48} />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-bold">{c.mascot}</p>
|
||||||
|
<p className="text-[10px] opacity-60 mt-0.5">{c.description}</p>
|
||||||
|
{c.isOnline && (
|
||||||
|
<span className={`inline-block mt-1.5 text-[9px] font-semibold px-2 py-0.5 rounded-full ${
|
||||||
|
campus === c.id ? `${c.bgLight} ${c.textColor}` : 'bg-slate-700/50 text-slate-500'
|
||||||
|
}`}>
|
||||||
|
ONLINE
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{campus === c.id && (
|
||||||
|
<div className="absolute -top-1 -right-1">
|
||||||
|
<div className={`w-5 h-5 rounded-full bg-gradient-to-r ${c.bgGradient} flex items-center justify-center shadow-lg`}>
|
||||||
|
<CheckCircle2 size={12} className="text-white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Navigation Buttons */}
|
||||||
|
<div className="flex items-center gap-3 mt-6">
|
||||||
|
{signupStep > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={goToPrevStep}
|
||||||
|
className="flex items-center gap-2 px-4 py-2.5 rounded-xl border border-slate-700/50 text-slate-400 hover:bg-slate-800 hover:text-white transition-all text-sm font-medium"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={16} />
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={goToNextStep}
|
||||||
|
disabled={loading || (signupStep === 3 && !campus)}
|
||||||
|
className="flex-1 py-3 bg-gradient-to-r from-violet-500 to-amber-500 hover:from-violet-600 hover:to-amber-600 text-white font-semibold rounded-xl transition-all duration-200 shadow-lg shadow-violet-500/25 hover:shadow-violet-500/40 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 size={18} className="animate-spin" />
|
||||||
|
) : signupStep === 3 ? (
|
||||||
|
<UserPlus size={18} />
|
||||||
|
) : (
|
||||||
|
<ArrowRight size={18} />
|
||||||
|
)}
|
||||||
|
{loading
|
||||||
|
? 'Creating account...'
|
||||||
|
: signupStep === 3
|
||||||
|
? 'Create Account'
|
||||||
|
: 'Continue'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary on Step 3 */}
|
||||||
|
{signupStep === 3 && (fullName || role) && (
|
||||||
|
<div className="mt-4 p-3 rounded-xl bg-slate-800/60 border border-slate-700/30">
|
||||||
|
<p className="text-[10px] text-slate-500 uppercase tracking-wider font-semibold mb-2">Account Summary</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<User size={12} className="text-slate-500" />
|
||||||
|
<span className="text-slate-400">{fullName}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<Mail size={12} className="text-slate-500" />
|
||||||
|
<span className="text-slate-400">{email}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<Shield size={12} className="text-slate-500" />
|
||||||
|
<span className="text-slate-400">{roleOptions.find(r => r.value === role)?.label}</span>
|
||||||
|
</div>
|
||||||
|
{selectedCampusInfo && (
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<div className={selectedCampusInfo.textColor}>
|
||||||
|
<MascotIcon mascot={selectedCampusInfo.id} size={14} />
|
||||||
|
</div>
|
||||||
|
<span className={selectedCampusInfo.textColor + ' font-semibold'}>{selectedCampusInfo.mascot}</span>
|
||||||
|
{selectedCampusInfo.isOnline && <span className="text-[9px] text-slate-500">(Online)</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Toggle Mode */}
|
||||||
|
<div className="mt-5 text-center">
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
{mode === 'signin' ? "Don't have an account?" : 'Already have an account?'}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setMode(mode === 'signin' ? 'signup' : 'signin');
|
||||||
|
setError(null);
|
||||||
|
setSuccess(null);
|
||||||
|
setSignupStep(1);
|
||||||
|
}}
|
||||||
|
className="ml-1.5 text-violet-400 hover:text-violet-300 font-semibold transition-colors"
|
||||||
|
>
|
||||||
|
{mode === 'signin' ? 'Sign Up' : 'Sign In'}
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Security note */}
|
||||||
|
<div className="mt-4 flex items-center justify-center gap-1.5 text-[10px] text-slate-600">
|
||||||
|
<Shield size={10} />
|
||||||
|
<span>Secured with Supabase Auth · FERPA Compliant</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SignInModal;
|
||||||
206
src/components/frameworks/SignLanguage.tsx
Normal file
206
src/components/frameworks/SignLanguage.tsx
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { SIGN_ITEMS } from '@/lib/appData';
|
||||||
|
import { saveProgress, fetchProgress, deleteProgress } from '@/lib/db';
|
||||||
|
import { HandMetal, Search, X, Star, Heart, Loader2, Database, Play } from 'lucide-react';
|
||||||
|
import SignLanguageVideoModal from './SignLanguageVideoModal';
|
||||||
|
|
||||||
|
const SignLanguage: React.FC = () => {
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [categoryFilter, setCategoryFilter] = useState<string>('all');
|
||||||
|
const [selectedSign, setSelectedSign] = useState<string | null>(null);
|
||||||
|
const [learnedSigns, setLearnedSigns] = useState<Set<string>>(new Set());
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const userName = 'Ms. Rodriguez';
|
||||||
|
|
||||||
|
useEffect(() => { loadProgress(); }, []);
|
||||||
|
|
||||||
|
const loadProgress = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const progress = await fetchProgress(userName, 'sign_learned');
|
||||||
|
if (progress.length > 0) {
|
||||||
|
setLearnedSigns(new Set(progress.map((p: any) => p.item_id)));
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const categories = [
|
||||||
|
{ value: 'all', label: 'All Signs', count: SIGN_ITEMS.length },
|
||||||
|
{ value: 'basic-needs', label: 'Basic Needs', count: SIGN_ITEMS.filter(s => s.category === 'basic-needs').length },
|
||||||
|
{ value: 'emotional', label: 'Emotional', count: SIGN_ITEMS.filter(s => s.category === 'emotional').length },
|
||||||
|
{ value: 'classroom', label: 'Classroom', count: SIGN_ITEMS.filter(s => s.category === 'classroom').length },
|
||||||
|
];
|
||||||
|
|
||||||
|
const filtered = SIGN_ITEMS.filter(s => {
|
||||||
|
if (searchQuery && !s.word.toLowerCase().includes(searchQuery.toLowerCase())) return false;
|
||||||
|
if (categoryFilter !== 'all' && s.category !== categoryFilter) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleLearned = async (id: string) => {
|
||||||
|
const next = new Set(learnedSigns);
|
||||||
|
if (next.has(id)) {
|
||||||
|
next.delete(id);
|
||||||
|
await deleteProgress(userName, 'sign_learned', id);
|
||||||
|
} else {
|
||||||
|
next.add(id);
|
||||||
|
await saveProgress({ userName, userRole: 'teacher', progressType: 'sign_learned', itemId: id, value: SIGN_ITEMS.find(s => s.id === id)?.word || '' });
|
||||||
|
}
|
||||||
|
setLearnedSigns(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const categoryColors: Record<string, string> = {
|
||||||
|
'basic-needs': 'bg-blue-100 text-blue-700',
|
||||||
|
'emotional': 'bg-pink-100 text-pink-700',
|
||||||
|
'classroom': 'bg-emerald-100 text-emerald-700',
|
||||||
|
};
|
||||||
|
|
||||||
|
const selected = SIGN_ITEMS.find(s => s.id === selectedSign);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-800 flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-indigo-400 to-indigo-600 flex items-center justify-center">
|
||||||
|
<HandMetal size={20} className="text-white" />
|
||||||
|
</div>
|
||||||
|
Basic Sign Language
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-500 mt-1 flex items-center gap-2">
|
||||||
|
Functional signs for non-verbal communication — click any sign to watch the demonstration
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-emerald-100 text-emerald-700 rounded-lg text-[10px] font-semibold">
|
||||||
|
<Database size={10} /> Progress Saved
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gradient-to-r from-indigo-50 to-violet-50 rounded-2xl border border-indigo-200 p-5">
|
||||||
|
<h3 className="font-bold text-indigo-800 mb-2 flex items-center gap-2">
|
||||||
|
<Heart size={16} /> Remember
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-indigo-700">
|
||||||
|
Accept attempts. Respond immediately. Pair sign with verbal language. Approximation over perfection. Consistency over fluency.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-2xl border border-violet-100 shadow-sm p-5">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h3 className="font-semibold text-gray-800 text-sm">Your Learning Progress</h3>
|
||||||
|
<span className="text-sm font-bold text-indigo-600">{learnedSigns.size}/{SIGN_ITEMS.length} signs</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-3 bg-gray-100 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-gradient-to-r from-indigo-400 to-violet-500 rounded-full transition-all duration-500"
|
||||||
|
style={{ width: `${(learnedSigns.size / SIGN_ITEMS.length) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col md:flex-row gap-3">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search size={18} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={e => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Search signs..."
|
||||||
|
className="w-full pl-10 pr-4 py-2.5 border border-gray-200 rounded-xl text-sm focus:ring-2 focus:ring-indigo-300 focus:border-indigo-400 outline-none bg-white"
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<button onClick={() => setSearchQuery('')} className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600">
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{categories.map(cat => (
|
||||||
|
<button
|
||||||
|
key={cat.value}
|
||||||
|
onClick={() => setCategoryFilter(cat.value)}
|
||||||
|
className={`px-4 py-2 rounded-xl text-sm font-medium transition-all ${
|
||||||
|
categoryFilter === cat.value
|
||||||
|
? 'bg-indigo-500 text-white shadow-sm'
|
||||||
|
: 'bg-white border border-gray-200 text-gray-600 hover:bg-indigo-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{cat.label} ({cat.count})
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-10">
|
||||||
|
<Loader2 size={24} className="animate-spin text-indigo-500" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||||
|
{filtered.map(sign => (
|
||||||
|
<div
|
||||||
|
key={sign.id}
|
||||||
|
onClick={() => setSelectedSign(sign.id)}
|
||||||
|
className={`group bg-white rounded-2xl border shadow-sm overflow-hidden cursor-pointer hover:shadow-lg transition-all duration-300 hover:-translate-y-1 ${
|
||||||
|
learnedSigns.has(sign.id) ? 'border-emerald-200 ring-1 ring-emerald-100' : 'border-violet-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="aspect-[4/5] overflow-hidden relative bg-gradient-to-br from-slate-100 to-slate-50">
|
||||||
|
<img
|
||||||
|
src={sign.image}
|
||||||
|
alt={`Sign language for "${sign.word}"`}
|
||||||
|
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Play button overlay on hover */}
|
||||||
|
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-all duration-300 flex items-center justify-center">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-white/0 group-hover:bg-white/90 flex items-center justify-center transition-all duration-300 scale-50 group-hover:scale-100 opacity-0 group-hover:opacity-100 shadow-lg">
|
||||||
|
<Play size={20} className="text-indigo-600 ml-0.5" fill="currentColor" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Learned badge */}
|
||||||
|
{learnedSigns.has(sign.id) && (
|
||||||
|
<div className="absolute top-2 right-2 w-7 h-7 bg-emerald-500 rounded-full flex items-center justify-center shadow-sm">
|
||||||
|
<Star size={13} className="text-white fill-white" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Category badge */}
|
||||||
|
<span className={`absolute bottom-2 left-2 px-2 py-0.5 rounded-lg text-[10px] font-semibold ${categoryColors[sign.category]}`}>
|
||||||
|
{sign.category.replace('-', ' ')}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Video indicator */}
|
||||||
|
<div className="absolute bottom-2 right-2 flex items-center gap-1 px-2 py-0.5 bg-black/50 backdrop-blur-sm rounded-lg">
|
||||||
|
<Play size={8} className="text-white" fill="white" />
|
||||||
|
<span className="text-[9px] text-white font-medium">
|
||||||
|
{sign.videoSteps.length * (sign.videoSteps[0]?.duration || 3)}s
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3 text-center">
|
||||||
|
<h4 className="font-bold text-gray-800 text-lg">{sign.word}</h4>
|
||||||
|
<p className="text-[10px] text-gray-400 mt-0.5 line-clamp-1">{sign.description}</p>
|
||||||
|
<p className="text-[10px] text-indigo-500 font-medium mt-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
Click to watch demonstration
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Video Modal */}
|
||||||
|
{selected && (
|
||||||
|
<SignLanguageVideoModal
|
||||||
|
sign={selected}
|
||||||
|
isLearned={learnedSigns.has(selected.id)}
|
||||||
|
onClose={() => setSelectedSign(null)}
|
||||||
|
onToggleLearned={toggleLearned}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SignLanguage;
|
||||||
221
src/components/frameworks/SignLanguageVideoModal.tsx
Normal file
221
src/components/frameworks/SignLanguageVideoModal.tsx
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { X, BookOpen, Star, ExternalLink, Play, RefreshCw } from 'lucide-react';
|
||||||
|
import { SignItem } from '@/lib/types';
|
||||||
|
|
||||||
|
interface SignLanguageVideoModalProps {
|
||||||
|
sign: SignItem;
|
||||||
|
isLearned: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onToggleLearned: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SignLanguageVideoModal: React.FC<SignLanguageVideoModalProps> = ({ sign, isLearned, onClose, onToggleLearned }) => {
|
||||||
|
const [showSteps, setShowSteps] = useState(false);
|
||||||
|
const [viewMode, setViewMode] = useState<'gif' | 'video'>('gif');
|
||||||
|
const [gifLoaded, setGifLoaded] = useState(false);
|
||||||
|
const [gifError, setGifError] = useState(false);
|
||||||
|
|
||||||
|
const categoryColors: Record<string, { bg: string; text: string }> = {
|
||||||
|
'basic-needs': { bg: 'bg-blue-100', text: 'text-blue-700' },
|
||||||
|
'emotional': { bg: 'bg-pink-100', text: 'text-pink-700' },
|
||||||
|
'classroom': { bg: 'bg-emerald-100', text: 'text-emerald-700' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const catColor = categoryColors[sign.category] || categoryColors['basic-needs'];
|
||||||
|
|
||||||
|
const youtubeSearchUrl = `https://www.youtube.com/results?search_query=ASL+sign+language+${encodeURIComponent(sign.word)}+tutorial`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4" onClick={onClose}>
|
||||||
|
<div className="bg-white rounded-2xl max-w-2xl w-full shadow-2xl overflow-hidden max-h-[90vh] flex flex-col" onClick={e => e.stopPropagation()}>
|
||||||
|
|
||||||
|
{/* View Mode Toggle */}
|
||||||
|
<div className="flex items-center bg-gray-50 border-b border-gray-100 px-4 py-2">
|
||||||
|
<div className="flex bg-gray-200 rounded-lg p-0.5 gap-0.5">
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('gif')}
|
||||||
|
className={`px-3 py-1.5 rounded-md text-xs font-semibold transition-all ${
|
||||||
|
viewMode === 'gif'
|
||||||
|
? 'bg-white text-indigo-700 shadow-sm'
|
||||||
|
: 'text-gray-500 hover:text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<RefreshCw size={10} className="inline mr-1" />
|
||||||
|
Animated Demo
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('video')}
|
||||||
|
className={`px-3 py-1.5 rounded-md text-xs font-semibold transition-all ${
|
||||||
|
viewMode === 'video'
|
||||||
|
? 'bg-white text-red-600 shadow-sm'
|
||||||
|
: 'text-gray-500 hover:text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Play size={10} className="inline mr-1" />
|
||||||
|
YouTube Video
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto">
|
||||||
|
<span className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold ${catColor.bg} ${catColor.text}`}>
|
||||||
|
{sign.category.replace('-', ' ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content Area */}
|
||||||
|
<div className="relative bg-gradient-to-br from-slate-900 to-slate-800 flex-shrink-0" style={{ minHeight: '300px' }}>
|
||||||
|
|
||||||
|
{viewMode === 'gif' ? (
|
||||||
|
/* Animated GIF Demonstration */
|
||||||
|
<div className="flex items-center justify-center p-6" style={{ minHeight: '300px' }}>
|
||||||
|
{!gifLoaded && !gifError && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center z-10">
|
||||||
|
<div className="text-center space-y-3">
|
||||||
|
<RefreshCw size={28} className="animate-spin text-indigo-400 mx-auto" />
|
||||||
|
<p className="text-white/60 text-sm">Loading demonstration...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!gifError ? (
|
||||||
|
<img
|
||||||
|
src={sign.gifUrl}
|
||||||
|
alt={`Animated demonstration of ASL sign for "${sign.word}"`}
|
||||||
|
className={`max-h-[280px] object-contain rounded-lg transition-opacity duration-300 ${gifLoaded ? 'opacity-100' : 'opacity-0'}`}
|
||||||
|
onLoad={() => setGifLoaded(true)}
|
||||||
|
onError={() => {
|
||||||
|
setGifError(true);
|
||||||
|
setGifLoaded(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
/* Fallback: Show the sign image if GIF fails */
|
||||||
|
<div className="flex flex-col items-center justify-center space-y-4">
|
||||||
|
<img
|
||||||
|
src={sign.image}
|
||||||
|
alt={`Sign for ${sign.word}`}
|
||||||
|
className="max-h-[240px] object-contain rounded-lg"
|
||||||
|
/>
|
||||||
|
<p className="text-white/50 text-xs">Animated demo unavailable — showing reference image</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Looping indicator */}
|
||||||
|
{gifLoaded && !gifError && (
|
||||||
|
<div className="absolute bottom-3 left-3 flex items-center gap-1.5 px-2.5 py-1 bg-black/50 backdrop-blur-sm rounded-lg">
|
||||||
|
<RefreshCw size={10} className="text-emerald-400 animate-spin" style={{ animationDuration: '3s' }} />
|
||||||
|
<span className="text-[10px] text-white/80 font-medium">Looping</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* YouTube Video Embed */
|
||||||
|
<div className="aspect-video w-full">
|
||||||
|
<iframe
|
||||||
|
src={`${sign.videoUrl}?autoplay=1&rel=0&modestbranding=1&playsinline=1`}
|
||||||
|
title={`ASL Sign: ${sign.word}`}
|
||||||
|
className="w-full h-full"
|
||||||
|
frameBorder="0"
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||||
|
allowFullScreen
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Close button */}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute top-3 right-3 w-9 h-9 bg-black/50 hover:bg-black/70 rounded-full flex items-center justify-center transition-colors z-20"
|
||||||
|
>
|
||||||
|
<X size={18} className="text-white" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content Below */}
|
||||||
|
<div className="p-5 space-y-4 overflow-y-auto">
|
||||||
|
{/* Sign name and description */}
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-2xl font-bold text-gray-800">"{sign.word}"</h3>
|
||||||
|
<p className="text-sm text-gray-500 mt-0.5">{sign.description}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isLearned && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-3 py-1 bg-emerald-100 text-emerald-700 rounded-full text-xs font-semibold">
|
||||||
|
<Star size={12} className="fill-emerald-600" /> Learned
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<a
|
||||||
|
href={youtubeSearchUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 px-3 py-1 bg-red-50 text-red-600 hover:bg-red-100 rounded-full text-xs font-semibold transition-colors"
|
||||||
|
>
|
||||||
|
<ExternalLink size={10} /> More on YouTube
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Source credit */}
|
||||||
|
{viewMode === 'gif' && (
|
||||||
|
<div className="flex items-center gap-2 text-[10px] text-gray-400">
|
||||||
|
<span>Animated demonstrations courtesy of</span>
|
||||||
|
<a href="https://www.lifeprint.com" target="_blank" rel="noopener noreferrer" className="text-indigo-500 hover:underline font-medium">
|
||||||
|
ASL University / Lifeprint.com
|
||||||
|
</a>
|
||||||
|
<span>— Dr. Bill Vicars</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Toggle step-by-step guide */}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowSteps(!showSteps)}
|
||||||
|
className="w-full text-left bg-gray-50 hover:bg-gray-100 rounded-xl p-4 transition-colors"
|
||||||
|
>
|
||||||
|
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wider flex items-center justify-between">
|
||||||
|
Step-by-Step Guide
|
||||||
|
<svg className={`w-4 h-4 transition-transform ${showSteps ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</h4>
|
||||||
|
{showSteps && (
|
||||||
|
<div className="space-y-2 mt-3">
|
||||||
|
{sign.videoSteps.map((step) => (
|
||||||
|
<div key={step.step} className="flex items-start gap-3 p-2.5 rounded-lg bg-white border border-gray-100">
|
||||||
|
<div className="flex-shrink-0 w-7 h-7 rounded-full bg-indigo-500 text-white flex items-center justify-center text-xs font-bold">
|
||||||
|
{step.step}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-700 leading-snug">{step.instruction}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Teaching tip */}
|
||||||
|
<div className="bg-amber-50 rounded-xl p-4 border border-amber-100">
|
||||||
|
<h4 className="font-semibold text-amber-800 text-sm mb-1 flex items-center gap-2">
|
||||||
|
<BookOpen size={14} /> Teaching Tip
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-amber-700">{sign.tip}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action button */}
|
||||||
|
<button
|
||||||
|
onClick={() => onToggleLearned(sign.id)}
|
||||||
|
className={`w-full flex items-center justify-center gap-2 px-4 py-3 rounded-xl font-medium text-sm transition-all ${
|
||||||
|
isLearned
|
||||||
|
? 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||||
|
: 'bg-gradient-to-r from-emerald-500 to-emerald-600 text-white hover:shadow-lg hover:shadow-emerald-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Star size={16} className={isLearned ? '' : 'fill-white'} />
|
||||||
|
{isLearned ? 'Remove from Learned' : 'Mark as Learned'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SignLanguageVideoModal;
|
||||||
247
src/components/frameworks/TopBar.tsx
Normal file
247
src/components/frameworks/TopBar.tsx
Normal file
@ -0,0 +1,247 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { UserRole } from '@/lib/types';
|
||||||
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
import { getCampusByMascot } from '@/lib/appData';
|
||||||
|
import SignInModal from '@/components/frameworks/SignInModal';
|
||||||
|
import {
|
||||||
|
Bell, Search, ChevronDown, User, LogOut, LogIn, Settings,
|
||||||
|
Menu, Shield, Building2, Wifi
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface TopBarProps {
|
||||||
|
userRole: UserRole;
|
||||||
|
userName: string;
|
||||||
|
userCampus: string;
|
||||||
|
toggleSidebar: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TopBar: React.FC<TopBarProps> = ({ userRole, userName, userCampus, toggleSidebar }) => {
|
||||||
|
const { isAuthenticated, signOut, profile } = useAuth();
|
||||||
|
const [showProfileMenu, setShowProfileMenu] = useState(false);
|
||||||
|
const [showNotifications, setShowNotifications] = useState(false);
|
||||||
|
const [showSignInModal, setShowSignInModal] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
|
const campusInfo = getCampusByMascot(userCampus);
|
||||||
|
|
||||||
|
const roleLabels: Record<UserRole, { label: string; color: string; bg: string }> = {
|
||||||
|
teacher: { label: 'Teacher', color: 'text-emerald-400', bg: 'bg-emerald-500/15 border-emerald-500/20' },
|
||||||
|
para: { label: 'Support Staff', color: 'text-blue-400', bg: 'bg-blue-500/15 border-blue-500/20' },
|
||||||
|
office: { label: 'Office Manager', color: 'text-amber-400', bg: 'bg-amber-500/15 border-amber-500/20' },
|
||||||
|
director: { label: 'Director', color: 'text-purple-400', bg: 'bg-purple-500/15 border-purple-500/20' },
|
||||||
|
superintendent: { label: 'Superintendent', color: 'text-rose-400', bg: 'bg-rose-500/15 border-rose-500/20' },
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const notifications = [
|
||||||
|
{ id: '1', text: 'QBS weekly quiz is due by Friday', time: '2 hours ago', unread: true },
|
||||||
|
{ id: '2', text: 'New F.R.A.M.E. entry posted for this week', time: '1 day ago', unread: true },
|
||||||
|
{ id: '3', text: 'Fire drill scheduled for tomorrow', time: '1 day ago', unread: false },
|
||||||
|
{ id: '4', text: 'All-Staff Meeting reminder: Wednesday 3:30 PM', time: '2 days ago', unread: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
const unreadCount = notifications.filter(n => n.unread).length;
|
||||||
|
|
||||||
|
const handleSignOut = async () => {
|
||||||
|
setShowProfileMenu(false);
|
||||||
|
await signOut();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getInitials = (name: string) => {
|
||||||
|
return name.split(' ').map(n => n[0]).join('').slice(0, 2).toUpperCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<header className="h-16 bg-slate-900/95 backdrop-blur-xl border-b border-slate-700/50 flex items-center justify-between px-4 md:px-6 relative z-20">
|
||||||
|
{/* Left */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button onClick={toggleSidebar} className="lg:hidden p-2 rounded-xl hover:bg-slate-800 text-slate-400">
|
||||||
|
<Menu size={20} />
|
||||||
|
</button>
|
||||||
|
<div className="hidden md:flex relative">
|
||||||
|
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={e => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Search modules, strategies, signs..."
|
||||||
|
className="pl-9 pr-4 py-2 w-72 bg-slate-800/80 border border-slate-700/50 rounded-xl text-sm text-white placeholder-slate-500 focus:ring-2 focus:ring-violet-500/50 focus:border-violet-500/50 outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{isAuthenticated ? (
|
||||||
|
<>
|
||||||
|
{/* Campus Badge */}
|
||||||
|
{campusInfo && (
|
||||||
|
<div className={`hidden sm:flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-semibold border ${campusInfo.borderColor} ${campusInfo.bgLight} ${campusInfo.textColor}`}>
|
||||||
|
<div className={`w-4 h-4 rounded bg-gradient-to-br ${campusInfo.bgGradient} flex items-center justify-center`}>
|
||||||
|
<span className="text-white text-[8px] font-bold">{campusInfo.mascot[0]}</span>
|
||||||
|
</div>
|
||||||
|
{campusInfo.mascot}
|
||||||
|
{campusInfo.isOnline && <Wifi size={10} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Role Badge */}
|
||||||
|
<div className={`hidden sm:flex items-center gap-2 px-3 py-1.5 rounded-xl text-xs font-semibold border ${roleLabels[userRole].bg} ${roleLabels[userRole].color}`}>
|
||||||
|
<Shield size={14} />
|
||||||
|
{roleLabels[userRole].label}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notifications */}
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowNotifications(!showNotifications)}
|
||||||
|
className="relative p-2 rounded-xl hover:bg-slate-800 text-slate-400 transition-colors"
|
||||||
|
>
|
||||||
|
<Bell size={20} />
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<span className="absolute -top-0.5 -right-0.5 bg-red-500 text-white text-[9px] font-bold rounded-full flex items-center justify-center min-w-[18px] h-[18px] shadow-lg shadow-red-500/40">
|
||||||
|
{unreadCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{showNotifications && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-30" onClick={() => setShowNotifications(false)} />
|
||||||
|
<div className="absolute right-0 top-full mt-2 w-80 bg-slate-800 rounded-xl shadow-2xl shadow-black/40 border border-slate-700/50 z-40 overflow-hidden">
|
||||||
|
<div className="px-4 py-3 border-b border-slate-700/50 flex items-center justify-between">
|
||||||
|
<h3 className="font-semibold text-sm text-white">Notifications</h3>
|
||||||
|
<span className="text-xs text-violet-400 font-medium">{unreadCount} new</span>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-72 overflow-y-auto">
|
||||||
|
{notifications.map(notif => (
|
||||||
|
<div
|
||||||
|
key={notif.id}
|
||||||
|
className={`px-4 py-3 border-b border-slate-700/30 hover:bg-slate-700/30 transition-colors ${
|
||||||
|
notif.unread ? 'bg-violet-500/5' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
{notif.unread && <span className="w-2 h-2 rounded-full bg-violet-500 mt-1.5 flex-shrink-0 shadow-sm shadow-violet-500/50" />}
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-slate-300">{notif.text}</p>
|
||||||
|
<p className="text-[10px] text-slate-500 mt-0.5">{notif.time}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User Profile Menu */}
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowProfileMenu(!showProfileMenu)}
|
||||||
|
className="flex items-center gap-2 pl-2 border-l border-slate-700/50 hover:bg-slate-800/50 rounded-xl pr-2 py-1 transition-colors"
|
||||||
|
>
|
||||||
|
<div className={`w-8 h-8 rounded-xl flex items-center justify-center text-white text-xs font-bold shadow-lg ${
|
||||||
|
campusInfo
|
||||||
|
? `bg-gradient-to-br ${campusInfo.bgGradient} shadow-${campusInfo.color}/20`
|
||||||
|
: 'bg-gradient-to-br from-violet-500 to-amber-400 shadow-violet-500/20'
|
||||||
|
}`}>
|
||||||
|
{getInitials(userName)}
|
||||||
|
</div>
|
||||||
|
<div className="hidden md:block text-left">
|
||||||
|
<p className="text-sm font-medium text-slate-200 leading-tight">{userName}</p>
|
||||||
|
<p className="text-[10px] text-slate-500 leading-tight">
|
||||||
|
{campusInfo ? campusInfo.fullName : userCampus}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ChevronDown size={14} className="text-slate-500 hidden md:block" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showProfileMenu && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-30" onClick={() => setShowProfileMenu(false)} />
|
||||||
|
<div className="absolute right-0 top-full mt-2 w-64 bg-slate-800 rounded-xl shadow-2xl shadow-black/40 border border-slate-700/50 py-2 z-40">
|
||||||
|
{/* Profile Info */}
|
||||||
|
<div className="px-4 py-3 border-b border-slate-700/50">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`w-10 h-10 rounded-xl flex items-center justify-center text-white text-sm font-bold shadow-lg ${
|
||||||
|
campusInfo
|
||||||
|
? `bg-gradient-to-br ${campusInfo.bgGradient}`
|
||||||
|
: 'bg-gradient-to-br from-violet-500 to-amber-400 shadow-violet-500/20'
|
||||||
|
}`}>
|
||||||
|
{getInitials(userName)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-white">{userName}</p>
|
||||||
|
<p className="text-[10px] text-slate-400">{profile?.role === 'para' ? 'Support Staff' : profile?.role === 'office' ? 'Office Manager' : profile?.role}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Campus info in dropdown */}
|
||||||
|
{campusInfo && (
|
||||||
|
<div className={`mt-2 flex items-center gap-1.5 text-[10px] ${campusInfo.textColor}`}>
|
||||||
|
<div className={`w-3 h-3 rounded bg-gradient-to-br ${campusInfo.bgGradient}`} />
|
||||||
|
<span className="font-semibold">{campusInfo.mascot}</span>
|
||||||
|
{campusInfo.isOnline && (
|
||||||
|
<>
|
||||||
|
<Wifi size={8} />
|
||||||
|
<span className="text-slate-500">(Online)</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!campusInfo && (
|
||||||
|
<div className="mt-2 flex items-center gap-1.5 text-[10px] text-slate-500">
|
||||||
|
<Building2 size={10} />
|
||||||
|
<span>{userCampus}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Menu Items */}
|
||||||
|
<div className="py-1">
|
||||||
|
<button className="w-full text-left px-4 py-2.5 flex items-center gap-3 hover:bg-slate-700/50 transition-colors text-slate-300">
|
||||||
|
<User size={16} className="text-slate-500" />
|
||||||
|
<span className="text-sm">My Profile</span>
|
||||||
|
</button>
|
||||||
|
<button className="w-full text-left px-4 py-2.5 flex items-center gap-3 hover:bg-slate-700/50 transition-colors text-slate-300">
|
||||||
|
<Settings size={16} className="text-slate-500" />
|
||||||
|
<span className="text-sm">Settings</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sign Out */}
|
||||||
|
<div className="border-t border-slate-700/50 pt-1">
|
||||||
|
<button
|
||||||
|
onClick={handleSignOut}
|
||||||
|
className="w-full text-left px-4 py-2.5 flex items-center gap-3 hover:bg-red-500/10 transition-colors text-red-400"
|
||||||
|
>
|
||||||
|
<LogOut size={16} />
|
||||||
|
<span className="text-sm font-medium">Sign Out</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
/* Not Authenticated - Show Sign In Button */
|
||||||
|
<button
|
||||||
|
onClick={() => setShowSignInModal(true)}
|
||||||
|
className="flex items-center gap-2 px-5 py-2 bg-gradient-to-r from-violet-500 to-amber-500 hover:from-violet-600 hover:to-amber-600 text-white font-semibold rounded-xl transition-all duration-200 shadow-lg shadow-violet-500/25 hover:shadow-violet-500/40 text-sm"
|
||||||
|
>
|
||||||
|
<LogIn size={16} />
|
||||||
|
Sign In
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Sign In Modal */}
|
||||||
|
<SignInModal isOpen={showSignInModal} onClose={() => setShowSignInModal(false)} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TopBar;
|
||||||
574
src/components/frameworks/VocationalOpportunities.tsx
Normal file
574
src/components/frameworks/VocationalOpportunities.tsx
Normal file
@ -0,0 +1,574 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Briefcase, Search, MapPin, Building2, Phone, Mail, ExternalLink,
|
||||||
|
ChevronDown, ChevronUp, Star, Clock, DollarSign, Users, CheckCircle,
|
||||||
|
Loader2, Filter, GraduationCap, Wrench, ShoppingBag, Leaf, Heart,
|
||||||
|
UtensilsCrossed, Truck, Monitor, Palette
|
||||||
|
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface VocationalOpp {
|
||||||
|
id: string;
|
||||||
|
company: string;
|
||||||
|
title: string;
|
||||||
|
category: string;
|
||||||
|
description: string;
|
||||||
|
address: string;
|
||||||
|
zipCode: string;
|
||||||
|
phone: string;
|
||||||
|
email: string;
|
||||||
|
website: string;
|
||||||
|
distance: string;
|
||||||
|
schedule: string;
|
||||||
|
compensation: string;
|
||||||
|
skills: string[];
|
||||||
|
requirements: string[];
|
||||||
|
accommodations: string[];
|
||||||
|
ageGroup: string;
|
||||||
|
spots: number;
|
||||||
|
featured: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VOCATIONAL_DATA: VocationalOpp[] = [
|
||||||
|
{
|
||||||
|
id: '1', company: 'Green Thumb Nursery', title: 'Horticulture Assistant',
|
||||||
|
category: 'Agriculture & Gardening',
|
||||||
|
description: 'Hands-on plant care, watering, potting, and greenhouse maintenance. Structured tasks with visual checklists. Calm, sensory-friendly outdoor environment.',
|
||||||
|
address: '1200 Garden Way, Phoenix, AZ 85001', zipCode: '85001',
|
||||||
|
phone: '(602) 555-0211', email: 'jobs@greenthumb.com', website: 'greenthumbphx.com',
|
||||||
|
distance: '1.5 mi', schedule: 'Mon-Wed, 9:00 AM - 12:00 PM', compensation: 'Stipend + School Credit',
|
||||||
|
skills: ['Plant identification', 'Watering schedules', 'Soil preparation', 'Tool handling'],
|
||||||
|
requirements: ['Interest in plants/nature', 'Ability to follow visual schedules', 'Comfortable outdoors'],
|
||||||
|
accommodations: ['Visual task boards', 'Noise-canceling headphones available', 'Flexible break schedule', 'Job coach welcome'],
|
||||||
|
ageGroup: '14-18', spots: 4, featured: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2', company: 'Sunrise Bakery & Cafe', title: 'Kitchen Prep Assistant',
|
||||||
|
category: 'Food Service',
|
||||||
|
description: 'Learn food preparation basics including measuring, mixing, and packaging. Work alongside experienced bakers in a structured, supportive environment.',
|
||||||
|
address: '890 Main St, Phoenix, AZ 85003', zipCode: '85003',
|
||||||
|
phone: '(602) 555-0234', email: 'hiring@sunrisebakery.com', website: 'sunrisebakerycafe.com',
|
||||||
|
distance: '2.8 mi', schedule: 'Tue-Thu, 8:00 AM - 11:00 AM', compensation: 'Minimum Wage',
|
||||||
|
skills: ['Food safety basics', 'Measuring ingredients', 'Following recipes', 'Kitchen cleanliness'],
|
||||||
|
requirements: ['Food handler card (training provided)', 'Comfortable in kitchen environment', 'Basic hygiene practices'],
|
||||||
|
accommodations: ['Step-by-step visual recipes', 'Sensory-friendly uniform options', 'Quiet break room', 'Structured task rotation'],
|
||||||
|
ageGroup: '16-22', spots: 3, featured: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3', company: 'PetSmart — East Phoenix', title: 'Pet Care Associate Trainee',
|
||||||
|
category: 'Animal Care',
|
||||||
|
description: 'Assist with pet care tasks including feeding, habitat cleaning, and customer greeting. Great for animal lovers who thrive with routine tasks.',
|
||||||
|
address: '3400 E Thomas Rd, Phoenix, AZ 85018', zipCode: '85018',
|
||||||
|
phone: '(602) 555-0267', email: 'careers@petsmart-eastphx.com', website: 'petsmart.com',
|
||||||
|
distance: '4.1 mi', schedule: 'Mon/Wed/Fri, 10:00 AM - 1:00 PM', compensation: 'Minimum Wage',
|
||||||
|
skills: ['Animal handling basics', 'Habitat maintenance', 'Inventory stocking', 'Customer interaction'],
|
||||||
|
requirements: ['Comfort around animals', 'Ability to follow cleaning protocols', 'Basic communication skills'],
|
||||||
|
accommodations: ['Structured daily routine', 'Visual checklists for all tasks', 'Designated quiet area', 'Gradual customer interaction exposure'],
|
||||||
|
ageGroup: '16-22', spots: 2, featured: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4', company: 'Goodwill Industries — Phoenix', title: 'Retail & Sorting Associate',
|
||||||
|
category: 'Retail',
|
||||||
|
description: 'Sort donated items, organize shelves, and assist with store operations. Highly structured environment with clear task expectations.',
|
||||||
|
address: '2100 W Camelback Rd, Phoenix, AZ 85015', zipCode: '85015',
|
||||||
|
phone: '(602) 555-0289', email: 'employment@goodwillphx.org', website: 'goodwillaz.org',
|
||||||
|
distance: '5.3 mi', schedule: 'Mon-Fri, 9:00 AM - 12:00 PM (flexible)', compensation: 'Minimum Wage + Benefits Training',
|
||||||
|
skills: ['Sorting & categorizing', 'Shelf organization', 'Price tagging', 'Customer service basics'],
|
||||||
|
requirements: ['Ability to stand for moderate periods', 'Basic sorting skills', 'Willingness to learn'],
|
||||||
|
accommodations: ['Job coach on-site', 'Visual work stations', 'Flexible scheduling', 'Gradual responsibility increase', 'Sensory break room'],
|
||||||
|
ageGroup: '16-22', spots: 6, featured: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '5', company: 'Desert Auto Detail', title: 'Auto Detailing Trainee',
|
||||||
|
category: 'Automotive',
|
||||||
|
description: 'Learn vehicle cleaning and detailing skills including washing, vacuuming, and interior cleaning. Repetitive, structured tasks ideal for routine-oriented workers.',
|
||||||
|
address: '780 S 16th St, Phoenix, AZ 85034', zipCode: '85034',
|
||||||
|
phone: '(602) 555-0301', email: 'train@desertautodetail.com', website: 'desertautodetail.com',
|
||||||
|
distance: '3.6 mi', schedule: 'Tue/Thu, 9:00 AM - 12:00 PM', compensation: 'Stipend',
|
||||||
|
skills: ['Vehicle washing techniques', 'Interior cleaning', 'Attention to detail', 'Tool maintenance'],
|
||||||
|
requirements: ['Comfortable with water/cleaning products', 'Ability to follow step-by-step process', 'Physical stamina for standing'],
|
||||||
|
accommodations: ['Visual process charts', 'Noise protection provided', 'Structured break times', 'One-on-one training period'],
|
||||||
|
ageGroup: '16-22', spots: 3, featured: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '6', company: 'Phoenix Public Library System', title: 'Library Assistant Intern',
|
||||||
|
category: 'Office & Administrative',
|
||||||
|
description: 'Shelve books, organize materials, assist with program setup, and learn basic library operations. Quiet, structured environment perfect for detail-oriented individuals.',
|
||||||
|
address: '1221 N Central Ave, Phoenix, AZ 85004', zipCode: '85004',
|
||||||
|
phone: '(602) 555-0312', email: 'internships@phoenixlib.org', website: 'phoenixpubliclibrary.org',
|
||||||
|
distance: '4.8 mi', schedule: 'Mon/Wed, 1:00 PM - 4:00 PM', compensation: 'School Credit + Stipend',
|
||||||
|
skills: ['Alphabetical/numerical sorting', 'Shelf organization', 'Computer basics', 'Quiet customer service'],
|
||||||
|
requirements: ['Comfort in quiet environments', 'Basic reading skills', 'Attention to detail'],
|
||||||
|
accommodations: ['Predictable daily schedule', 'Written task instructions', 'Low-stimulation environment', 'Flexible pacing'],
|
||||||
|
ageGroup: '14-22', spots: 4, featured: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '7', company: 'Home Depot — Tempe', title: 'Garden Center Associate Trainee',
|
||||||
|
category: 'Retail',
|
||||||
|
description: 'Water plants, organize garden supplies, assist customers with plant selection. Combines outdoor work with retail skills in a supportive team environment.',
|
||||||
|
address: '1800 E Baseline Rd, Tempe, AZ 85283', zipCode: '85283',
|
||||||
|
phone: '(480) 555-0178', email: 'careers@homedepot-tempe.com', website: 'homedepot.com',
|
||||||
|
distance: '7.2 mi', schedule: 'Sat/Sun, 8:00 AM - 12:00 PM', compensation: 'Minimum Wage',
|
||||||
|
skills: ['Plant watering', 'Inventory organization', 'Basic customer interaction', 'Cart management'],
|
||||||
|
requirements: ['Comfortable outdoors', 'Ability to lift 20 lbs', 'Basic communication'],
|
||||||
|
accommodations: ['Buddy system with experienced associate', 'Visual task cards', 'Scheduled breaks', 'Gradual customer exposure'],
|
||||||
|
ageGroup: '16-22', spots: 2, featured: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '8', company: 'Creative Sparks Art Studio', title: 'Studio Assistant',
|
||||||
|
category: 'Arts & Creative',
|
||||||
|
description: 'Help organize art supplies, prepare workstations, and assist with class setup. Creative environment that values different perspectives and abilities.',
|
||||||
|
address: '456 E Roosevelt St, Phoenix, AZ 85004', zipCode: '85004',
|
||||||
|
phone: '(602) 555-0345', email: 'studio@creativesparks.com', website: 'creativesparksphx.com',
|
||||||
|
distance: '3.9 mi', schedule: 'Wed/Fri, 10:00 AM - 1:00 PM', compensation: 'Stipend + Free Classes',
|
||||||
|
skills: ['Supply organization', 'Color sorting', 'Workspace preparation', 'Basic art techniques'],
|
||||||
|
requirements: ['Interest in art/creativity', 'Ability to follow setup procedures', 'Comfortable around groups'],
|
||||||
|
accommodations: ['Sensory-friendly workspace', 'Visual organization systems', 'Flexible creative expression', 'Quiet prep time available'],
|
||||||
|
ageGroup: '14-22', spots: 3, featured: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '9', company: 'FedEx Ground — Phoenix Hub', title: 'Package Handler Trainee',
|
||||||
|
category: 'Warehouse & Logistics',
|
||||||
|
description: 'Sort and organize packages in a structured warehouse environment. Repetitive, physical tasks with clear expectations and team support.',
|
||||||
|
address: '4700 E Cotton Center Blvd, Phoenix, AZ 85040', zipCode: '85040',
|
||||||
|
phone: '(602) 555-0378', email: 'jobs@fedexphx.com', website: 'fedex.com/careers',
|
||||||
|
distance: '9.1 mi', schedule: 'Mon-Fri, 6:00 AM - 10:00 AM', compensation: 'Above Minimum Wage + Benefits',
|
||||||
|
skills: ['Package sorting', 'Label reading', 'Physical stamina', 'Team coordination'],
|
||||||
|
requirements: ['Ability to lift 35 lbs', 'Reliable attendance', 'Comfortable in warehouse setting'],
|
||||||
|
accommodations: ['Noise protection provided', 'Visual sorting guides', 'Structured break schedule', 'Job coach allowed on-site'],
|
||||||
|
ageGroup: '18-22', spots: 5, featured: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '10', company: 'Valley Tech Recycling', title: 'Electronics Recycling Technician Trainee',
|
||||||
|
category: 'Technology',
|
||||||
|
description: 'Learn to disassemble, sort, and process electronic devices for recycling. Detail-oriented work perfect for individuals who enjoy taking things apart.',
|
||||||
|
address: '1500 W Buckeye Rd, Phoenix, AZ 85007', zipCode: '85007',
|
||||||
|
phone: '(602) 555-0390', email: 'careers@valleytechrecycle.com', website: 'valleytechrecycling.com',
|
||||||
|
distance: '4.5 mi', schedule: 'Tue/Thu, 9:00 AM - 12:00 PM', compensation: 'Stipend + Certification',
|
||||||
|
skills: ['Component identification', 'Basic tool use', 'Sorting & categorizing', 'Safety protocols'],
|
||||||
|
requirements: ['Interest in technology', 'Fine motor skills', 'Ability to follow safety procedures'],
|
||||||
|
accommodations: ['Structured workstation', 'Visual disassembly guides', 'Noise-controlled environment', 'Self-paced work'],
|
||||||
|
ageGroup: '16-22', spots: 4, featured: true
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const categoryIcons: Record<string, React.ReactNode> = {
|
||||||
|
'Agriculture & Gardening': <Leaf size={16} />,
|
||||||
|
'Food Service': <UtensilsCrossed size={16} />,
|
||||||
|
'Animal Care': <Heart size={16} />,
|
||||||
|
'Retail': <ShoppingBag size={16} />,
|
||||||
|
'Automotive': <Wrench size={16} />,
|
||||||
|
'Office & Administrative': <Monitor size={16} />,
|
||||||
|
'Arts & Creative': <Palette size={16} />,
|
||||||
|
'Warehouse & Logistics': <Truck size={16} />,
|
||||||
|
'Technology': <Monitor size={16} />,
|
||||||
|
};
|
||||||
|
|
||||||
|
const VocationalOpportunities: React.FC = () => {
|
||||||
|
const [zipCode, setZipCode] = useState('');
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [categoryFilter, setCategoryFilter] = useState('all');
|
||||||
|
const [expandedOpp, setExpandedOpp] = useState<string | null>(null);
|
||||||
|
const [savedOpps, setSavedOpps] = useState<Set<string>>(new Set());
|
||||||
|
const [hasSearched, setHasSearched] = useState(false);
|
||||||
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
|
const [results, setResults] = useState<VocationalOpp[]>([]);
|
||||||
|
|
||||||
|
const categories = [...new Set(VOCATIONAL_DATA.map(o => o.category))];
|
||||||
|
|
||||||
|
const handleZipSearch = () => {
|
||||||
|
if (!zipCode.trim()) return;
|
||||||
|
setIsSearching(true);
|
||||||
|
// Simulate API call delay
|
||||||
|
setTimeout(() => {
|
||||||
|
// Filter by zip code proximity (simulated - show all results for any valid zip)
|
||||||
|
const zipNum = parseInt(zipCode);
|
||||||
|
let filtered = VOCATIONAL_DATA;
|
||||||
|
if (zipNum >= 85001 && zipNum <= 85099) {
|
||||||
|
// Phoenix area - show all results sorted by distance
|
||||||
|
filtered = [...VOCATIONAL_DATA].sort((a, b) => parseFloat(a.distance) - parseFloat(b.distance));
|
||||||
|
} else if (zipNum >= 85200 && zipNum <= 85299) {
|
||||||
|
// Tempe/Mesa area - show results with adjusted distances
|
||||||
|
filtered = VOCATIONAL_DATA.map(opp => ({
|
||||||
|
...opp,
|
||||||
|
distance: (parseFloat(opp.distance) + 5).toFixed(1) + ' mi'
|
||||||
|
})).sort((a, b) => parseFloat(a.distance) - parseFloat(b.distance));
|
||||||
|
} else {
|
||||||
|
// Other areas - show all with larger distances
|
||||||
|
filtered = VOCATIONAL_DATA.map(opp => ({
|
||||||
|
...opp,
|
||||||
|
distance: (parseFloat(opp.distance) + 15).toFixed(1) + ' mi'
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
setResults(filtered);
|
||||||
|
setHasSearched(true);
|
||||||
|
setIsSearching(false);
|
||||||
|
}, 1200);
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayResults = results.filter(opp => {
|
||||||
|
const matchesSearch = searchQuery === '' ||
|
||||||
|
opp.company.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
opp.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
opp.category.toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
|
const matchesCategory = categoryFilter === 'all' || opp.category === categoryFilter;
|
||||||
|
return matchesSearch && matchesCategory;
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleSave = (id: string) => {
|
||||||
|
const next = new Set(savedOpps);
|
||||||
|
if (next.has(id)) next.delete(id); else next.add(id);
|
||||||
|
setSavedOpps(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-white flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-sky-400 to-blue-600 flex items-center justify-center shadow-lg shadow-sky-500/30">
|
||||||
|
<Briefcase size={20} className="text-white" />
|
||||||
|
</div>
|
||||||
|
Vocational Opportunities
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-slate-400 mt-1">Find local vocational training and job opportunities for students with autism and special needs</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Banner */}
|
||||||
|
<div className="bg-gradient-to-r from-sky-500/10 via-blue-500/10 to-indigo-500/10 rounded-2xl border border-sky-500/20 p-5">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-sky-400 to-blue-500 flex items-center justify-center flex-shrink-0 shadow-lg shadow-sky-500/30">
|
||||||
|
<GraduationCap size={22} className="text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-sky-400 text-sm uppercase tracking-wider">Transition to Employment</h3>
|
||||||
|
<p className="text-slate-300 text-sm mt-1 leading-relaxed">
|
||||||
|
Enter your zip code to discover vocational training programs, job placements, and internship opportunities in your area. All listings include autism-specific accommodations and support information.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Zip Code Search */}
|
||||||
|
<div className="bg-slate-800/40 backdrop-blur-sm rounded-2xl border border-slate-700/40 p-6">
|
||||||
|
<h3 className="font-semibold text-white mb-4 flex items-center gap-2">
|
||||||
|
<MapPin size={18} className="text-sky-400" />
|
||||||
|
Search by Zip Code
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3">
|
||||||
|
<div className="relative flex-1 max-w-xs">
|
||||||
|
<MapPin size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={zipCode}
|
||||||
|
onChange={e => {
|
||||||
|
const val = e.target.value.replace(/\D/g, '').slice(0, 5);
|
||||||
|
setZipCode(val);
|
||||||
|
}}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') handleZipSearch(); }}
|
||||||
|
placeholder="Enter zip code (e.g., 85001)"
|
||||||
|
className="w-full pl-9 pr-4 py-3 bg-slate-700/50 border border-slate-600/50 rounded-xl text-sm text-white placeholder-slate-500 focus:ring-2 focus:ring-sky-500/50 focus:border-sky-500/50 outline-none text-lg tracking-wider"
|
||||||
|
maxLength={5}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleZipSearch}
|
||||||
|
disabled={zipCode.length < 5 || isSearching}
|
||||||
|
className="px-8 py-3 bg-gradient-to-r from-sky-500 to-blue-600 text-white rounded-xl font-semibold text-sm hover:shadow-lg hover:shadow-sky-500/25 transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{isSearching ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={16} className="animate-spin" />
|
||||||
|
Searching...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Search size={16} />
|
||||||
|
Find Opportunities
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500 mt-2">Try zip codes: 85001, 85003, 85004, 85015, 85018, 85034, 85040, 85283</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Loading State */}
|
||||||
|
{isSearching && (
|
||||||
|
<div className="bg-slate-800/40 backdrop-blur-sm rounded-2xl border border-slate-700/40 p-12 text-center">
|
||||||
|
<Loader2 size={40} className="animate-spin text-sky-400 mx-auto mb-4" />
|
||||||
|
<h3 className="font-semibold text-white mb-1">Searching for opportunities near {zipCode}...</h3>
|
||||||
|
<p className="text-sm text-slate-400">Finding vocational programs and job placements in your area</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
{hasSearched && !isSearching && (
|
||||||
|
<>
|
||||||
|
{/* Results Header & Filters */}
|
||||||
|
<div className="bg-slate-800/40 backdrop-blur-sm rounded-2xl border border-slate-700/40 p-5">
|
||||||
|
<div className="flex flex-col md:flex-row gap-3 items-start md:items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-white">
|
||||||
|
{displayResults.length} Opportunit{displayResults.length !== 1 ? 'ies' : 'y'} Found Near {zipCode}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5">Showing vocational programs with autism-specific accommodations</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowFilters(!showFilters)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-slate-700/50 border border-slate-600/50 rounded-xl text-sm text-slate-300 hover:bg-slate-700 transition-colors"
|
||||||
|
>
|
||||||
|
<Filter size={14} />
|
||||||
|
Filters
|
||||||
|
{showFilters ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showFilters && (
|
||||||
|
<div className="mt-4 pt-4 border-t border-slate-700/40 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2 block">Search</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={e => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Search by company, title, or category..."
|
||||||
|
className="w-full pl-9 pr-4 py-2 bg-slate-700/50 border border-slate-600/50 rounded-xl text-sm text-white placeholder-slate-500 focus:ring-2 focus:ring-sky-500/50 outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2 block">Category</label>
|
||||||
|
<select
|
||||||
|
value={categoryFilter}
|
||||||
|
onChange={e => setCategoryFilter(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-slate-700/50 border border-slate-600/50 rounded-xl text-sm text-white focus:ring-2 focus:ring-sky-500/50 outline-none"
|
||||||
|
>
|
||||||
|
<option value="all">All Categories</option>
|
||||||
|
{categories.map(cat => <option key={cat} value={cat}>{cat}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
{[
|
||||||
|
{ label: 'Total Positions', value: displayResults.reduce((sum, o) => sum + o.spots, 0).toString(), color: 'from-sky-500 to-blue-600', icon: <Briefcase size={18} /> },
|
||||||
|
{ label: 'Companies', value: displayResults.length.toString(), color: 'from-blue-500 to-indigo-600', icon: <Building2 size={18} /> },
|
||||||
|
{ label: 'Categories', value: new Set(displayResults.map(o => o.category)).size.toString(), color: 'from-violet-500 to-purple-600', icon: <GraduationCap size={18} /> },
|
||||||
|
{ label: 'Saved', value: savedOpps.size.toString(), color: 'from-amber-500 to-orange-600', icon: <Star size={18} /> },
|
||||||
|
].map((stat, i) => (
|
||||||
|
<div key={i} className="bg-slate-800/40 backdrop-blur-sm rounded-2xl border border-slate-700/40 p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`w-10 h-10 rounded-xl bg-gradient-to-br ${stat.color} flex items-center justify-center text-white shadow-lg`}>{stat.icon}</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold text-white">{stat.value}</p>
|
||||||
|
<p className="text-xs text-slate-500">{stat.label}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Opportunity Cards */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{displayResults.map(opp => (
|
||||||
|
<div key={opp.id} className={`bg-slate-800/40 backdrop-blur-sm rounded-2xl border transition-all duration-200 overflow-hidden ${
|
||||||
|
expandedOpp === opp.id ? 'border-sky-500/30' : 'border-slate-700/40 hover:border-slate-600/50'
|
||||||
|
}`}>
|
||||||
|
<div
|
||||||
|
className="p-5 cursor-pointer"
|
||||||
|
onClick={() => setExpandedOpp(expandedOpp === opp.id ? null : opp.id)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex items-start gap-4 min-w-0 flex-1">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-sky-500/20 to-blue-500/20 border border-sky-500/20 flex items-center justify-center flex-shrink-0 text-sky-400">
|
||||||
|
{categoryIcons[opp.category] || <Briefcase size={16} />}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<h3 className="font-semibold text-white text-base">{opp.title}</h3>
|
||||||
|
{opp.featured && (
|
||||||
|
<span className="px-2 py-0.5 bg-amber-500/15 text-amber-400 border border-amber-500/30 rounded-lg text-[10px] font-semibold">FEATURED</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-sky-400 font-medium">{opp.company}</p>
|
||||||
|
<div className="flex items-center gap-3 mt-1.5 flex-wrap">
|
||||||
|
<span className="text-xs text-slate-500 flex items-center gap-1">
|
||||||
|
<MapPin size={10} /> {opp.distance}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-500 flex items-center gap-1">
|
||||||
|
<Clock size={10} /> {opp.schedule}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-500 flex items-center gap-1">
|
||||||
|
<DollarSign size={10} /> {opp.compensation}
|
||||||
|
</span>
|
||||||
|
<span className="px-2 py-0.5 bg-sky-500/15 text-sky-400 border border-sky-500/30 rounded-lg text-[10px] font-semibold">
|
||||||
|
{opp.category}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-400 mt-2 line-clamp-2">{opp.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={e => { e.stopPropagation(); toggleSave(opp.id); }}
|
||||||
|
className={`p-2 rounded-xl transition-all ${savedOpps.has(opp.id) ? 'bg-amber-500/15 text-amber-400' : 'bg-slate-700/50 text-slate-500 hover:text-amber-400'}`}
|
||||||
|
>
|
||||||
|
<Star size={16} fill={savedOpps.has(opp.id) ? 'currentColor' : 'none'} />
|
||||||
|
</button>
|
||||||
|
{expandedOpp === opp.id ? <ChevronUp size={18} className="text-slate-500" /> : <ChevronDown size={18} className="text-slate-500" />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expandedOpp === opp.id && (
|
||||||
|
<div className="px-5 pb-5 space-y-4 border-t border-slate-700/40 pt-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
{/* Contact Info */}
|
||||||
|
<div className="bg-slate-700/30 rounded-xl p-4 space-y-3">
|
||||||
|
<h4 className="font-semibold text-sm text-white flex items-center gap-2">
|
||||||
|
<Building2 size={14} className="text-sky-400" /> Contact Info
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-slate-300">
|
||||||
|
<MapPin size={14} className="text-slate-500 flex-shrink-0" />
|
||||||
|
<span>{opp.address}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-slate-300">
|
||||||
|
<Phone size={14} className="text-slate-500 flex-shrink-0" />
|
||||||
|
<span>{opp.phone}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-slate-300">
|
||||||
|
<Mail size={14} className="text-slate-500 flex-shrink-0" />
|
||||||
|
<a href={`mailto:${opp.email}`} className="text-sky-400 hover:text-sky-300">{opp.email}</a>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-slate-300">
|
||||||
|
<ExternalLink size={14} className="text-slate-500 flex-shrink-0" />
|
||||||
|
<a href={`https://${opp.website}`} target="_blank" rel="noopener noreferrer" className="text-sky-400 hover:text-sky-300">{opp.website}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Skills & Requirements */}
|
||||||
|
<div className="bg-slate-700/30 rounded-xl p-4">
|
||||||
|
<h4 className="font-semibold text-sm text-white flex items-center gap-2 mb-3">
|
||||||
|
<GraduationCap size={14} className="text-violet-400" /> Skills Developed
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-1.5 mb-4">
|
||||||
|
{opp.skills.map((skill, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2">
|
||||||
|
<div className="w-1.5 h-1.5 rounded-full bg-violet-400 flex-shrink-0" />
|
||||||
|
<span className="text-sm text-slate-300">{skill}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<h4 className="font-semibold text-sm text-white flex items-center gap-2 mb-2">
|
||||||
|
<CheckCircle size={14} className="text-emerald-400" /> Requirements
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{opp.requirements.map((req, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2">
|
||||||
|
<div className="w-1.5 h-1.5 rounded-full bg-emerald-400 flex-shrink-0" />
|
||||||
|
<span className="text-sm text-slate-300">{req}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Accommodations */}
|
||||||
|
<div className="bg-gradient-to-br from-sky-500/10 to-blue-500/10 rounded-xl p-4 border border-sky-500/20">
|
||||||
|
<h4 className="font-semibold text-sm text-white flex items-center gap-2 mb-3">
|
||||||
|
<Heart size={14} className="text-sky-400" /> Accommodations Available
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{opp.accommodations.map((acc, i) => (
|
||||||
|
<div key={i} className="flex items-start gap-2">
|
||||||
|
<CheckCircle size={12} className="text-sky-400 mt-0.5 flex-shrink-0" />
|
||||||
|
<span className="text-sm text-slate-300">{acc}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer Actions */}
|
||||||
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 pt-2">
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<span className="text-xs text-slate-500 flex items-center gap-1">
|
||||||
|
<Users size={12} /> {opp.spots} spot{opp.spots !== 1 ? 's' : ''} available
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-500">Ages: {opp.ageGroup}</span>
|
||||||
|
<span className="text-xs text-slate-500 flex items-center gap-1">
|
||||||
|
<Clock size={12} /> {opp.schedule}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button className="px-4 py-2 bg-gradient-to-r from-sky-500 to-blue-600 text-white rounded-xl text-sm font-medium hover:shadow-lg hover:shadow-sky-500/25 transition-all">
|
||||||
|
Apply Now
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleSave(opp.id)}
|
||||||
|
className={`px-4 py-2 rounded-xl text-sm font-medium border transition-all ${
|
||||||
|
savedOpps.has(opp.id)
|
||||||
|
? 'bg-amber-500/15 text-amber-400 border-amber-500/30'
|
||||||
|
: 'bg-slate-700/50 text-slate-300 border-slate-600/50 hover:border-amber-500/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{savedOpps.has(opp.id) ? 'Saved' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{displayResults.length === 0 && (
|
||||||
|
<div className="bg-slate-800/40 backdrop-blur-sm rounded-2xl border border-slate-700/40 p-12 text-center">
|
||||||
|
<Briefcase size={40} className="text-slate-600 mx-auto mb-3" />
|
||||||
|
<h3 className="font-semibold text-white mb-1">No opportunities found</h3>
|
||||||
|
<p className="text-sm text-slate-400">Try adjusting your search or filters, or search a different zip code.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pre-search state */}
|
||||||
|
{!hasSearched && !isSearching && (
|
||||||
|
<div className="bg-slate-800/40 backdrop-blur-sm rounded-2xl border border-slate-700/40 p-8">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<Briefcase size={48} className="text-sky-500/40 mx-auto mb-4" />
|
||||||
|
<h3 className="font-semibold text-white text-lg mb-2">Enter Your Zip Code to Get Started</h3>
|
||||||
|
<p className="text-sm text-slate-400 max-w-lg mx-auto">
|
||||||
|
We'll find vocational training programs, internships, and job opportunities near your school that provide autism-specific accommodations and support.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category Preview */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
||||||
|
{[
|
||||||
|
{ name: 'Food Service', icon: <UtensilsCrossed size={20} />, color: 'from-orange-500/20 to-red-500/20 border-orange-500/20 text-orange-400' },
|
||||||
|
{ name: 'Retail', icon: <ShoppingBag size={20} />, color: 'from-blue-500/20 to-indigo-500/20 border-blue-500/20 text-blue-400' },
|
||||||
|
{ name: 'Animal Care', icon: <Heart size={20} />, color: 'from-pink-500/20 to-rose-500/20 border-pink-500/20 text-pink-400' },
|
||||||
|
{ name: 'Technology', icon: <Monitor size={20} />, color: 'from-violet-500/20 to-purple-500/20 border-violet-500/20 text-violet-400' },
|
||||||
|
{ name: 'Arts & Creative', icon: <Palette size={20} />, color: 'from-emerald-500/20 to-teal-500/20 border-emerald-500/20 text-emerald-400' },
|
||||||
|
].map((cat, i) => (
|
||||||
|
<div key={i} className={`bg-gradient-to-br ${cat.color} rounded-xl border p-4 text-center`}>
|
||||||
|
<div className="mx-auto mb-2 flex justify-center">{cat.icon}</div>
|
||||||
|
<p className="text-xs font-medium text-slate-300">{cat.name}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VocationalOpportunities;
|
||||||
207
src/components/frameworks/WalkThroughCheckIn.tsx
Normal file
207
src/components/frameworks/WalkThroughCheckIn.tsx
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
ClipboardCheck, Plus, BarChart3, History, Loader2, RefreshCw
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { saveWalkthroughCheckin, fetchWalkthroughCheckins } from '@/lib/db';
|
||||||
|
import WalkThroughForm from './WalkThroughForm';
|
||||||
|
import WalkThroughSummary from './WalkThroughSummary';
|
||||||
|
|
||||||
|
type TabId = 'new' | 'history' | 'summary';
|
||||||
|
|
||||||
|
const WalkThroughCheckIn: React.FC = () => {
|
||||||
|
const [activeTab, setActiveTab] = useState<TabId>('new');
|
||||||
|
const [checkins, setCheckins] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadCheckins();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadCheckins = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await fetchWalkthroughCheckins();
|
||||||
|
setCheckins(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading walkthroughs:', err);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (data: any) => {
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const success = await saveWalkthroughCheckin(data);
|
||||||
|
if (success) {
|
||||||
|
await loadCheckins();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error submitting walkthrough:', err);
|
||||||
|
}
|
||||||
|
setSubmitting(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const tabs: { id: TabId; label: string; icon: React.ReactNode }[] = [
|
||||||
|
{ id: 'new', label: 'New Check-In', icon: <Plus size={16} /> },
|
||||||
|
{ id: 'summary', label: 'Performance Summary', icon: <BarChart3 size={16} /> },
|
||||||
|
{ id: 'history', label: 'History', icon: <History size={16} /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-white flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-indigo-500 to-violet-600 flex items-center justify-center shadow-lg shadow-indigo-500/30">
|
||||||
|
<ClipboardCheck size={20} className="text-white" />
|
||||||
|
</div>
|
||||||
|
Walk-Through Check-In
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-slate-400 mt-1">
|
||||||
|
Quick, structured classroom observations — complete in under 4 minutes
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={loadCheckins}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-slate-800/50 text-slate-400 rounded-xl text-xs font-medium hover:text-white transition-colors border border-slate-700/50"
|
||||||
|
>
|
||||||
|
<RefreshCw size={12} className={loading ? 'animate-spin' : ''} /> Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Bar */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
{[
|
||||||
|
{ label: 'Total Walk-Throughs', value: checkins.length.toString(), color: 'from-indigo-400 to-indigo-600' },
|
||||||
|
{ label: 'Teachers Observed', value: [...new Set(checkins.map(c => c.teacher_name))].length.toString(), color: 'from-violet-400 to-violet-600' },
|
||||||
|
{ label: 'This Month', value: checkins.filter(c => {
|
||||||
|
const d = new Date(c.check_in_date);
|
||||||
|
const now = new Date();
|
||||||
|
return d.getMonth() === now.getMonth() && d.getFullYear() === now.getFullYear();
|
||||||
|
}).length.toString(), color: 'from-cyan-400 to-cyan-600' },
|
||||||
|
{ label: 'Avg Completion', value: '~3 min', color: 'from-emerald-400 to-emerald-600' },
|
||||||
|
].map((stat, i) => (
|
||||||
|
<div key={i} className="bg-slate-800/40 rounded-xl border border-slate-700/40 p-3">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<div className={`w-2 h-2 rounded-full bg-gradient-to-r ${stat.color}`} />
|
||||||
|
<p className="text-[10px] text-slate-500 uppercase tracking-wider">{stat.label}</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-xl font-bold text-white">{stat.value}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex gap-1 bg-slate-800/40 rounded-xl border border-slate-700/40 p-1">
|
||||||
|
{tabs.map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setActiveTab(tab.id)}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium transition-all flex-1 justify-center ${
|
||||||
|
activeTab === tab.id
|
||||||
|
? 'bg-gradient-to-r from-indigo-500/20 to-violet-500/20 text-white border border-indigo-500/30'
|
||||||
|
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-700/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={activeTab === tab.id ? 'text-indigo-400' : 'text-slate-500'}>{tab.icon}</span>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab Content */}
|
||||||
|
{activeTab === 'new' && (
|
||||||
|
<WalkThroughForm onSubmit={handleSubmit} submitting={submitting} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'summary' && (
|
||||||
|
<WalkThroughSummary checkins={checkins} loading={loading} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'history' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-16">
|
||||||
|
<Loader2 size={24} className="animate-spin text-indigo-400" />
|
||||||
|
</div>
|
||||||
|
) : checkins.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16">
|
||||||
|
<div className="w-16 h-16 rounded-2xl bg-slate-800/50 border border-slate-700/50 flex items-center justify-center mb-4">
|
||||||
|
<History size={28} className="text-slate-600" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-1">No Walk-Throughs Yet</h3>
|
||||||
|
<p className="text-sm text-slate-500 mb-4">Start your first walk-through to build your history.</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('new')}
|
||||||
|
className="px-4 py-2 bg-indigo-500/20 text-indigo-400 rounded-xl text-sm font-medium hover:bg-indigo-500/30 transition-colors border border-indigo-500/30"
|
||||||
|
>
|
||||||
|
Start Walk-Through
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{checkins.map((c: any, i: number) => {
|
||||||
|
const categories = [
|
||||||
|
{ key: 'attitude', label: 'Attitude', max: 4 },
|
||||||
|
{ key: 'classroom_management', label: 'Classroom Mgmt', max: 4 },
|
||||||
|
{ key: 'cleanliness', label: 'Cleanliness', max: 4 },
|
||||||
|
{ key: 'vibes', label: 'Vibes', max: 4 },
|
||||||
|
{ key: 'team_dynamics', label: 'Team Dynamics', max: 4 },
|
||||||
|
{ key: 'emergency_exit', label: 'Emergency Exit', max: 3 },
|
||||||
|
{ key: 'lesson_plan', label: 'Lesson Plan', max: 3 },
|
||||||
|
];
|
||||||
|
const totalPct = categories.reduce((sum, cat) => {
|
||||||
|
const val = c[`${cat.key}_rating`] || 0;
|
||||||
|
return sum + (val / cat.max) * 100;
|
||||||
|
}, 0);
|
||||||
|
const avgPct = Math.round(totalPct / categories.length);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={c.id || i} className="bg-slate-800/40 rounded-xl border border-slate-700/40 p-4 hover:border-slate-600/50 transition-all">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-white">{c.teacher_name}</p>
|
||||||
|
<p className="text-xs text-slate-500">{c.classroom} — {c.check_in_date} at {c.check_in_time?.slice(0, 5)}</p>
|
||||||
|
</div>
|
||||||
|
<div className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-lg text-xs font-semibold ${
|
||||||
|
avgPct >= 75 ? 'bg-emerald-500/15 text-emerald-400 border border-emerald-500/30' :
|
||||||
|
avgPct >= 50 ? 'bg-amber-500/15 text-amber-400 border border-amber-500/30' :
|
||||||
|
'bg-red-500/15 text-red-400 border border-red-500/30'
|
||||||
|
}`}>
|
||||||
|
{avgPct}% Overall
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{categories.map(cat => {
|
||||||
|
const val = c[`${cat.key}_rating`] || 0;
|
||||||
|
const pct = val / cat.max;
|
||||||
|
return (
|
||||||
|
<span key={cat.key} className={`px-2 py-0.5 rounded-md text-[10px] font-medium ${
|
||||||
|
pct >= 0.75 ? 'bg-emerald-500/10 text-emerald-400' :
|
||||||
|
pct >= 0.5 ? 'bg-amber-500/10 text-amber-400' :
|
||||||
|
'bg-red-500/10 text-red-400'
|
||||||
|
}`}>
|
||||||
|
{cat.label}: {val}/{cat.max}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{c.overall_notes && (
|
||||||
|
<p className="mt-2 text-xs text-slate-500 italic border-t border-slate-700/30 pt-2">
|
||||||
|
{c.overall_notes}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WalkThroughCheckIn;
|
||||||
366
src/components/frameworks/WalkThroughForm.tsx
Normal file
366
src/components/frameworks/WalkThroughForm.tsx
Normal file
@ -0,0 +1,366 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Search, User, Calendar, Clock, MessageSquare,
|
||||||
|
ChevronDown, CheckCircle, AlertTriangle, Info, Loader2
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface WalkThroughFormProps {
|
||||||
|
onSubmit: (data: any) => Promise<void>;
|
||||||
|
submitting: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STAFF_LIST = [
|
||||||
|
{ name: 'Ms. Rodriguez', classroom: 'Room 101 - K-2 Autism Support', role: 'Lead Teacher' },
|
||||||
|
{ name: 'Mr. Thompson', classroom: 'Room 102 - K-2 Life Skills', role: 'Paraprofessional' },
|
||||||
|
{ name: 'Mrs. Davis', classroom: 'Room 103 - 3-5 Autism Support', role: 'Lead Teacher' },
|
||||||
|
{ name: 'Mr. Jackson', classroom: 'Room 104 - 3-5 Life Skills', role: 'Lead Teacher' },
|
||||||
|
{ name: 'Ms. Patel', classroom: 'Room 105 - 6-8 Transition', role: 'Lead Teacher' },
|
||||||
|
{ name: 'Mrs. Kim', classroom: 'Room 106 - Sensory Room', role: 'Occupational Therapist' },
|
||||||
|
{ name: 'Mr. Garcia', classroom: 'Room 107 - Speech Therapy', role: 'Speech Therapist' },
|
||||||
|
{ name: 'Ms. Brown', classroom: 'Room 108 - Vocational', role: 'Lead Teacher' },
|
||||||
|
{ name: 'Mrs. Wilson', classroom: 'Room 109 - Art Therapy', role: 'Art Therapist' },
|
||||||
|
{ name: 'Mr. Lee', classroom: 'Room 110 - PE / Motor Skills', role: 'PE Specialist' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const STANDARD_RATINGS = [
|
||||||
|
{ value: 4, label: 'Excellent', color: 'text-emerald-400', bg: 'bg-emerald-500/15 border-emerald-500/30' },
|
||||||
|
{ value: 3, label: 'Satisfactory', color: 'text-blue-400', bg: 'bg-blue-500/15 border-blue-500/30' },
|
||||||
|
{ value: 2, label: 'Needs Attention', color: 'text-amber-400', bg: 'bg-amber-500/15 border-amber-500/30' },
|
||||||
|
{ value: 1, label: 'Immediate Support Needed', color: 'text-red-400', bg: 'bg-red-500/15 border-red-500/30' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const EXIT_RATINGS = [
|
||||||
|
{ value: 3, label: 'Clearly visible & accessible', color: 'text-emerald-400', bg: 'bg-emerald-500/15 border-emerald-500/30' },
|
||||||
|
{ value: 2, label: 'Present but needs adjustment', color: 'text-amber-400', bg: 'bg-amber-500/15 border-amber-500/30' },
|
||||||
|
{ value: 1, label: 'Missing items', color: 'text-red-400', bg: 'bg-red-500/15 border-red-500/30' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const LESSON_RATINGS = [
|
||||||
|
{ value: 3, label: 'Posted & aligned', color: 'text-emerald-400', bg: 'bg-emerald-500/15 border-emerald-500/30' },
|
||||||
|
{ value: 2, label: 'Completed but not posted', color: 'text-amber-400', bg: 'bg-amber-500/15 border-amber-500/30' },
|
||||||
|
{ value: 1, label: 'Incomplete', color: 'text-red-400', bg: 'bg-red-500/15 border-red-500/30' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const CATEGORIES = [
|
||||||
|
{
|
||||||
|
key: 'attitude',
|
||||||
|
label: '1. Attitude',
|
||||||
|
tooltip: 'Professional tone, engagement, responsiveness to students and staff.',
|
||||||
|
ratings: STANDARD_RATINGS,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'classroom_management',
|
||||||
|
label: '2. Classroom Management',
|
||||||
|
tooltip: 'Transitions, student regulation, structure, consistency of routines.',
|
||||||
|
ratings: STANDARD_RATINGS,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'cleanliness',
|
||||||
|
label: '3. Cleanliness / Organization',
|
||||||
|
tooltip: 'Materials organized, visual structure, safe environment, clutter-free.',
|
||||||
|
ratings: STANDARD_RATINGS,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'vibes',
|
||||||
|
label: '4. Vibes / Feeling Welcomed',
|
||||||
|
tooltip: 'Warmth, student engagement, emotional tone, inviting atmosphere.',
|
||||||
|
ratings: STANDARD_RATINGS,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'team_dynamics',
|
||||||
|
label: '5. Team Dynamics',
|
||||||
|
tooltip: 'Paraprofessional collaboration, communication, alignment on goals.',
|
||||||
|
ratings: STANDARD_RATINGS,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'emergency_exit',
|
||||||
|
label: '6. Emergency Exit Items by Door',
|
||||||
|
tooltip: 'Emergency binder, class roster, first aid kit, and exit map visible and accessible.',
|
||||||
|
ratings: EXIT_RATINGS,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'lesson_plan',
|
||||||
|
label: '7. Lesson Plan Completed & Accessible',
|
||||||
|
tooltip: 'Lesson plan posted, aligned with IEP goals, and accessible for review.',
|
||||||
|
ratings: LESSON_RATINGS,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const WalkThroughForm: React.FC<WalkThroughFormProps> = ({ onSubmit, submitting }) => {
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [selectedTeacher, setSelectedTeacher] = useState<typeof STAFF_LIST[0] | null>(null);
|
||||||
|
const [showDropdown, setShowDropdown] = useState(false);
|
||||||
|
const [date, setDate] = useState(new Date().toISOString().split('T')[0]);
|
||||||
|
const [time, setTime] = useState(new Date().toTimeString().slice(0, 5));
|
||||||
|
const [ratings, setRatings] = useState<Record<string, number>>({});
|
||||||
|
const [comments, setComments] = useState<Record<string, string>>({});
|
||||||
|
const [overallNotes, setOverallNotes] = useState('');
|
||||||
|
const [showTooltip, setShowTooltip] = useState<string | null>(null);
|
||||||
|
const [submitted, setSubmitted] = useState(false);
|
||||||
|
|
||||||
|
const filteredStaff = STAFF_LIST.filter(s =>
|
||||||
|
s.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
s.classroom.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
const allRated = CATEGORIES.every(c => ratings[c.key] !== undefined);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!selectedTeacher || !allRated) return;
|
||||||
|
const data = {
|
||||||
|
teacher_name: selectedTeacher.name,
|
||||||
|
classroom: selectedTeacher.classroom,
|
||||||
|
director_name: 'Dr. Williams',
|
||||||
|
check_in_date: date,
|
||||||
|
check_in_time: time,
|
||||||
|
attitude_rating: ratings.attitude,
|
||||||
|
attitude_comment: comments.attitude || undefined,
|
||||||
|
classroom_management_rating: ratings.classroom_management,
|
||||||
|
classroom_management_comment: comments.classroom_management || undefined,
|
||||||
|
cleanliness_rating: ratings.cleanliness,
|
||||||
|
cleanliness_comment: comments.cleanliness || undefined,
|
||||||
|
vibes_rating: ratings.vibes,
|
||||||
|
vibes_comment: comments.vibes || undefined,
|
||||||
|
team_dynamics_rating: ratings.team_dynamics,
|
||||||
|
team_dynamics_comment: comments.team_dynamics || undefined,
|
||||||
|
emergency_exit_rating: ratings.emergency_exit,
|
||||||
|
emergency_exit_comment: comments.emergency_exit || undefined,
|
||||||
|
lesson_plan_rating: ratings.lesson_plan,
|
||||||
|
lesson_plan_comment: comments.lesson_plan || undefined,
|
||||||
|
overall_notes: overallNotes || undefined,
|
||||||
|
};
|
||||||
|
await onSubmit(data);
|
||||||
|
setSubmitted(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setSelectedTeacher(null);
|
||||||
|
setSearchTerm('');
|
||||||
|
setRatings({});
|
||||||
|
setComments({});
|
||||||
|
setOverallNotes('');
|
||||||
|
setSubmitted(false);
|
||||||
|
setDate(new Date().toISOString().split('T')[0]);
|
||||||
|
setTime(new Date().toTimeString().slice(0, 5));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (submitted) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16">
|
||||||
|
<div className="w-20 h-20 rounded-full bg-emerald-500/20 border-2 border-emerald-500/40 flex items-center justify-center mb-6">
|
||||||
|
<CheckCircle size={40} className="text-emerald-400" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-2xl font-bold text-white mb-2">Walk-Through Submitted</h3>
|
||||||
|
<p className="text-slate-400 text-sm mb-1">
|
||||||
|
{selectedTeacher?.name} — {selectedTeacher?.classroom}
|
||||||
|
</p>
|
||||||
|
<p className="text-slate-500 text-xs mb-6">{date} at {time}</p>
|
||||||
|
<button
|
||||||
|
onClick={resetForm}
|
||||||
|
className="px-6 py-2.5 bg-gradient-to-r from-indigo-500 to-violet-500 text-white font-semibold rounded-xl hover:from-indigo-600 hover:to-violet-600 transition-all shadow-lg shadow-indigo-500/25"
|
||||||
|
>
|
||||||
|
Start New Walk-Through
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Step 1: Teacher Selection */}
|
||||||
|
<div className="bg-slate-800/50 rounded-2xl border border-slate-700/50 p-5">
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||||
|
<div className="w-7 h-7 rounded-lg bg-indigo-500/20 flex items-center justify-center">
|
||||||
|
<User size={16} className="text-indigo-400" />
|
||||||
|
</div>
|
||||||
|
Step 1: Select Teacher
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="relative mb-4">
|
||||||
|
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search by name or classroom..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => { setSearchTerm(e.target.value); setShowDropdown(true); }}
|
||||||
|
onFocus={() => setShowDropdown(true)}
|
||||||
|
className="w-full pl-10 pr-4 py-2.5 bg-slate-900/70 border border-slate-600/50 rounded-xl text-sm text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500/50 focus:ring-1 focus:ring-indigo-500/30"
|
||||||
|
/>
|
||||||
|
{showDropdown && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-10" onClick={() => setShowDropdown(false)} />
|
||||||
|
<div className="absolute top-full mt-1 left-0 right-0 bg-slate-800 border border-slate-700/50 rounded-xl shadow-2xl shadow-black/40 max-h-60 overflow-y-auto z-20">
|
||||||
|
{filteredStaff.map((staff, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedTeacher(staff);
|
||||||
|
setSearchTerm(staff.name);
|
||||||
|
setShowDropdown(false);
|
||||||
|
}}
|
||||||
|
className={`w-full text-left px-4 py-3 hover:bg-slate-700/50 transition-colors border-b border-slate-700/30 last:border-0 ${
|
||||||
|
selectedTeacher?.name === staff.name ? 'bg-indigo-500/10' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<p className="text-sm font-medium text-white">{staff.name}</p>
|
||||||
|
<p className="text-xs text-slate-400">{staff.classroom} — {staff.role}</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{filteredStaff.length === 0 && (
|
||||||
|
<p className="px-4 py-3 text-sm text-slate-500">No staff found</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedTeacher && (
|
||||||
|
<div className="bg-indigo-500/10 border border-indigo-500/20 rounded-xl p-4 flex flex-col sm:flex-row sm:items-center gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-semibold text-white">{selectedTeacher.name}</p>
|
||||||
|
<p className="text-xs text-slate-400">{selectedTeacher.classroom} — {selectedTeacher.role}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Calendar size={14} className="text-slate-500" />
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={date}
|
||||||
|
onChange={(e) => setDate(e.target.value)}
|
||||||
|
className="bg-slate-900/70 border border-slate-600/50 rounded-lg px-2 py-1 text-xs text-white focus:outline-none focus:border-indigo-500/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Clock size={14} className="text-slate-500" />
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={time}
|
||||||
|
onChange={(e) => setTime(e.target.value)}
|
||||||
|
className="bg-slate-900/70 border border-slate-600/50 rounded-lg px-2 py-1 text-xs text-white focus:outline-none focus:border-indigo-500/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step 2: Evaluation */}
|
||||||
|
{selectedTeacher && (
|
||||||
|
<div className="bg-slate-800/50 rounded-2xl border border-slate-700/50 p-5">
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-1 flex items-center gap-2">
|
||||||
|
<div className="w-7 h-7 rounded-lg bg-violet-500/20 flex items-center justify-center">
|
||||||
|
<CheckCircle size={16} className="text-violet-400" />
|
||||||
|
</div>
|
||||||
|
Step 2: Walk-Through Evaluation
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-slate-500 mb-5 ml-9">Rate each category using the dropdown. Add optional comments for context.</p>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{CATEGORIES.map((cat) => (
|
||||||
|
<div key={cat.key} className={`rounded-xl border p-4 transition-all ${
|
||||||
|
ratings[cat.key] !== undefined
|
||||||
|
? 'bg-slate-900/40 border-slate-600/30'
|
||||||
|
: 'bg-slate-900/20 border-slate-700/30'
|
||||||
|
}`}>
|
||||||
|
<div className="flex items-start justify-between gap-3 mb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h4 className="text-sm font-semibold text-white">{cat.label}</h4>
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowTooltip(showTooltip === cat.key ? null : cat.key)}
|
||||||
|
className="w-5 h-5 rounded-full bg-slate-700/50 flex items-center justify-center hover:bg-slate-600/50 transition-colors"
|
||||||
|
>
|
||||||
|
<Info size={12} className="text-slate-400" />
|
||||||
|
</button>
|
||||||
|
{showTooltip === cat.key && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-10" onClick={() => setShowTooltip(null)} />
|
||||||
|
<div className="absolute left-0 top-full mt-1 w-64 bg-slate-700 rounded-lg p-3 shadow-xl z-20">
|
||||||
|
<p className="text-xs text-slate-300">{cat.tooltip}</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{ratings[cat.key] !== undefined && (
|
||||||
|
<CheckCircle size={14} className="text-emerald-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2 mb-3">
|
||||||
|
{cat.ratings.map((r) => (
|
||||||
|
<button
|
||||||
|
key={r.value}
|
||||||
|
onClick={() => setRatings(prev => ({ ...prev, [cat.key]: r.value }))}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-xs font-medium border transition-all ${
|
||||||
|
ratings[cat.key] === r.value
|
||||||
|
? `${r.bg} ${r.color} ring-1 ring-current`
|
||||||
|
: 'bg-slate-800/50 border-slate-700/50 text-slate-400 hover:border-slate-600/50 hover:text-slate-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{r.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<MessageSquare size={14} className="absolute left-3 top-2.5 text-slate-600" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Optional comment..."
|
||||||
|
value={comments[cat.key] || ''}
|
||||||
|
onChange={(e) => setComments(prev => ({ ...prev, [cat.key]: e.target.value }))}
|
||||||
|
className="w-full pl-9 pr-3 py-2 bg-slate-900/50 border border-slate-700/30 rounded-lg text-xs text-slate-300 placeholder-slate-600 focus:outline-none focus:border-indigo-500/40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Overall Notes */}
|
||||||
|
{selectedTeacher && (
|
||||||
|
<div className="bg-slate-800/50 rounded-2xl border border-slate-700/50 p-5">
|
||||||
|
<h3 className="text-sm font-semibold text-white mb-3">Overall Notes (Optional)</h3>
|
||||||
|
<textarea
|
||||||
|
value={overallNotes}
|
||||||
|
onChange={(e) => setOverallNotes(e.target.value)}
|
||||||
|
placeholder="Any additional observations, concerns, or recognitions..."
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-4 py-3 bg-slate-900/50 border border-slate-700/30 rounded-xl text-sm text-slate-300 placeholder-slate-600 focus:outline-none focus:border-indigo-500/40 resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Submit */}
|
||||||
|
{selectedTeacher && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className={`w-2 h-2 rounded-full ${allRated ? 'bg-emerald-400' : 'bg-amber-400'}`} />
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
{Object.keys(ratings).length}/{CATEGORIES.length} categories rated
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!allRated || submitting}
|
||||||
|
className={`flex items-center gap-2 px-6 py-2.5 rounded-xl font-semibold text-sm transition-all shadow-lg ${
|
||||||
|
allRated && !submitting
|
||||||
|
? 'bg-gradient-to-r from-indigo-500 to-violet-500 text-white hover:from-indigo-600 hover:to-violet-600 shadow-indigo-500/25'
|
||||||
|
: 'bg-slate-700/50 text-slate-500 cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{submitting ? (
|
||||||
|
<><Loader2 size={16} className="animate-spin" /> Submitting...</>
|
||||||
|
) : (
|
||||||
|
<><CheckCircle size={16} /> Submit Walk-Through</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WalkThroughForm;
|
||||||
653
src/components/frameworks/WalkThroughSummary.tsx
Normal file
653
src/components/frameworks/WalkThroughSummary.tsx
Normal file
@ -0,0 +1,653 @@
|
|||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
BarChart3, TrendingUp, TrendingDown, AlertTriangle, Award,
|
||||||
|
User, Calendar, ChevronDown, Printer, Maximize2, Minimize2,
|
||||||
|
FileText, Star, Target, ArrowRight, X, Eye
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface WalkThroughSummaryProps {
|
||||||
|
checkins: any[];
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATEGORY_LABELS: Record<string, string> = {
|
||||||
|
attitude: 'Attitude',
|
||||||
|
classroom_management: 'Classroom Mgmt',
|
||||||
|
cleanliness: 'Cleanliness',
|
||||||
|
vibes: 'Vibes / Welcome',
|
||||||
|
team_dynamics: 'Team Dynamics',
|
||||||
|
emergency_exit: 'Emergency Exit',
|
||||||
|
lesson_plan: 'Lesson Plan',
|
||||||
|
};
|
||||||
|
|
||||||
|
const CATEGORY_KEYS = Object.keys(CATEGORY_LABELS);
|
||||||
|
|
||||||
|
const ratingLabel = (val: number, key: string) => {
|
||||||
|
if (key === 'emergency_exit') {
|
||||||
|
if (val === 3) return 'Visible & Accessible';
|
||||||
|
if (val === 2) return 'Needs Adjustment';
|
||||||
|
return 'Missing Items';
|
||||||
|
}
|
||||||
|
if (key === 'lesson_plan') {
|
||||||
|
if (val === 3) return 'Posted & Aligned';
|
||||||
|
if (val === 2) return 'Not Posted';
|
||||||
|
return 'Incomplete';
|
||||||
|
}
|
||||||
|
if (val === 4) return 'Excellent';
|
||||||
|
if (val === 3) return 'Satisfactory';
|
||||||
|
if (val === 2) return 'Needs Attention';
|
||||||
|
return 'Immediate Support';
|
||||||
|
};
|
||||||
|
|
||||||
|
const ratingColor = (val: number, maxVal: number) => {
|
||||||
|
const pct = val / maxVal;
|
||||||
|
if (pct >= 0.85) return 'text-emerald-400';
|
||||||
|
if (pct >= 0.6) return 'text-blue-400';
|
||||||
|
if (pct >= 0.4) return 'text-amber-400';
|
||||||
|
return 'text-red-400';
|
||||||
|
};
|
||||||
|
|
||||||
|
const barColor = (val: number, maxVal: number) => {
|
||||||
|
const pct = val / maxVal;
|
||||||
|
if (pct >= 0.85) return 'bg-emerald-500';
|
||||||
|
if (pct >= 0.6) return 'bg-blue-500';
|
||||||
|
if (pct >= 0.4) return 'bg-amber-500';
|
||||||
|
return 'bg-red-500';
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusColor = (pct: number) => {
|
||||||
|
if (pct >= 85) return { text: 'text-emerald-400', bg: 'bg-emerald-500/20', border: 'border-emerald-500/30', label: 'Strong' };
|
||||||
|
if (pct >= 60) return { text: 'text-blue-400', bg: 'bg-blue-500/20', border: 'border-blue-500/30', label: 'Developing' };
|
||||||
|
if (pct >= 40) return { text: 'text-amber-400', bg: 'bg-amber-500/20', border: 'border-amber-500/30', label: 'Developing' };
|
||||||
|
return { text: 'text-red-400', bg: 'bg-red-500/20', border: 'border-red-500/30', label: 'Priority Support' };
|
||||||
|
};
|
||||||
|
|
||||||
|
const WalkThroughSummary: React.FC<WalkThroughSummaryProps> = ({ checkins, loading }) => {
|
||||||
|
const [selectedTeacher, setSelectedTeacher] = useState<string>('all');
|
||||||
|
const [timeRange, setTimeRange] = useState<'30' | '60' | '90'>('60');
|
||||||
|
const [presentationMode, setPresentationMode] = useState(false);
|
||||||
|
const [showReport, setShowReport] = useState(false);
|
||||||
|
const [growthPlan, setGrowthPlan] = useState({ strengths: '', focusAreas: '', nextSteps: '', reviewDate: '' });
|
||||||
|
|
||||||
|
const teachers = useMemo(() => {
|
||||||
|
const names = [...new Set(checkins.map(c => c.teacher_name))];
|
||||||
|
return names.sort();
|
||||||
|
}, [checkins]);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
let data = checkins;
|
||||||
|
if (selectedTeacher !== 'all') data = data.filter(c => c.teacher_name === selectedTeacher);
|
||||||
|
const cutoff = new Date();
|
||||||
|
cutoff.setDate(cutoff.getDate() - parseInt(timeRange));
|
||||||
|
data = data.filter(c => new Date(c.check_in_date) >= cutoff);
|
||||||
|
return data;
|
||||||
|
}, [checkins, selectedTeacher, timeRange]);
|
||||||
|
|
||||||
|
// Category averages
|
||||||
|
const categoryAverages = useMemo(() => {
|
||||||
|
if (filtered.length === 0) return {};
|
||||||
|
const avgs: Record<string, { avg: number; max: number; count: number }> = {};
|
||||||
|
CATEGORY_KEYS.forEach(key => {
|
||||||
|
const ratingKey = `${key}_rating`;
|
||||||
|
const vals = filtered.map(c => c[ratingKey]).filter((v: any) => v != null);
|
||||||
|
if (vals.length === 0) { avgs[key] = { avg: 0, max: 4, count: 0 }; return; }
|
||||||
|
const maxVal = (key === 'emergency_exit' || key === 'lesson_plan') ? 3 : 4;
|
||||||
|
avgs[key] = { avg: vals.reduce((a: number, b: number) => a + b, 0) / vals.length, max: maxVal, count: vals.length };
|
||||||
|
});
|
||||||
|
return avgs;
|
||||||
|
}, [filtered]);
|
||||||
|
|
||||||
|
// Overall percentage
|
||||||
|
const overallPct = useMemo(() => {
|
||||||
|
const entries = Object.values(categoryAverages);
|
||||||
|
if (entries.length === 0) return 0;
|
||||||
|
const totalPct = entries.reduce((sum, e) => sum + (e.avg / e.max) * 100, 0);
|
||||||
|
return Math.round(totalPct / entries.length);
|
||||||
|
}, [categoryAverages]);
|
||||||
|
|
||||||
|
const status = statusColor(overallPct);
|
||||||
|
|
||||||
|
// Flags & recognitions
|
||||||
|
const flags = useMemo(() => {
|
||||||
|
const result: { teacher: string; category: string; count: number }[] = [];
|
||||||
|
if (selectedTeacher === 'all') return result;
|
||||||
|
CATEGORY_KEYS.forEach(key => {
|
||||||
|
const ratingKey = `${key}_rating`;
|
||||||
|
const needsAttention = filtered.filter(c => c[ratingKey] <= 2);
|
||||||
|
if (needsAttention.length >= 2) {
|
||||||
|
result.push({ teacher: selectedTeacher, category: CATEGORY_LABELS[key], count: needsAttention.length });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}, [filtered, selectedTeacher]);
|
||||||
|
|
||||||
|
const recognitions = useMemo(() => {
|
||||||
|
const result: { teacher: string; category: string }[] = [];
|
||||||
|
if (selectedTeacher === 'all') return result;
|
||||||
|
CATEGORY_KEYS.forEach(key => {
|
||||||
|
const ratingKey = `${key}_rating`;
|
||||||
|
const maxVal = (key === 'emergency_exit' || key === 'lesson_plan') ? 3 : 4;
|
||||||
|
const recent = filtered.slice(0, 3);
|
||||||
|
if (recent.length >= 3 && recent.every(c => c[ratingKey] === maxVal)) {
|
||||||
|
result.push({ teacher: selectedTeacher, category: CATEGORY_LABELS[key] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}, [filtered, selectedTeacher]);
|
||||||
|
|
||||||
|
// Trend data for SVG chart
|
||||||
|
const trendData = useMemo(() => {
|
||||||
|
const sorted = [...filtered].sort((a, b) => new Date(a.check_in_date).getTime() - new Date(b.check_in_date).getTime());
|
||||||
|
return sorted.map(c => {
|
||||||
|
let total = 0; let max = 0;
|
||||||
|
CATEGORY_KEYS.forEach(key => {
|
||||||
|
const ratingKey = `${key}_rating`;
|
||||||
|
const maxVal = (key === 'emergency_exit' || key === 'lesson_plan') ? 3 : 4;
|
||||||
|
total += (c[ratingKey] || 0) / maxVal;
|
||||||
|
max += 1;
|
||||||
|
});
|
||||||
|
return { date: c.check_in_date, pct: max > 0 ? Math.round((total / max) * 100) : 0, teacher: c.teacher_name };
|
||||||
|
});
|
||||||
|
}, [filtered]);
|
||||||
|
|
||||||
|
// Auto-generated summary
|
||||||
|
const autoSummary = useMemo(() => {
|
||||||
|
if (filtered.length === 0) return '';
|
||||||
|
const strong = Object.entries(categoryAverages)
|
||||||
|
.filter(([_, v]) => (v.avg / v.max) >= 0.75)
|
||||||
|
.map(([k]) => CATEGORY_LABELS[k]);
|
||||||
|
const weak = Object.entries(categoryAverages)
|
||||||
|
.filter(([_, v]) => (v.avg / v.max) < 0.6)
|
||||||
|
.map(([k]) => CATEGORY_LABELS[k]);
|
||||||
|
const teacherStr = selectedTeacher !== 'all' ? selectedTeacher : 'staff';
|
||||||
|
let summary = `Over the past ${timeRange} days, `;
|
||||||
|
if (strong.length > 0) summary += `${strong.join(' and ')} show consistent strength. `;
|
||||||
|
if (weak.length > 0) summary += `${weak.join(' and ')} require${weak.length === 1 ? 's' : ''} moderate attention. `;
|
||||||
|
summary += `Overall performance for ${teacherStr} reflects ${overallPct >= 75 ? 'steady progress' : overallPct >= 50 ? 'developing patterns' : 'areas needing focused support'}.`;
|
||||||
|
return summary;
|
||||||
|
}, [filtered, categoryAverages, overallPct, selectedTeacher, timeRange]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-indigo-500/20 flex items-center justify-center mx-auto mb-3 animate-pulse">
|
||||||
|
<BarChart3 size={20} className="text-indigo-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-500">Loading performance data...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (checkins.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16">
|
||||||
|
<div className="w-16 h-16 rounded-2xl bg-slate-800/50 border border-slate-700/50 flex items-center justify-center mb-4">
|
||||||
|
<BarChart3 size={28} className="text-slate-600" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-1">No Walk-Through Data Yet</h3>
|
||||||
|
<p className="text-sm text-slate-500">Complete your first walk-through to see performance analytics here.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Presentation mode
|
||||||
|
if (presentationMode) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-slate-950 z-50 overflow-y-auto">
|
||||||
|
<div className="max-w-5xl mx-auto p-8">
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl font-bold text-white">Performance Overview</h2>
|
||||||
|
<p className="text-slate-400">{selectedTeacher !== 'all' ? selectedTeacher : 'All Staff'} — Last {timeRange} Days</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setPresentationMode(false)} className="p-2 rounded-lg bg-slate-800 text-slate-400 hover:text-white transition-colors">
|
||||||
|
<Minimize2 size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Big Score */}
|
||||||
|
<div className="flex items-center justify-center mb-12">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className={`text-8xl font-black ${status.text}`}>{overallPct}%</div>
|
||||||
|
<div className={`inline-flex items-center gap-2 px-4 py-1.5 rounded-full ${status.bg} border ${status.border} mt-3`}>
|
||||||
|
<span className={`text-sm font-semibold ${status.text}`}>{status.label}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category Bars */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 mb-12">
|
||||||
|
{CATEGORY_KEYS.map(key => {
|
||||||
|
const data = categoryAverages[key];
|
||||||
|
if (!data) return null;
|
||||||
|
const pct = (data.avg / data.max) * 100;
|
||||||
|
return (
|
||||||
|
<div key={key} className="flex items-center gap-4">
|
||||||
|
<span className="text-sm text-slate-400 w-40 text-right">{CATEGORY_LABELS[key]}</span>
|
||||||
|
<div className="flex-1 h-8 bg-slate-800 rounded-full overflow-hidden">
|
||||||
|
<div className={`h-full rounded-full ${barColor(data.avg, data.max)} transition-all duration-700`} style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className={`text-lg font-bold w-16 ${ratingColor(data.avg, data.max)}`}>{Math.round(pct)}%</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Trend */}
|
||||||
|
{trendData.length > 1 && (
|
||||||
|
<div className="bg-slate-900/50 rounded-2xl border border-slate-800 p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-4">Performance Trend</h3>
|
||||||
|
<svg viewBox="0 0 800 200" className="w-full h-48">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="trendGradPres" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="#818cf8" stopOpacity="0.3" />
|
||||||
|
<stop offset="100%" stopColor="#818cf8" stopOpacity="0" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
{[25, 50, 75, 100].map(v => (
|
||||||
|
<g key={v}>
|
||||||
|
<line x1="40" y1={180 - (v / 100) * 160} x2="780" y2={180 - (v / 100) * 160} stroke="#334155" strokeWidth="1" strokeDasharray="4" />
|
||||||
|
<text x="30" y={184 - (v / 100) * 160} fill="#64748b" fontSize="10" textAnchor="end">{v}%</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
{trendData.length > 1 && (
|
||||||
|
<>
|
||||||
|
<path
|
||||||
|
d={`M ${trendData.map((d, i) => `${40 + (i / (trendData.length - 1)) * 740},${180 - (d.pct / 100) * 160}`).join(' L ')} L ${40 + 740},180 L 40,180 Z`}
|
||||||
|
fill="url(#trendGradPres)"
|
||||||
|
/>
|
||||||
|
<polyline
|
||||||
|
points={trendData.map((d, i) => `${40 + (i / (trendData.length - 1)) * 740},${180 - (d.pct / 100) * 160}`).join(' ')}
|
||||||
|
fill="none" stroke="#818cf8" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
{trendData.map((d, i) => (
|
||||||
|
<circle key={i} cx={40 + (i / (trendData.length - 1)) * 740} cy={180 - (d.pct / 100) * 160} r="5" fill="#818cf8" stroke="#1e1b4b" strokeWidth="2" />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Printable Report
|
||||||
|
if (showReport) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-semibold text-white">Printable Report</h3>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button onClick={() => window.print()} className="flex items-center gap-1.5 px-4 py-2 bg-indigo-500/20 text-indigo-400 rounded-xl text-sm font-medium hover:bg-indigo-500/30 transition-colors border border-indigo-500/30">
|
||||||
|
<Printer size={14} /> Print Report
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setShowReport(false)} className="p-2 rounded-lg bg-slate-800 text-slate-400 hover:text-white transition-colors">
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Report Content */}
|
||||||
|
<div className="bg-white rounded-2xl p-8 text-gray-800 print:shadow-none" id="printable-report">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between border-b-2 border-indigo-500 pb-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Walk-Through Performance Report</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
{selectedTeacher !== 'all' ? selectedTeacher : 'All Staff'} — Review Period: Last {timeRange} Days
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500">Director: Dr. Williams — Sunrise Academy</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-violet-500 to-amber-400 flex items-center justify-center">
|
||||||
|
<span className="text-white font-bold text-lg">F</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Overall Summary */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2 className="text-lg font-bold text-gray-900 mb-3">Overall Summary</h2>
|
||||||
|
<div className="flex items-center gap-6 mb-3">
|
||||||
|
<div className={`text-5xl font-black ${overallPct >= 75 ? 'text-emerald-600' : overallPct >= 50 ? 'text-amber-600' : 'text-red-600'}`}>
|
||||||
|
{overallPct}%
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className={`inline-block px-3 py-1 rounded-full text-sm font-semibold ${
|
||||||
|
overallPct >= 75 ? 'bg-emerald-100 text-emerald-700' : overallPct >= 50 ? 'bg-amber-100 text-amber-700' : 'bg-red-100 text-red-700'
|
||||||
|
}`}>
|
||||||
|
{status.label}
|
||||||
|
</span>
|
||||||
|
<p className="text-sm text-gray-600 mt-1">{filtered.length} walk-throughs in period</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-600 bg-gray-50 rounded-lg p-3 italic">{autoSummary}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category Breakdown */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2 className="text-lg font-bold text-gray-900 mb-3">Category Breakdown</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{CATEGORY_KEYS.map(key => {
|
||||||
|
const data = categoryAverages[key];
|
||||||
|
if (!data) return null;
|
||||||
|
const pct = Math.round((data.avg / data.max) * 100);
|
||||||
|
return (
|
||||||
|
<div key={key} className="flex items-center gap-3">
|
||||||
|
<span className="text-sm text-gray-600 w-36">{CATEGORY_LABELS[key]}</span>
|
||||||
|
<div className="flex-1 h-5 bg-gray-100 rounded-full overflow-hidden">
|
||||||
|
<div className={`h-full rounded-full ${pct >= 75 ? 'bg-emerald-500' : pct >= 50 ? 'bg-amber-500' : 'bg-red-500'}`} style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-bold text-gray-700 w-12">{pct}%</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Detailed Ratings */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2 className="text-lg font-bold text-gray-900 mb-3">Detailed Ratings</h2>
|
||||||
|
<table className="w-full text-sm border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-gray-50">
|
||||||
|
<th className="text-left p-2 border border-gray-200">Date</th>
|
||||||
|
{CATEGORY_KEYS.map(k => (
|
||||||
|
<th key={k} className="text-center p-2 border border-gray-200 text-xs">{CATEGORY_LABELS[k]}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filtered.slice(0, 10).map((c: any, i: number) => (
|
||||||
|
<tr key={i} className={i % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
|
||||||
|
<td className="p-2 border border-gray-200 text-xs">{c.check_in_date}</td>
|
||||||
|
{CATEGORY_KEYS.map(k => {
|
||||||
|
const val = c[`${k}_rating`];
|
||||||
|
const maxV = (k === 'emergency_exit' || k === 'lesson_plan') ? 3 : 4;
|
||||||
|
return (
|
||||||
|
<td key={k} className={`p-2 border border-gray-200 text-center text-xs font-medium ${
|
||||||
|
val / maxV >= 0.75 ? 'text-emerald-600' : val / maxV >= 0.5 ? 'text-amber-600' : 'text-red-600'
|
||||||
|
}`}>
|
||||||
|
{ratingLabel(val, k)}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Growth Plan */}
|
||||||
|
<div className="border-t-2 border-indigo-500 pt-4">
|
||||||
|
<h2 className="text-lg font-bold text-gray-900 mb-3">Growth Plan</h2>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-semibold text-gray-700 block mb-1">Strengths Identified</label>
|
||||||
|
<textarea value={growthPlan.strengths} onChange={e => setGrowthPlan(p => ({ ...p, strengths: e.target.value }))} rows={3} className="w-full border border-gray-300 rounded-lg p-2 text-sm" placeholder="Enter strengths..." />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-semibold text-gray-700 block mb-1">Focus Areas</label>
|
||||||
|
<textarea value={growthPlan.focusAreas} onChange={e => setGrowthPlan(p => ({ ...p, focusAreas: e.target.value }))} rows={3} className="w-full border border-gray-300 rounded-lg p-2 text-sm" placeholder="Enter focus areas..." />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-semibold text-gray-700 block mb-1">Agreed Next Steps</label>
|
||||||
|
<textarea value={growthPlan.nextSteps} onChange={e => setGrowthPlan(p => ({ ...p, nextSteps: e.target.value }))} rows={3} className="w-full border border-gray-300 rounded-lg p-2 text-sm" placeholder="Enter next steps..." />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-semibold text-gray-700 block mb-1">Review Date</label>
|
||||||
|
<input type="date" value={growthPlan.reviewDate} onChange={e => setGrowthPlan(p => ({ ...p, reviewDate: e.target.value }))} className="w-full border border-gray-300 rounded-lg p-2 text-sm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main Dashboard View
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
value={selectedTeacher}
|
||||||
|
onChange={(e) => setSelectedTeacher(e.target.value)}
|
||||||
|
className="appearance-none bg-slate-800/70 border border-slate-700/50 rounded-xl px-4 py-2 pr-8 text-sm text-white focus:outline-none focus:border-indigo-500/50"
|
||||||
|
>
|
||||||
|
<option value="all">All Teachers</option>
|
||||||
|
{teachers.map(t => <option key={t} value={t}>{t}</option>)}
|
||||||
|
</select>
|
||||||
|
<ChevronDown size={14} className="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-500 pointer-events-none" />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1 bg-slate-800/50 rounded-xl border border-slate-700/50 p-0.5">
|
||||||
|
{(['30', '60', '90'] as const).map(r => (
|
||||||
|
<button key={r} onClick={() => setTimeRange(r)} className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${timeRange === r ? 'bg-indigo-500 text-white' : 'text-slate-400 hover:text-white'}`}>
|
||||||
|
{r}d
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button onClick={() => setPresentationMode(true)} className="flex items-center gap-1.5 px-3 py-1.5 bg-slate-800/50 text-slate-400 rounded-xl text-xs font-medium hover:text-white transition-colors border border-slate-700/50">
|
||||||
|
<Maximize2 size={12} /> Presentation
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setShowReport(true)} className="flex items-center gap-1.5 px-3 py-1.5 bg-indigo-500/20 text-indigo-400 rounded-xl text-xs font-medium hover:bg-indigo-500/30 transition-colors border border-indigo-500/30">
|
||||||
|
<FileText size={12} /> Generate Report
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Overview Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
{/* Overall Score */}
|
||||||
|
<div className="bg-slate-800/50 rounded-2xl border border-slate-700/50 p-5 flex items-center gap-5">
|
||||||
|
<div className={`w-20 h-20 rounded-2xl ${status.bg} border ${status.border} flex items-center justify-center flex-shrink-0`}>
|
||||||
|
<span className={`text-3xl font-black ${status.text}`}>{overallPct}%</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500 uppercase tracking-wider">Overall Performance</p>
|
||||||
|
<p className={`text-sm font-semibold ${status.text}`}>{status.label}</p>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">{filtered.length} walk-throughs</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Flags */}
|
||||||
|
<div className="bg-slate-800/50 rounded-2xl border border-slate-700/50 p-5">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<AlertTriangle size={16} className="text-amber-400" />
|
||||||
|
<p className="text-xs text-slate-500 uppercase tracking-wider">Attention Flags</p>
|
||||||
|
</div>
|
||||||
|
{flags.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{flags.map((f, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2 bg-amber-500/10 border border-amber-500/20 rounded-lg px-3 py-1.5">
|
||||||
|
<AlertTriangle size={12} className="text-amber-400 flex-shrink-0" />
|
||||||
|
<p className="text-xs text-amber-300">{f.category}: {f.count}x needs attention</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-slate-500">No recurring concerns flagged</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recognitions */}
|
||||||
|
<div className="bg-slate-800/50 rounded-2xl border border-slate-700/50 p-5">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Award size={16} className="text-emerald-400" />
|
||||||
|
<p className="text-xs text-slate-500 uppercase tracking-wider">Recognitions</p>
|
||||||
|
</div>
|
||||||
|
{recognitions.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{recognitions.map((r, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2 bg-emerald-500/10 border border-emerald-500/20 rounded-lg px-3 py-1.5">
|
||||||
|
<Star size={12} className="text-emerald-400 flex-shrink-0" />
|
||||||
|
<p className="text-xs text-emerald-300">{r.category}: 3+ consecutive Excellent</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-slate-500">Complete 3+ walk-throughs to unlock recognitions</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category Breakdown Bar Chart */}
|
||||||
|
<div className="bg-slate-800/50 rounded-2xl border border-slate-700/50 p-5">
|
||||||
|
<h3 className="text-sm font-semibold text-white mb-4 flex items-center gap-2">
|
||||||
|
<BarChart3 size={16} className="text-indigo-400" /> Category Breakdown
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{CATEGORY_KEYS.map(key => {
|
||||||
|
const data = categoryAverages[key];
|
||||||
|
if (!data) return null;
|
||||||
|
const pct = Math.round((data.avg / data.max) * 100);
|
||||||
|
return (
|
||||||
|
<div key={key} className="flex items-center gap-3">
|
||||||
|
<span className="text-xs text-slate-400 w-32 text-right truncate">{CATEGORY_LABELS[key]}</span>
|
||||||
|
<div className="flex-1 h-6 bg-slate-900/50 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full ${barColor(data.avg, data.max)} transition-all duration-700 flex items-center justify-end pr-2`}
|
||||||
|
style={{ width: `${Math.max(pct, 8)}%` }}
|
||||||
|
>
|
||||||
|
{pct > 20 && <span className="text-[10px] font-bold text-white">{pct}%</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{pct <= 20 && <span className={`text-xs font-bold ${ratingColor(data.avg, data.max)}`}>{pct}%</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Trend Line */}
|
||||||
|
{trendData.length > 1 && (
|
||||||
|
<div className="bg-slate-800/50 rounded-2xl border border-slate-700/50 p-5">
|
||||||
|
<h3 className="text-sm font-semibold text-white mb-4 flex items-center gap-2">
|
||||||
|
<TrendingUp size={16} className="text-violet-400" /> Performance Trend — Last {timeRange} Days
|
||||||
|
</h3>
|
||||||
|
<div className="relative">
|
||||||
|
<svg viewBox="0 0 800 200" className="w-full h-40">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="trendGrad" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="#818cf8" stopOpacity="0.2" />
|
||||||
|
<stop offset="100%" stopColor="#818cf8" stopOpacity="0" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
{[25, 50, 75, 100].map(v => (
|
||||||
|
<g key={v}>
|
||||||
|
<line x1="40" y1={180 - (v / 100) * 160} x2="780" y2={180 - (v / 100) * 160} stroke="#1e293b" strokeWidth="1" strokeDasharray="4" />
|
||||||
|
<text x="30" y={184 - (v / 100) * 160} fill="#475569" fontSize="10" textAnchor="end">{v}%</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
<path
|
||||||
|
d={`M ${trendData.map((d, i) => `${40 + (i / (trendData.length - 1)) * 740},${180 - (d.pct / 100) * 160}`).join(' L ')} L ${40 + 740},180 L 40,180 Z`}
|
||||||
|
fill="url(#trendGrad)"
|
||||||
|
/>
|
||||||
|
<polyline
|
||||||
|
points={trendData.map((d, i) => `${40 + (i / (trendData.length - 1)) * 740},${180 - (d.pct / 100) * 160}`).join(' ')}
|
||||||
|
fill="none" stroke="#818cf8" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
{trendData.map((d, i) => (
|
||||||
|
<g key={i}>
|
||||||
|
<circle cx={40 + (i / (trendData.length - 1)) * 740} cy={180 - (d.pct / 100) * 160} r="4" fill="#818cf8" stroke="#0f172a" strokeWidth="2" />
|
||||||
|
<text x={40 + (i / (trendData.length - 1)) * 740} y={175 - (d.pct / 100) * 160 - 8} fill="#94a3b8" fontSize="9" textAnchor="middle">{d.pct}%</text>
|
||||||
|
{trendData.length <= 10 && (
|
||||||
|
<text x={40 + (i / (trendData.length - 1)) * 740} y={196} fill="#475569" fontSize="8" textAnchor="middle">
|
||||||
|
{new Date(d.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Walk-Through Frequency */}
|
||||||
|
{selectedTeacher === 'all' && teachers.length > 0 && (
|
||||||
|
<div className="bg-slate-800/50 rounded-2xl border border-slate-700/50 p-5">
|
||||||
|
<h3 className="text-sm font-semibold text-white mb-4 flex items-center gap-2">
|
||||||
|
<User size={16} className="text-cyan-400" /> Walk-Through Frequency by Teacher
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{teachers.map(t => {
|
||||||
|
const count = filtered.filter(c => c.teacher_name === t).length;
|
||||||
|
const maxCount = Math.max(...teachers.map(tt => filtered.filter(c => c.teacher_name === tt).length), 1);
|
||||||
|
return (
|
||||||
|
<button key={t} onClick={() => setSelectedTeacher(t)} className="w-full flex items-center gap-3 hover:bg-slate-700/30 rounded-lg p-2 transition-colors group">
|
||||||
|
<span className="text-xs text-slate-400 w-32 text-right truncate">{t}</span>
|
||||||
|
<div className="flex-1 h-5 bg-slate-900/50 rounded-full overflow-hidden">
|
||||||
|
<div className="h-full bg-cyan-500/60 rounded-full transition-all" style={{ width: `${(count / maxCount) * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-semibold text-cyan-400 w-8">{count}</span>
|
||||||
|
<ArrowRight size={12} className="text-slate-600 group-hover:text-slate-400 transition-colors" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recent Walk-Throughs Table */}
|
||||||
|
<div className="bg-slate-800/50 rounded-2xl border border-slate-700/50 p-5">
|
||||||
|
<h3 className="text-sm font-semibold text-white mb-4 flex items-center gap-2">
|
||||||
|
<Calendar size={16} className="text-amber-400" /> Recent Walk-Throughs
|
||||||
|
</h3>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-700/50">
|
||||||
|
<th className="text-left p-2 text-xs text-slate-500 font-medium">Date</th>
|
||||||
|
<th className="text-left p-2 text-xs text-slate-500 font-medium">Teacher</th>
|
||||||
|
{CATEGORY_KEYS.map(k => (
|
||||||
|
<th key={k} className="text-center p-2 text-xs text-slate-500 font-medium">{CATEGORY_LABELS[k].split(' ')[0]}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filtered.slice(0, 8).map((c: any, i: number) => (
|
||||||
|
<tr key={i} className="border-b border-slate-800/50 hover:bg-slate-700/20 transition-colors">
|
||||||
|
<td className="p-2 text-xs text-slate-400">{c.check_in_date}</td>
|
||||||
|
<td className="p-2 text-xs text-white font-medium">{c.teacher_name}</td>
|
||||||
|
{CATEGORY_KEYS.map(k => {
|
||||||
|
const val = c[`${k}_rating`];
|
||||||
|
const maxV = (k === 'emergency_exit' || k === 'lesson_plan') ? 3 : 4;
|
||||||
|
return (
|
||||||
|
<td key={k} className="p-2 text-center">
|
||||||
|
<span className={`inline-block px-2 py-0.5 rounded-md text-[10px] font-semibold ${
|
||||||
|
val / maxV >= 0.75 ? 'bg-emerald-500/15 text-emerald-400' :
|
||||||
|
val / maxV >= 0.5 ? 'bg-amber-500/15 text-amber-400' :
|
||||||
|
'bg-red-500/15 text-red-400'
|
||||||
|
}`}>
|
||||||
|
{ratingLabel(val, k).split(' ')[0]}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Auto Summary */}
|
||||||
|
{autoSummary && (
|
||||||
|
<div className="bg-indigo-500/10 border border-indigo-500/20 rounded-2xl p-5">
|
||||||
|
<h3 className="text-sm font-semibold text-indigo-300 mb-2 flex items-center gap-2">
|
||||||
|
<Eye size={14} /> Auto-Generated Summary
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-slate-300 italic leading-relaxed">{autoSummary}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WalkThroughSummary;
|
||||||
186
src/components/frameworks/ZonesOfRegulation.tsx
Normal file
186
src/components/frameworks/ZonesOfRegulation.tsx
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { ZONES } from '@/lib/appData';
|
||||||
|
import { ZoneColor } from '@/lib/types';
|
||||||
|
import { Layers, ChevronDown, ChevronUp, HandMetal, Shield, Lightbulb, Eye } from 'lucide-react';
|
||||||
|
|
||||||
|
|
||||||
|
const ZonesOfRegulation: React.FC = () => {
|
||||||
|
const [expandedZone, setExpandedZone] = useState<ZoneColor | null>('green');
|
||||||
|
const [activeTab, setActiveTab] = useState<'overview' | 'student' | 'adult'>('overview');
|
||||||
|
|
||||||
|
const zoneGradients: Record<ZoneColor, string> = {
|
||||||
|
blue: 'from-blue-400 to-blue-600',
|
||||||
|
green: 'from-green-400 to-green-600',
|
||||||
|
yellow: 'from-yellow-400 to-amber-500',
|
||||||
|
red: 'from-red-400 to-red-600',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-800 flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-teal-400 to-teal-600 flex items-center justify-center">
|
||||||
|
<Layers size={20} className="text-white" />
|
||||||
|
</div>
|
||||||
|
Zones of Regulation
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">A whole-campus emotional regulation system — for students AND staff</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex gap-2 bg-white rounded-xl border border-violet-100 p-1.5 shadow-sm w-fit">
|
||||||
|
{[
|
||||||
|
{ id: 'overview', label: 'Zone Overview', icon: <Eye size={14} /> },
|
||||||
|
{ id: 'student', label: 'Student Cues', icon: <Lightbulb size={14} /> },
|
||||||
|
{ id: 'adult', label: 'Adult Examples', icon: <Shield size={14} /> },
|
||||||
|
].map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setActiveTab(tab.id as any)}
|
||||||
|
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||||
|
activeTab === tab.id
|
||||||
|
? 'bg-gradient-to-r from-teal-500 to-emerald-500 text-white shadow-sm'
|
||||||
|
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.icon} {tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Zone Visual Overview */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
{ZONES.map(zone => (
|
||||||
|
<button
|
||||||
|
key={zone.color}
|
||||||
|
onClick={() => setExpandedZone(expandedZone === zone.color ? null : zone.color)}
|
||||||
|
className={`${zone.bgClass} rounded-2xl p-5 border-2 ${
|
||||||
|
expandedZone === zone.color ? zone.borderClass + ' ring-2 ring-offset-2' : 'border-transparent'
|
||||||
|
} transition-all duration-200 hover:scale-[1.02] text-left`}
|
||||||
|
style={{ ringColor: zone.color === 'blue' ? '#93c5fd' : zone.color === 'green' ? '#86efac' : zone.color === 'yellow' ? '#fde047' : '#fca5a5' }}
|
||||||
|
>
|
||||||
|
<div className={`w-12 h-12 rounded-xl bg-gradient-to-br ${zoneGradients[zone.color]} flex items-center justify-center mb-3`}>
|
||||||
|
<span className="text-white text-xl font-bold">{zone.color[0].toUpperCase()}</span>
|
||||||
|
</div>
|
||||||
|
<h3 className={`font-bold ${zone.textClass}`}>{zone.name}</h3>
|
||||||
|
<p className="text-xs text-gray-500 mt-1 leading-relaxed">{zone.description}</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expanded Zone Detail */}
|
||||||
|
{expandedZone && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{ZONES.filter(z => z.color === expandedZone).map(zone => (
|
||||||
|
<div key={zone.color} className={`${zone.bgClass} rounded-2xl border-2 ${zone.borderClass} p-6`}>
|
||||||
|
<div className="flex items-center gap-3 mb-5">
|
||||||
|
<div className={`w-12 h-12 rounded-xl bg-gradient-to-br ${zoneGradients[zone.color]} flex items-center justify-center`}>
|
||||||
|
<Layers size={24} className="text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className={`text-xl font-bold ${zone.textClass}`}>{zone.name}</h3>
|
||||||
|
<p className="text-sm text-gray-500">{zone.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
{/* Behaviors */}
|
||||||
|
<div className="bg-white/70 rounded-xl p-4">
|
||||||
|
<h4 className={`font-semibold text-sm ${zone.textClass} mb-3 flex items-center gap-2`}>
|
||||||
|
<Eye size={14} />
|
||||||
|
{activeTab === 'adult' ? 'Adult Behaviors' : 'What It Looks Like'}
|
||||||
|
</h4>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{zone.behaviors.map((b, i) => (
|
||||||
|
<li key={i} className="flex items-start gap-2">
|
||||||
|
<div className={`w-1.5 h-1.5 rounded-full mt-1.5 flex-shrink-0 bg-gradient-to-br ${zoneGradients[zone.color]}`} />
|
||||||
|
<span className="text-xs text-gray-600">{b}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Strategies */}
|
||||||
|
<div className="bg-white/70 rounded-xl p-4">
|
||||||
|
<h4 className={`font-semibold text-sm ${zone.textClass} mb-3 flex items-center gap-2`}>
|
||||||
|
<Lightbulb size={14} />
|
||||||
|
Strategies to Use
|
||||||
|
</h4>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{zone.strategies.map((s, i) => (
|
||||||
|
<li key={i} className="flex items-start gap-2">
|
||||||
|
<div className={`w-1.5 h-1.5 rounded-full mt-1.5 flex-shrink-0 bg-gradient-to-br ${zoneGradients[zone.color]}`} />
|
||||||
|
<span className="text-xs text-gray-600">{s}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Signs */}
|
||||||
|
<div className="bg-white/70 rounded-xl p-4">
|
||||||
|
<h4 className={`font-semibold text-sm ${zone.textClass} mb-3 flex items-center gap-2`}>
|
||||||
|
<HandMetal size={14} />
|
||||||
|
Matching Signs
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{zone.signs.map((sign, i) => (
|
||||||
|
<div key={i} className={`${zone.bgClass} rounded-lg p-2.5 flex items-center gap-2`}>
|
||||||
|
<div className={`w-8 h-8 rounded-lg bg-gradient-to-br ${zoneGradients[zone.color]} flex items-center justify-center`}>
|
||||||
|
<HandMetal size={14} className="text-white" />
|
||||||
|
</div>
|
||||||
|
<span className={`text-sm font-semibold ${zone.textClass}`}>"{sign}"</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* QBS Connection */}
|
||||||
|
{(zone.color === 'yellow' || zone.color === 'red') && (
|
||||||
|
<div className="mt-4 bg-white/80 rounded-xl p-4 border border-amber-200">
|
||||||
|
<h4 className="font-semibold text-sm text-amber-800 flex items-center gap-2 mb-2">
|
||||||
|
<Shield size={14} />
|
||||||
|
QBS Safety Connection
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-gray-600">
|
||||||
|
{zone.color === 'yellow'
|
||||||
|
? 'Yellow Zone is the intervention window. Use de-escalation strategies NOW to prevent reaching Red Zone. Reduce demands, offer choices, and use matching signs.'
|
||||||
|
: 'Red Zone requires safety protocols. Follow QBS guidelines: ensure safety first, clear the area if needed, minimal words, calm presence. Do NOT process during crisis.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Quick Reference Cards */}
|
||||||
|
<div className="bg-white rounded-2xl border border-violet-100 shadow-sm p-5">
|
||||||
|
<h3 className="font-semibold text-gray-800 mb-4">Quick De-Escalation Flow</h3>
|
||||||
|
<div className="flex flex-col md:flex-row gap-3">
|
||||||
|
{[
|
||||||
|
{ step: '1', label: 'Notice the Zone', desc: 'Identify current zone', color: 'bg-teal-100 text-teal-700 border-teal-200' },
|
||||||
|
{ step: '2', label: 'Reduce Demands', desc: 'Lower expectations immediately', color: 'bg-blue-100 text-blue-700 border-blue-200' },
|
||||||
|
{ step: '3', label: 'Offer Support', desc: 'Signs, choices, or space', color: 'bg-amber-100 text-amber-700 border-amber-200' },
|
||||||
|
{ step: '4', label: 'Wait & Monitor', desc: 'Give processing time', color: 'bg-violet-100 text-violet-700 border-violet-200' },
|
||||||
|
{ step: '5', label: 'Reconnect', desc: 'Return to Green Zone', color: 'bg-emerald-100 text-emerald-700 border-emerald-200' },
|
||||||
|
].map((step, i) => (
|
||||||
|
<div key={i} className="flex-1">
|
||||||
|
<div className={`${step.color} rounded-xl p-4 border text-center h-full`}>
|
||||||
|
<div className="w-8 h-8 rounded-full bg-white/60 flex items-center justify-center mx-auto mb-2 font-bold text-sm">
|
||||||
|
{step.step}
|
||||||
|
</div>
|
||||||
|
<p className="font-semibold text-sm">{step.label}</p>
|
||||||
|
<p className="text-[10px] mt-1 opacity-80">{step.desc}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ZonesOfRegulation;
|
||||||
69
src/components/theme-provider.tsx
Normal file
69
src/components/theme-provider.tsx
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { createContext, useContext, useEffect, useState } from "react"
|
||||||
|
import { ThemeProviderProps } from "next-themes/dist/types"
|
||||||
|
|
||||||
|
type Theme = "dark" | "light" | "system"
|
||||||
|
|
||||||
|
type ThemeContextType = {
|
||||||
|
theme: Theme
|
||||||
|
setTheme: (theme: Theme) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemeContext = createContext<ThemeContextType | null>(null)
|
||||||
|
|
||||||
|
export function ThemeProvider({
|
||||||
|
children,
|
||||||
|
defaultTheme = "system",
|
||||||
|
value: _value,
|
||||||
|
...props
|
||||||
|
}: ThemeProviderProps) {
|
||||||
|
const [theme, setTheme] = useState<Theme>(() => {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
const savedTheme = localStorage.getItem("theme")
|
||||||
|
return (savedTheme && (savedTheme === "dark" || savedTheme === "light" || savedTheme === "system")
|
||||||
|
? savedTheme
|
||||||
|
: defaultTheme) as Theme
|
||||||
|
}
|
||||||
|
return defaultTheme as Theme
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = window.document.documentElement
|
||||||
|
root.classList.remove("light", "dark")
|
||||||
|
|
||||||
|
if (theme === "system") {
|
||||||
|
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
|
||||||
|
.matches
|
||||||
|
? "dark"
|
||||||
|
: "light"
|
||||||
|
root.classList.add(systemTheme)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
root.classList.add(theme)
|
||||||
|
}, [theme])
|
||||||
|
|
||||||
|
const value: ThemeContextType = {
|
||||||
|
theme,
|
||||||
|
setTheme: (theme: Theme) => {
|
||||||
|
localStorage.setItem("theme", theme)
|
||||||
|
setTheme(theme)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={value} {...props}>
|
||||||
|
{children}
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTheme = (): ThemeContextType => {
|
||||||
|
const context = useContext(ThemeContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useTheme must be used within a ThemeProvider")
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
56
src/components/ui/accordion.tsx
Normal file
56
src/components/ui/accordion.tsx
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
||||||
|
import { ChevronDown } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Accordion = AccordionPrimitive.Root
|
||||||
|
|
||||||
|
const AccordionItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AccordionPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AccordionPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn("border-b border-border/50", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AccordionItem.displayName = "AccordionItem"
|
||||||
|
|
||||||
|
const AccordionTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<AccordionPrimitive.Header className="flex">
|
||||||
|
<AccordionPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:text-primary [&[data-state=open]>svg]:rotate-180 [&[data-state=open]]:text-primary",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-300 ease-in-out text-muted-foreground" />
|
||||||
|
</AccordionPrimitive.Trigger>
|
||||||
|
</AccordionPrimitive.Header>
|
||||||
|
))
|
||||||
|
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const AccordionContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AccordionPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<AccordionPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className="overflow-hidden text-sm text-muted-foreground transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className={cn("pb-4 pt-0", className)}>{children}</div>
|
||||||
|
</AccordionPrimitive.Content>
|
||||||
|
))
|
||||||
|
|
||||||
|
AccordionContent.displayName = AccordionPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||||
139
src/components/ui/alert-dialog.tsx
Normal file
139
src/components/ui/alert-dialog.tsx
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
|
|
||||||
|
const AlertDialog = AlertDialogPrimitive.Root
|
||||||
|
|
||||||
|
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
|
||||||
|
|
||||||
|
const AlertDialogPortal = AlertDialogPrimitive.Portal
|
||||||
|
|
||||||
|
const AlertDialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Overlay
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
||||||
|
|
||||||
|
const AlertDialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-card p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
))
|
||||||
|
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const AlertDialogHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col space-y-2 text-center sm:text-left",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
AlertDialogHeader.displayName = "AlertDialogHeader"
|
||||||
|
|
||||||
|
const AlertDialogFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 mt-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
AlertDialogFooter.displayName = "AlertDialogFooter"
|
||||||
|
|
||||||
|
const AlertDialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-lg font-semibold text-primary/90", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
||||||
|
|
||||||
|
const AlertDialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground mt-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogDescription.displayName =
|
||||||
|
AlertDialogPrimitive.Description.displayName
|
||||||
|
|
||||||
|
const AlertDialogAction = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Action
|
||||||
|
ref={ref}
|
||||||
|
className={cn(buttonVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
|
||||||
|
|
||||||
|
const AlertDialogCancel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Cancel
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({ variant: "outline" }),
|
||||||
|
"mt-2 sm:mt-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
}
|
||||||
65
src/components/ui/alert.tsx
Normal file
65
src/components/ui/alert.tsx
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const alertVariants = cva(
|
||||||
|
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground shadow-sm",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-background text-foreground",
|
||||||
|
destructive:
|
||||||
|
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||||
|
success:
|
||||||
|
"border-green-500/50 text-green-600 dark:text-green-400 [&>svg]:text-green-600 dark:[&>svg]:text-green-400 bg-green-50 dark:bg-green-950/20",
|
||||||
|
warning:
|
||||||
|
"border-yellow-500/50 text-yellow-600 dark:text-yellow-400 [&>svg]:text-yellow-600 dark:[&>svg]:text-yellow-400 bg-yellow-50 dark:bg-yellow-950/20",
|
||||||
|
info:
|
||||||
|
"border-primary/50 text-primary dark:text-primary-foreground [&>svg]:text-primary bg-primary/10 dark:bg-primary/20",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const Alert = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||||
|
>(({ className, variant, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
role="alert"
|
||||||
|
className={cn(alertVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Alert.displayName = "Alert"
|
||||||
|
|
||||||
|
const AlertTitle = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLHeadingElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<h5
|
||||||
|
ref={ref}
|
||||||
|
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertTitle.displayName = "AlertTitle"
|
||||||
|
|
||||||
|
const AlertDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm [&_p]:leading-relaxed opacity-90", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDescription.displayName = "AlertDescription"
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription }
|
||||||
5
src/components/ui/aspect-ratio.tsx
Normal file
5
src/components/ui/aspect-ratio.tsx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
|
||||||
|
|
||||||
|
const AspectRatio = AspectRatioPrimitive.Root
|
||||||
|
|
||||||
|
export { AspectRatio }
|
||||||
54
src/components/ui/avatar.tsx
Normal file
54
src/components/ui/avatar.tsx
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Avatar = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root> & {
|
||||||
|
size?: "sm" | "md" | "lg" | "xl"
|
||||||
|
}
|
||||||
|
>(({ className, size = "md", ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex shrink-0 overflow-hidden rounded-full border border-border/30 ring-offset-background",
|
||||||
|
size === "sm" && "h-8 w-8",
|
||||||
|
size === "md" && "h-10 w-10",
|
||||||
|
size === "lg" && "h-12 w-12",
|
||||||
|
size === "xl" && "h-16 w-16",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Avatar.displayName = AvatarPrimitive.Root.displayName
|
||||||
|
|
||||||
|
const AvatarImage = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Image
|
||||||
|
ref={ref}
|
||||||
|
className={cn("aspect-square h-full w-full object-cover", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AvatarImage.displayName = AvatarPrimitive.Image.displayName
|
||||||
|
|
||||||
|
const AvatarFallback = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Fallback
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-full items-center justify-center rounded-full bg-muted text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
|
||||||
|
|
||||||
|
export { Avatar, AvatarImage, AvatarFallback }
|
||||||
48
src/components/ui/badge.tsx
Normal file
48
src/components/ui/badge.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-all focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||||
|
secondary:
|
||||||
|
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
destructive:
|
||||||
|
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||||
|
outline: "text-foreground border-border",
|
||||||
|
success:
|
||||||
|
"border-transparent bg-green-500/20 text-green-700 dark:text-green-300 border-green-500/30",
|
||||||
|
warning:
|
||||||
|
"border-transparent bg-yellow-500/20 text-yellow-700 dark:text-yellow-300 border-yellow-500/30",
|
||||||
|
info:
|
||||||
|
"border-transparent bg-primary/10 text-primary border-primary/30",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "px-2.5 py-0.5 text-xs",
|
||||||
|
sm: "px-2 py-0.5 text-[10px]",
|
||||||
|
lg: "px-3 py-0.5 text-sm",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface BadgeProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
|
function Badge({ className, variant, size, ...props }: BadgeProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn(badgeVariants({ variant, size }), className)} {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
115
src/components/ui/breadcrumb.tsx
Normal file
115
src/components/ui/breadcrumb.tsx
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { ChevronRight, MoreHorizontal } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Breadcrumb = React.forwardRef<
|
||||||
|
HTMLElement,
|
||||||
|
React.ComponentPropsWithoutRef<"nav"> & {
|
||||||
|
separator?: React.ReactNode
|
||||||
|
}
|
||||||
|
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />)
|
||||||
|
Breadcrumb.displayName = "Breadcrumb"
|
||||||
|
|
||||||
|
const BreadcrumbList = React.forwardRef<
|
||||||
|
HTMLOListElement,
|
||||||
|
React.ComponentPropsWithoutRef<"ol">
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ol
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
BreadcrumbList.displayName = "BreadcrumbList"
|
||||||
|
|
||||||
|
const BreadcrumbItem = React.forwardRef<
|
||||||
|
HTMLLIElement,
|
||||||
|
React.ComponentPropsWithoutRef<"li">
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<li
|
||||||
|
ref={ref}
|
||||||
|
className={cn("inline-flex items-center gap-1.5", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
BreadcrumbItem.displayName = "BreadcrumbItem"
|
||||||
|
|
||||||
|
const BreadcrumbLink = React.forwardRef<
|
||||||
|
HTMLAnchorElement,
|
||||||
|
React.ComponentPropsWithoutRef<"a"> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}
|
||||||
|
>(({ asChild, className, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "a"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
ref={ref}
|
||||||
|
className={cn("transition-colors hover:text-primary focus-visible:text-primary", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
BreadcrumbLink.displayName = "BreadcrumbLink"
|
||||||
|
|
||||||
|
const BreadcrumbPage = React.forwardRef<
|
||||||
|
HTMLSpanElement,
|
||||||
|
React.ComponentPropsWithoutRef<"span">
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<span
|
||||||
|
ref={ref}
|
||||||
|
role="link"
|
||||||
|
aria-disabled="true"
|
||||||
|
aria-current="page"
|
||||||
|
className={cn("font-medium text-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
BreadcrumbPage.displayName = "BreadcrumbPage"
|
||||||
|
|
||||||
|
const BreadcrumbSeparator = ({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"li">) => (
|
||||||
|
<li
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("[&>svg]:size-3.5 text-muted-foreground/50", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children ?? <ChevronRight className="h-3.5 w-3.5" />}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
|
||||||
|
|
||||||
|
const BreadcrumbEllipsis = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) => (
|
||||||
|
<span
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("flex h-9 w-9 items-center justify-center text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
<span className="sr-only">More</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"
|
||||||
|
|
||||||
|
export {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbPage,
|
||||||
|
BreadcrumbSeparator,
|
||||||
|
BreadcrumbEllipsis,
|
||||||
|
}
|
||||||
56
src/components/ui/button.tsx
Normal file
56
src/components/ui/button.tsx
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||||
|
outline:
|
||||||
|
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-10 px-4 py-2",
|
||||||
|
sm: "h-9 rounded-md px-3",
|
||||||
|
lg: "h-11 rounded-md px-8",
|
||||||
|
icon: "h-10 w-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Button.displayName = "Button"
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
64
src/components/ui/calendar.tsx
Normal file
64
src/components/ui/calendar.tsx
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
|
import { DayPicker } from "react-day-picker";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
|
||||||
|
|
||||||
|
function Calendar({
|
||||||
|
className,
|
||||||
|
classNames,
|
||||||
|
showOutsideDays = true,
|
||||||
|
...props
|
||||||
|
}: CalendarProps) {
|
||||||
|
return (
|
||||||
|
<DayPicker
|
||||||
|
showOutsideDays={showOutsideDays}
|
||||||
|
className={cn("p-3", className)}
|
||||||
|
classNames={{
|
||||||
|
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
|
||||||
|
month: "space-y-4",
|
||||||
|
caption: "flex justify-center pt-1 relative items-center",
|
||||||
|
caption_label: "text-sm font-medium text-foreground",
|
||||||
|
nav: "space-x-1 flex items-center",
|
||||||
|
nav_button: cn(
|
||||||
|
buttonVariants({ variant: "outline", size: "sm" }),
|
||||||
|
"h-7 w-7 bg-transparent p-0 opacity-70 hover:opacity-100 transition-opacity"
|
||||||
|
),
|
||||||
|
nav_button_previous: "absolute left-1",
|
||||||
|
nav_button_next: "absolute right-1",
|
||||||
|
table: "w-full border-collapse space-y-1",
|
||||||
|
head_row: "flex",
|
||||||
|
head_cell:
|
||||||
|
"text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
|
||||||
|
row: "flex w-full mt-2",
|
||||||
|
cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
|
||||||
|
day: cn(
|
||||||
|
buttonVariants({ variant: "ghost", size: "sm" }),
|
||||||
|
"h-9 w-9 p-0 font-normal aria-selected:opacity-100 hover:text-accent-foreground"
|
||||||
|
),
|
||||||
|
day_range_end: "day-range-end",
|
||||||
|
day_selected:
|
||||||
|
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground rounded-md transition-colors",
|
||||||
|
day_today: "bg-accent/50 text-accent-foreground rounded-md",
|
||||||
|
day_outside:
|
||||||
|
"day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/30 aria-selected:text-muted-foreground aria-selected:opacity-40",
|
||||||
|
day_disabled: "text-muted-foreground opacity-50",
|
||||||
|
day_range_middle:
|
||||||
|
"aria-selected:bg-accent/60 aria-selected:text-accent-foreground rounded-none",
|
||||||
|
day_hidden: "invisible",
|
||||||
|
...classNames,
|
||||||
|
}}
|
||||||
|
components={{
|
||||||
|
IconLeft: ({ ..._props }) => <ChevronLeft className="h-4 w-4" />,
|
||||||
|
IconRight: ({ ..._props }) => <ChevronRight className="h-4 w-4" />,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Calendar.displayName = "Calendar";
|
||||||
|
|
||||||
|
export { Calendar };
|
||||||
79
src/components/ui/card.tsx
Normal file
79
src/components/ui/card.tsx
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Card = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border border-border/40 bg-background shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Card.displayName = "Card"
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardHeader.displayName = "CardHeader"
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLHeadingElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<h3
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-2xl font-semibold leading-none tracking-tight text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardTitle.displayName = "CardTitle"
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardDescription.displayName = "CardDescription"
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||||
|
))
|
||||||
|
CardContent.displayName = "CardContent"
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex items-center p-6 pt-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardFooter.displayName = "CardFooter"
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||||
260
src/components/ui/carousel.tsx
Normal file
260
src/components/ui/carousel.tsx
Normal file
@ -0,0 +1,260 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import useEmblaCarousel, {
|
||||||
|
type UseEmblaCarouselType,
|
||||||
|
} from "embla-carousel-react"
|
||||||
|
import { ArrowLeft, ArrowRight } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
|
type CarouselApi = UseEmblaCarouselType[1]
|
||||||
|
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
|
||||||
|
type CarouselOptions = UseCarouselParameters[0]
|
||||||
|
type CarouselPlugin = UseCarouselParameters[1]
|
||||||
|
|
||||||
|
type CarouselProps = {
|
||||||
|
opts?: CarouselOptions
|
||||||
|
plugins?: CarouselPlugin
|
||||||
|
orientation?: "horizontal" | "vertical"
|
||||||
|
setApi?: (api: CarouselApi) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type CarouselContextProps = {
|
||||||
|
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
|
||||||
|
api: ReturnType<typeof useEmblaCarousel>[1]
|
||||||
|
scrollPrev: () => void
|
||||||
|
scrollNext: () => void
|
||||||
|
canScrollPrev: boolean
|
||||||
|
canScrollNext: boolean
|
||||||
|
} & CarouselProps
|
||||||
|
|
||||||
|
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
|
||||||
|
|
||||||
|
function useCarousel() {
|
||||||
|
const context = React.useContext(CarouselContext)
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useCarousel must be used within a <Carousel />")
|
||||||
|
}
|
||||||
|
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
const Carousel = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement> & CarouselProps
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
orientation = "horizontal",
|
||||||
|
opts,
|
||||||
|
setApi,
|
||||||
|
plugins,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const [carouselRef, api] = useEmblaCarousel(
|
||||||
|
{
|
||||||
|
...opts,
|
||||||
|
axis: orientation === "horizontal" ? "x" : "y",
|
||||||
|
},
|
||||||
|
plugins
|
||||||
|
)
|
||||||
|
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
|
||||||
|
const [canScrollNext, setCanScrollNext] = React.useState(false)
|
||||||
|
|
||||||
|
const onSelect = React.useCallback((api: CarouselApi) => {
|
||||||
|
if (!api) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setCanScrollPrev(api.canScrollPrev())
|
||||||
|
setCanScrollNext(api.canScrollNext())
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const scrollPrev = React.useCallback(() => {
|
||||||
|
api?.scrollPrev()
|
||||||
|
}, [api])
|
||||||
|
|
||||||
|
const scrollNext = React.useCallback(() => {
|
||||||
|
api?.scrollNext()
|
||||||
|
}, [api])
|
||||||
|
|
||||||
|
const handleKeyDown = React.useCallback(
|
||||||
|
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
if (event.key === "ArrowLeft") {
|
||||||
|
event.preventDefault()
|
||||||
|
scrollPrev()
|
||||||
|
} else if (event.key === "ArrowRight") {
|
||||||
|
event.preventDefault()
|
||||||
|
scrollNext()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[scrollPrev, scrollNext]
|
||||||
|
)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!api || !setApi) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setApi(api)
|
||||||
|
}, [api, setApi])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!api) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
onSelect(api)
|
||||||
|
api.on("reInit", onSelect)
|
||||||
|
api.on("select", onSelect)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
api?.off("select", onSelect)
|
||||||
|
}
|
||||||
|
}, [api, onSelect])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CarouselContext.Provider
|
||||||
|
value={{
|
||||||
|
carouselRef,
|
||||||
|
api: api,
|
||||||
|
opts,
|
||||||
|
orientation:
|
||||||
|
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
|
||||||
|
scrollPrev,
|
||||||
|
scrollNext,
|
||||||
|
canScrollPrev,
|
||||||
|
canScrollNext,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
onKeyDownCapture={handleKeyDown}
|
||||||
|
className={cn("relative", className)}
|
||||||
|
role="region"
|
||||||
|
aria-roledescription="carousel"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</CarouselContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Carousel.displayName = "Carousel"
|
||||||
|
|
||||||
|
const CarouselContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const { carouselRef, orientation } = useCarousel()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={carouselRef} className="overflow-hidden">
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex",
|
||||||
|
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
CarouselContent.displayName = "CarouselContent"
|
||||||
|
|
||||||
|
const CarouselItem = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const { orientation } = useCarousel()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
role="group"
|
||||||
|
aria-roledescription="slide"
|
||||||
|
className={cn(
|
||||||
|
"min-w-0 shrink-0 grow-0 basis-full",
|
||||||
|
orientation === "horizontal" ? "pl-4" : "pt-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
CarouselItem.displayName = "CarouselItem"
|
||||||
|
|
||||||
|
const CarouselPrevious = React.forwardRef<
|
||||||
|
HTMLButtonElement,
|
||||||
|
React.ComponentProps<typeof Button>
|
||||||
|
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
|
||||||
|
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
ref={ref}
|
||||||
|
variant={variant}
|
||||||
|
size={size}
|
||||||
|
className={cn(
|
||||||
|
"absolute h-8 w-8 rounded-full border border-border/40 opacity-80 hover:opacity-100 transition-opacity",
|
||||||
|
orientation === "horizontal"
|
||||||
|
? "-left-12 top-1/2 -translate-y-1/2"
|
||||||
|
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
disabled={!canScrollPrev}
|
||||||
|
onClick={scrollPrev}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Previous slide</span>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
CarouselPrevious.displayName = "CarouselPrevious"
|
||||||
|
|
||||||
|
const CarouselNext = React.forwardRef<
|
||||||
|
HTMLButtonElement,
|
||||||
|
React.ComponentProps<typeof Button>
|
||||||
|
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
|
||||||
|
const { orientation, scrollNext, canScrollNext } = useCarousel()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
ref={ref}
|
||||||
|
variant={variant}
|
||||||
|
size={size}
|
||||||
|
className={cn(
|
||||||
|
"absolute h-8 w-8 rounded-full border border-border/40 opacity-80 hover:opacity-100 transition-opacity",
|
||||||
|
orientation === "horizontal"
|
||||||
|
? "-right-12 top-1/2 -translate-y-1/2"
|
||||||
|
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
disabled={!canScrollNext}
|
||||||
|
onClick={scrollNext}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Next slide</span>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
CarouselNext.displayName = "CarouselNext"
|
||||||
|
|
||||||
|
export {
|
||||||
|
type CarouselApi,
|
||||||
|
Carousel,
|
||||||
|
CarouselContent,
|
||||||
|
CarouselItem,
|
||||||
|
CarouselPrevious,
|
||||||
|
CarouselNext,
|
||||||
|
}
|
||||||
363
src/components/ui/chart.tsx
Normal file
363
src/components/ui/chart.tsx
Normal file
@ -0,0 +1,363 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as RechartsPrimitive from "recharts"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||||
|
const THEMES = { light: "", dark: ".dark" } as const
|
||||||
|
|
||||||
|
export type ChartConfig = {
|
||||||
|
[k in string]: {
|
||||||
|
label?: React.ReactNode
|
||||||
|
icon?: React.ComponentType
|
||||||
|
} & (
|
||||||
|
| { color?: string; theme?: never }
|
||||||
|
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChartContextProps = {
|
||||||
|
config: ChartConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartContext = React.createContext<ChartContextProps | null>(null)
|
||||||
|
|
||||||
|
function useChart() {
|
||||||
|
const context = React.useContext(ChartContext)
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useChart must be used within a <ChartContainer />")
|
||||||
|
}
|
||||||
|
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartContainer = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.ComponentProps<"div"> & {
|
||||||
|
config: ChartConfig
|
||||||
|
children: React.ComponentProps<
|
||||||
|
typeof RechartsPrimitive.ResponsiveContainer
|
||||||
|
>["children"]
|
||||||
|
}
|
||||||
|
>(({ id, className, children, config, ...props }, ref) => {
|
||||||
|
const uniqueId = React.useId()
|
||||||
|
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartContext.Provider value={{ config }}>
|
||||||
|
<div
|
||||||
|
data-chart={chartId}
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/40 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border/60 [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border/40 [&_.recharts-radial-bar-background-sector]:fill-muted/50 [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted/80 [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border/40 [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChartStyle id={chartId} config={config} />
|
||||||
|
<RechartsPrimitive.ResponsiveContainer>
|
||||||
|
{children}
|
||||||
|
</RechartsPrimitive.ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</ChartContext.Provider>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
ChartContainer.displayName = "Chart"
|
||||||
|
|
||||||
|
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||||
|
const colorConfig = Object.entries(config).filter(
|
||||||
|
([_, config]) => config.theme || config.color
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!colorConfig.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<style
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: Object.entries(THEMES)
|
||||||
|
.map(
|
||||||
|
([theme, prefix]) => `
|
||||||
|
${prefix} [data-chart=${id}] {
|
||||||
|
${colorConfig
|
||||||
|
.map(([key, itemConfig]) => {
|
||||||
|
const color =
|
||||||
|
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
||||||
|
itemConfig.color
|
||||||
|
return color ? ` --color-${key}: ${color};` : null
|
||||||
|
})
|
||||||
|
.join("\n")}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.join("\n"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartTooltip = RechartsPrimitive.Tooltip
|
||||||
|
|
||||||
|
const ChartTooltipContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||||
|
React.ComponentProps<"div"> & {
|
||||||
|
hideLabel?: boolean
|
||||||
|
hideIndicator?: boolean
|
||||||
|
indicator?: "line" | "dot" | "dashed"
|
||||||
|
nameKey?: string
|
||||||
|
labelKey?: string
|
||||||
|
}
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
active,
|
||||||
|
payload,
|
||||||
|
className,
|
||||||
|
indicator = "dot",
|
||||||
|
hideLabel = false,
|
||||||
|
hideIndicator = false,
|
||||||
|
label,
|
||||||
|
labelFormatter,
|
||||||
|
labelClassName,
|
||||||
|
formatter,
|
||||||
|
color,
|
||||||
|
nameKey,
|
||||||
|
labelKey,
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const { config } = useChart()
|
||||||
|
|
||||||
|
const tooltipLabel = React.useMemo(() => {
|
||||||
|
if (hideLabel || !payload?.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const [item] = payload
|
||||||
|
const key = `${labelKey || item.dataKey || item.name || "value"}`
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||||
|
const value =
|
||||||
|
!labelKey && typeof label === "string"
|
||||||
|
? config[label as keyof typeof config]?.label || label
|
||||||
|
: itemConfig?.label
|
||||||
|
|
||||||
|
if (labelFormatter) {
|
||||||
|
return (
|
||||||
|
<div className={cn("font-medium", labelClassName)}>
|
||||||
|
{labelFormatter(value, payload)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className={cn("font-medium", labelClassName)}>{value}</div>
|
||||||
|
}, [
|
||||||
|
label,
|
||||||
|
labelFormatter,
|
||||||
|
payload,
|
||||||
|
hideLabel,
|
||||||
|
labelClassName,
|
||||||
|
config,
|
||||||
|
labelKey,
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!active || !payload?.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const nestLabel = payload.length === 1 && indicator !== "dot"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background/95 backdrop-blur-sm px-2.5 py-1.5 text-xs shadow-xl",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{!nestLabel ? tooltipLabel : null}
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
{payload.map((item, index) => {
|
||||||
|
const key = `${nameKey || item.name || item.dataKey || "value"}`
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||||
|
const indicatorColor = color || item.payload.fill || item.color
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.dataKey}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
||||||
|
indicator === "dot" && "items-center"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatter && item?.value !== undefined && item.name ? (
|
||||||
|
formatter(item.value, item.name, item, index, item.payload)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{itemConfig?.icon ? (
|
||||||
|
<itemConfig.icon />
|
||||||
|
) : (
|
||||||
|
!hideIndicator && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
|
||||||
|
{
|
||||||
|
"h-2.5 w-2.5": indicator === "dot",
|
||||||
|
"w-1": indicator === "line",
|
||||||
|
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||||
|
indicator === "dashed",
|
||||||
|
"my-0.5": nestLabel && indicator === "dashed",
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--color-bg": indicatorColor,
|
||||||
|
"--color-border": indicatorColor,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-1 justify-between leading-none",
|
||||||
|
nestLabel ? "items-end" : "items-center"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
{nestLabel ? tooltipLabel : null}
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{itemConfig?.label || item.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{item.value && (
|
||||||
|
<span className="font-mono font-medium tabular-nums text-foreground">
|
||||||
|
{item.value.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ChartTooltipContent.displayName = "ChartTooltip"
|
||||||
|
|
||||||
|
const ChartLegend = RechartsPrimitive.Legend
|
||||||
|
|
||||||
|
const ChartLegendContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.ComponentProps<"div"> &
|
||||||
|
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
||||||
|
hideIcon?: boolean
|
||||||
|
nameKey?: string
|
||||||
|
}
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const { config } = useChart()
|
||||||
|
|
||||||
|
if (!payload?.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-center gap-4",
|
||||||
|
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{payload.map((item) => {
|
||||||
|
const key = `${nameKey || item.dataKey || "value"}`
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.value}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{itemConfig?.icon && !hideIcon ? (
|
||||||
|
<itemConfig.icon />
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||||
|
style={{
|
||||||
|
backgroundColor: item.color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{itemConfig?.label}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ChartLegendContent.displayName = "ChartLegend"
|
||||||
|
|
||||||
|
// Helper to extract item config from a payload.
|
||||||
|
function getPayloadConfigFromPayload(
|
||||||
|
config: ChartConfig,
|
||||||
|
payload: unknown,
|
||||||
|
key: string
|
||||||
|
) {
|
||||||
|
if (typeof payload !== "object" || payload === null) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloadPayload =
|
||||||
|
"payload" in payload &&
|
||||||
|
typeof payload.payload === "object" &&
|
||||||
|
payload.payload !== null
|
||||||
|
? payload.payload
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
let configLabelKey: string = key
|
||||||
|
|
||||||
|
if (
|
||||||
|
key in payload &&
|
||||||
|
typeof payload[key as keyof typeof payload] === "string"
|
||||||
|
) {
|
||||||
|
configLabelKey = payload[key as keyof typeof payload] as string
|
||||||
|
} else if (
|
||||||
|
payloadPayload &&
|
||||||
|
key in payloadPayload &&
|
||||||
|
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||||
|
) {
|
||||||
|
configLabelKey = payloadPayload[
|
||||||
|
key as keyof typeof payloadPayload
|
||||||
|
] as string
|
||||||
|
}
|
||||||
|
|
||||||
|
return configLabelKey in config
|
||||||
|
? config[configLabelKey]
|
||||||
|
: config[key as keyof typeof config]
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
ChartContainer,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
ChartLegend,
|
||||||
|
ChartLegendContent,
|
||||||
|
ChartStyle,
|
||||||
|
}
|
||||||
28
src/components/ui/checkbox.tsx
Normal file
28
src/components/ui/checkbox.tsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||||
|
import { Check } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Checkbox = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"peer h-4 w-4 shrink-0 rounded-sm border border-primary/60 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground transition-colors duration-200",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator
|
||||||
|
className={cn("flex items-center justify-center text-current")}
|
||||||
|
>
|
||||||
|
<Check className="h-3.5 w-3.5 transition-transform duration-200" />
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
))
|
||||||
|
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Checkbox }
|
||||||
9
src/components/ui/collapsible.tsx
Normal file
9
src/components/ui/collapsible.tsx
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
||||||
|
|
||||||
|
const Collapsible = CollapsiblePrimitive.Root
|
||||||
|
|
||||||
|
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
|
||||||
|
|
||||||
|
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
|
||||||
|
|
||||||
|
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||||
151
src/components/ui/command.tsx
Normal file
151
src/components/ui/command.tsx
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { type DialogProps } from "@radix-ui/react-dialog"
|
||||||
|
import { Command as CommandPrimitive } from "cmdk"
|
||||||
|
import { Search } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Dialog, DialogContent } from "@/components/ui/dialog"
|
||||||
|
|
||||||
|
const Command = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Command.displayName = CommandPrimitive.displayName
|
||||||
|
|
||||||
|
const CommandDialog = ({ children, ...props }: DialogProps) => {
|
||||||
|
return (
|
||||||
|
<Dialog {...props}>
|
||||||
|
<DialogContent className="overflow-hidden p-0 shadow-lg">
|
||||||
|
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||||
|
{children}
|
||||||
|
</Command>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const CommandInput = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div className="flex items-center border-b border-border/40 px-3" cmdk-input-wrapper="">
|
||||||
|
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
<CommandPrimitive.Input
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground/60 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
|
||||||
|
CommandInput.displayName = CommandPrimitive.Input.displayName
|
||||||
|
|
||||||
|
const CommandList = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
|
||||||
|
CommandList.displayName = CommandPrimitive.List.displayName
|
||||||
|
|
||||||
|
const CommandEmpty = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||||
|
>((props, ref) => (
|
||||||
|
<CommandPrimitive.Empty
|
||||||
|
ref={ref}
|
||||||
|
className="py-6 text-center text-sm text-muted-foreground"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
|
||||||
|
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
|
||||||
|
|
||||||
|
const CommandGroup = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Group>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.Group
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
|
||||||
|
CommandGroup.displayName = CommandPrimitive.Group.displayName
|
||||||
|
|
||||||
|
const CommandSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 h-px bg-border/60", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
const CommandItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent/60 data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 transition-colors",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
|
||||||
|
CommandItem.displayName = CommandPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const CommandShortcut = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"ml-auto text-xs tracking-widest text-muted-foreground/70",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
CommandShortcut.displayName = "CommandShortcut"
|
||||||
|
|
||||||
|
export {
|
||||||
|
Command,
|
||||||
|
CommandDialog,
|
||||||
|
CommandInput,
|
||||||
|
CommandList,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandItem,
|
||||||
|
CommandShortcut,
|
||||||
|
CommandSeparator,
|
||||||
|
}
|
||||||
198
src/components/ui/context-menu.tsx
Normal file
198
src/components/ui/context-menu.tsx
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
|
||||||
|
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const ContextMenu = ContextMenuPrimitive.Root
|
||||||
|
|
||||||
|
const ContextMenuTrigger = ContextMenuPrimitive.Trigger
|
||||||
|
|
||||||
|
const ContextMenuGroup = ContextMenuPrimitive.Group
|
||||||
|
|
||||||
|
const ContextMenuPortal = ContextMenuPrimitive.Portal
|
||||||
|
|
||||||
|
const ContextMenuSub = ContextMenuPrimitive.Sub
|
||||||
|
|
||||||
|
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup
|
||||||
|
|
||||||
|
const ContextMenuSubTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, children, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.SubTrigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent/60 focus:text-accent-foreground data-[state=open]:bg-accent/60 data-[state=open]:text-accent-foreground transition-colors",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRight className="ml-auto h-4 w-4" />
|
||||||
|
</ContextMenuPrimitive.SubTrigger>
|
||||||
|
))
|
||||||
|
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
|
||||||
|
|
||||||
|
const ContextMenuSubContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.SubContent
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-border/40 bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 duration-200",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
|
||||||
|
|
||||||
|
const ContextMenuContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.Portal>
|
||||||
|
<ContextMenuPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-border/40 bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 duration-200",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</ContextMenuPrimitive.Portal>
|
||||||
|
))
|
||||||
|
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const ContextMenuItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent/60 focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 transition-colors",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const ContextMenuCheckboxItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
|
||||||
|
>(({ className, children, checked, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.CheckboxItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent/60 focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 transition-colors",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<ContextMenuPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4 text-primary" />
|
||||||
|
</ContextMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</ContextMenuPrimitive.CheckboxItem>
|
||||||
|
))
|
||||||
|
ContextMenuCheckboxItem.displayName =
|
||||||
|
ContextMenuPrimitive.CheckboxItem.displayName
|
||||||
|
|
||||||
|
const ContextMenuRadioItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.RadioItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent/60 focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 transition-colors",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<ContextMenuPrimitive.ItemIndicator>
|
||||||
|
<Circle className="h-2 w-2 fill-current text-primary" />
|
||||||
|
</ContextMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</ContextMenuPrimitive.RadioItem>
|
||||||
|
))
|
||||||
|
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName
|
||||||
|
|
||||||
|
const ContextMenuLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 text-sm font-medium text-foreground",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
|
||||||
|
|
||||||
|
const ContextMenuSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-border/60", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
const ContextMenuShortcut = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"ml-auto text-xs tracking-widest text-muted-foreground/70",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ContextMenuShortcut.displayName = "ContextMenuShortcut"
|
||||||
|
|
||||||
|
export {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuTrigger,
|
||||||
|
ContextMenuContent,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuCheckboxItem,
|
||||||
|
ContextMenuRadioItem,
|
||||||
|
ContextMenuLabel,
|
||||||
|
ContextMenuSeparator,
|
||||||
|
ContextMenuShortcut,
|
||||||
|
ContextMenuGroup,
|
||||||
|
ContextMenuPortal,
|
||||||
|
ContextMenuSub,
|
||||||
|
ContextMenuSubContent,
|
||||||
|
ContextMenuSubTrigger,
|
||||||
|
ContextMenuRadioGroup,
|
||||||
|
}
|
||||||
120
src/components/ui/dialog.tsx
Normal file
120
src/components/ui/dialog.tsx
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||||
|
import { X } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Dialog = DialogPrimitive.Root
|
||||||
|
|
||||||
|
const DialogTrigger = DialogPrimitive.Trigger
|
||||||
|
|
||||||
|
const DialogPortal = DialogPrimitive.Portal
|
||||||
|
|
||||||
|
const DialogClose = DialogPrimitive.Close
|
||||||
|
|
||||||
|
const DialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||||
|
|
||||||
|
const DialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-border/40 bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
))
|
||||||
|
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const DialogHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
DialogHeader.displayName = "DialogHeader"
|
||||||
|
|
||||||
|
const DialogFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
DialogFooter.displayName = "DialogFooter"
|
||||||
|
|
||||||
|
const DialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-lg font-semibold leading-none tracking-tight text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||||
|
|
||||||
|
const DialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogPortal,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogClose,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogFooter,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
}
|
||||||
116
src/components/ui/drawer.tsx
Normal file
116
src/components/ui/drawer.tsx
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Drawer as DrawerPrimitive } from "vaul"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Drawer = ({
|
||||||
|
shouldScaleBackground = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
|
||||||
|
<DrawerPrimitive.Root
|
||||||
|
shouldScaleBackground={shouldScaleBackground}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
Drawer.displayName = "Drawer"
|
||||||
|
|
||||||
|
const DrawerTrigger = DrawerPrimitive.Trigger
|
||||||
|
|
||||||
|
const DrawerPortal = DrawerPrimitive.Portal
|
||||||
|
|
||||||
|
const DrawerClose = DrawerPrimitive.Close
|
||||||
|
|
||||||
|
const DrawerOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DrawerPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DrawerPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn("fixed inset-0 z-50 bg-background/80 backdrop-blur-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
|
||||||
|
|
||||||
|
const DrawerContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DrawerPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DrawerPortal>
|
||||||
|
<DrawerOverlay />
|
||||||
|
<DrawerPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border border-border bg-card shadow-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted/50" />
|
||||||
|
{children}
|
||||||
|
</DrawerPrimitive.Content>
|
||||||
|
</DrawerPortal>
|
||||||
|
))
|
||||||
|
DrawerContent.displayName = "DrawerContent"
|
||||||
|
|
||||||
|
const DrawerHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
DrawerHeader.displayName = "DrawerHeader"
|
||||||
|
|
||||||
|
const DrawerFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
DrawerFooter.displayName = "DrawerFooter"
|
||||||
|
|
||||||
|
const DrawerTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DrawerPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DrawerPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-lg font-semibold leading-none tracking-tight text-primary/90",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DrawerTitle.displayName = DrawerPrimitive.Title.displayName
|
||||||
|
|
||||||
|
const DrawerDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DrawerPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DrawerPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground mt-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DrawerDescription.displayName = DrawerPrimitive.Description.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Drawer,
|
||||||
|
DrawerPortal,
|
||||||
|
DrawerOverlay,
|
||||||
|
DrawerTrigger,
|
||||||
|
DrawerClose,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerFooter,
|
||||||
|
DrawerTitle,
|
||||||
|
DrawerDescription,
|
||||||
|
}
|
||||||
198
src/components/ui/dropdown-menu.tsx
Normal file
198
src/components/ui/dropdown-menu.tsx
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||||
|
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||||
|
|
||||||
|
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||||
|
|
||||||
|
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||||
|
|
||||||
|
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||||
|
|
||||||
|
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||||
|
|
||||||
|
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||||
|
|
||||||
|
const DropdownMenuSubTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent/60 focus:text-accent-foreground data-[state=open]:bg-accent/60 data-[state=open]:text-accent-foreground transition-colors",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRight className="ml-auto h-4 w-4" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
))
|
||||||
|
DropdownMenuSubTrigger.displayName =
|
||||||
|
DropdownMenuPrimitive.SubTrigger.displayName
|
||||||
|
|
||||||
|
const DropdownMenuSubContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-border/40 bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 duration-200",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuSubContent.displayName =
|
||||||
|
DropdownMenuPrimitive.SubContent.displayName
|
||||||
|
|
||||||
|
const DropdownMenuContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-border/40 bg-popover/95 backdrop-blur-sm p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 duration-200",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
))
|
||||||
|
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const DropdownMenuItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent/60 focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
>(({ className, children, checked, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent/60 focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4 text-primary" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
))
|
||||||
|
DropdownMenuCheckboxItem.displayName =
|
||||||
|
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||||
|
|
||||||
|
const DropdownMenuRadioItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent/60 focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Circle className="h-2 w-2 fill-current text-primary" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
))
|
||||||
|
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||||
|
|
||||||
|
const DropdownMenuLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 text-sm font-medium text-foreground/80",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||||
|
|
||||||
|
const DropdownMenuSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-border/60", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
const DropdownMenuShortcut = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn("ml-auto text-xs tracking-widest text-muted-foreground/70", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
}
|
||||||
176
src/components/ui/form.tsx
Normal file
176
src/components/ui/form.tsx
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
ControllerProps,
|
||||||
|
FieldPath,
|
||||||
|
FieldValues,
|
||||||
|
FormProvider,
|
||||||
|
useFormContext,
|
||||||
|
} from "react-hook-form"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
|
||||||
|
const Form = FormProvider
|
||||||
|
|
||||||
|
type FormFieldContextValue<
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||||
|
> = {
|
||||||
|
name: TName
|
||||||
|
}
|
||||||
|
|
||||||
|
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||||
|
{} as FormFieldContextValue
|
||||||
|
)
|
||||||
|
|
||||||
|
const FormField = <
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||||
|
>({
|
||||||
|
...props
|
||||||
|
}: ControllerProps<TFieldValues, TName>) => {
|
||||||
|
return (
|
||||||
|
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||||
|
<Controller {...props} />
|
||||||
|
</FormFieldContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const useFormField = () => {
|
||||||
|
const fieldContext = React.useContext(FormFieldContext)
|
||||||
|
const itemContext = React.useContext(FormItemContext)
|
||||||
|
const { getFieldState, formState } = useFormContext()
|
||||||
|
|
||||||
|
const fieldState = getFieldState(fieldContext.name, formState)
|
||||||
|
|
||||||
|
if (!fieldContext) {
|
||||||
|
throw new Error("useFormField should be used within <FormField>")
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = itemContext
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: fieldContext.name,
|
||||||
|
formItemId: `${id}-form-item`,
|
||||||
|
formDescriptionId: `${id}-form-item-description`,
|
||||||
|
formMessageId: `${id}-form-item-message`,
|
||||||
|
...fieldState,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type FormItemContextValue = {
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||||
|
{} as FormItemContextValue
|
||||||
|
)
|
||||||
|
|
||||||
|
const FormItem = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const id = React.useId()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormItemContext.Provider value={{ id }}>
|
||||||
|
<div ref={ref} className={cn("space-y-2 mb-4", className)} {...props} />
|
||||||
|
</FormItemContext.Provider>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormItem.displayName = "FormItem"
|
||||||
|
|
||||||
|
const FormLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const { error, formItemId } = useFormField()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(error && "text-destructive", "text-sm font-medium mb-1", className)}
|
||||||
|
htmlFor={formItemId}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormLabel.displayName = "FormLabel"
|
||||||
|
|
||||||
|
const FormControl = React.forwardRef<
|
||||||
|
React.ElementRef<typeof Slot>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof Slot>
|
||||||
|
>(({ ...props }, ref) => {
|
||||||
|
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Slot
|
||||||
|
ref={ref}
|
||||||
|
id={formItemId}
|
||||||
|
aria-describedby={
|
||||||
|
!error
|
||||||
|
? `${formDescriptionId}`
|
||||||
|
: `${formDescriptionId} ${formMessageId}`
|
||||||
|
}
|
||||||
|
aria-invalid={!!error}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormControl.displayName = "FormControl"
|
||||||
|
|
||||||
|
const FormDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const { formDescriptionId } = useFormField()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
id={formDescriptionId}
|
||||||
|
className={cn("text-sm text-muted-foreground/80 mt-1", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormDescription.displayName = "FormDescription"
|
||||||
|
|
||||||
|
const FormMessage = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, children, ...props }, ref) => {
|
||||||
|
const { error, formMessageId } = useFormField()
|
||||||
|
const body = error ? String(error?.message) : children
|
||||||
|
|
||||||
|
if (!body) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
id={formMessageId}
|
||||||
|
className={cn("text-sm font-medium text-destructive mt-1", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormMessage.displayName = "FormMessage"
|
||||||
|
|
||||||
|
export {
|
||||||
|
useFormField,
|
||||||
|
Form,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormMessage,
|
||||||
|
FormField,
|
||||||
|
}
|
||||||
27
src/components/ui/hover-card.tsx
Normal file
27
src/components/ui/hover-card.tsx
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const HoverCard = HoverCardPrimitive.Root
|
||||||
|
|
||||||
|
const HoverCardTrigger = HoverCardPrimitive.Trigger
|
||||||
|
|
||||||
|
const HoverCardContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof HoverCardPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
|
||||||
|
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||||
|
<HoverCardPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 w-64 rounded-md border border-border/40 bg-popover/95 backdrop-blur-sm p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 duration-200",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
||||||
69
src/components/ui/input-otp.tsx
Normal file
69
src/components/ui/input-otp.tsx
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { OTPInput, OTPInputContext } from "input-otp"
|
||||||
|
import { Dot } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const InputOTP = React.forwardRef<
|
||||||
|
React.ElementRef<typeof OTPInput>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof OTPInput>
|
||||||
|
>(({ className, containerClassName, ...props }, ref) => (
|
||||||
|
<OTPInput
|
||||||
|
ref={ref}
|
||||||
|
containerClassName={cn(
|
||||||
|
"flex items-center gap-2 has-[:disabled]:opacity-50",
|
||||||
|
containerClassName
|
||||||
|
)}
|
||||||
|
className={cn("disabled:cursor-not-allowed", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
InputOTP.displayName = "InputOTP"
|
||||||
|
|
||||||
|
const InputOTPGroup = React.forwardRef<
|
||||||
|
React.ElementRef<"div">,
|
||||||
|
React.ComponentPropsWithoutRef<"div">
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("flex items-center", className)} {...props} />
|
||||||
|
))
|
||||||
|
InputOTPGroup.displayName = "InputOTPGroup"
|
||||||
|
|
||||||
|
const InputOTPSlot = React.forwardRef<
|
||||||
|
React.ElementRef<"div">,
|
||||||
|
React.ComponentPropsWithoutRef<"div"> & { index: number }
|
||||||
|
>(({ index, className, ...props }, ref) => {
|
||||||
|
const inputOTPContext = React.useContext(OTPInputContext)
|
||||||
|
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex h-10 w-10 items-center justify-center border-y border-r border-input bg-background/50 text-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
|
||||||
|
isActive && "z-10 ring-1 ring-primary ring-offset-background border-primary/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{char}
|
||||||
|
{hasFakeCaret && (
|
||||||
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="h-4 w-px animate-caret-blink bg-primary duration-700" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
InputOTPSlot.displayName = "InputOTPSlot"
|
||||||
|
|
||||||
|
const InputOTPSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<"div">,
|
||||||
|
React.ComponentPropsWithoutRef<"div">
|
||||||
|
>(({ ...props }, ref) => (
|
||||||
|
<div ref={ref} role="separator" {...props} className="text-muted-foreground">
|
||||||
|
<Dot className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
InputOTPSeparator.displayName = "InputOTPSeparator"
|
||||||
|
|
||||||
|
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }
|
||||||
24
src/components/ui/input.tsx
Normal file
24
src/components/ui/input.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
type InputProps = React.InputHTMLAttributes<HTMLInputElement>
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full rounded-md border border-border/60 bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Input.displayName = "Input"
|
||||||
|
|
||||||
|
export { Input }
|
||||||
43
src/components/ui/label.tsx
Normal file
43
src/components/ui/label.tsx
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const labelVariants = cva(
|
||||||
|
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "text-foreground",
|
||||||
|
muted: "text-muted-foreground",
|
||||||
|
accent: "text-primary",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "text-sm",
|
||||||
|
xs: "text-xs",
|
||||||
|
sm: "text-sm",
|
||||||
|
lg: "text-base",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const Label = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||||
|
VariantProps<typeof labelVariants>
|
||||||
|
>(({ className, variant, size, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(labelVariants({ variant, size }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Label }
|
||||||
234
src/components/ui/menubar.tsx
Normal file
234
src/components/ui/menubar.tsx
Normal file
@ -0,0 +1,234 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as MenubarPrimitive from "@radix-ui/react-menubar"
|
||||||
|
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const MenubarMenu = MenubarPrimitive.Menu
|
||||||
|
|
||||||
|
const MenubarGroup = MenubarPrimitive.Group
|
||||||
|
|
||||||
|
const MenubarPortal = MenubarPrimitive.Portal
|
||||||
|
|
||||||
|
const MenubarSub = MenubarPrimitive.Sub
|
||||||
|
|
||||||
|
const MenubarRadioGroup = MenubarPrimitive.RadioGroup
|
||||||
|
|
||||||
|
const Menubar = React.forwardRef<
|
||||||
|
React.ElementRef<typeof MenubarPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<MenubarPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 items-center space-x-1 rounded-md border border-border/50 bg-background/50 p-1 shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Menubar.displayName = MenubarPrimitive.Root.displayName
|
||||||
|
|
||||||
|
const MenubarTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof MenubarPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<MenubarPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default select-none items-center rounded-sm px-3 py-1.5 text-sm font-medium outline-none focus:bg-accent/60 focus:text-accent-foreground data-[state=open]:bg-accent/60 data-[state=open]:text-accent-foreground transition-colors",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const MenubarSubTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof MenubarPrimitive.SubTrigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, children, ...props }, ref) => (
|
||||||
|
<MenubarPrimitive.SubTrigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent/60 focus:text-accent-foreground data-[state=open]:bg-accent/60 data-[state=open]:text-accent-foreground transition-colors",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRight className="ml-auto h-4 w-4" />
|
||||||
|
</MenubarPrimitive.SubTrigger>
|
||||||
|
))
|
||||||
|
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName
|
||||||
|
|
||||||
|
const MenubarSubContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof MenubarPrimitive.SubContent>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<MenubarPrimitive.SubContent
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-border/40 bg-popover/95 backdrop-blur-sm p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 duration-200",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName
|
||||||
|
|
||||||
|
const MenubarContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof MenubarPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content>
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{ className, align = "start", alignOffset = -4, sideOffset = 8, ...props },
|
||||||
|
ref
|
||||||
|
) => (
|
||||||
|
<MenubarPrimitive.Portal>
|
||||||
|
<MenubarPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-[12rem] overflow-hidden rounded-md border border-border/40 bg-popover/95 backdrop-blur-sm p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 duration-200",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</MenubarPrimitive.Portal>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
MenubarContent.displayName = MenubarPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const MenubarItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof MenubarPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<MenubarPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent/60 focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 transition-colors",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
MenubarItem.displayName = MenubarPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const MenubarCheckboxItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof MenubarPrimitive.CheckboxItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem>
|
||||||
|
>(({ className, children, checked, ...props }, ref) => (
|
||||||
|
<MenubarPrimitive.CheckboxItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent/60 focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 transition-colors",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<MenubarPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4 text-primary" />
|
||||||
|
</MenubarPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</MenubarPrimitive.CheckboxItem>
|
||||||
|
))
|
||||||
|
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName
|
||||||
|
|
||||||
|
const MenubarRadioItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof MenubarPrimitive.RadioItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<MenubarPrimitive.RadioItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent/60 focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 transition-colors",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<MenubarPrimitive.ItemIndicator>
|
||||||
|
<Circle className="h-2 w-2 fill-current text-primary" />
|
||||||
|
</MenubarPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</MenubarPrimitive.RadioItem>
|
||||||
|
))
|
||||||
|
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName
|
||||||
|
|
||||||
|
const MenubarLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof MenubarPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<MenubarPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 text-sm font-medium text-foreground/80",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
MenubarLabel.displayName = MenubarPrimitive.Label.displayName
|
||||||
|
|
||||||
|
const MenubarSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof MenubarPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<MenubarPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-border/60", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
const MenubarShortcut = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"ml-auto text-xs tracking-widest text-muted-foreground/70",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
MenubarShortcut.displayname = "MenubarShortcut"
|
||||||
|
|
||||||
|
export {
|
||||||
|
Menubar,
|
||||||
|
MenubarMenu,
|
||||||
|
MenubarTrigger,
|
||||||
|
MenubarContent,
|
||||||
|
MenubarItem,
|
||||||
|
MenubarSeparator,
|
||||||
|
MenubarLabel,
|
||||||
|
MenubarCheckboxItem,
|
||||||
|
MenubarRadioGroup,
|
||||||
|
MenubarRadioItem,
|
||||||
|
MenubarPortal,
|
||||||
|
MenubarSubContent,
|
||||||
|
MenubarSubTrigger,
|
||||||
|
MenubarGroup,
|
||||||
|
MenubarSub,
|
||||||
|
MenubarShortcut,
|
||||||
|
}
|
||||||
128
src/components/ui/navigation-menu.tsx
Normal file
128
src/components/ui/navigation-menu.tsx
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
|
||||||
|
import { cva } from "class-variance-authority"
|
||||||
|
import { ChevronDown } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const NavigationMenu = React.forwardRef<
|
||||||
|
React.ElementRef<typeof NavigationMenuPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<NavigationMenuPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative z-10 flex max-w-max flex-1 items-center justify-center",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<NavigationMenuViewport />
|
||||||
|
</NavigationMenuPrimitive.Root>
|
||||||
|
))
|
||||||
|
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName
|
||||||
|
|
||||||
|
const NavigationMenuList = React.forwardRef<
|
||||||
|
React.ElementRef<typeof NavigationMenuPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<NavigationMenuPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"group flex flex-1 list-none items-center justify-center space-x-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName
|
||||||
|
|
||||||
|
const NavigationMenuItem = NavigationMenuPrimitive.Item
|
||||||
|
|
||||||
|
const navigationMenuTriggerStyle = cva(
|
||||||
|
"group inline-flex h-10 w-max items-center justify-center rounded-md bg-background/50 px-4 py-2 text-sm font-medium transition-all hover:bg-accent/50 hover:text-accent-foreground focus:bg-accent/50 focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/60 data-[state=open]:bg-accent/60"
|
||||||
|
)
|
||||||
|
|
||||||
|
const NavigationMenuTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<NavigationMenuPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(navigationMenuTriggerStyle(), "group", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}{" "}
|
||||||
|
<ChevronDown
|
||||||
|
className="relative top-[1px] ml-1 h-3 w-3 transition duration-300 ease-in-out group-data-[state=open]:rotate-180"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</NavigationMenuPrimitive.Trigger>
|
||||||
|
))
|
||||||
|
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const NavigationMenuContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof NavigationMenuPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<NavigationMenuPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 duration-200 md:absolute md:w-auto",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const NavigationMenuLink = NavigationMenuPrimitive.Link
|
||||||
|
|
||||||
|
const NavigationMenuViewport = React.forwardRef<
|
||||||
|
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div className={cn("absolute left-0 top-full flex justify-center")}>
|
||||||
|
<NavigationMenuPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border border-border/40 bg-popover/95 backdrop-blur-sm text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
NavigationMenuViewport.displayName =
|
||||||
|
NavigationMenuPrimitive.Viewport.displayName
|
||||||
|
|
||||||
|
const NavigationMenuIndicator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<NavigationMenuPrimitive.Indicator
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-primary/20 shadow-md" />
|
||||||
|
</NavigationMenuPrimitive.Indicator>
|
||||||
|
))
|
||||||
|
NavigationMenuIndicator.displayName =
|
||||||
|
NavigationMenuPrimitive.Indicator.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
navigationMenuTriggerStyle,
|
||||||
|
NavigationMenu,
|
||||||
|
NavigationMenuList,
|
||||||
|
NavigationMenuItem,
|
||||||
|
NavigationMenuContent,
|
||||||
|
NavigationMenuTrigger,
|
||||||
|
NavigationMenuLink,
|
||||||
|
NavigationMenuIndicator,
|
||||||
|
NavigationMenuViewport,
|
||||||
|
}
|
||||||
119
src/components/ui/pagination.tsx
Normal file
119
src/components/ui/pagination.tsx
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { ButtonProps, buttonVariants } from "@/components/ui/button"
|
||||||
|
|
||||||
|
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
|
||||||
|
<nav
|
||||||
|
role="navigation"
|
||||||
|
aria-label="pagination"
|
||||||
|
className={cn("mx-auto flex w-full justify-center", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
Pagination.displayName = "Pagination"
|
||||||
|
|
||||||
|
const PaginationContent = React.forwardRef<
|
||||||
|
HTMLUListElement,
|
||||||
|
React.ComponentProps<"ul">
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ul
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex flex-row items-center gap-1", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
PaginationContent.displayName = "PaginationContent"
|
||||||
|
|
||||||
|
const PaginationItem = React.forwardRef<
|
||||||
|
HTMLLIElement,
|
||||||
|
React.ComponentProps<"li">
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<li ref={ref} className={cn("", className)} {...props} />
|
||||||
|
))
|
||||||
|
PaginationItem.displayName = "PaginationItem"
|
||||||
|
|
||||||
|
type PaginationLinkProps = {
|
||||||
|
isActive?: boolean
|
||||||
|
} & Pick<ButtonProps, "size"> &
|
||||||
|
React.ComponentProps<"a">
|
||||||
|
|
||||||
|
const PaginationLink = ({
|
||||||
|
className,
|
||||||
|
isActive,
|
||||||
|
size = "icon",
|
||||||
|
...props
|
||||||
|
}: PaginationLinkProps) => (
|
||||||
|
<a
|
||||||
|
aria-current={isActive ? "page" : undefined}
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({
|
||||||
|
variant: isActive ? "outline" : "ghost",
|
||||||
|
size,
|
||||||
|
}),
|
||||||
|
isActive && "border-primary/50 bg-primary/5 text-primary hover:bg-primary/10",
|
||||||
|
"transition-colors",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
PaginationLink.displayName = "PaginationLink"
|
||||||
|
|
||||||
|
const PaginationPrevious = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PaginationLink>) => (
|
||||||
|
<PaginationLink
|
||||||
|
aria-label="Go to previous page"
|
||||||
|
size="default"
|
||||||
|
className={cn("gap-1 pl-2.5 hover:text-primary", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
<span>Previous</span>
|
||||||
|
</PaginationLink>
|
||||||
|
)
|
||||||
|
PaginationPrevious.displayName = "PaginationPrevious"
|
||||||
|
|
||||||
|
const PaginationNext = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PaginationLink>) => (
|
||||||
|
<PaginationLink
|
||||||
|
aria-label="Go to next page"
|
||||||
|
size="default"
|
||||||
|
className={cn("gap-1 pr-2.5 hover:text-primary", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span>Next</span>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</PaginationLink>
|
||||||
|
)
|
||||||
|
PaginationNext.displayName = "PaginationNext"
|
||||||
|
|
||||||
|
const PaginationEllipsis = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) => (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={cn("flex h-9 w-9 items-center justify-center text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
<span className="sr-only">More pages</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
PaginationEllipsis.displayName = "PaginationEllipsis"
|
||||||
|
|
||||||
|
export {
|
||||||
|
Pagination,
|
||||||
|
PaginationContent,
|
||||||
|
PaginationEllipsis,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationLink,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationPrevious,
|
||||||
|
}
|
||||||
29
src/components/ui/popover.tsx
Normal file
29
src/components/ui/popover.tsx
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Popover = PopoverPrimitive.Root
|
||||||
|
|
||||||
|
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||||
|
|
||||||
|
const PopoverContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||||
|
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 w-72 rounded-md border border-border/40 bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 duration-200",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
))
|
||||||
|
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Popover, PopoverTrigger, PopoverContent }
|
||||||
37
src/components/ui/progress.tsx
Normal file
37
src/components/ui/progress.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
interface ProgressProps extends
|
||||||
|
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> {
|
||||||
|
variant?: "default" | "success" | "warning" | "error"
|
||||||
|
}
|
||||||
|
|
||||||
|
const Progress = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||||
|
ProgressProps
|
||||||
|
>(({ className, value, variant = "default", ...props }, ref) => (
|
||||||
|
<ProgressPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative h-2 w-full overflow-hidden rounded-full bg-secondary/40",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ProgressPrimitive.Indicator
|
||||||
|
className={cn(
|
||||||
|
"h-full w-full flex-1 transition-all duration-300 ease-in-out",
|
||||||
|
variant === "default" && "bg-primary",
|
||||||
|
variant === "success" && "bg-green-500",
|
||||||
|
variant === "warning" && "bg-yellow-500",
|
||||||
|
variant === "error" && "bg-destructive",
|
||||||
|
)}
|
||||||
|
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||||
|
/>
|
||||||
|
</ProgressPrimitive.Root>
|
||||||
|
))
|
||||||
|
Progress.displayName = ProgressPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Progress }
|
||||||
42
src/components/ui/radio-group.tsx
Normal file
42
src/components/ui/radio-group.tsx
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
||||||
|
import { Circle } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const RadioGroup = React.forwardRef<
|
||||||
|
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Root
|
||||||
|
className={cn("grid gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
|
||||||
|
|
||||||
|
const RadioGroupItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"aspect-square h-4 w-4 rounded-full border border-primary/60 text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 transition-all duration-200",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||||
|
<Circle className="h-2.5 w-2.5 fill-current text-current animate-in scale-in-0 duration-200" />
|
||||||
|
</RadioGroupPrimitive.Indicator>
|
||||||
|
</RadioGroupPrimitive.Item>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
|
||||||
|
|
||||||
|
export { RadioGroup, RadioGroupItem }
|
||||||
43
src/components/ui/resizable.tsx
Normal file
43
src/components/ui/resizable.tsx
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { GripVertical } from "lucide-react"
|
||||||
|
import * as ResizablePrimitive from "react-resizable-panels"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const ResizablePanelGroup = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
|
||||||
|
<ResizablePrimitive.PanelGroup
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
const ResizablePanel = ResizablePrimitive.Panel
|
||||||
|
|
||||||
|
const ResizableHandle = ({
|
||||||
|
withHandle,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
||||||
|
withHandle?: boolean
|
||||||
|
}) => (
|
||||||
|
<ResizablePrimitive.PanelResizeHandle
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-px items-center justify-center bg-border/50 after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{withHandle && (
|
||||||
|
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border border-border/50 bg-border/30 hover:bg-border/50 transition-colors">
|
||||||
|
<GripVertical className="h-2.5 w-2.5 text-primary/40" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ResizablePrimitive.PanelResizeHandle>
|
||||||
|
)
|
||||||
|
|
||||||
|
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
|
||||||
48
src/components/ui/scroll-area.tsx
Normal file
48
src/components/ui/scroll-area.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const ScrollArea = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> & {
|
||||||
|
hideScrollbar?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, children, hideScrollbar = false, ...props }, ref) => (
|
||||||
|
<ScrollAreaPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn("relative overflow-hidden", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||||
|
{children}
|
||||||
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
{!hideScrollbar && <ScrollBar />}
|
||||||
|
<ScrollAreaPrimitive.Corner />
|
||||||
|
</ScrollAreaPrimitive.Root>
|
||||||
|
))
|
||||||
|
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
|
||||||
|
|
||||||
|
const ScrollBar = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||||
|
ref={ref}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"flex touch-none select-none transition-colors duration-300",
|
||||||
|
orientation === "vertical" &&
|
||||||
|
"h-full w-2 border-l border-l-transparent p-[1px] hover:w-2.5",
|
||||||
|
orientation === "horizontal" &&
|
||||||
|
"h-2 flex-col border-t border-t-transparent p-[1px] hover:h-2.5",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border/50 hover:bg-border/80 transition-colors" />
|
||||||
|
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
))
|
||||||
|
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
||||||
|
|
||||||
|
export { ScrollArea, ScrollBar }
|
||||||
158
src/components/ui/select.tsx
Normal file
158
src/components/ui/select.tsx
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||||
|
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Select = SelectPrimitive.Root
|
||||||
|
|
||||||
|
const SelectGroup = SelectPrimitive.Group
|
||||||
|
|
||||||
|
const SelectValue = SelectPrimitive.Value
|
||||||
|
|
||||||
|
const SelectTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground/60 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary focus-visible:border-primary/50 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1 transition-colors",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDown className="h-4 w-4 opacity-50 transition-transform duration-200 ease-in-out group-data-[state=open]:rotate-180" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
))
|
||||||
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const SelectScrollUpButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1 text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
))
|
||||||
|
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||||
|
|
||||||
|
const SelectScrollDownButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1 text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
))
|
||||||
|
SelectScrollDownButton.displayName =
|
||||||
|
SelectPrimitive.ScrollDownButton.displayName
|
||||||
|
|
||||||
|
const SelectContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||||
|
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-border/40 bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 duration-200",
|
||||||
|
position === "popper" &&
|
||||||
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
"p-1",
|
||||||
|
position === "popper" &&
|
||||||
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
))
|
||||||
|
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const SelectLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn("py-1.5 pl-8 pr-2 text-sm font-medium text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||||
|
|
||||||
|
const SelectItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent/50 focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 transition-colors",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4 text-primary" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
))
|
||||||
|
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const SelectSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectGroup,
|
||||||
|
SelectValue,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectContent,
|
||||||
|
SelectLabel,
|
||||||
|
SelectItem,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
}
|
||||||
37
src/components/ui/separator.tsx
Normal file
37
src/components/ui/separator.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
interface SeparatorProps extends
|
||||||
|
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root> {
|
||||||
|
variant?: "default" | "muted" | "accent"
|
||||||
|
}
|
||||||
|
|
||||||
|
const Separator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||||
|
SeparatorProps
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{ className, orientation = "horizontal", decorative = true, variant = "default", ...props },
|
||||||
|
ref
|
||||||
|
) => (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0",
|
||||||
|
variant === "default" && "bg-border",
|
||||||
|
variant === "muted" && "bg-muted",
|
||||||
|
variant === "accent" && "bg-primary/30",
|
||||||
|
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Separator }
|
||||||
131
src/components/ui/sheet.tsx
Normal file
131
src/components/ui/sheet.tsx
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { X } from "lucide-react"
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Sheet = SheetPrimitive.Root
|
||||||
|
|
||||||
|
const SheetTrigger = SheetPrimitive.Trigger
|
||||||
|
|
||||||
|
const SheetClose = SheetPrimitive.Close
|
||||||
|
|
||||||
|
const SheetPortal = SheetPrimitive.Portal
|
||||||
|
|
||||||
|
const SheetOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SheetPrimitive.Overlay
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
|
||||||
|
|
||||||
|
const sheetVariants = cva(
|
||||||
|
"fixed z-50 gap-4 bg-card border shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
side: {
|
||||||
|
top: "inset-x-0 top-0 border-b rounded-b-xl data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||||
|
bottom:
|
||||||
|
"inset-x-0 bottom-0 border-t rounded-t-xl data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||||
|
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||||
|
right:
|
||||||
|
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
side: "right",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
interface SheetContentProps
|
||||||
|
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||||
|
VariantProps<typeof sheetVariants> { }
|
||||||
|
|
||||||
|
const SheetContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||||
|
SheetContentProps
|
||||||
|
>(({ side = "right", className, children, ...props }, ref) => (
|
||||||
|
<SheetPortal>
|
||||||
|
<SheetOverlay />
|
||||||
|
<SheetPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(sheetVariants({ side }), className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 hover:text-primary focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 disabled:pointer-events-none">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</SheetPrimitive.Close>
|
||||||
|
</SheetPrimitive.Content>
|
||||||
|
</SheetPortal>
|
||||||
|
))
|
||||||
|
SheetContent.displayName = SheetPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const SheetHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col space-y-2 text-center sm:text-left",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
SheetHeader.displayName = "SheetHeader"
|
||||||
|
|
||||||
|
const SheetFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 mt-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
SheetFooter.displayName = "SheetFooter"
|
||||||
|
|
||||||
|
const SheetTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SheetPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SheetPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-lg font-semibold text-primary/90", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SheetTitle.displayName = SheetPrimitive.Title.displayName
|
||||||
|
|
||||||
|
const SheetDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SheetPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SheetPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground mt-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SheetDescription.displayName = SheetPrimitive.Description.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sheet, SheetClose,
|
||||||
|
SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetOverlay, SheetPortal, SheetTitle, SheetTrigger
|
||||||
|
}
|
||||||
|
|
||||||
738
src/components/ui/sidebar.tsx
Normal file
738
src/components/ui/sidebar.tsx
Normal file
@ -0,0 +1,738 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { VariantProps, cva } from "class-variance-authority"
|
||||||
|
import { PanelLeft } from "lucide-react"
|
||||||
|
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
import { Sheet, SheetContent } from "@/components/ui/sheet"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip"
|
||||||
|
|
||||||
|
const SIDEBAR_COOKIE_NAME = "sidebar:state"
|
||||||
|
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
||||||
|
const SIDEBAR_WIDTH = "16rem"
|
||||||
|
const SIDEBAR_WIDTH_MOBILE = "18rem"
|
||||||
|
const SIDEBAR_WIDTH_ICON = "3rem"
|
||||||
|
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
||||||
|
|
||||||
|
type SidebarContext = {
|
||||||
|
state: "expanded" | "collapsed"
|
||||||
|
open: boolean
|
||||||
|
setOpen: (open: boolean) => void
|
||||||
|
openMobile: boolean
|
||||||
|
setOpenMobile: (open: boolean) => void
|
||||||
|
isMobile: boolean
|
||||||
|
toggleSidebar: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const SidebarContext = React.createContext<SidebarContext | null>(null)
|
||||||
|
|
||||||
|
function useSidebar() {
|
||||||
|
const context = React.useContext(SidebarContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useSidebar must be used within a SidebarProvider.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
const SidebarProvider = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.ComponentProps<"div"> & {
|
||||||
|
defaultOpen?: boolean
|
||||||
|
open?: boolean
|
||||||
|
onOpenChange?: (open: boolean) => void
|
||||||
|
}
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
defaultOpen = true,
|
||||||
|
open: openProp,
|
||||||
|
onOpenChange: setOpenProp,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
const [openMobile, setOpenMobile] = React.useState(false)
|
||||||
|
|
||||||
|
// This is the internal state of the sidebar.
|
||||||
|
// We use openProp and setOpenProp for control from outside the component.
|
||||||
|
const [_open, _setOpen] = React.useState(defaultOpen)
|
||||||
|
const open = openProp ?? _open
|
||||||
|
const setOpen = React.useCallback(
|
||||||
|
(value: boolean | ((value: boolean) => boolean)) => {
|
||||||
|
const openState = typeof value === "function" ? value(open) : value
|
||||||
|
if (setOpenProp) {
|
||||||
|
setOpenProp(openState)
|
||||||
|
} else {
|
||||||
|
_setOpen(openState)
|
||||||
|
}
|
||||||
|
|
||||||
|
// This sets the cookie to keep the sidebar state.
|
||||||
|
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
||||||
|
},
|
||||||
|
[setOpenProp, open]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Helper to toggle the sidebar.
|
||||||
|
const toggleSidebar = React.useCallback(() => {
|
||||||
|
return isMobile
|
||||||
|
? setOpenMobile((open) => !open)
|
||||||
|
: setOpen((open) => !open)
|
||||||
|
}, [isMobile, setOpen, setOpenMobile])
|
||||||
|
|
||||||
|
// Adds a keyboard shortcut to toggle the sidebar.
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (
|
||||||
|
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||||
|
(event.metaKey || event.ctrlKey)
|
||||||
|
) {
|
||||||
|
event.preventDefault()
|
||||||
|
toggleSidebar()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown)
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||||
|
}, [toggleSidebar])
|
||||||
|
|
||||||
|
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||||
|
// This makes it easier to style the sidebar with Tailwind classes.
|
||||||
|
const state = open ? "expanded" : "collapsed"
|
||||||
|
|
||||||
|
const contextValue = React.useMemo<SidebarContext>(
|
||||||
|
() => ({
|
||||||
|
state,
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
isMobile,
|
||||||
|
openMobile,
|
||||||
|
setOpenMobile,
|
||||||
|
toggleSidebar,
|
||||||
|
}),
|
||||||
|
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarContext.Provider value={contextValue}>
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<div
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--sidebar-width": SIDEBAR_WIDTH,
|
||||||
|
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
||||||
|
...style,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
</SidebarContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
SidebarProvider.displayName = "SidebarProvider"
|
||||||
|
|
||||||
|
const sidebarVariants = cva(
|
||||||
|
"h-full bg-background/80 backdrop-blur-sm border-r border-border/40 shadow-sm",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
sm: "w-16",
|
||||||
|
md: "w-64",
|
||||||
|
lg: "w-80",
|
||||||
|
},
|
||||||
|
collapsible: {
|
||||||
|
true: "transition-all duration-300 ease-in-out",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
size: "md",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
interface SidebarProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof sidebarVariants> {
|
||||||
|
collapsed?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const Sidebar = React.forwardRef<HTMLDivElement, SidebarProps>(
|
||||||
|
(
|
||||||
|
{ className, size, collapsible, collapsed = false, children, ...props },
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const actualSize = collapsed ? "sm" : size
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(sidebarVariants({ size: actualSize, collapsible }), className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Sidebar.displayName = "Sidebar"
|
||||||
|
|
||||||
|
const SidebarHeader = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("p-4 border-b border-border/40", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SidebarHeader.displayName = "SidebarHeader"
|
||||||
|
|
||||||
|
const SidebarFooter = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("p-4 border-t border-border/40 mt-auto", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SidebarFooter.displayName = "SidebarFooter"
|
||||||
|
|
||||||
|
const SidebarContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("flex flex-col flex-1 p-2", className)} {...props} />
|
||||||
|
))
|
||||||
|
SidebarContent.displayName = "SidebarContent"
|
||||||
|
|
||||||
|
const SidebarNav = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<nav
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex flex-col gap-1", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SidebarNav.displayName = "SidebarNav"
|
||||||
|
|
||||||
|
const SidebarNavItem = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement> & { active?: boolean }
|
||||||
|
>(({ className, active, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center px-3 py-2 rounded-md text-sm text-foreground/80 hover:text-foreground hover:bg-accent/50 transition-colors cursor-pointer",
|
||||||
|
active && "bg-accent/60 text-primary font-medium",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SidebarNavItem.displayName = "SidebarNavItem"
|
||||||
|
|
||||||
|
const SidebarSection = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("mb-2", className)} {...props} />
|
||||||
|
))
|
||||||
|
SidebarSection.displayName = "SidebarSection"
|
||||||
|
|
||||||
|
const SidebarSectionTitle = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-xs uppercase font-medium text-muted-foreground/70 tracking-wider px-3 py-1", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SidebarSectionTitle.displayName = "SidebarSectionTitle"
|
||||||
|
|
||||||
|
const SidebarTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof Button>,
|
||||||
|
React.ComponentProps<typeof Button>
|
||||||
|
>(({ className, onClick, ...props }, ref) => {
|
||||||
|
const { toggleSidebar } = useSidebar()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="trigger"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={cn("h-7 w-7", className)}
|
||||||
|
onClick={(event) => {
|
||||||
|
onClick?.(event)
|
||||||
|
toggleSidebar()
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<PanelLeft />
|
||||||
|
<span className="sr-only">Toggle Sidebar</span>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
SidebarTrigger.displayName = "SidebarTrigger"
|
||||||
|
|
||||||
|
const SidebarRail = React.forwardRef<
|
||||||
|
HTMLButtonElement,
|
||||||
|
React.ComponentProps<"button">
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const { toggleSidebar } = useSidebar()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="rail"
|
||||||
|
aria-label="Toggle Sidebar"
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={toggleSidebar}
|
||||||
|
title="Toggle Sidebar"
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
|
||||||
|
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
|
||||||
|
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||||
|
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar",
|
||||||
|
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||||
|
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
SidebarRail.displayName = "SidebarRail"
|
||||||
|
|
||||||
|
const SidebarInset = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.ComponentProps<"main">
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<main
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex min-h-svh flex-1 flex-col bg-background",
|
||||||
|
"peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
SidebarInset.displayName = "SidebarInset"
|
||||||
|
|
||||||
|
const SidebarInput = React.forwardRef<
|
||||||
|
React.ElementRef<typeof Input>,
|
||||||
|
React.ComponentProps<typeof Input>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="input"
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
SidebarInput.displayName = "SidebarInput"
|
||||||
|
|
||||||
|
const SidebarSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof Separator>,
|
||||||
|
React.ComponentProps<typeof Separator>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<Separator
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="separator"
|
||||||
|
className={cn("mx-2 w-auto bg-sidebar-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
SidebarSeparator.displayName = "SidebarSeparator"
|
||||||
|
|
||||||
|
const SidebarGroup = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.ComponentProps<"div">
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="group"
|
||||||
|
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
SidebarGroup.displayName = "SidebarGroup"
|
||||||
|
|
||||||
|
const SidebarGroupLabel = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.ComponentProps<"div"> & { asChild?: boolean }
|
||||||
|
>(({ className, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "div"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="group-label"
|
||||||
|
className={cn(
|
||||||
|
"duration-200 flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opa] ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
|
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
SidebarGroupLabel.displayName = "SidebarGroupLabel"
|
||||||
|
|
||||||
|
const SidebarGroupAction = React.forwardRef<
|
||||||
|
HTMLButtonElement,
|
||||||
|
React.ComponentProps<"button"> & { asChild?: boolean }
|
||||||
|
>(({ className, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="group-action"
|
||||||
|
className={cn(
|
||||||
|
"absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
|
// Increases the hit area of the button on mobile.
|
||||||
|
"after:absolute after:-inset-2 after:md:hidden",
|
||||||
|
"group-data-[collapsible=icon]:hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
SidebarGroupAction.displayName = "SidebarGroupAction"
|
||||||
|
|
||||||
|
const SidebarGroupContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.ComponentProps<"div">
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="group-content"
|
||||||
|
className={cn("w-full text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SidebarGroupContent.displayName = "SidebarGroupContent"
|
||||||
|
|
||||||
|
const SidebarMenu = React.forwardRef<
|
||||||
|
HTMLUListElement,
|
||||||
|
React.ComponentProps<"ul">
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ul
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="menu"
|
||||||
|
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SidebarMenu.displayName = "SidebarMenu"
|
||||||
|
|
||||||
|
const SidebarMenuItem = React.forwardRef<
|
||||||
|
HTMLLIElement,
|
||||||
|
React.ComponentProps<"li">
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<li
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="menu-item"
|
||||||
|
className={cn("group/menu-item relative", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SidebarMenuItem.displayName = "SidebarMenuItem"
|
||||||
|
|
||||||
|
const sidebarMenuButtonVariants = cva(
|
||||||
|
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||||
|
outline:
|
||||||
|
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-8 text-sm",
|
||||||
|
sm: "h-7 text-xs",
|
||||||
|
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const SidebarMenuButton = React.forwardRef<
|
||||||
|
HTMLButtonElement,
|
||||||
|
React.ComponentProps<"button"> & {
|
||||||
|
asChild?: boolean
|
||||||
|
isActive?: boolean
|
||||||
|
tooltip?: string | React.ComponentProps<typeof TooltipContent>
|
||||||
|
} & VariantProps<typeof sidebarMenuButtonVariants>
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
asChild = false,
|
||||||
|
isActive = false,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
tooltip,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
const { isMobile, state } = useSidebar()
|
||||||
|
|
||||||
|
const button = (
|
||||||
|
<Comp
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="menu-button"
|
||||||
|
data-size={size}
|
||||||
|
data-active={isActive}
|
||||||
|
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!tooltip) {
|
||||||
|
return button
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof tooltip === "string") {
|
||||||
|
tooltip = {
|
||||||
|
children: tooltip,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="right"
|
||||||
|
align="center"
|
||||||
|
hidden={state !== "collapsed" || isMobile}
|
||||||
|
{...tooltip}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
SidebarMenuButton.displayName = "SidebarMenuButton"
|
||||||
|
|
||||||
|
const SidebarMenuAction = React.forwardRef<
|
||||||
|
HTMLButtonElement,
|
||||||
|
React.ComponentProps<"button"> & {
|
||||||
|
asChild?: boolean
|
||||||
|
showOnHover?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="menu-action"
|
||||||
|
className={cn(
|
||||||
|
"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
|
// Increases the hit area of the button on mobile.
|
||||||
|
"after:absolute after:-inset-2 after:md:hidden",
|
||||||
|
"peer-data-[size=sm]/menu-button:top-1",
|
||||||
|
"peer-data-[size=default]/menu-button:top-1.5",
|
||||||
|
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||||
|
"group-data-[collapsible=icon]:hidden",
|
||||||
|
showOnHover &&
|
||||||
|
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
SidebarMenuAction.displayName = "SidebarMenuAction"
|
||||||
|
|
||||||
|
const SidebarMenuBadge = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.ComponentProps<"div">
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="menu-badge"
|
||||||
|
className={cn(
|
||||||
|
"absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground select-none pointer-events-none",
|
||||||
|
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
|
||||||
|
"peer-data-[size=sm]/menu-button:top-1",
|
||||||
|
"peer-data-[size=default]/menu-button:top-1.5",
|
||||||
|
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||||
|
"group-data-[collapsible=icon]:hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SidebarMenuBadge.displayName = "SidebarMenuBadge"
|
||||||
|
|
||||||
|
const SidebarMenuSkeleton = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.ComponentProps<"div"> & {
|
||||||
|
showIcon?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, showIcon = false, ...props }, ref) => {
|
||||||
|
// Random width between 50 to 90%.
|
||||||
|
const width = React.useMemo(() => {
|
||||||
|
return `${Math.floor(Math.random() * 40) + 50}%`
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="menu-skeleton"
|
||||||
|
className={cn("rounded-md h-8 flex gap-2 px-2 items-center", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{showIcon && (
|
||||||
|
<Skeleton
|
||||||
|
className="size-4 rounded-md"
|
||||||
|
data-sidebar="menu-skeleton-icon"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Skeleton
|
||||||
|
className="h-4 flex-1 max-w-[--skeleton-width]"
|
||||||
|
data-sidebar="menu-skeleton-text"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--skeleton-width": width,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton"
|
||||||
|
|
||||||
|
const SidebarMenuSub = React.forwardRef<
|
||||||
|
HTMLUListElement,
|
||||||
|
React.ComponentProps<"ul">
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ul
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="menu-sub"
|
||||||
|
className={cn(
|
||||||
|
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5",
|
||||||
|
"group-data-[collapsible=icon]:hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SidebarMenuSub.displayName = "SidebarMenuSub"
|
||||||
|
|
||||||
|
const SidebarMenuSubItem = React.forwardRef<
|
||||||
|
HTMLLIElement,
|
||||||
|
React.ComponentProps<"li">
|
||||||
|
>(({ ...props }, ref) => <li ref={ref} {...props} />)
|
||||||
|
SidebarMenuSubItem.displayName = "SidebarMenuSubItem"
|
||||||
|
|
||||||
|
const SidebarMenuSubButton = React.forwardRef<
|
||||||
|
HTMLAnchorElement,
|
||||||
|
React.ComponentProps<"a"> & {
|
||||||
|
asChild?: boolean
|
||||||
|
size?: "sm" | "md"
|
||||||
|
isActive?: boolean
|
||||||
|
}
|
||||||
|
>(({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "a"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="menu-sub-button"
|
||||||
|
data-size={size}
|
||||||
|
data-active={isActive}
|
||||||
|
className={cn(
|
||||||
|
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
|
||||||
|
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||||
|
size === "sm" && "text-xs",
|
||||||
|
size === "md" && "text-sm",
|
||||||
|
"group-data-[collapsible=icon]:hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
SidebarMenuSubButton.displayName = "SidebarMenuSubButton"
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sidebar,
|
||||||
|
SidebarContent,
|
||||||
|
SidebarFooter,
|
||||||
|
SidebarGroup,
|
||||||
|
SidebarGroupAction,
|
||||||
|
SidebarGroupContent,
|
||||||
|
SidebarGroupLabel,
|
||||||
|
SidebarHeader,
|
||||||
|
SidebarInput,
|
||||||
|
SidebarInset,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuAction,
|
||||||
|
SidebarMenuBadge,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
SidebarMenuSkeleton,
|
||||||
|
SidebarMenuSub,
|
||||||
|
SidebarMenuSubButton,
|
||||||
|
SidebarMenuSubItem,
|
||||||
|
SidebarProvider,
|
||||||
|
SidebarRail,
|
||||||
|
SidebarSeparator,
|
||||||
|
SidebarTrigger,
|
||||||
|
useSidebar,
|
||||||
|
SidebarNav,
|
||||||
|
SidebarNavItem,
|
||||||
|
SidebarSection,
|
||||||
|
SidebarSectionTitle
|
||||||
|
}
|
||||||
25
src/components/ui/skeleton.tsx
Normal file
25
src/components/ui/skeleton.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
interface SkeletonProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
animated?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function Skeleton({
|
||||||
|
className,
|
||||||
|
animated = true,
|
||||||
|
...props
|
||||||
|
}: SkeletonProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"rounded-md bg-muted/70",
|
||||||
|
animated && "animate-pulse",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Skeleton }
|
||||||
26
src/components/ui/slider.tsx
Normal file
26
src/components/ui/slider.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as SliderPrimitive from "@radix-ui/react-slider"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Slider = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SliderPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full touch-none select-none items-center",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-secondary/50">
|
||||||
|
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
||||||
|
</SliderPrimitive.Track>
|
||||||
|
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow-sm ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 hover:border-primary hover:scale-110" />
|
||||||
|
</SliderPrimitive.Root>
|
||||||
|
))
|
||||||
|
Slider.displayName = SliderPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Slider }
|
||||||
31
src/components/ui/sonner.tsx
Normal file
31
src/components/ui/sonner.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
|
||||||
|
import React from "react"
|
||||||
|
import { Toaster as Sonner, toast } from "sonner"
|
||||||
|
import { useTheme } from "@/components/theme-provider"
|
||||||
|
|
||||||
|
type ToasterProps = React.ComponentProps<typeof Sonner>
|
||||||
|
|
||||||
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
|
const { theme } = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sonner
|
||||||
|
theme={theme as ToasterProps["theme"]}
|
||||||
|
className="toaster group"
|
||||||
|
toastOptions={{
|
||||||
|
classNames: {
|
||||||
|
toast:
|
||||||
|
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||||
|
description: "group-[.toast]:text-muted-foreground",
|
||||||
|
actionButton:
|
||||||
|
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
||||||
|
cancelButton:
|
||||||
|
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Toaster, toast }
|
||||||
27
src/components/ui/switch.tsx
Normal file
27
src/components/ui/switch.tsx
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Switch = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SwitchPrimitives.Root
|
||||||
|
className={cn(
|
||||||
|
"peer inline-flex h-5 w-10 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<SwitchPrimitives.Thumb
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-md ring-0 transition-transform duration-200 ease-in-out data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0.5 data-[state=checked]:bg-white"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SwitchPrimitives.Root>
|
||||||
|
))
|
||||||
|
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||||
|
|
||||||
|
export { Switch }
|
||||||
117
src/components/ui/table.tsx
Normal file
117
src/components/ui/table.tsx
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Table = React.forwardRef<
|
||||||
|
HTMLTableElement,
|
||||||
|
React.HTMLAttributes<HTMLTableElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div className="relative w-full overflow-auto">
|
||||||
|
<table
|
||||||
|
ref={ref}
|
||||||
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
Table.displayName = "Table"
|
||||||
|
|
||||||
|
const TableHeader = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||||
|
))
|
||||||
|
TableHeader.displayName = "TableHeader"
|
||||||
|
|
||||||
|
const TableBody = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tbody
|
||||||
|
ref={ref}
|
||||||
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableBody.displayName = "TableBody"
|
||||||
|
|
||||||
|
const TableFooter = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tfoot
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableFooter.displayName = "TableFooter"
|
||||||
|
|
||||||
|
const TableRow = React.forwardRef<
|
||||||
|
HTMLTableRowElement,
|
||||||
|
React.HTMLAttributes<HTMLTableRowElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tr
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableRow.displayName = "TableRow"
|
||||||
|
|
||||||
|
const TableHead = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<th
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableHead.displayName = "TableHead"
|
||||||
|
|
||||||
|
const TableCell = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<td
|
||||||
|
ref={ref}
|
||||||
|
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableCell.displayName = "TableCell"
|
||||||
|
|
||||||
|
const TableCaption = React.forwardRef<
|
||||||
|
HTMLTableCaptionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<caption
|
||||||
|
ref={ref}
|
||||||
|
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableCaption.displayName = "TableCaption"
|
||||||
|
|
||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableCaption,
|
||||||
|
}
|
||||||
53
src/components/ui/tabs.tsx
Normal file
53
src/components/ui/tabs.tsx
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Tabs = TabsPrimitive.Root
|
||||||
|
|
||||||
|
const TabsList = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-10 items-center justify-center rounded-md bg-muted/50 p-1 text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsList.displayName = TabsPrimitive.List.displayName
|
||||||
|
|
||||||
|
const TabsTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-primary data-[state=active]:shadow-sm hover:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const TabsContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 animate-in fade-in-0 data-[state=inactive]:animate-out data-[state=inactive]:fade-out-0 duration-200",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||||
22
src/components/ui/textarea.tsx
Normal file
22
src/components/ui/textarea.tsx
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Textarea = React.forwardRef<
|
||||||
|
HTMLTextAreaElement,
|
||||||
|
React.TextareaHTMLAttributes<HTMLTextAreaElement>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground/60 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary focus-visible:border-primary/50 disabled:cursor-not-allowed disabled:opacity-50 transition-colors resize-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
Textarea.displayName = "Textarea"
|
||||||
|
|
||||||
|
export { Textarea }
|
||||||
127
src/components/ui/toast.tsx
Normal file
127
src/components/ui/toast.tsx
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as ToastPrimitives from "@radix-ui/react-toast"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { X } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const ToastProvider = ToastPrimitives.Provider
|
||||||
|
|
||||||
|
const ToastViewport = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Viewport
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||||
|
|
||||||
|
const toastVariants = cva(
|
||||||
|
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "border bg-background text-foreground",
|
||||||
|
destructive:
|
||||||
|
"destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const Toast = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||||
|
VariantProps<typeof toastVariants>
|
||||||
|
>(({ className, variant, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<ToastPrimitives.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(toastVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
Toast.displayName = ToastPrimitives.Root.displayName
|
||||||
|
|
||||||
|
const ToastAction = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Action
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ToastAction.displayName = ToastPrimitives.Action.displayName
|
||||||
|
|
||||||
|
const ToastClose = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Close
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
toast-close=""
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</ToastPrimitives.Close>
|
||||||
|
))
|
||||||
|
ToastClose.displayName = ToastPrimitives.Close.displayName
|
||||||
|
|
||||||
|
const ToastTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
||||||
|
|
||||||
|
const ToastDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm opacity-90", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
||||||
|
|
||||||
|
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
||||||
|
|
||||||
|
type ToastActionElement = React.ReactElement<typeof ToastAction>
|
||||||
|
|
||||||
|
export {
|
||||||
|
type ToastProps,
|
||||||
|
type ToastActionElement,
|
||||||
|
ToastProvider,
|
||||||
|
ToastViewport,
|
||||||
|
Toast,
|
||||||
|
ToastTitle,
|
||||||
|
ToastDescription,
|
||||||
|
ToastClose,
|
||||||
|
ToastAction,
|
||||||
|
}
|
||||||
33
src/components/ui/toaster.tsx
Normal file
33
src/components/ui/toaster.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { useToast } from "@/hooks/use-toast"
|
||||||
|
import {
|
||||||
|
Toast,
|
||||||
|
ToastClose,
|
||||||
|
ToastDescription,
|
||||||
|
ToastProvider,
|
||||||
|
ToastTitle,
|
||||||
|
ToastViewport,
|
||||||
|
} from "@/components/ui/toast"
|
||||||
|
|
||||||
|
export function Toaster() {
|
||||||
|
const { toasts } = useToast()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastProvider>
|
||||||
|
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||||
|
return (
|
||||||
|
<Toast key={id} {...props}>
|
||||||
|
<div className="grid gap-1">
|
||||||
|
{title && <ToastTitle>{title}</ToastTitle>}
|
||||||
|
{description && (
|
||||||
|
<ToastDescription>{description}</ToastDescription>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{action}
|
||||||
|
<ToastClose />
|
||||||
|
</Toast>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<ToastViewport />
|
||||||
|
</ToastProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
67
src/components/ui/toggle-group.tsx
Normal file
67
src/components/ui/toggle-group.tsx
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
|
||||||
|
import { type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { toggleVariants } from "@/components/ui/toggle"
|
||||||
|
|
||||||
|
const ToggleGroupContext = React.createContext<
|
||||||
|
VariantProps<typeof toggleVariants>
|
||||||
|
>({
|
||||||
|
size: "default",
|
||||||
|
variant: "default",
|
||||||
|
})
|
||||||
|
|
||||||
|
const ToggleGroup = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
|
||||||
|
VariantProps<typeof toggleVariants> & {
|
||||||
|
orientation?: "horizontal" | "vertical"
|
||||||
|
}
|
||||||
|
>(({ className, variant, size, orientation = "horizontal", children, ...props }, ref) => (
|
||||||
|
<ToggleGroupPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1",
|
||||||
|
orientation === "vertical" ? "flex-col" : "flex-row",
|
||||||
|
variant === "outline" && "bg-background rounded-md border border-input p-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ToggleGroupContext.Provider value={{ variant, size }}>
|
||||||
|
{children}
|
||||||
|
</ToggleGroupContext.Provider>
|
||||||
|
</ToggleGroupPrimitive.Root>
|
||||||
|
))
|
||||||
|
|
||||||
|
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName
|
||||||
|
|
||||||
|
const ToggleGroupItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
|
||||||
|
VariantProps<typeof toggleVariants>
|
||||||
|
>(({ className, children, variant, size, ...props }, ref) => {
|
||||||
|
const context = React.useContext(ToggleGroupContext)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToggleGroupPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
toggleVariants({
|
||||||
|
variant: context.variant || variant,
|
||||||
|
size: context.size || size,
|
||||||
|
}),
|
||||||
|
context.variant === "outline" && "data-[state=on]:bg-background data-[state=on]:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ToggleGroupPrimitive.Item>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName
|
||||||
|
|
||||||
|
export { ToggleGroup, ToggleGroupItem }
|
||||||
45
src/components/ui/toggle.tsx
Normal file
45
src/components/ui/toggle.tsx
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as TogglePrimitive from "@radix-ui/react-toggle"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const toggleVariants = cva(
|
||||||
|
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent/60 data-[state=on]:text-accent-foreground",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-transparent hover:bg-muted/60 hover:text-foreground",
|
||||||
|
outline:
|
||||||
|
"border border-input bg-transparent hover:bg-accent/20 hover:text-accent-foreground data-[state=on]:border-accent",
|
||||||
|
soft:
|
||||||
|
"bg-transparent hover:bg-primary/10 data-[state=on]:bg-primary/20 data-[state=on]:text-primary",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-10 px-3",
|
||||||
|
sm: "h-8 px-2.5 text-xs",
|
||||||
|
lg: "h-11 px-5",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const Toggle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TogglePrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
|
||||||
|
VariantProps<typeof toggleVariants>
|
||||||
|
>(({ className, variant, size, ...props }, ref) => (
|
||||||
|
<TogglePrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(toggleVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
|
||||||
|
Toggle.displayName = TogglePrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Toggle, toggleVariants }
|
||||||
28
src/components/ui/tooltip.tsx
Normal file
28
src/components/ui/tooltip.tsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const TooltipProvider = TooltipPrimitive.Provider
|
||||||
|
|
||||||
|
const Tooltip = TooltipPrimitive.Root
|
||||||
|
|
||||||
|
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||||
|
|
||||||
|
const TooltipContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 overflow-hidden rounded-md border border-border/40 bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-lg animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||||
3
src/components/ui/use-toast.ts
Normal file
3
src/components/ui/use-toast.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import { useToast, toast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
|
export { useToast, toast };
|
||||||
36
src/contexts/AppContext.tsx
Normal file
36
src/contexts/AppContext.tsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import { toast } from '@/components/ui/use-toast';
|
||||||
|
|
||||||
|
interface AppContextType {
|
||||||
|
sidebarOpen: boolean;
|
||||||
|
toggleSidebar: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultAppContext: AppContextType = {
|
||||||
|
sidebarOpen: false,
|
||||||
|
toggleSidebar: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const AppContext = createContext<AppContextType>(defaultAppContext);
|
||||||
|
|
||||||
|
export const useAppContext = () => useContext(AppContext);
|
||||||
|
|
||||||
|
export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
|
||||||
|
const toggleSidebar = () => {
|
||||||
|
setSidebarOpen(prev => !prev);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppContext.Provider
|
||||||
|
value={{
|
||||||
|
sidebarOpen,
|
||||||
|
toggleSidebar,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AppContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
188
src/contexts/AuthContext.tsx
Normal file
188
src/contexts/AuthContext.tsx
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||||
|
import { supabase } from '@/lib/supabase';
|
||||||
|
import { StaffProfile, UserRole } from '@/lib/types';
|
||||||
|
import type { User as SupabaseUser, Session } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
interface AuthContextType {
|
||||||
|
user: SupabaseUser | null;
|
||||||
|
session: Session | null;
|
||||||
|
profile: StaffProfile | null;
|
||||||
|
loading: boolean;
|
||||||
|
signIn: (email: string, password: string) => Promise<{ error: string | null }>;
|
||||||
|
signUp: (email: string, password: string, fullName: string, role: UserRole, campus: string) => Promise<{ error: string | null }>;
|
||||||
|
signOut: () => Promise<void>;
|
||||||
|
updateProfile: (updates: Partial<StaffProfile>) => Promise<{ error: string | null }>;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType>({
|
||||||
|
user: null,
|
||||||
|
session: null,
|
||||||
|
profile: null,
|
||||||
|
loading: true,
|
||||||
|
signIn: async () => ({ error: null }),
|
||||||
|
signUp: async () => ({ error: null }),
|
||||||
|
signOut: async () => {},
|
||||||
|
updateProfile: async () => ({ error: null }),
|
||||||
|
isAuthenticated: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useAuth = () => useContext(AuthContext);
|
||||||
|
|
||||||
|
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const [user, setUser] = useState<SupabaseUser | null>(null);
|
||||||
|
const [session, setSession] = useState<Session | null>(null);
|
||||||
|
const [profile, setProfile] = useState<StaffProfile | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const fetchProfile = useCallback(async (userId: string) => {
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('staff_profiles')
|
||||||
|
.select('*')
|
||||||
|
.eq('id', userId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('Error fetching profile:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return data as StaffProfile;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching profile:', err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Initialize auth state
|
||||||
|
useEffect(() => {
|
||||||
|
// Get initial session
|
||||||
|
supabase.auth.getSession().then(({ data: { session: currentSession } }) => {
|
||||||
|
setSession(currentSession);
|
||||||
|
setUser(currentSession?.user ?? null);
|
||||||
|
|
||||||
|
if (currentSession?.user) {
|
||||||
|
fetchProfile(currentSession.user.id).then((p) => {
|
||||||
|
setProfile(p);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for auth changes
|
||||||
|
const { data: { subscription } } = supabase.auth.onAuthStateChange(
|
||||||
|
async (event, newSession) => {
|
||||||
|
setSession(newSession);
|
||||||
|
setUser(newSession?.user ?? null);
|
||||||
|
|
||||||
|
if (newSession?.user) {
|
||||||
|
// Small delay to allow trigger to create profile on signup
|
||||||
|
if (event === 'SIGNED_IN') {
|
||||||
|
setTimeout(async () => {
|
||||||
|
const p = await fetchProfile(newSession.user.id);
|
||||||
|
setProfile(p);
|
||||||
|
setLoading(false);
|
||||||
|
}, 500);
|
||||||
|
} else {
|
||||||
|
const p = await fetchProfile(newSession.user.id);
|
||||||
|
setProfile(p);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setProfile(null);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
subscription.unsubscribe();
|
||||||
|
};
|
||||||
|
}, [fetchProfile]);
|
||||||
|
|
||||||
|
const signIn = async (email: string, password: string): Promise<{ error: string | null }> => {
|
||||||
|
try {
|
||||||
|
const { error } = await supabase.auth.signInWithPassword({ email, password });
|
||||||
|
if (error) return { error: error.message };
|
||||||
|
return { error: null };
|
||||||
|
} catch (err: any) {
|
||||||
|
return { error: err.message || 'An unexpected error occurred' };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const signUp = async (
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
fullName: string,
|
||||||
|
role: UserRole,
|
||||||
|
campus: string
|
||||||
|
): Promise<{ error: string | null }> => {
|
||||||
|
try {
|
||||||
|
const { error } = await supabase.auth.signUp({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
options: {
|
||||||
|
data: {
|
||||||
|
full_name: fullName,
|
||||||
|
role: role,
|
||||||
|
campus: campus,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (error) return { error: error.message };
|
||||||
|
return { error: null };
|
||||||
|
} catch (err: any) {
|
||||||
|
return { error: err.message || 'An unexpected error occurred' };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const signOut = async () => {
|
||||||
|
await supabase.auth.signOut();
|
||||||
|
setUser(null);
|
||||||
|
setSession(null);
|
||||||
|
setProfile(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateProfile = async (updates: Partial<StaffProfile>): Promise<{ error: string | null }> => {
|
||||||
|
if (!user) return { error: 'Not authenticated' };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('staff_profiles')
|
||||||
|
.update({
|
||||||
|
...updates,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.eq('id', user.id);
|
||||||
|
|
||||||
|
if (error) return { error: error.message };
|
||||||
|
|
||||||
|
// Refresh profile
|
||||||
|
const p = await fetchProfile(user.id);
|
||||||
|
setProfile(p);
|
||||||
|
return { error: null };
|
||||||
|
} catch (err: any) {
|
||||||
|
return { error: err.message || 'An unexpected error occurred' };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider
|
||||||
|
value={{
|
||||||
|
user,
|
||||||
|
session,
|
||||||
|
profile,
|
||||||
|
loading,
|
||||||
|
signIn,
|
||||||
|
signUp,
|
||||||
|
signOut,
|
||||||
|
updateProfile,
|
||||||
|
isAuthenticated: !!user && !!profile,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
21
src/hooks/use-mobile.tsx
Normal file
21
src/hooks/use-mobile.tsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
const MOBILE_BREAKPOINT = 768;
|
||||||
|
|
||||||
|
export function useIsMobile() {
|
||||||
|
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
||||||
|
|
||||||
|
const onChange = () => {
|
||||||
|
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||||
|
};
|
||||||
|
|
||||||
|
mql.addEventListener("change", onChange);
|
||||||
|
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||||
|
return () => mql.removeEventListener("change", onChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return !!isMobile;
|
||||||
|
}
|
||||||
192
src/hooks/use-toast.ts
Normal file
192
src/hooks/use-toast.ts
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ToastActionElement,
|
||||||
|
ToastProps,
|
||||||
|
} from "@/components/ui/toast";
|
||||||
|
|
||||||
|
const TOAST_LIMIT = 1;
|
||||||
|
const TOAST_REMOVE_DELAY = 1000000;
|
||||||
|
|
||||||
|
type ToasterToast = ToastProps & {
|
||||||
|
id: string;
|
||||||
|
title?: React.ReactNode;
|
||||||
|
description?: React.ReactNode;
|
||||||
|
action?: ToastActionElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
const actionTypes = {
|
||||||
|
ADD_TOAST: "ADD_TOAST",
|
||||||
|
UPDATE_TOAST: "UPDATE_TOAST",
|
||||||
|
DISMISS_TOAST: "DISMISS_TOAST",
|
||||||
|
REMOVE_TOAST: "REMOVE_TOAST",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
function genId() {
|
||||||
|
count = (count + 1) % Number.MAX_SAFE_INTEGER;
|
||||||
|
return count.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActionType = typeof actionTypes;
|
||||||
|
|
||||||
|
type Action =
|
||||||
|
| {
|
||||||
|
type: ActionType["ADD_TOAST"];
|
||||||
|
toast: ToasterToast;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: ActionType["UPDATE_TOAST"];
|
||||||
|
toast: Partial<ToasterToast>;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: ActionType["DISMISS_TOAST"];
|
||||||
|
toastId?: ToasterToast["id"];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: ActionType["REMOVE_TOAST"];
|
||||||
|
toastId?: ToasterToast["id"];
|
||||||
|
};
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
toasts: ToasterToast[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
const addToRemoveQueue = (toastId: string) => {
|
||||||
|
if (toastTimeouts.has(toastId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
toastTimeouts.delete(toastId);
|
||||||
|
dispatch({
|
||||||
|
type: "REMOVE_TOAST",
|
||||||
|
toastId: toastId,
|
||||||
|
});
|
||||||
|
}, TOAST_REMOVE_DELAY);
|
||||||
|
|
||||||
|
toastTimeouts.set(toastId, timeout);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const reducer = (state: State, action: Action): State => {
|
||||||
|
switch (action.type) {
|
||||||
|
case "ADD_TOAST":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||||
|
}
|
||||||
|
|
||||||
|
case "UPDATE_TOAST":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.map((t) =>
|
||||||
|
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
case "DISMISS_TOAST": {
|
||||||
|
const { toastId } = action;
|
||||||
|
|
||||||
|
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||||
|
// but I'll keep it here for simplicity
|
||||||
|
if (toastId) {
|
||||||
|
addToRemoveQueue(toastId)
|
||||||
|
} else {
|
||||||
|
state.toasts.forEach((toast) => {
|
||||||
|
addToRemoveQueue(toast.id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.map((t) =>
|
||||||
|
t.id === toastId || toastId === undefined
|
||||||
|
? {
|
||||||
|
...t,
|
||||||
|
open: false,
|
||||||
|
}
|
||||||
|
: t
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "REMOVE_TOAST":
|
||||||
|
if (action.toastId === undefined) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const listeners: Array<(state: State) => void> = [];
|
||||||
|
|
||||||
|
let memoryState: State = { toasts: [] };
|
||||||
|
|
||||||
|
function dispatch(action: Action) {
|
||||||
|
memoryState = reducer(memoryState, action);
|
||||||
|
listeners.forEach((listener) => {
|
||||||
|
listener(memoryState);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type Toast = Omit<ToasterToast, "id">;
|
||||||
|
|
||||||
|
function toast({ ...props }: Toast) {
|
||||||
|
const id = genId();
|
||||||
|
|
||||||
|
const update = (props: ToasterToast) =>
|
||||||
|
dispatch({
|
||||||
|
type: "UPDATE_TOAST",
|
||||||
|
toast: { ...props, id },
|
||||||
|
});
|
||||||
|
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: "ADD_TOAST",
|
||||||
|
toast: {
|
||||||
|
...props,
|
||||||
|
id,
|
||||||
|
open: true,
|
||||||
|
onOpenChange: (open) => {
|
||||||
|
if (!open) dismiss()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: id,
|
||||||
|
dismiss,
|
||||||
|
update,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function useToast() {
|
||||||
|
const [state, setState] = React.useState<State>(memoryState);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
listeners.push(setState);
|
||||||
|
return () => {
|
||||||
|
const index = listeners.indexOf(setState);
|
||||||
|
if (index > -1) {
|
||||||
|
listeners.splice(index, 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toast,
|
||||||
|
dismiss: (toastId?: string) =>
|
||||||
|
dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { useToast, toast };
|
||||||
132
src/index.css
Normal file
132
src/index.css
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap');
|
||||||
|
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 210 40% 98%;
|
||||||
|
--foreground: 222.2 84% 4.9%;
|
||||||
|
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 222.2 84% 4.9%;
|
||||||
|
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 222.2 84% 4.9%;
|
||||||
|
|
||||||
|
--primary: 221 83% 53%;
|
||||||
|
--primary-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--secondary: 210 40% 96.1%;
|
||||||
|
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
|
||||||
|
--muted: 210 40% 96.1%;
|
||||||
|
--muted-foreground: 215.4 16.3% 46.9%;
|
||||||
|
|
||||||
|
--accent: 210 40% 96.1%;
|
||||||
|
--accent-foreground: 222.2 47.4% 11.2%;
|
||||||
|
|
||||||
|
--destructive: 0 84.2% 60.2%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--border: 214.3 31.8% 91.4%;
|
||||||
|
--input: 214.3 31.8% 91.4%;
|
||||||
|
--ring: 221 83% 53%;
|
||||||
|
|
||||||
|
--radius: 0.5rem;
|
||||||
|
|
||||||
|
--sidebar-background: 220 23% 95%;
|
||||||
|
--sidebar-foreground: 215 25% 27%;
|
||||||
|
--sidebar-primary: 221 83% 53%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
|
--sidebar-accent: 220 14% 90%;
|
||||||
|
--sidebar-accent-foreground: 215 25% 27%;
|
||||||
|
--sidebar-border: 220 13% 91%;
|
||||||
|
--sidebar-ring: 221 83% 53%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: 222.2 84% 4.9%;
|
||||||
|
--foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--card: 222.2 84% 4.9%;
|
||||||
|
--card-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--popover: 222.2 84% 4.9%;
|
||||||
|
--popover-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--primary: 217.2 91.2% 59.8%;
|
||||||
|
--primary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
|
||||||
|
--secondary: 217.2 32.6% 17.5%;
|
||||||
|
--secondary-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--muted: 217.2 32.6% 17.5%;
|
||||||
|
--muted-foreground: 215 20.2% 65.1%;
|
||||||
|
|
||||||
|
--accent: 217.2 32.6% 17.5%;
|
||||||
|
--accent-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--destructive: 0 62.8% 30.6%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--border: 217.2 32.6% 17.5%;
|
||||||
|
--input: 217.2 32.6% 17.5%;
|
||||||
|
--ring: 224.3 76.3% 48%;
|
||||||
|
|
||||||
|
--sidebar-background: 215 28% 17%;
|
||||||
|
--sidebar-foreground: 210 40% 98%;
|
||||||
|
--sidebar-primary: 217.2 91.2% 59.8%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
|
--sidebar-accent: 215 25% 27%;
|
||||||
|
--sidebar-accent-foreground: 210 40% 98%;
|
||||||
|
--sidebar-border: 215 25% 27%;
|
||||||
|
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground font-sans dark:bg-background dark:text-foreground;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre, code {
|
||||||
|
@apply font-mono;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-editor {
|
||||||
|
@apply font-mono text-base leading-relaxed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview {
|
||||||
|
@apply prose max-w-none prose-blue dark:prose-invert;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview pre {
|
||||||
|
@apply bg-secondary p-4 rounded-md overflow-x-auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview code {
|
||||||
|
@apply text-sm font-mono text-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview h1,
|
||||||
|
.markdown-preview h2,
|
||||||
|
.markdown-preview h3,
|
||||||
|
.markdown-preview h4,
|
||||||
|
.markdown-preview h5,
|
||||||
|
.markdown-preview h6 {
|
||||||
|
@apply font-sans font-semibold text-foreground;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview ul,
|
||||||
|
.markdown-preview ol {
|
||||||
|
@apply my-4 ml-6;
|
||||||
|
}
|
||||||
622
src/lib/appData.ts
Normal file
622
src/lib/appData.ts
Normal file
@ -0,0 +1,622 @@
|
|||||||
|
import { Module, FrameEntry, Strategy, SafetyQuiz, SignItem, Event, ParentMessage, ZoneInfo, User, CampusInfo } from './types';
|
||||||
|
|
||||||
|
export const CAMPUSES: CampusInfo[] = [
|
||||||
|
{
|
||||||
|
id: 'tigers',
|
||||||
|
mascot: 'Tigers',
|
||||||
|
fullName: 'Tigers Campus',
|
||||||
|
color: 'bg-orange-500',
|
||||||
|
bgGradient: 'from-orange-500 to-amber-500',
|
||||||
|
borderColor: 'border-orange-500/30',
|
||||||
|
textColor: 'text-orange-400',
|
||||||
|
bgLight: 'bg-orange-500/10',
|
||||||
|
description: 'Strength, courage & determination',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gators',
|
||||||
|
mascot: 'Gators',
|
||||||
|
fullName: 'Gators Campus',
|
||||||
|
color: 'bg-emerald-500',
|
||||||
|
bgGradient: 'from-emerald-500 to-green-500',
|
||||||
|
borderColor: 'border-emerald-500/30',
|
||||||
|
textColor: 'text-emerald-400',
|
||||||
|
bgLight: 'bg-emerald-500/10',
|
||||||
|
description: 'Resilience, adaptability & power',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hawks',
|
||||||
|
mascot: 'Hawks',
|
||||||
|
fullName: 'Hawks Campus',
|
||||||
|
color: 'bg-red-500',
|
||||||
|
bgGradient: 'from-red-500 to-rose-500',
|
||||||
|
borderColor: 'border-red-500/30',
|
||||||
|
textColor: 'text-red-400',
|
||||||
|
bgLight: 'bg-red-500/10',
|
||||||
|
description: 'Vision, focus & leadership',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'owls',
|
||||||
|
mascot: 'Owls',
|
||||||
|
fullName: 'Owls Campus (Online)',
|
||||||
|
color: 'bg-purple-500',
|
||||||
|
bgGradient: 'from-purple-500 to-violet-500',
|
||||||
|
borderColor: 'border-purple-500/30',
|
||||||
|
textColor: 'text-purple-400',
|
||||||
|
bgLight: 'bg-purple-500/10',
|
||||||
|
description: 'Wisdom, insight & virtual learning',
|
||||||
|
isOnline: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'wildcats',
|
||||||
|
mascot: 'Wildcats',
|
||||||
|
fullName: 'Wildcats Campus',
|
||||||
|
color: 'bg-blue-500',
|
||||||
|
bgGradient: 'from-blue-500 to-cyan-500',
|
||||||
|
borderColor: 'border-blue-500/30',
|
||||||
|
textColor: 'text-blue-400',
|
||||||
|
bgLight: 'bg-blue-500/10',
|
||||||
|
description: 'Agility, independence & spirit',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'grizzlies',
|
||||||
|
mascot: 'Grizzlies',
|
||||||
|
fullName: 'Grizzlies Campus',
|
||||||
|
color: 'bg-amber-700',
|
||||||
|
bgGradient: 'from-amber-700 to-yellow-600',
|
||||||
|
borderColor: 'border-amber-700/30',
|
||||||
|
textColor: 'text-amber-500',
|
||||||
|
bgLight: 'bg-amber-700/10',
|
||||||
|
description: 'Strength, protection & community',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const getCampusByMascot = (mascot: string): CampusInfo | undefined => {
|
||||||
|
return CAMPUSES.find(c => c.mascot.toLowerCase() === mascot.toLowerCase() || c.fullName.toLowerCase() === mascot.toLowerCase() || c.id === mascot.toLowerCase());
|
||||||
|
};
|
||||||
|
|
||||||
|
export const USERS: User[] = [
|
||||||
|
{ name: 'Ms. Rodriguez', role: 'teacher', avatar: 'MR', campus: 'Tigers' },
|
||||||
|
{ name: 'Mr. Thompson', role: 'para', avatar: 'MT', campus: 'Tigers' },
|
||||||
|
{ name: 'Mrs. Chen', role: 'office', avatar: 'MC', campus: 'Tigers' },
|
||||||
|
{ name: 'Dr. Williams', role: 'director', avatar: 'DW', campus: 'Tigers' },
|
||||||
|
];
|
||||||
|
|
||||||
|
|
||||||
|
export const MODULES: Module[] = [
|
||||||
|
{ id: 'dashboard', name: 'Home Dashboard', icon: 'home', roles: ['teacher', 'para', 'office', 'director', 'superintendent'], color: 'bg-violet-500' },
|
||||||
|
{ id: 'frame', name: 'F.R.A.M.E. Weekly', icon: 'frame', roles: ['teacher', 'para', 'office', 'director', 'superintendent'], color: 'bg-amber-500' },
|
||||||
|
{ id: 'classroom', name: 'Classroom Support', icon: 'book', roles: ['teacher', 'para', 'director', 'superintendent'], color: 'bg-emerald-500' },
|
||||||
|
{ id: 'timer', name: 'Classroom Timer', icon: 'timer', roles: ['teacher', 'para', 'director', 'superintendent'], color: 'bg-cyan-500' },
|
||||||
|
|
||||||
|
{ id: 'qbs', name: 'De-escalation Strategies', icon: 'shield', roles: ['teacher', 'para', 'director', 'superintendent'], color: 'bg-blue-500' },
|
||||||
|
{ id: 'ei', name: 'Emotional Intelligence', icon: 'heart', roles: ['teacher', 'para', 'office', 'director', 'superintendent'], color: 'bg-pink-500' },
|
||||||
|
{ id: 'zones', name: 'Regulate your Zone', icon: 'layers', roles: ['teacher', 'para', 'director', 'superintendent'], color: 'bg-teal-500' },
|
||||||
|
|
||||||
|
{ id: 'signs', name: 'Sign Language', icon: 'hand', roles: ['teacher', 'para', 'director', 'superintendent'], color: 'bg-indigo-500' },
|
||||||
|
{ id: 'attendance', name: 'Attendance', icon: 'clock', roles: ['teacher', 'para', 'office', 'director', 'superintendent'], color: 'bg-orange-500' },
|
||||||
|
{ id: 'parent-comm', name: 'Parent Communication', icon: 'message', roles: ['teacher', 'director', 'superintendent'], color: 'bg-cyan-500' },
|
||||||
|
{ id: 'internal-comm', name: 'Internal Alerts', icon: 'bell', roles: ['teacher', 'para', 'office', 'director', 'superintendent'], color: 'bg-rose-500' },
|
||||||
|
{ id: 'safety', name: 'Safety Protocols', icon: 'alert', roles: ['teacher', 'para', 'office', 'director', 'superintendent'], color: 'bg-red-500' },
|
||||||
|
{ id: 'handbook', name: 'Handbook & Policies', icon: 'file', roles: ['teacher', 'para', 'office', 'director', 'superintendent'], color: 'bg-slate-600' },
|
||||||
|
{ id: 'community', name: 'Community & Partnerships', icon: 'globe', roles: ['teacher', 'para', 'office', 'director', 'superintendent'], color: 'bg-green-600' },
|
||||||
|
{ id: 'vocational', name: 'Vocational Opportunities', icon: 'briefcase', roles: ['teacher', 'para', 'office', 'director', 'superintendent'], color: 'bg-sky-600' },
|
||||||
|
{ id: 'esa', name: 'ESA Funding Info', icon: 'wallet', roles: ['teacher', 'para', 'office', 'director', 'superintendent'], color: 'bg-emerald-600' },
|
||||||
|
{ id: 'walkthrough', name: 'Walk-Through Check-In', icon: 'clipboard', roles: ['director', 'superintendent'], color: 'bg-indigo-600' },
|
||||||
|
{ id: 'director', name: 'Director Dashboard', icon: 'chart', roles: ['director', 'superintendent'], color: 'bg-purple-600' },
|
||||||
|
|
||||||
|
];
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const FRAME_ENTRIES: FrameEntry[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
weekOf: 'February 9, 2026',
|
||||||
|
postedDate: 'February 6, 2026',
|
||||||
|
formal: 'Observed strong use of visual schedules across K-2 classrooms; inconsistent follow-through during transitions in upper grades. Positive trend in first/then board usage.',
|
||||||
|
recognition: 'Recognizing the K-2 team for consistent de-escalation language during high-demand times. Special shout-out to Ms. Rodriguez for implementing sensory breaks proactively.',
|
||||||
|
application: 'This week, apply first/then visuals before ALL transitions and allow a 30-second processing pause. Practice the "Help" and "Wait" signs campus-wide.',
|
||||||
|
management: 'Arrival supervision coverage needs consistency between 8:00-8:15. Please follow posted assignments. Classroom visual schedules must be updated by Wednesday.',
|
||||||
|
emotional: 'Focus on emotional regulation during escalations — pause, breathe, respond instead of react. Notice your own zone before intervening in a situation.',
|
||||||
|
|
||||||
|
author: 'Dr. Williams'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
weekOf: 'February 2, 2026',
|
||||||
|
postedDate: 'January 30, 2026',
|
||||||
|
formal: 'Walkthroughs showed improved sensory corner usage. Some classrooms still need visual boundary markers for safe spaces.',
|
||||||
|
recognition: 'Outstanding work from the 3-5 team on implementing calm-down kits. Mr. Thompson\'s para support during lunch transitions was exemplary.',
|
||||||
|
application: 'Practice using zone check-in boards at the start of each class period. Model the "calm" and "break" signs during morning circle.',
|
||||||
|
management: 'Fire drill response times need improvement in Building B. Review posted routes and practice with students before next drill.',
|
||||||
|
emotional: 'This week\'s EI focus: empathy in communication. Before responding to a colleague, ask "What might they be feeling right now?"',
|
||||||
|
author: 'Dr. Williams'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
weekOf: 'January 26, 2026',
|
||||||
|
postedDate: 'January 23, 2026',
|
||||||
|
formal: 'Formal observations noted excellent use of token boards in 60% of classrooms. Transition warnings are being given but timing is inconsistent.',
|
||||||
|
recognition: 'Mrs. Chen\'s front office team has created a welcoming check-in process for families. The calm, organized environment sets the tone for the whole campus.',
|
||||||
|
application: 'Implement 2-minute, 1-minute, and 30-second transition warnings consistently. Use visual timers alongside verbal cues.',
|
||||||
|
management: 'Communication logs for parent contacts need to be completed same-day. Several entries from last week are still missing.',
|
||||||
|
emotional: 'Focus on self-awareness: What triggers your stress response at work? Identify one personal regulation strategy to use this week.',
|
||||||
|
author: 'Dr. Williams'
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const STRATEGIES: Strategy[] = [
|
||||||
|
{
|
||||||
|
id: '1', title: 'Visual Schedule Boards', description: 'Use picture-based schedules to help students predict and prepare for daily activities. Place at eye level and reference frequently.',
|
||||||
|
category: 'visual-support', ageGroup: 'All', zone: 'green',
|
||||||
|
image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770658833077_9b6461a9.jpg'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2', title: 'First/Then Board', description: 'Show students what they need to do first, then what preferred activity follows. Builds motivation and reduces anxiety about tasks.',
|
||||||
|
category: 'visual-support', ageGroup: 'K-2', zone: 'green',
|
||||||
|
image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770658868373_09b2170b.jpg'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3', title: 'Sensory Break Station', description: 'Designate a calm area with fidgets, weighted items, noise-canceling headphones, and dim lighting for self-regulation.',
|
||||||
|
category: 'sensory', ageGroup: 'All', zone: 'yellow',
|
||||||
|
image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770658913969_250b3efa.jpg'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4', title: 'Transition Countdown Timer', description: 'Use a visual timer (sand timer or digital) to give 5-3-1 minute warnings before activity changes. Reduces transition anxiety.',
|
||||||
|
category: 'transition', ageGroup: 'All', zone: 'yellow',
|
||||||
|
image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770658981340_d834abc1.png'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '5', title: 'Social Story Cards', description: 'Short, illustrated stories that explain social situations, expected behaviors, and appropriate responses in specific scenarios.',
|
||||||
|
category: 'social', ageGroup: '3-5', zone: 'green',
|
||||||
|
image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770658995178_40196c48.jpg'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '6', title: 'Token Economy System', description: 'Reward system using tokens earned for desired behaviors, exchangeable for preferred activities or items. Keep it simple and visual.',
|
||||||
|
category: 'behavior', ageGroup: 'K-2', zone: 'green',
|
||||||
|
image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770659011984_838f549a.jpg'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '7', title: 'Choice Board', description: 'Offer 2-3 visual choices for activities, reducing demand avoidance and increasing autonomy. Great for non-verbal students.',
|
||||||
|
category: 'communication', ageGroup: 'All', zone: 'green',
|
||||||
|
image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770659031644_b34871e1.jpg'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '8', title: 'Movement Break Protocol', description: 'Scheduled 2-3 minute movement breaks every 20 minutes. Include jumping, stretching, or wall push-ups to regulate energy.',
|
||||||
|
category: 'sensory', ageGroup: 'All', zone: 'yellow',
|
||||||
|
image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770659048406_2603ea14.jpg'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '9', title: 'Calm Down Kit', description: 'Portable kit with stress ball, breathing card, favorite texture item, and visual coping steps. Personalize for each student.',
|
||||||
|
category: 'sensory', ageGroup: 'K-2', zone: 'red',
|
||||||
|
image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770659068729_0f0aeb1d.png'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '10', title: 'Visual Boundary Markers', description: 'Use tape, mats, or colored zones on the floor to define personal space and activity areas. Helps with spatial awareness.',
|
||||||
|
category: 'visual-support', ageGroup: 'All', zone: 'green',
|
||||||
|
image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770658996427_bb199e79.jpg'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '11', title: 'Peer Buddy System', description: 'Pair students for structured activities. Train buddies on patience, simple prompts, and when to get adult help.',
|
||||||
|
category: 'social', ageGroup: '3-5', zone: 'green',
|
||||||
|
image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770659033459_04837bc4.jpg'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '12', title: 'De-escalation Voice Protocol', description: 'Use low, slow, calm voice. Reduce words. Give space. Avoid questions during escalation. Wait for the student to reach yellow zone before processing.',
|
||||||
|
category: 'behavior', ageGroup: 'All', zone: 'red',
|
||||||
|
image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770659012324_2e0b2c92.jpg'
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const QBS_QUIZ: SafetyQuiz = {
|
||||||
|
id: '1',
|
||||||
|
title: 'De-Escalation Techniques Review',
|
||||||
|
focus: 'de-escalation',
|
||||||
|
weekOf: 'February 9, 2026',
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
id: 'q1',
|
||||||
|
question: 'What is the FIRST step when a student begins showing signs of escalation?',
|
||||||
|
options: ['Physically redirect the student', 'Assess the environment and reduce demands', 'Call for backup immediately', 'Begin documentation'],
|
||||||
|
correctIndex: 1,
|
||||||
|
explanation: 'Reducing environmental demands and assessing triggers is always the first response before any physical intervention.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'q2',
|
||||||
|
question: 'During a crisis, which voice technique is most effective?',
|
||||||
|
options: ['Firm, authoritative tone', 'Low, slow, and calm with minimal words', 'Matching the student\'s volume to be heard', 'Silent treatment until they calm down'],
|
||||||
|
correctIndex: 1,
|
||||||
|
explanation: 'A low, slow, calm voice with minimal words reduces stimulation and models regulation.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'q3',
|
||||||
|
question: 'When is physical management appropriate according to de-escalation protocols?',
|
||||||
|
options: ['When someone refuses to follow directions', 'Only when there is imminent danger of harm', 'Whenever verbal de-escalation fails', 'During any aggressive behavior'],
|
||||||
|
correctIndex: 1,
|
||||||
|
explanation: 'Physical management is a last resort, used ONLY when there is imminent danger of harm to the individual or others.'
|
||||||
|
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'q4',
|
||||||
|
question: 'What should you do AFTER a crisis has de-escalated?',
|
||||||
|
options: ['Immediately discuss what happened with the student', 'Allow recovery time, then document and debrief', 'Send the student to the office', 'Resume normal activities immediately'],
|
||||||
|
correctIndex: 1,
|
||||||
|
explanation: 'Recovery time is essential. Document the incident, debrief with your team, and allow the student to return to baseline.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'q5',
|
||||||
|
question: 'Which Zone of Regulation typically indicates a student is approaching crisis?',
|
||||||
|
options: ['Blue Zone', 'Green Zone', 'Yellow Zone', 'Red Zone'],
|
||||||
|
correctIndex: 2,
|
||||||
|
explanation: 'Yellow Zone is the warning zone — intervening here with de-escalation strategies can prevent reaching Red Zone crisis.'
|
||||||
|
},
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SIGN_ITEMS: SignItem[] = [
|
||||||
|
{
|
||||||
|
id: '1', word: 'Help', category: 'basic-needs',
|
||||||
|
description: 'Flat hand on top of fist, lift both up together',
|
||||||
|
image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1774037791874_e080660e.png',
|
||||||
|
tip: 'Teach this sign first — it reduces frustration-based behaviors immediately.',
|
||||||
|
videoUrl: 'https://www.youtube.com/embed/Euz1g9E-Mrw',
|
||||||
|
gifUrl: 'https://www.lifeprint.com/asl101/gifs/h/help.gif',
|
||||||
|
videoSteps: [
|
||||||
|
{ step: 1, instruction: 'Make a fist with your non-dominant hand and hold it at chest level', duration: 3 },
|
||||||
|
{ step: 2, instruction: 'Place your dominant hand flat on top of the fist, palm facing up', duration: 3 },
|
||||||
|
{ step: 3, instruction: 'Lift both hands upward together in one smooth motion', duration: 3 },
|
||||||
|
{ step: 4, instruction: 'Repeat the upward motion to emphasize the sign', duration: 3 },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2', word: 'More', category: 'basic-needs',
|
||||||
|
description: 'Fingertips of both hands touch together repeatedly',
|
||||||
|
image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1774037809931_bf2125b1.jpg',
|
||||||
|
tip: 'Accept any approximation. Respond immediately to reinforce communication.',
|
||||||
|
videoUrl: 'https://www.youtube.com/embed/fVkzBbQ-whs',
|
||||||
|
gifUrl: 'https://www.lifeprint.com/asl101/gifs/m/more.gif',
|
||||||
|
videoSteps: [
|
||||||
|
{ step: 1, instruction: 'Hold both hands in front of you with fingers pinched together', duration: 3 },
|
||||||
|
{ step: 2, instruction: 'Bring the fingertips of both hands together so they touch', duration: 3 },
|
||||||
|
{ step: 3, instruction: 'Separate your hands slightly, then tap fingertips together again', duration: 3 },
|
||||||
|
{ step: 4, instruction: 'Repeat the tapping motion 2-3 times', duration: 3 },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3', word: 'All Done', category: 'basic-needs',
|
||||||
|
description: 'Both hands open, palms facing you, flip outward',
|
||||||
|
image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1774037826231_d4a6656b.jpg',
|
||||||
|
tip: 'Pair with visual "finished" card. Great for ending non-preferred activities.',
|
||||||
|
videoUrl: 'https://www.youtube.com/embed/7xQE2N0z7gM',
|
||||||
|
gifUrl: 'https://www.lifeprint.com/asl101/gifs/f/finish.gif',
|
||||||
|
videoSteps: [
|
||||||
|
{ step: 1, instruction: 'Hold both hands up at chest level with palms facing toward you', duration: 3 },
|
||||||
|
{ step: 2, instruction: 'Spread your fingers wide open', duration: 3 },
|
||||||
|
{ step: 3, instruction: 'Rotate both hands outward so palms face away from you', duration: 3 },
|
||||||
|
{ step: 4, instruction: 'Complete the flip in one smooth motion — this means "all done"', duration: 3 },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4', word: 'Break', category: 'basic-needs',
|
||||||
|
description: 'Both hands together, pull apart like breaking a stick',
|
||||||
|
image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1774037847928_f8f28226.png',
|
||||||
|
tip: 'Teaching "break" proactively prevents escalation. Honor the request when possible.',
|
||||||
|
videoUrl: 'https://www.youtube.com/embed/q6LuW4Sp_XM',
|
||||||
|
gifUrl: 'https://www.lifeprint.com/asl101/images-signs/break.gif',
|
||||||
|
videoSteps: [
|
||||||
|
{ step: 1, instruction: 'Hold both fists together in front of your chest, touching', duration: 3 },
|
||||||
|
{ step: 2, instruction: 'Grip as if holding a stick or twig between both hands', duration: 3 },
|
||||||
|
{ step: 3, instruction: 'Twist and pull your hands apart as if snapping a stick', duration: 3 },
|
||||||
|
{ step: 4, instruction: 'End with hands separated — the breaking motion signals "break"', duration: 3 },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '5', word: 'Stop', category: 'emotional',
|
||||||
|
description: 'One flat hand strikes the palm of the other hand',
|
||||||
|
image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1774037865100_963f8d4a.jpg',
|
||||||
|
tip: 'Use in Red Zone situations. Pair with visual stop sign card.',
|
||||||
|
videoUrl: 'https://www.youtube.com/embed/RhQvlq-mZtA',
|
||||||
|
gifUrl: 'https://www.lifeprint.com/asl101/gifs/s/stop.gif',
|
||||||
|
videoSteps: [
|
||||||
|
{ step: 1, instruction: 'Hold your non-dominant hand out flat, palm facing up', duration: 3 },
|
||||||
|
{ step: 2, instruction: 'Raise your dominant hand with fingers together, palm facing down', duration: 3 },
|
||||||
|
{ step: 3, instruction: 'Bring your dominant hand down firmly onto the open palm', duration: 3 },
|
||||||
|
{ step: 4, instruction: 'Make the motion crisp and decisive to clearly communicate "stop"', duration: 3 },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '6', word: 'Calm', category: 'emotional',
|
||||||
|
description: 'Both hands move down slowly from chest level',
|
||||||
|
image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1774037891102_4977dae4.png',
|
||||||
|
tip: 'Model this sign while taking deep breaths. Others mirror the regulation.',
|
||||||
|
videoUrl: 'https://www.youtube.com/embed/RhQvlq-mZtA',
|
||||||
|
gifUrl: 'https://www.lifeprint.com/asl101/images-signs/calm.gif',
|
||||||
|
videoSteps: [
|
||||||
|
{ step: 1, instruction: 'Hold both hands at chest level, palms facing downward', duration: 3 },
|
||||||
|
{ step: 2, instruction: 'Take a slow, deep breath in as you hold position', duration: 3 },
|
||||||
|
{ step: 3, instruction: 'Slowly push both hands downward while exhaling', duration: 3 },
|
||||||
|
{ step: 4, instruction: 'Repeat the slow downward motion — model calm breathing with it', duration: 3 },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '7', word: 'Eat', category: 'basic-needs',
|
||||||
|
description: 'Fingertips to mouth, tapping gently',
|
||||||
|
image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1774037923119_35eee41c.png',
|
||||||
|
tip: 'Use before meals and snacks to build routine communication.',
|
||||||
|
videoUrl: 'https://www.youtube.com/embed/q6LuW4Sp_XM',
|
||||||
|
gifUrl: 'https://www.lifeprint.com/asl101/gifs/e/eat.gif',
|
||||||
|
videoSteps: [
|
||||||
|
{ step: 1, instruction: 'Bring your dominant hand up near your mouth', duration: 3 },
|
||||||
|
{ step: 2, instruction: 'Pinch your fingertips together as if holding small food', duration: 3 },
|
||||||
|
{ step: 3, instruction: 'Tap your fingertips gently against your lips', duration: 3 },
|
||||||
|
{ step: 4, instruction: 'Repeat the tapping motion 2-3 times to sign "eat"', duration: 3 },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '8', word: 'Drink', category: 'basic-needs',
|
||||||
|
description: 'Thumb to mouth, tilting like drinking from a cup',
|
||||||
|
image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1774037939928_1882c801.jpg',
|
||||||
|
tip: 'Pair with actual cup or water visual for stronger association.',
|
||||||
|
videoUrl: 'https://www.youtube.com/embed/q6LuW4Sp_XM',
|
||||||
|
gifUrl: 'https://www.lifeprint.com/asl101/gifs/d/drink-c.gif',
|
||||||
|
videoSteps: [
|
||||||
|
{ step: 1, instruction: 'Cup your dominant hand as if holding an invisible cup', duration: 3 },
|
||||||
|
{ step: 2, instruction: 'Bring the cupped hand up to your mouth', duration: 3 },
|
||||||
|
{ step: 3, instruction: 'Tilt your hand as if pouring a drink into your mouth', duration: 3 },
|
||||||
|
{ step: 4, instruction: 'Lower your hand and repeat — the tilting motion means "drink"', duration: 3 },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '9', word: 'Wait', category: 'classroom',
|
||||||
|
description: 'Both hands up, fingers wiggling slightly',
|
||||||
|
image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1774037963758_057baa79.png',
|
||||||
|
tip: 'Use with visual timer. "Wait" is hard — pair with a clear end signal.',
|
||||||
|
videoUrl: 'https://www.youtube.com/embed/YfwgS9ZsBVw',
|
||||||
|
gifUrl: 'https://www.lifeprint.com/asl101/gifs/w/wait.gif',
|
||||||
|
videoSteps: [
|
||||||
|
{ step: 1, instruction: 'Hold both hands up in front of you, palms facing outward', duration: 3 },
|
||||||
|
{ step: 2, instruction: 'Spread your fingers apart', duration: 3 },
|
||||||
|
{ step: 3, instruction: 'Wiggle your fingers gently in a wave-like motion', duration: 3 },
|
||||||
|
{ step: 4, instruction: 'Hold the position briefly — the wiggling fingers signal "wait"', duration: 3 },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '10', word: 'Sit', category: 'classroom',
|
||||||
|
description: 'Two fingers of one hand sit on two fingers of the other',
|
||||||
|
image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1774037980919_43f7a0c8.jpg',
|
||||||
|
tip: 'Model while pointing to the chair. Keep it simple and consistent.',
|
||||||
|
videoUrl: 'https://www.youtube.com/embed/ZvzKTn4qWfA',
|
||||||
|
gifUrl: 'https://www.lifeprint.com/asl101/gifs/s/sit.gif',
|
||||||
|
videoSteps: [
|
||||||
|
{ step: 1, instruction: 'Extend your index and middle fingers on your non-dominant hand (like a flat surface)', duration: 3 },
|
||||||
|
{ step: 2, instruction: 'Extend your index and middle fingers on your dominant hand (like legs)', duration: 3 },
|
||||||
|
{ step: 3, instruction: 'Place the "legs" fingers on top of the "surface" fingers, like sitting', duration: 3 },
|
||||||
|
{ step: 4, instruction: 'Tap down gently once or twice to emphasize "sit"', duration: 3 },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '11', word: 'Listen', category: 'classroom',
|
||||||
|
description: 'Cup hand behind ear',
|
||||||
|
image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1774037998820_245a2c77.jpg',
|
||||||
|
tip: 'Pair with visual "ears listening" icon on the board.',
|
||||||
|
videoUrl: 'https://www.youtube.com/embed/RhQvlq-mZtA',
|
||||||
|
gifUrl: 'https://www.lifeprint.com/asl101/images-signs/listen.gif',
|
||||||
|
videoSteps: [
|
||||||
|
{ step: 1, instruction: 'Raise your dominant hand up to the side of your head', duration: 3 },
|
||||||
|
{ step: 2, instruction: 'Cup your hand with fingers together, like catching sound', duration: 3 },
|
||||||
|
{ step: 3, instruction: 'Place your cupped hand just behind your ear', duration: 3 },
|
||||||
|
{ step: 4, instruction: 'Tilt your head slightly toward the hand — this means "listen"', duration: 3 },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '12', word: 'Happy', category: 'emotional',
|
||||||
|
description: 'Flat hand circles over chest, moving upward',
|
||||||
|
image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1774038017484_9fa3ff9d.jpg',
|
||||||
|
tip: 'Use during zone check-ins. "Are you happy? Show me the sign."',
|
||||||
|
videoUrl: 'https://www.youtube.com/embed/ZXHHO_DY6_A',
|
||||||
|
gifUrl: 'https://www.lifeprint.com/asl101/gifs/h/happy.gif',
|
||||||
|
videoSteps: [
|
||||||
|
{ step: 1, instruction: 'Place your flat, open hand on your chest', duration: 3 },
|
||||||
|
{ step: 2, instruction: 'Begin moving your hand in a circular motion on your chest', duration: 3 },
|
||||||
|
{ step: 3, instruction: 'Brush upward and outward repeatedly in circular strokes', duration: 3 },
|
||||||
|
{ step: 4, instruction: 'Smile while signing — the upward motion represents happiness rising', duration: 3 },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const EVENTS: Event[] = [
|
||||||
|
{ id: '1', title: 'All-Staff Meeting', date: '2026-02-11', type: 'meeting', roles: ['teacher', 'para', 'office', 'director'] },
|
||||||
|
{ id: '2', title: 'Fire Drill Practice', date: '2026-02-12', type: 'drill', roles: ['teacher', 'para', 'office', 'director'] },
|
||||||
|
{ id: '3', title: 'Parent Conference Week', date: '2026-02-16', type: 'event', roles: ['teacher', 'director'] },
|
||||||
|
{ id: '4', title: 'De-escalation Quiz Due', date: '2026-02-13', type: 'deadline', roles: ['teacher', 'para'] },
|
||||||
|
|
||||||
|
{ id: '5', title: 'Spring Concert Planning', date: '2026-02-18', type: 'event', roles: ['teacher', 'para', 'office'] },
|
||||||
|
{ id: '6', title: 'Lockdown Drill', date: '2026-02-20', type: 'drill', roles: ['teacher', 'para', 'office', 'director'] },
|
||||||
|
{ id: '7', title: 'EI Assessment Due', date: '2026-02-14', type: 'deadline', roles: ['teacher', 'para', 'office'] },
|
||||||
|
{ id: '8', title: 'K-2 Team Meeting', date: '2026-02-10', type: 'meeting', roles: ['teacher', 'para'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const PARENT_TEMPLATES: ParentMessage[] = [
|
||||||
|
{ id: '1', template: 'Your child had a wonderful day today! They showed great progress in [specific area]. Keep encouraging them at home!', category: 'progress' },
|
||||||
|
{ id: '2', template: 'Just a reminder that [event name] is coming up on [date]. Please [specific action needed]. Let us know if you have questions!', category: 'event' },
|
||||||
|
{ id: '3', template: 'Today we noticed [specific behavior]. We used [strategy] to help. At home, you might try [home suggestion]. We\'re working together!', category: 'behavior' },
|
||||||
|
{ id: '4', template: 'We wanted to share that your child successfully [achievement] today! This is a big step and we\'re so proud of their effort.', category: 'progress' },
|
||||||
|
{ id: '5', template: 'This week we are focusing on [skill/sign]. You can practice at home by [specific activity]. Consistency helps so much!', category: 'general' },
|
||||||
|
{ id: '6', template: 'Your child is learning the sign for "[sign word]" this week. Here\'s how to practice: [description]. Any attempt counts — celebrate it!', category: 'general' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ZONES: ZoneInfo[] = [
|
||||||
|
{
|
||||||
|
color: 'blue',
|
||||||
|
name: 'Blue Zone',
|
||||||
|
description: 'Low energy states — feeling drained, unmotivated, fatigued, or disconnected',
|
||||||
|
behaviors: ['Feeling withdrawn or disengaged', 'Low motivation or energy', 'Difficulty starting tasks', 'Feeling emotionally flat or numb', 'Avoiding interactions with colleagues'],
|
||||||
|
strategies: ['Take a short walk or get fresh air', 'Have a warm drink or water', 'Check in with a trusted colleague', 'Start with a small, manageable task', 'Practice a brief mindfulness exercise'],
|
||||||
|
signs: ['Help', 'Break', 'Tired'],
|
||||||
|
bgClass: 'bg-blue-100',
|
||||||
|
textClass: 'text-blue-700',
|
||||||
|
borderClass: 'border-blue-300',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
color: 'green',
|
||||||
|
name: 'Green Zone',
|
||||||
|
description: 'Calm, focused, and productive — feeling balanced, content, and in control',
|
||||||
|
behaviors: ['Engaged and productive at work', 'Communicating effectively', 'Responding calmly to challenges', 'Maintaining healthy boundaries', 'Collaborating well with team members'],
|
||||||
|
strategies: ['Maintain your current routine', 'Acknowledge your positive state', 'Use this energy to tackle challenging tasks', 'Support a colleague who may be struggling', 'Reflect on what is keeping you in this zone'],
|
||||||
|
signs: ['Happy', 'More', 'Listen'],
|
||||||
|
bgClass: 'bg-green-100',
|
||||||
|
textClass: 'text-green-700',
|
||||||
|
borderClass: 'border-green-300',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
color: 'yellow',
|
||||||
|
name: 'Yellow Zone',
|
||||||
|
description: 'Heightened energy — feeling stressed, anxious, frustrated, or overwhelmed',
|
||||||
|
behaviors: ['Feeling irritable or short-tempered', 'Racing thoughts or difficulty concentrating', 'Talking faster or louder than usual', 'Feeling overwhelmed by workload', 'Becoming impatient with others'],
|
||||||
|
strategies: ['Step away for a 5-minute break', 'Practice deep breathing (4-7-8 method)', 'Prioritize tasks — focus on one thing at a time', 'Talk to a colleague or supervisor', 'Use grounding techniques (5 senses exercise)'],
|
||||||
|
signs: ['Break', 'Wait', 'Calm'],
|
||||||
|
bgClass: 'bg-yellow-100',
|
||||||
|
textClass: 'text-yellow-700',
|
||||||
|
borderClass: 'border-yellow-300',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
color: 'red',
|
||||||
|
name: 'Red Zone',
|
||||||
|
description: 'Extreme energy — feeling overwhelmed, angry, panicked, or at a breaking point',
|
||||||
|
behaviors: ['Intense frustration or anger', 'Feeling unable to cope', 'Wanting to walk out or shut down', 'Difficulty controlling emotional responses', 'Physical tension (clenched jaw, tight shoulders)'],
|
||||||
|
strategies: ['Remove yourself from the situation immediately', 'Find a quiet space to decompress', 'Use slow, deep breathing until your heart rate lowers', 'Do NOT make major decisions in this state', 'Reach out to a supervisor or support person'],
|
||||||
|
signs: ['Stop', 'Help', 'All Done'],
|
||||||
|
bgClass: 'bg-red-100',
|
||||||
|
textClass: 'text-red-700',
|
||||||
|
borderClass: 'border-red-300',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
|
||||||
|
export const HERO_IMAGE = 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770658813159_517b0df6.jpg';
|
||||||
|
export const HANDBOOK_IMAGE = 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770659087111_5a2d27c8.jpg';
|
||||||
|
|
||||||
|
export const TEACHER_IMAGES = [
|
||||||
|
'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770656488581_0a5ecf7e.jpg',
|
||||||
|
'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770656507012_d07cff23.png',
|
||||||
|
'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770656551954_522c70b8.png',
|
||||||
|
'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770656493138_78bf55a9.jpg',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ENCOURAGING_QUOTES = [
|
||||||
|
{ quote: "Every child deserves a champion — an adult who will never give up on them.", author: "Rita Pierson" },
|
||||||
|
{ quote: "The greatest sign of success for a teacher is to be able to say, 'The children are now working as if I did not exist.'", author: "Maria Montessori" },
|
||||||
|
{ quote: "In a world where you can be anything, be kind.", author: "Jennifer Dukes Lee" },
|
||||||
|
{ quote: "It is not our differences that divide us. It is our inability to recognize, accept, and celebrate those differences.", author: "Audre Lorde" },
|
||||||
|
{ quote: "Inclusion is not a strategy to help people fit into the systems and structures which exist in our societies; it is about transforming those systems and structures.", author: "Diane Richler" },
|
||||||
|
{ quote: "The best teachers are those who show you where to look but don't tell you what to see.", author: "Alexandra K. Trenfor" },
|
||||||
|
{ quote: "Patience is not the ability to wait, but the ability to keep a good attitude while waiting.", author: "Joyce Meyer" },
|
||||||
|
{ quote: "You have been assigned this mountain to show others it can be moved.", author: "Mel Robbins" },
|
||||||
|
{ quote: "What makes you different is what makes you beautiful.", author: "Unknown" },
|
||||||
|
{ quote: "Behind every young child who believes in themselves is a parent or teacher who believed first.", author: "Matthew Jacobson" },
|
||||||
|
{ quote: "The only way to do great work is to love what you do.", author: "Steve Jobs" },
|
||||||
|
{ quote: "Small progress is still progress. Celebrate every step forward.", author: "Unknown" },
|
||||||
|
{ quote: "When we focus on our gratitude, the tide of disappointment goes out and the tide of love rushes in.", author: "Kristin Armstrong" },
|
||||||
|
{ quote: "Your calm is contagious. Your patience is powerful. Your presence matters.", author: "Unknown" },
|
||||||
|
{ quote: "Difficult roads often lead to beautiful destinations.", author: "Zig Ziglar" },
|
||||||
|
{ quote: "The influence of a good teacher can never be erased.", author: "Unknown" },
|
||||||
|
{ quote: "Not all superheroes wear capes. Some carry lesson plans.", author: "Unknown" },
|
||||||
|
{ quote: "Be the reason someone smiles today.", author: "Unknown" },
|
||||||
|
{ quote: "Teaching kids to count is fine, but teaching them what counts is best.", author: "Bob Talbert" },
|
||||||
|
{ quote: "You are enough. You do enough. Breathe extra deep, let go, and just live right now in this moment.", author: "Unknown" },
|
||||||
|
{ quote: "Believe you can and you're halfway there.", author: "Theodore Roosevelt" },
|
||||||
|
{ quote: "The beautiful thing about learning is that no one can take it away from you.", author: "B.B. King" },
|
||||||
|
{ quote: "Connection is the key that unlocks a child's potential.", author: "Unknown" },
|
||||||
|
{ quote: "When you change the way you look at things, the things you look at change.", author: "Wayne Dyer" },
|
||||||
|
{ quote: "A child's life is like a piece of paper on which every person leaves a mark.", author: "Chinese Proverb" },
|
||||||
|
{ quote: "You don't have to be perfect to be amazing.", author: "Unknown" },
|
||||||
|
{ quote: "Today is a new day. A fresh start. A chance to make a difference.", author: "Unknown" },
|
||||||
|
{ quote: "The greatest glory in living lies not in never falling, but in rising every time we fall.", author: "Nelson Mandela" },
|
||||||
|
{ quote: "What lies behind us and what lies before us are tiny matters compared to what lies within us.", author: "Ralph Waldo Emerson" },
|
||||||
|
{ quote: "Your work is going to fill a large part of your life, and the only way to be truly satisfied is to do what you believe is great work.", author: "Steve Jobs" },
|
||||||
|
{ quote: "Act as if what you do makes a difference. It does.", author: "William James" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface HandbookPolicy {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
category: string;
|
||||||
|
content: string;
|
||||||
|
lastUpdated: string;
|
||||||
|
updatedBy: string;
|
||||||
|
acknowledged?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_HANDBOOK_POLICIES: HandbookPolicy[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
title: 'Student Behavior Intervention Policy',
|
||||||
|
category: 'Behavior',
|
||||||
|
content: 'All behavior interventions must follow the least-restrictive approach. Staff must document all incidents using the Behavior Incident Report form within 24 hours. Physical intervention is only permitted when there is imminent danger to the individual or others, and must follow de-escalation protocols. All physical interventions must be reported to administration immediately.',
|
||||||
|
|
||||||
|
lastUpdated: '2026-01-15',
|
||||||
|
updatedBy: 'Dr. Williams'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
title: 'Attendance & Tardiness Policy',
|
||||||
|
category: 'Operations',
|
||||||
|
content: 'Staff are expected to arrive at their assigned post no later than 7:45 AM. Three unexcused tardies in a 30-day period will result in a written counseling. Absences must be reported through the ADP system by 6:00 AM. Excessive absences may impact performance evaluations. Staff requiring accommodations should contact HR.',
|
||||||
|
lastUpdated: '2026-01-10',
|
||||||
|
updatedBy: 'Dr. Williams'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
title: 'Parent Communication Standards',
|
||||||
|
category: 'Communication',
|
||||||
|
content: 'All parent communication must be professional, FERPA-compliant, and documented. Use pre-approved templates when possible. Never share information about other students. Respond to parent inquiries within 24 hours. All communication must go through approved channels (app messaging, school email, or scheduled conferences). Social media contact with parents is prohibited.',
|
||||||
|
lastUpdated: '2026-01-20',
|
||||||
|
updatedBy: 'Dr. Williams'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4',
|
||||||
|
title: 'Emergency Procedures Overview',
|
||||||
|
category: 'Safety',
|
||||||
|
content: 'All staff must be familiar with fire drill, lockdown, and shelter-in-place procedures. Fire drills are conducted monthly. Lockdown drills are conducted quarterly. Staff must review posted evacuation routes in their assigned areas. Emergency kits must be checked and restocked monthly. All staff must complete annual safety training and sign acknowledgment forms.',
|
||||||
|
lastUpdated: '2026-02-01',
|
||||||
|
updatedBy: 'Dr. Williams'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '5',
|
||||||
|
title: 'Professional Development Requirements',
|
||||||
|
category: 'Professional Growth',
|
||||||
|
content: 'All staff must complete a minimum of 20 hours of professional development annually. Required training includes: De-escalation Strategies recertification, CPR/First Aid, Autism-specific strategies, and Emotional Intelligence modules. Training hours are tracked through the FRAMEworks app. Staff who do not meet requirements by June 1st will be placed on an improvement plan.',
|
||||||
|
|
||||||
|
lastUpdated: '2026-01-25',
|
||||||
|
updatedBy: 'Dr. Williams'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '6',
|
||||||
|
title: 'Dress Code Policy',
|
||||||
|
category: 'Operations',
|
||||||
|
content: 'Staff are expected to dress professionally and appropriately for working with students. Closed-toe shoes are required at all times for safety. Staff working directly with students should avoid jewelry that could be grabbed. Campus-specific spirit wear is encouraged on designated days. Staff should be prepared for physical activity and outdoor supervision.',
|
||||||
|
lastUpdated: '2026-01-05',
|
||||||
|
updatedBy: 'Dr. Williams'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '7',
|
||||||
|
title: 'Confidentiality & FERPA Compliance',
|
||||||
|
category: 'Legal',
|
||||||
|
content: 'All student information is protected under FERPA. Staff may not discuss student information in public areas, on social media, or with unauthorized individuals. Student records must be stored securely. Violations of FERPA may result in disciplinary action up to and including termination. Report any suspected breaches to administration immediately.',
|
||||||
|
lastUpdated: '2026-02-03',
|
||||||
|
updatedBy: 'Dr. Williams'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '8',
|
||||||
|
title: 'Technology Use Policy',
|
||||||
|
category: 'Operations',
|
||||||
|
content: 'School-issued devices are for professional use only. Personal cell phone use during instructional time is prohibited. Staff must use approved apps and platforms for student-related communication. All passwords must meet security requirements and be changed quarterly. Report any suspicious activity or security concerns to IT immediately.',
|
||||||
|
lastUpdated: '2026-01-18',
|
||||||
|
updatedBy: 'Dr. Williams'
|
||||||
|
},
|
||||||
|
];
|
||||||
389
src/lib/db.ts
Normal file
389
src/lib/db.ts
Normal file
@ -0,0 +1,389 @@
|
|||||||
|
import { supabase } from '@/lib/supabase';
|
||||||
|
import { FrameEntry } from '@/lib/types';
|
||||||
|
|
||||||
|
// ==================== F.R.A.M.E. ENTRIES ====================
|
||||||
|
export async function fetchFrameEntries(): Promise<FrameEntry[]> {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('frame_entries')
|
||||||
|
.select('*')
|
||||||
|
.order('created_at', { ascending: false });
|
||||||
|
if (error) { console.error('Error fetching frame entries:', error); return []; }
|
||||||
|
return (data || []).map(row => ({
|
||||||
|
id: row.id,
|
||||||
|
weekOf: row.week_of,
|
||||||
|
postedDate: row.posted_date,
|
||||||
|
formal: row.formal,
|
||||||
|
recognition: row.recognition,
|
||||||
|
application: row.application,
|
||||||
|
management: row.management,
|
||||||
|
emotional: row.emotional,
|
||||||
|
author: row.author,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createFrameEntry(entry: Omit<FrameEntry, 'id'>): Promise<FrameEntry | null> {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('frame_entries')
|
||||||
|
.insert({
|
||||||
|
week_of: entry.weekOf,
|
||||||
|
posted_date: entry.postedDate,
|
||||||
|
formal: entry.formal,
|
||||||
|
recognition: entry.recognition,
|
||||||
|
application: entry.application,
|
||||||
|
management: entry.management,
|
||||||
|
emotional: entry.emotional,
|
||||||
|
author: entry.author,
|
||||||
|
})
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
if (error) { console.error('Error creating frame entry:', error); return null; }
|
||||||
|
return {
|
||||||
|
id: data.id,
|
||||||
|
weekOf: data.week_of,
|
||||||
|
postedDate: data.posted_date,
|
||||||
|
formal: data.formal,
|
||||||
|
recognition: data.recognition,
|
||||||
|
application: data.application,
|
||||||
|
management: data.management,
|
||||||
|
emotional: data.emotional,
|
||||||
|
author: data.author,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateFrameEntry(entry: FrameEntry): Promise<boolean> {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('frame_entries')
|
||||||
|
.update({
|
||||||
|
week_of: entry.weekOf,
|
||||||
|
posted_date: entry.postedDate,
|
||||||
|
formal: entry.formal,
|
||||||
|
recognition: entry.recognition,
|
||||||
|
application: entry.application,
|
||||||
|
management: entry.management,
|
||||||
|
emotional: entry.emotional,
|
||||||
|
author: entry.author,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.eq('id', entry.id);
|
||||||
|
if (error) { console.error('Error updating frame entry:', error); return false; }
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== QUIZ RESULTS ====================
|
||||||
|
export async function saveQuizResult(result: {
|
||||||
|
userName: string; userRole: string; quizId: string; quizTitle: string;
|
||||||
|
score: number; totalQuestions: number; answers: number[]; weekOf: string;
|
||||||
|
}): Promise<boolean> {
|
||||||
|
const { error } = await supabase.from('quiz_results').insert({
|
||||||
|
user_name: result.userName, user_role: result.userRole,
|
||||||
|
quiz_id: result.quizId, quiz_title: result.quizTitle,
|
||||||
|
score: result.score, total_questions: result.totalQuestions,
|
||||||
|
answers: result.answers, week_of: result.weekOf,
|
||||||
|
});
|
||||||
|
if (error) { console.error('Error saving quiz result:', error); return false; }
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchQuizResults(weekOf?: string) {
|
||||||
|
let query = supabase.from('quiz_results').select('*').order('completed_at', { ascending: false });
|
||||||
|
if (weekOf) query = query.eq('week_of', weekOf);
|
||||||
|
const { data, error } = await query;
|
||||||
|
if (error) { console.error('Error fetching quiz results:', error); return []; }
|
||||||
|
return data || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== MESSAGES ====================
|
||||||
|
export async function saveMessage(msg: {
|
||||||
|
senderName: string; recipientName: string; messageText: string; category: string;
|
||||||
|
}): Promise<boolean> {
|
||||||
|
const { error } = await supabase.from('messages').insert({
|
||||||
|
sender_name: msg.senderName, recipient_name: msg.recipientName,
|
||||||
|
message_text: msg.messageText, category: msg.category,
|
||||||
|
});
|
||||||
|
if (error) { console.error('Error saving message:', error); return false; }
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchMessages(senderName?: string) {
|
||||||
|
let query = supabase.from('messages').select('*').order('sent_at', { ascending: false });
|
||||||
|
if (senderName) query = query.eq('sender_name', senderName);
|
||||||
|
const { data, error } = await query;
|
||||||
|
if (error) { console.error('Error fetching messages:', error); return []; }
|
||||||
|
return (data || []).map(row => ({
|
||||||
|
id: row.id, text: row.message_text, to: row.recipient_name,
|
||||||
|
date: new Date(row.sent_at).toLocaleString(), category: row.category,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== USER PROGRESS ====================
|
||||||
|
export async function saveProgress(progress: {
|
||||||
|
userName: string; userRole: string; progressType: string;
|
||||||
|
itemId?: string; value?: string; score?: number;
|
||||||
|
}): Promise<boolean> {
|
||||||
|
const { error } = await supabase.from('user_progress').insert({
|
||||||
|
user_name: progress.userName, user_role: progress.userRole,
|
||||||
|
progress_type: progress.progressType,
|
||||||
|
item_id: progress.itemId || '', value: progress.value || '',
|
||||||
|
score: progress.score || 0,
|
||||||
|
});
|
||||||
|
if (error) { console.error('Error saving progress:', error); return false; }
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchProgress(userName: string, progressType: string) {
|
||||||
|
const { data, error } = await supabase.from('user_progress')
|
||||||
|
.select('*')
|
||||||
|
.eq('user_name', userName)
|
||||||
|
.eq('progress_type', progressType)
|
||||||
|
.order('created_at', { ascending: false });
|
||||||
|
if (error) { console.error('Error fetching progress:', error); return []; }
|
||||||
|
return data || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteProgress(userName: string, progressType: string, itemId: string): Promise<boolean> {
|
||||||
|
const { error } = await supabase.from('user_progress')
|
||||||
|
.delete()
|
||||||
|
.eq('user_name', userName)
|
||||||
|
.eq('progress_type', progressType)
|
||||||
|
.eq('item_id', itemId);
|
||||||
|
if (error) { console.error('Error deleting progress:', error); return false; }
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== ATTENDANCE ====================
|
||||||
|
export async function fetchAttendance(userName?: string) {
|
||||||
|
let query = supabase.from('attendance_records').select('*').order('date', { ascending: false });
|
||||||
|
if (userName) query = query.eq('user_name', userName);
|
||||||
|
const { data, error } = await query;
|
||||||
|
if (error) { console.error('Error fetching attendance:', error); return []; }
|
||||||
|
return data || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== EVENTS ====================
|
||||||
|
export async function fetchEvents() {
|
||||||
|
const { data, error } = await supabase.from('events').select('*').order('event_date', { ascending: true });
|
||||||
|
if (error) { console.error('Error fetching events:', error); return []; }
|
||||||
|
return (data || []).map(row => ({
|
||||||
|
id: row.id, title: row.title, date: row.event_date,
|
||||||
|
type: row.event_type, roles: row.roles || [],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createEvent(event: {
|
||||||
|
title: string; date: string; type: string; roles: string[];
|
||||||
|
}): Promise<boolean> {
|
||||||
|
const { error } = await supabase.from('events').insert({
|
||||||
|
title: event.title, event_date: event.date,
|
||||||
|
event_type: event.type, roles: event.roles,
|
||||||
|
});
|
||||||
|
if (error) { console.error('Error creating event:', error); return false; }
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== STAFF USERS ====================
|
||||||
|
export async function fetchStaffUsers() {
|
||||||
|
const { data, error } = await supabase.from('users').select('*').order('name');
|
||||||
|
if (error) { console.error('Error fetching users:', error); return []; }
|
||||||
|
return data || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== WALKTHROUGH CHECK-INS ====================
|
||||||
|
export async function saveWalkthroughCheckin(checkin: {
|
||||||
|
teacher_name: string;
|
||||||
|
classroom: string;
|
||||||
|
director_name: string;
|
||||||
|
check_in_date: string;
|
||||||
|
check_in_time: string;
|
||||||
|
attitude_rating: number;
|
||||||
|
attitude_comment?: string;
|
||||||
|
classroom_management_rating: number;
|
||||||
|
classroom_management_comment?: string;
|
||||||
|
cleanliness_rating: number;
|
||||||
|
cleanliness_comment?: string;
|
||||||
|
vibes_rating: number;
|
||||||
|
vibes_comment?: string;
|
||||||
|
team_dynamics_rating: number;
|
||||||
|
team_dynamics_comment?: string;
|
||||||
|
emergency_exit_rating: number;
|
||||||
|
emergency_exit_comment?: string;
|
||||||
|
lesson_plan_rating: number;
|
||||||
|
lesson_plan_comment?: string;
|
||||||
|
overall_notes?: string;
|
||||||
|
}): Promise<boolean> {
|
||||||
|
const { error } = await supabase.from('walkthrough_checkins').insert(checkin);
|
||||||
|
if (error) { console.error('Error saving walkthrough:', error); return false; }
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchWalkthroughCheckins(teacherName?: string) {
|
||||||
|
let query = supabase.from('walkthrough_checkins').select('*').order('check_in_date', { ascending: false });
|
||||||
|
if (teacherName) query = query.eq('teacher_name', teacherName);
|
||||||
|
const { data, error } = await query;
|
||||||
|
if (error) { console.error('Error fetching walkthroughs:', error); return []; }
|
||||||
|
return data || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteWalkthroughCheckin(id: string): Promise<boolean> {
|
||||||
|
const { error } = await supabase.from('walkthrough_checkins').delete().eq('id', id);
|
||||||
|
if (error) { console.error('Error deleting walkthrough:', error); return false; }
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ==================== CAMPUS ATTENDANCE CONFIG ====================
|
||||||
|
export async function fetchCampusAttendanceConfig(campusId?: string) {
|
||||||
|
let query = supabase.from('campus_attendance_config').select('*').order('campus_name');
|
||||||
|
if (campusId) query = query.eq('campus_id', campusId);
|
||||||
|
const { data, error } = await query;
|
||||||
|
if (error) { console.error('Error fetching attendance config:', error); return []; }
|
||||||
|
return data || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCampusAttendanceLink(campusId: string, link: string, updatedBy: string): Promise<boolean> {
|
||||||
|
const { error } = await supabase.from('campus_attendance_config')
|
||||||
|
.update({ attendance_link: link, updated_by: updatedBy, updated_at: new Date().toISOString() })
|
||||||
|
.eq('campus_id', campusId);
|
||||||
|
if (error) { console.error('Error updating attendance link:', error); return false; }
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== CAMPUS ATTENDANCE DATA ====================
|
||||||
|
export async function fetchCampusAttendanceData(campusId?: string, startDate?: string, endDate?: string) {
|
||||||
|
let query = supabase.from('campus_attendance_data').select('*').order('date', { ascending: false });
|
||||||
|
if (campusId) query = query.eq('campus_id', campusId);
|
||||||
|
if (startDate) query = query.gte('date', startDate);
|
||||||
|
if (endDate) query = query.lte('date', endDate);
|
||||||
|
const { data, error } = await query;
|
||||||
|
if (error) { console.error('Error fetching attendance data:', error); return []; }
|
||||||
|
return data || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertCampusAttendanceData(record: {
|
||||||
|
campus_id: string; date: string; total_enrolled: number;
|
||||||
|
total_present: number; total_absent: number; total_tardy: number;
|
||||||
|
attendance_percentage: number; recorded_by: string; notes?: string;
|
||||||
|
}): Promise<boolean> {
|
||||||
|
const { error } = await supabase.from('campus_attendance_data')
|
||||||
|
.upsert({
|
||||||
|
campus_id: record.campus_id,
|
||||||
|
date: record.date,
|
||||||
|
total_enrolled: record.total_enrolled,
|
||||||
|
total_present: record.total_present,
|
||||||
|
total_absent: record.total_absent,
|
||||||
|
total_tardy: record.total_tardy,
|
||||||
|
attendance_percentage: record.attendance_percentage,
|
||||||
|
recorded_by: record.recorded_by,
|
||||||
|
notes: record.notes || '',
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
}, { onConflict: 'campus_id,date' });
|
||||||
|
if (error) { console.error('Error upserting attendance data:', error); return false; }
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ==================== PERSONALITY QUIZ RESULTS ====================
|
||||||
|
export async function savePersonalityQuizResult(result: {
|
||||||
|
userName: string;
|
||||||
|
userRole: string;
|
||||||
|
campus: string;
|
||||||
|
personalityType: string;
|
||||||
|
quizAnswers: Record<number, string>;
|
||||||
|
}): Promise<boolean> {
|
||||||
|
// First, try to find existing record for this user
|
||||||
|
const { data: existing } = await supabase
|
||||||
|
.from('personality_quiz_results')
|
||||||
|
.select('id')
|
||||||
|
.eq('user_name', result.userName)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing && existing.length > 0) {
|
||||||
|
// Update existing record
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('personality_quiz_results')
|
||||||
|
.update({
|
||||||
|
personality_type: result.personalityType,
|
||||||
|
user_role: result.userRole,
|
||||||
|
campus: result.campus,
|
||||||
|
quiz_answers: result.quizAnswers,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.eq('id', existing[0].id);
|
||||||
|
if (error) { console.error('Error updating personality result:', error); return false; }
|
||||||
|
} else {
|
||||||
|
// Insert new record
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('personality_quiz_results')
|
||||||
|
.insert({
|
||||||
|
user_name: result.userName,
|
||||||
|
user_role: result.userRole,
|
||||||
|
campus: result.campus,
|
||||||
|
personality_type: result.personalityType,
|
||||||
|
quiz_answers: result.quizAnswers,
|
||||||
|
});
|
||||||
|
if (error) { console.error('Error saving personality result:', error); return false; }
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPersonalityQuizResult(userName: string): Promise<{
|
||||||
|
personalityType: string;
|
||||||
|
quizAnswers: Record<number, string>;
|
||||||
|
updatedAt: string;
|
||||||
|
} | null> {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('personality_quiz_results')
|
||||||
|
.select('*')
|
||||||
|
.eq('user_name', userName)
|
||||||
|
.order('updated_at', { ascending: false })
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (error) { console.error('Error fetching personality result:', error); return null; }
|
||||||
|
if (!data || data.length === 0) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
personalityType: data[0].personality_type,
|
||||||
|
quizAnswers: data[0].quiz_answers || {},
|
||||||
|
updatedAt: data[0].updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPersonalityDistribution(campus?: string): Promise<{
|
||||||
|
type: string;
|
||||||
|
count: number;
|
||||||
|
}[]> {
|
||||||
|
let query = supabase
|
||||||
|
.from('personality_quiz_results')
|
||||||
|
.select('personality_type, campus');
|
||||||
|
|
||||||
|
if (campus) {
|
||||||
|
query = query.eq('campus', campus);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = await query;
|
||||||
|
if (error) { console.error('Error fetching personality distribution:', error); return []; }
|
||||||
|
if (!data) return [];
|
||||||
|
|
||||||
|
// Aggregate counts by personality type
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
data.forEach((row: any) => {
|
||||||
|
const type = row.personality_type;
|
||||||
|
counts[type] = (counts[type] || 0) + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.entries(counts)
|
||||||
|
.map(([type, count]) => ({ type, count }))
|
||||||
|
.sort((a, b) => b.count - a.count);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update staff_profiles personality_type (for authenticated users)
|
||||||
|
export async function updateStaffProfilePersonalityType(userId: string, personalityType: string): Promise<boolean> {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('staff_profiles')
|
||||||
|
.update({
|
||||||
|
personality_type: personalityType,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.eq('id', userId);
|
||||||
|
if (error) { console.error('Error updating staff profile personality type:', error); return false; }
|
||||||
|
return true;
|
||||||
|
}
|
||||||
379
src/lib/personalityTypes.ts
Normal file
379
src/lib/personalityTypes.ts
Normal file
@ -0,0 +1,379 @@
|
|||||||
|
export interface PersonalityType {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
nickname: string;
|
||||||
|
description: string;
|
||||||
|
strengths: string[];
|
||||||
|
workRelationships: string;
|
||||||
|
workplaceLanguage: string;
|
||||||
|
idealWorkEnvironment: string;
|
||||||
|
communicationStyle: string;
|
||||||
|
color: string;
|
||||||
|
bgColor: string;
|
||||||
|
borderColor: string;
|
||||||
|
icon: string; // emoji-free descriptor for icon selection
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuizQuestion {
|
||||||
|
id: number;
|
||||||
|
dimension: 'EI' | 'SN' | 'TF' | 'JP';
|
||||||
|
question: string;
|
||||||
|
optionA: { text: string; value: string };
|
||||||
|
optionB: { text: string; value: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PERSONALITY_QUIZ_QUESTIONS: QuizQuestion[] = [
|
||||||
|
// E vs I (3 questions)
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
dimension: 'EI',
|
||||||
|
question: 'During a staff meeting, you feel most energized when:',
|
||||||
|
optionA: { text: 'Brainstorming ideas out loud with the group and building on others\' suggestions', value: 'E' },
|
||||||
|
optionB: { text: 'Listening carefully, then sharing a well-thought-out idea you\'ve been developing internally', value: 'I' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
dimension: 'EI',
|
||||||
|
question: 'After a long, intense day with students, you recharge by:',
|
||||||
|
optionA: { text: 'Chatting with colleagues, grabbing coffee together, or calling a friend', value: 'E' },
|
||||||
|
optionB: { text: 'Having quiet time alone — reading, walking, or just decompressing in silence', value: 'I' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
dimension: 'EI',
|
||||||
|
question: 'When working on a new classroom strategy, you prefer to:',
|
||||||
|
optionA: { text: 'Talk it through with a colleague or team to get immediate feedback', value: 'E' },
|
||||||
|
optionB: { text: 'Research and reflect on your own first before discussing with others', value: 'I' },
|
||||||
|
},
|
||||||
|
// S vs N (3 questions)
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
dimension: 'SN',
|
||||||
|
question: 'When planning a lesson or activity, you tend to focus on:',
|
||||||
|
optionA: { text: 'Concrete, step-by-step instructions and proven methods that have worked before', value: 'S' },
|
||||||
|
optionB: { text: 'The big picture concept and creative ways to make it engaging and meaningful', value: 'N' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
dimension: 'SN',
|
||||||
|
question: 'When a new policy or procedure is introduced, you first want to know:',
|
||||||
|
optionA: { text: 'The specific details — what exactly changes, when, and how it affects your daily routine', value: 'S' },
|
||||||
|
optionB: { text: 'The reasoning behind it — why the change matters and what the long-term vision is', value: 'N' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 6,
|
||||||
|
dimension: 'SN',
|
||||||
|
question: 'When observing a student\'s behavior, you naturally notice:',
|
||||||
|
optionA: { text: 'Specific, observable details — what they said, did, and the exact sequence of events', value: 'S' },
|
||||||
|
optionB: { text: 'Patterns and underlying themes — what might be driving the behavior emotionally or socially', value: 'N' },
|
||||||
|
},
|
||||||
|
// T vs F (3 questions)
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
dimension: 'TF',
|
||||||
|
question: 'When a colleague disagrees with your approach to a student situation, you:',
|
||||||
|
optionA: { text: 'Present logical evidence and data to support why your approach is effective', value: 'T' },
|
||||||
|
optionB: { text: 'Consider their perspective empathetically and look for a solution that honors both viewpoints', value: 'F' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 8,
|
||||||
|
dimension: 'TF',
|
||||||
|
question: 'When making a decision about a student\'s behavior plan, you prioritize:',
|
||||||
|
optionA: { text: 'Consistency, fairness, and what the data shows is most effective', value: 'T' },
|
||||||
|
optionB: { text: 'The student\'s emotional needs and how the plan will affect their sense of belonging', value: 'F' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 9,
|
||||||
|
dimension: 'TF',
|
||||||
|
question: 'When giving feedback to a colleague, you tend to:',
|
||||||
|
optionA: { text: 'Be direct and specific about what needs improvement, focusing on outcomes', value: 'T' },
|
||||||
|
optionB: { text: 'Start with what\'s going well, then gently suggest areas for growth with encouragement', value: 'F' },
|
||||||
|
},
|
||||||
|
// J vs P (3 questions)
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
dimension: 'JP',
|
||||||
|
question: 'Your ideal classroom or workspace is:',
|
||||||
|
optionA: { text: 'Organized with clear systems, schedules posted, and materials in designated spots', value: 'J' },
|
||||||
|
optionB: { text: 'Flexible and adaptable — you know where things are even if it looks a bit creative', value: 'P' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 11,
|
||||||
|
dimension: 'JP',
|
||||||
|
question: 'When an unexpected schedule change happens during the school day, you:',
|
||||||
|
optionA: { text: 'Feel a bit stressed and quickly create a new plan to stay on track', value: 'J' },
|
||||||
|
optionB: { text: 'Roll with it naturally and see it as an opportunity to be spontaneous', value: 'P' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 12,
|
||||||
|
dimension: 'JP',
|
||||||
|
question: 'When preparing for the next school week, you typically:',
|
||||||
|
optionA: { text: 'Plan everything in advance — lessons, materials, and contingencies are all mapped out', value: 'J' },
|
||||||
|
optionB: { text: 'Have a general idea but prefer to stay flexible and adjust based on how the week unfolds', value: 'P' },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const PERSONALITY_TYPES: PersonalityType[] = [
|
||||||
|
{
|
||||||
|
code: 'INTJ',
|
||||||
|
name: 'The Architect',
|
||||||
|
nickname: 'Strategic Visionary',
|
||||||
|
description: 'INTJs are strategic, independent thinkers who see the big picture and create long-term plans. In education, they excel at designing systems, analyzing data, and finding innovative solutions to complex challenges. They bring a calm, analytical presence to their teams.',
|
||||||
|
strengths: ['Strategic planning', 'Systems thinking', 'Independent problem-solving', 'Long-range vision'],
|
||||||
|
workRelationships: 'INTJs value competence and intellectual depth in their colleagues. They prefer working with people who are self-sufficient, logical, and open to constructive critique. They thrive in partnerships where ideas are debated respectfully, and they appreciate colleagues who come prepared and follow through on commitments. They may need encouragement to share their ideas more openly, as they tend to process internally before speaking.',
|
||||||
|
workplaceLanguage: 'INTJs communicate with precision and directness. They prefer concise, well-organized conversations that get to the point quickly. Their language tends to be analytical — they use phrases like "Based on the data..." or "The most efficient approach would be..." They may come across as blunt, but their intent is clarity, not coldness. They appreciate written communication (emails, documents) where they can organize their thoughts carefully.',
|
||||||
|
idealWorkEnvironment: 'Quiet, structured environments with autonomy and minimal micromanagement',
|
||||||
|
communicationStyle: 'Direct, analytical, and solution-focused',
|
||||||
|
color: 'from-indigo-500 to-purple-600',
|
||||||
|
bgColor: 'bg-indigo-500/10',
|
||||||
|
borderColor: 'border-indigo-500/20',
|
||||||
|
icon: 'architect',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'INTP',
|
||||||
|
name: 'The Logician',
|
||||||
|
nickname: 'Analytical Innovator',
|
||||||
|
description: 'INTPs are curious, analytical minds who love exploring ideas and understanding how things work. In education, they bring creative problem-solving and a unique perspective to challenges. They question assumptions and find unconventional solutions that others might miss.',
|
||||||
|
strengths: ['Analytical thinking', 'Creative problem-solving', 'Objective analysis', 'Intellectual curiosity'],
|
||||||
|
workRelationships: 'INTPs value intellectual stimulation and autonomy in their work relationships. They connect best with colleagues who enjoy exploring ideas and don\'t take disagreements personally. They prefer working with people who are open-minded and can engage in thoughtful debate. They may struggle with highly emotional or politically charged workplace dynamics and need space to think independently.',
|
||||||
|
workplaceLanguage: 'INTPs communicate through ideas and possibilities. They often use phrases like "What if we tried..." or "Have you considered..." Their language is exploratory and sometimes abstract. They may start multiple trains of thought before landing on their main point. They value accuracy over diplomacy and may need to be reminded to acknowledge others\' contributions before diving into analysis.',
|
||||||
|
idealWorkEnvironment: 'Intellectually stimulating spaces with freedom to explore and experiment',
|
||||||
|
communicationStyle: 'Exploratory, idea-driven, and questioning',
|
||||||
|
color: 'from-cyan-500 to-blue-600',
|
||||||
|
bgColor: 'bg-cyan-500/10',
|
||||||
|
borderColor: 'border-cyan-500/20',
|
||||||
|
icon: 'logician',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'ENTJ',
|
||||||
|
name: 'The Commander',
|
||||||
|
nickname: 'Decisive Leader',
|
||||||
|
description: 'ENTJs are natural leaders who organize people and resources to achieve goals efficiently. In education, they excel at driving initiatives, setting high standards, and motivating teams to reach their potential. They bring energy, confidence, and a results-oriented mindset.',
|
||||||
|
strengths: ['Leadership', 'Strategic execution', 'Team motivation', 'Goal achievement'],
|
||||||
|
workRelationships: 'ENTJs value efficiency and competence in their colleagues. They prefer working with people who are proactive, reliable, and willing to take ownership of their responsibilities. They build strong professional relationships through shared goals and mutual respect. They appreciate directness and may become frustrated with indecisiveness or lack of follow-through. They need to remember to balance their drive with patience for different working styles.',
|
||||||
|
workplaceLanguage: 'ENTJs communicate with authority and clarity. They use decisive language — "Here\'s the plan," "We need to," "The priority is..." Their communication is goal-oriented and action-focused. They naturally take charge in conversations and meetings. They should be mindful of leaving space for others to contribute and softening their delivery when working with more sensitive colleagues.',
|
||||||
|
idealWorkEnvironment: 'Dynamic, goal-driven environments where they can lead and make an impact',
|
||||||
|
communicationStyle: 'Commanding, clear, and action-oriented',
|
||||||
|
color: 'from-red-500 to-orange-600',
|
||||||
|
bgColor: 'bg-red-500/10',
|
||||||
|
borderColor: 'border-red-500/20',
|
||||||
|
icon: 'commander',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'ENTP',
|
||||||
|
name: 'The Debater',
|
||||||
|
nickname: 'Creative Challenger',
|
||||||
|
description: 'ENTPs are energetic innovators who love challenging the status quo and exploring new possibilities. In education, they bring fresh perspectives, creative solutions, and an infectious enthusiasm for trying new approaches. They keep teams thinking and growing.',
|
||||||
|
strengths: ['Innovation', 'Adaptability', 'Persuasion', 'Creative thinking'],
|
||||||
|
workRelationships: 'ENTPs thrive in relationships with colleagues who enjoy intellectual sparring and aren\'t afraid of new ideas. They connect through humor, debate, and shared enthusiasm for innovation. They value colleagues who can keep up with their rapid-fire ideas and help them refine their best ones. They may need to be more sensitive to colleagues who prefer stability and routine.',
|
||||||
|
workplaceLanguage: 'ENTPs communicate with enthusiasm and wit. They use phrases like "Why don\'t we try..." or "What if we flipped this..." Their language is persuasive and often playful. They enjoy devil\'s advocate positions and may challenge ideas not because they disagree, but because they want to strengthen the thinking. They should be aware that their debating style can feel confrontational to some colleagues.',
|
||||||
|
idealWorkEnvironment: 'Fast-paced, flexible environments that welcome experimentation and debate',
|
||||||
|
communicationStyle: 'Enthusiastic, persuasive, and intellectually challenging',
|
||||||
|
color: 'from-amber-500 to-yellow-600',
|
||||||
|
bgColor: 'bg-amber-500/10',
|
||||||
|
borderColor: 'border-amber-500/20',
|
||||||
|
icon: 'debater',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'INFJ',
|
||||||
|
name: 'The Advocate',
|
||||||
|
nickname: 'Insightful Guide',
|
||||||
|
description: 'INFJs are deeply empathetic visionaries who are driven by their values and desire to help others grow. In education, they excel at understanding students\' deeper needs, creating meaningful connections, and inspiring positive change. They bring wisdom, compassion, and quiet determination.',
|
||||||
|
strengths: ['Deep empathy', 'Visionary thinking', 'Meaningful connections', 'Values-driven leadership'],
|
||||||
|
workRelationships: 'INFJs seek authentic, meaningful connections with their colleagues. They value trust, mutual respect, and shared purpose. They prefer working with people who are genuine, compassionate, and committed to making a difference. They are excellent listeners and often become the person colleagues confide in. They may need to set boundaries to avoid emotional exhaustion and should seek out colleagues who reciprocate their depth of care.',
|
||||||
|
workplaceLanguage: 'INFJs communicate with warmth, depth, and purpose. They use phrases like "I feel like this matters because..." or "What I\'m sensing is..." Their language is values-driven and often metaphorical. They speak with conviction about things they believe in and can be surprisingly persuasive. They prefer one-on-one conversations over large group discussions and may need encouragement to share their insights in team settings.',
|
||||||
|
idealWorkEnvironment: 'Purposeful, harmonious environments where their work has meaningful impact',
|
||||||
|
communicationStyle: 'Warm, insightful, and values-driven',
|
||||||
|
color: 'from-emerald-500 to-teal-600',
|
||||||
|
bgColor: 'bg-emerald-500/10',
|
||||||
|
borderColor: 'border-emerald-500/20',
|
||||||
|
icon: 'advocate',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'INFP',
|
||||||
|
name: 'The Mediator',
|
||||||
|
nickname: 'Compassionate Idealist',
|
||||||
|
description: 'INFPs are creative, empathetic individuals guided by strong inner values. In education, they bring authenticity, creativity, and a deep understanding of each student\'s unique needs. They create safe, nurturing environments where students feel truly seen and valued.',
|
||||||
|
strengths: ['Creativity', 'Authentic empathy', 'Individual attention', 'Values alignment'],
|
||||||
|
workRelationships: 'INFPs value authenticity and kindness in their work relationships. They connect deeply with colleagues who share their values and passion for helping students. They prefer collaborative environments over competitive ones and thrive when they feel their unique contributions are appreciated. They may avoid conflict, so they benefit from colleagues who create safe spaces for honest dialogue.',
|
||||||
|
workplaceLanguage: 'INFPs communicate with sincerity and creativity. They use phrases like "I really believe..." or "What matters most here is..." Their language is personal and heartfelt. They may express ideas through stories, analogies, or creative examples rather than bullet points. They are excellent written communicators and may prefer email or notes over spontaneous verbal exchanges when discussing important topics.',
|
||||||
|
idealWorkEnvironment: 'Creative, supportive environments that honor individuality and personal growth',
|
||||||
|
communicationStyle: 'Sincere, creative, and personally meaningful',
|
||||||
|
color: 'from-pink-500 to-rose-600',
|
||||||
|
bgColor: 'bg-pink-500/10',
|
||||||
|
borderColor: 'border-pink-500/20',
|
||||||
|
icon: 'mediator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'ENFJ',
|
||||||
|
name: 'The Protagonist',
|
||||||
|
nickname: 'Inspiring Mentor',
|
||||||
|
description: 'ENFJs are charismatic, empathetic leaders who inspire others to reach their potential. In education, they naturally build strong teams, create positive cultures, and advocate passionately for their students and colleagues. They bring warmth, vision, and infectious optimism.',
|
||||||
|
strengths: ['Inspirational leadership', 'Team building', 'Empathetic communication', 'Cultural development'],
|
||||||
|
workRelationships: 'ENFJs are natural relationship builders who invest deeply in their colleagues\' growth and wellbeing. They create inclusive, supportive team dynamics and often serve as the emotional glue that holds teams together. They value harmony and work hard to resolve conflicts. They connect best with colleagues who are genuine, growth-oriented, and willing to contribute to a positive team culture. They should be mindful of not overextending themselves for others.',
|
||||||
|
workplaceLanguage: 'ENFJs communicate with warmth, enthusiasm, and encouragement. They use phrases like "I believe in you," "We can do this together," and "Let me help you with that." Their language is inclusive and motivating. They naturally affirm others and create psychological safety in conversations. They should be aware that their desire for harmony may sometimes prevent them from addressing difficult issues directly.',
|
||||||
|
idealWorkEnvironment: 'Collaborative, people-centered environments where they can mentor and inspire',
|
||||||
|
communicationStyle: 'Warm, encouraging, and inclusive',
|
||||||
|
color: 'from-orange-500 to-amber-600',
|
||||||
|
bgColor: 'bg-orange-500/10',
|
||||||
|
borderColor: 'border-orange-500/20',
|
||||||
|
icon: 'protagonist',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'ENFP',
|
||||||
|
name: 'The Campaigner',
|
||||||
|
nickname: 'Enthusiastic Connector',
|
||||||
|
description: 'ENFPs are creative, enthusiastic individuals who see potential everywhere and in everyone. In education, they bring energy, innovation, and an ability to connect with students on a personal level. They make learning exciting and help others discover their passions.',
|
||||||
|
strengths: ['Enthusiasm', 'Creative connections', 'Adaptability', 'Inspiring others'],
|
||||||
|
workRelationships: 'ENFPs build warm, energetic relationships with their colleagues. They are natural connectors who bring people together and create a fun, positive atmosphere. They value authenticity and freedom in their work relationships and connect best with colleagues who are open-minded, creative, and supportive. They may struggle with overly rigid or critical colleagues and need encouragement to follow through on their many ideas.',
|
||||||
|
workplaceLanguage: 'ENFPs communicate with energy, creativity, and personal warmth. They use phrases like "I have an amazing idea!" or "Imagine if we could..." Their language is expressive, enthusiastic, and often peppered with personal anecdotes. They think out loud and may jump between topics as connections spark in their mind. They should practice focusing their communication and following up on commitments they make in the excitement of the moment.',
|
||||||
|
idealWorkEnvironment: 'Flexible, creative environments that celebrate individuality and new ideas',
|
||||||
|
communicationStyle: 'Energetic, expressive, and personally engaging',
|
||||||
|
color: 'from-yellow-500 to-orange-500',
|
||||||
|
bgColor: 'bg-yellow-500/10',
|
||||||
|
borderColor: 'border-yellow-500/20',
|
||||||
|
icon: 'campaigner',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'ISTJ',
|
||||||
|
name: 'The Logistician',
|
||||||
|
nickname: 'Reliable Organizer',
|
||||||
|
description: 'ISTJs are dependable, thorough professionals who value tradition, responsibility, and doing things right. In education, they bring structure, consistency, and meticulous attention to detail. They are the backbone of any well-run classroom or school.',
|
||||||
|
strengths: ['Reliability', 'Attention to detail', 'Organizational skills', 'Consistency'],
|
||||||
|
workRelationships: 'ISTJs value reliability and professionalism in their colleagues. They build trust through consistent actions rather than words and prefer working with people who follow through on their commitments. They are loyal team members who can always be counted on. They connect best with colleagues who respect established procedures and communicate clearly. They may need to be more flexible with colleagues who have different working styles.',
|
||||||
|
workplaceLanguage: 'ISTJs communicate with clarity, precision, and factual accuracy. They use phrases like "According to the procedure..." or "The data shows..." Their language is straightforward and practical. They prefer clear, organized communication and may become frustrated with vague or overly emotional discussions. They are excellent at documenting processes and creating clear written instructions.',
|
||||||
|
idealWorkEnvironment: 'Structured, organized environments with clear expectations and procedures',
|
||||||
|
communicationStyle: 'Clear, factual, and procedure-oriented',
|
||||||
|
color: 'from-slate-500 to-gray-600',
|
||||||
|
bgColor: 'bg-slate-500/10',
|
||||||
|
borderColor: 'border-slate-500/20',
|
||||||
|
icon: 'logistician',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'ISFJ',
|
||||||
|
name: 'The Defender',
|
||||||
|
nickname: 'Nurturing Protector',
|
||||||
|
description: 'ISFJs are warm, dedicated individuals who quietly ensure everything runs smoothly and everyone is cared for. In education, they create stable, nurturing environments and go above and beyond for their students and colleagues. They are the unsung heroes of every school.',
|
||||||
|
strengths: ['Dedication', 'Nurturing care', 'Practical support', 'Attention to individual needs'],
|
||||||
|
workRelationships: 'ISFJs are loyal, supportive colleagues who remember the little things — birthdays, preferences, and personal challenges. They build relationships through acts of service and consistent care. They value harmony and work hard to maintain positive team dynamics. They connect best with colleagues who appreciate their contributions and reciprocate their thoughtfulness. They may need to practice saying no and setting boundaries to avoid burnout.',
|
||||||
|
workplaceLanguage: 'ISFJs communicate with warmth, practicality, and consideration. They use phrases like "How can I help?" or "I noticed you might need..." Their language is supportive and detail-oriented. They prefer gentle, respectful communication and may be hurt by blunt or critical feedback. They express care through actions more than words and may need encouragement to voice their own needs and opinions in team settings.',
|
||||||
|
idealWorkEnvironment: 'Stable, appreciative environments where their contributions are recognized',
|
||||||
|
communicationStyle: 'Warm, supportive, and detail-conscious',
|
||||||
|
color: 'from-teal-500 to-emerald-600',
|
||||||
|
bgColor: 'bg-teal-500/10',
|
||||||
|
borderColor: 'border-teal-500/20',
|
||||||
|
icon: 'defender',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'ESTJ',
|
||||||
|
name: 'The Executive',
|
||||||
|
nickname: 'Efficient Organizer',
|
||||||
|
description: 'ESTJs are organized, decisive leaders who value order, tradition, and getting things done. In education, they excel at managing operations, enforcing standards, and creating efficient systems. They bring structure and accountability to every team they join.',
|
||||||
|
strengths: ['Organization', 'Decisive action', 'Standards enforcement', 'Operational efficiency'],
|
||||||
|
workRelationships: 'ESTJs value competence, punctuality, and professionalism in their colleagues. They build respect through hard work and expect the same from others. They are natural organizers who take charge of projects and ensure deadlines are met. They connect best with colleagues who are responsible, direct, and committed to excellence. They should practice patience with colleagues who work at a different pace or have a more flexible approach.',
|
||||||
|
workplaceLanguage: 'ESTJs communicate with authority, clarity, and directness. They use phrases like "Here\'s what needs to happen," "The deadline is," and "Let\'s stay focused." Their language is task-oriented and efficient. They value clear agendas, action items, and follow-up. They may come across as bossy or inflexible, so they should practice acknowledging others\' input and being open to alternative approaches.',
|
||||||
|
idealWorkEnvironment: 'Well-organized environments with clear hierarchies and measurable goals',
|
||||||
|
communicationStyle: 'Direct, organized, and results-driven',
|
||||||
|
color: 'from-blue-600 to-indigo-700',
|
||||||
|
bgColor: 'bg-blue-600/10',
|
||||||
|
borderColor: 'border-blue-600/20',
|
||||||
|
icon: 'executive',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'ESFJ',
|
||||||
|
name: 'The Consul',
|
||||||
|
nickname: 'Caring Coordinator',
|
||||||
|
description: 'ESFJs are warm, social individuals who create harmony and ensure everyone feels included. In education, they excel at building community, coordinating events, and maintaining positive relationships with students, families, and colleagues. They are the heart of school culture.',
|
||||||
|
strengths: ['Community building', 'Social coordination', 'Inclusive leadership', 'Relationship maintenance'],
|
||||||
|
workRelationships: 'ESFJs are the social connectors of any team. They remember everyone\'s names, organize team celebrations, and make sure no one feels left out. They build relationships through genuine care and consistent follow-through. They value loyalty, cooperation, and positive team spirit. They connect best with colleagues who are appreciative, collaborative, and socially engaged. They may take criticism personally and need reassurance that they are valued.',
|
||||||
|
workplaceLanguage: 'ESFJs communicate with warmth, enthusiasm, and social awareness. They use phrases like "How is everyone feeling about this?" or "Let\'s make sure we include..." Their language is inclusive, encouraging, and relationship-focused. They are excellent at reading the room and adjusting their communication style to match their audience. They should practice being comfortable with constructive disagreement and not interpreting it as personal rejection.',
|
||||||
|
idealWorkEnvironment: 'Social, cooperative environments with strong team bonds and shared traditions',
|
||||||
|
communicationStyle: 'Warm, inclusive, and socially attuned',
|
||||||
|
color: 'from-rose-500 to-pink-600',
|
||||||
|
bgColor: 'bg-rose-500/10',
|
||||||
|
borderColor: 'border-rose-500/20',
|
||||||
|
icon: 'consul',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'ISTP',
|
||||||
|
name: 'The Virtuoso',
|
||||||
|
nickname: 'Practical Problem-Solver',
|
||||||
|
description: 'ISTPs are hands-on, adaptable individuals who excel at troubleshooting and finding practical solutions. In education, they bring a calm, resourceful presence and an ability to handle unexpected situations with ease. They are the go-to person when something needs fixing — literally or figuratively.',
|
||||||
|
strengths: ['Practical problem-solving', 'Crisis management', 'Hands-on skills', 'Calm under pressure'],
|
||||||
|
workRelationships: 'ISTPs value independence and mutual respect in their work relationships. They prefer colleagues who are competent, low-drama, and action-oriented. They build trust through demonstrated skill rather than social niceties. They are reliable in a crisis and appreciate colleagues who don\'t panic. They may seem reserved or detached, but they show care through practical actions — fixing things, solving problems, and stepping up when it matters.',
|
||||||
|
workplaceLanguage: 'ISTPs communicate with brevity and practicality. They use phrases like "Let me take a look at that," "Here\'s what I\'d do," or simply demonstrate solutions through action. Their language is minimal and to-the-point. They prefer showing over telling and may become impatient with lengthy discussions that don\'t lead to action. They should practice sharing their reasoning more openly so colleagues understand their thought process.',
|
||||||
|
idealWorkEnvironment: 'Hands-on environments with variety, autonomy, and real problems to solve',
|
||||||
|
communicationStyle: 'Brief, practical, and action-oriented',
|
||||||
|
color: 'from-zinc-500 to-stone-600',
|
||||||
|
bgColor: 'bg-zinc-500/10',
|
||||||
|
borderColor: 'border-zinc-500/20',
|
||||||
|
icon: 'virtuoso',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'ISFP',
|
||||||
|
name: 'The Adventurer',
|
||||||
|
nickname: 'Gentle Creative',
|
||||||
|
description: 'ISFPs are gentle, creative individuals who bring beauty and authenticity to everything they do. In education, they connect with students through creative expression, patience, and a genuine acceptance of each person\'s uniqueness. They create calm, aesthetically pleasing environments that promote learning.',
|
||||||
|
strengths: ['Creative expression', 'Gentle patience', 'Authentic connections', 'Aesthetic awareness'],
|
||||||
|
workRelationships: 'ISFPs value authenticity, kindness, and creative freedom in their work relationships. They connect best with colleagues who are genuine, non-judgmental, and respectful of personal space. They show care through thoughtful gestures and creative contributions rather than verbal expressions. They may avoid confrontation and need colleagues who create safe spaces for honest communication. They thrive when their unique creative contributions are noticed and appreciated.',
|
||||||
|
workplaceLanguage: 'ISFPs communicate with gentleness and sincerity. They use phrases like "I feel like..." or "What if we tried something different..." Their language is personal, understated, and often expressed through creative means — visual displays, handwritten notes, or carefully chosen words. They prefer one-on-one conversations and may become quiet in large group settings. They should be encouraged to share their creative ideas more openly.',
|
||||||
|
idealWorkEnvironment: 'Calm, aesthetically pleasing environments with creative freedom and personal space',
|
||||||
|
communicationStyle: 'Gentle, sincere, and creatively expressive',
|
||||||
|
color: 'from-violet-500 to-purple-600',
|
||||||
|
bgColor: 'bg-violet-500/10',
|
||||||
|
borderColor: 'border-violet-500/20',
|
||||||
|
icon: 'adventurer',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'ESTP',
|
||||||
|
name: 'The Entrepreneur',
|
||||||
|
nickname: 'Dynamic Doer',
|
||||||
|
description: 'ESTPs are energetic, action-oriented individuals who thrive in the moment. In education, they bring excitement, adaptability, and a hands-on approach that engages even the most reluctant learners. They are excellent at reading situations and responding quickly.',
|
||||||
|
strengths: ['Quick action', 'Situational awareness', 'Engaging energy', 'Practical adaptability'],
|
||||||
|
workRelationships: 'ESTPs build relationships through shared experiences and humor. They value colleagues who are fun, competent, and ready to jump into action. They prefer working with people who don\'t overthink things and can keep up with their fast pace. They are generous with their time and energy when they see a need. They may struggle with colleagues who are overly cautious or process-heavy and should practice patience with different working styles.',
|
||||||
|
workplaceLanguage: 'ESTPs communicate with energy, humor, and directness. They use phrases like "Let\'s just do it," "Watch this," or "I\'ve got an idea — follow me." Their language is action-oriented and often accompanied by physical demonstration. They are natural storytellers who use humor to connect and persuade. They should practice slowing down to listen fully before jumping to solutions and being more sensitive in their delivery of feedback.',
|
||||||
|
idealWorkEnvironment: 'Active, dynamic environments with variety and opportunities for hands-on engagement',
|
||||||
|
communicationStyle: 'Energetic, direct, and action-driven',
|
||||||
|
color: 'from-orange-600 to-red-600',
|
||||||
|
bgColor: 'bg-orange-600/10',
|
||||||
|
borderColor: 'border-orange-600/20',
|
||||||
|
icon: 'entrepreneur',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'ESFP',
|
||||||
|
name: 'The Entertainer',
|
||||||
|
nickname: 'Joyful Energizer',
|
||||||
|
description: 'ESFPs are vibrant, spontaneous individuals who bring joy and energy to every room they enter. In education, they create fun, engaging learning experiences and have a natural ability to connect with students through warmth, humor, and genuine enthusiasm.',
|
||||||
|
strengths: ['Joyful energy', 'Student engagement', 'Spontaneous creativity', 'Warm connections'],
|
||||||
|
workRelationships: 'ESFPs are the life of the staff room. They build relationships through shared laughter, spontaneous adventures, and genuine warmth. They value colleagues who are positive, fun-loving, and supportive. They create an atmosphere where people feel comfortable being themselves. They connect best with colleagues who appreciate their energy and don\'t try to dim their light. They may need help staying focused on long-term goals and following through on administrative tasks.',
|
||||||
|
workplaceLanguage: 'ESFPs communicate with enthusiasm, warmth, and expressiveness. They use phrases like "This is going to be so fun!" or "I love that idea!" Their language is animated, personal, and often accompanied by expressive gestures and facial expressions. They are natural entertainers who use humor and storytelling to engage their audience. They should practice being more concise in professional settings and balancing fun with focus during important discussions.',
|
||||||
|
idealWorkEnvironment: 'Fun, social environments with variety, teamwork, and opportunities to perform',
|
||||||
|
communicationStyle: 'Enthusiastic, expressive, and warmly engaging',
|
||||||
|
color: 'from-fuchsia-500 to-pink-600',
|
||||||
|
bgColor: 'bg-fuchsia-500/10',
|
||||||
|
borderColor: 'border-fuchsia-500/20',
|
||||||
|
icon: 'entertainer',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function calculateMBTI(answers: Record<number, string>): string {
|
||||||
|
const dimensions = { E: 0, I: 0, S: 0, N: 0, T: 0, F: 0, J: 0, P: 0 };
|
||||||
|
|
||||||
|
PERSONALITY_QUIZ_QUESTIONS.forEach((q) => {
|
||||||
|
const answer = answers[q.id];
|
||||||
|
if (answer) {
|
||||||
|
dimensions[answer as keyof typeof dimensions]++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const e_i = dimensions.E >= dimensions.I ? 'E' : 'I';
|
||||||
|
const s_n = dimensions.S >= dimensions.N ? 'S' : 'N';
|
||||||
|
const t_f = dimensions.T >= dimensions.F ? 'T' : 'F';
|
||||||
|
const j_p = dimensions.J >= dimensions.P ? 'J' : 'P';
|
||||||
|
|
||||||
|
return `${e_i}${s_n}${t_f}${j_p}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPersonalityType(code: string): PersonalityType | undefined {
|
||||||
|
return PERSONALITY_TYPES.find((t) => t.code === code);
|
||||||
|
}
|
||||||
10
src/lib/supabase.ts
Normal file
10
src/lib/supabase.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
|
||||||
|
// Initialize database client
|
||||||
|
const supabaseUrl = 'https://upurijfcidrqqisrvsxi.databasepad.com';
|
||||||
|
const supabaseKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjE1ZDE4YzdiLTdkMGEtNGJhOC1hODgyLWQ0YmU5MGI1ZjI1YyJ9.eyJwcm9qZWN0SWQiOiJ1cHVyaWpmY2lkcnFxaXNydnN4aSIsInJvbGUiOiJhbm9uIiwiaWF0IjoxNzcwNjU2MjY2LCJleHAiOjIwODYwMTYyNjYsImlzcyI6ImZhbW91cy5kYXRhYmFzZXBhZCIsImF1ZCI6ImZhbW91cy5jbGllbnRzIn0.VMKexiDKtnwNDyLsZlErKfFJFcUOE6UX-YJm_4sbQsE';
|
||||||
|
const supabase = createClient(supabaseUrl, supabaseKey);
|
||||||
|
|
||||||
|
|
||||||
|
export { supabase };
|
||||||
191
src/lib/types.ts
Normal file
191
src/lib/types.ts
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
export type UserRole = 'teacher' | 'para' | 'office' | 'director' | 'superintendent';
|
||||||
|
|
||||||
|
|
||||||
|
export type CampusId = 'tigers' | 'gators' | 'hawks' | 'owls' | 'wildcats' | 'grizzlies';
|
||||||
|
|
||||||
|
export interface CampusInfo {
|
||||||
|
id: CampusId;
|
||||||
|
mascot: string;
|
||||||
|
fullName: string;
|
||||||
|
color: string;
|
||||||
|
bgGradient: string;
|
||||||
|
borderColor: string;
|
||||||
|
textColor: string;
|
||||||
|
bgLight: string;
|
||||||
|
description: string;
|
||||||
|
isOnline?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
name: string;
|
||||||
|
role: UserRole;
|
||||||
|
avatar: string;
|
||||||
|
campus: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StaffProfile {
|
||||||
|
id: string;
|
||||||
|
full_name: string;
|
||||||
|
role: UserRole;
|
||||||
|
campus: string;
|
||||||
|
avatar_url: string | null;
|
||||||
|
personality_type?: string | null;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export interface FrameEntry {
|
||||||
|
id: string;
|
||||||
|
weekOf: string;
|
||||||
|
postedDate: string;
|
||||||
|
formal: string;
|
||||||
|
recognition: string;
|
||||||
|
application: string;
|
||||||
|
management: string;
|
||||||
|
emotional: string;
|
||||||
|
author: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Strategy {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
category: 'visual-support' | 'transition' | 'sensory' | 'communication' | 'behavior' | 'social';
|
||||||
|
ageGroup: 'K-2' | '3-5' | '6-8' | 'All';
|
||||||
|
zone: 'blue' | 'green' | 'yellow' | 'red';
|
||||||
|
image: string;
|
||||||
|
isFavorite?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuizQuestion {
|
||||||
|
id: string;
|
||||||
|
question: string;
|
||||||
|
options: string[];
|
||||||
|
correctIndex: number;
|
||||||
|
explanation: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SafetyQuiz {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
focus: 'physical-management' | 'de-escalation' | 'safety-reminders';
|
||||||
|
questions: QuizQuestion[];
|
||||||
|
weekOf: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignVideoStep {
|
||||||
|
step: number;
|
||||||
|
instruction: string;
|
||||||
|
duration: number; // seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignItem {
|
||||||
|
id: string;
|
||||||
|
word: string;
|
||||||
|
category: 'basic-needs' | 'emotional' | 'classroom';
|
||||||
|
description: string;
|
||||||
|
image: string;
|
||||||
|
tip: string;
|
||||||
|
videoUrl: string; // YouTube embed URL for the sign demonstration
|
||||||
|
gifUrl: string; // Animated GIF URL showing the sign demonstration (always works)
|
||||||
|
videoSteps: SignVideoStep[];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export interface Event {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
date: string;
|
||||||
|
type: 'meeting' | 'drill' | 'event' | 'deadline';
|
||||||
|
roles: UserRole[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AttendanceRecord {
|
||||||
|
date: string;
|
||||||
|
status: 'present' | 'late' | 'absent';
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParentMessage {
|
||||||
|
id: string;
|
||||||
|
template: string;
|
||||||
|
category: 'behavior' | 'event' | 'progress' | 'general';
|
||||||
|
lastSent?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ZoneColor = 'blue' | 'green' | 'yellow' | 'red';
|
||||||
|
|
||||||
|
export interface ZoneInfo {
|
||||||
|
color: ZoneColor;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
behaviors: string[];
|
||||||
|
strategies: string[];
|
||||||
|
signs: string[];
|
||||||
|
bgClass: string;
|
||||||
|
textClass: string;
|
||||||
|
borderClass: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ModuleId =
|
||||||
|
| 'dashboard'
|
||||||
|
| 'frame'
|
||||||
|
| 'classroom'
|
||||||
|
| 'timer'
|
||||||
|
| 'qbs'
|
||||||
|
| 'ei'
|
||||||
|
| 'zones'
|
||||||
|
| 'signs'
|
||||||
|
| 'attendance'
|
||||||
|
| 'parent-comm'
|
||||||
|
| 'internal-comm'
|
||||||
|
| 'safety'
|
||||||
|
| 'handbook'
|
||||||
|
| 'director'
|
||||||
|
| 'community'
|
||||||
|
| 'vocational'
|
||||||
|
| 'esa'
|
||||||
|
| 'walkthrough';
|
||||||
|
|
||||||
|
export interface WalkthroughCheckin {
|
||||||
|
id: string;
|
||||||
|
teacher_name: string;
|
||||||
|
classroom: string;
|
||||||
|
director_name: string;
|
||||||
|
check_in_date: string;
|
||||||
|
check_in_time: string;
|
||||||
|
attitude_rating: number;
|
||||||
|
attitude_comment?: string;
|
||||||
|
classroom_management_rating: number;
|
||||||
|
classroom_management_comment?: string;
|
||||||
|
cleanliness_rating: number;
|
||||||
|
cleanliness_comment?: string;
|
||||||
|
vibes_rating: number;
|
||||||
|
vibes_comment?: string;
|
||||||
|
team_dynamics_rating: number;
|
||||||
|
team_dynamics_comment?: string;
|
||||||
|
emergency_exit_rating: number;
|
||||||
|
emergency_exit_comment?: string;
|
||||||
|
lesson_plan_rating: number;
|
||||||
|
lesson_plan_comment?: string;
|
||||||
|
overall_notes?: string;
|
||||||
|
created_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export interface Module {
|
||||||
|
id: ModuleId;
|
||||||
|
name: string;
|
||||||
|
icon: string;
|
||||||
|
roles: UserRole[];
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
7
src/main.tsx
Normal file
7
src/main.tsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import App from './App.tsx'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
// Remove dark mode class addition
|
||||||
|
createRoot(document.getElementById("root")!).render(<App />);
|
||||||
16
src/pages/Index.tsx
Normal file
16
src/pages/Index.tsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import AppLayout from '@/components/AppLayout';
|
||||||
|
import { AppProvider } from '@/contexts/AppContext';
|
||||||
|
import { AuthProvider } from '@/contexts/AuthContext';
|
||||||
|
|
||||||
|
const Index: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<AppProvider>
|
||||||
|
<AppLayout />
|
||||||
|
</AppProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Index;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user