Initial import

This commit is contained in:
Flatlogic Bot 2026-02-24 15:03:51 +00:00
commit 725af8a3ea
106 changed files with 26778 additions and 0 deletions

30
Migdalor-main/.gitignore vendored Normal file
View File

@ -0,0 +1,30 @@
#env
.env
.env.*
# 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?
.env

1
Migdalor-main/README.md Normal file
View File

@ -0,0 +1 @@
Migdalor

View File

@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": false,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

View File

@ -0,0 +1,60 @@
import globals from "globals";
import pluginJs from "@eslint/js";
import pluginReact from "eslint-plugin-react";
import pluginReactHooks from "eslint-plugin-react-hooks";
import pluginUnusedImports from "eslint-plugin-unused-imports";
export default [
{
files: [
"src/components/**/*.{js,mjs,cjs,jsx}",
"src/pages/**/*.{js,mjs,cjs,jsx}",
"src/Layout.jsx",
],
ignores: ["src/lib/**/*", "src/components/ui/**/*"],
...pluginJs.configs.recommended,
...pluginReact.configs.flat.recommended,
languageOptions: {
globals: globals.browser,
parserOptions: {
ecmaVersion: 2022,
sourceType: "module",
ecmaFeatures: {
jsx: true,
},
},
},
settings: {
react: {
version: "detect",
},
},
plugins: {
react: pluginReact,
"react-hooks": pluginReactHooks,
"unused-imports": pluginUnusedImports,
},
rules: {
"no-unused-vars": "off",
"react/jsx-uses-vars": "error",
"react/jsx-uses-react": "error",
"unused-imports/no-unused-imports": "error",
"unused-imports/no-unused-vars": [
"warn",
{
vars: "all",
varsIgnorePattern: "^_",
args: "after-used",
argsIgnorePattern: "^_",
},
],
"react/prop-types": "off",
"react/react-in-jsx-scope": "off",
"react/no-unknown-property": [
"error",
{ ignore: ["cmdk-input-wrapper", "toast-close"] },
],
"react-hooks/rules-of-hooks": "error",
},
},
];

14
Migdalor-main/index.html Normal file
View File

@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="https://base44.com/logo_v2.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="manifest" href="/manifest.json" />
<title>Base44 APP</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

View File

@ -0,0 +1,21 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"jsx": "react-jsx",
"module": "esnext",
"moduleResolution": "bundler",
"lib": ["esnext", "dom"],
"target": "esnext",
"checkJs": true,
"skipLibCheck": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"types": []
},
"include": ["src/components/**/*.js", "src/pages/**/*.jsx", "src/Layout.jsx"],
"exclude": ["node_modules", "dist", "src/vite-plugins", "src/components/ui", "src/api", "src/lib"]
}

10133
Migdalor-main/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

100
Migdalor-main/package.json Normal file
View File

@ -0,0 +1,100 @@
{
"name": "base44-app",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint . --quiet",
"lint:fix": "eslint . --fix",
"typecheck": "tsc -p ./jsconfig.json",
"preview": "vite preview"
},
"dependencies": {
"@base44/sdk": "^0.8.3",
"@base44/vite-plugin": "^0.2.9",
"@hello-pangea/dnd": "^17.0.0",
"@hookform/resolvers": "^4.1.2",
"@radix-ui/react-accordion": "^1.2.3",
"@radix-ui/react-alert-dialog": "^1.1.6",
"@radix-ui/react-aspect-ratio": "^1.1.2",
"@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-collapsible": "^1.1.3",
"@radix-ui/react-context-menu": "^2.2.6",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-hover-card": "^1.1.6",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-menubar": "^1.1.6",
"@radix-ui/react-navigation-menu": "^1.2.5",
"@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-progress": "^1.1.2",
"@radix-ui/react-radio-group": "^1.2.3",
"@radix-ui/react-scroll-area": "^1.2.3",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slider": "^1.2.3",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tabs": "^1.1.3",
"@radix-ui/react-toast": "^1.2.2",
"@radix-ui/react-toggle": "^1.1.2",
"@radix-ui/react-toggle-group": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8",
"@stripe/react-stripe-js": "^3.0.0",
"@stripe/stripe-js": "^5.2.0",
"@tanstack/react-query": "^5.84.1",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"date-fns": "^3.6.0",
"embla-carousel-react": "^8.5.2",
"framer-motion": "^11.16.4",
"html2canvas": "^1.4.1",
"input-otp": "^1.4.2",
"jspdf": "^2.5.2",
"lodash": "^4.17.21",
"lucide-react": "^0.475.0",
"moment": "^2.30.1",
"next-themes": "^0.4.4",
"react": "^18.2.0",
"react-day-picker": "^8.10.1",
"react-dom": "^18.2.0",
"react-hook-form": "^7.54.2",
"react-hot-toast": "^2.6.0",
"react-leaflet": "^4.2.1",
"react-markdown": "^9.0.1",
"react-quill": "^2.0.0",
"react-resizable-panels": "^2.1.7",
"react-router-dom": "^6.26.0",
"recharts": "^2.15.4",
"sonner": "^2.0.1",
"tailwind-merge": "^3.0.2",
"tailwindcss-animate": "^1.0.7",
"three": "^0.171.0",
"vaul": "^1.1.2",
"zod": "^3.24.2"
},
"devDependencies": {
"@eslint/js": "^9.19.0",
"@types/node": "^22.13.5",
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"baseline-browser-mapping": "^2.8.32",
"eslint": "^9.19.0",
"eslint-plugin-react": "^7.37.4",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.18",
"eslint-plugin-unused-imports": "^4.3.0",
"globals": "^15.14.0",
"postcss": "^8.5.3",
"tailwindcss": "^3.4.17",
"typescript": "^5.8.2",
"vite": "^6.1.0"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

85
Migdalor-main/src/App.jsx Normal file
View File

@ -0,0 +1,85 @@
import './App.css'
import { Toaster } from "@/components/ui/toaster"
import { QueryClientProvider } from '@tanstack/react-query'
import { queryClientInstance } from '@/lib/query-client'
import VisualEditAgent from '@/lib/VisualEditAgent'
import NavigationTracker from '@/lib/NavigationTracker'
import { pagesConfig } from './pages.config'
import { BrowserRouter as Router, Route, Routes } from 'react-router-dom';
import PageNotFound from './lib/PageNotFound';
import { AuthProvider, useAuth } from '@/lib/AuthContext';
import UserNotRegisteredError from '@/components/UserNotRegisteredError';
const { Pages, Layout, mainPage } = pagesConfig;
const mainPageKey = mainPage ?? Object.keys(Pages)[0];
const MainPage = mainPageKey ? Pages[mainPageKey] : <></>;
const LayoutWrapper = ({ children, currentPageName }) => Layout ?
<Layout currentPageName={currentPageName}>{children}</Layout>
: <>{children}</>;
const AuthenticatedApp = () => {
const { isLoadingAuth, isLoadingPublicSettings, authError, isAuthenticated, navigateToLogin } = useAuth();
// Show loading spinner while checking app public settings or auth
if (isLoadingPublicSettings || isLoadingAuth) {
return (
<div className="fixed inset-0 flex items-center justify-center">
<div className="w-8 h-8 border-4 border-slate-200 border-t-slate-800 rounded-full animate-spin"></div>
</div>
);
}
// Handle authentication errors
if (authError) {
if (authError.type === 'user_not_registered') {
return <UserNotRegisteredError />;
} else if (authError.type === 'auth_required') {
// Redirect to login automatically
navigateToLogin();
return null;
}
}
// Render the main app
return (
<Routes>
<Route path="/" element={
<LayoutWrapper currentPageName={mainPageKey}>
<MainPage />
</LayoutWrapper>
} />
{Object.entries(Pages).map(([path, Page]) => (
<Route
key={path}
path={`/${path}`}
element={
<LayoutWrapper currentPageName={path}>
<Page />
</LayoutWrapper>
}
/>
))}
<Route path="*" element={<PageNotFound />} />
</Routes>
);
};
function App() {
return (
<AuthProvider>
<QueryClientProvider client={queryClientInstance}>
<Router>
<NavigationTracker />
<AuthenticatedApp />
</Router>
<Toaster />
<VisualEditAgent />
</QueryClientProvider>
</AuthProvider>
)
}
export default App

View File

@ -0,0 +1,293 @@
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { createPageUrl } from './utils';
import { base44 } from '@/api/base44Client';
import { Key, Users, Settings, LayoutDashboard, Calendar, Target, LogOut, ChevronDown, Shield, Briefcase, MapPin, Image, Database } from 'lucide-react';
import { Toaster } from "@/components/ui/sonner";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export default function Layout({ children, currentPageName }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [userPermissions, setUserPermissions] = useState(null);
useEffect(() => {
const loadUserData = async () => {
try {
const user = await base44.auth.me();
setUser(user);
// Redirect to onboarding if not completed
if (!user.onboarding_completed && currentPageName !== 'Onboarding') {
window.location.href = createPageUrl('Onboarding');
}
// Load user permissions based on positions
if (user.positions && user.positions.length > 0) {
const allPermissions = await base44.entities.PositionPermission.list();
console.log('🔍 All permissions:', allPermissions);
console.log('👤 User positions:', user.positions);
const userPositionPerms = allPermissions.filter(p =>
user.positions.includes(p.position_name)
);
console.log('✅ Matched permissions:', userPositionPerms);
// Merge all permissions
const mergedPerms = {
has_classroom_management_access: userPositionPerms.some(p => p.has_classroom_management_access),
pages_access: [...new Set(userPositionPerms.flatMap(p => p.pages_access || []))]
};
console.log('📋 Final permissions:', mergedPerms);
setUserPermissions(mergedPerms);
} else {
console.log('⚠️ No positions found for user');
}
setLoading(false);
} catch (error) {
setLoading(false);
}
};
loadUserData();
}, [currentPageName]);
// Show loading while checking onboarding status
if (loading) {
return (
<div className="min-h-screen bg-slate-50 flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-slate-800"></div>
</div>
);
}
// Hide navigation for onboarding and MyProfile pages
if (currentPageName === 'Onboarding' || currentPageName === 'MyProfile') {
return <div className="min-h-screen bg-slate-50">{children}</div>;
}
const isAdmin = user?.role === 'admin';
// Check if we're in the "User Management" area
const isUserManagementArea = currentPageName === 'ManageUsers' || currentPageName === 'ManagePermissions' || currentPageName === 'ManagePositions';
const isHackalonArea = ['HackalonSchedule', 'HackalonOverview', 'HackalonAssignment', 'HackalonTeamArea', 'HackalonManageProblems', 'HackalonStatus'].includes(currentPageName);
// Redirect to HackalonSchedule when clicking on any Hackalon nav item if not already in Hackalon area
if (isHackalonArea && currentPageName !== 'HackalonSchedule' && !window.location.search) {
// This allows direct navigation to specific Hackalon pages via URL
}
// Classroom Management Navigation
const allNavItems = [
{ name: 'לוח בקרה', icon: LayoutDashboard, page: 'Dashboard', tooltip: 'לוח בקרה' },
{ name: 'תמונת מצב', icon: Image, page: 'DailyOverview', tooltip: 'תמונת מצב' },
{ name: 'לוח הזמנים שלי', icon: Calendar, page: 'MySchedule', tooltip: 'לוח זמנים' },
{ name: 'הקצאת מפתחות', icon: Target, page: 'KeyAllocation', tooltip: 'הקצאה' },
{ name: 'מפתחות', icon: Key, page: 'ManageKeys', tooltip: 'מפתחות' },
];
// Filter nav items based on permissions
const getFilteredNavItems = () => {
if (isAdmin) {
return allNavItems;
}
if (!userPermissions?.has_classroom_management_access) {
return [];
}
// If user has classroom management access, show all non-admin items
// Or filter by specific pages if pages_access is defined and not empty
if (userPermissions.pages_access && userPermissions.pages_access.length > 0) {
return allNavItems.filter(item => {
if (item.adminOnly) return false;
return userPermissions.pages_access.includes(item.page);
});
} else {
// Show all non-admin items if pages_access is not specified
return allNavItems.filter(item => !item.adminOnly);
}
};
const adminNavItems = isAdmin ? allNavItems : [];
const userNavItems = getFilteredNavItems();
// User Management Navigation (for admins only)
const userManagementNavItems = [
{ name: 'משתמשים', icon: Users, page: 'ManageUsers', tooltip: 'ניהול משתמשים' },
{ name: 'תפקידים', icon: Briefcase, page: 'ManagePositions', tooltip: 'ניהול תפקידים' },
{ name: 'הרשאות', icon: Shield, page: 'ManagePermissions', tooltip: 'ניהול הרשאות תפקידים' },
];
// HackAlon Navigation - Default page is HackalonSchedule
const allHackalonNavItems = [
{ name: 'לוח זמנים', icon: Calendar, page: 'HackalonSchedule', tooltip: 'לוח זמנים ואירועים' },
{ name: 'אזור הצוות', icon: Users, page: 'HackalonTeamArea', tooltip: 'אזור הצוות שלי' },
{ name: 'סקירה', icon: LayoutDashboard, page: 'HackalonOverview', tooltip: 'סקירה כללית' },
{ name: 'תמונת מצב', icon: Image, page: 'HackalonStatus', tooltip: 'מעקב אחר העלאות'},
{ name: 'שיבוץ צוערים', icon: Users, page: 'HackalonAssignment', tooltip: 'שיבוץ למדורים וצוותים', adminOnly: true },
{ name: 'ניהול בעיות', icon: Settings, page: 'HackalonManageProblems', tooltip: 'הגדרת בעיות לצוותים', adminOnly: true },
];
// Filter HackAlon nav items based on permissions
const getFilteredHackalonItems = () => {
if (isAdmin) {
return allHackalonNavItems;
}
const allowedPositions = ['מפק״ץ', 'מנהל האקתון'];
const hasAllowedPosition = user?.positions?.some(pos => allowedPositions.includes(pos));
return allHackalonNavItems.filter(item => {
// Always show non-admin items
if (!item.adminOnly) {
// For Overview and Status pages, check special permissions
if (item.page === 'HackalonOverview' || item.page === 'HackalonStatus') {
return hasAllowedPosition;
}
return true;
}
// For admin-only items, check permissions
if (!userPermissions?.pages_access) return false;
return userPermissions.pages_access.includes(item.page);
});
};
const hackalonNavItems = getFilteredHackalonItems();
const allManagementItems = [
{ name: 'פלוגות', page: 'ManageCrews', icon: Shield },
{ name: 'צוותים', page: 'ManageSquads', icon: Users },
{ name: 'אזורים', page: 'ManageZones', icon: MapPin, adminOnly: true },
];
// Filter management items based on permissions
const managementItems = isAdmin
? allManagementItems
: allManagementItems.filter(item => {
if (item.adminOnly) return false;
if (!userPermissions?.has_classroom_management_access) return false;
// If pages_access exists and has items, filter by it; otherwise show all non-admin items
if (userPermissions.pages_access && userPermissions.pages_access.length > 0) {
return userPermissions.pages_access.includes(item.page);
}
return true;
});
const managementPages = managementItems.map(item => item.page);
const navItems = isUserManagementArea ? userManagementNavItems :
isHackalonArea ? hackalonNavItems :
(isAdmin ? adminNavItems : userNavItems);
const isManagementPage = managementPages.includes(currentPageName);
// Show management dropdown only in classroom area
const showManagementDropdown = !isUserManagementArea && !isHackalonArea;
// Hide navigation for Home page
if (currentPageName === 'Home') {
return <div className="min-h-screen bg-slate-50">{children}</div>;
}
return (
<div className="min-h-screen bg-slate-50" dir="rtl">
{/* Top Navigation */}
<nav className="bg-white border-b border-slate-200 sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-start h-16">
{/* Logo */}
<Link to={createPageUrl('Home')} className="flex items-center gap-3">
<img
src="https://qtrypzzcjebvfcihiynt.supabase.co/storage/v1/object/public/base44-prod/public/693b00a201212578d09f8396/9732960ed_8.png"
alt="מגדלור לוגו"
className="w-12 h-12 object-contain"
/>
</Link>
{/* Nav Links */}
<div className="flex items-center gap-1">
{navItems.map((item) => {
const isActive = currentPageName === item.page;
return (
<Link
key={item.page}
to={createPageUrl(item.page)}
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-all group relative ${
isActive
? 'bg-slate-100 text-slate-900'
: 'text-slate-500 hover:text-slate-700 hover:bg-slate-50'
}`}
title={item.tooltip}
>
<item.icon className="w-4 h-4" />
<span className="hidden sm:block text-sm font-medium">{item.name}</span>
{/* Mobile tooltip */}
<span className="sm:hidden absolute bottom-full mb-2 left-1/2 -translate-x-1/2 px-2 py-1 bg-slate-800 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none">
{item.tooltip}
</span>
</Link>
);
})}
{/* Management Dropdown - Only show in classroom area */}
{showManagementDropdown && (
<DropdownMenu dir="rtl">
<DropdownMenuTrigger asChild>
<button
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-all group relative ${
isManagementPage
? 'bg-slate-100 text-slate-900'
: 'text-slate-500 hover:text-slate-700 hover:bg-slate-50'
}`}
title="ניהול"
>
<Users className="w-4 h-4" />
<span className="hidden sm:flex items-center gap-1 text-sm font-medium">
ניהול
<ChevronDown className="w-3 h-3" />
</span>
{/* Mobile tooltip */}
<span className="sm:hidden absolute bottom-full mb-2 left-1/2 -translate-x-1/2 px-2 py-1 bg-slate-800 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none">
ניהול
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{managementItems.map((item) => (
<DropdownMenuItem key={item.page} asChild>
<Link
to={createPageUrl(item.page)}
className={`cursor-pointer flex items-center gap-2 ${currentPageName === item.page ? 'bg-slate-100' : ''}`}
>
<item.icon className="w-4 h-4" />
{item.name}
</Link>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
</div>
</nav>
{/* Main Content */}
<main>{children}</main>
{/* Toast notifications */}
<Toaster position="top-right" />
</div>
);
}

View File

@ -0,0 +1,13 @@
import { createClient } from '@base44/sdk';
import { appParams } from '@/lib/app-params';
const { appId, serverUrl, token, functionsVersion } = appParams;
//Create a client with authentication required
export const base44 = createClient({
appId,
serverUrl,
token,
functionsVersion,
requiresAuth: false
});

View File

@ -0,0 +1,9 @@
import { base44 } from './base44Client';
export const Query = base44.entities.Query;
// auth sdk:
export const User = base44.auth;

View File

@ -0,0 +1,24 @@
import { base44 } from './base44Client';
export const Core = base44.integrations.Core;
export const InvokeLLM = base44.integrations.Core.InvokeLLM;
export const SendEmail = base44.integrations.Core.SendEmail;
export const SendSMS = base44.integrations.Core.SendSMS;
export const UploadFile = base44.integrations.Core.UploadFile;
export const GenerateImage = base44.integrations.Core.GenerateImage;
export const ExtractDataFromUploadedFile = base44.integrations.Core.ExtractDataFromUploadedFile;

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -0,0 +1,31 @@
import React from 'react';
const UserNotRegisteredError = () => {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gradient-to-b from-white to-slate-50">
<div className="max-w-md w-full p-8 bg-white rounded-lg shadow-lg border border-slate-100">
<div className="text-center">
<div className="inline-flex items-center justify-center w-16 h-16 mb-6 rounded-full bg-orange-100">
<svg className="w-8 h-8 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<h1 className="text-3xl font-bold text-slate-900 mb-4">Access Restricted</h1>
<p className="text-slate-600 mb-8">
You are not registered to use this application. Please contact the app administrator to request access.
</p>
<div className="p-4 bg-slate-50 rounded-md text-sm text-slate-600">
<p>If you believe this is an error, you can:</p>
<ul className="list-disc list-inside mt-2 space-y-1">
<li>Verify you are logged in with the correct account</li>
<li>Contact the app administrator for access</li>
<li>Try logging out and back in again</li>
</ul>
</div>
</div>
</div>
</div>
);
};
export default UserNotRegisteredError;

View File

@ -0,0 +1,200 @@
import React, { useState } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription } from
"@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue } from
"@/components/ui/select";
import { Key, Users, AlertCircle } from 'lucide-react';
import { toast } from 'sonner';
export default function CheckoutModal({ open, onClose, keyItem, crews, squads, currentUser, onConfirm, selectedDate }) {
const [selectedCrew, setSelectedCrew] = useState('');
const [platoonName, setPlatoonName] = useState('');
const [customName, setCustomName] = useState('');
const [useCustom, setUseCustom] = useState(false);
const [startTime, setStartTime] = useState('');
const [endTime, setEndTime] = useState('23:59');
// Prefill times if provided
React.useEffect(() => {
if (keyItem?.prefilledTimes) {
setStartTime(keyItem.prefilledTimes.start);
setEndTime(keyItem.prefilledTimes.end);
}
}, [keyItem]);
const handleConfirm = () => {
const holderName = useCustom ? customName : selectedCrew;
if (holderName && startTime && endTime) {
// Check if time has already passed (only for today)
const today = new Date().toISOString().split('T')[0];
if (selectedDate === today) {
const now = new Date();
const currentTime = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
if (startTime < currentTime) {
toast.error('לא ניתן למשוך מפתח בזמן שכבר חלף');
return;
}
}
onConfirm(keyItem, holderName, startTime, endTime, platoonName);
setSelectedCrew('');
setPlatoonName('');
setCustomName('');
setUseCustom(false);
setStartTime('');
setEndTime('23:59');
}
};
if (!keyItem) return null;
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent
dir="rtl"
className="sm:max-w-md text-right">
<DialogHeader className="text-right">
<DialogTitle className="text-lg font-semibold leading-none tracking-tight flex flex-row-reverse items-center gap-2 justify-end">משוך מפתח
</DialogTitle>
<DialogDescription className="text-right">
משיכת מפתח לחדר {keyItem.room_number} ({keyItem.room_type})
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{!useCustom &&
<div className="space-y-2">
<Label className="peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-sm font-medium">החדר עבור *</Label>
<select
value={selectedCrew}
onChange={(e) => {
const selectedValue = e.target.value;
setSelectedCrew(selectedValue);
// Check if a squad was selected and auto-fill platoon name
const selectedSquad = squads?.find((s) => s.squad_number === selectedValue);
setPlatoonName(selectedSquad ? selectedSquad.platoon_name : '');
}}
className="w-full px-3 py-2 border border-slate-300 rounded-md text-right">
<option value="">בחר פלוגה או צוות...</option>
{crews && crews.length > 0 && (
<optgroup label="פלוגות">
{crews.map((crew) => (
<option key={crew.id} value={crew.name}>{crew.name}</option>
))}
</optgroup>
)}
{squads && squads.length > 0 && (
<optgroup label="צוותים">
{squads.map((squad) => (
<option key={squad.id} value={squad.squad_number}>
{squad.squad_number} {squad.platoon_name ? `(${squad.platoon_name})` : ''}
</option>
))}
</optgroup>
)}
</select>
<Button
variant="ghost"
size="sm" className="inline-flex items-center justify-center gap-2 whitespace-nowrap font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 hover:bg-accent hover:text-accent-foreground h-8 rounded-md px-3 text-slate-500 text-xs w-full"
onClick={() => setUseCustom(true)}>
הזן שם ידנית
</Button>
</div>
}
{useCustom &&
<div className="space-y-2">
<Label className="peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-sm font-medium">החדר עבור *</Label>
<Input
placeholder="למשל, סגל"
value={customName}
onChange={(e) => setCustomName(e.target.value)} />
<Button
variant="ghost"
size="sm" className="inline-flex items-center justify-center gap-2 whitespace-nowrap font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 hover:bg-accent hover:text-accent-foreground h-8 rounded-md px-3 text-slate-500 text-xs w-full"
onClick={() => setUseCustom(false)}>
בחר מאפשרויות קיימות
</Button>
</div>
}
<div className="space-y-4 border-t pt-4">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label className="text-sm font-medium">שעת התחלה *</Label>
<Input
type="time"
value={startTime}
onChange={(e) => setStartTime(e.target.value)} />
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">שעת סיום *</Label>
<Input
type="time"
value={endTime}
onChange={(e) => setEndTime(e.target.value)} />
</div>
</div>
{endTime === '23:59' &&
<div className="bg-orange-50 border border-orange-200 rounded-lg p-3 flex items-start gap-2">
<AlertCircle className="w-4 h-4 text-orange-600 mt-0.5 flex-shrink-0" />
<p className="text-xs text-orange-800">
<strong>חשוב:</strong> אם לא ציינת שעת סיום מדויקת, יש לסמן את המפתח כהוחזר בעצמך כאשר מחזירים אותו.
</p>
</div>
}
</div>
</div>
<div className="flex gap-3">
<Button
onClick={handleConfirm}
disabled={(useCustom ? !customName : !selectedCrew) || !startTime || !endTime}
className="flex-1 bg-emerald-600 hover:bg-emerald-700">
אשר משיכה
</Button>
<Button variant="outline" onClick={onClose} className="flex-1">
ביטול
</Button>
</div>
</DialogContent>
</Dialog>);
}

View File

@ -0,0 +1,97 @@
import React from 'react';
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Key, User, Clock, ArrowRight, Monitor } from 'lucide-react';
import { format } from 'date-fns';
import { motion } from 'framer-motion';
export default function KeyCard({ keyItem, onCheckout, onReturn, crews, currentUser, currentHolder, isAvailableInTimeRange, timeFilterActive }) {
// If time filter is active, use time-range availability, otherwise use current status
const isAvailable = timeFilterActive ? isAvailableInTimeRange : (keyItem.status === 'available' && !currentHolder);
const isAdmin = currentUser?.role === 'admin';
const canReturn = !timeFilterActive && (!isAvailable && (isAdmin || keyItem.checked_out_by === currentUser?.email));
const displayHolder = currentHolder || keyItem.current_holder;
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<Card className={`p-5 border-2 transition-all duration-300 hover:shadow-lg ${
isAvailable
? 'border-emerald-200 bg-gradient-to-br from-emerald-50/50 to-white'
: 'border-amber-200 bg-gradient-to-br from-amber-50/50 to-white'
}`}>
<div dir="ltr" className="flex flex-row items-start justify-between mb-4">
<Badge className={`${
isAvailable
? 'bg-emerald-500 hover:bg-emerald-600'
: 'bg-amber-500 hover:bg-amber-600'
}`}>
{isAvailable ? 'זמין' : 'תפוס'}
</Badge>
<div className="flex flex-row items-center gap-3">
<div className="text-right">
<h3 className="font-semibold text-slate-800 text-lg flex items-center gap-2 justify-end">
{keyItem.has_computers && (
<Monitor className="w-4 h-4 text-blue-600" />
)}
חדר {keyItem.room_number}
</h3>
<div className="flex justify-end">
<Badge variant="outline" className={`mt-1 ${
keyItem.room_type === 'פלוגתי'
? 'border-purple-300 text-purple-700 bg-purple-50'
: 'border-blue-300 text-blue-700 bg-blue-50'
}`}>
{keyItem.room_type === 'פלוגתי' ? '🏢 פלוגתי' : '🏠 צוותי'}
</Badge>
</div>
</div>
<div className={`p-3 rounded-xl ${
isAvailable ? 'bg-emerald-100' : 'bg-amber-100'
}`}>
<Key className={`w-5 h-5 ${
isAvailable ? 'text-emerald-600' : 'text-amber-600'
}`} />
</div>
</div>
</div>
{!isAvailable && displayHolder && (
<div className="mb-4 p-3 bg-white/80 rounded-lg border border-slate-100">
<div className="flex items-center gap-2 text-slate-600 mb-1">
<User className="w-4 h-4" />
<span className="text-sm font-medium">{displayHolder}</span>
</div>
{keyItem.checkout_start_time && keyItem.checkout_end_time && (
<div className="flex items-center gap-2 text-slate-400 text-xs">
<Clock className="w-3 h-3" />
<span>{keyItem.checkout_end_time} - {keyItem.checkout_start_time}</span>
</div>
)}
</div>
)}
{isAvailable ? (
<Button
onClick={() => onCheckout(keyItem)}
className="w-full bg-emerald-600 hover:bg-emerald-700 transition-all"
>
משוך מפתח <ArrowRight className="w-4 h-4 mr-2" />
</Button>
) : canReturn ? (
<Button
onClick={() => onReturn(keyItem)}
variant="outline"
className="w-full border-amber-300 text-amber-700 hover:bg-amber-50"
>
החזר מפתח
</Button>
) : null}
</Card>
</motion.div>
);
}

View File

@ -0,0 +1,222 @@
import React, { useState } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription } from
"@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue } from
"@/components/ui/select";
import { Clock, Users } from 'lucide-react';
import { toast } from 'sonner';
export default function AddToQueueModal({ open, onClose, crews, squads, currentUser, onConfirm }) {
const [crewName, setCrewName] = useState('');
const [platoonName, setPlatoonName] = useState('');
const [preferredType, setPreferredType] = useState('any');
const [notes, setNotes] = useState('');
const [useExisting, setUseExisting] = useState(false);
const [startTime, setStartTime] = useState('');
const [endTime, setEndTime] = useState('');
const handleConfirm = () => {
if (crewName && startTime && endTime) {
// Check if time has already passed
const now = new Date();
const currentTime = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
if (startTime < currentTime) {
toast.error('לא ניתן להוסיף בקשה בזמן שכבר חלף');
return;
}
if (startTime >= endTime) {
toast.error('שעת הסיום חייבת להיות מאוחרת משעת ההתחלה');
return;
}
// Get today's date
const today = new Date().toISOString().split('T')[0];
onConfirm({
crew_name: crewName,
platoon_name: platoonName,
preferred_type: preferredType,
start_time: startTime,
end_time: endTime,
notes: notes,
date: today
});
setCrewName('');
setPlatoonName('');
setPreferredType('any');
setStartTime('');
setEndTime('');
setNotes('');
setUseExisting(false);
}
};
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent dir="rtl" className="text-right">
<DialogHeader className="text-right">
<DialogTitle className="w-full text-right">
<div className="flex w-full items-center gap-2">
<span className="text-right">בקשה מיוחדת למפתח</span>
<div className="p-2 bg-blue-100 rounded-lg">
<Clock className="w-5 h-5 text-blue-600" />
</div>
</div>
</DialogTitle>
<DialogDescription className="text-right">
הוסף בקשה מיוחדת למפתח
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{!useExisting ?
<div className="space-y-2">
<Label className="text-sm font-medium text-right block">החדר עבור *</Label>
<select
value={crewName}
onChange={(e) => {
const selectedValue = e.target.value;
setCrewName(selectedValue);
// Check if a squad was selected and auto-fill platoon name
const selectedSquad = squads?.find((s) => s.squad_number === selectedValue);
setPlatoonName(selectedSquad ? selectedSquad.platoon_name : '');
}}
className="w-full px-3 py-2 border border-slate-300 rounded-md text-right">
<option value="">בחר פלוגה או צוות...</option>
{crews && crews.length > 0 && (
<optgroup label="פלוגות">
{crews.map((crew) => (
<option key={crew.id} value={crew.name}>{crew.name}</option>
))}
</optgroup>
)}
{squads && squads.length > 0 && (
<optgroup label="צוותים">
{squads.map((squad) => (
<option key={squad.id} value={squad.squad_number}>
{squad.squad_number} {squad.platoon_name ? `(${squad.platoon_name})` : ''}
</option>
))}
</optgroup>
)}
</select>
<Button
variant="ghost"
size="sm"
className="text-slate-500 text-xs w-full"
onClick={() => setUseExisting(true)}>
הזן שם ידנית
</Button>
</div> :
<div className="space-y-2">
<Label className="peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-sm font-medium text-right block">החדר עבור *</Label>
<Input
placeholder="למשל, סגל"
value={crewName}
onChange={(e) => setCrewName(e.target.value)}
className="text-right" />
<Button
variant="ghost"
size="sm"
className="text-slate-500 text-xs w-full"
onClick={() => setUseExisting(false)}>
בחר מאפשרויות קיימות
</Button>
</div>
}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label className="text-sm font-medium text-right block">שעת התחלה</Label>
<Input
type="time"
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
className="text-right" />
</div>
<div className="space-y-2">
<Label className="text-sm font-medium text-right block">שעת סיום</Label>
<Input
type="time"
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
className="text-right" />
</div>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium text-right block">סוג חדר מועדף</Label>
<Select value={preferredType} onValueChange={setPreferredType}>
<SelectTrigger className="text-right" dir="rtl">
<SelectValue className="text-right" />
</SelectTrigger>
<SelectContent align="end" dir="rtl">
<SelectItem value="any">🔄 כל חדר זמין</SelectItem>
<SelectItem value="צוותי">🏠 צוותי</SelectItem>
<SelectItem value="פלוגתי">🏢 פלוגתי</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium text-right block">הערות (אופציונלי)</Label>
<Textarea
placeholder="דרישות מיוחדות..."
value={notes}
onChange={(e) => setNotes(e.target.value)}
className="h-20 text-right" />
</div>
</div>
<div className="flex flex-row-reverse gap-3">
<Button variant="outline" onClick={onClose} className="flex-1">
ביטול
</Button>
<Button
onClick={handleConfirm}
disabled={!crewName || !startTime || !endTime}
className="flex-1 bg-blue-600 hover:bg-blue-700">
הוסף לתור
</Button>
</div>
</DialogContent>
</Dialog>);
}

View File

@ -0,0 +1,72 @@
import React from 'react';
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Users, Clock, Trash2, ArrowUp } from 'lucide-react';
import { motion } from 'framer-motion';
export default function WaitingQueueCard({ item, position, onRemove, onMoveUp, isAdmin }) {
return (
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
transition={{ duration: 0.2 }}
>
<Card className="p-4 border border-slate-200 hover:border-slate-300 transition-all" dir="rtl">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-slate-100 to-slate-200 flex items-center justify-center">
<span className="font-bold text-slate-600">#{position}</span>
</div>
<div>
<div className="flex items-center gap-2">
<Users className="w-4 h-4 text-slate-400" />
<span className="font-medium text-slate-800">{item.crew_name}</span>
</div>
<div className="flex items-center gap-2 mt-1">
<Badge variant="outline" className="text-xs">
{item.preferred_type === 'any' ? '🔄 הכל' :
item.preferred_type === 'פלוגתי' ? '🏢 פלוגתי' : '🏠 צוותי'}
</Badge>
{item.start_time && item.end_time && (
<span className="text-xs text-slate-500">
{item.start_time}-{item.end_time}
</span>
)}
{item.notes && (
<span className="text-xs text-slate-400">{item.notes}</span>
)}
</div>
{item.created_by && (
<span className="text-xs text-slate-400 mt-1 block">
נשלח ע״י {item.created_by}
</span>
)}
</div>
</div>
<div className="flex items-center gap-2">
{position > 1 && isAdmin && (
<Button
variant="ghost"
size="icon"
onClick={() => onMoveUp(item)}
className="text-slate-400 hover:text-slate-600"
>
<ArrowUp className="w-4 h-4" />
</Button>
)}
<Button
variant="ghost"
size="icon"
onClick={() => onRemove(item)}
className="text-red-400 hover:text-red-600 hover:bg-red-50"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
</Card>
</motion.div>
);
}

View File

@ -0,0 +1,66 @@
import React from 'react';
import { Key, Clock, Users } from 'lucide-react';
import { motion } from 'framer-motion';
export default function StatsBar({ keys, queueCount }) {
const availableKeys = keys.filter(k => k.status === 'available').length;
const takenKeys = keys.filter(k => k.status === 'taken').length;
const smallKeys = keys.filter(k => k.room_type === 'צוותי');
const largeKeys = keys.filter(k => k.room_type === 'פלוגתי');
const stats = [
{
label: 'זמינים',
value: availableKeys,
total: keys.length,
icon: Key,
color: 'emerald',
gradient: 'from-emerald-500 to-teal-500',
},
{
label: 'בשימוש',
value: takenKeys,
total: keys.length,
icon: Users,
color: 'amber',
gradient: 'from-amber-500 to-orange-500',
},
{
label: 'ממתינים',
value: queueCount,
icon: Clock,
color: 'blue',
gradient: 'from-blue-500 to-indigo-500',
},
];
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
{stats.map((stat, index) => (
<motion.div
key={stat.label}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
className="relative overflow-hidden rounded-2xl bg-white border border-slate-200 p-6 shadow-sm"
>
<div className={`absolute top-0 right-0 w-32 h-32 bg-gradient-to-br ${stat.gradient} opacity-10 rounded-full -translate-y-1/2 translate-x-1/2`} />
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500 mb-1">{stat.label}</p>
<div className="flex items-baseline gap-1">
{stat.total && (
<span className="text-slate-400 text-lg">{stat.total} /</span>
)}
<span className="text-3xl font-bold text-slate-800">{stat.value}</span>
</div>
</div>
<div className={`p-3 rounded-xl bg-${stat.color}-100`}>
<stat.icon className={`w-6 h-6 text-${stat.color}-600`} />
</div>
</div>
</motion.div>
))}
</div>
);
}

View File

@ -0,0 +1,41 @@
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(({ className, ...props }, ref) => (
<AccordionPrimitive.Item ref={ref} className={cn("border-b", className)} {...props} />
))
AccordionItem.displayName = "AccordionItem"
const AccordionTrigger = React.forwardRef(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline text-left [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}>
{children}
<ChevronDown
className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
))
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
const AccordionContent = React.forwardRef(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm 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 }

View File

@ -0,0 +1,97 @@
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(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 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(({ 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-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} />
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}) => (
<div
className={cn("flex flex-col space-y-2 text-center sm:text-left", className)}
{...props} />
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}) => (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
{...props} />
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title ref={ref} className={cn("text-lg font-semibold", className)} {...props} />
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props} />
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef(({ 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,
}

View File

@ -0,0 +1,47 @@
import * as React from "react"
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props} />
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef(({ 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(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props} />
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View File

@ -0,0 +1,5 @@
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
const AspectRatio = AspectRatioPrimitive.Root
export { AspectRatio }

View File

@ -0,0 +1,35 @@
"use client"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
const Avatar = React.forwardRef(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn("relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", className)}
{...props} />
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props} />
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props} />
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }

View File

@ -0,0 +1,34 @@
import * as React from "react"
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
...props
}) {
return (<div className={cn(badgeVariants({ variant }), className)} {...props} />);
}
export { Badge, badgeVariants }

View File

@ -0,0 +1,92 @@
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(
({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />
)
Breadcrumb.displayName = "Breadcrumb"
const BreadcrumbList = React.forwardRef(({ 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(({ className, ...props }, ref) => (
<li
ref={ref}
className={cn("inline-flex items-center gap-1.5", className)}
{...props} />
))
BreadcrumbItem.displayName = "BreadcrumbItem"
const BreadcrumbLink = React.forwardRef(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a"
return (
(<Comp
ref={ref}
className={cn("transition-colors hover:text-foreground", className)}
{...props} />)
);
})
BreadcrumbLink.displayName = "BreadcrumbLink"
const BreadcrumbPage = React.forwardRef(({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props} />
))
BreadcrumbPage.displayName = "BreadcrumbPage"
const BreadcrumbSeparator = ({
children,
className,
...props
}) => (
<li
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:w-3.5 [&>svg]:h-3.5", className)}
{...props}>
{children ?? <ChevronRight />}
</li>
)
BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
const BreadcrumbEllipsis = ({
className,
...props
}) => (
<span
role="presentation"
aria-hidden="true"
className={cn("flex h-9 w-9 items-center justify-center", 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,
}

View File

@ -0,0 +1,48 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const Button = React.forwardRef(({ 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 }

View File

@ -0,0 +1,71 @@
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"
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}) {
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",
nav: "space-x-1 flex items-center",
nav_button: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
),
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-8 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: cn(
"relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected].day-range-end)]:rounded-r-md",
props.mode === "range"
? "[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md"
: "[&:has([aria-selected])]:rounded-md"
),
day: cn(
buttonVariants({ variant: "ghost" }),
"h-8 w-8 p-0 font-normal aria-selected:opacity-100"
),
day_range_start: "day-range-start",
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",
day_today: "bg-accent text-accent-foreground",
day_outside:
"day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle:
"aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames,
}}
components={{
IconLeft: ({ className, ...props }) => (
<ChevronLeft className={cn("h-4 w-4", className)} {...props} />
),
IconRight: ({ className, ...props }) => (
<ChevronRight className={cn("h-4 w-4", className)} {...props} />
),
}}
{...props} />)
);
}
Calendar.displayName = "Calendar"
export { Calendar }

View File

@ -0,0 +1,50 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("rounded-xl border bg-card text-card-foreground shadow", className)}
{...props} />
))
Card.displayName = "Card"
const CardHeader = React.forwardRef(({ 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(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props} />
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props} />
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef(({ 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 }

View File

@ -0,0 +1,193 @@
import * as React from "react"
import useEmblaCarousel from "embla-carousel-react";
import { ArrowLeft, ArrowRight } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
const CarouselContext = React.createContext(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((
{
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) => {
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) => {
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(({ 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(({ 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(({ 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", 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(({ 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", 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 { Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext };

View File

@ -0,0 +1,309 @@
"use client";
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"
}
const ChartContext = React.createContext(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(({ 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/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.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
}) => {
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] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`)
.join("\n"),
}} />)
);
}
const ChartTooltip = RechartsPrimitive.Tooltip
const ChartTooltipContent = React.forwardRef((
{
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]?.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 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
}
} />
)
)}
<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((
{ 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,
payload,
key
) {
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 = key
if (
key in payload &&
typeof payload[key] === "string"
) {
configLabelKey = payload[key]
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key] === "string"
) {
configLabelKey = payloadPayload[key]
}
return configLabelKey in config
? config[configLabelKey]
: config[key];
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}

View File

@ -0,0 +1,22 @@
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(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}>
<CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

View File

@ -0,0 +1,11 @@
"use client"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
const Collapsible = CollapsiblePrimitive.Root
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View File

@ -0,0 +1,116 @@
import * as React from "react"
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(({ 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
}) => {
return (
(<Dialog {...props}>
<DialogContent className="overflow-hidden p-0">
<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(({ className, ...props }, ref) => (
<div className="flex items-center border-b 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-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props} />
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef(({ 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((props, ref) => (
<CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef(({ 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(({ className, ...props }, ref) => (
<CommandPrimitive.Separator ref={ref} className={cn("-mx-1 h-px bg-border", className)} {...props} />
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default gap-2 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 data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className
)}
{...props} />
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({
className,
...props
}) => {
return (
(<span
className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
{...props} />)
);
}
CommandShortcut.displayName = "CommandShortcut"
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

@ -0,0 +1,156 @@
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(({ 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 focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
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(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border 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",
className
)}
{...props} />
))
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
const ContextMenuContent = React.forwardRef(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border 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",
className
)}
{...props} />
</ContextMenuPrimitive.Portal>
))
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
const ContextMenuItem = React.forwardRef(({ 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 focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props} />
))
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
const ContextMenuCheckboxItem = React.forwardRef(({ 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 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">
<ContextMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
))
ContextMenuCheckboxItem.displayName =
ContextMenuPrimitive.CheckboxItem.displayName
const ContextMenuRadioItem = React.forwardRef(({ 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 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">
<ContextMenuPrimitive.ItemIndicator>
<Circle className="h-4 w-4 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
))
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName
const ContextMenuLabel = React.forwardRef(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold text-foreground",
inset && "pl-8",
className
)}
{...props} />
))
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
const ContextMenuSeparator = React.forwardRef(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props} />
))
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
const ContextMenuShortcut = ({
className,
...props
}) => {
return (
(<span
className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
{...props} />)
);
}
ContextMenuShortcut.displayName = "ContextMenuShortcut"
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}

View File

@ -0,0 +1,96 @@
"use client"
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(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 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(({ 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 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 left-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
}) => (
<div
className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)}
{...props} />
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}) => (
<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(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props} />
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef(({ 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,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@ -0,0 +1,92 @@
"use client"
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
const Drawer = ({
shouldScaleBackground = true,
...props
}) => (
<DrawerPrimitive.Root shouldScaleBackground={shouldScaleBackground} {...props} />
)
Drawer.displayName = "Drawer"
const DrawerTrigger = DrawerPrimitive.Trigger
const DrawerPortal = DrawerPrimitive.Portal
const DrawerClose = DrawerPrimitive.Close
const DrawerOverlay = React.forwardRef(({ className, ...props }, ref) => (
<DrawerPrimitive.Overlay
ref={ref}
className={cn("fixed inset-0 z-50 bg-black/80", className)}
{...props} />
))
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
const DrawerContent = React.forwardRef(({ 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 bg-background",
className
)}
{...props}>
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
))
DrawerContent.displayName = "DrawerContent"
const DrawerHeader = ({
className,
...props
}) => (
<div
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
{...props} />
)
DrawerHeader.displayName = "DrawerHeader"
const DrawerFooter = ({
className,
...props
}) => (
<div className={cn("mt-auto flex flex-col gap-2 p-4", className)} {...props} />
)
DrawerFooter.displayName = "DrawerFooter"
const DrawerTitle = React.forwardRef(({ className, ...props }, ref) => (
<DrawerPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props} />
))
DrawerTitle.displayName = DrawerPrimitive.Title.displayName
const DrawerDescription = React.forwardRef(({ className, ...props }, ref) => (
<DrawerPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props} />
))
DrawerDescription.displayName = DrawerPrimitive.Description.displayName
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}

View File

@ -0,0 +1,156 @@
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(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border 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",
className
)}
{...props} />
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef(({ 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 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",
className
)}
{...props} />
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
inset && "pl-8",
className
)}
{...props} />
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef(({ 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 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" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef(({ 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 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" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
{...props} />
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props} />
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}) => {
return (
(<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props} />)
);
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

View File

@ -0,0 +1,134 @@
"use client";
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { Controller, FormProvider, useFormContext } from "react-hook-form";
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
const FormFieldContext = React.createContext({})
const FormField = (
{
...props
}
) => {
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,
}
}
const FormItemContext = React.createContext({})
const FormItem = React.forwardRef(({ className, ...props }, ref) => {
const id = React.useId()
return (
(<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>)
);
})
FormItem.displayName = "FormItem"
const FormLabel = React.forwardRef(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField()
return (
(<Label
ref={ref}
className={cn(error && "text-destructive", className)}
htmlFor={formItemId}
{...props} />)
);
})
FormLabel.displayName = "FormLabel"
const FormControl = React.forwardRef(({ ...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(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField()
return (
(<p
ref={ref}
id={formDescriptionId}
className={cn("text-[0.8rem] text-muted-foreground", className)}
{...props} />)
);
})
FormDescription.displayName = "FormDescription"
const FormMessage = React.forwardRef(({ 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-[0.8rem] font-medium text-destructive", className)}
{...props}>
{body}
</p>)
);
})
FormMessage.displayName = "FormMessage"
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View File

@ -0,0 +1,25 @@
"use client"
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(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 rounded-md border 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",
className
)}
{...props} />
))
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
export { HoverCard, HoverCardTrigger, HoverCardContent }

View File

@ -0,0 +1,53 @@
import * as React from "react"
import { OTPInput, OTPInputContext } from "input-otp"
import { Minus } from "lucide-react"
import { cn } from "@/lib/utils"
const InputOTP = React.forwardRef(({ 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(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center", className)} {...props} />
))
InputOTPGroup.displayName = "InputOTPGroup"
const InputOTPSlot = React.forwardRef(({ 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-9 w-9 items-center justify-center border-y border-r border-input text-sm shadow-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
isActive && "z-10 ring-1 ring-ring",
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-foreground duration-1000" />
</div>
)}
</div>)
);
})
InputOTPSlot.displayName = "InputOTPSlot"
const InputOTPSeparator = React.forwardRef(({ ...props }, ref) => (
<div ref={ref} role="separator" {...props}>
<Minus />
</div>
))
InputOTPSeparator.displayName = "InputOTPSeparator"
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }

View File

@ -0,0 +1,19 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef(({ className, type, ...props }, ref) => {
return (
(<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props} />)
);
})
Input.displayName = "Input"
export { Input }

View File

@ -0,0 +1,16 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva } 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"
)
const Label = React.forwardRef(({ className, ...props }, ref) => (
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@ -0,0 +1,200 @@
"use client"
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"
function MenubarMenu({
...props
}) {
return <MenubarPrimitive.Menu {...props} />;
}
function MenubarGroup({
...props
}) {
return <MenubarPrimitive.Group {...props} />;
}
function MenubarPortal({
...props
}) {
return <MenubarPrimitive.Portal {...props} />;
}
function MenubarRadioGroup({
...props
}) {
return <MenubarPrimitive.RadioGroup {...props} />;
}
function MenubarSub({
...props
}) {
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />;
}
const Menubar = React.forwardRef(({ className, ...props }, ref) => (
<MenubarPrimitive.Root
ref={ref}
className={cn(
"flex h-9 items-center space-x-1 rounded-md border bg-background p-1 shadow-sm",
className
)}
{...props} />
))
Menubar.displayName = MenubarPrimitive.Root.displayName
const MenubarTrigger = React.forwardRef(({ className, ...props }, ref) => (
<MenubarPrimitive.Trigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-3 py-1 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
className
)}
{...props} />
))
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName
const MenubarSubTrigger = React.forwardRef(({ 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 focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
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(({ className, ...props }, ref) => (
<MenubarPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border 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",
className
)}
{...props} />
))
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName
const MenubarContent = React.forwardRef((
{ 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 bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in 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",
className
)}
{...props} />
</MenubarPrimitive.Portal>
))
MenubarContent.displayName = MenubarPrimitive.Content.displayName
const MenubarItem = React.forwardRef(({ 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 focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props} />
))
MenubarItem.displayName = MenubarPrimitive.Item.displayName
const MenubarCheckboxItem = React.forwardRef(({ 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 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">
<MenubarPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
))
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName
const MenubarRadioItem = React.forwardRef(({ 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 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">
<MenubarPrimitive.ItemIndicator>
<Circle className="h-4 w-4 fill-current" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
))
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName
const MenubarLabel = React.forwardRef(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
{...props} />
))
MenubarLabel.displayName = MenubarPrimitive.Label.displayName
const MenubarSeparator = React.forwardRef(({ className, ...props }, ref) => (
<MenubarPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props} />
))
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName
const MenubarShortcut = ({
className,
...props
}) => {
return (
(<span
className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
{...props} />)
);
}
MenubarShortcut.displayname = "MenubarShortcut"
export {
Menubar,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarItem,
MenubarSeparator,
MenubarLabel,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarPortal,
MenubarSubContent,
MenubarSubTrigger,
MenubarGroup,
MenubarSub,
MenubarShortcut,
}

View File

@ -0,0 +1,104 @@
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(({ 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(({ 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-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50"
)
const NavigationMenuTrigger = React.forwardRef(({ 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 group-data-[state=open]:rotate-180"
aria-hidden="true" />
</NavigationMenuPrimitive.Trigger>
))
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName
const NavigationMenuContent = React.forwardRef(({ 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 md:absolute md:w-auto ",
className
)}
{...props} />
))
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName
const NavigationMenuLink = NavigationMenuPrimitive.Link
const NavigationMenuViewport = React.forwardRef(({ 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 bg-popover text-popover-foreground shadow 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(({ 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-border shadow-md" />
</NavigationMenuPrimitive.Indicator>
))
NavigationMenuIndicator.displayName =
NavigationMenuPrimitive.Indicator.displayName
export {
navigationMenuTriggerStyle,
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
}

View File

@ -0,0 +1,100 @@
import * as React from "react"
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button";
const Pagination = ({
className,
...props
}) => (
<nav
role="navigation"
aria-label="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props} />
)
Pagination.displayName = "Pagination"
const PaginationContent = React.forwardRef(({ className, ...props }, ref) => (
<ul
ref={ref}
className={cn("flex flex-row items-center gap-1", className)}
{...props} />
))
PaginationContent.displayName = "PaginationContent"
const PaginationItem = React.forwardRef(({ className, ...props }, ref) => (
<li ref={ref} className={cn("", className)} {...props} />
))
PaginationItem.displayName = "PaginationItem"
const PaginationLink = ({
className,
isActive,
size = "icon",
...props
}) => (
<a
aria-current={isActive ? "page" : undefined}
className={cn(buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}), className)}
{...props} />
)
PaginationLink.displayName = "PaginationLink"
const PaginationPrevious = ({
className,
...props
}) => (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 pl-2.5", className)}
{...props}>
<ChevronLeft className="h-4 w-4" />
<span>Previous</span>
</PaginationLink>
)
PaginationPrevious.displayName = "PaginationPrevious"
const PaginationNext = ({
className,
...props
}) => (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 pr-2.5", className)}
{...props}>
<span>Next</span>
<ChevronRight className="h-4 w-4" />
</PaginationLink>
)
PaginationNext.displayName = "PaginationNext"
const PaginationEllipsis = ({
className,
...props
}) => (
<span
aria-hidden
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More pages</span>
</span>
)
PaginationEllipsis.displayName = "PaginationEllipsis"
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
}

View File

@ -0,0 +1,27 @@
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 PopoverAnchor = PopoverPrimitive.Anchor
const PopoverContent = React.forwardRef(({ 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 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",
className
)}
{...props} />
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@ -0,0 +1,23 @@
"use client"
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
const Progress = React.forwardRef(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
className
)}
{...props}>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }} />
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }

View File

@ -0,0 +1,29 @@
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(({ className, ...props }, ref) => {
return (<RadioGroupPrimitive.Root className={cn("grid gap-2", className)} {...props} ref={ref} />);
})
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
const RadioGroupItem = React.forwardRef(({ className, ...props }, ref) => {
return (
(<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary shadow focus:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-3.5 w-3.5 fill-primary" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>)
);
})
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
export { RadioGroup, RadioGroupItem }

View File

@ -0,0 +1,42 @@
"use client"
import { GripVertical } from "lucide-react"
import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "@/lib/utils"
const ResizablePanelGroup = ({
className,
...props
}) => (
<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
}) => (
<ResizablePrimitive.PanelResizeHandle
className={cn(
"relative flex w-px items-center justify-center bg-border 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-ring 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 bg-border">
<GripVertical className="h-2.5 w-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
)
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }

View File

@ -0,0 +1,38 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef(({ className, children, ...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>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

View File

@ -0,0 +1,121 @@
"use client"
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(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef(({ 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 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",
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(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props} />
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef(({ 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-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef(({ 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,
}

View File

@ -0,0 +1,23 @@
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef((
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props} />
))
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

View File

@ -0,0 +1,109 @@
"use client";
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { cva } from "class-variance-authority";
import { X } from "lucide-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(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 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-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t 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",
},
}
)
const SheetContent = React.forwardRef(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
<SheetPrimitive.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-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
{children}
</SheetPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({
className,
...props
}) => (
<div
className={cn("flex flex-col space-y-2 text-center sm:text-left", className)}
{...props} />
)
SheetHeader.displayName = "SheetHeader"
const SheetFooter = ({
className,
...props
}) => (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
{...props} />
)
SheetFooter.displayName = "SheetFooter"
const SheetTitle = React.forwardRef(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props} />
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props} />
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@ -0,0 +1,626 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { 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"
const SidebarContext = React.createContext(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((
{
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) => {
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) => {
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(() => ({
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
}
}
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 Sidebar = React.forwardRef((
{
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
},
ref
) => {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
(<div
className={cn(
"flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground",
className
)}
ref={ref}
{...props}>
{children}
</div>)
);
}
if (isMobile) {
return (
(<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-mobile="true"
className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE
}
}
side={side}>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>)
);
}
return (
(<div
ref={ref}
className="group peer hidden text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}>
{/* This is what handles the sidebar gap on desktop */}
<div
className={cn(
"relative h-svh w-[--sidebar-width] bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]"
)} />
<div
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}>
<div
data-sidebar="sidebar"
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow">
{children}
</div>
</div>
</div>)
);
})
Sidebar.displayName = "Sidebar"
const SidebarTrigger = React.forwardRef(({ className, onClick, asChild = false, ...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()
}}
asChild={asChild}
{...props}>
{asChild ? (
<PanelLeft />
) : (
<>
<PanelLeft />
<span className="sr-only">Toggle Sidebar</span>
</>
)}
</Button>)
);
})
SidebarTrigger.displayName = "SidebarTrigger"
const SidebarRail = React.forwardRef(({ 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(({ 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(({ 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 SidebarHeader = React.forwardRef(({ className, ...props }, ref) => {
return (
(<div
ref={ref}
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props} />)
);
})
SidebarHeader.displayName = "SidebarHeader"
const SidebarFooter = React.forwardRef(({ className, ...props }, ref) => {
return (
(<div
ref={ref}
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props} />)
);
})
SidebarFooter.displayName = "SidebarFooter"
const SidebarSeparator = React.forwardRef(({ 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 SidebarContent = React.forwardRef(({ className, ...props }, ref) => {
return (
(<div
ref={ref}
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props} />)
);
})
SidebarContent.displayName = "SidebarContent"
const SidebarGroup = React.forwardRef(({ 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(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "div"
return (
(<Comp
ref={ref}
data-sidebar="group-label"
className={cn(
"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,opacity] duration-200 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(({ 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(({ 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(({ 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(({ 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((
{
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(({ 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(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground",
"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(({ 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("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}>
{showIcon && (
<Skeleton className="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />
)}
<Skeleton
className="h-4 max-w-[--skeleton-width] flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width
}
} />
</div>)
);
})
SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton"
const SidebarMenuSub = React.forwardRef(({ 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(({ ...props }, ref) => <li ref={ref} {...props} />)
SidebarMenuSubItem.displayName = "SidebarMenuSubItem"
const SidebarMenuSubButton = React.forwardRef(
({ 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,
}

View File

@ -0,0 +1,14 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}) {
return (
(<div
className={cn("animate-pulse rounded-md bg-primary/10", className)}
{...props} />)
);
}
export { Skeleton }

View File

@ -0,0 +1,21 @@
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import { cn } from "@/lib/utils"
const Slider = React.forwardRef(({ 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-primary/20">
<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 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
))
Slider.displayName = SliderPrimitive.Root.displayName
export { Slider }

View File

@ -0,0 +1,29 @@
"use client";
import { useTheme } from "next-themes"
import { Toaster as Sonner } from "sonner"
const Toaster = ({
...props
}) => {
const { theme = "system" } = useTheme()
return (
(<Sonner
theme={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 }

View File

@ -0,0 +1,22 @@
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring 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-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)} />
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

View File

@ -0,0 +1,86 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef(({ 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(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props} />
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef(({ 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(({ 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(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props} />
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props} />
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef(({ 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,
}

View File

@ -0,0 +1,41 @@
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(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className
)}
{...props} />
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className
)}
{...props} />
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef(({ 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-ring focus-visible:ring-offset-2",
className
)}
{...props} />
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Textarea = React.forwardRef(({ className, ...props }, ref) => {
return (
(<textarea
className={cn(
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props} />)
);
})
Textarea.displayName = "Textarea"
export { Textarea }

View File

@ -0,0 +1,104 @@
import * as React from "react";
import { cva } from "class-variance-authority";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const ToastProvider = React.forwardRef(({ ...props }, ref) => (
<div
ref={ref}
className="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]"
{...props}
/>
));
ToastProvider.displayName = "ToastProvider";
const ToastViewport = React.forwardRef(({ ...props }, ref) => (
<div
ref={ref}
className="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]"
{...props}
/>
));
ToastViewport.displayName = "ToastViewport";
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(({ className, variant, ...props }, ref) => {
return (
<div
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
);
});
Toast.displayName = "Toast";
const ToastAction = React.forwardRef(({ className, ...props }, ref) => (
<div
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 = "ToastAction";
const ToastClose = React.forwardRef(({ className, ...props }, ref) => (
<button
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" />
</button>
));
ToastClose.displayName = "ToastClose";
const ToastTitle = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm font-semibold", className)}
{...props}
/>
));
ToastTitle.displayName = "ToastTitle";
const ToastDescription = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
));
ToastDescription.displayName = "ToastDescription";
export {
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
};

View File

@ -0,0 +1,33 @@
import { useToast } from "@/components/ui/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>
);
}

View File

@ -0,0 +1,44 @@
"use client";
import * as React from "react"
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
import { cn } from "@/lib/utils"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext({
size: "default",
variant: "default",
})
const ToggleGroup = React.forwardRef(({ className, variant, size, children, ...props }, ref) => (
<ToggleGroupPrimitive.Root
ref={ref}
className={cn("flex items-center justify-center gap-1", className)}
{...props}>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
))
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName
const ToggleGroupItem = React.forwardRef(({ 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,
}), className)}
{...props}>
{children}
</ToggleGroupPrimitive.Item>)
);
})
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName
export { ToggleGroup, ToggleGroupItem }

View File

@ -0,0 +1,38 @@
import * as React from "react"
import * as TogglePrimitive from "@radix-ui/react-toggle"
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 px-2 min-w-9",
sm: "h-8 px-1.5 min-w-8",
lg: "h-10 px-2.5 min-w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const Toggle = React.forwardRef(({ className, variant, size, ...props }, ref) => (
<TogglePrimitive.Root
ref={ref}
className={cn(toggleVariants({ variant, size, className }))}
{...props} />
))
Toggle.displayName = TogglePrimitive.Root.displayName
export { Toggle, toggleVariants }

View File

@ -0,0 +1,28 @@
"use client"
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(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground 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} />
</TooltipPrimitive.Portal>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@ -0,0 +1,164 @@
// Inspired by react-hot-toast library
import { useState, useEffect } from "react";
const TOAST_LIMIT = 20;
const TOAST_REMOVE_DELAY = 1000000;
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
};
let count = 0;
function genId() {
count = (count + 1) % Number.MAX_VALUE;
return count.toString();
}
const toastTimeouts = new Map();
const addToRemoveQueue = (toastId) => {
if (toastTimeouts.has(toastId)) {
return;
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId);
dispatch({
type: actionTypes.REMOVE_TOAST,
toastId,
});
}, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout);
};
const _clearFromRemoveQueue = (toastId) => {
const timeout = toastTimeouts.get(toastId);
if (timeout) {
clearTimeout(timeout);
toastTimeouts.delete(toastId);
}
};
export const reducer = (state, action) => {
switch (action.type) {
case actionTypes.ADD_TOAST:
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
};
case actionTypes.UPDATE_TOAST:
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
};
case actionTypes.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 actionTypes.REMOVE_TOAST:
if (action.toastId === undefined) {
return {
...state,
toasts: [],
};
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
};
}
};
const listeners = [];
let memoryState = { toasts: [] };
function dispatch(action) {
memoryState = reducer(memoryState, action);
listeners.forEach((listener) => {
listener(memoryState);
});
}
function toast({ ...props }) {
const id = genId();
const update = (props) =>
dispatch({
type: actionTypes.UPDATE_TOAST,
toast: { ...props, id },
});
const dismiss = () =>
dispatch({ type: actionTypes.DISMISS_TOAST, toastId: id });
dispatch({
type: actionTypes.ADD_TOAST,
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss();
},
},
});
return {
id,
dismiss,
update,
};
}
function useToast() {
const [state, setState] = useState(memoryState);
useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1);
}
};
}, [state]);
return {
...state,
toast,
dismiss: (toastId) => dispatch({ type: actionTypes.DISMISS_TOAST, toastId }),
};
}
export { useToast, toast };

View File

@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState(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
}

158
Migdalor-main/src/index.css Normal file
View File

@ -0,0 +1,158 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* :root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
} */
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--radius: 0.5rem;
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

View File

@ -0,0 +1,154 @@
import React, { createContext, useState, useContext, useEffect } from 'react';
import { base44 } from '@/api/base44Client';
import { appParams } from '@/lib/app-params';
import { createAxiosClient } from '@base44/sdk/dist/utils/axios-client';
const AuthContext = createContext();
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [isLoadingAuth, setIsLoadingAuth] = useState(true);
const [isLoadingPublicSettings, setIsLoadingPublicSettings] = useState(true);
const [authError, setAuthError] = useState(null);
const [appPublicSettings, setAppPublicSettings] = useState(null); // Contains only { id, public_settings }
useEffect(() => {
checkAppState();
}, []);
const checkAppState = async () => {
try {
setIsLoadingPublicSettings(true);
setAuthError(null);
// First, check app public settings (with token if available)
// This will tell us if auth is required, user not registered, etc.
const appClient = createAxiosClient({
baseURL: `${appParams.serverUrl}/api/apps/public`,
headers: {
'X-App-Id': appParams.appId
},
token: appParams.token, // Include token if available
interceptResponses: true
});
try {
const publicSettings = await appClient.get(`/prod/public-settings/by-id/${appParams.appId}`);
setAppPublicSettings(publicSettings);
// If we got the app public settings successfully, check if user is authenticated
if (appParams.token) {
await checkUserAuth();
} else {
setIsLoadingAuth(false);
setIsAuthenticated(false);
}
setIsLoadingPublicSettings(false);
} catch (appError) {
console.error('App state check failed:', appError);
// Handle app-level errors
if (appError.status === 403 && appError.data?.extra_data?.reason) {
const reason = appError.data.extra_data.reason;
if (reason === 'auth_required') {
setAuthError({
type: 'auth_required',
message: 'Authentication required'
});
} else if (reason === 'user_not_registered') {
setAuthError({
type: 'user_not_registered',
message: 'User not registered for this app'
});
} else {
setAuthError({
type: reason,
message: appError.message
});
}
} else {
setAuthError({
type: 'unknown',
message: appError.message || 'Failed to load app'
});
}
setIsLoadingPublicSettings(false);
setIsLoadingAuth(false);
}
} catch (error) {
console.error('Unexpected error:', error);
setAuthError({
type: 'unknown',
message: error.message || 'An unexpected error occurred'
});
setIsLoadingPublicSettings(false);
setIsLoadingAuth(false);
}
};
const checkUserAuth = async () => {
try {
// Now check if the user is authenticated
setIsLoadingAuth(true);
const currentUser = await base44.auth.me();
setUser(currentUser);
setIsAuthenticated(true);
setIsLoadingAuth(false);
} catch (error) {
console.error('User auth check failed:', error);
setIsLoadingAuth(false);
setIsAuthenticated(false);
// If user auth fails, it might be an expired token
if (error.status === 401 || error.status === 403) {
setAuthError({
type: 'auth_required',
message: 'Authentication required'
});
}
}
};
const logout = (shouldRedirect = true) => {
setUser(null);
setIsAuthenticated(false);
if (shouldRedirect) {
// Use the SDK's logout method which handles token cleanup and redirect
base44.auth.logout(window.location.href);
} else {
// Just remove the token without redirect
base44.auth.logout();
}
};
const navigateToLogin = () => {
// Use the SDK's redirectToLogin method
base44.auth.redirectToLogin(window.location.href);
};
return (
<AuthContext.Provider value={{
user,
isAuthenticated,
isLoadingAuth,
isLoadingPublicSettings,
authError,
appPublicSettings,
logout,
navigateToLogin,
checkAppState
}}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};

View File

@ -0,0 +1,50 @@
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { useAuth } from './AuthContext';
import { base44 } from '@/api/base44Client';
import { pagesConfig } from '@/pages.config';
export default function NavigationTracker() {
const location = useLocation();
const { isAuthenticated } = useAuth();
const { Pages, mainPage } = pagesConfig;
const mainPageKey = mainPage ?? Object.keys(Pages)[0];
// Post navigation changes to parent window
useEffect(() => {
window.parent?.postMessage({
type: "app_changed_url",
url: window.location.href
}, '*');
}, [location]);
// Log user activity when navigating to a page
useEffect(() => {
// Extract page name from pathname
const pathname = location.pathname;
let pageName;
if (pathname === '/' || pathname === '') {
pageName = mainPageKey;
} else {
// Remove leading slash and get the first segment
const pathSegment = pathname.replace(/^\//, '').split('/')[0];
// Try case-insensitive lookup in Pages config
const pageKeys = Object.keys(Pages);
const matchedKey = pageKeys.find(
key => key.toLowerCase() === pathSegment.toLowerCase()
);
pageName = matchedKey || null;
}
if (isAuthenticated && pageName) {
base44.appLogs.logUserInApp(pageName).catch(() => {
// Silently fail - logging shouldn't break the app
});
}
}, [location, isAuthenticated, Pages, mainPageKey]);
return null;
}

View File

@ -0,0 +1,75 @@
import { useLocation } from 'react-router-dom';
import { base44 } from '@/api/base44Client';
import { useQuery } from '@tanstack/react-query';
export default function PageNotFound({}) {
const location = useLocation();
const pageName = location.pathname.substring(1);
const { data: authData, isFetched } = useQuery({
queryKey: ['user'],
queryFn: async () => {
try {
const user = await base44.auth.me();
return { user, isAuthenticated: true };
} catch (error) {
return { user: null, isAuthenticated: false };
}
}
});
return (
<div className="min-h-screen flex items-center justify-center p-6 bg-slate-50">
<div className="max-w-md w-full">
<div className="text-center space-y-6">
{/* 404 Error Code */}
<div className="space-y-2">
<h1 className="text-7xl font-light text-slate-300">404</h1>
<div className="h-0.5 w-16 bg-slate-200 mx-auto"></div>
</div>
{/* Main Message */}
<div className="space-y-3">
<h2 className="text-2xl font-medium text-slate-800">
Page Not Found
</h2>
<p className="text-slate-600 leading-relaxed">
The page <span className="font-medium text-slate-700">"{pageName}"</span> could not be found in this application.
</p>
</div>
{/* Admin Note */}
{isFetched && authData.isAuthenticated && authData.user?.role === 'admin' && (
<div className="mt-8 p-4 bg-slate-100 rounded-lg border border-slate-200">
<div className="flex items-start space-x-3">
<div className="flex-shrink-0 w-5 h-5 rounded-full bg-orange-100 flex items-center justify-center mt-0.5">
<div className="w-2 h-2 rounded-full bg-orange-400"></div>
</div>
<div className="text-left space-y-1">
<p className="text-sm font-medium text-slate-700">Admin Note</p>
<p className="text-sm text-slate-600 leading-relaxed">
This could mean that the AI hasn't implemented this page yet. Ask it to implement it in the chat.
</p>
</div>
</div>
</div>
)}
{/* Action Button */}
<div className="pt-6">
<button
onClick={() => window.location.href = '/'}
className="inline-flex items-center px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-200 rounded-lg hover:bg-slate-50 hover:border-slate-300 transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-slate-500"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
Go Home
</button>
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,648 @@
import { useEffect, useRef, useState } from 'react';
import { twMerge } from 'tailwind-merge'
export default function VisualEditAgent() {
// this functions job is to receive first a message from the parent window, to set or unset visual edits mode.
// once in visual edits mode, every hover over an elelmnt that has linenumbers should show an overlay, when clicked - it should stick the overlay and send a message to the parent window with the selected element
// then, the parent window will have an editor, allow for changes to the tailwind css classes of the selected element, and send the updated css classes back to the iframe.
// the iframe will then update the css classes of the selected element.
// State and refs
const [isVisualEditMode, setIsVisualEditMode] = useState(false);
const isVisualEditModeRef = useRef(false);
const [isPopoverDragging, setIsPopoverDragging] = useState(false);
const isPopoverDraggingRef = useRef(false);
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const isDropdownOpenRef = useRef(false);
const hoverOverlaysRef = useRef([]); // Multiple overlays for hover
const selectedOverlaysRef = useRef([]); // Multiple overlays for selection
const currentHighlightedElementsRef = useRef([]); // Multiple elements for hover
const selectedElementIdRef = useRef(null); // Store the visual selector ID
// Create overlay element
const createOverlay = (isSelected = false) => {
const overlay = document.createElement('div');
overlay.style.position = 'absolute';
overlay.style.pointerEvents = 'none';
overlay.style.transition = 'all 0.1s ease-in-out';
overlay.style.zIndex = '9999';
// Use different styles for hover vs selected
if (isSelected) {
overlay.style.border = '2px solid #2563EB';
} else {
overlay.style.border = '2px solid #95a5fc';
overlay.style.backgroundColor = 'rgba(99, 102, 241, 0.05)';
}
return overlay;
};
// Position overlay relative to element
const positionOverlay = (overlay, element, isSelected = false) => {
if (!element || !isVisualEditModeRef.current) return;
// Force layout recalculation
void element.offsetWidth;
const rect = element.getBoundingClientRect();
overlay.style.top = `${rect.top + window.scrollY}px`;
overlay.style.left = `${rect.left + window.scrollX}px`; // weird bug with the offset
overlay.style.width = `${rect.width}px`;
overlay.style.height = `${rect.height}px`;
// Check if label already exists in overlay
let label = overlay.querySelector('div');
if (!label) {
// Create new label if it doesn't exist
label = document.createElement('div');
label.textContent = element.tagName.toLowerCase();
label.style.position = 'absolute';
label.style.top = '-27px';
label.style.left = '-2px';
label.style.padding = '2px 8px';
label.style.fontSize = '11px';
label.style.fontWeight = isSelected ? '500' : '400';
label.style.color = isSelected ? '#ffffff' : '#526cff';
label.style.backgroundColor = isSelected ? '#526cff' : '#DBEAFE';
label.style.borderRadius = '3px';
label.style.boxShadow = isSelected ? 'none' : 'none';
label.style.minWidth = '24px';
label.style.textAlign = 'center';
overlay.appendChild(label);
}
// If label exists, we preserve its existing styling (don't recreate or modify)
};
// Find elements by ID - first try data-source-location, fallback to data-visual-selector-id
const findElementsById = (id) => {
if (!id) return [];
const sourceElements = [...document.querySelectorAll(`[data-source-location="${id}"]`)];
if (sourceElements.length > 0) {
return sourceElements;
}
return [...document.querySelectorAll(`[data-visual-selector-id="${id}"]`)];
};
// Clear hover overlays
const clearHoverOverlays = () => {
hoverOverlaysRef.current.forEach(overlay => {
if (overlay && overlay.parentNode) {
overlay.remove();
}
});
hoverOverlaysRef.current = [];
currentHighlightedElementsRef.current = [];
};
// Handle mouse over event
const handleMouseOver = (e) => {
if (!isVisualEditModeRef.current || isPopoverDraggingRef.current) return;
// Prevent hover effects when a dropdown is open
if (isDropdownOpenRef.current) {
clearHoverOverlays();
return;
}
// Prevent hover effects on SVG path elements
if (e.target.tagName.toLowerCase() === 'path') {
clearHoverOverlays();
return;
}
// Support both data-source-location and data-visual-selector-id
const element = e.target.closest('[data-source-location], [data-visual-selector-id]');
if (!element) {
clearHoverOverlays();
return;
}
// Prefer data-source-location, fallback to data-visual-selector-id
const selectorId = element.dataset.sourceLocation || element.dataset.visualSelectorId;
const useSourceLocation = !!element.dataset.sourceLocation;
// Skip if this element is already selected
if (selectedElementIdRef.current === selectorId) {
clearHoverOverlays();
return;
}
// Find all elements with the same ID
const elements = findElementsById(selectorId, useSourceLocation);
// Clear previous hover overlays
clearHoverOverlays();
// Create overlays for all matching elements
elements.forEach(el => {
const overlay = createOverlay(false);
document.body.appendChild(overlay);
hoverOverlaysRef.current.push(overlay);
positionOverlay(overlay, el);
});
currentHighlightedElementsRef.current = elements;
};
// Handle mouse out event
const handleMouseOut = () => {
if (isPopoverDraggingRef.current) return;
clearHoverOverlays();
};
// Handle element click
const handleElementClick = (e) => {
if (!isVisualEditModeRef.current) return;
// Close dropdowns when clicking anywhere in iframe if a dropdown is open
if (isDropdownOpenRef.current) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
// Send message to parent to close all dropdowns
window.parent.postMessage({
type: 'close-dropdowns'
}, '*');
return;
}
// Prevent clicking on SVG path elements
if (e.target.tagName.toLowerCase() === 'path') {
return;
}
// Prevent default behavior immediately when in visual edit mode
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
// Support both data-source-location and data-visual-selector-id
const element = e.target.closest('[data-source-location], [data-visual-selector-id]');
if (!element) {
return;
}
// Prefer data-source-location, fallback to data-visual-selector-id
const visualSelectorId = element.dataset.sourceLocation || element.dataset.visualSelectorId;
const useSourceLocation = !!element.dataset.sourceLocation;
// Clear any existing selected overlays
selectedOverlaysRef.current.forEach(overlay => {
if (overlay && overlay.parentNode) {
overlay.remove();
}
});
selectedOverlaysRef.current = [];
// Find all elements with the same ID
const elements = findElementsById(visualSelectorId, useSourceLocation);
// Create selected overlays for all matching elements
elements.forEach(el => {
const overlay = createOverlay(true);
document.body.appendChild(overlay);
selectedOverlaysRef.current.push(overlay);
positionOverlay(overlay, el, true);
});
selectedElementIdRef.current = visualSelectorId;
// Clear hover overlays
clearHoverOverlays();
// Calculate element position for popover positioning
const rect = element.getBoundingClientRect();
const elementPosition = {
top: rect.top,
left: rect.left,
right: rect.right,
bottom: rect.bottom,
width: rect.width,
height: rect.height,
centerX: rect.left + rect.width / 2,
centerY: rect.top + rect.height / 2
};
// Send message to parent window with element info including position
const elementData = {
type: 'element-selected',
tagName: element.tagName,
classes: element.className?.baseVal || element.className || '',
visualSelectorId: visualSelectorId,
content: element.innerText,
dataSourceLocation: element.dataset.sourceLocation,
isDynamicContent: element.dataset.dynamicContent === 'true',
linenumber: element.dataset.linenumber, // Keep for backward compatibility
filename: element.dataset.filename, // Keep for backward compatibility
position: elementPosition // Add position data for popover
};
window.parent.postMessage(elementData, '*');
};
// Unselect the current element
const unselectElement = () => {
// Clear selected overlays
selectedOverlaysRef.current.forEach(overlay => {
if (overlay && overlay.parentNode) {
overlay.remove();
}
});
selectedOverlaysRef.current = [];
selectedElementIdRef.current = null;
};
// Update element classes by visual selector ID
const updateElementClasses = (visualSelectorId, classes, replace = false) => {
// Find all elements with the same visual selector ID
const elements = findElementsById(visualSelectorId);
if (elements.length === 0) {
return;
}
// Update classes for all matching elements
elements.forEach(element => {
if (replace) {
// For reverts, replace classes completely
element.className = classes;
} else {
// For normal updates, merge with existing classes
const currentClasses = element.className?.baseVal || element.className || '';
element.className = twMerge(currentClasses, classes);
}
});
// Use a small delay to allow the browser to recalculate layout before repositioning
setTimeout(() => {
// Reposition selected overlays
if (selectedElementIdRef.current === visualSelectorId) {
selectedOverlaysRef.current.forEach((overlay, index) => {
if (index < elements.length) {
positionOverlay(overlay, elements[index]);
}
});
}
// Reposition hover overlays if needed
if (currentHighlightedElementsRef.current.length > 0) {
const hoveredId = currentHighlightedElementsRef.current[0]?.dataset?.visualSelectorId;
if (hoveredId === visualSelectorId) {
hoverOverlaysRef.current.forEach((overlay, index) => {
if (index < currentHighlightedElementsRef.current.length) {
positionOverlay(overlay, currentHighlightedElementsRef.current[index]);
}
});
}
}
}, 50); // Small delay to ensure the browser has time to recalculate layout
};
// Update element content by visual selector ID
const updateElementContent = (visualSelectorId, content) => {
// Find all elements with the same visual selector ID
const elements = findElementsById(visualSelectorId);
if (elements.length === 0) {
return;
}
// Update content for all matching elements
elements.forEach((element) => {
element.innerText = content;
});
// Use a small delay to allow the browser to recalculate layout before repositioning
setTimeout(() => {
// Reposition selected overlays
if (selectedElementIdRef.current === visualSelectorId) {
selectedOverlaysRef.current.forEach((overlay, index) => {
if (index < elements.length) {
positionOverlay(overlay, elements[index]);
}
});
}
}, 50); // Small delay to ensure the browser has time to recalculate layout
};
// Toggle visual edit mode
const toggleVisualEditMode = (isEnabled) => {
setIsVisualEditMode(isEnabled);
isVisualEditModeRef.current = isEnabled;
if (!isEnabled) {
// Clear hover overlays
clearHoverOverlays();
// Clear selected overlays
selectedOverlaysRef.current.forEach(overlay => {
if (overlay && overlay.parentNode) {
overlay.remove();
}
});
selectedOverlaysRef.current = [];
currentHighlightedElementsRef.current = [];
selectedElementIdRef.current = null;
document.body.style.cursor = 'default';
// Remove event listeners
document.removeEventListener('mouseover', handleMouseOver);
document.removeEventListener('mouseout', handleMouseOut);
document.removeEventListener('click', handleElementClick, true);
} else {
// Set cursor and add event listeners
document.body.style.cursor = 'crosshair';
document.addEventListener('mouseover', handleMouseOver);
document.addEventListener('mouseout', handleMouseOut);
document.addEventListener('click', handleElementClick, true); // Use capture mode
}
};
// Listen for messages from parent window
useEffect(() => {
// Add IDs to elements that don't have them but have linenumbers
const elementsWithLineNumber = document.querySelectorAll('[data-linenumber]:not([data-visual-selector-id])');
elementsWithLineNumber.forEach((el, index) => {
const id = `visual-id-${el.dataset.filename}-${el.dataset.linenumber}-${index}`;
el.dataset.visualSelectorId = id;
});
// Handle scroll events to update popover position
const handleScroll = () => {
if (selectedElementIdRef.current) {
// Find the element using the stored ID
const elements = findElementsById(selectedElementIdRef.current);
if (elements.length > 0) {
const element = elements[0];
const rect = element.getBoundingClientRect();
// Check if element is in viewport
const viewportHeight = window.innerHeight;
const viewportWidth = window.innerWidth;
const isInViewport = (
rect.top < viewportHeight &&
rect.bottom > 0 &&
rect.left < viewportWidth &&
rect.right > 0
);
const elementPosition = {
top: rect.top,
left: rect.left,
right: rect.right,
bottom: rect.bottom,
width: rect.width,
height: rect.height,
centerX: rect.left + rect.width / 2,
centerY: rect.top + rect.height / 2
};
window.parent.postMessage({
type: 'element-position-update',
position: elementPosition,
isInViewport: isInViewport,
visualSelectorId: selectedElementIdRef.current
}, '*');
}
}
};
const handleMessage = (event) => {
// Check origin if desired
//if (event.origin !== 'parent-origin') return;
const message = event.data;
switch (message.type) {
case 'toggle-visual-edit-mode':
toggleVisualEditMode(message.data.enabled);
break;
case 'update-classes':
if (message.data && message.data.classes !== undefined) {
// Update with the visual selector ID
// Pass replace flag if provided (used for reverts)
updateElementClasses(
message.data.visualSelectorId,
message.data.classes,
message.data.replace || false
);
} else {
console.warn('[Agent] Invalid update-classes message:', message);
}
break;
case 'unselect-element':
unselectElement();
break;
case 'refresh-page':
window.location.reload();
break;
case 'update-content':
if (message.data && message.data.content !== undefined) {
updateElementContent(
message.data.visualSelectorId,
message.data.content
);
} else {
console.warn('[Agent] Invalid update-content message:', message);
}
break;
case 'request-element-position':
// Send current position of selected element for popover repositioning
if (selectedElementIdRef.current) {
// Find the element using the stored ID
const elements = findElementsById(selectedElementIdRef.current);
if (elements.length > 0) {
const element = elements[0];
const rect = element.getBoundingClientRect();
// Check if element is in viewport
const viewportHeight = window.innerHeight;
const viewportWidth = window.innerWidth;
const isInViewport = (
rect.top < viewportHeight &&
rect.bottom > 0 &&
rect.left < viewportWidth &&
rect.right > 0
);
const elementPosition = {
top: rect.top,
left: rect.left,
right: rect.right,
bottom: rect.bottom,
width: rect.width,
height: rect.height,
centerX: rect.left + rect.width / 2,
centerY: rect.top + rect.height / 2
};
window.parent.postMessage({
type: 'element-position-update',
position: elementPosition,
isInViewport: isInViewport,
visualSelectorId: selectedElementIdRef.current
}, '*');
}
}
break;
case 'popover-drag-state':
// Handle popover drag state to prevent mouseover conflicts
if (message.data && message.data.isDragging !== undefined) {
setIsPopoverDragging(message.data.isDragging);
isPopoverDraggingRef.current = message.data.isDragging;
// Clear hover overlays when dragging starts
if (message.data.isDragging) {
clearHoverOverlays();
}
}
break;
case 'dropdown-state':
// Handle dropdown open/close state
if (message.data && message.data.isOpen !== undefined) {
setIsDropdownOpen(message.data.isOpen);
isDropdownOpenRef.current = message.data.isOpen;
// Clear hover overlays when dropdown opens
if (message.data.isOpen) {
clearHoverOverlays();
}
}
break;
default:
break;
}
};
window.addEventListener('message', handleMessage);
window.addEventListener('scroll', handleScroll, true); // Use capture to catch all scroll events
document.addEventListener('scroll', handleScroll, true); // Also listen on document
// Send ready message to parent
window.parent.postMessage({ type: 'visual-edit-agent-ready' }, '*');
return () => {
window.removeEventListener('message', handleMessage);
window.removeEventListener('scroll', handleScroll, true);
document.removeEventListener('scroll', handleScroll, true);
document.removeEventListener('mouseover', handleMouseOver);
document.removeEventListener('mouseout', handleMouseOut);
document.removeEventListener('click', handleElementClick, true);
// Clean up all overlays
clearHoverOverlays();
selectedOverlaysRef.current.forEach(overlay => {
if (overlay && overlay.parentNode) {
overlay.remove();
}
});
};
}, []);
// Keep the refs in sync with state changes
useEffect(() => {
isVisualEditModeRef.current = isVisualEditMode;
}, [isVisualEditMode]);
useEffect(() => {
isPopoverDraggingRef.current = isPopoverDragging;
}, [isPopoverDragging]);
useEffect(() => {
isDropdownOpenRef.current = isDropdownOpen;
}, [isDropdownOpen]);
// Handle window resize and scroll to reposition overlays
useEffect(() => {
const handleResize = () => {
// Reposition selected overlays
if (selectedElementIdRef.current) {
const elements = findElementsById(selectedElementIdRef.current);
selectedOverlaysRef.current.forEach((overlay, index) => {
if (index < elements.length) {
positionOverlay(overlay, elements[index]);
}
});
}
// Reposition hover overlays
if (currentHighlightedElementsRef.current.length > 0) {
hoverOverlaysRef.current.forEach((overlay, index) => {
if (index < currentHighlightedElementsRef.current.length) {
positionOverlay(overlay, currentHighlightedElementsRef.current[index]);
}
});
}
};
// Create a mutation observer to detect changes in the DOM
const mutationObserver = new MutationObserver((mutations) => {
// Check if mutations affect relevant elements
const needsUpdate = mutations.some(mutation => {
// Check if the target or its children have data-visual-selector-id
const hasVisualId = (node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
if (node.dataset && node.dataset.visualSelectorId) {
return true;
}
// Check children
for (let i = 0; i < node.children.length; i++) {
if (hasVisualId(node.children[i])) {
return true;
}
}
}
return false;
};
// Check if this is a style or attribute mutation that might affect layout
const isLayoutChange = mutation.type === 'attributes' &&
(mutation.attributeName === 'style' ||
mutation.attributeName === 'class' ||
mutation.attributeName === 'width' ||
mutation.attributeName === 'height');
// Check if target is or contains an element with visual selector ID
return isLayoutChange && hasVisualId(mutation.target);
});
if (needsUpdate) {
// Use timeout to let browser calculate layout
setTimeout(handleResize, 50);
}
});
// Start observing
mutationObserver.observe(document.body, {
attributes: true,
childList: true,
subtree: true,
attributeFilter: ['style', 'class', 'width', 'height']
});
window.addEventListener('resize', handleResize);
window.addEventListener('scroll', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
window.removeEventListener('scroll', handleResize);
mutationObserver.disconnect();
};
}, []);
// No visible UI - all functionality is handled through event listeners and message passing
return null;
}

View File

@ -0,0 +1,54 @@
const isNode = typeof window === 'undefined';
const windowObj = isNode ? { localStorage: new Map() } : window;
const storage = windowObj.localStorage;
const toSnakeCase = (str) => {
return str.replace(/([A-Z])/g, '_$1').toLowerCase();
}
const getAppParamValue = (paramName, { defaultValue = undefined, removeFromUrl = false } = {}) => {
if (isNode) {
return defaultValue;
}
const storageKey = `base44_${toSnakeCase(paramName)}`;
const urlParams = new URLSearchParams(window.location.search);
const searchParam = urlParams.get(paramName);
if (removeFromUrl) {
urlParams.delete(paramName);
const newUrl = `${window.location.pathname}${urlParams.toString() ? `?${urlParams.toString()}` : ""
}${window.location.hash}`;
window.history.replaceState({}, document.title, newUrl);
}
if (searchParam) {
storage.setItem(storageKey, searchParam);
return searchParam;
}
if (defaultValue) {
storage.setItem(storageKey, defaultValue);
return defaultValue;
}
const storedValue = storage.getItem(storageKey);
if (storedValue) {
return storedValue;
}
return null;
}
const getAppParams = () => {
if (getAppParamValue("clear_access_token") === 'true') {
storage.removeItem('base44_access_token');
storage.removeItem('token');
}
return {
appId: getAppParamValue("app_id", { defaultValue: import.meta.env.VITE_BASE44_APP_ID }),
serverUrl: getAppParamValue("server_url", { defaultValue: import.meta.env.VITE_BASE44_BACKEND_URL }),
token: getAppParamValue("access_token", { removeFromUrl: true }),
fromUrl: getAppParamValue("from_url", { defaultValue: window.location.href }),
functionsVersion: getAppParamValue("functions_version", { defaultValue: import.meta.env.VITE_BASE44_FUNCTIONS_VERSION }),
}
}
export const appParams = {
...getAppParams()
}

View File

@ -0,0 +1,11 @@
import { QueryClient } from '@tanstack/react-query';
export const queryClientInstance = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: 1,
},
},
});

View File

@ -0,0 +1,9 @@
import { clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs) {
return twMerge(clsx(inputs))
}
export const isIframe = window.self !== window.top;

View File

@ -0,0 +1,22 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from '@/App.jsx'
import '@/index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
// <React.StrictMode>
<App />
// </React.StrictMode>,
)
if (import.meta.hot) {
import.meta.hot.on('vite:beforeUpdate', () => {
window.parent?.postMessage({ type: 'sandbox:beforeUpdate' }, '*');
});
import.meta.hot.on('vite:afterUpdate', () => {
window.parent?.postMessage({ type: 'sandbox:afterUpdate' }, '*');
});
}

View File

@ -0,0 +1,53 @@
import DailyOverview from './pages/DailyOverview';
import Dashboard from './pages/Dashboard';
import HackalonAssignment from './pages/HackalonAssignment';
import HackalonManageProblems from './pages/HackalonManageProblems';
import HackalonOverview from './pages/HackalonOverview';
import HackalonSchedule from './pages/HackalonSchedule';
import HackalonStatus from './pages/HackalonStatus';
import HackalonTeamArea from './pages/HackalonTeamArea';
import Home from './pages/Home';
import KeyAllocation from './pages/KeyAllocation';
import ManageCrews from './pages/ManageCrews';
import ManageKeys from './pages/ManageKeys';
import ManagePermissions from './pages/ManagePermissions';
import ManagePositions from './pages/ManagePositions';
import ManageSquads from './pages/ManageSquads';
import ManageUsers from './pages/ManageUsers';
import ManageZones from './pages/ManageZones';
import MyProfile from './pages/MyProfile';
import MySchedule from './pages/MySchedule';
import Onboarding from './pages/Onboarding';
import DataExport from './pages/DataExport';
import __Layout from './Layout.jsx';
export const PAGES = {
"DailyOverview": DailyOverview,
"Dashboard": Dashboard,
"HackalonAssignment": HackalonAssignment,
"HackalonManageProblems": HackalonManageProblems,
"HackalonOverview": HackalonOverview,
"HackalonSchedule": HackalonSchedule,
"HackalonStatus": HackalonStatus,
"HackalonTeamArea": HackalonTeamArea,
"Home": Home,
"KeyAllocation": KeyAllocation,
"ManageCrews": ManageCrews,
"ManageKeys": ManageKeys,
"ManagePermissions": ManagePermissions,
"ManagePositions": ManagePositions,
"ManageSquads": ManageSquads,
"ManageUsers": ManageUsers,
"ManageZones": ManageZones,
"MyProfile": MyProfile,
"MySchedule": MySchedule,
"Onboarding": Onboarding,
"DataExport": DataExport,
}
export const pagesConfig = {
mainPage: "Home",
Pages: PAGES,
Layout: __Layout,
};

View File

@ -0,0 +1,633 @@
import React, { useState, useMemo } from 'react';
import { base44 } from '@/api/base44Client';
import { useQuery } from '@tanstack/react-query';
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Calendar, Clock, Loader2, Key, Info } from 'lucide-react';
import { motion } from 'framer-motion';
import { format } from 'date-fns';
export default function DailyOverview() {
const [selectedDate, setSelectedDate] = useState(format(new Date(), 'yyyy-MM-dd'));
const [filterType, setFilterType] = useState('all'); // 'all', 'room', 'crew', 'platoon'
const [selectedRoom, setSelectedRoom] = useState('');
const [selectedCrew, setSelectedCrew] = useState('');
const [selectedPlatoon, setSelectedPlatoon] = useState('');
const [selectedPlatoonForCrew, setSelectedPlatoonForCrew] = useState('');
const [selectedUnit, setSelectedUnit] = useState(null);
const [showModal, setShowModal] = useState(false);
const { data: lessons = [], isLoading } = useQuery({
queryKey: ['daily-lessons', selectedDate],
queryFn: () => base44.entities.Lesson.filter({ date: selectedDate }, 'start_time')
});
const { data: squads = [] } = useQuery({
queryKey: ['squads'],
queryFn: () => base44.entities.Squad.list('order')
});
const { data: crews = [] } = useQuery({
queryKey: ['crews'],
queryFn: () => base44.entities.Crew.list('order')
});
const { data: keys = [] } = useQuery({
queryKey: ['keys'],
queryFn: () => base44.entities.ClassroomKey.list()
});
// Generate time slots (07:00 - 23:59)
const timeSlots = useMemo(() => {
const slots = [];
for (let hour = 7; hour <= 23; hour++) {
slots.push(`${String(hour).padStart(2, '0')}:00`);
}
return slots;
}, []);
// Get all unique platoons
const platoons = useMemo(() => {
const platoonSet = new Set(squads.map(s => s.platoon_name).filter(Boolean));
return Array.from(platoonSet).sort();
}, [squads]);
// Get crew names (filtered by platoon if selected for crew filter)
const crewNames = useMemo(() => {
if (filterType === 'crew' && selectedPlatoonForCrew) {
return squads
.filter(s => s.platoon_name === selectedPlatoonForCrew)
.map(s => s.squad_number)
.sort((a, b) => {
const numA = parseInt(a.match(/\d+/)?.[0] || 0);
const numB = parseInt(b.match(/\d+/)?.[0] || 0);
return numA - numB;
});
}
return crews.map(c => c.name).sort((a, b) => a.localeCompare(b, 'he'));
}, [crews, filterType, selectedPlatoonForCrew, squads]);
// Get room numbers
const roomNumbers = useMemo(() => {
return keys.map(k => k.room_number).sort((a, b) => {
const numA = parseInt(a) || 0;
const numB = parseInt(b) || 0;
return numA - numB;
});
}, [keys]);
// Generate colors for crews/squads
const crewColors = useMemo(() => {
const colors = [
'bg-blue-100 border-blue-300 text-blue-800',
'bg-green-100 border-green-300 text-green-800',
'bg-purple-100 border-purple-300 text-purple-800',
'bg-pink-100 border-pink-300 text-pink-800',
'bg-orange-100 border-orange-300 text-orange-800',
'bg-teal-100 border-teal-300 text-teal-800',
'bg-indigo-100 border-indigo-300 text-indigo-800',
'bg-rose-100 border-rose-300 text-rose-800',
];
const colorMap = {};
// If filtering by platoon, assign colors to squads in that platoon
if (filterType === 'platoon' && selectedPlatoon) {
const platoonSquads = squads
.filter(s => s.platoon_name === selectedPlatoon)
.map(s => s.squad_number)
.sort((a, b) => {
const numA = parseInt(a.match(/\d+/)?.[0] || 0);
const numB = parseInt(b.match(/\d+/)?.[0] || 0);
return numA - numB;
});
platoonSquads.forEach((squad, idx) => {
colorMap[squad] = colors[idx % colors.length];
});
} else {
// Otherwise use crew names
crewNames.forEach((crew, idx) => {
colorMap[crew] = colors[idx % colors.length];
});
}
return colorMap;
}, [crewNames, filterType, selectedPlatoon, squads]);
// Get display data based on filter type
const displayData = useMemo(() => {
if (filterType === 'room' && selectedRoom) {
// Show timeline for a specific room
return {
type: 'room',
items: [{ id: selectedRoom, name: `חדר ${selectedRoom}`, platoon: null }]
};
} else if (filterType === 'crew' && selectedCrew) {
// Show timeline for a specific crew
return {
type: 'crew',
items: [{ id: selectedCrew, name: selectedCrew, platoon: null }]
};
} else if (filterType === 'platoon' && selectedPlatoon) {
// Show the platoon itself and all squads in the platoon
const items = [];
// Add the platoon row (for platoon-level lessons)
items.push({ id: `platoon_${selectedPlatoon}`, name: selectedPlatoon, platoon: selectedPlatoon, isPlatoon: true });
// Add all squads in this platoon
const platoonSquads = squads
.filter(s => s.platoon_name === selectedPlatoon)
.map(s => ({ id: s.id, name: s.squad_number, platoon: selectedPlatoon, isPlatoon: false }))
.sort((a, b) => {
const numA = parseInt(a.name.match(/\d+/)?.[0] || 0);
const numB = parseInt(b.name.match(/\d+/)?.[0] || 0);
return numA - numB;
});
items.push(...platoonSquads);
return {
type: 'platoon',
items: items
};
} else {
// Show all platoons and crews
const items = [];
// Add all platoons
platoons.forEach(platoonName => {
items.push({
id: `platoon_${platoonName}`,
name: platoonName,
platoon: platoonName,
isPlatoon: true
});
});
// Add only crews (not platoons)
crews.filter(c => c.name.includes('צוות')).forEach(c => {
items.push({
id: c.id,
name: c.name,
platoon: squads.find(s => s.squad_number === c.name)?.platoon_name || null,
isPlatoon: false
});
});
return {
type: 'all',
items: items.sort((a, b) => a.name.localeCompare(b.name, 'he'))
};
}
}, [filterType, selectedRoom, selectedCrew, selectedPlatoon, crews, squads]);
// Check if a lesson is active during a time slot
const isLessonActive = (lesson, timeSlot) => {
const slotStart = timeSlot;
const slotEnd = `${String(parseInt(timeSlot.split(':')[0]) + 1).padStart(2, '0')}:00`;
return lesson.start_time < slotEnd && lesson.end_time > slotStart;
};
// Get lesson for a specific unit and time slot
const getLessonForSlot = (unitName, timeSlot, item) => {
if (displayData.type === 'room') {
// For room view, find any lesson using this room
return lessons.find(lesson =>
lesson.assigned_key === unitName.replace('חדר ', '') && isLessonActive(lesson, timeSlot)
);
} else {
// For crew/platoon view, find lesson by crew name OR platoon name (if it's a platoon row)
return lessons.find(lesson => {
if (!isLessonActive(lesson, timeSlot)) return false;
// If this is a platoon row, show platoon-level lessons
if (item?.isPlatoon) {
return lesson.crew_name === unitName || lesson.platoon_name === unitName;
}
// Otherwise, show crew lessons
return lesson.crew_name === unitName;
});
}
};
// Get all lessons for a unit
const getUnitLessons = (unitName) => {
return lessons.filter(lesson => lesson.crew_name === unitName)
.sort((a, b) => a.start_time.localeCompare(b.start_time));
};
// Handle cell click
const handleCellClick = (unitName, lesson) => {
setSelectedUnit({ name: unitName, lesson });
setShowModal(true);
};
// Get status color
const getStatusColor = (status) => {
switch (status) {
case 'assigned':
return 'bg-green-100 border-green-300 text-green-800';
case 'pending':
return 'bg-yellow-100 border-yellow-300 text-yellow-800';
case 'completed':
return 'bg-slate-100 border-slate-300 text-slate-600';
default:
return 'bg-slate-50 border-slate-200 text-slate-500';
}
};
// Get cell color based on filter type
const getCellColor = (lesson) => {
if (!lesson) return '';
if (displayData.type === 'platoon') {
// If this is a platoon-level lesson (crew_name is the platoon itself), use unique platoon color
if (lesson.crew_name === selectedPlatoon) {
return 'bg-amber-100 border-amber-300 text-amber-800';
}
// Different color for each squad/crew
return crewColors[lesson.crew_name] || getStatusColor(lesson.status);
} else {
// Default status color
return getStatusColor(lesson.status);
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-100" dir="rtl">
<div className="max-w-[95vw] mx-auto px-4 sm:px-6 lg:px-8 py-8">
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="mb-8">
<h1 className="text-3xl font-bold text-slate-800 mb-2">
תמונת מצב יומית 📊
</h1>
<p className="text-slate-500">מעקב אחר לוח הזמנים של כל הצוותים והפלוגות</p>
</motion.div>
{/* Controls */}
<div className="flex flex-col gap-4 mb-6">
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-4">
<div className="flex items-center gap-3">
<Label className="text-sm font-medium">תאריך:</Label>
<Input
type="date"
value={selectedDate}
onChange={(e) => setSelectedDate(e.target.value)}
className="w-auto"
/>
</div>
<div className="flex items-center gap-3">
<Label className="text-sm font-medium">סינון לפי:</Label>
<Select value={filterType} onValueChange={(value) => {
setFilterType(value);
setSelectedRoom('');
setSelectedCrew('');
setSelectedPlatoon('');
setSelectedPlatoonForCrew('');
}} dir="rtl">
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent dir="rtl">
<SelectItem value="all">הכל</SelectItem>
<SelectItem value="room">חדר ספציפי</SelectItem>
<SelectItem value="crew">צוות ספציפי</SelectItem>
<SelectItem value="platoon">פלוגה ספציפית</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Secondary filters */}
{filterType === 'room' && (
<div className="flex items-center gap-3">
<Label className="text-sm font-medium">בחר חדר:</Label>
<Select value={selectedRoom} onValueChange={setSelectedRoom} dir="rtl">
<SelectTrigger className="w-48">
<SelectValue placeholder="בחר חדר..." />
</SelectTrigger>
<SelectContent dir="rtl">
{roomNumbers.map((room) => (
<SelectItem key={room} value={room}>חדר {room}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{filterType === 'crew' && (
<div className="flex items-center gap-3">
<Label className="text-sm font-medium">בחר פלוגה:</Label>
<Select value={selectedPlatoonForCrew} onValueChange={(value) => {
setSelectedPlatoonForCrew(value);
setSelectedCrew('');
}} dir="rtl">
<SelectTrigger className="w-48">
<SelectValue placeholder="בחר פלוגה..." />
</SelectTrigger>
<SelectContent dir="rtl">
{platoons.map((platoon) => (
<SelectItem key={platoon} value={platoon}>{platoon}</SelectItem>
))}
</SelectContent>
</Select>
{selectedPlatoonForCrew && (
<>
<Label className="text-sm font-medium">בחר צוות:</Label>
<Select value={selectedCrew} onValueChange={setSelectedCrew} dir="rtl">
<SelectTrigger className="w-48">
<SelectValue placeholder="בחר צוות..." />
</SelectTrigger>
<SelectContent dir="rtl">
{crewNames.map((crew) => (
<SelectItem key={crew} value={crew}>{crew}</SelectItem>
))}
</SelectContent>
</Select>
</>
)}
</div>
)}
{filterType === 'platoon' && (
<div className="flex items-center gap-3">
<Label className="text-sm font-medium">בחר פלוגה:</Label>
<Select value={selectedPlatoon} onValueChange={setSelectedPlatoon} dir="rtl">
<SelectTrigger className="w-48">
<SelectValue placeholder="בחר פלוגה..." />
</SelectTrigger>
<SelectContent dir="rtl">
{platoons.map((platoon) => (
<SelectItem key={platoon} value={platoon}>{platoon}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
{/* Stats */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-6">
<Card className="p-4">
<p className="text-sm text-slate-500">סה״כ שיעורים</p>
<p className="text-2xl font-bold text-slate-800">{lessons.length}</p>
</Card>
<Card className="p-4 bg-green-50 border-green-200">
<p className="text-sm text-green-600">שובצו</p>
<p className="text-2xl font-bold text-green-700">
{lessons.filter(l => l.status === 'assigned').length}
</p>
</Card>
<Card className="p-4 bg-yellow-50 border-yellow-200">
<p className="text-sm text-yellow-600">ממתינים</p>
<p className="text-2xl font-bold text-yellow-700">
{lessons.filter(l => l.status === 'pending').length}
</p>
</Card>
<Card className="p-4 bg-blue-50 border-blue-200">
<p className="text-sm text-blue-600">יחידות פעילות</p>
<p className="text-2xl font-bold text-blue-700">
{new Set(lessons.map(l => l.crew_name)).size}
</p>
</Card>
</div>
{/* Legend */}
<Card className="p-4 mb-6">
<div className="flex flex-wrap items-center gap-4 text-sm">
<span className="font-semibold text-slate-700">מקרא:</span>
{displayData.type === 'platoon' ? (
<>
<Badge className="bg-amber-100 border-amber-300 text-amber-800 hover:bg-amber-100">
{selectedPlatoon} (פלוגה)
</Badge>
{displayData.items
.filter(item => !item.isPlatoon)
.map(item => (
<Badge
key={item.id}
className={`${crewColors[item.name]} hover:${crewColors[item.name]}`}
>
{item.name}
</Badge>
))}
</>
) : (
<>
<Badge className="bg-green-100 text-green-800 hover:bg-green-100">שובץ</Badge>
<Badge className="bg-yellow-100 text-yellow-800 hover:bg-yellow-100">ממתין</Badge>
<Badge className="bg-slate-100 text-slate-600 hover:bg-slate-100">הושלם</Badge>
</>
)}
</div>
</Card>
{/* Timeline Table */}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-slate-400" />
</div>
) : (
<Card className="overflow-hidden border-slate-200">
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr className="bg-slate-50 border-b-2 border-slate-200">
<th className="sticky right-0 z-20 bg-slate-50 p-3 text-right font-semibold text-slate-700 border-l-2 border-slate-200 min-w-[150px]">
{displayData.type === 'room' ? 'חדר' :
displayData.type === 'crew' ? 'צוות' :
displayData.type === 'platoon' ? 'צוותים בפלוגה' : 'צוותים'}
</th>
{timeSlots.map((slot) => (
<th key={slot} className="p-2 text-center font-medium text-slate-600 text-sm min-w-[100px] border-l border-slate-200">
<div className="flex items-center justify-center gap-1">
<Clock className="w-3 h-3" />
{slot}
</div>
</th>
))}
</tr>
</thead>
<tbody>
{displayData.items.map((item) => (
<tr key={item.id} className="border-b border-slate-200 hover:bg-slate-50/50">
<td className="sticky right-0 z-10 bg-white p-3 font-medium text-slate-700 border-l-2 border-slate-200">
{item.name}
</td>
{timeSlots.map((slot) => {
const lesson = getLessonForSlot(item.name, slot, item);
return (
<td key={slot} className="p-1 border-l border-slate-200 align-middle">
{lesson ? (
<div
onClick={() => handleCellClick(item.name, lesson)}
className={`p-2 rounded border-2 text-center text-xs cursor-pointer hover:shadow-lg transition-all ${getCellColor(lesson)}`}>
{displayData.type === 'room' ? (
<>
<div className="font-bold mb-1">{lesson.crew_name}</div>
<div className="text-[10px] opacity-75">
{lesson.start_time}-{lesson.end_time}
</div>
{lesson.room_type_needed && (
<div className="mt-1">
{lesson.room_type_needed === 'פלוגתי' ? '🏢' : '🏠'}
</div>
)}
</>
) : (
<>
<div className="font-bold mb-1">
{lesson.assigned_key ? `חדר ${lesson.assigned_key}` : 'לא שובץ'}
</div>
<div className="text-[10px] opacity-75">
{lesson.start_time}-{lesson.end_time}
</div>
{displayData.type === 'platoon' && (
<div className="text-[10px] font-semibold mt-1">
{lesson.crew_name}
</div>
)}
{displayData.type === 'all' && (
<div className="text-[10px] opacity-75 mt-1">
{lesson.crew_name.includes('צוות')
? `צוות: ${lesson.crew_name}`
: `פלוגה: ${lesson.platoon_name}`
}
</div>
)}
</>
)}
</div>
) : (
<div className="h-full min-h-[60px]"></div>
)}
</td>
);
})}
</tr>
))}
{displayData.items.length === 0 && (
<tr>
<td colSpan={timeSlots.length + 1} className="p-8 text-center text-slate-400">
{filterType === 'room' && !selectedRoom && 'בחר חדר להצגת לוח הזמנים'}
{filterType === 'crew' && !selectedCrew && 'בחר צוות להצגת לוח הזמנים'}
{filterType === 'platoon' && !selectedPlatoon && 'בחר פלוגה להצגת לוח הזמנים'}
{filterType === 'all' && 'אין צוותים להצגה'}
</td>
</tr>
)}
</tbody>
</table>
</div>
</Card>
)}
{/* Details Modal */}
<Dialog open={showModal} onOpenChange={setShowModal}>
<DialogContent className="sm:max-w-lg" dir="rtl">
<DialogHeader className="text-right">
<DialogTitle className="flex items-center gap-2 flex-row-reverse justify-end">
<div className="p-2 bg-blue-100 rounded-lg">
<Info className="w-5 h-5 text-blue-600" />
</div>
פרטי שיעור
</DialogTitle>
</DialogHeader>
{selectedUnit && selectedUnit.lesson && (
<div className="space-y-4 py-4">
{/* Crew/Platoon Info */}
<div className="p-4 bg-slate-50 rounded-lg">
<div className="text-sm text-slate-600 mb-1">
{displayData.type === 'room' ? 'חדר' : 'צוות'}:
</div>
<div className="text-xl font-bold text-slate-800">{selectedUnit.name}</div>
{selectedUnit.lesson.platoon_name && (
<Badge variant="outline" className="mt-2 bg-purple-50">
{selectedUnit.lesson.platoon_name}
</Badge>
)}
</div>
{/* Lesson Details */}
<Card className={`p-4 border-2 ${getStatusColor(selectedUnit.lesson.status)}`}>
<div className="space-y-3">
<div className="flex items-center gap-3">
<Clock className="w-5 h-5 text-slate-600" />
<div>
<div className="text-sm text-slate-500">שעות השיעור</div>
<div className="text-lg font-bold">
{selectedUnit.lesson.end_time} - {selectedUnit.lesson.start_time}
</div>
</div>
</div>
{selectedUnit.lesson.assigned_key ? (
<div className="flex items-center gap-3">
<Key className="w-5 h-5 text-slate-600" />
<div>
<div className="text-sm text-slate-500">חדר משובץ</div>
<div className="text-lg font-bold">חדר {selectedUnit.lesson.assigned_key}</div>
</div>
</div>
) : (
<div className="flex items-center gap-3">
<Key className="w-5 h-5 text-slate-400" />
<div className="text-slate-500">טרם שובץ חדר</div>
</div>
)}
<div className="pt-3 border-t">
<div className="text-sm text-slate-500 mb-2">דרישות</div>
<div className="flex items-center gap-2 flex-wrap">
<Badge variant="outline">
{selectedUnit.lesson.room_type_needed === 'פלוגתי' ? '🏢 חדר פלוגתי' : '🏠 חדר צוותי'}
</Badge>
{selectedUnit.lesson.needs_computers && (
<Badge variant="outline">💻 נדרשים מחשבים</Badge>
)}
<Badge className={
selectedUnit.lesson.status === 'assigned'
? 'bg-green-600 hover:bg-green-600'
: 'bg-yellow-600 hover:bg-yellow-600'
}>
{selectedUnit.lesson.status === 'assigned' ? '✓ שובץ' : '⏳ ממתין'}
</Badge>
</div>
</div>
{selectedUnit.lesson.notes && (
<div className="pt-3 border-t">
<div className="text-sm text-slate-500 mb-1">הערות</div>
<div className="text-sm text-slate-700">{selectedUnit.lesson.notes}</div>
</div>
)}
</div>
</Card>
</div>
)}
</DialogContent>
</Dialog>
</div>
</div>
);
}

View File

@ -0,0 +1,520 @@
import React, { useState } from 'react';
import { base44 } from '@/api/base44Client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Plus, Key, Clock, Users, Filter, Calendar } from 'lucide-react';
import { AnimatePresence, motion } from 'framer-motion';
import { toast } from 'sonner';
import { format } from 'date-fns';
import KeyCard from '@/components/keys/KeyCard';
import CheckoutModal from '@/components/keys/CheckoutModal';
import WaitingQueueCard from '@/components/queue/WaitingQueueCard';
import AddToQueueModal from '@/components/queue/AddToQueueModal';
import StatsBar from '@/components/stats/StatsBar';
export default function Dashboard() {
const [checkoutKey, setCheckoutKey] = useState(null);
const [showQueueModal, setShowQueueModal] = useState(false);
const [filter, setFilter] = useState('all');
const [timeFilter, setTimeFilter] = useState({ start: '', end: '' });
const [selectedDate, setSelectedDate] = useState(format(new Date(), 'yyyy-MM-dd'));
const [user, setUser] = useState(null);
const queryClient = useQueryClient();
const [userLoading, setUserLoading] = React.useState(true);
React.useEffect(() => {
base44.auth.me().then(user => {
console.log('👤 User loaded in Dashboard:', {
email: user.email,
platoon_name: user.platoon_name,
squad_name: user.squad_name
});
setUser(user);
setUserLoading(false);
}).catch(() => {
setUserLoading(false);
});
}, []);
const { data: keys = [], isLoading: keysLoading } = useQuery({
queryKey: ['keys'],
queryFn: () => base44.entities.ClassroomKey.list()
});
const { data: crews = [] } = useQuery({
queryKey: ['crews'],
queryFn: () => base44.entities.Crew.list()
});
const { data: squads = [] } = useQuery({
queryKey: ['squads'],
queryFn: () => base44.entities.Squad.list('order')
});
const { data: allQueue = [] } = useQuery({
queryKey: ['queue', selectedDate],
queryFn: async () => {
return base44.entities.WaitingQueue.filter({ date: selectedDate }, 'priority');
}
});
// Filter crews and squads based on user's platoon (admins see all)
const isAdmin = user?.role === 'admin';
// Filter queue by platoon and squad (admins see all)
const queue = (isAdmin || !user
? allQueue
: allQueue.filter(item =>
item.platoon_name === user.platoon_name ||
item.crew_name === user.squad_name
));
const filteredCrews = (isAdmin || !user?.platoon_name
? crews
: crews.filter((crew) => crew.name === user.platoon_name))
.sort((a, b) => a.name.localeCompare(b.name, 'he'));
const filteredSquads = (isAdmin || !user?.platoon_name
? squads
: squads.filter((squad) => squad.platoon_name === user.platoon_name))
.sort((a, b) => {
const numA = parseInt(a.squad_number.match(/\d+/)?.[0] || 0);
const numB = parseInt(b.squad_number.match(/\d+/)?.[0] || 0);
return numA - numB;
});
const { data: todayLessons = [] } = useQuery({
queryKey: ['today-lessons', selectedDate],
queryFn: async () => {
return base44.entities.Lesson.filter({ date: selectedDate });
}
});
// Auto-return keys after end time
React.useEffect(() => {
if (!keys.length) return;
const checkAndReturnKeys = async () => {
const now = new Date();
const currentTime = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
const keysToReturn = keys.filter(key =>
key.status === 'taken' &&
key.checkout_end_time &&
currentTime >= key.checkout_end_time
);
for (const key of keysToReturn) {
await updateKeyMutation.mutateAsync({
id: key.id,
data: {
status: 'available',
current_holder: null,
checkout_time: null,
checkout_start_time: null,
checkout_end_time: null,
checked_out_by: null
}
});
}
};
const interval = setInterval(checkAndReturnKeys, 60000); // Check every minute
checkAndReturnKeys(); // Check immediately on mount
return () => clearInterval(interval);
}, [keys]);
// Get current key holder for a room (considering time filter if active)
const getCurrentHolder = (roomNumber) => {
// If time filter is active, don't show current holder
if (timeFilter.start && timeFilter.end) {
return null;
}
const now = new Date();
const currentTime = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
const currentLesson = todayLessons.find(lesson =>
lesson.assigned_key === roomNumber &&
lesson.start_time <= currentTime &&
lesson.end_time > currentTime
);
return currentLesson ? currentLesson.crew_name : null;
};
const updateKeyMutation = useMutation({
mutationFn: ({ id, data }) => base44.entities.ClassroomKey.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['keys'] });
toast.success('מפתח עודכן בהצלחה');
}
});
const addToQueueMutation = useMutation({
mutationFn: (data) => {
return base44.entities.WaitingQueue.create({
...data,
date: selectedDate,
priority: queue.length + 1,
crew_manager: user?.email
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['queue'] });
queryClient.invalidateQueries({ queryKey: ['all-lessons'] });
setShowQueueModal(false);
toast.success('נוסף לתור המתנה');
}
});
const removeFromQueueMutation = useMutation({
mutationFn: (id) => base44.entities.WaitingQueue.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['queue'] });
toast.success('הוסר מהתור');
}
});
const handleCheckout = async (key, holderName, startTime, endTime, platoonName) => {
if (!user?.email) {
toast.error('שגיאה: משתמש לא מחובר');
return;
}
// Check for time conflicts with existing lessons
const existingLessons = await base44.entities.Lesson.filter({
date: selectedDate,
assigned_key: key.room_number
});
// Check for overlap
const hasConflict = existingLessons.some(lesson =>
lesson.start_time < endTime && startTime < lesson.end_time
);
if (hasConflict) {
toast.error('החדר תפוס בשעות אלה. בדוק את לוח הזמנים');
return;
}
// Create a lesson for this checkout
await base44.entities.Lesson.create({
crew_manager: user.email,
crew_name: holderName,
platoon_name: platoonName || '',
date: selectedDate,
start_time: startTime,
end_time: endTime,
room_type_needed: key.room_type,
needs_computers: key.has_computers || false,
assigned_key: key.room_number,
status: 'assigned',
notes: 'משיכה ידנית מלוח בקרה'
});
// Update key status
updateKeyMutation.mutate({
id: key.id,
data: {
status: 'taken',
current_holder: holderName,
checkout_time: new Date().toISOString(),
checkout_start_time: startTime,
checkout_end_time: endTime,
checked_out_by: user?.email
}
});
// Refresh lessons
queryClient.invalidateQueries({ queryKey: ['today-lessons'] });
queryClient.invalidateQueries({ queryKey: ['my-lessons'] });
queryClient.invalidateQueries({ queryKey: ['all-lessons'] });
queryClient.invalidateQueries({ queryKey: ['lessons'] });
setCheckoutKey(null);
};
const handleReturn = (key) => {
const isAdmin = user?.role === 'admin';
const isKeyOwner = key.checked_out_by === user?.email;
if (!isAdmin && !isKeyOwner) {
toast.error('רק המשתמש שלקח את המפתח או המנהל יכולים להחזיר אותו');
return;
}
updateKeyMutation.mutate({
id: key.id,
data: {
status: 'available',
current_holder: null,
checkout_time: null,
checkout_start_time: null,
checkout_end_time: null,
checked_out_by: null
}
});
};
const handleMoveUp = async (item) => {
const currentIndex = queue.findIndex((q) => q.id === item.id);
if (currentIndex > 0) {
const prevItem = queue[currentIndex - 1];
await base44.entities.WaitingQueue.update(item.id, { priority: prevItem.priority });
await base44.entities.WaitingQueue.update(prevItem.id, { priority: item.priority });
queryClient.invalidateQueries({ queryKey: ['queue'] });
}
};
// Check if a key is available during the time filter
const isKeyAvailableInTimeRange = (key) => {
if (!timeFilter.start || !timeFilter.end) return true;
// Find lessons for this specific room
const roomLessons = todayLessons.filter(lesson =>
lesson.assigned_key === key.room_number &&
(lesson.status === 'assigned' || lesson.status === 'pending')
);
// Check if any lesson conflicts with this time range
const hasConflict = roomLessons.some(lesson => {
// Check time overlap: lessons overlap if start1 < end2 AND start2 < end1
const overlap = lesson.start_time < timeFilter.end && timeFilter.start < lesson.end_time;
return overlap;
});
return !hasConflict;
};
const filteredKeys = keys
.filter((k) => filter === 'all' || k.room_type === filter)
.filter((k) => isKeyAvailableInTimeRange(k));
if (userLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 via-white to-slate-100">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-slate-800"></div>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-100" dir="rtl">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="mb-8">
<h1 className="text-3xl font-bold text-slate-800 mb-2">
ניהול מפתחות 🔑
</h1>
</motion.div>
{/* Stats */}
<StatsBar keys={keys} queueCount={queue.length} />
{/* Main Content */}
<Tabs defaultValue="keys" className="space-y-6">
<div className="flex flex-col sm:flex-row-reverse sm:items-center sm:justify-between gap-4">
<TabsList className="bg-white border border-slate-200 p-1">
<TabsTrigger value="queue" className="data-[state=active]:bg-slate-100">
תור ({queue.length})
<Clock className="w-4 h-4 ml-2" />
</TabsTrigger>
<TabsTrigger value="keys" className="data-[state=active]:bg-slate-100">
מפתחות
<Key className="w-4 h-4 ml-2" />
</TabsTrigger>
</TabsList>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => setShowQueueModal(true)} className="bg-background text-blue-600 px-4 py-2 text-sm font-medium rounded-md inline-flex items-center justify-center gap-2 whitespace-nowrap transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 border shadow-sm hover:text-accent-foreground h-9 border-blue-200">
הוסף בקשה מיוחדת
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
<TabsContent value="keys" className="space-y-6">
{/* Filters */}
<div className="space-y-4">
{/* Room Type Filter */}
<div className="flex flex-row-reverse items-center gap-2">
<span className="text-sm text-slate-500">:סוג חדר</span>
<Filter className="w-4 h-4 text-slate-400" />
<div className="flex flex-row-reverse gap-2">
{['all', 'צוותי', 'פלוגתי'].map((f) =>
<Button
key={f}
variant={filter === f ? 'default' : 'outline'}
size="sm"
onClick={() => setFilter(f)}
className={filter === f ? 'bg-slate-800' : ''}>
{f === 'all' ? 'הכל' : f === 'צוותי' ? '🏠 צוותי' : '🏢 פלוגתי'}
</Button>
)}
</div>
</div>
{/* Date and Time Range Filter */}
<div className="flex flex-col sm:flex-row sm:flex-row-reverse items-stretch sm:items-center gap-4 bg-white p-4 rounded-lg border border-slate-200">
<div className="flex flex-row-reverse items-center gap-3">
<Label className="text-sm font-medium text-slate-700">:תאריך</Label>
<Calendar className="w-4 h-4 text-slate-600" />
<Input
type="date"
value={selectedDate}
onChange={(e) => setSelectedDate(e.target.value)}
className="w-auto"
/>
</div>
<div className="flex flex-row-reverse items-center gap-3">
<span className="text-sm font-medium text-slate-700">:סנן לפי זמינות</span>
<Clock className="w-5 h-5 text-slate-600" />
<div className="flex items-center gap-2">
<input
type="time"
value={timeFilter.end}
onChange={(e) => setTimeFilter({ ...timeFilter, end: e.target.value })}
className="px-3 py-1.5 border border-slate-300 rounded-md text-sm"
/>
<span className="text-slate-500">עד</span>
<input
type="time"
value={timeFilter.start}
onChange={(e) => setTimeFilter({ ...timeFilter, start: e.target.value })}
className="px-3 py-1.5 border border-slate-300 rounded-md text-sm"
/>
{(timeFilter.start || timeFilter.end) && (
<Button
variant="ghost"
size="sm"
onClick={() => setTimeFilter({ start: '', end: '' })}
className="text-slate-500 hover:text-slate-700"
>
נקה
</Button>
)}
</div>
{timeFilter.start && timeFilter.end && (
<span className="text-xs text-emerald-600 font-medium">
מציג {filteredKeys.length} כיתות זמינות
</span>
)}
</div>
</div>
</div>
{/* Keys Grid */}
{keysLoading ?
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[1, 2, 3].map((i) =>
<div key={i} className="h-48 bg-slate-100 rounded-xl animate-pulse" />
)}
</div> :
filteredKeys.length === 0 ?
<div className="text-center py-16 bg-white rounded-2xl border border-dashed border-slate-200">
<Key className="w-12 h-12 text-slate-300 mx-auto mb-4" />
<h3 className="text-lg font-medium text-slate-600 mb-2">אין מפתחות עדיין</h3>
<p className="text-slate-400 mb-4">הוסף את המפתח הראשון שלך כדי להתחיל</p>
</div> :
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4" dir="rtl">
<AnimatePresence>
{filteredKeys.map((key) =>
<KeyCard
key={key.id}
keyItem={key}
crews={crews}
currentUser={user}
currentHolder={getCurrentHolder(key.room_number)}
isAvailableInTimeRange={isKeyAvailableInTimeRange(key)}
timeFilterActive={!!(timeFilter.start && timeFilter.end)}
onCheckout={(key) => {
setCheckoutKey({
...key,
prefilledTimes: timeFilter.start && timeFilter.end ? timeFilter : null
});
}}
onReturn={handleReturn} />
)}
</AnimatePresence>
</div>
}
</TabsContent>
<TabsContent value="queue" className="space-y-4">
{queue.length === 0 ?
<div className="text-center py-16 bg-white rounded-2xl border border-dashed border-slate-200">
<Clock className="w-12 h-12 text-slate-300 mx-auto mb-4" />
<h3 className="text-lg font-medium text-slate-600 mb-2">אין בקשות מיוחדות</h3>
<Button onClick={() => setShowQueueModal(true)}>
<Plus className="w-4 h-4 ml-2" />
הוסף לתור
</Button>
</div> :
<div className="space-y-3">
<AnimatePresence>
{queue.map((item, index) =>
<WaitingQueueCard
key={item.id}
item={item}
position={index + 1}
onRemove={() => removeFromQueueMutation.mutate(item.id)}
onMoveUp={handleMoveUp}
isAdmin={isAdmin} />
)}
</AnimatePresence>
</div>
}
</TabsContent>
</Tabs>
</div>
{/* Modals */}
{checkoutKey && (
<CheckoutModal
open={!!checkoutKey}
onClose={() => setCheckoutKey(null)}
keyItem={checkoutKey}
crews={filteredCrews}
squads={filteredSquads}
currentUser={user}
selectedDate={selectedDate}
onConfirm={handleCheckout} />
)}
{showQueueModal && (
<AddToQueueModal
open={showQueueModal}
onClose={() => setShowQueueModal(false)}
crews={filteredCrews}
squads={filteredSquads}
currentUser={user}
onConfirm={(data) => addToQueueMutation.mutate(data)} />
)}
</div>);
}

View File

@ -0,0 +1,549 @@
import React, { useState, useEffect } from 'react';
import { base44 } from '@/api/base44Client';
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Database, Download, Loader2, CheckCircle2, Copy } from 'lucide-react';
import { toast } from 'sonner';
export default function DataExport() {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(false);
const [loadingDDL, setLoadingDDL] = useState(false);
const [loadingRLS, setLoadingRLS] = useState(false);
const [exportedSQL, setExportedSQL] = useState('');
const [selectedEntities, setSelectedEntities] = useState({});
const entities = [
'HackalonSubmission',
'HackalonTeam',
'HackalonDepartment',
'HackalonScheduleItem',
'ClassroomKey',
'Crew',
'Squad',
'Position',
'Zone',
'PositionPermission',
'WaitingQueue',
'Lesson'
];
useEffect(() => {
const loadUser = async () => {
try {
const u = await base44.auth.me();
setUser(u);
// Initialize all entities as selected
const initial = {};
entities.forEach(e => initial[e] = true);
setSelectedEntities(initial);
} catch (error) {
console.error(error);
}
};
loadUser();
}, []);
const escapeSQLString = (str) => {
if (str === null || str === undefined) return 'NULL';
if (typeof str === 'boolean') return str ? 'true' : 'false';
if (typeof str === 'number') return str;
if (Array.isArray(str)) return `'${JSON.stringify(str).replace(/'/g, "''")}'`;
if (typeof str === 'object') return `'${JSON.stringify(str).replace(/'/g, "''")}'`;
return `'${String(str).replace(/'/g, "''")}'`;
};
const formatDateForSQL = (date) => {
if (!date) return 'NULL';
try {
return `'${new Date(date).toISOString()}'`;
} catch {
return 'NULL';
}
};
const generateUUID = () => {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
};
const generateSQLForEntity = (entityName, records, schema) => {
if (!records || records.length === 0) return '';
const tableName = entityName.toLowerCase();
let sql = `-- ${entityName} (${records.length} records)\n`;
// Get all possible columns from schema and records
const schemaFields = schema?.properties ? Object.keys(schema.properties) : [];
const recordFields = new Set();
records.forEach(record => {
Object.keys(record).forEach(key => recordFields.add(key));
});
const allFields = [...new Set([...schemaFields, ...recordFields])];
const fields = ['id', 'created_date', 'updated_date', 'created_by', ...allFields.filter(f => !['id', 'created_date', 'updated_date', 'created_by'].includes(f))];
records.forEach(record => {
const values = fields.map(field => {
const value = record[field];
// Handle special fields
if (field === 'id') return escapeSQLString(record.id || generateUUID());
if (field === 'created_date' || field === 'updated_date') return formatDateForSQL(value);
if (field === 'created_by') return escapeSQLString(value);
// Handle different data types based on schema
const fieldSchema = schema?.properties?.[field];
if (fieldSchema) {
if (fieldSchema.format === 'date' || fieldSchema.format === 'date-time') {
return formatDateForSQL(value);
}
if (fieldSchema.type === 'boolean') {
return value === true ? 'true' : 'false';
}
if (fieldSchema.type === 'number' || fieldSchema.type === 'integer') {
return value !== null && value !== undefined ? value : 'NULL';
}
if (fieldSchema.type === 'array' || fieldSchema.type === 'object') {
return escapeSQLString(value);
}
}
return escapeSQLString(value);
});
sql += `INSERT INTO ${tableName} (${fields.join(', ')}) VALUES (${values.join(', ')});\n`;
});
sql += '\n';
return sql;
};
const handleExport = async () => {
setLoading(true);
let allSQL = `-- Base44 Data Export to PostgreSQL\n`;
allSQL += `-- Generated: ${new Date().toISOString()}\n`;
allSQL += `-- App: ${window.location.origin}\n\n`;
allSQL += `BEGIN;\n\n`;
try {
for (const entityName of entities) {
if (!selectedEntities[entityName]) continue;
try {
// Fetch entity data
const records = await base44.entities[entityName].list();
// Fetch entity schema
let schema = null;
try {
schema = await base44.entities[entityName].schema();
} catch (e) {
console.warn(`Could not fetch schema for ${entityName}:`, e);
}
// Generate SQL
const sql = generateSQLForEntity(entityName, records, schema);
allSQL += sql;
} catch (error) {
console.error(`Error exporting ${entityName}:`, error);
allSQL += `-- Error exporting ${entityName}: ${error.message}\n\n`;
}
}
allSQL += `COMMIT;\n`;
setExportedSQL(allSQL);
toast.success('הנתונים יוצאו בהצלחה');
} catch (error) {
toast.error('שגיאה בייצוא הנתונים');
console.error(error);
} finally {
setLoading(false);
}
};
const handleDownload = () => {
const blob = new Blob([exportedSQL], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `base44_export_${new Date().toISOString().split('T')[0]}.sql`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success('הקובץ הורד בהצלחה');
};
const handleCopy = () => {
navigator.clipboard.writeText(exportedSQL);
toast.success('הועתק ללוח');
};
const getSQLType = (fieldSchema) => {
if (!fieldSchema) return 'TEXT';
if (fieldSchema.type === 'string') {
if (fieldSchema.format === 'date') return 'DATE';
if (fieldSchema.format === 'date-time') return 'TIMESTAMP WITH TIME ZONE';
if (fieldSchema.enum) return 'TEXT';
return 'TEXT';
}
if (fieldSchema.type === 'number') return 'NUMERIC';
if (fieldSchema.type === 'integer') return 'INTEGER';
if (fieldSchema.type === 'boolean') return 'BOOLEAN';
if (fieldSchema.type === 'array') return 'JSONB';
if (fieldSchema.type === 'object') return 'JSONB';
return 'TEXT';
};
const generateDDL = async () => {
setLoadingDDL(true);
let ddl = `-- DDL (Data Definition Language) Export\n`;
ddl += `-- Generated: ${new Date().toISOString()}\n`;
ddl += `-- App: ${window.location.origin}\n\n`;
try {
for (const entityName of entities) {
if (!selectedEntities[entityName]) continue;
try {
const schema = await base44.entities[entityName].schema();
const tableName = entityName.toLowerCase();
ddl += `-- ${entityName}\n`;
ddl += `CREATE TABLE ${tableName} (\n`;
// Built-in fields
ddl += ` id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n`;
ddl += ` created_date TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n`;
ddl += ` updated_date TIMESTAMP WITH TIME ZONE DEFAULT NOW(),\n`;
ddl += ` created_by TEXT,\n`;
// Schema fields
if (schema?.properties) {
const fields = Object.entries(schema.properties);
fields.forEach(([fieldName, fieldSchema], idx) => {
const sqlType = getSQLType(fieldSchema);
const isRequired = schema.required?.includes(fieldName);
const notNull = isRequired ? ' NOT NULL' : '';
const defaultVal = fieldSchema.default !== undefined
? ` DEFAULT ${typeof fieldSchema.default === 'string' ? `'${fieldSchema.default}'` : fieldSchema.default}`
: '';
ddl += ` ${fieldName} ${sqlType}${notNull}${defaultVal}`;
if (idx < fields.length - 1) ddl += ',';
if (fieldSchema.description) {
ddl += ` -- ${fieldSchema.description}`;
}
ddl += '\n';
});
}
ddl += `);\n\n`;
// Add indexes
ddl += `CREATE INDEX idx_${tableName}_created_date ON ${tableName}(created_date);\n`;
ddl += `CREATE INDEX idx_${tableName}_created_by ON ${tableName}(created_by);\n\n`;
} catch (error) {
ddl += `-- Error generating DDL for ${entityName}: ${error.message}\n\n`;
}
}
const blob = new Blob([ddl], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `base44_ddl_${new Date().toISOString().split('T')[0]}.sql`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success('קובץ DDL הורד בהצלחה');
} catch (error) {
toast.error('שגיאה ביצירת DDL');
console.error(error);
} finally {
setLoadingDDL(false);
}
};
const generateRLS = async () => {
setLoadingRLS(true);
let rls = `-- RLS (Row Level Security) Policies Export\n`;
rls += `-- Generated: ${new Date().toISOString()}\n`;
rls += `-- App: ${window.location.origin}\n\n`;
try {
for (const entityName of entities) {
if (!selectedEntities[entityName]) continue;
try {
const schema = await base44.entities[entityName].schema();
const tableName = entityName.toLowerCase();
if (!schema?.rls) {
rls += `-- ${entityName}: No RLS policies defined\n\n`;
continue;
}
rls += `-- ${entityName} RLS Policies\n`;
rls += `ALTER TABLE ${tableName} ENABLE ROW LEVEL SECURITY;\n\n`;
// Create policies
if (schema.rls.read) {
rls += `-- Read Policy\n`;
rls += `CREATE POLICY "${tableName}_read_policy" ON ${tableName}\n`;
rls += ` FOR SELECT\n`;
rls += ` USING (\n`;
rls += ` -- ${JSON.stringify(schema.rls.read, null, 2).replace(/\n/g, '\n -- ')}\n`;
rls += ` true -- Customize based on your RLS rules\n`;
rls += ` );\n\n`;
}
if (schema.rls.create) {
rls += `-- Create Policy\n`;
rls += `CREATE POLICY "${tableName}_create_policy" ON ${tableName}\n`;
rls += ` FOR INSERT\n`;
rls += ` WITH CHECK (\n`;
rls += ` -- ${JSON.stringify(schema.rls.create, null, 2).replace(/\n/g, '\n -- ')}\n`;
rls += ` true -- Customize based on your RLS rules\n`;
rls += ` );\n\n`;
}
if (schema.rls.update) {
rls += `-- Update Policy\n`;
rls += `CREATE POLICY "${tableName}_update_policy" ON ${tableName}\n`;
rls += ` FOR UPDATE\n`;
rls += ` USING (\n`;
rls += ` -- ${JSON.stringify(schema.rls.update, null, 2).replace(/\n/g, '\n -- ')}\n`;
rls += ` true -- Customize based on your RLS rules\n`;
rls += ` );\n\n`;
}
if (schema.rls.delete) {
rls += `-- Delete Policy\n`;
rls += `CREATE POLICY "${tableName}_delete_policy" ON ${tableName}\n`;
rls += ` FOR DELETE\n`;
rls += ` USING (\n`;
rls += ` -- ${JSON.stringify(schema.rls.delete, null, 2).replace(/\n/g, '\n -- ')}\n`;
rls += ` true -- Customize based on your RLS rules\n`;
rls += ` );\n\n`;
}
} catch (error) {
rls += `-- Error generating RLS for ${entityName}: ${error.message}\n\n`;
}
}
const blob = new Blob([rls], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `base44_rls_${new Date().toISOString().split('T')[0]}.sql`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success('קובץ RLS הורד בהצלחה');
} catch (error) {
toast.error('שגיאה ביצירת RLS');
console.error(error);
} finally {
setLoadingRLS(false);
}
};
if (!user) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-100 flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-slate-400" />
</div>
);
}
if (user.role !== 'admin') {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-100 flex items-center justify-center" dir="rtl">
<Card className="p-8 text-center max-w-md">
<Database className="w-16 h-16 text-red-400 mx-auto mb-4" />
<h2 className="text-2xl font-bold text-slate-800 mb-2">אין הרשאת גישה</h2>
<p className="text-slate-600">רק מנהלים יכולים לייצא נתונים</p>
</Card>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-100" dir="rtl">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold text-slate-800 mb-2 flex items-center gap-3">
<Database className="w-8 h-8 text-indigo-600" />
ייצוא נתונים ל-PostgreSQL
</h1>
<p className="text-slate-500">ייצוא כל הנתונים בפורמט SQL תואם Supabase</p>
</div>
{/* Entity Selection */}
<Card className="p-6 mb-6">
<h2 className="text-lg font-bold text-slate-800 mb-4">בחר טבלאות לייצוא</h2>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
{entities.map(entity => (
<label key={entity} className="flex items-center gap-2 p-3 bg-slate-50 rounded-lg cursor-pointer hover:bg-slate-100 transition-colors">
<input
type="checkbox"
checked={selectedEntities[entity] || false}
onChange={(e) => setSelectedEntities({
...selectedEntities,
[entity]: e.target.checked
})}
className="w-4 h-4"
/>
<span className="text-sm font-medium text-slate-700">{entity}</span>
</label>
))}
</div>
<div className="flex gap-2 mt-4">
<Button
variant="outline"
size="sm"
onClick={() => {
const all = {};
entities.forEach(e => all[e] = true);
setSelectedEntities(all);
}}
>
בחר הכל
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
const none = {};
entities.forEach(e => none[e] = false);
setSelectedEntities(none);
}}
>
בטל הכל
</Button>
</div>
</Card>
{/* Export Buttons */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<Card className="p-6">
<h3 className="font-semibold text-slate-800 mb-2">ייצוא נתונים</h3>
<p className="text-sm text-slate-600 mb-4">INSERT statements</p>
<Button
onClick={handleExport}
disabled={loading || Object.values(selectedEntities).every(v => !v)}
className="w-full bg-indigo-600 hover:bg-indigo-700"
>
{loading ? (
<>
<Loader2 className="w-4 h-4 ml-2 animate-spin" />
מייצא...
</>
) : (
<>
<Database className="w-4 h-4 ml-2" />
ייצא נתונים
</>
)}
</Button>
</Card>
<Card className="p-6">
<h3 className="font-semibold text-slate-800 mb-2">DDL Schema</h3>
<p className="text-sm text-slate-600 mb-4">CREATE TABLE statements</p>
<Button
onClick={generateDDL}
disabled={loadingDDL || Object.values(selectedEntities).every(v => !v)}
className="w-full bg-green-600 hover:bg-green-700"
>
{loadingDDL ? (
<>
<Loader2 className="w-4 h-4 ml-2 animate-spin" />
מייצא...
</>
) : (
<>
<Download className="w-4 h-4 ml-2" />
הורד DDL
</>
)}
</Button>
</Card>
<Card className="p-6">
<h3 className="font-semibold text-slate-800 mb-2">RLS Policies</h3>
<p className="text-sm text-slate-600 mb-4">Row Level Security</p>
<Button
onClick={generateRLS}
disabled={loadingRLS || Object.values(selectedEntities).every(v => !v)}
className="w-full bg-purple-600 hover:bg-purple-700"
>
{loadingRLS ? (
<>
<Loader2 className="w-4 h-4 ml-2 animate-spin" />
מייצא...
</>
) : (
<>
<Download className="w-4 h-4 ml-2" />
הורד RLS
</>
)}
</Button>
</Card>
</div>
{/* Results */}
{exportedSQL && (
<Card className="p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<CheckCircle2 className="w-5 h-5 text-green-600" />
<h2 className="text-lg font-bold text-slate-800">SQL מוכן</h2>
</div>
<div className="flex gap-2">
<Button onClick={handleCopy} variant="outline" size="sm">
<Copy className="w-4 h-4 ml-2" />
העתק
</Button>
<Button onClick={handleDownload} size="sm">
<Download className="w-4 h-4 ml-2" />
הורד קובץ
</Button>
</div>
</div>
<div className="bg-slate-900 text-slate-100 p-4 rounded-lg overflow-x-auto max-h-[500px] overflow-y-auto">
<pre className="text-xs font-mono" dir="ltr">{exportedSQL}</pre>
</div>
<div className="mt-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<h3 className="font-semibold text-blue-900 mb-2">הוראות שימוש:</h3>
<ol className="text-sm text-blue-800 space-y-1 list-decimal list-inside">
<li>צור טבלאות ב-Supabase שתואמות את ה-schema של Base44</li>
<li>העתק את הקוד SQL או הורד את הקובץ</li>
<li>הרץ את הקוד ב-Supabase SQL Editor</li>
<li>וודא שכל ה-foreign keys מוגדרים נכון</li>
</ol>
</div>
</Card>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,731 @@
import React, { useState, useEffect } from 'react';
import { base44 } from '@/api/base44Client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Users, Building2, Plus, Edit2, Trash2, Loader2, Shield } from 'lucide-react';
import { motion } from 'framer-motion';
import { toast } from 'sonner';
export default function HackalonAssignment() {
const [user, setUser] = useState(null);
const [showDeptModal, setShowDeptModal] = useState(false);
const [showTeamModal, setShowTeamModal] = useState(false);
const [showAddMembersModal, setShowAddMembersModal] = useState(false);
const [editingDept, setEditingDept] = useState(null);
const [editingTeam, setEditingTeam] = useState(null);
const [selectedTeam, setSelectedTeam] = useState(null);
const [selectedUserForAssign, setSelectedUserForAssign] = useState(null);
const [showUserAssignModal, setShowUserAssignModal] = useState(false);
const [deptForm, setDeptForm] = useState({ name: '', icon: 'Users', classroom: '' });
const [teamForm, setTeamForm] = useState({ name: '', department: '' });
const [searchQuery, setSearchQuery] = useState('');
const [newMemberName, setNewMemberName] = useState('');
const [filterView, setFilterView] = useState('all');
const queryClient = useQueryClient();
useEffect(() => {
base44.auth.me().then(setUser).catch(() => {});
}, []);
const isAdmin = user?.role === 'admin';
const { data: departments = [] } = useQuery({
queryKey: ['hackalon-departments'],
queryFn: () => base44.entities.HackalonDepartment.list('order'),
enabled: isAdmin
});
const { data: teams = [] } = useQuery({
queryKey: ['hackalon-teams'],
queryFn: () => base44.entities.HackalonTeam.list('order'),
enabled: isAdmin
});
const { data: users = [] } = useQuery({
queryKey: ['users'],
queryFn: () => base44.entities.User.list(),
enabled: isAdmin
});
const { data: classroomKeys = [] } = useQuery({
queryKey: ['classroom-keys'],
queryFn: () => base44.entities.ClassroomKey.list(),
enabled: isAdmin
});
// Department mutations
const createDeptMutation = useMutation({
mutationFn: (data) => base44.entities.HackalonDepartment.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['hackalon-departments'] });
setShowDeptModal(false);
setDeptForm({ name: '', icon: 'Users', classroom: '' });
toast.success('המדור נוצר בהצלחה');
}
});
const updateDeptMutation = useMutation({
mutationFn: async ({ id, data, oldName }) => {
// Update the department
await base44.entities.HackalonDepartment.update(id, data);
// If name changed, update all teams in this department
if (oldName && data.name !== oldName) {
const deptTeams = teams.filter((t) => t.department_name === oldName);
for (const team of deptTeams) {
await base44.entities.HackalonTeam.update(team.id, {
department_name: data.name
});
// Also update users assigned to this team
const teamUsers = users.filter((u) => u.hackalon_department === oldName && u.hackalon_team === team.name);
for (const user of teamUsers) {
await base44.entities.User.update(user.id, {
hackalon_department: data.name
});
}
}
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['hackalon-departments'] });
queryClient.invalidateQueries({ queryKey: ['hackalon-teams'] });
queryClient.invalidateQueries({ queryKey: ['users'] });
setShowDeptModal(false);
setEditingDept(null);
setDeptForm({ name: '', icon: 'Users', classroom: '' });
toast.success('המדור עודכן בהצלחה');
}
});
const deleteDeptMutation = useMutation({
mutationFn: async (deptId) => {
const dept = departments.find((d) => d.id === deptId);
// Delete all teams in this department first
const deptTeams = teams.filter((t) => t.department_name === dept.name);
for (const team of deptTeams) {
// Remove team assignments from all users
const teamUsers = users.filter((u) => u.hackalon_team === team.name);
for (const user of teamUsers) {
await base44.entities.User.update(user.id, {
hackalon_team: null,
hackalon_department: null
});
}
// Delete the team
await base44.entities.HackalonTeam.delete(team.id);
}
// Finally delete the department
return base44.entities.HackalonDepartment.delete(deptId);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['hackalon-departments'] });
queryClient.invalidateQueries({ queryKey: ['hackalon-teams'] });
queryClient.invalidateQueries({ queryKey: ['users'] });
toast.success('המדור וכל הצוותים שלו נמחקו בהצלחה');
}
});
// Team mutations
const createTeamMutation = useMutation({
mutationFn: (data) => base44.entities.HackalonTeam.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['hackalon-teams'] });
setShowTeamModal(false);
setTeamForm({ name: '', department: '' });
toast.success('הצוות נוצר בהצלחה');
}
});
const updateTeamMutation = useMutation({
mutationFn: ({ id, data }) => base44.entities.HackalonTeam.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['hackalon-teams'] });
setShowTeamModal(false);
setEditingTeam(null);
setTeamForm({ name: '', department: '' });
toast.success('הצוות עודכן בהצלחה');
}
});
const deleteTeamMutation = useMutation({
mutationFn: (id) => base44.entities.HackalonTeam.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['hackalon-teams'] });
toast.success('הצוות נמחק בהצלחה');
}
});
// Add member to team mutation
const addMemberMutation = useMutation({
mutationFn: async ({ teamId, memberName, userId }) => {
const team = teams.find((t) => t.id === teamId);
const updatedMembers = [...(team.member_names || []), memberName];
await base44.entities.HackalonTeam.update(teamId, { member_names: updatedMembers });
// If user exists, assign them
if (userId) {
await base44.entities.User.update(userId, {
hackalon_department: team.department_name,
hackalon_team: team.name
});
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['hackalon-teams'] });
queryClient.invalidateQueries({ queryKey: ['users'] });
toast.success('הצוער נוסף בהצלחה');
setSearchQuery('');
setNewMemberName('');
}
});
// Remove member from team mutation
const removeMemberMutation = useMutation({
mutationFn: async ({ teamId, memberName }) => {
const team = teams.find((t) => t.id === teamId);
const updatedMembers = (team.member_names || []).filter((m) => m !== memberName);
await base44.entities.HackalonTeam.update(teamId, { member_names: updatedMembers });
// Remove assignment from user if exists
const userToUpdate = users.find((u) => (u.onboarding_full_name || u.full_name) === memberName && u.hackalon_team === team.name);
if (userToUpdate) {
await base44.entities.User.update(userToUpdate.id, {
hackalon_department: null,
hackalon_team: null
});
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['hackalon-teams'] });
queryClient.invalidateQueries({ queryKey: ['users'] });
toast.success('הצוער הוסר בהצלחה');
}
});
const handleSaveDept = () => {
if (!deptForm.name.trim()) return;
// Convert form data to match database fields
const dataToSave = {
name: deptForm.name,
icon: deptForm.icon,
classroom_number: deptForm.classroom // המרה לשדה הנכון
};
if (editingDept) {
updateDeptMutation.mutate({
id: editingDept.id,
data: dataToSave,
oldName: editingDept.name
});
} else {
createDeptMutation.mutate(dataToSave);
}
};
const handleAssignUserToTeam = (teamId, teamName, deptName) => {
if (!selectedUserForAssign) return;
const team = teams.find((t) => t.id === teamId);
const memberName = selectedUserForAssign.onboarding_full_name || selectedUserForAssign.full_name;
addMemberMutation.mutate({
teamId,
memberName,
userId: selectedUserForAssign.id
});
setShowUserAssignModal(false);
setSelectedUserForAssign(null);
};
const handleSaveTeam = () => {
if (!teamForm.name.trim() || !teamForm.department) return;
const data = {
name: teamForm.name,
department_name: teamForm.department
};
if (editingTeam) {
updateTeamMutation.mutate({ id: editingTeam.id, data });
} else {
createTeamMutation.mutate(data);
}
};
const handleOpenAddMembers = (team) => {
setSelectedTeam(team);
setShowAddMembersModal(true);
setSearchQuery('');
setNewMemberName('');
};
const handleAddExistingUser = (u) => {
if (!selectedTeam) return;
const memberName = u.onboarding_full_name || u.full_name;
addMemberMutation.mutate({ teamId: selectedTeam.id, memberName, userId: u.id });
};
const handleAddNewMember = () => {
if (!selectedTeam || !newMemberName.trim()) return;
addMemberMutation.mutate({ teamId: selectedTeam.id, memberName: newMemberName.trim(), userId: null });
};
const handleRemoveMember = (teamId, memberName) => {
removeMemberMutation.mutate({ teamId, memberName });
};
if (!user) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-100 flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-slate-400" />
</div>);
}
if (!isAdmin) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-100 flex items-center justify-center" dir="rtl">
<Card className="p-8 text-center max-w-md">
<Shield className="w-16 h-16 text-red-400 mx-auto mb-4" />
<h2 className="text-2xl font-bold text-slate-800 mb-2">אין הרשאת גישה</h2>
<p className="text-slate-600">רק מנהלי מערכת יכולים לשבץ צוערים</p>
</Card>
</div>);
}
const assignedUsers = users.filter((u) => u.hackalon_team);
const unassignedUsers = users.filter((u) => !u.hackalon_team);
// Get total members including those not yet registered
const getTotalMembers = () => {
return teams.reduce((sum, team) => sum + (team.member_names?.length || 0), 0);
};
// Filter users for search
const filteredUsers = users.filter((u) => {
const name = (u.onboarding_full_name || u.full_name || '').toLowerCase();
const email = (u.email || '').toLowerCase();
const query = searchQuery.toLowerCase();
return name.includes(query) || email.includes(query);
});
// Filter view logic
const getDisplayedUsers = () => {
if (filterView === 'assigned') return assignedUsers;
if (filterView === 'unassigned') return unassignedUsers;
return users;
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-100" dir="rtl">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="mb-8">
<h1 className="text-3xl font-bold text-slate-800 mb-2">
📋 שיבוץ צוערים ל-HackAlon
</h1>
<p className="text-slate-500">ניהול מדורים, צוותים ושיבוץ צוערים</p>
</motion.div>
{/* Stats */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
<Card
className={`p-4 cursor-pointer transition-all ${filterView === 'all' ? 'ring-2 ring-blue-500 bg-blue-50' : 'hover:shadow-md'}`}
onClick={() => setFilterView('all')}>
<p className="text-sm text-slate-500">סה״כ משתמשים</p>
<p className="text-2xl font-bold text-slate-800">{users.length}</p>
</Card>
<Card
className={`p-4 cursor-pointer transition-all ${filterView === 'assigned' ? 'ring-2 ring-green-500 bg-green-50' : 'bg-green-50 border-green-200 hover:shadow-md'}`}
onClick={() => setFilterView('assigned')}>
<p className="text-sm text-green-600">משובצים</p>
<p className="text-2xl font-bold text-green-700">{assignedUsers.length}</p>
</Card>
<Card
className={`p-4 cursor-pointer transition-all ${filterView === 'unassigned' ? 'ring-2 ring-orange-500 bg-orange-50' : 'bg-orange-50 border-orange-200 hover:shadow-md'}`}
onClick={() => setFilterView('unassigned')}>
<p className="text-sm text-orange-600">ממתינים לשיבוץ</p>
<p className="text-2xl font-bold text-orange-700">{unassignedUsers.length}</p>
</Card>
</div>
{/* Teams by Department */}
<div className="space-y-6 mb-6">
{departments.map((dept) => {
const deptTeams = teams.filter((t) => t.department_name === dept.name);
return (
<Card key={dept.id} className="p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<Building2 className="w-6 h-6 text-blue-600" />
<div>
<h2 className="text-xl font-bold text-slate-800">{dept.name}</h2>
<p className="text-sm text-slate-500">כיתה {dept.classroom_number || 'לא הוגדר'}</p>
</div>
</div>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={() => {setEditingDept(dept);setDeptForm({ name: dept.name, icon: dept.icon, classroom: dept.classroom_number || '' });setShowDeptModal(true);}}>
<Edit2 className="w-4 h-4 ml-2" />
ערוך מדור
</Button>
<Button size="sm" variant="outline" onClick={() => deleteDeptMutation.mutate(dept.id)} className="text-red-600">
<Trash2 className="w-4 h-4 ml-2" />
מחק
</Button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{deptTeams.map((team) =>
<Card key={team.id} className="p-4 hover:shadow-md transition-all cursor-pointer" onClick={() => handleOpenAddMembers(team)}>
<div className="flex items-start justify-between mb-2">
<div className="flex-1">
<p className="font-semibold text-slate-800">{team.name}</p>
</div>
<div className="flex gap-1">
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
setEditingTeam(team);
setTeamForm({ name: team.name, department: team.department_name });
setShowTeamModal(true);
}}>
<Edit2 className="w-3 h-3" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
if (window.confirm(`האם אתה בטוח שברצונך למחוק את הצוות "${team.name}"?`)) {
deleteTeamMutation.mutate(team.id);
}
}}
className="text-red-600 hover:text-red-700">
<Trash2 className="w-3 h-3" />
</Button>
</div>
</div>
<div className="flex items-center gap-2 text-sm text-slate-600 mb-2">
<Users className="w-4 h-4" />
<span>{team.member_names?.length || 0} צוערים</span>
</div>
{team.member_names && team.member_names.length > 0 &&
<div className="mt-2 space-y-1">
{team.member_names.slice(0, 3).map((name, idx) =>
<p key={idx} className="text-xs text-slate-500"> {name}</p>
)}
{team.member_names.length > 3 &&
<p className="text-xs text-slate-400">ועוד {team.member_names.length - 3}...</p>
}
</div>
}
</Card>
)}
<Card className="p-4 border-dashed border-2 flex items-center justify-center cursor-pointer hover:bg-slate-50" onClick={() => {setShowTeamModal(true);setEditingTeam(null);setTeamForm({ name: '', department: dept.name });}}>
<div className="text-center">
<Plus className="w-8 h-8 text-slate-400 mx-auto mb-2" />
<p className="text-sm text-slate-500">הוסף צוות</p>
</div>
</Card>
</div>
</Card>);
})}
{/* Add Department Card */}
<Card className="p-6 border-dashed border-2 flex items-center justify-center cursor-pointer hover:bg-slate-50" onClick={() => {setShowDeptModal(true);setEditingDept(null);setDeptForm({ name: '', icon: 'Users', classroom: '' });}}>
<div className="text-center">
<Plus className="w-8 h-8 text-slate-400 mx-auto mb-2" />
<p className="text-sm text-slate-500">הוסף מדור</p>
</div>
</Card>
</div>
{/* Management Sections - Hidden */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6 hidden">
{/* Departments */}
<Card className="p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-bold text-slate-800">מדורים</h2>
<Button onClick={() => {setShowDeptModal(true);setEditingDept(null);setDeptForm({ name: '', icon: 'Users', classroom: '' });}} size="sm">
<Plus className="w-4 h-4 ml-2" />
הוסף מדור
</Button>
</div>
<div className="space-y-2">
{departments.map((dept) =>
<div key={dept.id} className="flex items-center justify-between p-3 bg-slate-50 rounded-lg">
<span className="font-medium text-slate-800">{dept.name}</span>
<div className="flex gap-2">
<Button size="sm" variant="ghost" onClick={() => {setEditingDept(dept);setDeptForm({ name: dept.name, icon: dept.icon, classroom: dept.classroom_number || '' });setShowDeptModal(true);}}>
<Edit2 className="w-4 h-4" />
</Button>
<Button size="sm" variant="ghost" onClick={() => deleteDeptMutation.mutate(dept.id)} className="text-red-600">
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
)}
</div>
</Card>
{/* Teams */}
<Card className="p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-bold text-slate-800">צוותים</h2>
<Button onClick={() => {setShowTeamModal(true);setEditingTeam(null);setTeamForm({ name: '', department: '' });}} size="sm">
<Plus className="w-4 h-4 ml-2" />
הוסף צוות
</Button>
</div>
<div className="space-y-2 max-h-96 overflow-y-auto">
{teams.map((team) =>
<div key={team.id} className="flex items-center justify-between p-3 bg-slate-50 rounded-lg">
<div>
<p className="font-medium text-slate-800">{team.name}</p>
<p className="text-sm text-slate-500">{team.department_name}</p>
</div>
<div className="flex gap-2">
<Button size="sm" variant="ghost" onClick={() => {
setEditingTeam(team);
setTeamForm({ name: team.name, department: team.department_name });
setShowTeamModal(true);
}}>
<Edit2 className="w-4 h-4" />
</Button>
<Button size="sm" variant="ghost" onClick={() => deleteTeamMutation.mutate(team.id)} className="text-red-600">
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
)}
</div>
</Card>
</div>
{/* User List */}
<Card className="p-6">
<h2 className="text-xl font-bold text-slate-800 mb-4">רשימת צוערים</h2>
<div className="space-y-2 max-h-96 overflow-y-auto">
{getDisplayedUsers().map((u) =>
<div key={u.id} className="flex items-center justify-between p-3 bg-slate-50 rounded-lg">
<div>
<p className="font-medium text-slate-800">{u.onboarding_full_name || u.full_name}</p>
<p className="text-sm text-slate-500">{u.email}</p>
{u.hackalon_team &&
<p className="text-sm text-green-600 mt-1"> {u.hackalon_department} {u.hackalon_team}</p>
}
</div>
<Button size="sm" onClick={() => {setSelectedUserForAssign(u);setShowUserAssignModal(true);}}>
{u.hackalon_team ? 'שנה צוות' : 'שבץ לצוות'}
</Button>
</div>
)}
{getDisplayedUsers().length === 0 &&
<p className="text-center text-slate-400 py-8">אין משתמשים להצגה</p>
}
</div>
</Card>
{/* Department Modal */}
// Department Modal
<Dialog open={showDeptModal} onOpenChange={setShowDeptModal}>
<DialogContent dir="rtl">
<DialogHeader>
<DialogTitle className="text-lg font-semibold leading-none tracking-tight text-right">{editingDept ? 'ערוך מדור' : 'הוסף מדור'}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label>שם המדור</Label>
<Input value={deptForm.name} onChange={(e) => setDeptForm({ ...deptForm, name: e.target.value })} placeholder="הזן שם מדור" />
</div>
<div>
<Label>מספר כיתה</Label>
<select value={deptForm.classroom} onChange={(e) => setDeptForm({ ...deptForm, classroom: e.target.value })} className="w-full px-3 py-2 border rounded-md">
<option value="">בחר כיתה...</option>
{classroomKeys.map((key) => {
// בדיקה אם הכיתה תפוסה על ידי מדור אחר
const isOccupied = departments.some(
(d) => d.classroom_number === key.room_number &&
(!editingDept || d.id !== editingDept.id) // לא תופס על ידי המדור הנוכחי
);
return (
<option
key={key.id}
value={key.room_number}
disabled={isOccupied}
>
כיתה {key.room_number} ({key.room_type}) {isOccupied ? '- תפוס' : ''}
</option>
);
})}
</select>
</div>
<div className="flex gap-2">
<Button onClick={handleSaveDept} disabled={!deptForm.name.trim()} className="flex-1">שמור</Button>
<Button variant="outline" onClick={() => setShowDeptModal(false)} className="flex-1">ביטול</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* Team Modal */}
<Dialog open={showTeamModal} onOpenChange={setShowTeamModal}>
<DialogContent dir="rtl">
<DialogHeader>
<DialogTitle>{editingTeam ? 'ערוך צוות' : 'הוסף צוות'}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label>שם הצוות</Label>
<Input value={teamForm.name} onChange={(e) => setTeamForm({ ...teamForm, name: e.target.value })} placeholder="הזן שם צוות" />
</div>
<div>
<Label>מדור</Label>
<select value={teamForm.department} onChange={(e) => setTeamForm({ ...teamForm, department: e.target.value })} className="w-full px-3 py-2 border rounded-md">
<option value="">בחר מדור...</option>
{departments.map((d) => <option key={d.id} value={d.name}>{d.name} - כיתה {d.classroom_number}</option>)}
</select>
</div>
<div className="flex gap-2">
<Button onClick={handleSaveTeam} disabled={!teamForm.name.trim() || !teamForm.department} className="flex-1">שמור</Button>
<Button variant="outline" onClick={() => setShowTeamModal(false)} className="flex-1">ביטול</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* User Assignment Modal */}
<Dialog open={showUserAssignModal} onOpenChange={setShowUserAssignModal}>
<DialogContent dir="rtl">
<DialogHeader>
<DialogTitle>שבץ לצוות - {selectedUserForAssign?.onboarding_full_name || selectedUserForAssign?.full_name}</DialogTitle>
</DialogHeader>
<div className="space-y-3 max-h-96 overflow-y-auto">
{departments.map((dept) => {
const deptTeams = teams.filter((t) => t.department_name === dept.name);
return (
<div key={dept.id} className="border rounded-lg p-3">
<h3 className="font-semibold text-slate-800 mb-2">{dept.name}</h3>
<div className="space-y-1">
{deptTeams.map((team) =>
<Button
key={team.id}
variant="outline"
className="w-full justify-start"
onClick={() => handleAssignUserToTeam(team.id, team.name, dept.name)}>
{team.name}
</Button>
)}
</div>
</div>);
})}
</div>
</DialogContent>
</Dialog>
{/* Add Members Modal */}
<Dialog open={showAddMembersModal} onOpenChange={setShowAddMembersModal}>
<DialogContent dir="rtl" className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="text-lg font-semibold leading-none tracking-tight text-right">הוספת צוערים - {selectedTeam?.name}</DialogTitle>
</DialogHeader>
{selectedTeam &&
<div className="space-y-4 flex-1 overflow-hidden flex flex-col">
{/* Current Members */}
<div className="border-b pb-4">
<h3 className="font-semibold text-slate-700 mb-2">צוערים בצוות ({selectedTeam.member_names?.length || 0})</h3>
{selectedTeam.member_names && selectedTeam.member_names.length > 0 ?
<div className="flex flex-wrap gap-2">
{selectedTeam.member_names.map((name, idx) =>
<div key={idx} className="flex items-center gap-2 px-3 py-1 bg-slate-100 rounded-lg">
<span className="text-sm">{name}</span>
<button onClick={() => handleRemoveMember(selectedTeam.id, name)} className="text-red-600 hover:text-red-700">
<Trash2 className="w-3 h-3" />
</button>
</div>
)}
</div> :
<p className="text-sm text-slate-400">אין צוערים עדיין</p>
}
</div>
{/* Search Existing Users */}
<div className="flex-1 overflow-hidden flex flex-col">
<h3 className="font-semibold text-slate-700 mb-2">הוסף צוער קיים</h3>
<Input
placeholder="חפש לפי שם או אימייל..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="mb-2" />
<div className="space-y-2 overflow-y-auto flex-1">
{filteredUsers.
filter((u) => !selectedTeam.member_names?.includes(u.onboarding_full_name || u.full_name)).
map((u) =>
<div key={u.id} className="flex items-center justify-between p-2 bg-slate-50 rounded-lg">
<div>
<p className="text-sm font-medium">{u.onboarding_full_name || u.full_name}</p>
<p className="text-xs text-slate-500">{u.email}</p>
</div>
<Button size="sm" onClick={() => handleAddExistingUser(u)}>הוסף</Button>
</div>
)}
</div>
</div>
{/* Add New Member */}
<div className="border-t pt-4">
<h3 className="font-semibold text-slate-700 mb-2">הוסף צוער שעדיין לא נרשם</h3>
<div className="flex gap-2">
<Input
placeholder="שם מלא"
value={newMemberName}
onChange={(e) => setNewMemberName(e.target.value)} />
<Button onClick={handleAddNewMember} disabled={!newMemberName.trim()}>הוסף</Button>
</div>
</div>
</div>
}
</DialogContent>
</Dialog>
</div>
</div>);
}

View File

@ -0,0 +1,301 @@
import React, { useState, useEffect } from 'react';
import { base44 } from '@/api/base44Client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Edit2, Lightbulb, Loader2, Shield } from 'lucide-react';
import { motion } from 'framer-motion';
import { toast } from 'sonner';
export default function HackalonManageProblems() {
const [user, setUser] = useState(null);
const [showModal, setShowModal] = useState(false);
const [selectedTeam, setSelectedTeam] = useState(null);
const [problemForm, setProblemForm] = useState({
name: '',
intro: '',
objective: '',
requirements: '',
template_url: '',
deadline: '',
final_deadline: ''
});
const queryClient = useQueryClient();
useEffect(() => {
base44.auth.me().then(setUser).catch(() => {});
}, []);
const isAdmin = user?.role === 'admin';
const { data: departments = [] } = useQuery({
queryKey: ['hackalon-departments'],
queryFn: () => base44.entities.HackalonDepartment.list('order'),
enabled: isAdmin
});
const { data: teams = [] } = useQuery({
queryKey: ['hackalon-teams'],
queryFn: () => base44.entities.HackalonTeam.list('order'),
enabled: isAdmin
});
const updateProblemMutation = useMutation({
mutationFn: ({ id, data }) => base44.entities.HackalonTeam.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['hackalon-teams'] });
setShowModal(false);
setSelectedTeam(null);
setProblemForm({ name: '', intro: '', objective: '', requirements: '', template_url: '', deadline: '', final_deadline: '' });
toast.success('הבעיה עודכנה בהצלחה');
}
});
const handleEditProblem = (team) => {
setSelectedTeam(team);
setProblemForm({
name: team.problem_name || '',
intro: team.problem_intro || '',
objective: team.problem_objective || '',
requirements: team.problem_requirements || '',
template_url: team.specification_template_url || '',
deadline: team.specification_deadline ? new Date(team.specification_deadline).toISOString().slice(0, 16) : '',
final_deadline: team.final_product_deadline ? new Date(team.final_product_deadline).toISOString().slice(0, 16) : ''
});
setShowModal(true);
};
const handleSaveProblem = () => {
if (!selectedTeam) return;
updateProblemMutation.mutate({
id: selectedTeam.id,
data: {
problem_name: problemForm.name,
problem_intro: problemForm.intro,
problem_objective: problemForm.objective,
problem_requirements: problemForm.requirements,
specification_template_url: problemForm.template_url || null,
specification_deadline: problemForm.deadline ? new Date(problemForm.deadline).toISOString() : null,
final_product_deadline: problemForm.final_deadline ? new Date(problemForm.final_deadline).toISOString() : null
}
});
};
if (!user) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-100 flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-slate-400" />
</div>
);
}
if (!isAdmin) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-100 flex items-center justify-center" dir="rtl">
<Card className="p-8 text-center max-w-md">
<Shield className="w-16 h-16 text-red-400 mx-auto mb-4" />
<h2 className="text-2xl font-bold text-slate-800 mb-2">אין הרשאת גישה</h2>
<p className="text-slate-600">רק מנהלי מערכת יכולים לנהל בעיות</p>
</Card>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-100" dir="rtl">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="mb-8"
>
<h1 className="text-3xl font-bold text-slate-800 mb-2">
🎯 ניהול בעיות HackAlon
</h1>
<p className="text-slate-500">הגדרת בעיות לכל צוות</p>
</motion.div>
{/* Stats */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
<Card className="p-4">
<p className="text-sm text-slate-500">סה״כ צוותים</p>
<p className="text-2xl font-bold text-slate-800">{teams.length}</p>
</Card>
<Card className="p-4 bg-green-50 border-green-200">
<p className="text-sm text-green-600">צוותים עם בעיה מוגדרת</p>
<p className="text-2xl font-bold text-green-700">{teams.filter(t => t.problem_name).length}</p>
</Card>
</div>
{/* Departments and Teams */}
<div className="space-y-6">
{departments.map((dept, index) => {
const deptTeams = teams.filter(t => t.department_name === dept.name);
return (
<motion.div
key={dept.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
>
<Card className="p-6">
<h3 className="text-xl font-bold text-slate-800 mb-4">{dept.name}</h3>
{deptTeams.length > 0 ? (
<div className="space-y-3">
{deptTeams.map(team => (
<Card key={team.id} className="p-4 bg-slate-50 border-slate-200">
<div className="flex items-start justify-between">
<div className="flex-1">
<h4 className="font-semibold text-slate-800 mb-2">{team.name}</h4>
{team.problem_name ? (
<div>
<p className="text-sm text-slate-700">{team.problem_name}</p>
</div>
) : (
<p className="text-sm text-slate-400 italic">אין בעיה מוגדרת</p>
)}
</div>
<Button size="sm" onClick={() => handleEditProblem(team)} className="mr-4">
<Edit2 className="w-4 h-4 ml-2" />
{team.problem_name ? 'ערוך' : 'הגדר בעיה'}
</Button>
</div>
</Card>
))}
</div>
) : (
<p className="text-slate-400 text-center py-4">אין צוותים במדור זה</p>
)}
</Card>
</motion.div>
);
})}
</div>
{/* Edit Problem Modal */}
<Dialog open={showModal} onOpenChange={setShowModal}>
<DialogContent dir="rtl" className="max-w-4xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-lg font-semibold leading-none tracking-tight text-right">הגדרת בעיה - {selectedTeam?.name}</DialogTitle>
</DialogHeader>
<div className="space-y-6">
{/* Template Upload Section */}
<div className="border-b pb-4">
<Label className="text-base font-semibold mb-2 block">טמפלייט למסמך איפיון</Label>
<p className="text-xs text-slate-500 mb-2">העלה קובץ שהצוערים יכולים להוריד ולמלא</p>
<input
type="file"
id="template-upload"
className="hidden"
onChange={async (e) => {
const file = e.target.files[0];
if (!file) return;
try {
const { file_url } = await base44.integrations.Core.UploadFile({ file });
setProblemForm({...problemForm, template_url: file_url});
toast.success('הטמפלייט הועלה בהצלחה');
} catch (error) {
toast.error('שגיאה בהעלאת הטמפלייט');
}
}}
/>
<div className="flex gap-2">
<label htmlFor="template-upload">
<Button type="button" asChild variant="outline">
<span>העלה טמפלייט</span>
</Button>
</label>
{problemForm.template_url && (
<a href={problemForm.template_url} target="_blank" rel="noopener noreferrer">
<Button type="button" variant="outline">צפה בטמפלייט הנוכחי</Button>
</a>
)}
</div>
</div>
{/* Deadline Section */}
<div className="border-b pb-4">
<Label className="text-base font-semibold mb-2 block">דדליין להגשת מסמך איפיון</Label>
<Input
type="datetime-local"
value={problemForm.deadline || ''}
onChange={(e) => setProblemForm({...problemForm, deadline: e.target.value})}
/>
</div>
{/* Final Product Deadline Section */}
<div className="border-b pb-4">
<Label className="text-base font-semibold mb-2 block">דדליין להגשת תוצר סופי</Label>
<Input
type="datetime-local"
value={problemForm.final_deadline || ''}
onChange={(e) => setProblemForm({...problemForm, final_deadline: e.target.value})}
/>
</div>
<div>
<Label className="text-base font-semibold mb-2 block">שם הבעיה</Label>
<Input
value={problemForm.name}
onChange={(e) => setProblemForm({...problemForm, name: e.target.value})}
placeholder="הזן שם לבעיה"
/>
</div>
<div className="border-t pt-4 space-y-6">
<div>
<Label className="text-base font-semibold mb-2 block">מבוא</Label>
<p className="text-xs text-slate-500 mb-2">הצג את ההקשר והרקע לבעיה</p>
<Textarea
value={problemForm.intro}
onChange={(e) => setProblemForm({...problemForm, intro: e.target.value})}
placeholder="הזן מבוא"
rows={6}
/>
</div>
<div>
<Label className="text-base font-semibold mb-2 block">מטרת המוצר</Label>
<p className="text-xs text-slate-500 mb-2">מה המוצר אמור להשיג ולמי הוא מיועד</p>
<Textarea
value={problemForm.objective}
onChange={(e) => setProblemForm({...problemForm, objective: e.target.value})}
placeholder="הזן מטרה"
rows={6}
/>
</div>
<div>
<Label className="text-base font-semibold mb-2 block">דרישות מרכזיות</Label>
<p className="text-xs text-slate-500 mb-2">פרט את הדרישות והפיצ׳רים העיקריים</p>
<Textarea
value={problemForm.requirements}
onChange={(e) => setProblemForm({...problemForm, requirements: e.target.value})}
placeholder="הזן דרישות"
rows={6}
/>
</div>
</div>
<div className="flex gap-2 sticky bottom-0 bg-white pt-4 border-t">
<Button onClick={handleSaveProblem} className="flex-1">שמור</Button>
<Button variant="outline" onClick={() => setShowModal(false)} className="flex-1">ביטול</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
</div>
);
}

View File

@ -0,0 +1,296 @@
import React, { useState, useEffect } from 'react';
import { base44 } from '@/api/base44Client';
import { useQuery } from '@tanstack/react-query';
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Users, Building2, Loader2, FileText, Presentation, Download, X, Shield } from 'lucide-react';
import { motion } from 'framer-motion';
export default function HackalonOverview() {
const [user, setUser] = useState(null);
const [selectedTeam, setSelectedTeam] = useState(null);
const [showTeamModal, setShowTeamModal] = useState(false);
const [userPermissions, setUserPermissions] = useState(null);
useEffect(() => {
const loadUserData = async () => {
try {
const u = await base44.auth.me();
setUser(u);
// Load user permissions
if (u.positions && u.positions.length > 0) {
const allPermissions = await base44.entities.PositionPermission.list();
const userPositionPerms = allPermissions.filter(p => u.positions.includes(p.position_name));
const mergedPerms = {
pages_access: [...new Set(userPositionPerms.flatMap(p => p.pages_access || []))]
};
setUserPermissions(mergedPerms);
}
} catch (error) {}
};
loadUserData();
}, []);
const isAdmin = user?.role === 'admin';
const allowedPositions = ['מפק״ץ', 'מנהל האקתון'];
const hasAllowedPosition = user?.positions?.some(pos => allowedPositions.includes(pos));
const hasAccess = isAdmin || hasAllowedPosition;
const { data: departments = [], isLoading: depsLoading } = useQuery({
queryKey: ['hackalon-departments'],
queryFn: () => base44.entities.HackalonDepartment.list('order'),
enabled: hasAccess
});
const { data: teams = [], isLoading: teamsLoading } = useQuery({
queryKey: ['hackalon-teams'],
queryFn: () => base44.entities.HackalonTeam.list('order'),
enabled: hasAccess
});
const { data: submissions = [] } = useQuery({
queryKey: ['hackalon-submissions'],
queryFn: () => base44.entities.HackalonSubmission.list(),
enabled: hasAccess
});
if (!user || depsLoading || teamsLoading) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-100 flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-slate-400" />
</div>
);
}
if (!hasAccess) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-100 flex items-center justify-center" dir="rtl">
<Card className="p-8 text-center max-w-md">
<Shield className="w-16 h-16 text-red-400 mx-auto mb-4" />
<h2 className="text-2xl font-bold text-slate-800 mb-2">אין הרשאת גישה</h2>
<p className="text-slate-600">אין לך הרשאה לצפות בעמוד זה</p>
</Card>
</div>
);
}
const getSubmission = (teamName, type) => {
return submissions.find(s => s.team_name === teamName && s.submission_type === type);
};
const getTeamProgress = (teamName) => {
const hasSpec = !!getSubmission(teamName, 'specification');
const hasFinal = !!getSubmission(teamName, 'final_product');
return [hasSpec, hasFinal].filter(Boolean).length;
};
const handleTeamClick = (team) => {
setSelectedTeam(team);
setShowTeamModal(true);
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-100" dir="rtl">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="mb-8"
>
<h1 className="text-3xl font-bold text-slate-800 mb-2">
💡 HackAlon - סקירה כללית
</h1>
<p className="text-slate-500">מדורים, צוותים ומיקומים</p>
</motion.div>
{/* Stats */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
<Card className="p-4 bg-blue-50 border-blue-200">
<p className="text-sm text-blue-600">סה״כ מדורים</p>
<p className="text-2xl font-bold text-blue-700">{departments.length}</p>
</Card>
<Card className="p-4 bg-purple-50 border-purple-200">
<p className="text-sm text-purple-600">סה״כ צוותים</p>
<p className="text-2xl font-bold text-purple-700">{teams.length}</p>
</Card>
</div>
{/* Departments and Teams */}
<div className="space-y-6">
{departments.map((dept, index) => {
const deptTeams = teams.filter(t => t.department_name === dept.name);
return (
<motion.div
key={dept.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
>
<Card className="p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
<Building2 className="w-6 h-6 text-blue-600" />
</div>
<div>
<h3 className="text-xl font-bold text-slate-800">{dept.name}</h3>
<p className="text-sm text-slate-500">{deptTeams.length} צוותים כיתה {dept.classroom_number || 'לא הוגדר'}</p>
</div>
</div>
{deptTeams.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{deptTeams.map(team => {
const progress = getTeamProgress(team.name);
return (
<Card
key={team.id}
className="p-4 bg-slate-50 border-slate-200 hover:shadow-md transition-all cursor-pointer"
onClick={() => handleTeamClick(team)}
>
<div className="flex items-start gap-2 mb-3">
<Users className="w-5 h-5 text-purple-600 mt-1" />
<div className="flex-1">
<p className="font-semibold text-slate-800">{team.name}</p>
</div>
</div>
{/* Progress bar */}
<div className="flex gap-1 mt-2">
{[1, 2].map((step) => (
<div
key={step}
className={`h-2 flex-1 rounded-full ${
step <= progress ? 'bg-green-500' : 'bg-slate-200'
}`}
/>
))}
</div>
<p className="text-xs text-slate-500 mt-1 text-center">{progress}/2 הועלו</p>
</Card>
);
})}
</div>
) : (
<p className="text-slate-400 text-center py-4">אין צוותים במדור זה</p>
)}
</Card>
</motion.div>
);
})}
{departments.length === 0 && (
<Card className="p-12 text-center">
<Building2 className="w-12 h-12 text-slate-300 mx-auto mb-4" />
<h3 className="text-lg font-medium text-slate-600 mb-2">אין מדורים עדיין</h3>
<p className="text-slate-400">צור מדורים וצוותים בדף השיבוץ</p>
</Card>
)}
</div>
{/* Team Details Modal */}
<Dialog open={showTeamModal} onOpenChange={setShowTeamModal}>
<DialogContent dir="rtl" className="max-w-3xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center justify-between">
<span>{selectedTeam?.name}</span>
</DialogTitle>
</DialogHeader>
{selectedTeam && (
<div className="space-y-6">
{/* Problem Definition */}
{selectedTeam.problem_name && (
<div className="border-b pb-4">
<h3 className="text-lg font-bold text-slate-800 mb-3">הבעיה</h3>
<div className="space-y-3 bg-slate-50 p-4 rounded-lg">
<div>
<h4 className="font-semibold text-purple-600 mb-1">{selectedTeam.problem_name}</h4>
</div>
{selectedTeam.problem_intro && (
<div>
<h5 className="text-sm font-semibold text-slate-600 mb-1">מבוא</h5>
<p className="text-sm text-slate-600 whitespace-pre-wrap">{selectedTeam.problem_intro}</p>
</div>
)}
{selectedTeam.problem_objective && (
<div>
<h5 className="text-sm font-semibold text-slate-600 mb-1">מטרת המוצר</h5>
<p className="text-sm text-slate-600 whitespace-pre-wrap">{selectedTeam.problem_objective}</p>
</div>
)}
{selectedTeam.problem_requirements && (
<div>
<h5 className="text-sm font-semibold text-slate-600 mb-1">דרישות מרכזיות</h5>
<p className="text-sm text-slate-600 whitespace-pre-wrap">{selectedTeam.problem_requirements}</p>
</div>
)}
</div>
</div>
)}
{/* Submissions */}
<div>
<h3 className="text-lg font-bold text-slate-800 mb-3">מסמכים שהועלו</h3>
<div className="grid grid-cols-1 gap-3">
{/* Specification */}
<div className="flex items-center justify-between p-4 bg-slate-50 rounded-lg border">
<div className="flex items-center gap-3">
<FileText className="w-6 h-6 text-green-600" />
<div>
<p className="font-medium">מסמך איפיון</p>
{getSubmission(selectedTeam.name, 'specification') && (
<p className="text-xs text-slate-500">
הועלה על ידי {getSubmission(selectedTeam.name, 'specification').uploaded_by}
</p>
)}
</div>
</div>
{getSubmission(selectedTeam.name, 'specification') ? (
<a href={getSubmission(selectedTeam.name, 'specification').file_url} target="_blank" rel="noopener noreferrer">
<Button size="sm">
<Download className="w-4 h-4 ml-2" />
הורד
</Button>
</a>
) : (
<span className="text-sm text-slate-400">לא הועלה</span>
)}
</div>
{/* Final Product */}
<div className="flex items-center justify-between p-4 bg-slate-50 rounded-lg border">
<div className="flex items-center gap-3">
<Presentation className="w-6 h-6 text-purple-600" />
<div>
<p className="font-medium">תוצר סופי</p>
{getSubmission(selectedTeam.name, 'final_product') && (
<p className="text-xs text-slate-500">
הועלה על ידי {getSubmission(selectedTeam.name, 'final_product').uploaded_by}
</p>
)}
</div>
</div>
{getSubmission(selectedTeam.name, 'final_product') ? (
<a href={getSubmission(selectedTeam.name, 'final_product').file_url} target="_blank" rel="noopener noreferrer">
<Button size="sm">
<Download className="w-4 h-4 ml-2" />
הורד
</Button>
</a>
) : (
<span className="text-sm text-slate-400">לא הועלה</span>
)}
</div>
</div>
</div>
</div>
)}
</DialogContent>
</Dialog>
</div>
</div>
);
}

View File

@ -0,0 +1,558 @@
import React, { useState, useEffect } from 'react';
import { base44 } from '@/api/base44Client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Plus, Loader2, Edit2, Trash2, Calendar, ChevronLeft, ChevronRight } from 'lucide-react';
import { motion } from 'framer-motion';
import { toast } from 'sonner';
import { format, addDays, subDays, parseISO } from 'date-fns';
export default function HackalonSchedule() {
const [user, setUser] = useState(null);
const [selectedDate, setSelectedDate] = useState(format(new Date(), 'yyyy-MM-dd'));
const [showModal, setShowModal] = useState(false);
const [editingItem, setEditingItem] = useState(null);
const [viewingItem, setViewingItem] = useState(null);
const [formData, setFormData] = useState({
title: '',
description: '',
date: selectedDate,
start_time: '',
end_time: '',
event_type: 'פורום מדורי'
});
const queryClient = useQueryClient();
useEffect(() => {
base44.auth.me().then(setUser).catch(() => {});
}, []);
const isAdmin = user?.role === 'admin';
const { data: scheduleItems = [], isLoading } = useQuery({
queryKey: ['hackalon-schedule', selectedDate],
queryFn: () => base44.entities.HackalonScheduleItem.filter({ date: selectedDate }, 'start_time')
});
const createMutation = useMutation({
mutationFn: (data) => base44.entities.HackalonScheduleItem.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['hackalon-schedule'] });
setShowModal(false);
resetForm();
toast.success('אירוע נוסף בהצלחה');
}
});
const updateMutation = useMutation({
mutationFn: ({ id, data }) => base44.entities.HackalonScheduleItem.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['hackalon-schedule'] });
setShowModal(false);
resetForm();
toast.success('אירוע עודכן בהצלחה');
}
});
const deleteMutation = useMutation({
mutationFn: (id) => base44.entities.HackalonScheduleItem.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['hackalon-schedule'] });
toast.success('אירוע נמחק בהצלחה');
}
});
const handleSubmit = () => {
if (!formData.title || !formData.start_time || !formData.end_time) {
toast.error('אנא מלא את כל השדות הנדרשים');
return;
}
if (formData.start_time >= formData.end_time) {
toast.error('שעת הסיום חייבת להיות מאוחרת יותר משעת ההתחלה');
return;
}
if (editingItem) {
updateMutation.mutate({ id: editingItem.id, data: formData });
} else {
createMutation.mutate(formData);
}
};
const handleEdit = (item) => {
setEditingItem(item);
setFormData({
title: item.title,
description: item.description || '',
date: item.date,
start_time: item.start_time,
end_time: item.end_time,
event_type: item.event_type
});
setShowModal(true);
};
const handleDelete = (id) => {
if (window.confirm('האם אתה בטוח שברצונך למחוק את האירוע?')) {
deleteMutation.mutate(id);
}
};
const resetForm = () => {
setEditingItem(null);
setFormData({
title: '',
description: '',
date: selectedDate,
start_time: '',
end_time: '',
event_type: 'פורום מדורי'
});
};
const openAddModal = () => {
resetForm();
setShowModal(true);
};
const changeDate = (days) => {
const newDate = days > 0 ?
addDays(parseISO(selectedDate), days) :
subDays(parseISO(selectedDate), Math.abs(days));
setSelectedDate(format(newDate, 'yyyy-MM-dd'));
};
// Generate time slots for timeline (6:00 to 23:00)
const generateTimeSlots = () => {
const slots = [];
for (let hour = 6; hour <= 23; hour++) {
slots.push(`${String(hour).padStart(2, '0')}:00`);
}
return slots;
};
const timeSlots = generateTimeSlots();
// Calculate event position and height
const getEventStyle = (startTime, endTime) => {
const startHour = parseInt(startTime.split(':')[0]);
const startMinute = parseInt(startTime.split(':')[1]);
const endHour = parseInt(endTime.split(':')[0]);
const endMinute = parseInt(endTime.split(':')[1]);
const startOffset = (startHour - 6) * 60 + startMinute;
const endOffset = (endHour - 6) * 60 + endMinute;
const duration = endOffset - startOffset;
const pixelsPerMinute = 120 / 60; // 120px per hour (increased from 80px)
const top = startOffset * pixelsPerMinute;
const height = Math.max(duration * pixelsPerMinute - 4, 50); // Subtract 4px for gap, minimum 50px height
return { top: `${top}px`, height: `${height}px`, duration };
};
// Event type colors
const eventTypeColors = {
'פורום גדודי': 'bg-purple-100 border-purple-300 text-purple-900',
'פורום מדורי': 'bg-blue-100 border-blue-300 text-blue-900',
'הרצאת אורח': 'bg-green-100 border-green-300 text-green-900',
'מתפללים': 'bg-amber-100 border-amber-300 text-amber-900',
'ארוחה': 'bg-orange-100 border-orange-300 text-orange-900'
};
const eventTypeIcons = {
'פורום גדודי': '👥',
'פורום מדורי': '🏢',
'הרצאת אורח': '🎤',
'מתפללים': '🙏',
'ארוחה': '🍽️'
};
// Detect overlapping events - improved algorithm
const getOverlappingEvents = () => {
const sorted = [...scheduleItems].sort((a, b) => {
const timeCompare = a.start_time.localeCompare(b.start_time);
if (timeCompare !== 0) return timeCompare;
return a.end_time.localeCompare(b.end_time);
});
const columns = [];
sorted.forEach((event) => {
let placed = false;
// Try to find a column where this event doesn't overlap
for (let i = 0; i < columns.length; i++) {
const col = columns[i];
const hasOverlap = col.some(existingEvent => {
// Events overlap if one starts before the other ends
return !(event.start_time >= existingEvent.end_time ||
event.end_time <= existingEvent.start_time);
});
if (!hasOverlap) {
col.push(event);
placed = true;
break;
}
}
// If no suitable column found, create a new one
if (!placed) {
columns.push([event]);
}
});
return columns;
};
const eventColumns = getOverlappingEvents();
if (!user || isLoading) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-100 flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-slate-400" />
</div>);
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-100" dir="rtl">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="mb-8">
<h1 className="text-3xl font-bold text-slate-800 mb-2">
📅 לוח זמנים HackAlon
</h1>
<p className="text-slate-500">תכנון ומעקב אחר אירועים</p>
</motion.div>
{/* Date Navigation */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<Button variant="outline" size="icon" onClick={() => changeDate(-1)}>
<ChevronRight className="w-4 h-4" />
</Button>
<Input
type="date"
value={selectedDate}
onChange={(e) => setSelectedDate(e.target.value)}
className="w-auto" />
<Button variant="outline" size="icon" onClick={() => changeDate(1)}>
<ChevronLeft className="w-4 h-4" />
</Button>
<Button
variant="outline"
onClick={() => setSelectedDate(format(new Date(), 'yyyy-MM-dd'))}>
היום
</Button>
</div>
{isAdmin &&
<Button onClick={openAddModal} className="bg-indigo-600 hover:bg-indigo-700">
<Plus className="w-4 h-4 ml-2" />
הוסף אירוע
</Button>
}
</div>
{/* Legend */}
<Card className="p-4 mb-6">
<div className="flex flex-wrap gap-4">
{Object.entries(eventTypeColors).map(([type, color]) =>
<div key={type} className="flex items-center gap-2">
<div className={`w-4 h-4 rounded ${color}`}></div>
<span className="text-sm">{type} {eventTypeIcons[type]}</span>
</div>
)}
</div>
</Card>
{/* Timeline View */}
<Card className="overflow-auto max-h-[800px]">
<div className="relative" style={{ minHeight: '2160px', minWidth: '600px' }}>
{/* Time slots */}
{timeSlots.map((time, idx) =>
<div
key={time}
className="absolute left-0 right-0 border-t border-slate-200"
style={{ top: `${idx * 120}px` }}>
<div className="absolute -top-3 right-4 bg-white px-2 text-sm font-medium text-slate-600">
{time}
</div>
</div>
)}
{/* Events */}
<div className="relative pr-20" style={{ minHeight: '2160px' }}>
{eventColumns.map((column, colIdx) =>
<div
key={colIdx}
className="absolute"
style={{
right: `${100 + colIdx * 290}px`,
width: '270px',
top: 0,
bottom: 0
}}>
{column.map((item) => {
const style = getEventStyle(item.start_time, item.end_time);
const color = eventTypeColors[item.event_type] || 'bg-slate-100 border-slate-300 text-slate-900';
const isShortEvent = style.duration < 30; // Less than 30 minutes
return (
<motion.div
key={item.id}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className={`absolute right-0 left-0 ${color} rounded-lg border-2 p-2 cursor-pointer hover:shadow-lg transition-all overflow-hidden group`}
style={style}
onClick={() => {
if (isAdmin) {
handleEdit(item);
} else {
setViewingItem(item);
}
}}>
<div className="flex items-start justify-between gap-2 h-full">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 mb-0.5">
<span className="text-base flex-shrink-0">{eventTypeIcons[item.event_type]}</span>
<p className="font-bold text-sm truncate">{item.title}</p>
</div>
<p className="text-xs opacity-75 font-medium">
{item.start_time} - {item.end_time}
</p>
{!isShortEvent && item.description &&
<p className="text-xs opacity-65 mt-1 line-clamp-2">{item.description}</p>
}
</div>
{isAdmin &&
<div className="flex gap-1 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 hover:bg-black/10"
onClick={(e) => {
e.stopPropagation();
handleEdit(item);
}}>
<Edit2 className="w-3 h-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 hover:bg-red-500/20"
onClick={(e) => {
e.stopPropagation();
handleDelete(item.id);
}}>
<Trash2 className="w-3 h-3" />
</Button>
</div>
}
</div>
</motion.div>);
})}
</div>
)}
{scheduleItems.length === 0 &&
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center">
<Calendar className="w-12 h-12 text-slate-300 mx-auto mb-4" />
<p className="text-slate-400">אין אירועים מתוכננים לתאריך זה</p>
{isAdmin &&
<Button onClick={openAddModal} variant="outline" className="mt-4">
<Plus className="w-4 h-4 ml-2" />
הוסף אירוע ראשון
</Button>
}
</div>
</div>
}
</div>
</div>
</Card>
{/* Add/Edit Modal */}
<Dialog open={showModal} onOpenChange={setShowModal}>
<DialogContent dir="rtl" className="max-w-md">
<DialogHeader>
<DialogTitle className="text-lg font-semibold leading-none tracking-tight text-right">{editingItem ? 'ערוך אירוע' : 'הוסף אירוע חדש'}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label>כותרת *</Label>
<Input
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
placeholder="שם האירוע" />
</div>
<div>
<Label>תיאור</Label>
<Textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="פרטים נוספים"
rows={3} />
</div>
<div>
<Label>תאריך *</Label>
<Input
type="date"
value={formData.date}
onChange={(e) => setFormData({ ...formData, date: e.target.value })} className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors
text-right
file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground
placeholder:text-muted-foreground
focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring
disabled:cursor-not-allowed disabled:opacity-50
md:text-sm
flex-row-reverse text-right
" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>שעת התחלה *</Label>
<Input
type="time"
value={formData.start_time}
onChange={(e) => setFormData({ ...formData, start_time: e.target.value })} className="flex h-9 w-full flex-row-reverse rounded-md border border-input bg-transparent
px-3 py-1 text-base shadow-sm transition-colors
text-right
file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground
placeholder:text-muted-foreground
focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring
disabled:cursor-not-allowed disabled:opacity-50
md:text-sm
" />
</div>
<div>
<Label>שעת סיום *</Label>
<Input
type="time"
value={formData.end_time}
onChange={(e) => setFormData({ ...formData, end_time: e.target.value })} className="flex h-9 w-full flex-row-reverse rounded-md border border-input bg-transparent
px-3 py-1 text-base shadow-sm transition-colors
text-right
file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground
placeholder:text-muted-foreground
focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring
disabled:cursor-not-allowed disabled:opacity-50
md:text-sm
" />
</div>
</div>
<div>
<Label>סוג אירוע *</Label>
<Select
value={formData.event_type}
onValueChange={(value) => setFormData({ ...formData, event_type: value })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent dir="rtl">
<SelectItem value="פורום גדודי">פורום גדודי 👥</SelectItem>
<SelectItem value="פורום מדורי">פורום מדורי 🏢</SelectItem>
<SelectItem value="הרצאת אורח">הרצאת אורח 🎤</SelectItem>
<SelectItem value="מתפללים">מתפללים 🙏</SelectItem>
<SelectItem value="ארוחה">ארוחה 🍽</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex gap-3 pt-4">
<Button onClick={handleSubmit} className="flex-1 bg-indigo-600 hover:bg-indigo-700">
{editingItem ? 'עדכן' : 'הוסף'}
</Button>
<Button variant="outline" onClick={() => setShowModal(false)} className="flex-1">
ביטול
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* View Modal for Non-Admin Users */}
<Dialog open={!!viewingItem} onOpenChange={(open) => !open && setViewingItem(null)}>
<DialogContent dir="rtl" className="max-w-sm">
<DialogHeader>
<DialogTitle className="text-lg font-semibold leading-none tracking-tight text-right flex items-center gap-2">
<span className="text-2xl">{viewingItem && eventTypeIcons[viewingItem.event_type]}</span>
{viewingItem?.title}
</DialogTitle>
</DialogHeader>
{viewingItem && (
<div className="space-y-4">
<div className={`p-4 rounded-lg ${eventTypeColors[viewingItem.event_type]}`}>
<div className="flex items-center gap-2 mb-2">
<Calendar className="w-4 h-4" />
<span className="font-medium">תאריך:</span>
<span>{new Date(viewingItem.date).toLocaleDateString('he-IL')}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-2xl">🕐</span>
<span className="font-medium">שעות:</span>
<span className="text-lg font-bold">{viewingItem.start_time} - {viewingItem.end_time}</span>
</div>
</div>
{viewingItem.description && (
<div>
<p className="font-medium mb-2">תיאור:</p>
<p className="text-slate-600 bg-slate-50 p-3 rounded-lg">{viewingItem.description}</p>
</div>
)}
<div>
<p className="font-medium mb-1">סוג אירוע:</p>
<div className={`inline-flex items-center gap-2 px-3 py-1.5 rounded-full ${eventTypeColors[viewingItem.event_type]}`}>
<span>{eventTypeIcons[viewingItem.event_type]}</span>
<span className="font-medium">{viewingItem.event_type}</span>
</div>
</div>
<Button
onClick={() => setViewingItem(null)}
className="w-full bg-indigo-600 hover:bg-indigo-700"
>
סגור
</Button>
</div>
)}
</DialogContent>
</Dialog>
</div>
</div>);
}

View File

@ -0,0 +1,299 @@
import React, { useState, useEffect } from 'react';
import { base44 } from '@/api/base44Client';
import { useQuery } from '@tanstack/react-query';
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { FileText, Presentation, Download, CheckCircle2, XCircle, Loader2, Shield } from 'lucide-react';
import { motion } from 'framer-motion';
export default function HackalonStatus() {
const [user, setUser] = useState(null);
const [filterType, setFilterType] = useState('all');
const [userPermissions, setUserPermissions] = useState(null);
useEffect(() => {
const loadUserData = async () => {
try {
const u = await base44.auth.me();
setUser(u);
// Load user permissions
if (u.positions && u.positions.length > 0) {
const allPermissions = await base44.entities.PositionPermission.list();
const userPositionPerms = allPermissions.filter(p => u.positions.includes(p.position_name));
const mergedPerms = {
pages_access: [...new Set(userPositionPerms.flatMap(p => p.pages_access || []))]
};
setUserPermissions(mergedPerms);
}
} catch (error) {}
};
loadUserData();
}, []);
const isAdmin = user?.role === 'admin';
const allowedPositions = ['מפק״ץ', 'מנהל האקתון'];
const hasAllowedPosition = user?.positions?.some(pos => allowedPositions.includes(pos));
const hasAccess = isAdmin || hasAllowedPosition;
const { data: departments = [] } = useQuery({
queryKey: ['hackalon-departments'],
queryFn: () => base44.entities.HackalonDepartment.list('order'),
enabled: hasAccess
});
const { data: teams = [] } = useQuery({
queryKey: ['hackalon-teams'],
queryFn: () => base44.entities.HackalonTeam.list('order'),
enabled: hasAccess
});
// const { data: submissions = [] } = useQuery({
// queryKey: ['hackalon-submissions'],
// queryFn: () => base44.entities.HackalonSubmission.list(),
// enabled: hasAccess
// });
// השינוי העיקרי - פילטור submissions רק לצוותים קיימים
const { data: submissions = [] } = useQuery({
queryKey: ['hackalon-submissions', teams.length],
queryFn: async () => {
const allSubmissions = await base44.entities.HackalonSubmission.list();
// Filter only submissions that belong to existing teams
const teamNames = teams.map(t => t.name);
return allSubmissions.filter(s => teamNames.includes(s.team_name));
},
enabled: hasAccess && teams.length > 0
});
if (!user) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-100 flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-slate-400" />
</div>
);
}
if (!hasAccess) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-100 flex items-center justify-center" dir="rtl">
<Card className="p-8 text-center max-w-md">
<Shield className="w-16 h-16 text-red-400 mx-auto mb-4" />
<h2 className="text-2xl font-bold text-slate-800 mb-2">אין הרשאת גישה</h2>
<p className="text-slate-600">אין לך הרשאה לצפות בעמוד זה</p>
</Card>
</div>
);
}
const getSubmission = (teamName, type) => {
return submissions.find(s => s.team_name === teamName && s.submission_type === type);
};
const getStats = () => {
const totalTeams = teams.length;
const teamsWithSpec = new Set(submissions.filter(s => s.submission_type === 'specification').map(s => s.team_name)).size;
const teamsWithFinal = new Set(submissions.filter(s => s.submission_type === 'final_product').map(s => s.team_name)).size;
const teamsWithoutSpec = totalTeams - teamsWithSpec;
const teamsWithoutFinal = totalTeams - teamsWithFinal;
return { totalTeams, teamsWithSpec, teamsWithFinal, teamsWithoutSpec, teamsWithoutFinal };
};
const stats = getStats();
const getDeptStats = (deptName) => {
const deptTeams = teams.filter(t => t.department_name === deptName);
const deptTotal = deptTeams.length;
const deptSpec = deptTeams.filter(t => getSubmission(t.name, 'specification')).length;
const deptFinal = deptTeams.filter(t => getSubmission(t.name, 'final_product')).length;
return { deptTotal, deptSpec, deptFinal };
};
const shouldShowTeam = (team) => {
if (filterType === 'all') return true;
if (filterType === 'with-spec') return !!getSubmission(team.name, 'specification');
if (filterType === 'no-spec') return !getSubmission(team.name, 'specification');
if (filterType === 'with-final') return !!getSubmission(team.name, 'final_product');
if (filterType === 'no-final') return !getSubmission(team.name, 'final_product');
return true;
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-100" dir="rtl">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="mb-8"
>
<h1 className="text-3xl font-bold text-slate-800 mb-2">
📊 תמונת מצב - HackAlon
</h1>
<p className="text-slate-500">מעקב אחר העלאות והגשות של הצוותים</p>
</motion.div>
{/* Stats with filters */}
<Card className="p-6 mb-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Specification */}
<div>
<h3 className="text-sm font-semibold text-slate-700 mb-3 flex items-center gap-2">
<FileText className="w-4 h-4" />
מסמך איפיון
</h3>
<div className="grid grid-cols-2 gap-2">
<Card
className={`p-3 cursor-pointer transition-all ${filterType === 'with-spec' ? 'ring-2 ring-green-500 bg-green-50' : 'bg-green-50 border-green-200 hover:shadow-md'}`}
onClick={() => setFilterType(filterType === 'with-spec' ? 'all' : 'with-spec')}
>
<p className="text-xs text-green-600">עם מסמך</p>
<p className="text-xl font-bold text-green-700">{stats.teamsWithSpec}</p>
</Card>
<Card
className={`p-3 cursor-pointer transition-all ${filterType === 'no-spec' ? 'ring-2 ring-red-500 bg-red-50' : 'bg-red-50 border-red-200 hover:shadow-md'}`}
onClick={() => setFilterType(filterType === 'no-spec' ? 'all' : 'no-spec')}
>
<p className="text-xs text-red-600">ללא מסמך</p>
<p className="text-xl font-bold text-red-700">{stats.teamsWithoutSpec}</p>
</Card>
</div>
</div>
{/* Final Product */}
<div>
<h3 className="text-sm font-semibold text-slate-700 mb-3 flex items-center gap-2">
<Presentation className="w-4 h-4" />
תוצר סופי
</h3>
<div className="grid grid-cols-2 gap-2">
<Card
className={`p-3 cursor-pointer transition-all ${filterType === 'with-final' ? 'ring-2 ring-purple-500 bg-purple-50' : 'bg-purple-50 border-purple-200 hover:shadow-md'}`}
onClick={() => setFilterType(filterType === 'with-final' ? 'all' : 'with-final')}
>
<p className="text-xs text-purple-600">עם תוצר</p>
<p className="text-xl font-bold text-purple-700">{stats.teamsWithFinal}</p>
</Card>
<Card
className={`p-3 cursor-pointer transition-all ${filterType === 'no-final' ? 'ring-2 ring-amber-500 bg-amber-50' : 'bg-amber-50 border-amber-200 hover:shadow-md'}`}
onClick={() => setFilterType(filterType === 'no-final' ? 'all' : 'no-final')}
>
<p className="text-xs text-amber-600">ללא תוצר</p>
<p className="text-xl font-bold text-amber-700">{stats.teamsWithoutFinal}</p>
</Card>
</div>
</div>
</div>
</Card>
{/* Departments and Teams Status */}
<div className="space-y-6">
{departments.map((dept, index) => {
const deptTeams = teams.filter(t => t.department_name === dept.name);
const visibleTeams = deptTeams.filter(shouldShowTeam);
const deptStats = getDeptStats(dept.name);
if (visibleTeams.length === 0 && filterType !== 'all') return null;
return (
<motion.div
key={dept.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
>
<Card className="p-6">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-xl font-bold text-slate-800">{dept.name}</h3>
<p className="text-sm text-slate-500">כיתה {dept.classroom_number || 'לא הוגדר'}</p>
</div>
{/* Department Stats */}
<div className="flex gap-3 text-sm">
<div className="text-center px-3 py-1 bg-green-50 rounded-lg">
<p className="text-xs text-green-600">מסמכי איפיון</p>
<p className="font-bold text-green-700">{deptStats.deptSpec}/{deptStats.deptTotal}</p>
</div>
<div className="text-center px-3 py-1 bg-purple-50 rounded-lg">
<p className="text-xs text-purple-600">תוצרים סופיים</p>
<p className="font-bold text-purple-700">{deptStats.deptFinal}/{deptStats.deptTotal}</p>
</div>
</div>
</div>
{visibleTeams.length > 0 ? (
<div className="space-y-3">
{visibleTeams.map(team => {
const specSubmission = getSubmission(team.name, 'specification');
const finalSubmission = getSubmission(team.name, 'final_product');
return (
<Card key={team.id} className="p-4 bg-slate-50">
<div className="flex items-start justify-between mb-3">
<div>
<h4 className="font-semibold text-slate-800">{team.name}</h4>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{/* Specification */}
<div className="flex items-center justify-between p-3 bg-white rounded-lg border">
<div className="flex items-center gap-2">
<FileText className="w-5 h-5 text-green-600" />
<div>
<p className="text-sm font-medium">מסמך איפיון</p>
{specSubmission && (
<p className="text-xs text-slate-500">{specSubmission.uploaded_by}</p>
)}
</div>
</div>
{specSubmission ? (
<a href={specSubmission.file_url} target="_blank" rel="noopener noreferrer">
<Button size="sm" variant="ghost">
<Download className="w-4 h-4" />
</Button>
</a>
) : (
<XCircle className="w-5 h-5 text-red-400" />
)}
</div>
{/* Final Product */}
<div className="flex items-center justify-between p-3 bg-white rounded-lg border">
<div className="flex items-center gap-2">
<Presentation className="w-5 h-5 text-purple-600" />
<div>
<p className="text-sm font-medium">תוצר סופי</p>
{finalSubmission && (
<p className="text-xs text-slate-500">{finalSubmission.uploaded_by}</p>
)}
</div>
</div>
{finalSubmission ? (
<a href={finalSubmission.file_url} target="_blank" rel="noopener noreferrer">
<Button size="sm" variant="ghost">
<Download className="w-4 h-4" />
</Button>
</a>
) : (
<XCircle className="w-5 h-5 text-red-400" />
)}
</div>
</div>
</Card>
);
})}
</div>
) : (
<p className="text-slate-400 text-center py-4">אין צוותים במדור זה</p>
)}
</Card>
</motion.div>
);
})}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,591 @@
import React, { useState, useEffect } from 'react';
import { base44 } from '@/api/base44Client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Upload, FileText, Presentation, Users, MapPin, Lightbulb, Loader2, Link as LinkIcon, Trash2,AppWindow } from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { motion } from 'framer-motion';
import { toast } from 'sonner';
export default function HackalonTeamArea() {
const [user, setUser] = useState(null);
const [uploading, setUploading] = useState(null);
const [showLinkModal, setShowLinkModal] = useState(null);
const [linkUrl, setLinkUrl] = useState('');
const queryClient = useQueryClient();
const { data: teamInfo, isLoading: teamLoading } = useQuery({
queryKey: ['hackalon-team-info', user?.hackalon_team],
queryFn: async () => {
const teams = await base44.entities.HackalonTeam.list();
return teams.find((t) => t.name === user.hackalon_team);
},
enabled: !!user?.hackalon_team
});
// Auto-assign user if name matches team member list
useEffect(() => {
const autoAssign = async () => {
if (!user) return;
const userName = (user.onboarding_full_name || user.full_name || '').trim().toLowerCase();
if (!userName) return;
try {
const allTeams = await base44.entities.HackalonTeam.list();
const matchingTeam = allTeams.find(team =>
team.member_names?.some(name => name.trim().toLowerCase() === userName)
);
// Check if current team/department still exists
const currentTeamExists = user.hackalon_team ?
allTeams.some(t => t.name === user.hackalon_team) : false;
// If assigned team was deleted - remove assignment
if (user.hackalon_team && !currentTeamExists) {
await base44.entities.User.update(user.id, {
hackalon_team: null,
hackalon_department: null
});
const updatedUser = await base44.auth.me();
setUser(updatedUser);
toast.info('הצוות שלך נמחק - הוסרת מהשיבוץ');
return;
}
// If name matches a team but user isn't assigned - assign them
if (matchingTeam && user.hackalon_team !== matchingTeam.name) {
await base44.entities.User.update(user.id, {
hackalon_team: matchingTeam.name,
hackalon_department: matchingTeam.department_name
});
const updatedUser = await base44.auth.me();
setUser(updatedUser);
toast.success(`שובצת אוטומטית לצוות ${matchingTeam.name}`);
}
// If name doesn't match current team - remove assignment
if (!matchingTeam && user.hackalon_team && currentTeamExists) {
await base44.entities.User.update(user.id, {
hackalon_team: null,
hackalon_department: null
});
const updatedUser = await base44.auth.me();
setUser(updatedUser);
toast.info('הוסרת מהצוות כי השם שלך השתנה');
}
} catch (error) {
console.error('Auto-assign failed:', error);
}
};
autoAssign();
}, [user?.onboarding_full_name, user?.full_name]);
useEffect(() => {
const loadUser = async () => {
try {
const userData = await base44.auth.me();
// Check if assigned team still exists
if (userData.hackalon_team) {
const allTeams = await base44.entities.HackalonTeam.list();
const teamExists = allTeams.some(t => t.name === userData.hackalon_team);
if (!teamExists) {
console.log('Team deleted, removing assignment...');
// Team was deleted - remove assignment
await base44.entities.User.update(userData.id, {
hackalon_team: null,
hackalon_department: null
});
// Force reload from server
const freshUser = await base44.auth.me();
setUser(freshUser);
toast.info('הצוות שלך נמחק - הוסרת מהשיבוץ');
return;
}
}
setUser(userData);
} catch (error) {
console.error('Load user error:', error);
}
};
loadUser();
// Reload user data every 2 seconds to catch updates from other pages
const interval = setInterval(loadUser, 2000);
return () => clearInterval(interval);
}, []);
const { data: teamMembers = [], isLoading: membersLoading } = useQuery({
queryKey: ['hackalon-team-members', user?.hackalon_team, teamInfo?.member_names],
queryFn: async () => {
const allUsers = await base44.entities.User.list();
// Find by hackalon_team OR by name match in member_names
return allUsers.filter((u) => {
// Direct team assignment
if (u.hackalon_team === user.hackalon_team) return true;
// Check if user's name is in the team's member_names list
if (teamInfo?.member_names) {
const userName = (u.onboarding_full_name || u.full_name || '').trim().toLowerCase();
return teamInfo.member_names.some((name) =>
name.trim().toLowerCase() === userName
);
}
return false;
});
},
enabled: !!user?.hackalon_team && !!teamInfo
});
const { data: submissions = [] } = useQuery({
queryKey: ['hackalon-submissions', user?.hackalon_team],
queryFn: async () => {
const allSubmissions = await base44.entities.HackalonSubmission.list();
return allSubmissions.filter((s) => s.team_name === user.hackalon_team);
},
enabled: !!user?.hackalon_team
});
const uploadMutation = useMutation({
mutationFn: async ({ file, type, existingSubmission }) => {
const { file_url } = await base44.integrations.Core.UploadFile({ file });
// Check if late submission for specification
let isLate = false;
if (type === 'specification' && teamInfo?.specification_deadline) {
const deadline = new Date(teamInfo.specification_deadline);
const now = new Date();
isLate = now > deadline;
}
if (existingSubmission) {
// Update existing
return base44.entities.HackalonSubmission.update(existingSubmission.id, {
submission_method: 'file',
file_url: file_url,
file_name: file.name,
uploaded_by: user.email,
upload_date: new Date().toISOString(),
is_late: isLate
});
} else {
// Create new
return base44.entities.HackalonSubmission.create({
team_name: user.hackalon_team,
submission_type: type,
submission_method: 'file',
file_url: file_url,
file_name: file.name,
uploaded_by: user.email,
upload_date: new Date().toISOString(),
is_late: isLate
});
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['hackalon-submissions'] });
toast.success('הקובץ הועלה בהצלחה');
setUploading(null);
},
onError: () => {
toast.error('שגיאה בהעלאת הקובץ');
setUploading(null);
},
catch (error) {
console.error('Upload error details:', error);
throw error; // זה חשוב כדי שה-onError יתפוס את השגיאה
},
onError: (error) => {
console.error('Mutation error:', error);
toast.error(`שגיאה בהעלאת הקובץ: ${error.message || 'שגיאה לא ידועה'}`);
setUploading(null);
}
});
const addLinkMutation = useMutation({
mutationFn: async ({ url, type, existingSubmission }) => {
// Check if late submission for specification
let isLate = false;
if (type === 'specification' && teamInfo?.specification_deadline) {
const deadline = new Date(teamInfo.specification_deadline);
const now = new Date();
isLate = now > deadline;
}
if (existingSubmission) {
return base44.entities.HackalonSubmission.update(existingSubmission.id, {
submission_method: 'link',
file_url: url,
file_name: 'קישור חיצוני',
uploaded_by: user.email,
upload_date: new Date().toISOString(),
is_late: isLate
});
} else {
return base44.entities.HackalonSubmission.create({
team_name: user.hackalon_team,
submission_type: type,
submission_method: 'link',
file_url: url,
file_name: 'קישור חיצוני',
uploaded_by: user.email,
upload_date: new Date().toISOString(),
is_late: isLate
});
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['hackalon-submissions'] });
toast.success('הקישור נוסף בהצלחה');
setShowLinkModal(null);
setLinkUrl('');
},
onError: () => {
toast.error('שגיאה בהוספת הקישור');
}
});
const deleteSubmissionMutation = useMutation({
mutationFn: (submissionId) => base44.entities.HackalonSubmission.delete(submissionId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['hackalon-submissions'] });
toast.success('הקובץ נמחק בהצלחה');
},
onError: () => {
toast.error('שגיאה במחיקת הקובץ');
}
});
const handleFileUpload = async (e, type) => {
const file = e.target.files[0];
if (!file) return;
const existingSubmission = getSubmission(type);
if (existingSubmission) {
const confirmed = window.confirm(
`כבר קיים קובץ שהועלה על ידי ${existingSubmission.uploaded_by}.\nהאם לדרוס את הקובץ הקיים?`
);
if (!confirmed) {
e.target.value = '';
return;
}
}
setUploading(type);
uploadMutation.mutate({ file, type, existingSubmission });
};
const handleAddLink = (type) => {
if (!linkUrl.trim()) return;
const existingSubmission = getSubmission(type);
addLinkMutation.mutate({ url: linkUrl, type, existingSubmission });
};
const handleDelete = (submission) => {
const confirmed = window.confirm('האם אתה בטוח שברצונך למחוק את הקובץ?');
if (confirmed) {
deleteSubmissionMutation.mutate(submission.id);
}
};
if (!user || teamLoading || membersLoading) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-100 flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-slate-400" />
</div>);
}
if (!user.hackalon_team) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-100 flex items-center justify-center" dir="rtl">
<Card className="p-8 text-center max-w-md">
<Users className="w-16 h-16 text-slate-300 mx-auto mb-4" />
<h2 className="text-2xl font-bold text-slate-800 mb-2">לא שובצת לצוות</h2>
<p className="text-slate-600">פנה למנהל המערכת לשיבוץ</p>
</Card>
</div>);
}
const getSubmission = (type) => submissions.find((s) => s.submission_type === type);
const specSubmission = getSubmission('specification');
const finalProductSubmission = getSubmission('final_product');
// Check if specification deadline passed
const isSpecDeadlinePassed = teamInfo?.specification_deadline
? new Date() > new Date(teamInfo.specification_deadline)
: false;
// Check if final product deadline passed
const isFinalDeadlinePassed = teamInfo?.final_product_deadline
? new Date() > new Date(teamInfo.final_product_deadline)
: false;
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-100" dir="rtl">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="mb-8">
<h1 className="text-3xl font-bold text-slate-800 mb-2"> אזור הצוות שלי 🚀
</h1>
<p className="text-slate-500">{user.hackalon_team} {user.hackalon_department}</p>
</motion.div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
{/* Team Info */}
<Card className="p-6 lg:col-span-2">
<div className="flex items-center gap-3 mb-4">
<div className="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center">
<Lightbulb className="w-6 h-6 text-purple-600" />
</div>
<div>
<h2 className="text-xl font-bold text-slate-800">הבעיה שלנו</h2>
{teamInfo?.classroom_number &&
<p className="text-sm text-slate-500 flex items-center gap-1">
<MapPin className="w-4 h-4" />
כיתה {teamInfo.classroom_number}
</p>
}
</div>
</div>
{teamInfo?.problem_name ?
<div className="space-y-4">
<div>
<h3 className="text-xl font-bold text-slate-800 mb-3">{teamInfo.problem_name}</h3>
</div>
<div className="space-y-4">
{teamInfo.problem_intro &&
<div>
<h4 className="text-sm font-semibold text-purple-600 mb-2">מבוא</h4>
<p className="text-slate-600 whitespace-pre-wrap">{teamInfo.problem_intro}</p>
</div>
}
{teamInfo.problem_objective &&
<div>
<h4 className="text-sm font-semibold text-purple-600 mb-2">מטרת המוצר</h4>
<p className="text-slate-600 whitespace-pre-wrap">{teamInfo.problem_objective}</p>
</div>
}
{teamInfo.problem_requirements &&
<div>
<h4 className="text-sm font-semibold text-purple-600 mb-2">דרישות מרכזיות</h4>
<p className="text-slate-600 whitespace-pre-wrap">{teamInfo.problem_requirements}</p>
</div>
}
</div>
</div> :
<p className="text-slate-400 text-center py-8">המנהל עדיין לא הגדיר את הבעיה</p>
}
</Card>
{/* Team Members */}
<Card className="p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
<Users className="w-5 h-5 text-blue-600" />
</div>
<h3 className="text-lg font-bold text-slate-800">חברי הצוות</h3>
</div>
<div className="space-y-2">
{teamInfo?.member_names && teamInfo.member_names.length > 0 ?
teamInfo.member_names.map((name, idx) => {
const matchedUser = teamMembers.find((u) =>
(u.onboarding_full_name || u.full_name || '').trim().toLowerCase() === name.trim().toLowerCase()
);
return (
<div key={idx} className="p-2 bg-slate-50 rounded-lg">
<p className="font-medium text-slate-800 text-sm">{name}</p>
{matchedUser ?
<p className="text-xs text-slate-500">{matchedUser.email}</p> :
<p className="text-xs text-slate-400"></p>
}
</div>);
}) :
teamMembers.length > 0 ?
teamMembers.map((member) =>
<div key={member.id} className="p-2 bg-slate-50 rounded-lg">
<p className="font-medium text-slate-800 text-sm">{member.onboarding_full_name || member.full_name}</p>
<p className="text-xs text-slate-500">{member.email}</p>
</div>
) :
<p className="text-slate-400 text-sm text-center py-4">אין חברי צוות</p>
}
</div>
</Card>
</div>
{/* Submissions */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Specification */}
<Card className="p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<FileText className="w-6 h-6 text-green-600" />
<h3 className="text-lg font-bold text-slate-800">מסמך איפיון</h3>
</div>
{teamInfo?.specification_deadline && (
<div className="text-xs text-slate-500">
<div>דדליין: {new Date(teamInfo.specification_deadline).toLocaleTimeString('he-IL', { hour: '2-digit', minute: '2-digit' })} {new Date(teamInfo.specification_deadline).toLocaleDateString('he-IL')}</div>
{isSpecDeadlinePassed && !specSubmission && (
<div className="text-red-600 font-semibold">חלף המועד!</div>
)}
</div>
)}
</div>
{/* Download Template */}
{teamInfo?.specification_template_url && !specSubmission && (
<a
href={teamInfo.specification_template_url}
download
className="block mb-3 p-3 bg-blue-50 border border-blue-200 rounded-lg hover:bg-blue-100 transition-colors"
>
<div className="flex items-center gap-2 text-blue-700">
<FileText className="w-4 h-4" />
<span className="text-sm font-medium"> הורד טמפלייט</span>
</div>
</a>
)}
{specSubmission ?
<div className="space-y-2">
<a href={specSubmission.file_url} target="_blank" rel="noopener noreferrer" className="block p-3 bg-green-50 border border-green-200 rounded-lg hover:bg-green-100 transition-colors">
<div className="flex items-start justify-between">
<div className="flex-1">
<p className="font-medium text-green-800 text-sm">{specSubmission.file_name}</p>
<p className="text-xs text-green-600 mt-1">הועלה על ידי {specSubmission.uploaded_by}</p>
{specSubmission.is_late && (
<p className="text-xs text-red-600 font-semibold mt-1"> הוגש באיחור</p>
)}
</div>
<Button size="sm" variant="ghost" onClick={(e) => {e.preventDefault();handleDelete(specSubmission);}} className="text-red-600">
<Trash2 className="w-4 h-4" />
</Button>
</div>
</a>
</div> :
<div className="space-y-2">
<input type="file" id="spec-upload" className="hidden" onChange={(e) => handleFileUpload(e, 'specification')} disabled={uploading === 'specification'} />
<label htmlFor="spec-upload">
<Button asChild disabled={uploading === 'specification'} className="w-full cursor-pointer">
<span>
{uploading === 'specification' ? <Loader2 className="w-4 h-4 ml-2 animate-spin" /> : <Upload className="w-4 h-4 ml-2" />}
{uploading === 'specification' ? 'מעלה...' : 'העלה קובץ'}
</span>
</Button>
</label>
<Button variant="outline" onClick={() => {setShowLinkModal('specification');setLinkUrl('');}} className="w-full">
<LinkIcon className="w-4 h-4 ml-2" />
הוסף קישור
</Button>
</div>
}
</Card>
{/* Final Product */}
<Card className="p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<AppWindow className="w-6 h-6 text-purple-600" />
<h3 className="text-lg font-bold text-slate-800">תוצר סופי</h3>
</div>
{teamInfo?.final_product_deadline && (
<div className="text-xs text-slate-500">
<div>דדליין: {new Date(teamInfo.final_product_deadline).toLocaleTimeString('he-IL', { hour: '2-digit', minute: '2-digit' })} {new Date(teamInfo.final_product_deadline).toLocaleDateString('he-IL')}</div>
{isFinalDeadlinePassed && !finalProductSubmission && (
<div className="text-red-600 font-semibold">חלף המועד!</div>
)}
</div>
)}
</div>
{finalProductSubmission ?
<div className="space-y-2">
<a href={finalProductSubmission.file_url} target="_blank" rel="noopener noreferrer" className="block p-3 bg-purple-50 border border-purple-200 rounded-lg hover:bg-purple-100 transition-colors">
<div className="flex items-start justify-between">
<div className="flex-1">
<p className="font-medium text-purple-800 text-sm">{finalProductSubmission.file_name}</p>
<p className="text-xs text-purple-600 mt-1">הועלה על ידי {finalProductSubmission.uploaded_by}</p>
</div>
<Button size="sm" variant="ghost" onClick={(e) => {e.preventDefault();handleDelete(finalProductSubmission);}} className="text-red-600">
<Trash2 className="w-4 h-4" />
</Button>
</div>
</a>
</div> :
<div className="space-y-2">
<input type="file" id="final-upload" className="hidden" onChange={(e) => handleFileUpload(e, 'final_product')} disabled={uploading === 'final_product'} />
<label htmlFor="final-upload">
<Button asChild disabled={uploading === 'final_product'} className="w-full cursor-pointer">
<span>
{uploading === 'final_product' ? <Loader2 className="w-4 h-4 ml-2 animate-spin" /> : <Upload className="w-4 h-4 ml-2" />}
{uploading === 'final_product' ? 'מעלה...' : 'העלה קובץ'}
</span>
</Button>
</label>
<Button variant="outline" onClick={() => {setShowLinkModal('final_product');setLinkUrl('');}} className="w-full">
<LinkIcon className="w-4 h-4 ml-2" />
הוסף קישור
</Button>
</div>
}
</Card>
</div>
{/* Link Modal */}
<Dialog open={!!showLinkModal} onOpenChange={() => setShowLinkModal(null)}>
<DialogContent dir="ltr">
<DialogHeader>
<DialogTitle>הוסף קישור</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label>URL</Label>
<Input
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
placeholder="https://..."
type="url" />
</div>
<div className="flex gap-2">
<Button onClick={() => handleAddLink(showLinkModal)} disabled={!linkUrl.trim()} className="flex-1">הוסף</Button>
<Button variant="outline" onClick={() => setShowLinkModal(null)} className="flex-1">ביטול</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
</div>);
}

View File

@ -0,0 +1,263 @@
import React, { useState, useEffect } from 'react';
import { base44 } from '@/api/base44Client';
import { Link } from 'react-router-dom';
import { createPageUrl } from '../utils';
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Key, Calendar, Users, UserPen, ArrowLeft, Shield, Settings, LogOut, Lightbulb, Sparkles, Database } from 'lucide-react';
import { motion } from 'framer-motion';
export default function Home() {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [userPermissions, setUserPermissions] = useState(null);
useEffect(() => {
const loadUserData = async () => {
try {
const u = await base44.auth.me();
setUser(u);
// Load user permissions based on positions
if (u.positions && u.positions.length > 0) {
const allPermissions = await base44.entities.PositionPermission.list();
const userPositionPerms = allPermissions.filter((p) =>
u.positions.includes(p.position_name)
);
// Merge all permissions
const mergedPerms = {
has_classroom_management_access: userPositionPerms.some((p) => p.has_classroom_management_access),
pages_access: [...new Set(userPositionPerms.flatMap((p) => p.pages_access || []))]
};
setUserPermissions(mergedPerms);
}
setLoading(false);
} catch (error) {
setLoading(false);
}
};
loadUserData();
}, []);
const isAdmin = user?.role === 'admin';
// Get first accessible page for classroom management
const getFirstAccessiblePage = () => {
if (isAdmin) return 'Dashboard';
if (!userPermissions?.has_classroom_management_access) return null;
const pageOrder = ['Dashboard', 'DailyOverview', 'MySchedule', 'ManageKeys', 'KeyAllocation', 'ManageCrews', 'ManageSquads'];
const accessiblePage = pageOrder.find((page) => userPermissions.pages_access.includes(page));
return accessiblePage || null;
};
const classroomPath = getFirstAccessiblePage();
const features = [
{
title: 'ניהול כיתות',
description: 'ניהול מפתחות, הקצאת חדרים ולוח זמנים',
icon: Key,
path: classroomPath,
color: 'bg-indigo-500',
available: !!classroomPath
},
{
title: 'HackAlon',
description: 'ניהול האקאלון, מחזור 2',
icon: Lightbulb,
path: 'HackalonSchedule',
color: 'bg-blue-500',
available: true
},
{
title: 'המוצר שלכם יהיה ממש כאן!',
description: 'וכאן התיאור שלו...',
icon: Sparkles,
path: null,
color: 'bg-purple-500',
available: false
}];
const adminFeatures = [
{
title: 'ניהול משתמשים',
description: 'צפייה ועריכת משתמשים במערכת',
icon: Settings,
path: 'ManageUsers',
available: isAdmin
},
{
title: 'ייצוא נתונים',
description: 'ייצוא נתונים ל-PostgreSQL/Supabase',
icon: Database,
path: 'DataExport',
available: isAdmin
}];
if (loading) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-100 flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-slate-800"></div>
</div>);
}
const hasPositions = user?.positions && user.positions.length > 0;
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-100" dir="rtl">
{/* Top Navigation */}
<nav className="bg-white border-b border-slate-200 sticky top-0 z-50">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
<Link to={createPageUrl('Home')} className="flex items-center gap-3">
<img
src="https://qtrypzzcjebvfcihiynt.supabase.co/storage/v1/object/public/base44-prod/public/693b00a201212578d09f8396/9732960ed_8.png"
alt="מגדלור לוגו"
className="w-12 h-12 object-contain" />
<h2 className="text-lg font-semibold text-slate-800">מגדלור</h2>
</Link>
{user &&
<div className="flex items-center gap-3">
{/* My Profile Button */}
<Link to={createPageUrl('MyProfile')}>
<Button variant="outline" className="text-slate-700 hover:text-slate-900 hover:bg-slate-50">
<UserPen className="w-4 h-4 ml-2" />
</Button>
</Link>
{/* Logout Button */}
<Button
onClick={() => base44.auth.logout()}
variant="outline"
className="text-red-600 hover:text-red-700 hover:bg-red-50 border-red-200">
<LogOut className="w-4 h-4 ml-2" />
</Button>
</div>
}
</div>
</div>
</nav>
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="text-center mb-12">
<img
src="https://qtrypzzcjebvfcihiynt.supabase.co/storage/v1/object/public/base44-prod/public/693b00a201212578d09f8396/2f970d938_9.png"
alt="מגדלור לוגו"
className="w-24 h-24 object-contain mx-auto mb-6" />
<h1 className="text-4xl font-bold text-slate-800 mb-3">
שלום {user.onboarding_full_name || user.full_name} 👋
</h1>
{user &&
<p className="text-lg text-slate-600">
מגדלור, כאן בשבילך 🙂
</p>
}
<p className="text-slate-500 mt-2">״כשהאור תמיד דולק, הדרך ברורה.״</p>
</motion.div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{features.map((feature, index) =>
<motion.div
key={feature.title}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}>
{feature.available ?
<Link to={createPageUrl(feature.path)}>
<Card className="p-6 hover:shadow-xl transition-all duration-300 cursor-pointer group h-full border-slate-200">
<div className={`w-14 h-14 ${feature.color} rounded-xl flex items-center justify-center mb-4 group-hover:scale-110 transition-transform`}>
<feature.icon className="w-7 h-7 text-white" />
</div>
<h3 className="text-xl font-bold text-slate-800 mb-2">
{feature.title}
</h3>
<p className="text-slate-600 mb-4">{feature.description}</p>
<div className="flex items-center gap-2 text-indigo-600 font-medium group-hover:gap-3 transition-all"> <span>כניסה</span> <ArrowLeft className="w-4 h-4" /> </div>
</Card>
</Link> :
<Card className="p-6 h-full border-slate-200 opacity-60 cursor-not-allowed">
<div className={`w-14 h-14 ${feature.color} rounded-xl flex items-center justify-center mb-4 opacity-50`}>
<feature.icon className="w-7 h-7 text-white" />
</div>
<h3 className="text-xl font-bold text-slate-800 mb-2">
{feature.title}
</h3>
<p className="text-slate-600 mb-4">{feature.description}</p>
<div className="flex items-center gap-2 text-slate-400 font-medium">
<span>בקרוב...</span>
</div>
</Card>
}
</motion.div>
)}
</div>
{/* Admin Section */}
{isAdmin &&
<div className="mt-12">
<motion.h2
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.4 }}
className="text-2xl font-bold text-slate-800 mb-6">
🛡 אזור מנהל
</motion.h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{adminFeatures.map((feature, index) =>
<motion.div
key={feature.title}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 + index * 0.1 }}>
<Link to={createPageUrl(feature.path)}>
<Card className="p-6 hover:shadow-xl transition-all duration-300 cursor-pointer group h-full border-2 border-blue-200 hover:border-blue-300 bg-blue-50/30">
<div className="w-14 h-14 bg-blue-600 rounded-xl flex items-center justify-center mb-4 group-hover:scale-110 transition-transform">
<feature.icon className="w-7 h-7 text-white" />
</div>
<h3 className="text-xl font-bold text-slate-800 mb-2">
{feature.title}
</h3>
<p className="text-slate-600 mb-4">{feature.description}</p>
<div className="flex items-center gap-2 text-blue-600 font-medium group-hover:gap-3 transition-all">
<span>כניסה</span>
<ArrowLeft className="w-4 h-4" />
</div>
</Card>
</Link>
</motion.div>
)}
</div>
</div>
}
</div>
</div>);
}

View File

@ -0,0 +1,840 @@
import React, { useState, useEffect } from 'react';
import { base44 } from '@/api/base44Client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow } from
"@/components/ui/table";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue } from
"@/components/ui/select";
import { Wand2, Calendar, Key, RefreshCw, Loader2, AlertTriangle, CheckCircle, Trash2, Shield } from 'lucide-react';
import { toast } from 'sonner';
import { motion } from 'framer-motion';
import { format } from 'date-fns';
export default function KeyAllocation() {
const [user, setUser] = useState(null);
const [selectedDate, setSelectedDate] = useState(format(new Date(), 'yyyy-MM-dd'));
const [selectedKeys, setSelectedKeys] = useState([]);
const [selectedLessons, setSelectedLessons] = useState([]);
const [isAllocating, setIsAllocating] = useState(false);
const queryClient = useQueryClient();
useEffect(() => {
base44.auth.me().then(setUser).catch(() => {});
}, []);
const { data: allKeys = [] } = useQuery({
queryKey: ['keys'],
queryFn: () => base44.entities.ClassroomKey.list()
});
const { data: lessons = [], isLoading } = useQuery({
queryKey: ['all-lessons', selectedDate],
queryFn: () => base44.entities.Lesson.filter({ date: selectedDate }, 'start_time')
});
const { data: specialRequests = [] } = useQuery({
queryKey: ['special-requests', selectedDate],
queryFn: () => base44.entities.WaitingQueue.filter({ date: selectedDate }, 'priority'),
enabled: !!selectedDate
});
const updateLessonMutation = useMutation({
mutationFn: ({ id, data }) => base44.entities.Lesson.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['all-lessons'] });
}
});
const deleteLessonMutation = useMutation({
mutationFn: (id) => base44.entities.Lesson.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['all-lessons'] });
toast.success('שיעור נמחק');
}
});
const deleteAllLessonsMutation = useMutation({
mutationFn: async () => {
for (const lesson of lessons) {
await base44.entities.Lesson.delete(lesson.id);
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['all-lessons'] });
toast.success('כל השיעורים נמחקו');
}
});
const manualAssignMutation = useMutation({
mutationFn: async ({ lessonId, roomNumber }) => {
// Find the lesson
const lesson = lessons.find(l => l.id === lessonId);
if (!lesson) throw new Error('שיעור לא נמצא');
// Check for conflicts - if another lesson is using this room at the same time
const conflict = lessons.find(l =>
l.id !== lessonId &&
l.assigned_key === roomNumber &&
l.status === 'assigned' &&
timeSlotsOverlap(lesson.start_time, lesson.end_time, l.start_time, l.end_time)
);
if (conflict) {
throw new Error(`חדר ${roomNumber} תפוס ע״י ${conflict.crew_name} בשעות ${conflict.start_time}-${conflict.end_time}`);
}
// Assign the key
return base44.entities.Lesson.update(lessonId, {
assigned_key: roomNumber,
status: 'assigned'
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['all-lessons'] });
toast.success('חדר הוקצה בהצלחה');
},
onError: (error) => {
toast.error(error.message);
}
});
const toggleKeySelection = (keyId) => {
setSelectedKeys((prev) =>
prev.includes(keyId) ?
prev.filter((id) => id !== keyId) :
[...prev, keyId]
);
};
const toggleSelectAll = () => {
if (selectedKeys.length === allKeys.length) {
setSelectedKeys([]);
} else {
setSelectedKeys(allKeys.map((k) => k.id));
}
};
const toggleLessonSelection = (lessonId) => {
setSelectedLessons((prev) =>
prev.includes(lessonId) ?
prev.filter((id) => id !== lessonId) :
[...prev, lessonId]
);
};
const toggleSelectAllLessons = () => {
const allIds = allItemsToDisplay.map(l => l.id);
if (selectedLessons.length === allIds.length) {
setSelectedLessons([]);
} else {
setSelectedLessons(allIds);
}
};
const allocateKeys = async () => {
setIsAllocating(true);
try {
// Get available keys
const availableKeys = allKeys.filter((k) => selectedKeys.includes(k.id));
// Get pending lessons (only selected ones if any selected, otherwise all)
const lessonsToProcess = selectedLessons.length > 0
? lessons.filter((l) => selectedLessons.includes(l.id) && l.status === 'pending')
: lessons.filter((l) => l.status === 'pending');
const pendingLessons = lessonsToProcess.sort((a, b) => {
// Priority 1: Earlier time
const timeA = a.start_time;
const timeB = b.start_time;
if (timeA !== timeB) return timeA.localeCompare(timeB);
// Priority 2: פלוגתי rooms first
if (a.room_type_needed === 'פלוגתי' && b.room_type_needed === 'צוותי') return -1;
if (a.room_type_needed === 'צוותי' && b.room_type_needed === 'פלוגתי') return 1;
// Priority 3: Needs computers (lower priority for those who don't need)
if (a.needs_computers && !b.needs_computers) return -1;
if (!a.needs_computers && b.needs_computers) return 1;
return 0;
});
// Add special requests to the end (only selected ones if any lessons selected)
const requestsToProcess = selectedLessons.length > 0
? specialRequests.filter((req) => selectedLessons.includes(`special_${req.id}`))
: specialRequests;
const specialRequestsToAllocate = requestsToProcess.map((req) => ({
id: `special_${req.id}`,
crew_name: req.crew_name,
start_time: req.start_time,
end_time: req.end_time,
room_type_needed: req.preferred_type === 'any' ? 'צוותי' : req.preferred_type,
needs_computers: false,
status: 'pending',
isSpecialRequest: true,
originalRequestId: req.id
}));
const allToAllocate = [...pendingLessons, ...specialRequestsToAllocate];
const assignments = [];
const failureReasons = [];
const crewKeyMap = {}; // Track which key each crew/platoon was assigned
// Pre-populate crewKeyMap with already assigned lessons (including manual assignments)
const alreadyAssigned = lessons.filter(l => l.status === 'assigned' && l.assigned_key);
for (const lesson of alreadyAssigned) {
const key = availableKeys.find(k => k.room_number === lesson.assigned_key);
if (key) {
// Remember for crew
if (lesson.crew_name && !crewKeyMap[lesson.crew_name]) {
crewKeyMap[lesson.crew_name] = key.id;
}
// Remember for platoon
if (lesson.platoon_name && !crewKeyMap[lesson.platoon_name]) {
crewKeyMap[lesson.platoon_name] = key.id;
}
}
}
for (const lesson of allToAllocate) {
// Find suitable key
let assignedKey = null;
const crewName = lesson.crew_name;
const platoonName = lesson.platoon_name;
// Priority 1: Try to use the same key this CREW already has today
if (crewName && crewKeyMap[crewName]) {
const previousKey = availableKeys.find((k) => k.id === crewKeyMap[crewName]);
// Only reuse if: exact match OR upgrade (צוותי request gets פלוגתי room)
// Never downgrade (פלוגתי request gets צוותי room)
if (previousKey &&
!isKeyOccupied(previousKey, lesson, assignments) &&
(previousKey.room_type === lesson.room_type_needed ||
(lesson.room_type_needed === 'צוותי' && previousKey.room_type === 'פלוגתי')) &&
(!lesson.needs_computers || previousKey.has_computers)) {
assignedKey = previousKey;
}
}
// Priority 2: Try to use the same key this PLATOON already has today
if (!assignedKey && platoonName && crewKeyMap[platoonName]) {
const previousKey = availableKeys.find((k) => k.id === crewKeyMap[platoonName]);
if (previousKey &&
!isKeyOccupied(previousKey, lesson, assignments) &&
(previousKey.room_type === lesson.room_type_needed ||
(lesson.room_type_needed === 'צוותי' && previousKey.room_type === 'פלוגתי')) &&
(!lesson.needs_computers || previousKey.has_computers)) {
assignedKey = previousKey;
}
}
// Priority 3: Try to find a key in the same zone as previously assigned keys
if (!assignedKey) {
const crewName = lesson.crew_name;
const platoonName = lesson.platoon_name;
// Check if crew or platoon has a previous key with a zone
let preferredZone = null;
if (crewName && crewKeyMap[crewName]) {
const previousKey = availableKeys.find((k) => k.id === crewKeyMap[crewName]);
if (previousKey?.zone) preferredZone = previousKey.zone;
}
if (!preferredZone && platoonName && crewKeyMap[platoonName]) {
const previousKey = availableKeys.find((k) => k.id === crewKeyMap[platoonName]);
if (previousKey?.zone) preferredZone = previousKey.zone;
}
// If we have a preferred zone, try to find a key in that zone
if (preferredZone) {
assignedKey = availableKeys.find(
(k) => k.zone === preferredZone &&
k.room_type === lesson.room_type_needed &&
(!lesson.needs_computers || k.has_computers) &&
!isKeyOccupied(k, lesson, assignments)
);
// If not found exact match in zone, try upgrade in same zone
if (!assignedKey && lesson.room_type_needed === 'צוותי') {
assignedKey = availableKeys.find(
(k) => k.zone === preferredZone &&
k.room_type === 'פלוגתי' &&
(!lesson.needs_computers || k.has_computers) &&
!isKeyOccupied(k, lesson, assignments)
);
}
}
}
// First try to find exact match with computer requirement
if (!assignedKey && lesson.needs_computers) {
assignedKey = availableKeys.find(
(k) => k.room_type === lesson.room_type_needed &&
k.has_computers &&
!isKeyOccupied(k, lesson, assignments)
);
}
// If not found or doesn't need computers, find by room type
if (!assignedKey) {
assignedKey = availableKeys.find(
(k) => k.room_type === lesson.room_type_needed &&
!isKeyOccupied(k, lesson, assignments)
);
}
// If still not found, try any available key (upgrade צוותי to פלוגתי)
if (!assignedKey && lesson.room_type_needed === 'צוותי') {
assignedKey = availableKeys.find(
(k) => k.room_type === 'פלוגתי' &&
!isKeyOccupied(k, lesson, assignments)
);
}
if (assignedKey) {
assignments.push({
lessonId: lesson.id,
keyId: assignedKey.id,
roomNumber: assignedKey.room_number,
crewName: lesson.crew_name,
startTime: lesson.start_time,
endTime: lesson.end_time
});
// Remember this key for this crew and platoon (first match wins)
const crewName = lesson.crew_name;
const platoonName = lesson.platoon_name;
if (crewName && !crewKeyMap[crewName]) {
crewKeyMap[crewName] = assignedKey.id;
}
if (platoonName && !crewKeyMap[platoonName]) {
crewKeyMap[platoonName] = assignedKey.id;
}
} else {
// Track why this lesson couldn't be assigned
const matchingTypeKeys = availableKeys.filter((k) => k.room_type === lesson.room_type_needed);
const occupiedKeys = matchingTypeKeys.filter((k) => isKeyOccupied(k, lesson, assignments));
let reason = `${lesson.crew_name} (${lesson.start_time}-${lesson.end_time}): `;
if (matchingTypeKeys.length === 0) {
reason += `אין מפתחות מסוג ${lesson.room_type_needed}`;
} else if (occupiedKeys.length === matchingTypeKeys.length) {
reason += `כל המפתחות מסוג ${lesson.room_type_needed} תפוסים בשעות אלה`;
} else if (lesson.needs_computers) {
reason += `אין מפתחות עם מחשבים זמינים`;
} else {
reason += `לא נמצא מפתח מתאים`;
}
failureReasons.push(reason);
}
}
// Apply assignments
for (const assignment of assignments) {
// Check if it's a special request
if (assignment.lessonId.startsWith('special_')) {
// Create a new lesson for the special request
const requestId = assignment.lessonId.replace('special_', '');
const originalRequest = specialRequests.find(r => r.id === requestId);
await base44.entities.Lesson.create({
crew_manager: originalRequest?.crew_manager || 'special_request',
crew_name: assignment.crewName,
platoon_name: originalRequest?.platoon_name || '',
date: selectedDate,
start_time: assignment.startTime,
end_time: assignment.endTime,
room_type_needed: originalRequest?.preferred_type === 'any' ? 'צוותי' : originalRequest?.preferred_type,
needs_computers: false,
assigned_key: assignment.roomNumber,
status: 'assigned',
notes: originalRequest?.notes || 'בקשה מיוחדת'
});
// Delete the special request from queue
await base44.entities.WaitingQueue.delete(requestId);
toast.success(`בקשה מיוחדת של ${assignment.crewName} שובצה`);
} else {
// Regular lesson - update status
await updateLessonMutation.mutateAsync({
id: assignment.lessonId,
data: {
assigned_key: assignment.roomNumber,
status: 'assigned'
}
});
}
}
// Refresh special requests
queryClient.invalidateQueries({ queryKey: ['special-requests'] });
toast.success(`שובצו בהצלחה ${assignments.length} שיעורים ובקשות!`);
const unassigned = allToAllocate.length - assignments.length;
if (unassigned > 0) {
console.log('שיעורים שלא שובצו:', failureReasons);
toast.warning(`${unassigned} שיעורים לא שובצו. פתח Console לפרטים`);
}
} catch (error) {
toast.error('שגיאה בהקצאת מפתחות');
console.error(error);
} finally {
setIsAllocating(false);
}
};
// Check if a key is already occupied during the lesson time
const isKeyOccupied = (key, newLesson, currentAssignments) => {
// Check against temporary assignments
const overlapping = currentAssignments.filter((a) => a.keyId === key.id);
for (const assignment of overlapping) {
if (timeSlotsOverlap(
newLesson.start_time, newLesson.end_time,
assignment.startTime, assignment.endTime
)) {
return true;
}
}
// Check against already assigned lessons (manual assignments)
const alreadyAssigned = lessons.filter(l =>
l.status === 'assigned' &&
l.assigned_key === key.room_number
);
for (const lesson of alreadyAssigned) {
if (timeSlotsOverlap(
newLesson.start_time, newLesson.end_time,
lesson.start_time, lesson.end_time
)) {
return true;
}
}
return false;
};
const timeSlotsOverlap = (start1, end1, start2, end2) => {
return start1 < end2 && start2 < end1;
};
const resetAllocations = async () => {
const assigned = lessons.filter((l) => l.status === 'assigned');
for (const lesson of assigned) {
await updateLessonMutation.mutateAsync({
id: lesson.id,
data: { assigned_key: null, status: 'pending' }
});
}
toast.success('ההקצאות אופסו');
};
// Combine lessons and special requests for display
const allItemsToDisplay = [
...lessons,
...specialRequests.map((req) => ({
id: `special_${req.id}`,
crew_name: req.crew_name,
start_time: req.start_time,
end_time: req.end_time,
room_type_needed: req.preferred_type === 'any' ? 'צוותי' : req.preferred_type,
needs_computers: false,
status: 'special_request',
notes: req.notes,
isSpecialRequest: true,
originalRequestId: req.id
}))
].sort((a, b) => a.start_time.localeCompare(b.start_time));
const pendingCount = lessons.filter((l) => l.status === 'pending').length;
const assignedCount = lessons.filter((l) => l.status === 'assigned').length;
const specialRequestsCount = specialRequests.length;
if (!user) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-100 flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-slate-400" />
</div>
);
}
if (user.role !== 'admin') {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-100 flex items-center justify-center" dir="rtl">
<Card className="p-8 text-center max-w-md">
<Shield className="w-16 h-16 text-red-400 mx-auto mb-4" />
<h2 className="text-2xl font-bold text-slate-800 mb-2">אין הרשאת גישה</h2>
<p className="text-slate-600">רק מנהלי מערכת יכולים לגשת להקצאת מפתחות</p>
</Card>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-100">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="mb-8">
<h1 className="text-3xl font-bold text-slate-800 mb-2">
הקצאת מפתחות 🎯
</h1>
</motion.div>
{/* Date and Actions */}
{/* Date and Actions */}
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4 mb-6">
<div className="flex items-center gap-3">
<Label className="text-sm font-medium">תאריך:</Label>
<Input
type="date"
value={selectedDate}
onChange={(e) => setSelectedDate(e.target.value)}
className="w-auto" />
</div>
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
<Button
onClick={allocateKeys}
disabled={selectedKeys.length === 0 || (pendingCount === 0 && specialRequestsCount === 0) || isAllocating}
className="bg-emerald-600 hover:bg-emerald-700">
{isAllocating ?
<Loader2 className="w-4 h-4 ml-2 animate-spin" /> :
<Wand2 className="w-4 h-4 ml-2" />
}
{selectedLessons.length > 0
? `שבץ ${selectedLessons.length} נבחרים`
: 'שבץ אוטומטית'}
</Button>
<Button
variant="outline"
onClick={resetAllocations}
disabled={assignedCount === 0}>
<RefreshCw className="w-4 h-4 ml-2" />
אפס הקצאות
</Button>
<Button
variant="outline"
onClick={() => {
if (confirm('האם למחוק את כל השיעורים?')) {
deleteAllLessonsMutation.mutate();
}
}}
disabled={lessons.length === 0}
className="text-red-600 hover:text-red-700 hover:bg-red-50">
<Trash2 className="w-4 h-4 ml-2" />
מחק הכל
</Button>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-1 sm:grid-cols-6 gap-4 mb-6">
<Card className="p-4">
<p className="text-sm text-slate-500">סה״כ שיעורים</p>
<p className="text-2xl font-bold text-slate-800">{lessons.length}</p>
</Card>
<Card className="p-4 bg-yellow-50 border-yellow-200">
<p className="text-sm text-yellow-600">ממתינים</p>
<p className="text-2xl font-bold text-yellow-700">{pendingCount}</p>
</Card>
<Card className="p-4 bg-green-50 border-green-200">
<p className="text-sm text-green-600">שובצו</p>
<p className="text-2xl font-bold text-green-700">{assignedCount}</p>
</Card>
<Card className="p-4 bg-purple-50 border-purple-200">
<p className="text-sm text-purple-600">בקשות מיוחדות</p>
<p className="text-2xl font-bold text-purple-700">{specialRequestsCount}</p>
</Card>
<Card className="p-4 bg-blue-50 border-blue-200">
<p className="text-sm text-blue-600">מפתחות זמינים</p>
<p className="text-2xl font-bold text-blue-700">{selectedKeys.length}</p>
</Card>
<Card className="p-4 bg-indigo-50 border-indigo-200">
<p className="text-sm text-indigo-600">שיעורים נבחרים</p>
<p className="text-2xl font-bold text-indigo-700">{selectedLessons.length}</p>
</Card>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Available Keys Selection */}
<Card className="p-6 border-slate-200">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-lg flex items-center gap-2">
<Key className="w-5 h-5 text-slate-600" />
בחר מפתחות זמינים
</h3>
<Button
variant="outline"
size="sm"
onClick={toggleSelectAll} className="bg-background px-1 text-xs font-medium rounded-md inline-flex items-center justify-center gap-2 whitespace-nowrap transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 border border-input shadow-sm hover:bg-accent hover:text-accent-foreground h-8">
{selectedKeys.length === allKeys.length ? 'בטל הכל' : 'בחר הכל'}
</Button>
</div>
<div className="space-y-2 max-h-[500px] overflow-y-auto">
{allKeys.map((key) =>
<div
key={key.id}
className="flex items-center space-x-3 p-3 rounded-lg border border-slate-200 hover:bg-slate-50">
<Checkbox
id={key.id}
checked={selectedKeys.includes(key.id)}
onCheckedChange={() => toggleKeySelection(key.id)} />
<label htmlFor={key.id} className="flex-1 cursor-pointer">
<div className="text-slate-700 mx-3 font-medium opacity-100">חדר {key.room_number}</div>
<div className="flex items-center gap-2 mt-1">
<Badge variant="outline" className="bg-teal-100 text-foreground mx-2 px-2.5 py-0.5 text-xs font-semibold rounded-md inline-flex items-center border transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2">
{key.room_type === 'פלוגתי' ? '🏢' : '🏠'} {key.room_type}
</Badge>
{key.has_computers &&
<Badge variant="outline" className="text-xs">💻</Badge>
}
</div>
</label>
</div>
)}
</div>
</Card>
{/* Lessons List */}
<Card className="lg:col-span-2 overflow-hidden border-slate-200">
<div className="p-6 border-b bg-slate-50 flex items-center justify-between">
<h3 className="font-semibold text-lg flex items-center gap-2">
<Calendar className="w-5 h-5 text-slate-600" />
לוח זמנים שיעורים
</h3>
<Button
variant="outline"
size="sm"
onClick={toggleSelectAllLessons}
className="text-xs">
{selectedLessons.length === allItemsToDisplay.length ? 'בטל הכל' : 'בחר הכל'}
</Button>
</div>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="h-10 px-2 text-center align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]"></TableHead>
<TableHead className="h-10 px-2 text-center align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]">שעה</TableHead>
<TableHead className="h-10 px-2 text-center align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]">צוות</TableHead>
<TableHead className="h-10 px-2 text-center align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]">סוג</TableHead>
<TableHead className="h-10 px-2 text-center align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]">💻</TableHead>
<TableHead className="h-10 px-2 text-center align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]">סטטוס</TableHead>
<TableHead className="h-10 px-2 text-center align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]">חדר</TableHead>
<TableHead className="h-10 px-2 text-center align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]">הקצאה ידנית</TableHead>
<TableHead className="h-10 px-2 text-center align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]">מחק</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ?
<TableRow>
<TableCell colSpan={9} className="text-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-slate-400 mx-auto" />
</TableCell>
</TableRow> :
allItemsToDisplay.length === 0 ?
<TableRow>
<TableCell colSpan={9} className="text-center py-8 text-slate-400">
אין שיעורים או בקשות מתוכננים לתאריך זה
</TableCell>
</TableRow> :
allItemsToDisplay.map((lesson) =>
<TableRow key={lesson.id} className="hover:bg-slate-50/50">
<TableCell className="p-2 text-center align-middle">
<Checkbox
checked={selectedLessons.includes(lesson.id)}
onCheckedChange={() => toggleLessonSelection(lesson.id)}
/>
</TableCell>
<TableCell className="p-2 text-center align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px] font-mono text-sm">
{lesson.start_time}-{lesson.end_time}
</TableCell>
<TableCell className="p-2 text-center align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px] font-medium">
{lesson.crew_name}
{lesson.isSpecialRequest && (
<Badge className="mr-2 bg-purple-100 text-purple-700 text-xs">בקשה מיוחדת</Badge>
)}
</TableCell>
<TableCell className="p-2 flex items-center justify-center [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]\n">
<Badge variant="outline" className="text-xs">
{lesson.room_type_needed === 'פלוגתי' ? '🏢' : '🏠'}
</Badge>
</TableCell>
<TableCell className="p-2 text-center align-middle[&:has([role=checkbox])]:pr-0 p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]">
{lesson.needs_computers ? '✅' : '—'}
</TableCell>
<TableCell className="p-2 flex items-center justify-center [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]\n">
{lesson.status === 'assigned' ? (
<CheckCircle className="w-4 h-4 text-green-600" />
) : lesson.status === 'special_request' ? (
<Badge className="bg-purple-100 text-purple-700 text-xs">תור</Badge>
) : (
<AlertTriangle className="w-4 h-4 text-yellow-600" />
)}
</TableCell>
<TableCell className="p-2 text-center align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]">
{lesson.assigned_key ?
<Badge className="bg-emerald-100 text-emerald-700 hover:bg-emerald-100">
{lesson.assigned_key}
</Badge> :
<span className="text-slate-400"></span>
}
</TableCell>
<TableCell className="p-2 text-center align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]">
{lesson.isSpecialRequest ? (
<Select
value=""
onValueChange={async (value) => {
// Create lesson from special request
const requestId = lesson.originalRequestId;
const originalRequest = specialRequests.find(r => r.id === requestId);
await base44.entities.Lesson.create({
crew_manager: originalRequest?.crew_manager || 'special_request',
crew_name: lesson.crew_name,
platoon_name: originalRequest?.platoon_name || '',
date: selectedDate,
start_time: lesson.start_time,
end_time: lesson.end_time,
room_type_needed: originalRequest?.preferred_type === 'any' ? 'צוותי' : originalRequest?.preferred_type,
needs_computers: false,
assigned_key: value,
status: 'assigned',
notes: originalRequest?.notes || 'בקשה מיוחדת'
});
// Delete the special request from queue
await base44.entities.WaitingQueue.delete(requestId);
queryClient.invalidateQueries({ queryKey: ['all-lessons'] });
queryClient.invalidateQueries({ queryKey: ['special-requests'] });
toast.success(`בקשה מיוחדת של ${lesson.crew_name} שובצה ידנית`);
}}
>
<SelectTrigger className="h-8 w-[120px] text-xs">
<SelectValue placeholder="שבץ ידנית" />
</SelectTrigger>
<SelectContent dir="rtl">
{allKeys.map((key) => (
<SelectItem key={key.id} value={key.room_number}>
{key.room_type === 'פלוגתי' ? '🏢' : '🏠'} חדר {key.room_number}
{key.has_computers && ' 💻'}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Select
value={lesson.assigned_key || ''}
onValueChange={(value) => {
if (value === 'unassign') {
updateLessonMutation.mutate({
id: lesson.id,
data: { assigned_key: null, status: 'pending' }
});
} else {
manualAssignMutation.mutate({
lessonId: lesson.id,
roomNumber: value
});
}
}}
>
<SelectTrigger className="h-8 w-[120px] text-xs">
<SelectValue placeholder="בחר חדר" />
</SelectTrigger>
<SelectContent dir="rtl">
{lesson.assigned_key && (
<SelectItem value="unassign" className="text-red-600">
בטל הקצאה
</SelectItem>
)}
{allKeys.map((key) => (
<SelectItem key={key.id} value={key.room_number}>
{key.room_type === 'פלוגתי' ? '🏢' : '🏠'} חדר {key.room_number}
{key.has_computers && ' 💻'}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</TableCell>
<TableCell className="p-2 text-center align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]">
<Button
variant="ghost"
size="icon"
onClick={() => {
if (lesson.isSpecialRequest) {
base44.entities.WaitingQueue.delete(lesson.originalRequestId).then(() => {
queryClient.invalidateQueries({ queryKey: ['special-requests'] });
toast.success('בקשה מיוחדת נמחקה');
});
} else {
deleteLessonMutation.mutate(lesson.id);
}
}}
className="text-red-400 hover:text-red-600 hover:bg-red-50">
<Trash2 className="w-4 h-4" />
</Button>
</TableCell>
</TableRow>
)
}
</TableBody>
</Table>
</div>
</Card>
</div>
{/* Priority Info */}
<Card className="mt-6 p-6 bg-blue-50 border-blue-200">
<h4 className="font-semibold text-blue-900 mb-3">סדר עדיפויות הקצאה:</h4>
<ol className="space-y-2 text-sm text-blue-800">
<li>1. <strong>שימור כיתות לצוות - צוות שקיבל כיתה מסוימת ישאר איתה לאורך היום</strong></li>
<li>2. <strong>שימור כיתות לפלוגה - העדפה לאותה כיתה שהפלוגה השתמשה בה</strong></li>
<li>3. <strong>שימור אזור - העדפה לכיתות באותו אזור פיזי</strong></li>
<li>4. שיעורים מוקדמים יותר מקבלים עדיפות</li>
<li>5. חדרים פלוגתיים משובצים ראשונים</li>
<li>6. שיעורים שדורשים מחשבים מקבלים עדיפות על פני אלו שלא</li>
<li>7. בקשות לחדרים צוותיים עשויות לקבל שדרוג לפלוגתי במידת הצורך</li>
<li>8. <strong>בקשות מיוחדות מקבלות עדיפות נמוכה - משובצות אחרונות</strong></li>
</ol>
</Card>
</div>
</div>);
}

View File

@ -0,0 +1,316 @@
import React, { useState } from 'react';
import { base44 } from '@/api/base44Client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Card } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription } from
"@/components/ui/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow } from
"@/components/ui/table";
import { Plus, Users, Trash2, Edit2, Phone, GripVertical } from 'lucide-react';
import { toast } from 'sonner';
import { motion } from 'framer-motion';
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
export default function ManageCrews() {
const [showModal, setShowModal] = useState(false);
const [editingCrew, setEditingCrew] = useState(null);
const [user, setUser] = useState(null);
const [formData, setFormData] = useState({ name: '', contact: '', notes: '' });
const queryClient = useQueryClient();
React.useEffect(() => {
base44.auth.me().then(setUser).catch(() => {});
}, []);
const isAdmin = user?.role === 'admin';
const { data: crews = [], isLoading } = useQuery({
queryKey: ['crews'],
queryFn: async () => {
const data = await base44.entities.Crew.list();
return data.sort((a, b) => (a.order || 0) - (b.order || 0));
}
});
const createMutation = useMutation({
mutationFn: (data) => base44.entities.Crew.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['crews'] });
setShowModal(false);
setFormData({ name: '', contact: '', notes: '' });
toast.success('פלוגה נוספה בהצלחה');
}
});
const updateMutation = useMutation({
mutationFn: ({ id, data }) => base44.entities.Crew.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['crews'] });
setShowModal(false);
setEditingCrew(null);
setFormData({ name: '', contact: '', notes: '' });
toast.success('פלוגה עודכנה בהצלחה');
}
});
const deleteMutation = useMutation({
mutationFn: (id) => base44.entities.Crew.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['crews'] });
toast.success('פלוגה נמחקה בהצלחה');
}
});
const handleSubmit = () => {
if (editingCrew) {
updateMutation.mutate({ id: editingCrew.id, data: formData });
} else {
createMutation.mutate(formData);
}
};
const handleEdit = (crew) => {
setEditingCrew(crew);
setFormData({ name: crew.name, contact: crew.contact || '', notes: crew.notes || '' });
setShowModal(true);
};
const handleClose = () => {
setShowModal(false);
setEditingCrew(null);
setFormData({ name: '', contact: '', notes: '' });
};
const handleDragEnd = async (result) => {
if (!result.destination || !isAdmin) return;
const items = Array.from(crews);
const [reorderedItem] = items.splice(result.source.index, 1);
items.splice(result.destination.index, 0, reorderedItem);
// Update order for all items
const updates = items.map((item, index) =>
base44.entities.Crew.update(item.id, { order: index })
);
await Promise.all(updates);
queryClient.invalidateQueries({ queryKey: ['crews'] });
toast.success('הסדר עודכן');
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-100">
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="mb-8">
<h1 className="text-3xl font-bold text-slate-800 mb-2">
ניהול פלוגות 👥
</h1>
</motion.div>
{/* Stats */}
<div className="mb-8">
<Card className="p-4 border-slate-200 inline-block">
<p className="text-sm text-slate-500">סה״כ פלוגות</p>
<p className="text-2xl font-bold text-slate-800">{crews.length}</p>
</Card>
</div>
{/* Add Button */}
{isAdmin &&
<div className="flex justify-end mb-6">
<Button onClick={() => setShowModal(true)} className="bg-indigo-600 hover:bg-indigo-700">
<Plus className="w-4 h-4 ml-2" />
הוסף פלוגה חדשה
</Button>
</div>
}
{/* Crews Table */}
<Card className="overflow-hidden border-slate-200">
<Table>
<TableHeader>
<TableRow className="bg-slate-50">
{isAdmin && <TableHead className="w-12"></TableHead>}
<TableHead className="text-muted-foreground mx-64 my-8 px-2 font-medium text-left h-10 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px] text-center">פלוגות</TableHead>
<TableHead className="h-1 px-2 align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px] text-center">איש קשר</TableHead>
<TableHead className="text-center">הערות</TableHead>
{isAdmin && <TableHead className="text-center">פעולות</TableHead>}
</TableRow>
</TableHeader>
<DragDropContext onDragEnd={handleDragEnd}>
<Droppable droppableId="crews">
{(provided) => (
<TableBody {...provided.droppableProps} ref={provided.innerRef}>
{isLoading ?
<TableRow>
<TableCell colSpan={isAdmin ? 5 : 3} className="text-center py-8 text-slate-400">
טוען...
</TableCell>
</TableRow> :
crews.length === 0 ?
<TableRow>
<TableCell colSpan={isAdmin ? 5 : 3} className="text-center py-8 text-slate-400">
עדיין לא נוספו פלוגות
</TableCell>
</TableRow> :
crews.map((crew, index) =>
<Draggable key={crew.id} draggableId={crew.id} index={index} isDragDisabled={!isAdmin}>
{(provided, snapshot) => (
<TableRow
ref={provided.innerRef}
{...provided.draggableProps}
className={`hover:bg-slate-50/50 [&_td]:text-center ${snapshot.isDragging ? 'bg-slate-100' : ''}`}
>
{isAdmin && (
<TableCell {...provided.dragHandleProps} className="cursor-grab active:cursor-grabbing">
<GripVertical className="w-4 h-4 text-slate-400 mx-auto" />
</TableCell>
)}
<TableCell className="font-medium text-center">
<div className="flex flex-row-reverse items-center justify-between gap-2">
<span className="flex-1 text-center">{crew.name}</span>
<div className="w-8 h-8 rounded-full bg-indigo-100 flex items-center justify-center flex-shrink-0">
<Users className="w-4 h-4 text-indigo-600" />
</div>
</div>
</TableCell>
<TableCell className="text-center">
{crew.contact ?
<a
href={`tel:${crew.contact}`}
className="flex flex-row-reverse items-center justify-center gap-2 text-slate-600 hover:text-blue-600 transition-colors cursor-pointer">
<Phone className="w-4 h-4" />
<span dir="ltr">{crew.contact}</span>
</a> :
<span className="text-slate-400"></span>
}
</TableCell>
<TableCell className="text-slate-500 max-w-xs truncate">
{crew.notes || '—'}
</TableCell>
{isAdmin &&
<TableCell className="text-center">
<div className="flex justify-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => handleEdit(crew)}
className="text-slate-400 hover:text-slate-600">
<Edit2 className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => deleteMutation.mutate(crew.id)}
className="text-red-400 hover:text-red-600 hover:bg-red-50">
<Trash2 className="w-4 h-4" />
</Button>
</div>
</TableCell>
}
</TableRow>
)}
</Draggable>
)
}
{provided.placeholder}
</TableBody>
)}
</Droppable>
</DragDropContext>
</Table>
</Card>
</div>
{/* Add/Edit Modal */}
<Dialog open={showModal} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-md" dir="rtl">
<DialogHeader className="text-right">
<DialogTitle className="flex flex-row-reverse items-center gap-2 justify-end">
{editingCrew ? 'ערוך פלוגה' : 'הוסף פלוגה חדשה'}
<div className="p-2 bg-indigo-100 rounded-lg">
<Users className="w-5 h-5 text-indigo-600" />
</div>
</DialogTitle>
<DialogDescription className="text-right">
{editingCrew ? 'עדכן את פרטי הפלוגה' : 'הוסף פלוגה חדשה למעקב'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label className="text-right block">שם הפלוגה</Label>
<Input
placeholder="למשל, פלוגת יפתח..."
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="text-right" />
</div>
<div className="space-y-2">
<Label className="text-right block">טלפון איש קשר</Label>
<Input
type="tel"
placeholder="+972.."
value={formData.contact}
onChange={(e) => setFormData({ ...formData, contact: e.target.value })}
className="text-right" />
</div>
<div className="space-y-2">
<Label className="text-right block">הערות (אופציונלי)</Label>
<Textarea
placeholder="שם הקה״ד הפלוגתי"
value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
className="h-20 text-right" />
</div>
</div>
<div className="flex flex-row-reverse gap-3">
<Button variant="outline" onClick={handleClose} className="flex-1">
ביטול
</Button>
<Button
onClick={handleSubmit}
disabled={!formData.name}
className="flex-1 bg-indigo-600 hover:bg-indigo-700">
{editingCrew ? 'עדכן פלוגה' : 'הוסף פלוגה'}
</Button>
</div>
</DialogContent>
</Dialog>
</div>);
}

View File

@ -0,0 +1,532 @@
import React, { useState } from 'react';
import { base44 } from '@/api/base44Client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription } from
"@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue } from
"@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow } from
"@/components/ui/table";
import { Plus, Key, Trash2, Edit2, Monitor } from 'lucide-react';
import { Checkbox } from "@/components/ui/checkbox";
import { toast } from 'sonner';
import { motion } from 'framer-motion';
export default function ManageKeys() {
const [showModal, setShowModal] = useState(false);
const [editingKey, setEditingKey] = useState(null);
const [misdarEditKey, setMisdarEditKey] = useState(null);
const [misdarValue, setMisdarValue] = useState('');
const [user, setUser] = useState(null);
const [formData, setFormData] = useState({ room_number: '', room_type: 'צוותי', has_computers: false, zone: '' });
const queryClient = useQueryClient();
React.useEffect(() => {
base44.auth.me().then(setUser).catch(() => {});
}, []);
const isAdmin = user?.role === 'admin';
const { data: keys = [], isLoading } = useQuery({
queryKey: ['keys'],
queryFn: () => base44.entities.ClassroomKey.list()
});
const { data: zones = [] } = useQuery({
queryKey: ['zones'],
queryFn: () => base44.entities.Zone.list('order'),
enabled: isAdmin
});
const { data: todayLessons = [] } = useQuery({
queryKey: ['today-lessons'],
queryFn: async () => {
const today = new Date().toISOString().split('T')[0];
return base44.entities.Lesson.filter({ date: today, status: 'assigned' });
}
});
const { data: wednesdayLessons = [] } = useQuery({
queryKey: ['wednesday-lessons'],
queryFn: async () => {
// Find the next Wednesday (or today if it's Wednesday)
const today = new Date();
const dayOfWeek = today.getDay();
const daysUntilWednesday = dayOfWeek === 3 ? 0 : (3 - dayOfWeek + 7) % 7;
const nextWednesday = new Date(today);
nextWednesday.setDate(today.getDate() + daysUntilWednesday);
const wednesdayDate = nextWednesday.toISOString().split('T')[0];
return base44.entities.Lesson.filter({ date: wednesdayDate, status: 'assigned' }, '-end_time');
},
enabled: isAdmin
});
const { data: allUsers = [] } = useQuery({
queryKey: ['users'],
queryFn: () => base44.entities.User.list(),
enabled: isAdmin
});
// Get current key holder for a room
const getCurrentHolder = (roomNumber) => {
const now = new Date();
const currentTime = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
const currentLesson = todayLessons.find((lesson) =>
lesson.assigned_key === roomNumber &&
lesson.start_time <= currentTime &&
lesson.end_time > currentTime
);
return currentLesson ? currentLesson.crew_name : null;
};
// Get who's responsible for cleaning this room (Misdar)
const getMisdarResponsible = (key) => {
// Check for manual assignment first
if (key.manual_misdar_assignment) {
return { crewName: key.manual_misdar_assignment, platoon: null };
}
if (!wednesdayLessons.length) return null;
// Find all lessons for this room
const roomLessons = wednesdayLessons.filter((l) => l.assigned_key === key.room_number);
if (roomLessons.length === 0) return null;
// Check each lesson to see if the key was passed to another crew
for (const lesson of roomLessons) {
// Check if there's another lesson that took this key after this one
const nextLesson = wednesdayLessons.find((l) =>
l.assigned_key === key.room_number &&
l.crew_manager !== lesson.crew_manager &&
l.start_time >= lesson.end_time
);
// If no one took the key after this lesson, this crew is responsible
if (!nextLesson) {
// Find the user who created this lesson to get their platoon
const userWhoCreated = allUsers.find((u) => u.email === lesson.crew_manager);
const platoon = userWhoCreated?.platoon_name || null;
return { crewName: lesson.crew_name, platoon };
}
}
return null;
};
const createMutation = useMutation({
mutationFn: (data) => base44.entities.ClassroomKey.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['keys'] });
setShowModal(false);
setFormData({ room_number: '', room_type: 'צוותי', has_computers: false, zone: '' });
toast.success('מפתח נוסף בהצלחה');
}
});
const updateMutation = useMutation({
mutationFn: ({ id, data }) => base44.entities.ClassroomKey.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['keys'] });
setShowModal(false);
setEditingKey(null);
setFormData({ room_number: '', room_type: 'צוותי', has_computers: false, zone: '' });
toast.success('מפתח עודכן בהצלחה');
}
});
const deleteMutation = useMutation({
mutationFn: (id) => base44.entities.ClassroomKey.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['keys'] });
toast.success('מפתח נמחק בהצלחה');
}
});
const handleSubmit = () => {
if (editingKey) {
updateMutation.mutate({ id: editingKey.id, data: formData });
} else {
createMutation.mutate({ ...formData, status: 'available' });
}
};
const handleEdit = (key) => {
setEditingKey(key);
setFormData({ room_number: key.room_number, room_type: key.room_type, has_computers: key.has_computers || false, zone: key.zone || '' });
setShowModal(true);
};
const handleClose = () => {
setShowModal(false);
setEditingKey(null);
setFormData({ room_number: '', room_type: 'צוותי', has_computers: false, zone: '' });
};
const handleMisdarEdit = (key) => {
setMisdarEditKey(key);
setMisdarValue(key.manual_misdar_assignment || '');
};
const handleMisdarSave = async () => {
if (misdarEditKey) {
await updateMutation.mutateAsync({
id: misdarEditKey.id,
data: { manual_misdar_assignment: misdarValue || null }
});
setMisdarEditKey(null);
setMisdarValue('');
}
};
const smallCount = keys.filter((k) => k.room_type === 'צוותי').length;
const largeCount = keys.filter((k) => k.room_type === 'פלוגתי').length;
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-100">
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="mb-8">
<h1 className="text-3xl font-bold text-slate-800 mb-2">
ניהול מפתחות 🗝
</h1>
<p className="text-slate-500">
הוסף, ערוך או הסר מפתחות כיתות
</p>
</motion.div>
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 mb-8">
<Card className="p-4 border-slate-200">
<p className="text-sm text-slate-500">סה״כ מפתחות</p>
<p className="text-2xl font-bold text-slate-800">{keys.length}</p>
</Card>
<Card className="p-4 border-blue-200 bg-blue-50/50">
<p className="text-sm text-blue-600">חדרים צוותיים</p>
<p className="text-2xl font-bold text-blue-700">{smallCount}</p>
</Card>
<Card className="p-4 border-purple-200 bg-purple-50/50">
<p className="text-sm text-purple-600">חדרים פלוגתיים</p>
<p className="text-2xl font-bold text-purple-700">{largeCount}</p>
</Card>
</div>
{isAdmin &&
<div className="flex justify-end mb-6">
<Button
onClick={() => setShowModal(true)}
className="bg-emerald-600 hover:bg-emerald-700">
<Plus className="w-4 h-4 ml-2" />
הוסף מפתח חדש
</Button>
</div>
}
{/* Keys Table */}
<Card className="overflow-hidden border-slate-200">
<Table>
<TableHeader>
<TableRow className="bg-slate-50">
<TableHead className="h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px] text-center">מספר חדר</TableHead>
<TableHead className="h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px] text-center">סוג</TableHead>
<TableHead className="h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px] text-center">אזור</TableHead>
<TableHead className="h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px] text-center">מחשבים</TableHead>
<TableHead className="h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px] text-center">סטטוס / מחזיק</TableHead>
{isAdmin &&
<TableHead className="h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px] text-center">מסדר כיתות 🧹</TableHead>
}
{isAdmin &&
<TableHead className="h-10 px-2 align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px] text-center">פעולות</TableHead>
}
</TableRow>
</TableHeader>
<TableBody>
{isLoading ?
<TableRow>
<TableCell colSpan={isAdmin ? 7 : 5} className="text-center py-8 text-slate-400">
טוען...
</TableCell>
</TableRow> :
keys.length === 0 ?
<TableRow>
<TableCell colSpan={isAdmin ? 7 : 5} className="text-center py-8 text-slate-400">
עדיין לא נוספו מפתחות
</TableCell>
</TableRow> :
keys.map((key) =>
<TableRow key={key.id} className="hover:bg-slate-50/50">
<TableCell className="p-2 text-center flex items-center justify-center align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px] font-medium">
<div className="flex items-center gap-2">
<Key className="w-4 h-4 text-slate-400" />
{key.room_number}
</div>
</TableCell>
<TableCell className="p-2 align-middle text-center [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]">
<Badge variant="outline" className={
key.room_type === 'פלוגתי' ?
'border-purple-300 text-purple-700' :
'border-blue-300 text-blue-700'
}>
{key.room_type === 'פלוגתי' ? '🏢 פלוגתי' : '🏠 צוותי'}
</Badge>
</TableCell>
<TableCell className="p-2 align-middle text-center [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]">
{key.zone ? (
<Badge variant="outline" className="border-slate-300 text-slate-700">
📍 {key.zone}
</Badge>
) : (
<span className="text-slate-400"></span>
)}
</TableCell>
<TableCell className="text-cente my-3 p-2 text-center t te tex texx text align-middle flex items-center justify-center [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]">
{key.has_computers ?
<Monitor className="w-4 h-4 text-blue-600" /> :
<span className="text-slate-300"></span>
}
</TableCell>
<TableCell className="p-2 align-middle text-center [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]">
{(() => {
const holder = getCurrentHolder(key.room_number);
return holder ?
<div className="flex flex-col items-center gap-1">
<Badge className="bg-amber-100 text-amber-700 hover:bg-amber-100">
תפוס
</Badge>
<span className="text-xs text-slate-600">{holder}</span>
</div> :
<Badge className="bg-emerald-100 text-emerald-700 hover:bg-emerald-100">
זמין
</Badge>;
})()}
</TableCell>
{isAdmin &&
<TableCell className="p-2 align-middle text-center [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]">
<div className="flex items-center justify-center gap-2">
{(() => {
const responsible = getMisdarResponsible(key);
return responsible ?
<div className="flex flex-col items-center gap-1">
<Badge variant="outline" className="bg-orange-50 text-orange-700 border-orange-200">
🧹 {responsible.crewName}
</Badge>
{responsible.platoon &&
<span className="text-xs text-slate-500 font-medium">
{responsible.platoon}
</span>
}
</div> :
<span className="text-slate-400 text-xs"></span>;
})()}
<Button
variant="ghost"
size="icon"
onClick={() => handleMisdarEdit(key)}
className="h-6 w-6 text-slate-400 hover:text-orange-600">
<Edit2 className="w-3 h-3" />
</Button>
</div>
</TableCell>
}
{isAdmin &&
<TableCell className="text-right">
<div className="flex justify-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => handleEdit(key)}
className="text-slate-400 hover:text-slate-600">
<Edit2 className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => deleteMutation.mutate(key.id)}
className="text-red-400 hover:text-red-600 hover:bg-red-50">
<Trash2 className="w-4 h-4" />
</Button>
</div>
</TableCell>
}
</TableRow>
)
}
</TableBody>
</Table>
</Card>
</div>
{/* Misdar Edit Modal */}
<Dialog open={!!misdarEditKey} onOpenChange={() => {setMisdarEditKey(null);setMisdarValue('');}}>
<DialogContent className="sm:max-w-md" dir="rtl">
<DialogHeader className="text-right">
<DialogTitle className="flex flex-row-reverse items-center gap-2 justify-end">
ערוך מסדר כיתות
<div className="p-2 bg-orange-100 rounded-lg">
<span className="text-lg">🧹</span>
</div>
</DialogTitle>
<DialogDescription className="text-right">
הגדר ידנית איזו פלוגה אחראית על מסדר חדר {misdarEditKey?.room_number}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label className="text-right block">שם הפלוגה האחראית</Label>
<select
value={misdarValue}
onChange={(e) => setMisdarValue(e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-md text-right">
<option value="">חישוב אוטומטי</option>
<option value="פלוגה א - סהר">פלוגה א - סהר</option>
<option value="פלוגה ב - יפתח">פלוגה ב - יפתח</option>
<option value="פלוגה ג - אייל">פלוגה ג - אייל</option>
<option value="פלוגה ד - אסף">פלוגה ד - אסף</option>
<option value="פלוגה ה - איתן">פלוגה ה - איתן</option>
</select>
<p className="text-xs text-slate-500 text-right">
בחר "חישוב אוטומטי" כדי להשתמש בחישוב לפי לוח השיעורים ביום רביעי
</p>
</div>
</div>
<div className="flex flex-row-reverse gap-3">
<Button variant="outline" onClick={() => {setMisdarEditKey(null);setMisdarValue('');}} className="flex-1">
ביטול
</Button>
<Button
onClick={handleMisdarSave}
className="flex-1 bg-orange-600 hover:bg-orange-700">
שמור
</Button>
</div>
</DialogContent>
</Dialog>
{/* Add/Edit Modal */}
<Dialog open={showModal} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-md" dir="rtl">
<DialogHeader className="text-right">
<DialogTitle className="flex flex-row-reverse items-center gap-2 justify-end">
{editingKey ? 'ערוך מפתח' : 'הוסף מפתח חדש'}
<div className="p-2 bg-emerald-100 rounded-lg">
<Key className="w-5 h-5 text-emerald-600" />
</div>
</DialogTitle>
<DialogDescription className="text-right">
{editingKey ? 'עדכן את פרטי המפתח' : 'הוסף מפתח חדש למעקב'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-right block">מספר חדר *</Label>
<Input
placeholder="למשל, 101..."
value={formData.room_number}
onChange={(e) => setFormData({ ...formData, room_number: e.target.value })}
className="text-right" />
</div>
<div className="space-y-2">
<Label className="text-right block">סוג חדר</Label>
<Select
value={formData.room_type}
onValueChange={(value) => setFormData({ ...formData, room_type: value })}>
<SelectTrigger className="text-right" dir="rtl">
<SelectValue className="text-right" />
</SelectTrigger>
<SelectContent align="end" dir="rtl">
<SelectItem value="צוותי">צוותי 🏠</SelectItem>
<SelectItem value="פלוגתי">פלוגתי 🏢</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-row-reverse items-center gap-2 justify-end">
<Label htmlFor="has_computers" className="cursor-pointer">
יש מחשב בכיתה 💻
</Label>
<Checkbox
id="has_computers"
checked={formData.has_computers}
onCheckedChange={(checked) =>
setFormData({ ...formData, has_computers: checked })
} />
</div>
<div className="space-y-2">
<Label className="text-right block">אזור (אופציונלי)</Label>
<select
value={formData.zone}
onChange={(e) => setFormData({ ...formData, zone: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-md text-right"
>
<option value="">בחר אזור...</option>
{zones.map((zone) => (
<option key={zone.id} value={zone.name}>{zone.name}</option>
))}
</select>
</div>
</div>
<div className="flex flex-row-reverse gap-3">
<Button variant="outline" onClick={handleClose} className="flex-1">
ביטול
</Button>
<Button
onClick={handleSubmit}
disabled={!formData.room_number}
className="flex-1 bg-emerald-600 hover:bg-emerald-700">
{editingKey ? 'עדכן מפתח' : 'הוסף מפתח'}
</Button>
</div>
</DialogContent>
</Dialog>
</div>);
}

View File

@ -0,0 +1,411 @@
import React, { useState, useEffect } from 'react';
import { base44 } from '@/api/base44Client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Shield, Save, Loader2, ChevronDown, ChevronUp } from 'lucide-react';
import { motion } from 'framer-motion';
import { toast } from 'sonner';
export default function ManagePermissions() {
const [expandedPosition, setExpandedPosition] = useState(null);
const [user, setUser] = useState(null);
const queryClient = useQueryClient();
useEffect(() => {
base44.auth.me().then(setUser).catch(() => {});
}, []);
const isAdmin = user?.role === 'admin';
// Fetch positions
const { data: positions = [], isLoading: positionsLoading } = useQuery({
queryKey: ['positions'],
queryFn: () => base44.entities.Position.list('order'),
enabled: isAdmin
});
// Fetch existing permissions
const { data: permissions = [], isLoading: permissionsLoading } = useQuery({
queryKey: ['permissions'],
queryFn: () => base44.entities.PositionPermission.list(),
enabled: isAdmin
});
// Available pages for access control
const availablePages = [
{ id: 'Dashboard', name: 'לוח בקרה', area: 'classroom' },
{ id: 'DailyOverview', name: 'תמונת מצב', area: 'classroom' },
{ id: 'KeyAllocation', name: 'הקצאת מפתחות', area: 'classroom' },
{ id: 'ManageKeys', name: 'ניהול מפתחות', area: 'classroom' },
{ id: 'MySchedule', name: 'לוח הזמנים שלי', area: 'classroom' },
{ id: 'ManageCrews', name: 'ניהול פלוגות', area: 'classroom' },
{ id: 'ManageSquads', name: 'ניהול צוותים', area: 'classroom' },
{ id: 'HackalonSchedule', name: 'HackAlon - לוח זמנים', area: 'hackalon' },
{ id: 'HackalonTeamArea', name: 'HackAlon - אזור הצוות', area: 'hackalon' },
{ id: 'HackalonOverview', name: 'HackAlon - סקירה', area: 'hackalon' },
{ id: 'HackalonStatus', name: 'HackAlon - תמונת מצב', area: 'hackalon' },
{ id: 'HackalonAssignment', name: 'HackAlon - שיבוץ צוערים', area: 'hackalon' },
{ id: 'HackalonManageProblems', name: 'HackAlon - ניהול בעיות', area: 'hackalon' },
];
// Pages under classroom management (only these require access toggle)
const classroomPages = availablePages.filter(p => p.area === 'classroom');
const hackalonPages = availablePages.filter(p => p.area === 'hackalon');
// Available entities for CRUD permissions
const availableEntities = [
{ id: 'ClassroomKey', name: 'מפתחות כיתות' },
{ id: 'Lesson', name: 'שיעורים' },
{ id: 'WaitingQueue', name: 'תור המתנה' },
{ id: 'Crew', name: 'פלוגות' },
{ id: 'Squad', name: 'צוותים' },
];
const crudOptions = [
{ id: 'read', name: 'צפייה', icon: '👁️' },
{ id: 'create', name: 'יצירה', icon: '' },
{ id: 'update', name: 'עריכה', icon: '✏️' },
{ id: 'delete', name: 'מחיקה', icon: '🗑️' },
];
// Get permission for a specific position
const getPermissionForPosition = (positionId) => {
return permissions.find(p => p.position_id === positionId);
};
// Update permissions mutation
const updatePermissionMutation = useMutation({
mutationFn: async ({ positionId, positionName, pagesAccess, entityPermissions, hasClassroomAccess }) => {
const existing = getPermissionForPosition(positionId);
if (existing) {
return base44.entities.PositionPermission.update(existing.id, {
pages_access: pagesAccess,
entity_permissions: entityPermissions,
has_classroom_management_access: hasClassroomAccess
});
} else {
return base44.entities.PositionPermission.create({
position_id: positionId,
position_name: positionName,
pages_access: pagesAccess,
entity_permissions: entityPermissions,
has_classroom_management_access: hasClassroomAccess || false
});
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['permissions'] });
toast.success('ההרשאות עודכנו בהצלחה');
},
onError: () => {
toast.error('שגיאה בעדכון ההרשאות');
}
});
// Handle classroom management access toggle
const handleClassroomAccessToggle = (position) => {
const permission = getPermissionForPosition(position.id);
const newAccess = !permission?.has_classroom_management_access;
updatePermissionMutation.mutate({
positionId: position.id,
positionName: position.title,
pagesAccess: permission?.pages_access || [],
entityPermissions: permission?.entity_permissions || {},
hasClassroomAccess: newAccess
});
};
// Handle page access toggle
const handlePageToggle = (position, pageId) => {
const permission = getPermissionForPosition(position.id);
const currentPages = permission?.pages_access || [];
const newPages = currentPages.includes(pageId)
? currentPages.filter(p => p !== pageId)
: [...currentPages, pageId];
updatePermissionMutation.mutate({
positionId: position.id,
positionName: position.title,
pagesAccess: newPages,
entityPermissions: permission?.entity_permissions || {},
hasClassroomAccess: permission?.has_classroom_management_access || false
});
};
// Handle entity CRUD toggle
const handleEntityCrudToggle = (position, entityId, crudType) => {
const permission = getPermissionForPosition(position.id);
const currentEntityPerms = permission?.entity_permissions || {};
const currentCrud = currentEntityPerms[entityId] || [];
const newCrud = currentCrud.includes(crudType)
? currentCrud.filter(c => c !== crudType)
: [...currentCrud, crudType];
const newEntityPerms = {
...currentEntityPerms,
[entityId]: newCrud
};
updatePermissionMutation.mutate({
positionId: position.id,
positionName: position.title,
pagesAccess: permission?.pages_access || [],
entityPermissions: newEntityPerms,
hasClassroomAccess: permission?.has_classroom_management_access || false
});
};
if (!user || positionsLoading || permissionsLoading) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-100 flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-slate-400" />
</div>
);
}
if (!isAdmin) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-100 flex items-center justify-center" dir="rtl">
<Card className="p-8 text-center max-w-md">
<Shield className="w-16 h-16 text-red-400 mx-auto mb-4" />
<h2 className="text-2xl font-bold text-slate-800 mb-2">אין הרשאת גישה</h2>
<p className="text-slate-600">רק מנהלי מערכת יכולים לנהל הרשאות תפקידים</p>
</Card>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-100" dir="rtl">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="mb-8"
>
<h1 className="text-3xl font-bold text-slate-800 mb-2">
ניהול הרשאות תפקידים 🔐
</h1>
<p className="text-slate-500">
הגדר לכל תפקיד אילו עמודים וישויות הוא יכול לגשת אליהם
</p>
</motion.div>
{/* Stats */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
<Card className="p-4">
<p className="text-sm text-slate-500">סה״כ תפקידים</p>
<p className="text-2xl font-bold text-slate-800">{positions.length}</p>
</Card>
<Card className="p-4 bg-blue-50 border-blue-200">
<p className="text-sm text-blue-600">תפקידים עם הרשאות</p>
<p className="text-2xl font-bold text-blue-700">{permissions.length}</p>
</Card>
<Card className="p-4 bg-green-50 border-green-200">
<p className="text-sm text-green-600">עמודים זמינים</p>
<p className="text-2xl font-bold text-green-700">{availablePages.length}</p>
</Card>
</div>
{/* Positions List */}
<div className="space-y-4">
{positions.map((position, index) => {
const permission = getPermissionForPosition(position.id);
const isExpanded = expandedPosition === position.id;
const pagesAccess = permission?.pages_access || [];
const entityPermissions = permission?.entity_permissions || {};
return (
<motion.div
key={position.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05 }}
>
<Card className="overflow-hidden">
{/* Position Header */}
<div
onClick={() => setExpandedPosition(isExpanded ? null : position.id)}
className="p-4 cursor-pointer hover:bg-slate-50 transition-colors"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
<Shield className="w-5 h-5 text-blue-600" />
</div>
<div>
<h3 className="text-lg font-semibold text-slate-800">
{position.title}
</h3>
<p className="text-sm text-slate-500">
{pagesAccess.length} עמודים {Object.keys(entityPermissions).length} ישויות
</p>
</div>
</div>
{isExpanded ? (
<ChevronUp className="w-5 h-5 text-slate-400" />
) : (
<ChevronDown className="w-5 h-5 text-slate-400" />
)}
</div>
</div>
{/* Expanded Content */}
{isExpanded && (
<div className="p-6 border-t border-slate-200 bg-slate-50/50">
{/* Main Classroom Management Access Toggle */}
<div className="mb-6 p-4 bg-blue-50 border-2 border-blue-200 rounded-lg">
<div
onClick={() => handleClassroomAccessToggle(position)}
className="flex items-center gap-3 cursor-pointer"
>
<Checkbox checked={permission?.has_classroom_management_access || false} />
<div>
<Label className="cursor-pointer text-base font-semibold text-blue-900">
גישה לניהול כיתות
</Label>
<p className="text-sm text-blue-700 mt-1">
סמן כדי לאפשר גישה לכל מערכת ניהול הכיתות
</p>
</div>
</div>
</div>
{/* Show pages and entities only if classroom access is enabled */}
{permission?.has_classroom_management_access && (
<>
{/* Pages Access */}
<div className="mb-6">
<h4 className="text-md font-semibold text-slate-700 mb-3">
גישה לעמודים - ניהול כיתות
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{classroomPages.map((page) => {
const hasAccess = pagesAccess.includes(page.id);
return (
<div
key={page.id}
onClick={() => handlePageToggle(position, page.id)}
className={`p-3 rounded-lg border-2 cursor-pointer transition-all ${
hasAccess
? 'bg-blue-50 border-blue-300'
: 'bg-white border-slate-200 hover:border-slate-300'
}`}
>
<div className="flex items-center gap-2">
<Checkbox checked={hasAccess} />
<Label className="cursor-pointer text-sm">
{page.name}
</Label>
</div>
</div>
);
})}
</div>
</div>
{/* HackAlon Pages Access */}
<div className="mb-6">
<h4 className="text-md font-semibold text-slate-700 mb-3">
גישה לעמודים - HackAlon
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{hackalonPages.map((page) => {
const hasAccess = pagesAccess.includes(page.id);
return (
<div
key={page.id}
onClick={() => handlePageToggle(position, page.id)}
className={`p-3 rounded-lg border-2 cursor-pointer transition-all ${
hasAccess
? 'bg-purple-50 border-purple-300'
: 'bg-white border-slate-200 hover:border-slate-300'
}`}
>
<div className="flex items-center gap-2">
<Checkbox checked={hasAccess} />
<Label className="cursor-pointer text-sm">
{page.name}
</Label>
</div>
</div>
);
})}
</div>
</div>
{/* Entity Permissions */}
<div>
<h4 className="text-md font-semibold text-slate-700 mb-3">
הרשאות CRUD על ישויות
</h4>
<div className="space-y-3">
{availableEntities.map((entity) => {
const entityCrud = entityPermissions[entity.id] || [];
return (
<Card key={entity.id} className="p-4">
<div className="flex items-center justify-between mb-3">
<Label className="font-medium text-slate-700">
{entity.name}
</Label>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
{crudOptions.map((crud) => {
const hasCrud = entityCrud.includes(crud.id);
return (
<div
key={crud.id}
onClick={() => handleEntityCrudToggle(position, entity.id, crud.id)}
className={`p-2 rounded-lg border-2 cursor-pointer transition-all text-center ${
hasCrud
? 'bg-green-50 border-green-300'
: 'bg-white border-slate-200 hover:border-slate-300'
}`}
>
<div className="flex flex-col items-center gap-1">
<span className="text-lg">{crud.icon}</span>
<span className="text-xs font-medium">
{crud.name}
</span>
</div>
</div>
);
})}
</div>
</Card>
);
})}
</div>
</div>
</>
)}
</div>
)}
</Card>
</motion.div>
);
})}
{positions.length === 0 && (
<Card className="p-12 text-center">
<Shield className="w-12 h-12 text-slate-300 mx-auto mb-4" />
<h3 className="text-lg font-medium text-slate-600 mb-2">
אין תפקידים עדיין
</h3>
<p className="text-slate-400">
צור תפקידים בעמוד ניהול התפקידים כדי להתחיל להגדיר הרשאות
</p>
</Card>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,225 @@
import React, { useState } from 'react';
import { base44 } from '@/api/base44Client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription
} from "@/components/ui/dialog";
import { Briefcase, X, Plus, Shield, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { motion } from 'framer-motion';
export default function ManagePositions() {
const [currentUser, setCurrentUser] = useState(null);
const [showModal, setShowModal] = useState(false);
const [newPositionTitle, setNewPositionTitle] = useState('');
const queryClient = useQueryClient();
React.useEffect(() => {
base44.auth.me().then(setCurrentUser).catch(() => {});
}, []);
const isAdmin = currentUser?.role === 'admin';
const { data: positions = [], isLoading } = useQuery({
queryKey: ['positions'],
queryFn: () => base44.entities.Position.list('order'),
enabled: isAdmin
});
const { data: users = [] } = useQuery({
queryKey: ['users'],
queryFn: () => base44.entities.User.list(),
enabled: isAdmin
});
const createPositionMutation = useMutation({
mutationFn: (data) => base44.entities.Position.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['positions'] });
setShowModal(false);
setNewPositionTitle('');
toast.success('תפקיד נוסף בהצלחה');
}
});
const deletePositionMutation = useMutation({
mutationFn: (id) => base44.entities.Position.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['positions'] });
toast.success('תפקיד נמחק');
}
});
const handleAddPosition = () => {
if (newPositionTitle && newPositionTitle.trim()) {
createPositionMutation.mutate({
title: newPositionTitle.trim(),
order: positions.length
});
}
};
// Count users per position
const getUserCountForPosition = (positionTitle) => {
return users.filter(user =>
user.positions && user.positions.includes(positionTitle)
).length;
};
if (!isAdmin) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-100 flex items-center justify-center">
<Card className="p-8 text-center">
<Shield className="w-12 h-12 text-slate-300 mx-auto mb-4" />
<h2 className="text-xl font-semibold text-slate-700 mb-2">גישה מוגבלת</h2>
<p className="text-slate-500">רק מנהלים יכולים לגשת לדף זה</p>
</Card>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-100" dir="rtl">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="mb-8"
>
<h1 className="text-3xl font-bold text-slate-800 mb-2">
ניהול תפקידים 💼
</h1>
<p className="text-slate-500">הוסף, ערוך, ומחק תפקידים במערכת</p>
</motion.div>
{/* Stats */}
<div className="mb-6">
<Card className="p-4 border-slate-200">
<p className="text-sm text-slate-500">סה״כ תפקידים</p>
<p className="text-2xl font-bold text-slate-800">{positions.length}</p>
</Card>
</div>
{/* Add Button */}
<div className="mb-6">
<Button
onClick={() => setShowModal(true)}
className="bg-indigo-600 hover:bg-indigo-700"
>
<Plus className="w-4 h-4 ml-2" />
הוסף תפקיד חדש
</Button>
</div>
{/* Positions List */}
<Card className="p-6 border-slate-200">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-slate-400" />
</div>
) : positions.length === 0 ? (
<div className="text-center py-8">
<Briefcase className="w-12 h-12 text-slate-300 mx-auto mb-4" />
<p className="text-slate-400">אין תפקידים. הוסף את התפקיד הראשון</p>
</div>
) : (
<div className="space-y-3">
{positions.map((position) => {
const userCount = getUserCountForPosition(position.title);
return (
<div
key={position.id}
className="flex items-center justify-between p-4 bg-white rounded-lg border-2 border-slate-200 hover:border-slate-300 transition-all"
>
<div className="flex items-center gap-3">
<Briefcase className="w-5 h-5 text-indigo-600" />
<span className="text-lg font-medium text-slate-800">
{position.title}
</span>
<span className="text-sm text-slate-500 bg-slate-100 px-3 py-1 rounded-full">
{userCount} {userCount === 1 ? 'משתמש' : 'משתמשים'}
</span>
</div>
<button
onClick={() => {
if (confirm(`למחוק את התפקיד "${position.title}"?`)) {
deletePositionMutation.mutate(position.id);
}
}}
className="hover:bg-red-50 rounded-full p-2 transition-colors"
>
<X className="w-4 h-4 text-red-500" />
</button>
</div>
);
})}
</div>
)}
</Card>
</div>
{/* Add Position Modal */}
<Dialog open={showModal} onOpenChange={setShowModal}>
<DialogContent className="sm:max-w-md" dir="rtl">
<DialogHeader className="text-right">
<DialogTitle className="flex flex-row-reverse items-center gap-2 justify-end">
הוסף תפקיד חדש
<div className="p-2 bg-indigo-100 rounded-lg">
<Briefcase className="w-5 h-5 text-indigo-600" />
</div>
</DialogTitle>
<DialogDescription className="text-right">
הזן את שם התפקיד החדש
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label className="text-right block">שם התפקיד *</Label>
<Input
value={newPositionTitle}
onChange={(e) => setNewPositionTitle(e.target.value)}
placeholder="לדוגמה: קה״ד צוותי"
className="text-right"
onKeyDown={(e) => {
if (e.key === 'Enter' && newPositionTitle.trim()) {
handleAddPosition();
}
}}
autoFocus
/>
</div>
</div>
<div className="flex flex-row-reverse gap-3">
<Button
variant="outline"
onClick={() => {
setShowModal(false);
setNewPositionTitle('');
}}
className="flex-1"
>
ביטול
</Button>
<Button
onClick={handleAddPosition}
disabled={!newPositionTitle.trim()}
className="flex-1 bg-indigo-600 hover:bg-indigo-700"
>
הוסף תפקיד
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -0,0 +1,359 @@
import React, { useState } from 'react';
import { base44 } from '@/api/base44Client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Card } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription
} from "@/components/ui/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from "@/components/ui/table";
import { Plus, Users, Trash2, Edit2, Phone, GripVertical } from 'lucide-react';
import { toast } from 'sonner';
import { motion } from 'framer-motion';
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
export default function ManageSquads() {
const [showModal, setShowModal] = useState(false);
const [editingSquad, setEditingSquad] = useState(null);
const [user, setUser] = useState(null);
const [formData, setFormData] = useState({ squad_number: '', platoon_name: '', contact: '', notes: '' });
const queryClient = useQueryClient();
React.useEffect(() => {
base44.auth.me().then(setUser).catch(() => {});
}, []);
const isAdmin = user?.role === 'admin';
const { data: squads = [], isLoading } = useQuery({
queryKey: ['squads'],
queryFn: async () => {
const data = await base44.entities.Squad.list();
return data.sort((a, b) => (a.order || 0) - (b.order || 0));
}
});
const createMutation = useMutation({
mutationFn: (data) => base44.entities.Squad.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['squads'] });
setShowModal(false);
setFormData({ squad_number: '', platoon_name: '', contact: '', notes: '' });
toast.success('צוות נוסף בהצלחה');
}
});
const updateMutation = useMutation({
mutationFn: ({ id, data }) => base44.entities.Squad.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['squads'] });
setShowModal(false);
setEditingSquad(null);
setFormData({ squad_number: '', platoon_name: '', contact: '', notes: '' });
toast.success('צוות עודכן בהצלחה');
}
});
const deleteMutation = useMutation({
mutationFn: (id) => base44.entities.Squad.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['squads'] });
toast.success('צוות נמחק בהצלחה');
}
});
const handleSubmit = () => {
if (editingSquad) {
updateMutation.mutate({ id: editingSquad.id, data: formData });
} else {
createMutation.mutate(formData);
}
};
const handleEdit = (squad) => {
setEditingSquad(squad);
setFormData({
squad_number: squad.squad_number,
platoon_name: squad.platoon_name,
contact: squad.contact || '',
notes: squad.notes || ''
});
setShowModal(true);
};
const handleClose = () => {
setShowModal(false);
setEditingSquad(null);
setFormData({ squad_number: '', platoon_name: '', contact: '', notes: '' });
};
const handleDragEnd = async (result) => {
if (!result.destination || !isAdmin) return;
const items = Array.from(squads);
const [reorderedItem] = items.splice(result.source.index, 1);
items.splice(result.destination.index, 0, reorderedItem);
// Update order for all items
const updates = items.map((item, index) =>
base44.entities.Squad.update(item.id, { order: index })
);
await Promise.all(updates);
queryClient.invalidateQueries({ queryKey: ['squads'] });
toast.success('הסדר עודכן');
};
// Get unique platoon names and assign colors
const getPlatoonColor = (platoonName) => {
const colors = [
{ bg: 'bg-blue-100', text: 'text-blue-700', icon: 'bg-blue-200', iconText: 'text-blue-700' },
{ bg: 'bg-purple-100', text: 'text-purple-700', icon: 'bg-purple-200', iconText: 'text-purple-700' },
{ bg: 'bg-green-100', text: 'text-green-700', icon: 'bg-green-200', iconText: 'text-green-700' },
{ bg: 'bg-orange-100', text: 'text-orange-700', icon: 'bg-orange-200', iconText: 'text-orange-700' },
{ bg: 'bg-pink-100', text: 'text-pink-700', icon: 'bg-pink-200', iconText: 'text-pink-700' },
];
const uniquePlatoons = [...new Set(squads.map(s => s.platoon_name))].sort();
const index = uniquePlatoons.indexOf(platoonName);
return colors[index % colors.length];
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-100">
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="mb-8"
>
<h1 className="text-3xl font-bold text-slate-800 mb-2">
ניהול צוותים 👥
</h1>
</motion.div>
{/* Stats */}
<div className="mb-8 flex flex-wrap gap-4">
<Card className="p-4 border-slate-200">
<p className="text-sm text-slate-500">סה״כ צוותים</p>
<p className="text-2xl font-bold text-slate-800">{squads.length}</p>
</Card>
{[...new Set(squads.map(s => s.platoon_name))].sort().map(platoon => {
const count = squads.filter(s => s.platoon_name === platoon).length;
const colors = getPlatoonColor(platoon);
return (
<Card key={platoon} className={`p-4 border-2 ${colors.bg}`}>
<p className={`text-sm font-medium ${colors.text}`}>{platoon}</p>
<p className={`text-2xl font-bold ${colors.text}`}>{count}</p>
</Card>
);
})}
</div>
{/* Add Button */}
{isAdmin && (
<div className="flex justify-end mb-6">
<Button onClick={() => setShowModal(true)} className="bg-teal-600 hover:bg-teal-700">
<Plus className="w-4 h-4 ml-2" />
הוסף צוות חדש
</Button>
</div>
)}
{/* Squads Table */}
<Card className="overflow-hidden border-slate-200">
<Table>
<TableHeader>
<TableRow className="bg-slate-50">
{isAdmin && <TableHead className="w-12"></TableHead>}
<TableHead className="text-center">מספר צוות</TableHead>
<TableHead className="text-center">פלוגה</TableHead>
<TableHead className="text-center">איש קשר</TableHead>
<TableHead className="text-center">הערות</TableHead>
{isAdmin && <TableHead className="text-center">פעולות</TableHead>}
</TableRow>
</TableHeader>
<DragDropContext onDragEnd={handleDragEnd}>
<Droppable droppableId="squads">
{(provided) => (
<TableBody {...provided.droppableProps} ref={provided.innerRef}>
{isLoading ? (
<TableRow>
<TableCell colSpan={isAdmin ? 6 : 4} className="text-center py-8 text-slate-400">
טוען...
</TableCell>
</TableRow>
) : squads.length === 0 ? (
<TableRow>
<TableCell colSpan={isAdmin ? 6 : 4} className="text-center py-8 text-slate-400">
עדיין לא נוספו צוותים
</TableCell>
</TableRow>
) : (
squads.map((squad, index) => (
<Draggable key={squad.id} draggableId={squad.id} index={index} isDragDisabled={!isAdmin}>
{(provided, snapshot) => (
<TableRow
ref={provided.innerRef}
{...provided.draggableProps}
className={`hover:bg-slate-50/50 [&_td]:text-center ${snapshot.isDragging ? 'bg-slate-100' : ''}`}
>
{isAdmin && (
<TableCell {...provided.dragHandleProps} className="cursor-grab active:cursor-grabbing">
<GripVertical className="w-4 h-4 text-slate-400 mx-auto" />
</TableCell>
)}
<TableCell className="font-medium text-center">
<div className="flex flex-row-reverse items-center justify-between gap-2">
<span className="flex-1 text-center">{squad.squad_number}</span>
<div className={`w-8 h-8 rounded-full ${getPlatoonColor(squad.platoon_name).icon} flex items-center justify-center flex-shrink-0`}>
<Users className={`w-4 h-4 ${getPlatoonColor(squad.platoon_name).iconText}`} />
</div>
</div>
</TableCell>
<TableCell className="text-center">
<span className={`inline-block px-3 py-1 rounded-full font-medium ${getPlatoonColor(squad.platoon_name).bg} ${getPlatoonColor(squad.platoon_name).text}`}>
{squad.platoon_name}
</span>
</TableCell>
<TableCell className="text-center">
{squad.contact ? (
<a
href={`tel:${squad.contact}`}
className="flex flex-row-reverse items-center justify-center gap-2 text-slate-600 hover:text-blue-600 transition-colors cursor-pointer"
>
<Phone className="w-4 h-4" />
<span dir="ltr">{squad.contact}</span>
</a>
) : (
<span className="text-slate-400"></span>
)}
</TableCell>
<TableCell className="text-slate-500 max-w-xs truncate">
{squad.notes || '—'}
</TableCell>
{isAdmin && (
<TableCell className="text-center">
<div className="flex justify-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => handleEdit(squad)}
className="text-slate-400 hover:text-slate-600"
>
<Edit2 className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => deleteMutation.mutate(squad.id)}
className="text-red-400 hover:text-red-600 hover:bg-red-50"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</TableCell>
)}
</TableRow>
)}
</Draggable>
))
)}
{provided.placeholder}
</TableBody>
)}
</Droppable>
</DragDropContext>
</Table>
</Card>
</div>
{/* Add/Edit Modal */}
<Dialog open={showModal} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-md" dir="rtl">
<DialogHeader className="text-right">
<DialogTitle className="flex flex-row-reverse items-center gap-2 justify-end">
{editingSquad ? 'ערוך צוות' : 'הוסף צוות חדש'}
<div className="p-2 bg-teal-100 rounded-lg">
<Users className="w-5 h-5 text-teal-600" />
</div>
</DialogTitle>
<DialogDescription className="text-right">
{editingSquad ? 'עדכן את פרטי הצוות' : 'הוסף צוות חדש למעקב'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label className="text-right block">מספר צוות</Label>
<Input
placeholder="למשל, צוות 5..."
value={formData.squad_number}
onChange={(e) => setFormData({ ...formData, squad_number: e.target.value })}
className="text-right"
/>
</div>
<div className="space-y-2">
<Label className="text-right block">פלוגה</Label>
<Input
placeholder="למשל, פלוגת יפתח..."
value={formData.platoon_name}
onChange={(e) => setFormData({ ...formData, platoon_name: e.target.value })}
className="text-right"
/>
</div>
<div className="space-y-2">
<Label className="text-right block">טלפון איש קשר (אופציונלי)</Label>
<Input
type="tel"
placeholder="+972.."
value={formData.contact}
onChange={(e) => setFormData({ ...formData, contact: e.target.value })}
className="text-right"
/>
</div>
<div className="space-y-2">
<Label className="text-right block">הערות (אופציונלי)</Label>
<Textarea
placeholder="שם קה״ד צוותי"
value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
className="h-20 text-right"
/>
</div>
</div>
<div className="flex flex-row-reverse gap-3">
<Button variant="outline" onClick={handleClose} className="flex-1">
ביטול
</Button>
<Button
onClick={handleSubmit}
disabled={!formData.squad_number || !formData.platoon_name}
className="flex-1 bg-teal-600 hover:bg-teal-700"
>
{editingSquad ? 'עדכן צוות' : 'הוסף צוות'}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -0,0 +1,598 @@
import React, { useState } from 'react';
import { base44 } from '@/api/base44Client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription
} from "@/components/ui/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from "@/components/ui/table";
import { Users, Edit2, Mail, Shield, User, Briefcase, Trash2, X, Plus, Filter } from 'lucide-react';
import { toast } from 'sonner';
import { motion } from 'framer-motion';
export default function ManageUsers() {
const [showModal, setShowModal] = useState(false);
const [editingUser, setEditingUser] = useState(null);
const [currentUser, setCurrentUser] = useState(null);
const [formData, setFormData] = useState({ squad_name: '', platoon_name: '', positions: [], role: 'user', phone_number: '', full_name: '', onboarding_full_name: '' });
const [newPosition, setNewPosition] = useState('');
const [filters, setFilters] = useState({
name: '',
email: '',
role: '',
squad: '',
platoon: '',
position: '',
noPermissions: false
});
const queryClient = useQueryClient();
React.useEffect(() => {
base44.auth.me().then(setCurrentUser).catch(() => {});
}, []);
const isAdmin = currentUser?.role === 'admin';
const { data: users = [], isLoading } = useQuery({
queryKey: ['users'],
queryFn: async () => {
const allUsers = await base44.entities.User.list();
// Auto-assign 'צוער' to users without positions
const usersWithoutPositions = allUsers.filter(u => !u.positions || u.positions.length === 0);
for (const user of usersWithoutPositions) {
await base44.entities.User.update(user.id, { positions: ['צוער'] });
}
// Fetch updated list if we made changes
if (usersWithoutPositions.length > 0) {
return await base44.entities.User.list();
}
return allUsers;
},
enabled: isAdmin
});
const { data: positions = [] } = useQuery({
queryKey: ['positions'],
queryFn: () => base44.entities.Position.list('order'),
enabled: isAdmin
});
const { data: squads = [] } = useQuery({
queryKey: ['squads'],
queryFn: () => base44.entities.Squad.list('order'),
enabled: isAdmin
});
const updateUserMutation = useMutation({
mutationFn: ({ id, data }) => base44.entities.User.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
setShowModal(false);
setEditingUser(null);
setFormData({ squad_name: '', platoon_name: '', positions: [], role: 'user', phone_number: '', full_name: '', onboarding_full_name: '' });
setNewPosition('');
toast.success('פרטי משתמש עודכנו בהצלחה');
}
});
const deleteUserMutation = useMutation({
mutationFn: (id) => base44.entities.User.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
toast.success('משתמש נמחק בהצלחה');
}
});
const handleSubmit = () => {
if (editingUser) {
updateUserMutation.mutate({ id: editingUser.id, data: formData });
}
};
const handleEdit = (user) => {
setEditingUser(user);
const userPositions = user.positions || (user.position ? [user.position] : []);
// Add 'צוער' if user has no positions
if (userPositions.length === 0) {
userPositions.push('צוער');
}
setFormData({
squad_name: user.squad_name || '',
platoon_name: user.platoon_name || '',
positions: userPositions,
role: user.role || 'user',
phone_number: user.phone_number || '',
full_name: user.full_name || '',
onboarding_full_name: user.onboarding_full_name || user.full_name || ''
});
setNewPosition('');
setShowModal(true);
};
const handleClose = () => {
setShowModal(false);
setEditingUser(null);
setFormData({ squad_name: '', platoon_name: '', positions: [], role: 'user', phone_number: '' });
setNewPosition('');
};
const handleAddPosition = () => {
if (newPosition && !formData.positions.includes(newPosition)) {
setFormData({ ...formData, positions: [...formData.positions, newPosition] });
setNewPosition('');
}
};
const handleRemovePosition = (positionToRemove) => {
setFormData({
...formData,
positions: formData.positions.filter(p => p !== positionToRemove)
});
};
// Predefined platoon names
const platoonNames = [
'פלוגה א - סהר',
'פלוגה ב - יפתח',
'פלוגה ג - אייל',
'פלוגה ד - אסף',
'פלוגה ה - איתן'
];
const positionTitles = positions.map(p => p.title);
const normalize = (str = '') =>
str
.toString()
.trim()
.normalize('NFKC');
// Filter users
const filteredUsers = users.filter(user => {
const nameValue = user.onboarding_full_name || user.full_name || '';
const matchName =
!filters.name ||
normalize(nameValue).includes(normalize(filters.name));
const matchEmail = !filters.email ||
(user.email && user.email.toLowerCase().includes(filters.email.toLowerCase()));
const matchRole = !filters.role || user.role === filters.role;
const matchSquad = !filters.squad || user.squad_name === filters.squad;
const matchPlatoon = !filters.platoon || user.platoon_name === filters.platoon;
const matchPosition = !filters.position ||
(user.positions && user.positions.includes(filters.position));
const matchNoPermissions = !filters.noPermissions ||
(!user.positions || user.positions.length === 0);
return matchName && matchEmail && matchRole && matchSquad && matchPlatoon && matchPosition && matchNoPermissions;
});
// Group users by platoon
const usersByPlatoon = users.reduce((acc, user) => {
const platoon = user.platoon_name || 'ללא פלוגה';
if (!acc[platoon]) acc[platoon] = [];
acc[platoon].push(user);
return acc;
}, {});
if (!isAdmin) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-100 flex items-center justify-center">
<Card className="p-8 text-center">
<Shield className="w-12 h-12 text-slate-300 mx-auto mb-4" />
<h2 className="text-xl font-semibold text-slate-700 mb-2">גישה מוגבלת</h2>
<p className="text-slate-500">רק מנהלים יכולים לגשת לדף זה</p>
</Card>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-100">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="mb-8"
>
<h1 className="text-3xl font-bold text-slate-800 mb-2">
ניהול משתמשים 👤
</h1>
<p className="text-slate-500">עדכן פרטים של משתמשים - פלוגה ותפקיד</p>
</motion.div>
{/* Stats */}
<div className="mb-8 flex flex-wrap gap-4">
<Card
className={`p-4 border-2 cursor-pointer transition-all ${!filters.platoon && !filters.noPermissions ? 'border-blue-400 bg-blue-50' : 'border-slate-200 hover:border-blue-300'}`}
onClick={() => setFilters({ name: '', email: '', role: '', squad: '', platoon: '', position: '', noPermissions: false })}
>
<p className="text-sm text-slate-500">סה״כ משתמשים</p>
<p className="text-2xl font-bold text-slate-800">{users.length}</p>
</Card>
<Card
className={`p-4 border-2 cursor-pointer transition-all ${filters.noPermissions ? 'border-amber-400 bg-amber-50' : 'border-slate-200 hover:border-amber-300'}`}
onClick={() => setFilters({ ...filters, noPermissions: !filters.noPermissions, platoon: '' })}
>
<p className="text-sm text-slate-500">ללא הרשאות</p>
<p className="text-2xl font-bold text-slate-800">
{users.filter(u => !u.positions || u.positions.length === 0).length}
</p>
</Card>
{Object.entries(usersByPlatoon).map(([platoon, platoonUsers]) => (
<Card
key={platoon}
className={`p-4 border-2 cursor-pointer transition-all ${filters.platoon === platoon ? 'border-green-400 bg-green-50' : 'border-slate-200 hover:border-green-300'}`}
onClick={() => setFilters({ ...filters, platoon: filters.platoon === platoon ? '' : platoon, noPermissions: false })}
>
<p className="text-sm text-slate-500">{platoon}</p>
<p className="text-2xl font-bold text-slate-800">{platoonUsers.length}</p>
</Card>
))}
</div>
{/* Users Table */}
<Card className="overflow-hidden border-slate-200">
<Table>
<TableHeader>
<TableRow className="bg-slate-50">
<TableHead className="text-center">
<div className="flex flex-col gap-2">
<span>שם מלא</span>
<Input
placeholder="סנן..."
value={filters.name}
onChange={(e) => setFilters({ ...filters, name: e.target.value })}
className="h-8 text-sm"
/>
</div>
</TableHead>
<TableHead className="text-center">
<div className="flex flex-col gap-2">
<span>אימייל</span>
<Input
placeholder="סנן..."
value={filters.email}
onChange={(e) => setFilters({ ...filters, email: e.target.value })}
className="h-8 text-sm"
/>
</div>
</TableHead>
<TableHead className="text-center">
<span>טלפון</span>
</TableHead>
<TableHead className="text-center">
<div className="flex flex-col gap-2">
<span>תפקיד במערכת</span>
<select
value={filters.role}
onChange={(e) => setFilters({ ...filters, role: e.target.value })}
className="h-8 text-sm px-2 border border-slate-300 rounded-md text-right"
>
<option value="">הכל</option>
<option value="admin">מנהל</option>
<option value="user">משתמש</option>
</select>
</div>
</TableHead>
<TableHead className="text-center">
<div className="flex flex-col gap-2">
<span>צוות</span>
<select
value={filters.squad || ''}
onChange={(e) => setFilters({ ...filters, squad: e.target.value })}
className="h-8 text-sm px-2 border border-slate-300 rounded-md text-right"
>
<option value="">הכל</option>
{squads.map((squad) => (
<option key={squad.id} value={squad.squad_number}>{squad.squad_number}</option>
))}
</select>
</div>
</TableHead>
<TableHead className="text-center">
<div className="flex flex-col gap-2">
<span>פלוגה</span>
<select
value={filters.platoon}
onChange={(e) => setFilters({ ...filters, platoon: e.target.value })}
className="h-8 text-sm px-2 border border-slate-300 rounded-md text-right"
>
<option value="">הכל</option>
{platoonNames.map((name) => (
<option key={name} value={name}>{name}</option>
))}
</select>
</div>
</TableHead>
<TableHead className="text-center">
<div className="flex flex-col gap-2">
<span>תפקיד</span>
<select
value={filters.position}
onChange={(e) => setFilters({ ...filters, position: e.target.value })}
className="h-8 text-sm px-2 border border-slate-300 rounded-md text-right"
>
<option value="">הכל</option>
{positionTitles.map((pos) => (
<option key={pos} value={pos}>{pos}</option>
))}
</select>
</div>
</TableHead>
<TableHead className="text-center">פעולות</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-8 text-slate-400">
טוען...
</TableCell>
</TableRow>
) : filteredUsers.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-8 text-slate-400">
אין משתמשים מתאימים לסינון
</TableCell>
</TableRow>
) : (
filteredUsers.map((user) => (
<TableRow key={user.id} className="hover:bg-slate-50/50 [&_td]:text-center">
<TableCell className="font-medium text-center">
<div className="flex flex-col items-center justify-center gap-1">
<span className="font-semibold">{user.onboarding_full_name || user.full_name || 'ללא שם'}</span>
{user.onboarding_completed && (
<span className="text-xs text-green-600"> הושלם</span>
)}
</div>
</TableCell>
<TableCell className="text-center">
<div className="flex items-center justify-center gap-2 text-slate-600">
<Mail className="w-4 h-4" />
<span className="text-sm">{user.email}</span>
</div>
</TableCell>
<TableCell className="text-center text-slate-600">
{user.phone_number || <span className="text-slate-400"></span>}
</TableCell>
<TableCell className="text-center">
{user.role === 'admin' ? (
<span className="inline-flex items-center gap-1 px-3 py-1 rounded-full text-xs font-medium bg-purple-100 text-purple-700">
<Shield className="w-3 h-3" />
מנהל
</span>
) : (
<span className="inline-flex items-center gap-1 px-3 py-1 rounded-full text-xs font-medium bg-slate-100 text-slate-700">
משתמש
</span>
)}
</TableCell>
<TableCell className="text-center text-slate-600">
{user.squad_name ? (
<span className="font-medium">{user.squad_name}</span>
) : (
<span className="text-slate-400"></span>
)}
</TableCell>
<TableCell className="text-center text-slate-600">
{user.platoon_name ? (
<span className="font-medium">{user.platoon_name}</span>
) : (
<span className="text-slate-400"></span>
)}
</TableCell>
<TableCell className="text-center text-slate-600">
{(user.positions && user.positions.length > 0) ? (
<div className="flex flex-wrap items-center justify-center gap-1">
{user.positions.map((pos, idx) => (
<span key={idx} className="inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium bg-blue-100 text-blue-700">
<Briefcase className="w-3 h-3" />
{pos}
</span>
))}
</div>
) : user.position ? (
<div className="flex items-center justify-center gap-2">
<Briefcase className="w-4 h-4 text-slate-400" />
<span>{user.position}</span>
</div>
) : (
<span className="text-slate-400"></span>
)}
</TableCell>
<TableCell className="text-center">
<div className="flex justify-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => handleEdit(user)}
className="text-slate-400 hover:text-slate-600"
>
<Edit2 className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => deleteUserMutation.mutate(user.id)}
className="text-red-400 hover:text-red-600 hover:bg-red-50"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</Card>
</div>
{/* Edit Modal */}
<Dialog open={showModal} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-md" dir="rtl">
<DialogHeader className="text-right">
<DialogTitle className="flex flex-row-reverse items-center gap-2 justify-end">
עדכן פרטי משתמש
<div className="p-2 bg-indigo-100 rounded-lg">
<Users className="w-5 h-5 text-indigo-600" />
</div>
</DialogTitle>
<DialogDescription className="text-right">
{editingUser?.full_name} - {editingUser?.email}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label className="text-right block">שם מלא</Label>
<Input
value={formData.onboarding_full_name}
onChange={(e) => setFormData({ ...formData, onboarding_full_name: e.target.value, full_name: e.target.value })}
className="text-right"
placeholder="הזן שם מלא"
/>
</div>
<div className="space-y-2">
<Label className="text-right block">תפקיד במערכת</Label>
<select
value={formData.role}
onChange={(e) => setFormData({ ...formData, role: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-md text-right"
>
<option value="user">משתמש</option>
<option value="admin">מנהל</option>
</select>
</div>
<div className="space-y-2">
<Label className="text-right block">צוות</Label>
<select
value={formData.squad_name}
onChange={(e) => {
const selectedSquad = squads.find(s => s.squad_number === e.target.value);
setFormData({
...formData,
squad_name: e.target.value,
platoon_name: selectedSquad?.platoon_name || ''
});
}}
className="w-full px-3 py-2 border border-slate-300 rounded-md text-right"
>
<option value="">בחר צוות...</option>
{squads.map((squad) => (
<option key={squad.id} value={squad.squad_number}>
{squad.squad_number} {squad.platoon_name ? `(${squad.platoon_name})` : ''}
</option>
))}
</select>
</div>
<div className="space-y-2">
<Label className="text-right block">מספר טלפון</Label>
<Input
type="tel"
placeholder="05X-XXXXXXX"
value={formData.phone_number}
onChange={(e) => setFormData({ ...formData, phone_number: e.target.value })}
className="text-right"
/>
</div>
<div className="space-y-2">
<Label className="text-right block text-base font-semibold">ניהול תפקידים</Label>
{/* Display current positions */}
<div className="p-3 border-2 border-slate-200 rounded-lg bg-white min-h-[60px]">
{formData.positions.length > 0 ? (
<div className="flex flex-wrap gap-2">
{formData.positions.map((pos, idx) => (
<div key={idx} className="flex items-center gap-2 px-3 py-2 bg-blue-500 text-white rounded-lg text-sm font-medium shadow-sm hover:bg-blue-600 transition-colors">
<Briefcase className="w-3.5 h-3.5" />
<span>{pos}</span>
<button
type="button"
onClick={() => handleRemovePosition(pos)}
className="hover:bg-blue-700 rounded-full p-1 transition-colors"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
) : (
<div className="flex items-center justify-center h-full text-slate-400 text-sm">
אין תפקידים מוקצים
</div>
)}
</div>
{/* Add new position */}
<div className="flex gap-2 pt-2">
<select
value={newPosition}
onChange={(e) => setNewPosition(e.target.value)}
className="flex-1 px-3 py-2.5 border-2 border-slate-300 rounded-lg text-right text-sm focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200 transition-all"
>
<option value="">בחר תפקיד להוספה...</option>
{positionTitles
.filter(pos => !formData.positions.includes(pos))
.map((pos) => (
<option key={pos} value={pos}>{pos}</option>
))}
</select>
<Button
type="button"
onClick={handleAddPosition}
disabled={!newPosition}
className="px-4 bg-green-600 hover:bg-green-700 disabled:bg-slate-300"
size="default"
>
<Plus className="w-4 h-4 ml-1" />
הוסף
</Button>
</div>
</div>
</div>
<div className="flex flex-row-reverse gap-3">
<Button variant="outline" onClick={handleClose} className="flex-1">
ביטול
</Button>
<Button
onClick={handleSubmit}
className="flex-1 bg-indigo-600 hover:bg-indigo-700"
>
שמור שינויים
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -0,0 +1,278 @@
import React, { useState } from 'react';
import { base44 } from '@/api/base44Client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { MapPin, Plus, Edit2, Trash2, GripVertical, Shield } from 'lucide-react';
import { toast } from 'sonner';
import { motion } from 'framer-motion';
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
export default function ManageZones() {
const [showModal, setShowModal] = useState(false);
const [editingZone, setEditingZone] = useState(null);
const [formData, setFormData] = useState({ name: '' });
const [currentUser, setCurrentUser] = useState(null);
const queryClient = useQueryClient();
React.useEffect(() => {
base44.auth.me().then(setCurrentUser).catch(() => {});
}, []);
const isAdmin = currentUser?.role === 'admin';
const { data: zones = [], isLoading } = useQuery({
queryKey: ['zones'],
queryFn: () => base44.entities.Zone.list('order'),
enabled: isAdmin
});
const createMutation = useMutation({
mutationFn: (data) => base44.entities.Zone.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['zones'] });
setShowModal(false);
setFormData({ name: '' });
toast.success('אזור נוסף בהצלחה');
}
});
const updateMutation = useMutation({
mutationFn: ({ id, data }) => base44.entities.Zone.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['zones'] });
setShowModal(false);
setEditingZone(null);
setFormData({ name: '' });
toast.success('אזור עודכן בהצלחה');
}
});
const deleteMutation = useMutation({
mutationFn: (id) => base44.entities.Zone.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['zones'] });
toast.success('אזור נמחק בהצלחה');
}
});
const handleSubmit = () => {
if (!formData.name.trim()) {
toast.error('אנא הזן שם אזור');
return;
}
if (editingZone) {
updateMutation.mutate({ id: editingZone.id, data: formData });
} else {
createMutation.mutate({ ...formData, order: zones.length });
}
};
const handleEdit = (zone) => {
setEditingZone(zone);
setFormData({ name: zone.name });
setShowModal(true);
};
const handleDragEnd = async (result) => {
if (!result.destination) return;
const items = Array.from(zones);
const [reorderedItem] = items.splice(result.source.index, 1);
items.splice(result.destination.index, 0, reorderedItem);
// Update order for all items
for (let i = 0; i < items.length; i++) {
await base44.entities.Zone.update(items[i].id, { order: i });
}
queryClient.invalidateQueries({ queryKey: ['zones'] });
};
if (!isAdmin) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-100 flex items-center justify-center">
<Card className="p-8 text-center">
<Shield className="w-12 h-12 text-slate-300 mx-auto mb-4" />
<h2 className="text-xl font-semibold text-slate-700 mb-2">גישה מוגבלת</h2>
<p className="text-slate-500">רק מנהלים יכולים לגשת לדף זה</p>
</Card>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-100" dir="rtl">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="mb-8"
>
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-slate-800 mb-2">
ניהול אזורים 📍
</h1>
<p className="text-slate-500">הגדר אזורים פיזיים למפתחות</p>
</div>
<Button onClick={() => setShowModal(true)} className="bg-indigo-600 hover:bg-indigo-700">
<Plus className="w-4 h-4 ml-2" />
הוסף אזור
</Button>
</div>
</motion.div>
{/* Stats */}
<Card className="p-4 mb-6">
<p className="text-sm text-slate-500">סה״כ אזורים</p>
<p className="text-2xl font-bold text-slate-800">{zones.length}</p>
</Card>
{/* Zones Table */}
<Card className="overflow-hidden border-slate-200">
<DragDropContext onDragEnd={handleDragEnd}>
<Droppable droppableId="zones">
{(provided) => (
<Table>
<TableHeader>
<TableRow className="bg-slate-50">
<TableHead className="text-center w-12"></TableHead>
<TableHead className="text-center">שם האזור</TableHead>
<TableHead className="text-center">פעולות</TableHead>
</TableRow>
</TableHeader>
<TableBody {...provided.droppableProps} ref={provided.innerRef}>
{isLoading ? (
<TableRow>
<TableCell colSpan={3} className="text-center py-8 text-slate-400">
טוען...
</TableCell>
</TableRow>
) : zones.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="text-center py-8 text-slate-400">
אין אזורים עדיין
</TableCell>
</TableRow>
) : (
zones.map((zone, index) => (
<Draggable key={zone.id} draggableId={zone.id} index={index}>
{(provided) => (
<TableRow
ref={provided.innerRef}
{...provided.draggableProps}
className="hover:bg-slate-50/50"
>
<TableCell className="text-center">
<div {...provided.dragHandleProps} className="cursor-move">
<GripVertical className="w-4 h-4 text-slate-400" />
</div>
</TableCell>
<TableCell className="text-center font-medium">
<div className="flex items-center justify-center gap-2">
<MapPin className="w-4 h-4 text-slate-400" />
{zone.name}
</div>
</TableCell>
<TableCell className="text-center">
<div className="flex justify-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => handleEdit(zone)}
className="text-slate-400 hover:text-slate-600"
>
<Edit2 className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => deleteMutation.mutate(zone.id)}
className="text-red-400 hover:text-red-600 hover:bg-red-50"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</TableCell>
</TableRow>
)}
</Draggable>
))
)}
{provided.placeholder}
</TableBody>
</Table>
)}
</Droppable>
</DragDropContext>
</Card>
</div>
{/* Add/Edit Modal */}
<Dialog open={showModal} onOpenChange={(open) => {
setShowModal(open);
if (!open) {
setEditingZone(null);
setFormData({ name: '' });
}
}}>
<DialogContent className="sm:max-w-md" dir="rtl">
<DialogHeader className="text-right">
<DialogTitle className="flex flex-row-reverse items-center gap-2 justify-end">
{editingZone ? 'ערוך אזור' : 'הוסף אזור'}
<div className="p-2 bg-indigo-100 rounded-lg">
<MapPin className="w-5 h-5 text-indigo-600" />
</div>
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label className="text-right block">שם האזור *</Label>
<Input
placeholder="לדוגמה: קומה 1, בניין A..."
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="text-right"
/>
</div>
</div>
<div className="flex flex-row-reverse gap-3">
<Button
onClick={handleSubmit}
disabled={!formData.name.trim()}
className="flex-1 bg-indigo-600 hover:bg-indigo-700"
>
{editingZone ? 'עדכן' : 'הוסף'}
</Button>
<Button
variant="outline"
onClick={() => setShowModal(false)}
className="flex-1"
>
ביטול
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More