first commit
This commit is contained in:
0
src/API_documentation.md
Normal file
0
src/API_documentation.md
Normal file
0
src/App.css
Normal file
0
src/App.css
Normal file
142
src/App.jsx
Normal file
142
src/App.jsx
Normal file
@@ -0,0 +1,142 @@
|
||||
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, useLocation } from 'react-router-dom';
|
||||
import { setupIframeMessaging } from './lib/iframe-messaging';
|
||||
import PageNotFound from './lib/PageNotFound';
|
||||
import { AuthProvider, useAuth } from '@/lib/AuthContext';
|
||||
import UserNotRegisteredError from '@/components/UserNotRegisteredError';
|
||||
import FeatureReport from './pages/FeatureReport';
|
||||
import LandingPage from './pages/LandingPage';
|
||||
import KrowX from './pages/KrowX';
|
||||
import AboutUs from './pages/AboutUs';
|
||||
import ThePlatform from './pages/ThePlatform';
|
||||
import ForWorkers from './pages/ForWorkers';
|
||||
import PredictiveIntelligence from './pages/PredictiveIntelligence';
|
||||
import KrowEdgePage from './pages/KrowEdge';
|
||||
import Recruiting from './pages/Recruiting';
|
||||
import Communications from './pages/Communications';
|
||||
import Announcements from './pages/Announcements';
|
||||
import StaffProfileDemo from './pages/StaffProfileDemo';
|
||||
import CapabilityMap from './pages/CapabilityMap';
|
||||
import PlatformOnboarding from './pages/PlatformOnboarding';
|
||||
import DailyPL from './pages/DailyPL';
|
||||
import Sales from './pages/Sales';
|
||||
import PayrollDetail from './pages/PayrollDetail';
|
||||
import EmployeeTimecard from './pages/EmployeeTimecard';
|
||||
import ClientLaborReport from './pages/ClientLaborReport';
|
||||
import WorkforceNetwork from './pages/WorkforceNetwork';
|
||||
import KrowAI from './pages/KrowAI';
|
||||
import StaffingMarketplace from './pages/StaffingMarketplace';
|
||||
import KrowConsole from './pages/KrowConsole';
|
||||
import KrowCommand from './pages/KrowCommand';
|
||||
import DemandSupply from './pages/DemandSupply';
|
||||
import DocumentApproval from './pages/DocumentApproval';
|
||||
import VendorReports from './pages/VendorReports';
|
||||
|
||||
const { Pages, Layout, mainPage } = pagesConfig;
|
||||
const mainPageKey = mainPage ?? Object.keys(Pages)[0];
|
||||
const MainPage = mainPageKey ? Pages[mainPageKey] : <></>;
|
||||
|
||||
setupIframeMessaging();
|
||||
|
||||
const LayoutWrapper = ({ children, currentPageName }) => Layout ?
|
||||
<Layout currentPageName={currentPageName}>{children}</Layout>
|
||||
: <>{children}</>;
|
||||
|
||||
const AuthenticatedApp = () => {
|
||||
const { isLoadingAuth, isLoadingPublicSettings, authError, isAuthenticated, navigateToLogin } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
// 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
|
||||
|
||||
if (location.pathname === '/LandingPage') {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/LandingPage" element={<LandingPage />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<LayoutWrapper currentPageName={mainPageKey}>
|
||||
<Routes>
|
||||
<Route path="/" element={<MainPage />} />
|
||||
{Object.entries(Pages).map(([path, Page]) => (
|
||||
<Route key={path} path={`/${path}`} element={<Page />} />
|
||||
))}
|
||||
<Route path="/FeatureReport" element={<FeatureReport />} />
|
||||
<Route path="/LandingPage" element={<LandingPage />} />
|
||||
<Route path="/KrowX" element={<KrowX />} />
|
||||
<Route path="/AboutUs" element={<AboutUs />} />
|
||||
<Route path="/ThePlatform" element={<ThePlatform />} />
|
||||
<Route path="/ForWorkers" element={<ForWorkers />} />
|
||||
<Route path="/PredictiveIntelligence" element={<PredictiveIntelligence />} />
|
||||
<Route path="/KrowEdge" element={<KrowEdgePage />} />
|
||||
<Route path="/Recruiting" element={<Recruiting />} />
|
||||
<Route path="/Communications" element={<Communications />} />
|
||||
<Route path="/Announcements" element={<Announcements />} />
|
||||
<Route path="/StaffProfileDemo" element={<StaffProfileDemo />} />
|
||||
<Route path="/CapabilityMap" element={<CapabilityMap />} />
|
||||
<Route path="/PlatformOnboarding" element={<PlatformOnboarding />} />
|
||||
<Route path="/DailyPL" element={<DailyPL />} />
|
||||
<Route path="/Sales" element={<Sales />} />
|
||||
<Route path="/PayrollDetail" element={<PayrollDetail />} />
|
||||
<Route path="/EmployeeTimecard" element={<EmployeeTimecard />} />
|
||||
<Route path="/ClientLaborReport" element={<ClientLaborReport />} />
|
||||
<Route path="/WorkforceNetwork" element={<WorkforceNetwork />} />
|
||||
<Route path="/KrowAI" element={<KrowAI />} />
|
||||
<Route path="/StaffingMarketplace" element={<StaffingMarketplace />} />
|
||||
<Route path="/KrowConsole" element={<KrowConsole />} />
|
||||
<Route path="/KrowCommand" element={<KrowCommand />} />
|
||||
<Route path="/DemandSupply" element={<DemandSupply />} />
|
||||
<Route path="/DocumentApproval" element={<DocumentApproval />} />
|
||||
<Route path="/VendorReports" element={<VendorReports />} />
|
||||
<Route path="*" element={<PageNotFound />} />
|
||||
</Routes>
|
||||
</LayoutWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
function App() {
|
||||
|
||||
return (
|
||||
<AuthProvider>
|
||||
<QueryClientProvider client={queryClientInstance}>
|
||||
<Router>
|
||||
<NavigationTracker />
|
||||
<AuthenticatedApp />
|
||||
</Router>
|
||||
<Toaster />
|
||||
<VisualEditAgent />
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
739
src/Layout.jsx
Normal file
739
src/Layout.jsx
Normal file
@@ -0,0 +1,739 @@
|
||||
import React from "react";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
import { createPageUrl } from "@/utils";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Users, LayoutDashboard, UserPlus, Calendar, Briefcase, FileText,
|
||||
DollarSign, Award, HelpCircle, BarChart3, Activity, Menu, MessageSquare,
|
||||
Package, TrendingUp, Clipboard, LogOut, Shield, MapPin, Bell, CloudOff, Megaphone,
|
||||
RefreshCw, User, Search, ShoppingCart, Home, Settings as SettingsIcon, MoreVertical,
|
||||
Building2, Sparkles, CheckSquare, UserCheck, Store, GraduationCap, ArrowLeft, FolderOpen, Brain, Zap, Network, Cpu, Command, BadgeCheck,
|
||||
ChevronDown, ChevronRight
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetTrigger,
|
||||
} from "@/components/ui/sheet";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Badge } from "./components/ui/badge";
|
||||
import krowLogo from "@/assets/krow-logo.webp";
|
||||
|
||||
import ChatBubble from "./components/chat/ChatBubble";
|
||||
import RoleSwitcher from "./components/dev/RoleSwitcher";
|
||||
import NotificationPanel from "./components/notifications/NotificationPanel";
|
||||
import { NotificationEngine } from "./components/notifications/NotificationEngine";
|
||||
import { Toaster } from "./components/ui/toaster";
|
||||
import OwliverProvider from "./components/owliver/OwliverProvider";
|
||||
import OwliverShell from "./components/owliver/OwliverShell";
|
||||
|
||||
// Navigation items for each role - organized by categories
|
||||
const roleNavigationMap = {
|
||||
admin: [
|
||||
{ category: "Overview", items: [
|
||||
{ title: "KROW Command", url: createPageUrl("KrowCommand"), icon: Command },
|
||||
{ title: "Home", url: createPageUrl("Dashboard"), icon: LayoutDashboard },
|
||||
{ title: "AI Console", url: createPageUrl("KrowConsole"), icon: Cpu },
|
||||
{ title: "Savings Engine", url: createPageUrl("SavingsEngine"), icon: TrendingUp },
|
||||
]},
|
||||
{ category: "Operations", items: [
|
||||
{ title: "Orders", url: createPageUrl("Events"), icon: Calendar },
|
||||
{ title: "Schedule", url: createPageUrl("Schedule"), icon: Calendar },
|
||||
{ title: "Staff Availability", url: createPageUrl("StaffAvailability"), icon: Users },
|
||||
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
|
||||
]},
|
||||
{ category: "Management", items: [
|
||||
{ title: "Enterprises", url: createPageUrl("EnterpriseManagement"), icon: Building2 },
|
||||
{ title: "Sectors", url: createPageUrl("SectorManagement"), icon: MapPin },
|
||||
{ title: "Partners", url: createPageUrl("PartnerManagement"), icon: Briefcase },
|
||||
{ title: "Vendors", url: createPageUrl("VendorManagement"), icon: Package },
|
||||
]},
|
||||
{ category: "Workforce", items: [
|
||||
{ title: "Staff Directory", url: createPageUrl("StaffDirectory"), icon: Users },
|
||||
{ title: "Onboarding", url: createPageUrl("StaffOnboarding"), icon: GraduationCap },
|
||||
{ title: "Document Approval", url: createPageUrl("DocumentApproval"), icon: BadgeCheck },
|
||||
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
|
||||
{ title: "Certifications", url: createPageUrl("Certification"), icon: Award },
|
||||
]},
|
||||
{ category: "Finance", items: [
|
||||
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
|
||||
{ title: "Payroll", url: createPageUrl("Payroll"), icon: DollarSign },
|
||||
]},
|
||||
{ category: "Analytics", items: [
|
||||
{ title: "Predictive AI", url: createPageUrl("PredictiveIntelligence"), icon: Brain },
|
||||
{ title: "KROW EDGE", url: createPageUrl("KrowEdge"), icon: Zap },
|
||||
{ title: "AI Recruiting", url: createPageUrl("Recruiting"), icon: UserPlus },
|
||||
{ title: "Trust Network", url: createPageUrl("WorkforceNetwork"), icon: Network },
|
||||
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
|
||||
{ title: "Activity Log", url: createPageUrl("ActivityLog"), icon: Activity },
|
||||
]},
|
||||
{ category: "Communication", items: [
|
||||
{ title: "Communications Hub", url: createPageUrl("Communications"), icon: MessageSquare },
|
||||
{ title: "Announcements", url: createPageUrl("Announcements"), icon: Megaphone },
|
||||
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
|
||||
{ title: "Tutorials", url: createPageUrl("Tutorials"), icon: Sparkles },
|
||||
{ title: "Feature Report", url: createPageUrl("FeatureReport"), icon: FileText },
|
||||
]},
|
||||
{ category: "Settings", items: [
|
||||
{ title: "User Management", url: createPageUrl("UserManagement"), icon: Users },
|
||||
{ title: "Permissions", url: createPageUrl("Permissions"), icon: Shield },
|
||||
{ title: "Settings", url: createPageUrl("Settings"), icon: SettingsIcon },
|
||||
]},
|
||||
],
|
||||
procurement: [
|
||||
{ category: "Overview", items: [
|
||||
{ title: "Home", url: createPageUrl("ProcurementDashboard"), icon: LayoutDashboard },
|
||||
|
||||
{ title: "AI Console", url: createPageUrl("KrowConsole"), icon: Cpu },
|
||||
{ title: "Savings Engine", url: createPageUrl("SavingsEngine"), icon: TrendingUp },
|
||||
]},
|
||||
{ category: "Operations", items: [
|
||||
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
|
||||
]},
|
||||
{ category: "Network", items: [
|
||||
{ title: "Enterprises", url: createPageUrl("EnterpriseManagement"), icon: Building2 },
|
||||
{ title: "Sectors", url: createPageUrl("SectorManagement"), icon: MapPin },
|
||||
{ title: "Partners", url: createPageUrl("PartnerManagement"), icon: Briefcase },
|
||||
{ title: "Vendors", url: createPageUrl("VendorManagement"), icon: Package },
|
||||
]},
|
||||
{ category: "Compliance", items: [
|
||||
{ title: "Compliance", url: createPageUrl("WorkforceCompliance"), icon: Shield },
|
||||
{ title: "Rate Matrix", url: createPageUrl("VendorRateCard"), icon: DollarSign },
|
||||
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
|
||||
]},
|
||||
{ category: "Finance", items: [
|
||||
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
|
||||
]},
|
||||
{ category: "Analytics", items: [
|
||||
{ title: "Predictive AI", url: createPageUrl("PredictiveIntelligence"), icon: Brain },
|
||||
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
|
||||
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
|
||||
]},
|
||||
{ category: "Settings", items: [
|
||||
{ title: "Permissions", url: createPageUrl("Permissions"), icon: Shield },
|
||||
]},
|
||||
],
|
||||
operator: [
|
||||
{ category: "Overview", items: [
|
||||
{ title: "Home", url: createPageUrl("OperatorDashboard"), icon: LayoutDashboard },
|
||||
|
||||
{ title: "AI Console", url: createPageUrl("KrowConsole"), icon: Cpu },
|
||||
{ title: "Savings Engine", url: createPageUrl("SavingsEngine"), icon: TrendingUp },
|
||||
]},
|
||||
{ category: "Operations", items: [
|
||||
{ title: "Orders", url: createPageUrl("Events"), icon: Calendar },
|
||||
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
|
||||
]},
|
||||
{ category: "Network", items: [
|
||||
{ title: "My Sectors", url: createPageUrl("SectorManagement"), icon: MapPin },
|
||||
{ title: "Partners", url: createPageUrl("PartnerManagement"), icon: Briefcase },
|
||||
{ title: "Vendors", url: createPageUrl("VendorManagement"), icon: Package },
|
||||
{ title: "Clients", url: createPageUrl("Business"), icon: Users },
|
||||
]},
|
||||
{ category: "Workforce", items: [
|
||||
{ title: "Staff Directory", url: createPageUrl("StaffDirectory"), icon: Users },
|
||||
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
|
||||
]},
|
||||
{ category: "Analytics", items: [
|
||||
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
|
||||
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
|
||||
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
|
||||
]},
|
||||
{ category: "Settings", items: [
|
||||
{ title: "Permissions", url: createPageUrl("Permissions"), icon: Shield },
|
||||
]},
|
||||
],
|
||||
sector: [
|
||||
{ category: "Overview", items: [
|
||||
{ title: "Home", url: createPageUrl("OperatorDashboard"), icon: LayoutDashboard },
|
||||
|
||||
{ title: "AI Console", url: createPageUrl("KrowConsole"), icon: Cpu },
|
||||
{ title: "Savings Engine", url: createPageUrl("SavingsEngine"), icon: TrendingUp },
|
||||
]},
|
||||
{ category: "Operations", items: [
|
||||
{ title: "Orders", url: createPageUrl("Events"), icon: Calendar },
|
||||
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
|
||||
]},
|
||||
{ category: "Network", items: [
|
||||
{ title: "Partners", url: createPageUrl("PartnerManagement"), icon: Briefcase },
|
||||
{ title: "Vendors", url: createPageUrl("VendorManagement"), icon: Package },
|
||||
{ title: "Clients", url: createPageUrl("Business"), icon: Users },
|
||||
]},
|
||||
{ category: "Workforce", items: [
|
||||
{ title: "Staff Directory", url: createPageUrl("StaffDirectory"), icon: Users },
|
||||
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
|
||||
]},
|
||||
{ category: "Analytics", items: [
|
||||
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
|
||||
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
|
||||
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
|
||||
]},
|
||||
{ category: "Settings", items: [
|
||||
{ title: "Permissions", url: createPageUrl("Permissions"), icon: Shield },
|
||||
]},
|
||||
],
|
||||
client: [
|
||||
{ category: "Overview", items: [
|
||||
{ title: "Home", url: createPageUrl("ClientDashboard"), icon: LayoutDashboard },
|
||||
|
||||
{ title: "AI Console", url: createPageUrl("KrowConsole"), icon: Cpu },
|
||||
{ title: "Savings Engine", url: createPageUrl("SavingsEngine"), icon: TrendingUp },
|
||||
]},
|
||||
{ category: "Orders", items: [
|
||||
{ title: "My Orders", url: createPageUrl("ClientOrders"), icon: Clipboard },
|
||||
{ title: "New Order", url: createPageUrl("CreateEvent"), icon: UserPlus },
|
||||
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
|
||||
]},
|
||||
{ category: "Marketplace", items: [
|
||||
{ title: "Staffing Marketplace", url: createPageUrl("StaffingMarketplace"), icon: Store },
|
||||
{ title: "Vendor Marketplace", url: createPageUrl("VendorMarketplace"), icon: Store },
|
||||
{ title: "Compare Rates", url: createPageUrl("VendorRateCard"), icon: DollarSign },
|
||||
]},
|
||||
{ category: "Team", items: [
|
||||
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
|
||||
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
|
||||
]},
|
||||
{ category: "Analytics", items: [
|
||||
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
|
||||
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
|
||||
]},
|
||||
{ category: "Support", items: [
|
||||
{ title: "Tutorials", url: createPageUrl("Tutorials"), icon: Sparkles },
|
||||
{ title: "Support", url: createPageUrl("Support"), icon: HelpCircle },
|
||||
]},
|
||||
{ category: "Settings", items: [
|
||||
{ title: "Permissions", url: createPageUrl("Permissions"), icon: Shield },
|
||||
]},
|
||||
],
|
||||
vendor: [
|
||||
{ category: "Overview", items: [
|
||||
{ title: "Home", url: createPageUrl("VendorDashboard"), icon: LayoutDashboard },
|
||||
|
||||
{ title: "Performance", url: createPageUrl("VendorPerformance"), icon: TrendingUp },
|
||||
]},
|
||||
{ category: "Workforce", items: [
|
||||
{ title: "Onboard Staff", url: createPageUrl("StaffOnboarding"), icon: GraduationCap },
|
||||
{ title: "Staff Directory", url: createPageUrl("StaffDirectory"), icon: Users },
|
||||
{ title: "Compliance", url: createPageUrl("VendorCompliance"), icon: Shield },
|
||||
{ title: "Employee Documents", url: createPageUrl("EmployeeDocuments"), icon: FolderOpen },
|
||||
{ title: "Document Approval", url: createPageUrl("DocumentApproval"), icon: BadgeCheck },
|
||||
{ title: "Team", url: createPageUrl("Teams"), icon: UserCheck },
|
||||
]},
|
||||
{ category: "Operations", items: [
|
||||
{ title: "Orders", url: createPageUrl("VendorOrders"), icon: FileText },
|
||||
{ title: "Demand & Supply", url: createPageUrl("DemandSupply"), icon: TrendingUp },
|
||||
{ title: "Create Order", url: createPageUrl("CreateEvent"), icon: UserPlus },
|
||||
{ title: "Schedule", url: createPageUrl("Schedule"), icon: Calendar },
|
||||
{ title: "Staff Availability", url: createPageUrl("StaffAvailability"), icon: Users },
|
||||
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
|
||||
]},
|
||||
{ category: "Business", items: [
|
||||
{ title: "Clients", url: createPageUrl("Business"), icon: Briefcase },
|
||||
{ title: "Service Rates", url: createPageUrl("VendorRates"), icon: DollarSign },
|
||||
{ title: "Savings Engine", url: createPageUrl("SavingsEngine"), icon: TrendingUp },
|
||||
{ title: "AI Recruiting", url: createPageUrl("Recruiting"), icon: UserPlus },
|
||||
]},
|
||||
{ category: "Finance", items: [
|
||||
{ title: "Invoices", url: createPageUrl("Invoices"), icon: Clipboard },
|
||||
{ title: "Sales Tracker", url: createPageUrl("Sales"), icon: TrendingUp },
|
||||
{ title: "Daily Operating Report", url: createPageUrl("DailyPL"), icon: BarChart3 },
|
||||
{ title: "Payroll", url: createPageUrl("Payroll"), icon: DollarSign },
|
||||
]},
|
||||
{ category: "Analytics", items: [
|
||||
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
|
||||
{ title: "Audit Trail", url: createPageUrl("ActivityLog"), icon: Activity },
|
||||
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
|
||||
]},
|
||||
{ category: "Settings", items: [
|
||||
{ title: "Permissions", url: createPageUrl("Permissions"), icon: Shield },
|
||||
]},
|
||||
],
|
||||
workforce: [
|
||||
{ category: "Overview", items: [
|
||||
{ title: "Home", url: createPageUrl("WorkforceDashboard"), icon: LayoutDashboard },
|
||||
|
||||
{ title: "Profile", url: createPageUrl("WorkforceProfile"), icon: Users },
|
||||
]},
|
||||
{ category: "Work", items: [
|
||||
{ title: "Shift Requests", url: createPageUrl("WorkerShiftProposals"), icon: Calendar },
|
||||
{ title: "My Shifts", url: createPageUrl("WorkforceShifts"), icon: Calendar },
|
||||
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
|
||||
]},
|
||||
{ category: "Team", items: [
|
||||
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
|
||||
{ title: "Onboarding", url: createPageUrl("StaffOnboarding"), icon: GraduationCap },
|
||||
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
|
||||
]},
|
||||
{ category: "Growth", items: [
|
||||
{ title: "Certifications", url: createPageUrl("Certification"), icon: Award },
|
||||
{ title: "Earnings", url: createPageUrl("WorkforceEarnings"), icon: DollarSign },
|
||||
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
|
||||
]},
|
||||
{ category: "Settings", items: [
|
||||
{ title: "Permissions", url: createPageUrl("Permissions"), icon: Shield },
|
||||
]},
|
||||
],
|
||||
};
|
||||
|
||||
const getRoleName = (role) => {
|
||||
const names = {
|
||||
admin: "KROW Admin",
|
||||
procurement: "Procurement Manager",
|
||||
operator: "Operator",
|
||||
sector: "Sector Manager",
|
||||
client: "Client",
|
||||
vendor: "Vendor Partner",
|
||||
workforce: "Staff Member"
|
||||
};
|
||||
return names[role] || "User";
|
||||
};
|
||||
|
||||
const getRoleDescription = (role) => {
|
||||
const descriptions = {
|
||||
admin: "Platform Administrator",
|
||||
procurement: "Connects Operators, Sectors & Vendors",
|
||||
operator: "Manages Multiple Sectors",
|
||||
sector: "Branch Location Manager",
|
||||
client: "Service Requester",
|
||||
vendor: "Workforce Provider",
|
||||
workforce: "Service Professional"
|
||||
};
|
||||
return descriptions[role] || "";
|
||||
};
|
||||
|
||||
const getDashboardUrl = (role) => {
|
||||
const dashboardMap = {
|
||||
admin: "Dashboard",
|
||||
procurement: "ProcurementDashboard",
|
||||
operator: "OperatorDashboard",
|
||||
sector: "OperatorDashboard",
|
||||
client: "ClientDashboard",
|
||||
vendor: "VendorDashboard",
|
||||
workforce: "WorkforceDashboard"
|
||||
};
|
||||
return createPageUrl(dashboardMap[role] || "Dashboard");
|
||||
};
|
||||
|
||||
const getLayerName = (role) => {
|
||||
const layers = {
|
||||
admin: "KROW Admin",
|
||||
procurement: "Procurement",
|
||||
operator: "Operator",
|
||||
sector: "Sector",
|
||||
client: "Client",
|
||||
vendor: "Vendor",
|
||||
workforce: "Workforce"
|
||||
};
|
||||
return layers[role] || "User";
|
||||
};
|
||||
|
||||
const getLayerColor = (role) => {
|
||||
const colors = {
|
||||
admin: "from-[#D4B886] to-[#C8A876]",
|
||||
procurement: "from-[#0039A6] to-[#3C4F5C]",
|
||||
operator: "from-[#C8DBDC] to-[#B8CED0]",
|
||||
sector: "from-[#3C4F5C] to-[#2A3840]",
|
||||
client: "from-[#C8DBDC] to-[#B8CED0]",
|
||||
vendor: "from-[#F9E732] to-[#E8D622]",
|
||||
workforce: "from-[#3C4F5C] to-[#2A3840]"
|
||||
};
|
||||
return colors[role] || "from-[#0039A6] to-[#3C4F5C]";
|
||||
};
|
||||
|
||||
/**
|
||||
* Categories that are configuration, help or miscellaneous rather than one of
|
||||
* the primary work areas. They leave the main sidebar and render in the
|
||||
* existing "More options" menu in the top bar — nothing is deleted, and every
|
||||
* route still resolves exactly as before.
|
||||
*/
|
||||
const SECONDARY_NAV_CATEGORIES = new Set(["Settings", "Support", "Communication"]);
|
||||
|
||||
/** Destinations the "More options" menu already offers; never list them twice. */
|
||||
const EXISTING_MENU_URLS = new Set([
|
||||
createPageUrl("Messages"),
|
||||
createPageUrl("Support"),
|
||||
createPageUrl("NotificationSettings"),
|
||||
createPageUrl("Settings"),
|
||||
createPageUrl("Reports"),
|
||||
createPageUrl("ActivityLog"),
|
||||
]);
|
||||
|
||||
const navigationFor = (userRole) => roleNavigationMap[userRole] || roleNavigationMap.admin;
|
||||
|
||||
/** The categories that stay in the sidebar, in their existing order. */
|
||||
const primaryCategoriesFor = (userRole) =>
|
||||
navigationFor(userRole).filter((group) => !SECONDARY_NAV_CATEGORIES.has(group.category));
|
||||
|
||||
/** Secondary links, de-duplicated against what the menu already shows. */
|
||||
const secondaryItemsFor = (userRole) =>
|
||||
navigationFor(userRole)
|
||||
.filter((group) => SECONDARY_NAV_CATEGORIES.has(group.category))
|
||||
.flatMap((group) => group.items)
|
||||
.filter((item) => !EXISTING_MENU_URLS.has(item.url));
|
||||
|
||||
/**
|
||||
* Category-first sidebar: the top level is the work areas, and a category's
|
||||
* pages appear only once it is open. One category is open at a time, so the
|
||||
* sidebar stays about as tall as its category list however many pages a role
|
||||
* has.
|
||||
*/
|
||||
function NavigationMenu({ location, userRole, closeSheet }) {
|
||||
const categories = primaryCategoriesFor(userRole);
|
||||
const currentPath = location.pathname;
|
||||
|
||||
const activeCategory =
|
||||
categories.find((group) => group.items.some((item) => item.url === currentPath))
|
||||
?.category || null;
|
||||
|
||||
const [openCategory, setOpenCategory] = React.useState(activeCategory);
|
||||
|
||||
// Navigating re-opens whichever category owns the new page, so the current
|
||||
// page is never hidden behind a closed category. Between navigations the
|
||||
// user's own choice wins — this only re-syncs when the route actually
|
||||
// changes. (React's documented "adjust state when a prop changes" pattern:
|
||||
// it re-renders immediately rather than painting a stale frame.)
|
||||
const syncedPath = React.useRef(currentPath);
|
||||
if (syncedPath.current !== currentPath) {
|
||||
syncedPath.current = currentPath;
|
||||
setOpenCategory(activeCategory);
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className="space-y-1">
|
||||
{categories.map((categoryGroup) => {
|
||||
const isOpen = openCategory === categoryGroup.category;
|
||||
const holdsActivePage = activeCategory === categoryGroup.category;
|
||||
|
||||
return (
|
||||
<div key={categoryGroup.category}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpenCategory(isOpen ? null : categoryGroup.category)}
|
||||
aria-expanded={isOpen}
|
||||
className={`w-full flex items-center justify-between gap-2 px-4 py-2 text-xs sm:text-sm font-bold uppercase tracking-wider transition-colors focus-visible:outline-none ${
|
||||
holdsActivePage
|
||||
? 'text-[#0039A6]'
|
||||
: 'text-slate-600 hover:text-[#0039A6]'
|
||||
}`}
|
||||
>
|
||||
<span>{categoryGroup.category}</span>
|
||||
{isOpen
|
||||
? <ChevronDown className="w-4 h-4" />
|
||||
: <ChevronRight className="w-4 h-4" />}
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="space-y-0.5 mt-0.5">
|
||||
{categoryGroup.items.map((item) => {
|
||||
const isActive = currentPath === item.url;
|
||||
return (
|
||||
<Link
|
||||
key={item.title}
|
||||
to={item.url}
|
||||
onClick={closeSheet}
|
||||
className={`flex items-center gap-3 px-4 py-2 rounded-lg transition-all duration-200 ${
|
||||
isActive
|
||||
? 'bg-[#0039A6] text-white shadow-md font-medium'
|
||||
: 'text-slate-600 hover:bg-[#C8DBDC]/30 hover:text-[#3C4F5C]'
|
||||
}`}
|
||||
>
|
||||
<item.icon className="w-4 h-4" />
|
||||
<span className="text-sm">{item.title}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function LayoutChrome({ children }) {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [showNotifications, setShowNotifications] = React.useState(false);
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = React.useState(false);
|
||||
|
||||
const { data: user } = useQuery({
|
||||
queryKey: ['current-user-layout'],
|
||||
queryFn: () => base44.auth.me(),
|
||||
});
|
||||
|
||||
const sampleAvatar = "https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=400&h=400&fit=crop";
|
||||
const userAvatar = user?.profile_picture || sampleAvatar;
|
||||
|
||||
const { data: unreadCount = 0 } = useQuery({
|
||||
queryKey: ['unread-notifications', user?.id],
|
||||
queryFn: async () => {
|
||||
if (!user?.id) return 0;
|
||||
const notifications = await base44.entities.ActivityLog.filter({
|
||||
user_id: user?.id,
|
||||
is_read: false
|
||||
});
|
||||
return notifications.length;
|
||||
},
|
||||
enabled: !!user?.id,
|
||||
initialData: 0,
|
||||
refetchInterval: 10000,
|
||||
});
|
||||
|
||||
const userRole = localStorage.getItem("dev_role_override") || user?.user_role || user?.role || "admin";
|
||||
// Categories that left the sidebar now live in the "More options" menu.
|
||||
const secondaryNavItems = secondaryItemsFor(userRole);
|
||||
const userName = user?.full_name || user?.email || "User";
|
||||
const userInitial = userName.charAt(0).toUpperCase();
|
||||
|
||||
const handleLogout = () => {
|
||||
base44.auth.logout();
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col w-full bg-slate-50">
|
||||
<style>{`
|
||||
:root {
|
||||
--primary: 0 57 166;
|
||||
--primary-foreground: 255 255 255;
|
||||
--secondary: 200 219 220;
|
||||
--accent: 212 184 134;
|
||||
--muted: 241 245 249;
|
||||
--success: 249 231 50;
|
||||
--gold: 212 184 134;
|
||||
--charcoal: 60 79 92;
|
||||
--sage: 200 219 220;
|
||||
--royal-blue: 0 57 166;
|
||||
}
|
||||
|
||||
.rdp * { border-color: transparent !important; }
|
||||
.rdp-day { font-size: 0.875rem !important; min-width: 36px !important; height: 36px !important; border-radius: 50% !important; transition: all 0.2s ease !important; font-weight: 500 !important; position: relative !important; }
|
||||
.rdp-day button { width: 100% !important; height: 100% !important; border-radius: 50% !important; background-color: transparent !important; }
|
||||
.rdp-day_range_start, .rdp-day_range_start > button { background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%) !important; color: white !important; font-weight: 700 !important; border-radius: 50% !important; box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3) !important; }
|
||||
.rdp-day_range_end, .rdp-day_range_end > button { background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%) !important; color: white !important; font-weight: 700 !important; border-radius: 50% !important; box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3) !important; }
|
||||
.rdp-day_selected:not(.rdp-day_range_start):not(.rdp-day_range_end), .rdp-day_selected:not(.rdp-day_range_start):not(.rdp-day_range_end) > button { background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%) !important; color: white !important; font-weight: 700 !important; border-radius: 50% !important; box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3) !important; }
|
||||
.rdp-day_selected, .rdp-day_selected > button { background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%) !important; color: white !important; font-weight: 700 !important; border-radius: 50% !important; box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3) !important; }
|
||||
.rdp-day_range_middle, .rdp-day_range_middle > button { background-color: #dbeafe !important; background: #dbeafe !important; color: #2563eb !important; font-weight: 600 !important; border-radius: 0 !important; box-shadow: none !important; }
|
||||
.rdp-day_range_start.rdp-day_range_end, .rdp-day_range_start.rdp-day_range_end > button { border-radius: 50% !important; background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%) !important; }
|
||||
.rdp-day:hover:not(.rdp-day_selected):not(.rdp-day_disabled):not(.rdp-day_range_start):not(.rdp-day_range_end):not(.rdp-day_range_middle) > button { background-color: #eff6ff !important; background: #eff6ff !important; color: #2563eb !important; border-radius: 50% !important; }
|
||||
.rdp-day_today:not(.rdp-day_selected):not(.rdp-day_range_start):not(.rdp-day_range_end)::after { content: '' !important; position: absolute !important; bottom: 4px !important; left: 50% !important; transform: translateX(-50%) !important; width: 4px !important; height: 4px !important; background-color: #ec4899 !important; border-radius: 50% !important; z-index: 10 !important; }
|
||||
.rdp-day_today.rdp-day_selected, .rdp-day_today.rdp-day_range_start, .rdp-day_today.rdp-day_range_end { color: white !important; }
|
||||
.rdp-day_today.rdp-day_selected > button, .rdp-day_today.rdp-day_range_start > button, .rdp-day_today.rdp-day_range_end > button { color: white !important; }
|
||||
.rdp-day_outside, .rdp-day_outside > button { color: #cbd5e1 !important; opacity: 0.5 !important; }
|
||||
.rdp-day_disabled, .rdp-day_disabled > button { opacity: 0.3 !important; cursor: not-allowed !important; }
|
||||
.rdp-day_selected, .rdp-day_range_start, .rdp-day_range_end, .rdp-day_range_middle { opacity: 1 !important; visibility: visible !important; z-index: 5 !important; }
|
||||
.rdp-day.has-events:not(.rdp-day_selected):not(.rdp-day_range_start):not(.rdp-day_range_end)::before { content: '' !important; position: absolute !important; top: 4px !important; right: 4px !important; width: 4px !important; height: 4px !important; background-color: #2563eb !important; border-radius: 50% !important; }
|
||||
.rdp-day_selected.has-events::before, .rdp-day_range_start.has-events::before, .rdp-day_range_end.has-events::before { background-color: white !important; }
|
||||
.rdp-day_range_middle.has-events::before { background-color: #2563eb !important; }
|
||||
.rdp-head_cell { color: #64748b !important; font-weight: 600 !important; font-size: 0.75rem !important; text-transform: uppercase !important; padding: 8px 0 !important; }
|
||||
.rdp-caption_label { font-size: 1rem !important; font-weight: 700 !important; color: #0f172a !important; }
|
||||
.rdp-nav_button { width: 32px !important; height: 32px !important; border-radius: 6px !important; transition: all 0.2s ease !important; }
|
||||
.rdp-nav_button:hover { background-color: #eff6ff !important; color: #2563eb !important; }
|
||||
.rdp-months { gap: 2rem !important; }
|
||||
.rdp-month { padding: 0.75rem !important; }
|
||||
.rdp-table { border-spacing: 0 !important; margin-top: 1rem !important; }
|
||||
.rdp-cell { padding: 2px !important; }
|
||||
.rdp-day[style*="background"] { background: transparent !important; }
|
||||
`}</style>
|
||||
|
||||
<header className="bg-white border-b border-slate-200 shadow-sm sticky top-0 z-30">
|
||||
<div className="px-2 sm:px-4 md:px-6 py-3 flex items-center justify-between gap-2 md:gap-4">
|
||||
<div className="flex items-center gap-1 sm:gap-3 md:gap-4 flex-1 min-w-0">
|
||||
|
||||
|
||||
<Sheet open={mobileMenuOpen} onOpenChange={setMobileMenuOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="lg:hidden hover:bg-slate-100">
|
||||
<Menu className="w-5 h-5 text-[#1C323E]" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="w-64 p-0 bg-white border-slate-200">
|
||||
<div className="border-b border-slate-200 p-6">
|
||||
<Link to={getDashboardUrl(userRole)} className="flex items-center gap-3 mb-4" onClick={() => setMobileMenuOpen(false)}>
|
||||
<img src={krowLogo} alt="KROW" className="h-6 w-auto object-contain" />
|
||||
</Link>
|
||||
<div className="flex items-center gap-3 bg-slate-50 p-3 rounded-lg">
|
||||
<Avatar className="w-10 h-10">
|
||||
<AvatarImage src={userAvatar} alt={userName} />
|
||||
<AvatarFallback className="bg-[#0039A6] text-white font-bold">{userInitial}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-semibold text-[#1C323E] text-sm truncate">{userName}</p>
|
||||
<p className="text-xs text-slate-500 truncate">{getRoleName(userRole)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<NavigationMenu location={location} userRole={userRole} closeSheet={() => setMobileMenuOpen(false)} />
|
||||
</div>
|
||||
<div className="p-3 border-t border-slate-200">
|
||||
<Button variant="ghost" className="w-full justify-start text-red-600 hover:text-red-700 hover:bg-red-50" onClick={() => {handleLogout(); setMobileMenuOpen(false);}}>
|
||||
<LogOut className="w-4 h-4 mr-2" />Logout
|
||||
</Button>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<Link to={getDashboardUrl(userRole)} className="flex items-center gap-2 hover:opacity-80 transition-opacity min-w-0">
|
||||
<img src={krowLogo} alt="KROW" className="h-6 sm:h-7 w-auto flex-shrink-0 object-contain" />
|
||||
<div className="hidden sm:block min-w-0">
|
||||
<h1 className="text-sm lg:text-base font-bold text-[#1C323E] truncate">
|
||||
<span className="xl:hidden">KROW</span>
|
||||
<span className="hidden xl:inline">KROW Workforce Control Tower</span>
|
||||
</h1>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className="hidden md:flex flex-1 max-w-xl">
|
||||
<div className="relative w-full">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<input type="text" placeholder="Find employees, menu items, settings, and more..." className="w-full pl-10 pr-4 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-[#0039A6] focus:border-transparent" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-0.5 sm:gap-1 md:gap-2 flex-shrink-0">
|
||||
{/* Below xl these collapse into the "More options" menu so the header
|
||||
never overflows a narrow viewport. */}
|
||||
|
||||
|
||||
<button onClick={() => setShowNotifications(true)} className="relative p-2 hover:bg-[#C8DBDC]/30 rounded-lg transition-colors" title="Notifications" aria-label={`Notifications${unreadCount > 0 ? ` (${unreadCount} unread)` : ''}`}>
|
||||
<Bell className="w-5 h-5 text-[#3C4F5C]" />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 text-white text-[10px] font-bold rounded-full flex items-center justify-center">
|
||||
{unreadCount > 9 ? '9+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="hover:bg-slate-100" title="More options" aria-label="More options">
|
||||
<MoreVertical className="w-5 h-5 text-slate-600" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuItem className="lg:hidden" onClick={() => window.location.href = getDashboardUrl(userRole)}>
|
||||
<Home className="w-4 h-4 mr-2" />Home
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="lg:hidden" onClick={() => window.location.href = createPageUrl("Messages")}>
|
||||
<MessageSquare className="w-4 h-4 mr-2" />Messages
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="lg:hidden" onClick={() => window.location.href = createPageUrl("Support")}>
|
||||
<HelpCircle className="w-4 h-4 mr-2" />Help & Support
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="xl:hidden" onClick={handleRefresh}>
|
||||
<CloudOff className="w-4 h-4 mr-2" />Refresh changes
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="lg:hidden" />
|
||||
<DropdownMenuItem onClick={() => window.location.href = createPageUrl("NotificationSettings")}>
|
||||
<Bell className="w-4 h-4 mr-2" />Notification Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => window.location.href = createPageUrl("Settings")}>
|
||||
<SettingsIcon className="w-4 h-4 mr-2" />Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => window.location.href = createPageUrl("Reports")}>
|
||||
<FileText className="w-4 h-4 mr-2" />Reports
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => window.location.href = createPageUrl("ActivityLog")}>
|
||||
<Activity className="w-4 h-4 mr-2" />Activity Log
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* Secondary categories lifted out of the sidebar. Already
|
||||
de-duplicated against the entries above, so Settings and
|
||||
Messages are not offered twice. */}
|
||||
{secondaryNavItems.length > 0 && <DropdownMenuSeparator />}
|
||||
{secondaryNavItems.map((item) => (
|
||||
<DropdownMenuItem
|
||||
key={item.url}
|
||||
onClick={() => window.location.href = item.url}
|
||||
>
|
||||
<item.icon className="w-4 h-4 mr-2" />{item.title}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleLogout} className="text-red-600">
|
||||
<LogOut className="w-4 h-4 mr-2" />Logout
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="flex items-center gap-2 hover:bg-slate-100 rounded-lg p-1.5 transition-colors" title={`${userName} - ${getRoleName(userRole)}`}>
|
||||
<Avatar className="w-8 h-8">
|
||||
<AvatarImage src={userAvatar} alt={userName} />
|
||||
<AvatarFallback className="bg-gradient-to-br from-[#0039A6] to-[#3C4F5C] text-white font-bold text-sm">{userInitial}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="hidden lg:block text-sm font-medium text-slate-700">{userName.split(' ')[0]}</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuLabel>
|
||||
<div>
|
||||
<p className="font-semibold">{userName}</p>
|
||||
<p className="text-xs text-slate-500">{user?.email}</p>
|
||||
<p className="text-xs text-slate-500 mt-1">{getRoleName(userRole)}</p>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => window.location.href = getDashboardUrl(userRole)}>
|
||||
<Home className="w-4 h-4 mr-2" />Dashboard
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => window.location.href = createPageUrl("WorkforceProfile")}>
|
||||
<User className="w-4 h-4 mr-2" />My Profile
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden min-w-0">
|
||||
<aside className="hidden lg:flex lg:flex-col w-64 flex-shrink-0 bg-white border-r border-slate-200 shadow-sm overflow-y-auto">
|
||||
<div className="p-3">
|
||||
<NavigationMenu location={location} userRole={userRole} closeSheet={() => {}} />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* min-w-0 is required: without it a wide child (table, chart, scheduler)
|
||||
stretches this flex item and forces horizontal scroll on the page. */}
|
||||
<main className="flex-1 min-w-0 overflow-auto pb-8">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<NotificationPanel isOpen={showNotifications} onClose={() => setShowNotifications(false)} />
|
||||
<NotificationEngine />
|
||||
|
||||
<OwliverShell />
|
||||
<Toaster />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Layout({ children }) {
|
||||
return (
|
||||
<OwliverProvider>
|
||||
<LayoutChrome>{children}</LayoutChrome>
|
||||
</OwliverProvider>
|
||||
);
|
||||
}
|
||||
13
src/api/base44Client.js
Normal file
13
src/api/base44Client.js
Normal 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
|
||||
});
|
||||
9
src/api/entities.js
Normal file
9
src/api/entities.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { base44 } from './base44Client';
|
||||
|
||||
|
||||
export const Query = base44.entities.Query;
|
||||
|
||||
|
||||
|
||||
// auth sdk:
|
||||
export const User = base44.auth;
|
||||
24
src/api/integrations.js
Normal file
24
src/api/integrations.js
Normal 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;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
BIN
src/assets/krow-logo.webp
Normal file
BIN
src/assets/krow-logo.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.9 KiB |
BIN
src/assets/owliver-avatar.jpg
Normal file
BIN
src/assets/owliver-avatar.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
BIN
src/assets/owliver-mascot.jpg
Normal file
BIN
src/assets/owliver-mascot.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 KiB |
1
src/assets/react.svg
Normal file
1
src/assets/react.svg
Normal 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 |
37
src/components/ProtectedRoute.jsx
Normal file
37
src/components/ProtectedRoute.jsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { useAuth } from '@/lib/AuthContext';
|
||||
import UserNotRegisteredError from '@/components/UserNotRegisteredError';
|
||||
|
||||
const DefaultFallback = () => (
|
||||
<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>
|
||||
);
|
||||
|
||||
export default function ProtectedRoute({ fallback = <DefaultFallback />, unauthenticatedElement }) {
|
||||
const { isAuthenticated, isLoadingAuth, authChecked, authError, checkUserAuth } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (!authChecked && !isLoadingAuth) {
|
||||
checkUserAuth();
|
||||
}
|
||||
}, [authChecked, isLoadingAuth, checkUserAuth]);
|
||||
|
||||
if (isLoadingAuth || !authChecked) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (authError) {
|
||||
if (authError.type === 'user_not_registered') {
|
||||
return <UserNotRegisteredError />;
|
||||
}
|
||||
return unauthenticatedElement;
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return unauthenticatedElement;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
31
src/components/UserNotRegisteredError.jsx
Normal file
31
src/components/UserNotRegisteredError.jsx
Normal 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;
|
||||
714
src/components/announcements/AnnouncementsTab.jsx
Normal file
714
src/components/announcements/AnnouncementsTab.jsx
Normal file
@@ -0,0 +1,714 @@
|
||||
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 { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import {
|
||||
Megaphone, Plus, Edit, Trash2, Pause, Play, Pin, PinOff, Eye,
|
||||
Clock, Calendar, Upload, X, AlertTriangle, Globe, Loader2,
|
||||
Bell, Mail, Image as ImageIcon, ChevronDown, ChevronUp, Search,
|
||||
Zap, TrendingUp, Users, UserCheck, UserPlus
|
||||
} from "lucide-react";
|
||||
import { format, isPast, isFuture } from "date-fns";
|
||||
|
||||
const AUDIENCE_OPTIONS = [
|
||||
{ value: "all", label: "Everyone", color: "bg-[#0039A6] text-white" },
|
||||
{ value: "vendor", label: "Vendors", color: "bg-purple-600 text-white" },
|
||||
{ value: "client", label: "Clients", color: "bg-[#C8DBDC] text-[#3C4F5C]" },
|
||||
{ value: "workforce", label: "Workforce", color: "bg-[#D4B886] text-[#3C4F5C]" },
|
||||
{ value: "procurement", label: "Procurement", color: "bg-slate-700 text-white" },
|
||||
{ value: "operator", label: "Operators", color: "bg-emerald-600 text-white" },
|
||||
{ value: "sector", label: "Sector Managers", color: "bg-indigo-600 text-white" },
|
||||
{ value: "admin", label: "Admins Only", color: "bg-red-600 text-white" },
|
||||
];
|
||||
|
||||
const PRIORITY_CONFIG = {
|
||||
low: { label: "Low", color: "bg-slate-100 text-slate-600 border-slate-200", icon: null },
|
||||
normal: { label: "Normal", color: "bg-blue-50 text-[#0039A6] border-blue-200", icon: null },
|
||||
high: { label: "High", color: "bg-amber-50 text-amber-700 border-amber-200", icon: TrendingUp },
|
||||
urgent: { label: "Urgent", color: "bg-red-50 text-red-700 border-red-200", icon: Zap },
|
||||
};
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
draft: { label: "Draft", color: "bg-slate-100 text-slate-600 border-slate-200" },
|
||||
scheduled: { label: "Scheduled", color: "bg-blue-50 text-[#0039A6] border-blue-200" },
|
||||
active: { label: "Active", color: "bg-green-50 text-green-700 border-green-200" },
|
||||
paused: { label: "Paused", color: "bg-amber-50 text-amber-700 border-amber-200" },
|
||||
expired: { label: "Expired", color: "bg-slate-50 text-slate-400 border-slate-200" },
|
||||
};
|
||||
|
||||
const EMPTY_FORM = {
|
||||
title: "",
|
||||
body: "",
|
||||
flyer_url: "",
|
||||
audience: ["all"],
|
||||
specific_recipients: [], // [{staff_id, name, email, phone, position}]
|
||||
priority: "normal",
|
||||
status: "draft",
|
||||
publish_date: "",
|
||||
expire_date: "",
|
||||
send_email: false,
|
||||
send_push: false,
|
||||
pin_to_top: false,
|
||||
tags: [],
|
||||
};
|
||||
|
||||
export default function AnnouncementsTab({ teamId, user }) {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingAnnouncement, setEditingAnnouncement] = useState(null);
|
||||
const [form, setForm] = useState(EMPTY_FORM);
|
||||
const [tagInput, setTagInput] = useState("");
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [uploadingFlyer, setUploadingFlyer] = useState(false);
|
||||
const [expandedId, setExpandedId] = useState(null);
|
||||
const [recipientSearch, setRecipientSearch] = useState("");
|
||||
const [showRecipientPicker, setShowRecipientPicker] = useState(false);
|
||||
|
||||
const { data: announcements = [], isLoading } = useQuery({
|
||||
queryKey: ["announcements", teamId],
|
||||
queryFn: async () => {
|
||||
const all = await base44.entities.Announcement.list("-created_date");
|
||||
return all.filter(a => a.team_id === teamId);
|
||||
},
|
||||
enabled: !!teamId,
|
||||
});
|
||||
|
||||
const { data: allStaff = [] } = useQuery({
|
||||
queryKey: ["staff-for-announcements"],
|
||||
queryFn: () => base44.entities.Staff.list(),
|
||||
});
|
||||
|
||||
const filteredStaff = allStaff.filter(s => {
|
||||
if (!recipientSearch.trim()) return true;
|
||||
const q = recipientSearch.toLowerCase();
|
||||
return (
|
||||
s.employee_name?.toLowerCase().includes(q) ||
|
||||
s.email?.toLowerCase().includes(q) ||
|
||||
s.contact_number?.toLowerCase().includes(q) ||
|
||||
s.phone?.toLowerCase().includes(q) ||
|
||||
s.position?.toLowerCase().includes(q) ||
|
||||
s.position_2?.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async (data) => {
|
||||
const payload = {
|
||||
...data,
|
||||
team_id: teamId,
|
||||
vendor_id: user?.id,
|
||||
created_by_name: user?.full_name || user?.email,
|
||||
view_count: data.view_count || 0,
|
||||
specific_recipients: data.specific_recipients || [],
|
||||
};
|
||||
// Auto-set status based on publish_date
|
||||
if (payload.status === "draft" && payload.publish_date) {
|
||||
payload.status = isFuture(new Date(payload.publish_date)) ? "scheduled" : "active";
|
||||
}
|
||||
if (editingAnnouncement) {
|
||||
return base44.entities.Announcement.update(editingAnnouncement.id, payload);
|
||||
}
|
||||
return base44.entities.Announcement.create(payload);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["announcements", teamId] });
|
||||
setShowForm(false);
|
||||
setEditingAnnouncement(null);
|
||||
setForm(EMPTY_FORM);
|
||||
toast({ title: editingAnnouncement ? "✅ Announcement updated" : "✅ Announcement created" });
|
||||
},
|
||||
});
|
||||
|
||||
const updateStatusMutation = useMutation({
|
||||
mutationFn: ({ id, status }) => base44.entities.Announcement.update(id, { status }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["announcements", teamId] }),
|
||||
});
|
||||
|
||||
const togglePinMutation = useMutation({
|
||||
mutationFn: ({ id, pin_to_top }) => base44.entities.Announcement.update(id, { pin_to_top }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["announcements", teamId] }),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id) => base44.entities.Announcement.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["announcements", teamId] });
|
||||
toast({ title: "🗑️ Announcement deleted" });
|
||||
},
|
||||
});
|
||||
|
||||
const handleFlyerUpload = async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
setUploadingFlyer(true);
|
||||
const result = await base44.integrations.Core.UploadFile({ file });
|
||||
setForm({ ...form, flyer_url: result.file_url });
|
||||
setUploadingFlyer(false);
|
||||
};
|
||||
|
||||
const handleEdit = (ann) => {
|
||||
setEditingAnnouncement(ann);
|
||||
setForm({
|
||||
title: ann.title || "",
|
||||
body: ann.body || "",
|
||||
flyer_url: ann.flyer_url || "",
|
||||
audience: ann.audience || ["all"],
|
||||
specific_recipients: ann.specific_recipients || [],
|
||||
priority: ann.priority || "normal",
|
||||
status: ann.status || "draft",
|
||||
publish_date: ann.publish_date ? ann.publish_date.slice(0, 16) : "",
|
||||
expire_date: ann.expire_date ? ann.expire_date.slice(0, 16) : "",
|
||||
send_email: ann.send_email || false,
|
||||
send_push: ann.send_push || false,
|
||||
pin_to_top: ann.pin_to_top || false,
|
||||
tags: ann.tags || [],
|
||||
});
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleNewAnnouncement = () => {
|
||||
setEditingAnnouncement(null);
|
||||
setForm(EMPTY_FORM);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const toggleAudience = (val) => {
|
||||
if (val === "all") {
|
||||
setForm({ ...form, audience: ["all"] });
|
||||
return;
|
||||
}
|
||||
const current = form.audience.filter(a => a !== "all");
|
||||
if (current.includes(val)) {
|
||||
setForm({ ...form, audience: current.filter(a => a !== val) });
|
||||
} else {
|
||||
setForm({ ...form, audience: [...current, val] });
|
||||
}
|
||||
};
|
||||
|
||||
const toggleRecipient = (staff) => {
|
||||
const existing = form.specific_recipients || [];
|
||||
const isSelected = existing.some(r => r.staff_id === staff.id);
|
||||
if (isSelected) {
|
||||
setForm({ ...form, specific_recipients: existing.filter(r => r.staff_id !== staff.id) });
|
||||
} else {
|
||||
setForm({
|
||||
...form,
|
||||
specific_recipients: [...existing, {
|
||||
staff_id: staff.id,
|
||||
name: staff.employee_name,
|
||||
email: staff.email || "",
|
||||
phone: staff.contact_number || staff.phone || "",
|
||||
position: staff.position || "",
|
||||
}],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const addTag = (e) => {
|
||||
if (e.key === "Enter" && tagInput.trim()) {
|
||||
setForm({ ...form, tags: [...form.tags, tagInput.trim()] });
|
||||
setTagInput("");
|
||||
}
|
||||
};
|
||||
|
||||
const filtered = announcements
|
||||
.filter(a => statusFilter === "all" || a.status === statusFilter)
|
||||
.filter(a => !searchTerm || a.title?.toLowerCase().includes(searchTerm.toLowerCase()) || a.body?.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
.sort((a, b) => (b.pin_to_top ? 1 : 0) - (a.pin_to_top ? 1 : 0));
|
||||
|
||||
const stats = {
|
||||
total: announcements.length,
|
||||
active: announcements.filter(a => a.status === "active").length,
|
||||
scheduled: announcements.filter(a => a.status === "scheduled").length,
|
||||
pinned: announcements.filter(a => a.pin_to_top).length,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: "Total", value: stats.total, color: "text-[#3C4F5C]", bg: "bg-slate-50 border-slate-200" },
|
||||
{ label: "Active", value: stats.active, color: "text-green-700", bg: "bg-green-50 border-green-200" },
|
||||
{ label: "Scheduled", value: stats.scheduled, color: "text-[#0039A6]", bg: "bg-blue-50 border-blue-200" },
|
||||
{ label: "Pinned", value: stats.pinned, color: "text-[#D4B886]", bg: "bg-amber-50 border-amber-200" },
|
||||
].map(s => (
|
||||
<div key={s.label} className={`rounded-xl border p-4 text-center ${s.bg}`}>
|
||||
<p className={`text-2xl font-bold ${s.color}`}>{s.value}</p>
|
||||
<p className="text-xs text-slate-500 font-medium mt-1">{s.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-wrap items-center gap-3 justify-between">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<Input
|
||||
placeholder="Search announcements..."
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
className="pl-9 border-slate-300"
|
||||
/>
|
||||
</div>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-40 border-slate-300">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Statuses</SelectItem>
|
||||
{Object.entries(STATUS_CONFIG).map(([k, v]) => (
|
||||
<SelectItem key={k} value={k}>{v.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button onClick={handleNewAnnouncement} className="bg-[#0039A6] hover:bg-[#0039A6]/90 shadow-lg">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
New Announcement
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Announcements List */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-[#0039A6]" />
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="text-center py-20 bg-white rounded-2xl border-2 border-dashed border-[#C8DBDC]">
|
||||
<div className="w-20 h-20 mx-auto mb-4 rounded-full bg-gradient-to-br from-[#0039A6]/10 to-[#C8DBDC] flex items-center justify-center">
|
||||
<Megaphone className="w-10 h-10 text-[#0039A6]" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-[#3C4F5C] mb-2">No Announcements Yet</h3>
|
||||
<p className="text-slate-500 mb-6 max-w-sm mx-auto">Broadcast news, events, and updates to your entire network instantly</p>
|
||||
<Button onClick={handleNewAnnouncement} className="bg-[#0039A6] hover:bg-[#0039A6]/90">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Create First Announcement
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{filtered.map(ann => {
|
||||
const priorityCfg = PRIORITY_CONFIG[ann.priority] || PRIORITY_CONFIG.normal;
|
||||
const statusCfg = STATUS_CONFIG[ann.status] || STATUS_CONFIG.draft;
|
||||
const isExpanded = expandedId === ann.id;
|
||||
const PriorityIcon = priorityCfg.icon;
|
||||
|
||||
return (
|
||||
<Card key={ann.id} className={`border-2 transition-all duration-200 ${ann.pin_to_top ? "border-[#D4B886] bg-amber-50/30" : "border-slate-200 hover:border-[#C8DBDC]"}`}>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Left: Flyer or Icon */}
|
||||
<div className="flex-shrink-0">
|
||||
{ann.flyer_url ? (
|
||||
<img src={ann.flyer_url} alt="flyer" className="w-16 h-16 rounded-xl object-cover border-2 border-slate-200 shadow" />
|
||||
) : (
|
||||
<div className="w-16 h-16 rounded-xl bg-gradient-to-br from-[#0039A6] to-[#3C4F5C] flex items-center justify-center shadow">
|
||||
<Megaphone className="w-7 h-7 text-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Center: Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2 flex-wrap">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{ann.pin_to_top && <Pin className="w-4 h-4 text-[#D4B886] fill-[#D4B886]" />}
|
||||
<h4 className="font-bold text-[#3C4F5C] text-base">{ann.title}</h4>
|
||||
<Badge className={`text-xs border ${statusCfg.color}`}>{statusCfg.label}</Badge>
|
||||
<Badge className={`text-xs border flex items-center gap-1 ${priorityCfg.color}`}>
|
||||
{PriorityIcon && <PriorityIcon className="w-3 h-3" />}
|
||||
{priorityCfg.label}
|
||||
</Badge>
|
||||
</div>
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 hover:bg-amber-50" title={ann.pin_to_top ? "Unpin" : "Pin to top"}
|
||||
onClick={() => togglePinMutation.mutate({ id: ann.id, pin_to_top: !ann.pin_to_top })}>
|
||||
{ann.pin_to_top ? <PinOff className="w-4 h-4 text-amber-600" /> : <Pin className="w-4 h-4 text-slate-400" />}
|
||||
</Button>
|
||||
{ann.status === "active" ? (
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 hover:bg-amber-50" title="Pause"
|
||||
onClick={() => updateStatusMutation.mutate({ id: ann.id, status: "paused" })}>
|
||||
<Pause className="w-4 h-4 text-amber-600" />
|
||||
</Button>
|
||||
) : ann.status === "paused" || ann.status === "draft" || ann.status === "scheduled" ? (
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 hover:bg-green-50" title="Publish now"
|
||||
onClick={() => updateStatusMutation.mutate({ id: ann.id, status: "active" })}>
|
||||
<Play className="w-4 h-4 text-green-600" />
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 hover:bg-blue-50" title="Edit"
|
||||
onClick={() => handleEdit(ann)}>
|
||||
<Edit className="w-4 h-4 text-[#0039A6]" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 hover:bg-red-50" title="Delete"
|
||||
onClick={() => deleteMutation.mutate(ann.id)}>
|
||||
<Trash2 className="w-4 h-4 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Meta row */}
|
||||
<div className="flex items-center gap-4 mt-2 text-xs text-slate-500 flex-wrap">
|
||||
<span className="flex items-center gap-1">
|
||||
<Globe className="w-3 h-3" />
|
||||
{(ann.audience || []).includes("all") ? "Everyone" : (ann.audience || []).join(", ")}
|
||||
</span>
|
||||
{ann.specific_recipients?.length > 0 && (
|
||||
<span className="flex items-center gap-1 text-[#0039A6]">
|
||||
<Users className="w-3 h-3" />
|
||||
+{ann.specific_recipients.length} specific people
|
||||
</span>
|
||||
)}
|
||||
{ann.publish_date && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="w-3 h-3" />
|
||||
{format(new Date(ann.publish_date), "MMM d, yyyy h:mm a")}
|
||||
</span>
|
||||
)}
|
||||
{ann.expire_date && (
|
||||
<span className={`flex items-center gap-1 ${isPast(new Date(ann.expire_date)) ? "text-red-400" : ""}`}>
|
||||
<Clock className="w-3 h-3" />
|
||||
Expires {format(new Date(ann.expire_date), "MMM d, yyyy")}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1"><Eye className="w-3 h-3" />{ann.view_count || 0} views</span>
|
||||
{ann.send_email && <span className="flex items-center gap-1"><Mail className="w-3 h-3" />Email</span>}
|
||||
{ann.send_push && <span className="flex items-center gap-1"><Bell className="w-3 h-3" />Push</span>}
|
||||
</div>
|
||||
|
||||
{/* Body preview / expand */}
|
||||
<div className="mt-3">
|
||||
<p className={`text-sm text-slate-600 leading-relaxed ${isExpanded ? "" : "line-clamp-2"}`}>
|
||||
{ann.body}
|
||||
</p>
|
||||
{ann.body?.length > 120 && (
|
||||
<button
|
||||
className="text-xs text-[#0039A6] mt-1 flex items-center gap-1 hover:underline"
|
||||
onClick={() => setExpandedId(isExpanded ? null : ann.id)}
|
||||
>
|
||||
{isExpanded ? <><ChevronUp className="w-3 h-3" /> Show less</> : <><ChevronDown className="w-3 h-3" /> Read more</>}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
{ann.tags?.length > 0 && (
|
||||
<div className="flex gap-1 mt-2 flex-wrap">
|
||||
{ann.tags.map(t => (
|
||||
<span key={t} className="px-2 py-0.5 bg-[#C8DBDC]/50 text-[#3C4F5C] rounded-full text-xs font-medium">#{t}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create / Edit Modal */}
|
||||
<Dialog open={showForm} onOpenChange={(v) => { if (!v) { setShowForm(false); setEditingAnnouncement(null); setForm(EMPTY_FORM); } }}>
|
||||
<DialogContent className="max-w-2xl max-h-[90dvh] overflow-y-auto">
|
||||
<DialogHeader className="pb-4 border-b">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-[#0039A6] to-[#3C4F5C] rounded-xl flex items-center justify-center">
|
||||
<Megaphone className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<DialogTitle className="text-xl">{editingAnnouncement ? "Edit Announcement" : "New Announcement"}</DialogTitle>
|
||||
<DialogDescription>Broadcast to your entire network</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-5 py-4">
|
||||
{/* Title */}
|
||||
<div>
|
||||
<Label className="font-semibold">Title *</Label>
|
||||
<Input
|
||||
className="mt-1.5"
|
||||
placeholder="e.g., All-Hands Meeting – May 15th"
|
||||
value={form.title}
|
||||
onChange={e => setForm({ ...form, title: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div>
|
||||
<Label className="font-semibold">Message *</Label>
|
||||
<Textarea
|
||||
className="mt-1.5 min-h-[120px]"
|
||||
placeholder="Write your full announcement here..."
|
||||
value={form.body}
|
||||
onChange={e => setForm({ ...form, body: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Flyer Upload */}
|
||||
<div>
|
||||
<Label className="font-semibold flex items-center gap-2"><ImageIcon className="w-4 h-4" />Flyer / Image</Label>
|
||||
{form.flyer_url ? (
|
||||
<div className="mt-2 relative inline-block">
|
||||
<img src={form.flyer_url} alt="flyer" className="h-24 w-auto rounded-xl border-2 border-[#C8DBDC] shadow" />
|
||||
<button
|
||||
onClick={() => setForm({ ...form, flyer_url: "" })}
|
||||
className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 rounded-full flex items-center justify-center shadow"
|
||||
>
|
||||
<X className="w-3 h-3 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<label className="mt-1.5 flex flex-col items-center gap-2 p-4 border-2 border-dashed border-[#C8DBDC] rounded-xl cursor-pointer hover:bg-[#C8DBDC]/10 transition-colors">
|
||||
{uploadingFlyer ? <Loader2 className="w-5 h-5 animate-spin text-[#0039A6]" /> : <Upload className="w-5 h-5 text-[#0039A6]" />}
|
||||
<span className="text-sm text-slate-500">{uploadingFlyer ? "Uploading..." : "Click to upload flyer (JPG, PNG)"}</span>
|
||||
<input type="file" accept="image/*" className="hidden" onChange={handleFlyerUpload} disabled={uploadingFlyer} />
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Audience */}
|
||||
<div>
|
||||
<Label className="font-semibold flex items-center gap-2"><Globe className="w-4 h-4" />Visible To *</Label>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{AUDIENCE_OPTIONS.map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => toggleAudience(opt.value)}
|
||||
className={`px-3 py-1.5 rounded-full text-sm font-medium border-2 transition-all ${
|
||||
form.audience.includes(opt.value)
|
||||
? opt.color + " border-transparent scale-105 shadow"
|
||||
: "bg-white text-slate-500 border-slate-200 hover:border-[#0039A6]"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Specific Recipients */}
|
||||
<div className="border-2 border-dashed border-[#C8DBDC] rounded-xl p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="font-semibold flex items-center gap-2 text-[#3C4F5C]">
|
||||
<Users className="w-4 h-4" />
|
||||
Send to Specific People
|
||||
{form.specific_recipients?.length > 0 && (
|
||||
<span className="ml-1 px-2 py-0.5 bg-[#0039A6] text-white text-xs rounded-full font-bold">
|
||||
{form.specific_recipients.length} selected
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowRecipientPicker(!showRecipientPicker)}
|
||||
className="text-sm text-[#0039A6] hover:underline font-medium flex items-center gap-1"
|
||||
>
|
||||
{showRecipientPicker ? "Hide" : "Search & Add"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Selected Recipients Pills */}
|
||||
{form.specific_recipients?.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{form.specific_recipients.map(r => (
|
||||
<span key={r.staff_id} className="flex items-center gap-1.5 px-3 py-1.5 bg-[#0039A6]/10 text-[#0039A6] rounded-full text-sm font-medium border border-[#0039A6]/30">
|
||||
<UserCheck className="w-3 h-3" />
|
||||
{r.name}
|
||||
{r.position && <span className="text-xs text-slate-400">· {r.position}</span>}
|
||||
<button
|
||||
onClick={() => setForm({ ...form, specific_recipients: form.specific_recipients.filter(x => x.staff_id !== r.staff_id) })}
|
||||
className="ml-0.5 hover:text-red-500 transition-colors"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Staff Search Picker */}
|
||||
{showRecipientPicker && (
|
||||
<div className="space-y-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<Input
|
||||
placeholder="Search by name, email, phone, or position..."
|
||||
value={recipientSearch}
|
||||
onChange={e => setRecipientSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-48 overflow-y-auto rounded-xl border border-slate-200 divide-y divide-slate-100 bg-white">
|
||||
{filteredStaff.length === 0 ? (
|
||||
<div className="p-4 text-center text-sm text-slate-400">No staff found</div>
|
||||
) : filteredStaff.slice(0, 30).map(staff => {
|
||||
const isSelected = (form.specific_recipients || []).some(r => r.staff_id === staff.id);
|
||||
return (
|
||||
<button
|
||||
key={staff.id}
|
||||
type="button"
|
||||
onClick={() => toggleRecipient(staff)}
|
||||
className={`w-full flex items-center gap-3 px-4 py-3 text-left hover:bg-slate-50 transition-colors ${isSelected ? "bg-blue-50" : ""}`}
|
||||
>
|
||||
<div className={`w-9 h-9 rounded-lg flex items-center justify-center text-white font-bold text-sm flex-shrink-0 ${isSelected ? "bg-[#0039A6]" : "bg-gradient-to-br from-slate-400 to-slate-600"}`}>
|
||||
{staff.employee_name?.charAt(0) || "?"}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-semibold text-sm text-slate-800 truncate">{staff.employee_name}</p>
|
||||
<p className="text-xs text-slate-400 truncate">
|
||||
{[staff.position, staff.email, staff.contact_number || staff.phone].filter(Boolean).join(" · ")}
|
||||
</p>
|
||||
</div>
|
||||
{isSelected && <UserCheck className="w-4 h-4 text-[#0039A6] flex-shrink-0" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{filteredStaff.length > 30 && (
|
||||
<p className="text-xs text-slate-400 text-center">Showing first 30 results. Refine your search.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Priority & Status */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="font-semibold">Priority</Label>
|
||||
<Select value={form.priority} onValueChange={v => setForm({ ...form, priority: v })}>
|
||||
<SelectTrigger className="mt-1.5">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(PRIORITY_CONFIG).map(([k, v]) => (
|
||||
<SelectItem key={k} value={k}>{v.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="font-semibold">Status</Label>
|
||||
<Select value={form.status} onValueChange={v => setForm({ ...form, status: v })}>
|
||||
<SelectTrigger className="mt-1.5">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(STATUS_CONFIG).map(([k, v]) => (
|
||||
<SelectItem key={k} value={k}>{v.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Schedule */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="font-semibold flex items-center gap-2"><Calendar className="w-4 h-4" />Publish Date & Time</Label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
className="mt-1.5"
|
||||
value={form.publish_date}
|
||||
onChange={e => setForm({ ...form, publish_date: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="font-semibold flex items-center gap-2"><Clock className="w-4 h-4" />Expiry Date & Time</Label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
className="mt-1.5"
|
||||
value={form.expire_date}
|
||||
onChange={e => setForm({ ...form, expire_date: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notifications */}
|
||||
<div className="bg-slate-50 rounded-xl p-4 space-y-3 border border-slate-200">
|
||||
<Label className="font-semibold text-[#3C4F5C]">Delivery Channels</Label>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-2 text-sm text-slate-600"><Mail className="w-4 h-4" />Send Email to Audience</span>
|
||||
<Switch checked={form.send_email} onCheckedChange={v => setForm({ ...form, send_email: v })} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-2 text-sm text-slate-600"><Bell className="w-4 h-4" />Send Push Notification</span>
|
||||
<Switch checked={form.send_push} onCheckedChange={v => setForm({ ...form, send_push: v })} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-2 text-sm text-slate-600"><Pin className="w-4 h-4" />Pin to Top of Feed</span>
|
||||
<Switch checked={form.pin_to_top} onCheckedChange={v => setForm({ ...form, pin_to_top: v })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div>
|
||||
<Label className="font-semibold">Tags <span className="text-slate-400 font-normal">(press Enter to add)</span></Label>
|
||||
<Input
|
||||
className="mt-1.5"
|
||||
placeholder="e.g., event, policy, urgent"
|
||||
value={tagInput}
|
||||
onChange={e => setTagInput(e.target.value)}
|
||||
onKeyDown={addTag}
|
||||
/>
|
||||
{form.tags.length > 0 && (
|
||||
<div className="flex gap-2 mt-2 flex-wrap">
|
||||
{form.tags.map(t => (
|
||||
<span key={t} className="flex items-center gap-1 px-2 py-1 bg-[#C8DBDC]/50 text-[#3C4F5C] rounded-full text-xs font-medium">
|
||||
#{t}
|
||||
<button onClick={() => setForm({ ...form, tags: form.tags.filter(x => x !== t) })}>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Smart Tip */}
|
||||
{form.publish_date && isFuture(new Date(form.publish_date)) && (
|
||||
<div className="bg-blue-50 border-l-4 border-[#0039A6] p-3 rounded-r-xl text-sm text-[#0039A6]">
|
||||
<strong>Scheduled:</strong> This announcement will automatically go live on {format(new Date(form.publish_date), "MMM d, yyyy 'at' h:mm a")}.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="border-t pt-4 gap-2">
|
||||
<Button variant="outline" onClick={() => { setShowForm(false); setForm(EMPTY_FORM); }}>Cancel</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => saveMutation.mutate({ ...form, status: "draft" })}
|
||||
disabled={!form.title || !form.body || saveMutation.isPending}
|
||||
>
|
||||
Save as Draft
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-[#0039A6] hover:bg-[#0039A6]/90"
|
||||
onClick={() => saveMutation.mutate(form)}
|
||||
disabled={!form.title || !form.body || form.audience.length === 0 || saveMutation.isPending}
|
||||
>
|
||||
{saveMutation.isPending ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <Megaphone className="w-4 h-4 mr-2" />}
|
||||
{form.publish_date && isFuture(new Date(form.publish_date)) ? "Schedule" : editingAnnouncement ? "Update" : "Publish Now"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
406
src/components/budget/BudgetUtilizationTracker.jsx
Normal file
406
src/components/budget/BudgetUtilizationTracker.jsx
Normal file
@@ -0,0 +1,406 @@
|
||||
import React, { useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import {
|
||||
DollarSign, TrendingUp, TrendingDown, AlertTriangle, CheckCircle,
|
||||
Target, Lightbulb, ArrowRight, PieChart, BarChart3, Wallet,
|
||||
Building2, Users, Package, Calendar, Zap, Brain, Shield
|
||||
} from "lucide-react";
|
||||
import ClientBudgetConfiguration from "./ClientBudgetConfiguration";
|
||||
|
||||
const ROLE_BUDGET_CONFIG = {
|
||||
procurement: {
|
||||
title: "Procurement Budget Control",
|
||||
color: "blue",
|
||||
metrics: ["vendor_spend", "rate_compliance", "savings_achieved"],
|
||||
focus: "Vendor rate optimization & consolidation"
|
||||
},
|
||||
operator: {
|
||||
title: "Operator Budget Overview",
|
||||
color: "emerald",
|
||||
metrics: ["sector_allocation", "labor_costs", "efficiency"],
|
||||
focus: "Cross-sector resource optimization"
|
||||
},
|
||||
sector: {
|
||||
title: "Sector Budget Allocation",
|
||||
color: "purple",
|
||||
metrics: ["site_costs", "overtime", "headcount"],
|
||||
focus: "Site-level cost management"
|
||||
},
|
||||
client: {
|
||||
title: "Your Staffing Budget",
|
||||
color: "green",
|
||||
metrics: ["order_costs", "vendor_rates", "savings"],
|
||||
focus: "Cost-effective staffing solutions"
|
||||
},
|
||||
vendor: {
|
||||
title: "Revenue & Margin Tracker",
|
||||
color: "amber",
|
||||
metrics: ["revenue", "margins", "utilization"],
|
||||
focus: "Maximize revenue & worker utilization"
|
||||
},
|
||||
admin: {
|
||||
title: "Platform Budget Intelligence",
|
||||
color: "slate",
|
||||
metrics: ["total_gmv", "platform_fees", "growth"],
|
||||
focus: "Platform-wide financial health"
|
||||
}
|
||||
};
|
||||
|
||||
export default function BudgetUtilizationTracker({
|
||||
userRole = 'admin',
|
||||
events = [],
|
||||
invoices = [],
|
||||
budgetData = null
|
||||
}) {
|
||||
const [selectedPeriod, setSelectedPeriod] = useState("month");
|
||||
const config = ROLE_BUDGET_CONFIG[userRole] || ROLE_BUDGET_CONFIG.admin;
|
||||
|
||||
if (userRole === 'client') {
|
||||
return <ClientBudgetConfiguration invoices={invoices} />;
|
||||
}
|
||||
|
||||
// Calculate budget metrics
|
||||
const totalSpent = invoices.reduce((sum, inv) => sum + (inv.amount || 0), 0) || 15000;
|
||||
const totalBudget = budgetData?.total_budget || totalSpent * 1.2;
|
||||
const utilizationRate = ((totalSpent / totalBudget) * 100).toFixed(1);
|
||||
const remainingBudget = totalBudget - totalSpent;
|
||||
|
||||
// Savings calculation
|
||||
const potentialSavings = totalSpent * 0.12; // 12% potential savings
|
||||
const achievedSavings = totalSpent * 0.05; // 5% already achieved
|
||||
|
||||
// Trend data
|
||||
const trend = utilizationRate < 80 ? "under" : utilizationRate < 95 ? "on_track" : "over";
|
||||
|
||||
// Smart recommendations based on role and budget status
|
||||
const getSmartRecommendations = () => {
|
||||
const recommendations = [];
|
||||
|
||||
if (userRole === 'procurement') {
|
||||
if (utilizationRate > 90) {
|
||||
recommendations.push({
|
||||
priority: "high",
|
||||
action: "Renegotiate top 3 vendor contracts",
|
||||
impact: `Save $${(totalSpent * 0.08).toLocaleString()}`,
|
||||
icon: DollarSign
|
||||
});
|
||||
}
|
||||
recommendations.push({
|
||||
priority: "medium",
|
||||
action: "Consolidate 5 underperforming vendors",
|
||||
impact: "18% rate reduction",
|
||||
icon: Package
|
||||
});
|
||||
recommendations.push({
|
||||
priority: "low",
|
||||
action: "Lock in Q1 rates before price increases",
|
||||
impact: "Protect against 5% inflation",
|
||||
icon: Shield
|
||||
});
|
||||
} else if (userRole === 'operator') {
|
||||
recommendations.push({
|
||||
priority: "high",
|
||||
action: "Reallocate 12 workers from Sector A to B",
|
||||
impact: "+15% fill rate improvement",
|
||||
icon: Users
|
||||
});
|
||||
recommendations.push({
|
||||
priority: "medium",
|
||||
action: "Reduce overtime in kitchen roles by 20%",
|
||||
impact: `Save $${(totalSpent * 0.04).toLocaleString()}/month`,
|
||||
icon: TrendingDown
|
||||
});
|
||||
} else if (userRole === 'sector') {
|
||||
recommendations.push({
|
||||
priority: "high",
|
||||
action: "Review 3 sites with >110% budget usage",
|
||||
impact: "Prevent $8,200 overage",
|
||||
icon: AlertTriangle
|
||||
});
|
||||
recommendations.push({
|
||||
priority: "medium",
|
||||
action: "Shift Tuesday/Wednesday staffing levels",
|
||||
impact: "15% efficiency gain",
|
||||
icon: Calendar
|
||||
});
|
||||
} else if (userRole === 'client') {
|
||||
recommendations.push({
|
||||
priority: "high",
|
||||
action: "Switch 2 orders to Preferred Vendor rates",
|
||||
impact: `Save $${(totalSpent * 0.06).toLocaleString()}`,
|
||||
icon: DollarSign
|
||||
});
|
||||
recommendations.push({
|
||||
priority: "medium",
|
||||
action: "Lock recurring staff for top 3 positions",
|
||||
impact: "15% rate lock guarantee",
|
||||
icon: Shield
|
||||
});
|
||||
} else if (userRole === 'vendor') {
|
||||
recommendations.push({
|
||||
priority: "high",
|
||||
action: "Fill 12 idle workers with pending orders",
|
||||
impact: `+$${(potentialSavings * 0.8).toLocaleString()} revenue`,
|
||||
icon: Users
|
||||
});
|
||||
recommendations.push({
|
||||
priority: "medium",
|
||||
action: "Upsell premium rates to 3 new clients",
|
||||
impact: "+8% margin improvement",
|
||||
icon: TrendingUp
|
||||
});
|
||||
} else {
|
||||
recommendations.push({
|
||||
priority: "high",
|
||||
action: "Enable 2 more automation workflows",
|
||||
impact: "+$12K/month platform savings",
|
||||
icon: Zap
|
||||
});
|
||||
recommendations.push({
|
||||
priority: "medium",
|
||||
action: "Approve 5 pending vendor applications",
|
||||
impact: "Unlock $45K GMV potential",
|
||||
icon: Building2
|
||||
});
|
||||
}
|
||||
|
||||
return recommendations;
|
||||
};
|
||||
|
||||
const recommendations = getSmartRecommendations();
|
||||
|
||||
// Budget breakdown by category
|
||||
const budgetBreakdown = [
|
||||
{ name: "Labor Costs", amount: totalSpent * 0.65, percentage: 65, color: "bg-blue-500" },
|
||||
{ name: "Vendor Fees", amount: totalSpent * 0.12, percentage: 12, color: "bg-purple-500" },
|
||||
{ name: "Overtime", amount: totalSpent * 0.15, percentage: 15, color: "bg-amber-500" },
|
||||
{ name: "Other", amount: totalSpent * 0.08, percentage: 8, color: "bg-slate-400" }
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-12 h-12 bg-${config.color}-100 rounded-xl flex items-center justify-center`}>
|
||||
<Wallet className={`w-6 h-6 text-${config.color}-600`} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-slate-900">{config.title}</h2>
|
||||
<p className="text-sm text-slate-500">{config.focus}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{["week", "month", "quarter", "year"].map(period => (
|
||||
<Button
|
||||
key={period}
|
||||
size="sm"
|
||||
variant={selectedPeriod === period ? "default" : "outline"}
|
||||
onClick={() => setSelectedPeriod(period)}
|
||||
className={selectedPeriod === period ? "bg-[#0A39DF]" : ""}
|
||||
>
|
||||
{period.charAt(0).toUpperCase() + period.slice(1)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Budget Card */}
|
||||
<Card className={`border-2 ${trend === 'under' ? 'border-green-200 bg-green-50/30' : trend === 'on_track' ? 'border-blue-200 bg-blue-50/30' : 'border-red-200 bg-red-50/30'}`}>
|
||||
<CardContent className="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
{/* Total Spent */}
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wider mb-1">Total Spent</p>
|
||||
<p className="text-3xl font-bold text-slate-900">${totalSpent.toLocaleString()}</p>
|
||||
<p className="text-sm text-slate-500">of ${totalBudget.toLocaleString()} budget</p>
|
||||
</div>
|
||||
|
||||
{/* Utilization Rate */}
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wider mb-1">Utilization</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className={`text-3xl font-bold ${trend === 'under' ? 'text-green-600' : trend === 'on_track' ? 'text-blue-600' : 'text-red-600'}`}>
|
||||
{utilizationRate}%
|
||||
</p>
|
||||
{trend === 'under' && <TrendingDown className="w-5 h-5 text-green-500" />}
|
||||
{trend === 'on_track' && <Target className="w-5 h-5 text-blue-500" />}
|
||||
{trend === 'over' && <TrendingUp className="w-5 h-5 text-red-500" />}
|
||||
</div>
|
||||
<Progress
|
||||
value={parseFloat(utilizationRate)}
|
||||
className={`h-2 mt-2 ${trend === 'over' ? '[&>div]:bg-red-500' : trend === 'on_track' ? '[&>div]:bg-blue-500' : '[&>div]:bg-green-500'}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Remaining Budget */}
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wider mb-1">Remaining</p>
|
||||
<p className={`text-3xl font-bold ${remainingBudget > 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
${Math.abs(remainingBudget).toLocaleString()}
|
||||
</p>
|
||||
<p className="text-sm text-slate-500">
|
||||
{remainingBudget > 0 ? 'Available to spend' : 'Over budget'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Savings Opportunity */}
|
||||
<div className="bg-purple-50 rounded-xl p-4 border-2 border-purple-200">
|
||||
<p className="text-xs text-purple-600 uppercase tracking-wider font-semibold mb-1">
|
||||
💡 Savings Potential
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-purple-700">${potentialSavings.toLocaleString()}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge className="bg-green-100 text-green-700 text-[10px]">
|
||||
${achievedSavings.toLocaleString()} achieved
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* AI-Powered Recommendations */}
|
||||
<Card className="bg-gradient-to-r from-[#1C323E] to-[#0A39DF] text-white overflow-hidden">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Brain className="w-5 h-5" />
|
||||
<span className="font-bold">AI Budget Advisor</span>
|
||||
<Badge className="bg-white/20 text-white border-0 text-[10px]">Smart Recommendations</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
{recommendations.map((rec, idx) => {
|
||||
const Icon = rec.icon;
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className={`p-4 rounded-xl ${rec.priority === 'high' ? 'bg-white/20' : 'bg-white/10'} backdrop-blur-sm`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Icon className="w-4 h-4" />
|
||||
<Badge className={`text-[10px] ${rec.priority === 'high' ? 'bg-red-500' : rec.priority === 'medium' ? 'bg-amber-500' : 'bg-green-500'}`}>
|
||||
{rec.priority}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm font-medium mb-1">{rec.action}</p>
|
||||
<p className="text-xs text-white/70">{rec.impact}</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="mt-2 h-7 text-xs bg-white text-[#0A39DF] hover:bg-white/90"
|
||||
onClick={() => window.location.href = rec.priority === 'high' ? '/VendorManagement' : '/Reports'}
|
||||
>
|
||||
Take Action <ArrowRight className="w-3 h-3 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Budget Breakdown */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<PieChart className="w-5 h-5 text-[#0A39DF]" />
|
||||
Spend Breakdown
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{budgetBreakdown.map((item, idx) => (
|
||||
<div key={idx}>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm font-medium">{item.name}</span>
|
||||
<span className="text-sm text-slate-600">${item.amount.toLocaleString()} ({item.percentage}%)</span>
|
||||
</div>
|
||||
<div className="w-full bg-slate-100 rounded-full h-2">
|
||||
<div
|
||||
className={`${item.color} h-2 rounded-full transition-all`}
|
||||
style={{ width: `${item.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<BarChart3 className="w-5 h-5 text-[#0A39DF]" />
|
||||
Budget Health Score
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-center py-4">
|
||||
<div className={`w-24 h-24 mx-auto rounded-full flex items-center justify-center text-3xl font-bold ${trend === 'under' ? 'bg-green-100 text-green-700' : trend === 'on_track' ? 'bg-blue-100 text-blue-700' : 'bg-red-100 text-red-700'}`}>
|
||||
{trend === 'under' ? 'A+' : trend === 'on_track' ? 'B+' : 'C'}
|
||||
</div>
|
||||
<p className="mt-3 font-semibold text-slate-900">
|
||||
{trend === 'under' ? 'Excellent Budget Management' : trend === 'on_track' ? 'Good - Monitor Closely' : 'Action Required'}
|
||||
</p>
|
||||
<p className="text-sm text-slate-500 mt-1">
|
||||
{trend === 'under'
|
||||
? 'You have room for strategic investments'
|
||||
: trend === 'on_track'
|
||||
? 'Stay on track with current spending pace'
|
||||
: 'Immediate cost reduction recommended'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3 mt-4">
|
||||
<div className="text-center p-3 bg-slate-50 rounded-lg">
|
||||
<p className="text-lg font-bold text-green-600">↓12%</p>
|
||||
<p className="text-[10px] text-slate-500">vs Last Period</p>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-slate-50 rounded-lg">
|
||||
<p className="text-lg font-bold text-blue-600">94%</p>
|
||||
<p className="text-[10px] text-slate-500">Forecast Accuracy</p>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-slate-50 rounded-lg">
|
||||
<p className="text-lg font-bold text-purple-600">$8.2K</p>
|
||||
<p className="text-[10px] text-slate-500">Saved This Month</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Quick Decision Panel */}
|
||||
<Card className="border-l-4 border-l-purple-500 bg-purple-50">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Lightbulb className="w-5 h-5 text-purple-600 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold text-purple-900">Quick Decision for {config.title.split(' ')[0]}</p>
|
||||
<p className="text-sm text-purple-700 mt-1">
|
||||
{userRole === 'procurement' && "Lock in 3 vendor contracts at current rates before Q1 price increases — potential 8% savings on $125K annual spend."}
|
||||
{userRole === 'operator' && "Reallocate 15% of Sector B's overtime budget to temporary staffing — same output, 22% cost reduction."}
|
||||
{userRole === 'sector' && "Approve shift consolidation for Tuesdays — reduces 4 overlapping positions, saves $1,800/week."}
|
||||
{userRole === 'client' && "Switch to annual contract with Preferred Vendor — locks in 15% discount, saves $6,400/year."}
|
||||
{userRole === 'vendor' && "Accept 3 pending orders at standard rates — fills idle capacity, adds $12K revenue this month."}
|
||||
{userRole === 'admin' && "Enable automated invoice reconciliation — reduces processing time 60%, saves $8K/month in admin costs."}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-purple-600 hover:bg-purple-700"
|
||||
onClick={() => window.location.href = '/SavingsEngine'}
|
||||
>
|
||||
Execute <ArrowRight className="w-3 h-3 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
292
src/components/budget/ClientBudgetConfiguration.jsx
Normal file
292
src/components/budget/ClientBudgetConfiguration.jsx
Normal file
@@ -0,0 +1,292 @@
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Wallet, Plus, AlertTriangle, Trash2, Building2, RefreshCw } from "lucide-react";
|
||||
import { startOfWeek, startOfMonth, startOfDay, parseISO, isAfter } from "date-fns";
|
||||
|
||||
const safeParse = (d) => {
|
||||
if (!d) return null;
|
||||
try { return parseISO(d); } catch { return null; }
|
||||
};
|
||||
|
||||
const periodStartFor = (frequency) => {
|
||||
const now = new Date();
|
||||
if (frequency === "Daily") return startOfDay(now);
|
||||
if (frequency === "Weekly") return startOfWeek(now, { weekStartsOn: 0 });
|
||||
return startOfMonth(now);
|
||||
};
|
||||
|
||||
export default function ClientBudgetConfiguration({ invoices = [] }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
hub_name: "",
|
||||
cost_center: "",
|
||||
amount: "",
|
||||
frequency: "Monthly",
|
||||
alert_enabled: true,
|
||||
alert_threshold: 80,
|
||||
});
|
||||
|
||||
const { data: user } = useQuery({
|
||||
queryKey: ["budget-user"],
|
||||
queryFn: () => base44.auth.me(),
|
||||
});
|
||||
|
||||
const { data: budgets = [], isLoading } = useQuery({
|
||||
queryKey: ["client-budgets", user?.id],
|
||||
queryFn: () => base44.entities.Budget.filter({ created_by_id: user?.id }),
|
||||
enabled: !!user?.id,
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data) => base44.entities.Budget.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["client-budgets"] });
|
||||
setOpen(false);
|
||||
setForm({ hub_name: "", cost_center: "", amount: "", frequency: "Monthly", alert_enabled: true, alert_threshold: 80 });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id) => base44.entities.Budget.delete(id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["client-budgets"] }),
|
||||
});
|
||||
|
||||
const hubs = useMemo(() => {
|
||||
return budgets.map((b) => {
|
||||
const start = periodStartFor(b.frequency);
|
||||
const spent = invoices
|
||||
.filter((inv) => {
|
||||
if (!inv.cost_center || inv.cost_center !== b.cost_center) return false;
|
||||
const d = safeParse(inv.issue_date);
|
||||
return d && isAfter(d, start);
|
||||
})
|
||||
.reduce((sum, inv) => sum + (inv.amount || 0), 0);
|
||||
const utilization = b.amount > 0 ? Math.min(100, (spent / b.amount) * 100) : 0;
|
||||
const overThreshold = b.alert_enabled && utilization >= (b.alert_threshold || 100);
|
||||
return { ...b, spent, utilization, overThreshold };
|
||||
});
|
||||
}, [budgets, invoices]);
|
||||
|
||||
const alertHubs = hubs.filter((h) => h.overThreshold);
|
||||
|
||||
const handleSave = () => {
|
||||
if (!form.hub_name || !form.cost_center || !form.amount) return;
|
||||
createMutation.mutate({
|
||||
hub_name: form.hub_name,
|
||||
cost_center: form.cost_center,
|
||||
amount: parseFloat(form.amount),
|
||||
frequency: form.frequency,
|
||||
alert_enabled: form.alert_enabled,
|
||||
alert_threshold: Number(form.alert_threshold),
|
||||
business_id: user?.id,
|
||||
business_name: user?.full_name,
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<RefreshCw className="w-5 h-5 text-slate-400 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-green-100 rounded-xl flex items-center justify-center">
|
||||
<Wallet className="w-6 h-6 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-slate-900">Your Staffing Budget</h2>
|
||||
<p className="text-sm text-slate-500">Cost-effective staffing solutions</p>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="bg-[#0A39DF] hover:bg-[#002a7d]">
|
||||
<Plus className="w-4 h-4 mr-2" /> Add a new Hub
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add a new Budget Hub</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div>
|
||||
<Label className="text-sm font-medium">Hub Name</Label>
|
||||
<Input
|
||||
className="mt-1.5"
|
||||
placeholder="e.g. Sunnyvale Kitchen"
|
||||
value={form.hub_name}
|
||||
onChange={(e) => setForm({ ...form, hub_name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm font-medium">Cost Center</Label>
|
||||
<Input
|
||||
className="mt-1.5"
|
||||
placeholder="e.g. CC-1001"
|
||||
value={form.cost_center}
|
||||
onChange={(e) => setForm({ ...form, cost_center: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-sm font-medium">Budget Amount ($)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
className="mt-1.5"
|
||||
placeholder="5000"
|
||||
value={form.amount}
|
||||
onChange={(e) => setForm({ ...form, amount: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm font-medium">Frequency</Label>
|
||||
<Select value={form.frequency} onValueChange={(v) => setForm({ ...form, frequency: v })}>
|
||||
<SelectTrigger className="mt-1.5"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Daily">Daily</SelectItem>
|
||||
<SelectItem value="Weekly">Weekly</SelectItem>
|
||||
<SelectItem value="Monthly">Monthly</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-slate-200 p-3 space-y-3 bg-slate-50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-800">Alert when usage approaches limit</p>
|
||||
<p className="text-xs text-slate-500">Get notified when utilization hits your threshold.</p>
|
||||
</div>
|
||||
<Switch checked={form.alert_enabled} onCheckedChange={(v) => setForm({ ...form, alert_enabled: v })} />
|
||||
</div>
|
||||
{form.alert_enabled && (
|
||||
<div>
|
||||
<Label className="text-sm font-medium">Alert threshold (%)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
className="mt-1.5"
|
||||
value={form.alert_threshold}
|
||||
onChange={(e) => setForm({ ...form, alert_threshold: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleSave} disabled={createMutation.isPending || !form.hub_name || !form.cost_center || !form.amount}>
|
||||
{createMutation.isPending ? "Saving..." : "Save Hub"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{/* Alert banner */}
|
||||
{alertHubs.length > 0 && (
|
||||
<Card className="border-2 border-amber-300 bg-amber-50">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-600 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-semibold text-amber-900">
|
||||
{alertHubs.length} hub{alertHubs.length > 1 ? "s" : ""} approaching or over budget limit
|
||||
</p>
|
||||
<p className="text-sm text-amber-700 mt-0.5">
|
||||
{alertHubs.map((h) => h.hub_name).join(", ")} — review usage to avoid overruns.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{hubs.length === 0 ? (
|
||||
<div className="rounded-xl border border-slate-200 bg-[#f9fafb] py-16 px-6 text-center">
|
||||
<div className="w-14 h-14 mx-auto rounded-full bg-slate-200 flex items-center justify-center mb-4">
|
||||
<Building2 className="w-7 h-7 text-slate-500" />
|
||||
</div>
|
||||
<p className="text-lg font-bold text-[#42526e]">Budget not configured</p>
|
||||
<p className="text-sm text-[#5e6c84] mt-1 max-w-md mx-auto">
|
||||
No client report budget allocation matches the selected range and filter scope.
|
||||
</p>
|
||||
<Button className="mt-5 bg-[#0A39DF] hover:bg-[#002a7d]" onClick={() => setOpen(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" /> Add a new Hub
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{hubs.map((hub) => {
|
||||
const status = hub.utilization >= 100 ? "over" : hub.utilization >= (hub.alert_threshold || 80) ? "near" : "ok";
|
||||
return (
|
||||
<Card key={hub.id} className={status === "over" ? "border-2 border-red-300" : status === "near" ? "border-2 border-amber-300" : ""}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Building2 className="w-4 h-4 text-slate-500" />
|
||||
{hub.hub_name}
|
||||
</CardTitle>
|
||||
<p className="text-xs text-slate-500 mt-1">Cost Center: {hub.cost_center} · {hub.frequency}</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-red-600" onClick={() => deleteMutation.mutate(hub.id)}>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-end justify-between mb-2">
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-slate-900">${hub.spent.toLocaleString()}</p>
|
||||
<p className="text-xs text-slate-500">spent of ${hub.amount.toLocaleString()}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className={`text-2xl font-bold ${status === "over" ? "text-red-600" : status === "near" ? "text-amber-600" : "text-green-600"}`}>
|
||||
{hub.utilization.toFixed(0)}%
|
||||
</p>
|
||||
{hub.alert_enabled && (
|
||||
<Badge className={`text-[10px] ${status === "over" ? "bg-red-100 text-red-700" : status === "near" ? "bg-amber-100 text-amber-700" : "bg-slate-100 text-slate-600"}`}>
|
||||
alert @ {hub.alert_threshold}%
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Progress
|
||||
value={hub.utilization}
|
||||
className={`h-2 ${status === "over" ? "[&>div]:bg-red-500" : status === "near" ? "[&>div]:bg-amber-500" : "[&>div]:bg-green-500"}`}
|
||||
/>
|
||||
{status !== "ok" && (
|
||||
<p className={`text-xs mt-2 flex items-center gap-1 ${status === "over" ? "text-red-600" : "text-amber-600"}`}>
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
{status === "over" ? "Over budget limit" : "Approaching budget limit"}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
273
src/components/business/BusinessCard.jsx
Normal file
273
src/components/business/BusinessCard.jsx
Normal file
@@ -0,0 +1,273 @@
|
||||
import React from "react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Building2, MapPin, Briefcase, Phone, Mail, TrendingUp, Clock, Award, Users, Eye, Edit2, DollarSign } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { createPageUrl } from "@/utils";
|
||||
|
||||
export default function BusinessCard({ company, metrics, isListView = false, onView, onEdit }) {
|
||||
const { companyName, logo, sector, monthlySpend, totalStaff, location, serviceType, phone, email, technology, performance, gradeColor, clientGrade, isActive, lastOrderDate, rateCard, businessId } = company;
|
||||
|
||||
if (isListView) {
|
||||
return (
|
||||
<Card className="border-slate-200 shadow-sm hover:shadow-md transition-all">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Logo */}
|
||||
<div className="flex-shrink-0">
|
||||
{logo ? (
|
||||
<img src={logo} alt={companyName} className="w-16 h-16 rounded-lg object-cover border-2 border-slate-200" />
|
||||
) : (
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-blue-700 rounded-lg flex items-center justify-center">
|
||||
<Building2 className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Company Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="text-lg font-bold text-slate-900 truncate">{companyName}</h3>
|
||||
<div className={`px-3 py-1 ${gradeColor} rounded-lg font-bold text-white text-sm`}>
|
||||
{clientGrade}
|
||||
</div>
|
||||
{isActive === false && (
|
||||
<Badge className="bg-slate-300 text-slate-700 text-xs">Inactive</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-slate-600 mb-2">{serviceType}</p>
|
||||
<div className="flex items-center gap-4 text-xs text-slate-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<MapPin className="w-3 h-3" />
|
||||
{location}
|
||||
</span>
|
||||
<span>Monthly: ${(monthlySpend / 1000).toFixed(0)}k</span>
|
||||
<span>{totalStaff} Staff</span>
|
||||
{lastOrderDate && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
Last: {new Date(lastOrderDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* All Performance Metrics */}
|
||||
<div className="flex items-center gap-4 flex-shrink-0">
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-slate-500 mb-1">Cancellations</p>
|
||||
<p className="text-lg font-bold text-orange-700">{performance.cancelRate}%</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-slate-500 mb-1">On-Time</p>
|
||||
<p className="text-lg font-bold text-blue-700">{performance.onTimeRate}%</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-slate-500 mb-1">Rapid Orders</p>
|
||||
<p className="text-lg font-bold text-purple-700">{performance.rapidOrders}</p>
|
||||
</div>
|
||||
<div className="text-center min-w-[80px]">
|
||||
<p className="text-xs text-slate-500 mb-1">Main Position</p>
|
||||
<p className="text-sm font-bold text-green-700 truncate">{performance.mainPosition}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rate Card Badge */}
|
||||
{rateCard && (
|
||||
<div className="flex-shrink-0">
|
||||
<Link to={`${createPageUrl("EditBusiness")}?id=${businessId}&tab=services`}>
|
||||
<Badge className="bg-emerald-100 text-emerald-700 border border-emerald-200 hover:bg-emerald-200 cursor-pointer px-3 py-1.5">
|
||||
<DollarSign className="w-3 h-3 mr-1 inline" />
|
||||
{rateCard}
|
||||
</Badge>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<Button variant="outline" size="sm" onClick={onView} className="border-blue-600 text-blue-600 hover:bg-blue-50">
|
||||
<Eye className="w-4 h-4 mr-1" />
|
||||
View
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onEdit} className="border-slate-300 hover:bg-slate-50">
|
||||
<Edit2 className="w-4 h-4 mr-1" />
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="border-slate-200 shadow-lg hover:shadow-xl transition-all overflow-hidden">
|
||||
{/* Header - Blue Gradient */}
|
||||
<div className="bg-gradient-to-br from-blue-600 to-blue-800 p-6 relative">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-4">
|
||||
{logo ? (
|
||||
<img src={logo} alt={companyName} className="w-16 h-16 rounded-xl object-cover border-2 border-white shadow-md bg-white p-1" />
|
||||
) : (
|
||||
<div className="w-16 h-16 bg-white rounded-xl flex items-center justify-center shadow-md">
|
||||
<Building2 className="w-8 h-8 text-blue-600" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold text-white mb-1">{companyName}</h3>
|
||||
<Badge className="bg-white/20 text-white border-white/40 text-xs">
|
||||
# N/A
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`px-4 py-2 ${gradeColor} rounded-xl font-bold text-2xl text-white shadow-lg`}>
|
||||
{clientGrade}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-blue-100 text-sm font-medium">{serviceType}</p>
|
||||
|
||||
{/* Metrics Row */}
|
||||
<div className="grid grid-cols-3 gap-3 mt-4">
|
||||
<div className="bg-white/10 backdrop-blur-sm rounded-lg p-4">
|
||||
<p className="text-blue-200 text-xs font-medium mb-1">Monthly Sales</p>
|
||||
<p className="text-white text-2xl font-bold">${(monthlySpend / 1000).toFixed(0)}k</p>
|
||||
</div>
|
||||
<div className="bg-white/10 backdrop-blur-sm rounded-lg p-4">
|
||||
<p className="text-blue-200 text-xs font-medium mb-1">Total Staff</p>
|
||||
<p className="text-white text-2xl font-bold">{totalStaff}</p>
|
||||
</div>
|
||||
<div className="bg-white/10 backdrop-blur-sm rounded-lg p-4">
|
||||
<p className="text-blue-200 text-xs font-medium mb-1">Last Order</p>
|
||||
<p className="text-white text-lg font-bold">
|
||||
{lastOrderDate
|
||||
? new Date(lastOrderDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
: 'None'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<CardContent className="p-6 bg-slate-50">
|
||||
{/* Company Information */}
|
||||
<div className="mb-6">
|
||||
<h4 className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-3">COMPANY INFORMATION</h4>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<MapPin className="w-5 h-5 text-slate-400 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-semibold text-slate-900">{location}</p>
|
||||
<p className="text-sm text-slate-500">California</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<Briefcase className="w-5 h-5 text-slate-400 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-semibold text-slate-900">{serviceType}</p>
|
||||
<p className="text-sm text-slate-500">Primary Service</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<Phone className="w-5 h-5 text-slate-400 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-slate-900">{phone}</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<Mail className="w-5 h-5 text-slate-400 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-slate-900 break-all">{email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rate Card & Technology */}
|
||||
<div className="mb-6">
|
||||
<h4 className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-3">RATE CARD & TECHNOLOGY</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{rateCard ? (
|
||||
<Link to={`${createPageUrl("EditBusiness")}?id=${businessId}&tab=services`}>
|
||||
<Badge className="bg-emerald-50 text-emerald-700 border border-emerald-200 text-xs cursor-pointer hover:bg-emerald-100">
|
||||
<DollarSign className="w-3 h-3 mr-1 inline" />
|
||||
{rateCard}
|
||||
</Badge>
|
||||
</Link>
|
||||
) : (
|
||||
<Badge className="bg-slate-100 text-slate-500 border border-slate-200 text-xs">
|
||||
No Rate Card
|
||||
</Badge>
|
||||
)}
|
||||
{technology?.isUsingKROW ? (
|
||||
<Badge className="bg-blue-50 text-blue-700 border border-blue-200 text-xs">
|
||||
Using KROW
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="bg-amber-50 text-amber-700 border border-amber-200 text-xs cursor-pointer hover:bg-amber-100">
|
||||
Invite to KROW
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Performance Metrics */}
|
||||
<div className="mb-4">
|
||||
<h4 className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-3">PERFORMANCE METRICS</h4>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Cancellation Rate */}
|
||||
<div className="bg-white rounded-lg p-4 border border-slate-200">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<TrendingUp className="w-4 h-4 text-orange-600" />
|
||||
<p className="text-xs font-semibold text-slate-600">Cancellations</p>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-orange-600 mb-2">{performance.cancelRate}%</p>
|
||||
<div className="w-full h-2 bg-orange-100 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-orange-600 rounded-full transition-all" style={{ width: `${performance.cancelRate}%` }}></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* On-Time Ordering */}
|
||||
<div className="bg-white rounded-lg p-4 border border-slate-200">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Clock className="w-4 h-4 text-blue-600" />
|
||||
<p className="text-xs font-semibold text-slate-600">On-Time Order</p>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-blue-600 mb-2">{performance.onTimeRate}%</p>
|
||||
<div className="w-full h-2 bg-blue-100 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-blue-600 rounded-full transition-all" style={{ width: `${performance.onTimeRate}%` }}></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rapid Orders */}
|
||||
<div className="bg-white rounded-lg p-4 border border-slate-200">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Award className="w-4 h-4 text-purple-600" />
|
||||
<p className="text-xs font-semibold text-slate-600">Rapid Orders</p>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-purple-600 mb-2">{performance.rapidOrders}</p>
|
||||
<p className="text-xs text-slate-500">Last 30 days</p>
|
||||
</div>
|
||||
|
||||
{/* Main Position */}
|
||||
<div className="bg-white rounded-lg p-4 border border-slate-200">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Users className="w-4 h-4 text-green-600" />
|
||||
<p className="text-xs font-semibold text-slate-600">Main Position</p>
|
||||
</div>
|
||||
<p className="text-lg font-bold text-green-600">{performance.mainPosition}</p>
|
||||
<p className="text-xs text-slate-500">Most requested</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<button
|
||||
onClick={onView}
|
||||
className="w-full bg-blue-50 hover:bg-blue-100 rounded-lg p-3 text-center transition-colors cursor-pointer"
|
||||
>
|
||||
<p className="text-xs text-blue-700 font-medium">
|
||||
Click to view full profile and detailed analytics
|
||||
</p>
|
||||
</button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
292
src/components/business/CreateBusinessModal.jsx
Normal file
292
src/components/business/CreateBusinessModal.jsx
Normal file
@@ -0,0 +1,292 @@
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Save, X } from "lucide-react";
|
||||
|
||||
export default function CreateBusinessModal({ open, onOpenChange, onSubmit, isSubmitting }) {
|
||||
const [formData, setFormData] = useState({
|
||||
business_name: "",
|
||||
company_logo: "", // Added company_logo field
|
||||
contact_name: "",
|
||||
phone: "",
|
||||
email: "",
|
||||
hub_building: "",
|
||||
address: "",
|
||||
city: "",
|
||||
area: "Bay Area",
|
||||
sector: "",
|
||||
rate_group: "",
|
||||
status: "Active",
|
||||
notes: ""
|
||||
});
|
||||
|
||||
const handleChange = (field, value) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
onSubmit(formData);
|
||||
// Reset form after submission
|
||||
setFormData({
|
||||
business_name: "",
|
||||
company_logo: "", // Reset company_logo field
|
||||
contact_name: "",
|
||||
phone: "",
|
||||
email: "",
|
||||
hub_building: "",
|
||||
address: "",
|
||||
city: "",
|
||||
area: "Bay Area",
|
||||
sector: "",
|
||||
rate_group: "",
|
||||
status: "Active",
|
||||
notes: ""
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl max-h-[90dvh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-bold text-[#1C323E]">
|
||||
Create New Business
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6 mt-4">
|
||||
{/* Business Name & Company Logo */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="business_name" className="text-slate-700 font-medium">
|
||||
Business Name <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="business_name"
|
||||
value={formData.business_name}
|
||||
onChange={(e) => handleChange('business_name', e.target.value)}
|
||||
placeholder="Enter business name"
|
||||
required
|
||||
className="border-slate-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="company_logo" className="text-slate-700 font-medium">
|
||||
Company Logo URL
|
||||
</Label>
|
||||
<Input
|
||||
id="company_logo"
|
||||
value={formData.company_logo}
|
||||
onChange={(e) => handleChange('company_logo', e.target.value)}
|
||||
placeholder="https://example.com/logo.png"
|
||||
className="border-slate-300"
|
||||
/>
|
||||
<p className="text-xs text-slate-500">Optional: URL to company logo image</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Primary Contact (Moved from first grid to its own section) */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="contact_name" className="text-slate-700 font-medium">
|
||||
Primary Contact <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="contact_name"
|
||||
value={formData.contact_name}
|
||||
onChange={(e) => handleChange('contact_name', e.target.value)}
|
||||
placeholder="Contact name"
|
||||
required
|
||||
className="border-slate-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Contact Number & Email */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone" className="text-slate-700 font-medium">
|
||||
Contact Number
|
||||
</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
type="tel"
|
||||
value={formData.phone}
|
||||
onChange={(e) => handleChange('phone', e.target.value)}
|
||||
placeholder="(555) 123-4567"
|
||||
className="border-slate-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email" className="text-slate-700 font-medium">
|
||||
Email
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => handleChange('email', e.target.value)}
|
||||
placeholder="business@example.com"
|
||||
className="border-slate-300"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hub / Building */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="hub_building" className="text-slate-700 font-medium">
|
||||
Hub / Building
|
||||
</Label>
|
||||
<Input
|
||||
id="hub_building"
|
||||
value={formData.hub_building}
|
||||
onChange={(e) => handleChange('hub_building', e.target.value)}
|
||||
placeholder="Building name or location"
|
||||
className="border-slate-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Address */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="address" className="text-slate-700 font-medium">
|
||||
Address
|
||||
</Label>
|
||||
<Input
|
||||
id="address"
|
||||
value={formData.address}
|
||||
onChange={(e) => handleChange('address', e.target.value)}
|
||||
placeholder="Street address"
|
||||
className="border-slate-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* City & Area */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="city" className="text-slate-700 font-medium">
|
||||
City
|
||||
</Label>
|
||||
<Input
|
||||
id="city"
|
||||
value={formData.city}
|
||||
onChange={(e) => handleChange('city', e.target.value)}
|
||||
placeholder="City"
|
||||
className="border-slate-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="area" className="text-slate-700 font-medium">
|
||||
Area
|
||||
</Label>
|
||||
<Select value={formData.area} onValueChange={(value) => handleChange('area', value)}>
|
||||
<SelectTrigger className="border-slate-300">
|
||||
<SelectValue placeholder="Select area" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Bay Area">Bay Area</SelectItem>
|
||||
<SelectItem value="Southern California">Southern California</SelectItem>
|
||||
<SelectItem value="Northern California">Northern California</SelectItem>
|
||||
<SelectItem value="Central Valley">Central Valley</SelectItem>
|
||||
<SelectItem value="Other">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sector & Rate Group */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sector" className="text-slate-700 font-medium">
|
||||
Sector
|
||||
</Label>
|
||||
<Select value={formData.sector} onValueChange={(value) => handleChange('sector', value)}>
|
||||
<SelectTrigger className="border-slate-300">
|
||||
<SelectValue placeholder="Select sector" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Bon Appétit">Bon Appétit</SelectItem>
|
||||
<SelectItem value="Eurest">Eurest</SelectItem>
|
||||
<SelectItem value="Aramark">Aramark</SelectItem>
|
||||
<SelectItem value="Epicurean Group">Epicurean Group</SelectItem>
|
||||
<SelectItem value="Chartwells">Chartwells</SelectItem>
|
||||
<SelectItem value="Other">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rate_group" className="text-slate-700 font-medium">
|
||||
Rate Group <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select value={formData.rate_group} onValueChange={(value) => handleChange('rate_group', value)} required>
|
||||
<SelectTrigger className="border-slate-300">
|
||||
<SelectValue placeholder="Select pricing tier" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Standard">Standard</SelectItem>
|
||||
<SelectItem value="Premium">Premium</SelectItem>
|
||||
<SelectItem value="Enterprise">Enterprise</SelectItem>
|
||||
<SelectItem value="Custom">Custom</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="status" className="text-slate-700 font-medium">
|
||||
Status
|
||||
</Label>
|
||||
<Select value={formData.status} onValueChange={(value) => handleChange('status', value)}>
|
||||
<SelectTrigger className="border-slate-300">
|
||||
<SelectValue placeholder="Select status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Active">Active</SelectItem>
|
||||
<SelectItem value="Inactive">Inactive</SelectItem>
|
||||
<SelectItem value="Pending">Pending</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-slate-200">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="border-slate-300"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="bg-[#0A39DF] hover:bg-[#0A39DF]/90 text-white"
|
||||
>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{isSubmitting ? "Creating..." : "Create Business"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
256
src/components/business/ERPSettingsTab.jsx
Normal file
256
src/components/business/ERPSettingsTab.jsx
Normal file
@@ -0,0 +1,256 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Save, Loader2, Link2, Building2, FileText, Mail, CheckCircle, AlertCircle } from "lucide-react";
|
||||
|
||||
const ERP_SYSTEMS = [
|
||||
{ value: "None", label: "No ERP Integration" },
|
||||
{ value: "SAP Ariba", label: "SAP Ariba" },
|
||||
{ value: "Fieldglass", label: "SAP Fieldglass" },
|
||||
{ value: "CrunchTime", label: "CrunchTime" },
|
||||
{ value: "Coupa", label: "Coupa" },
|
||||
{ value: "Oracle NetSuite", label: "Oracle NetSuite" },
|
||||
{ value: "Workday", label: "Workday" },
|
||||
{ value: "Other", label: "Other" },
|
||||
];
|
||||
|
||||
const EDI_FORMATS = [
|
||||
{ value: "CSV", label: "CSV (Excel Compatible)" },
|
||||
{ value: "EDI 810", label: "EDI 810 (Standard Invoice)" },
|
||||
{ value: "cXML", label: "cXML (Ariba/Coupa)" },
|
||||
{ value: "JSON", label: "JSON (API Format)" },
|
||||
{ value: "Custom", label: "Custom Template" },
|
||||
];
|
||||
|
||||
export default function ERPSettingsTab({ business, onSave, isSaving }) {
|
||||
const [settings, setSettings] = useState({
|
||||
erp_system: business?.erp_system || "None",
|
||||
erp_vendor_id: business?.erp_vendor_id || "",
|
||||
erp_cost_center: business?.erp_cost_center || "",
|
||||
edi_enabled: business?.edi_enabled || false,
|
||||
edi_format: business?.edi_format || "CSV",
|
||||
invoice_email: business?.invoice_email || "",
|
||||
po_required: business?.po_required || false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (business) {
|
||||
setSettings({
|
||||
erp_system: business.erp_system || "None",
|
||||
erp_vendor_id: business.erp_vendor_id || "",
|
||||
erp_cost_center: business.erp_cost_center || "",
|
||||
edi_enabled: business.edi_enabled || false,
|
||||
edi_format: business.edi_format || "CSV",
|
||||
invoice_email: business.invoice_email || "",
|
||||
po_required: business.po_required || false,
|
||||
});
|
||||
}
|
||||
}, [business]);
|
||||
|
||||
const handleChange = (field, value) => {
|
||||
setSettings(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
onSave(settings);
|
||||
};
|
||||
|
||||
const isConfigured = settings.erp_system !== "None" && settings.erp_vendor_id;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Status Card */}
|
||||
<Card className={`border-2 ${isConfigured ? 'border-green-200 bg-green-50' : 'border-amber-200 bg-amber-50'}`}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{isConfigured ? (
|
||||
<CheckCircle className="w-6 h-6 text-green-600" />
|
||||
) : (
|
||||
<AlertCircle className="w-6 h-6 text-amber-600" />
|
||||
)}
|
||||
<div>
|
||||
<p className={`font-semibold ${isConfigured ? 'text-green-900' : 'text-amber-900'}`}>
|
||||
{isConfigured ? 'ERP Integration Active' : 'ERP Integration Not Configured'}
|
||||
</p>
|
||||
<p className={`text-sm ${isConfigured ? 'text-green-700' : 'text-amber-700'}`}>
|
||||
{isConfigured
|
||||
? `Connected to ${settings.erp_system} • Vendor ID: ${settings.erp_vendor_id}`
|
||||
: 'Configure ERP settings to enable automated invoice delivery'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ERP System Settings */}
|
||||
<Card className="border-slate-200 shadow-lg">
|
||||
<CardHeader className="bg-slate-50 border-b border-slate-200">
|
||||
<CardTitle className="flex items-center gap-2 text-slate-900">
|
||||
<Building2 className="w-5 h-5 text-blue-600" />
|
||||
ERP System Configuration
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6 space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-slate-700 font-medium">ERP System</Label>
|
||||
<Select
|
||||
value={settings.erp_system}
|
||||
onValueChange={(value) => handleChange('erp_system', value)}
|
||||
>
|
||||
<SelectTrigger className="border-slate-200">
|
||||
<SelectValue placeholder="Select ERP system" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ERP_SYSTEMS.map(erp => (
|
||||
<SelectItem key={erp.value} value={erp.value}>{erp.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-slate-500">Client's procurement or ERP system</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-slate-700 font-medium">Vendor ID in ERP</Label>
|
||||
<Input
|
||||
value={settings.erp_vendor_id}
|
||||
onChange={(e) => handleChange('erp_vendor_id', e.target.value)}
|
||||
placeholder="e.g., VND-12345"
|
||||
className="border-slate-200"
|
||||
/>
|
||||
<p className="text-xs text-slate-500">Your vendor identifier in client's system</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-slate-700 font-medium">Default Cost Center</Label>
|
||||
<Input
|
||||
value={settings.erp_cost_center}
|
||||
onChange={(e) => handleChange('erp_cost_center', e.target.value)}
|
||||
placeholder="e.g., CC-1001"
|
||||
className="border-slate-200"
|
||||
/>
|
||||
<p className="text-xs text-slate-500">Default cost center for invoice allocation</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-slate-700 font-medium">Invoice Delivery Email</Label>
|
||||
<Input
|
||||
type="email"
|
||||
value={settings.invoice_email}
|
||||
onChange={(e) => handleChange('invoice_email', e.target.value)}
|
||||
placeholder="ap@client.com"
|
||||
className="border-slate-200"
|
||||
/>
|
||||
<p className="text-xs text-slate-500">Accounts payable email for invoice delivery</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* EDI Settings */}
|
||||
<Card className="border-slate-200 shadow-lg">
|
||||
<CardHeader className="bg-slate-50 border-b border-slate-200">
|
||||
<CardTitle className="flex items-center gap-2 text-slate-900">
|
||||
<Link2 className="w-5 h-5 text-purple-600" />
|
||||
EDI / Export Settings
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between p-4 bg-slate-50 rounded-lg">
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">Enable EDI Integration</p>
|
||||
<p className="text-sm text-slate-500">Automatically format invoices for EDI transmission</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.edi_enabled}
|
||||
onCheckedChange={(checked) => handleChange('edi_enabled', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-slate-700 font-medium">Preferred Export Format</Label>
|
||||
<Select
|
||||
value={settings.edi_format}
|
||||
onValueChange={(value) => handleChange('edi_format', value)}
|
||||
>
|
||||
<SelectTrigger className="border-slate-200">
|
||||
<SelectValue placeholder="Select format" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{EDI_FORMATS.map(format => (
|
||||
<SelectItem key={format.value} value={format.value}>{format.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-slate-500">Default format for invoice exports</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-4 bg-slate-50 rounded-lg h-fit">
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">PO Required</p>
|
||||
<p className="text-sm text-slate-500">Require PO number on invoices</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.po_required}
|
||||
onCheckedChange={(checked) => handleChange('po_required', checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Format Info */}
|
||||
<div className="p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<h4 className="font-semibold text-blue-900 mb-2 flex items-center gap-2">
|
||||
<FileText className="w-4 h-4" />
|
||||
Supported Formats
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className="bg-green-100 text-green-700">EDI 810</Badge>
|
||||
<span className="text-blue-700">Standard</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className="bg-purple-100 text-purple-700">cXML</Badge>
|
||||
<span className="text-blue-700">Ariba/Coupa</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className="bg-orange-100 text-orange-700">CSV</Badge>
|
||||
<span className="text-blue-700">Excel</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className="bg-slate-100 text-slate-700">JSON</Badge>
|
||||
<span className="text-blue-700">API</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSaving}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-8"
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
Save ERP Settings
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
322
src/components/chat/ChatBubble.jsx
Normal file
322
src/components/chat/ChatBubble.jsx
Normal file
@@ -0,0 +1,322 @@
|
||||
import React, { useState } from "react";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { MessageSquare, X, Send, Minimize2, Maximize2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { format } from "date-fns";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { createPageUrl } from "@/utils";
|
||||
|
||||
export default function ChatBubble() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isMinimized, setIsMinimized] = useState(false);
|
||||
const [selectedConv, setSelectedConv] = useState(null);
|
||||
const [messageInput, setMessageInput] = useState("");
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data: user } = useQuery({
|
||||
queryKey: ['current-user'],
|
||||
queryFn: () => base44.auth.me(),
|
||||
});
|
||||
|
||||
const { data: conversations, refetch: refetchConversations } = useQuery({
|
||||
queryKey: ['conversations-bubble'],
|
||||
queryFn: () => base44.entities.Conversation.list('-last_message_at', 5),
|
||||
initialData: [],
|
||||
refetchInterval: 10000, // Refresh every 10 seconds
|
||||
});
|
||||
|
||||
const { data: messages, refetch: refetchMessages } = useQuery({
|
||||
queryKey: ['messages-bubble', selectedConv?.id],
|
||||
queryFn: () => base44.entities.Message.filter({ conversation_id: selectedConv?.id }),
|
||||
initialData: [],
|
||||
enabled: !!selectedConv?.id,
|
||||
refetchInterval: 5000, // Refresh every 5 seconds when viewing
|
||||
});
|
||||
|
||||
const totalUnread = conversations.reduce((sum, conv) => sum + (conv.unread_count || 0), 0);
|
||||
|
||||
const handleSendMessage = async () => {
|
||||
if (!messageInput.trim() || !selectedConv) return;
|
||||
|
||||
await base44.entities.Message.create({
|
||||
conversation_id: selectedConv.id,
|
||||
sender_id: user.id,
|
||||
sender_name: user.full_name || user.email,
|
||||
sender_role: user.role || "admin",
|
||||
content: messageInput.trim(),
|
||||
read_by: [user.id]
|
||||
});
|
||||
|
||||
await base44.entities.Conversation.update(selectedConv.id, {
|
||||
last_message: messageInput.trim().substring(0, 100),
|
||||
last_message_at: new Date().toISOString()
|
||||
});
|
||||
|
||||
setMessageInput("");
|
||||
refetchMessages();
|
||||
refetchConversations();
|
||||
};
|
||||
|
||||
const getRoleColor = (role) => {
|
||||
const colors = {
|
||||
client: "bg-purple-100 text-purple-700",
|
||||
vendor: "bg-amber-100 text-amber-700",
|
||||
staff: "bg-blue-100 text-blue-700",
|
||||
admin: "bg-slate-100 text-slate-700"
|
||||
};
|
||||
return colors[role] || "bg-slate-100 text-slate-700";
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Chat Bubble Button */}
|
||||
<AnimatePresence>
|
||||
{!isOpen && (
|
||||
<motion.div
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0, opacity: 0 }}
|
||||
className="krow-dock-2 z-30"
|
||||
>
|
||||
<Button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="w-14 h-14 rounded-full bg-gradient-to-br from-[#0A39DF] to-[#1C323E] hover:from-[#0A39DF]/90 hover:to-[#1C323E]/90 shadow-2xl relative"
|
||||
>
|
||||
<MessageSquare className="w-7 h-7 text-white" />
|
||||
{totalUnread > 0 && (
|
||||
<Badge className="absolute -top-2 -right-2 bg-red-500 text-white px-2 py-0.5 text-xs">
|
||||
{totalUnread > 9 ? '9+' : totalUnread}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Chat Window */}
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ scale: 0, opacity: 0, y: 100 }}
|
||||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||
exit={{ scale: 0, opacity: 0, y: 100 }}
|
||||
className="fixed z-50 inset-x-3 sm:inset-x-auto sm:right-6 sm:w-[400px]"
|
||||
style={{
|
||||
bottom: 'var(--krow-dock-base)',
|
||||
maxWidth: 'calc(100vw - 1.5rem)',
|
||||
height: isMinimized ? 'auto' : 'min(600px, calc(100dvh - 5rem))',
|
||||
}}
|
||||
>
|
||||
<Card className="shadow-2xl border-2 border-slate-200 overflow-hidden h-full flex flex-col">
|
||||
{/* Header */}
|
||||
<CardHeader className="bg-gradient-to-br from-[#0A39DF] to-[#1C323E] text-white p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-white rounded-full flex items-center justify-center">
|
||||
<MessageSquare className="w-5 h-5 text-[#0A39DF]" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-white text-base">
|
||||
{selectedConv ? selectedConv.subject : 'Messages'}
|
||||
</CardTitle>
|
||||
{selectedConv && (
|
||||
<p className="text-xs text-white/80">
|
||||
{selectedConv.is_group
|
||||
? `${selectedConv.participants?.length || 0} members`
|
||||
: selectedConv.participants?.[1]?.name || 'Chat'}
|
||||
</p>
|
||||
)}
|
||||
{!selectedConv && (
|
||||
<p className="text-xs text-white/80">
|
||||
{totalUnread > 0 ? `${totalUnread} unread` : 'Online'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedConv && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-white hover:bg-white/20"
|
||||
onClick={() => setSelectedConv(null)}
|
||||
>
|
||||
←
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-white hover:bg-white/20"
|
||||
onClick={() => setIsMinimized(!isMinimized)}
|
||||
>
|
||||
{isMinimized ? <Maximize2 className="w-4 h-4" /> : <Minimize2 className="w-4 h-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-white hover:bg-white/20"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{!isMinimized && (
|
||||
<CardContent className="p-0 flex-1 flex flex-col">
|
||||
{!selectedConv ? (
|
||||
<>
|
||||
{/* Conversations List */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-4 space-y-2">
|
||||
{conversations.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<MessageSquare className="w-12 h-12 mx-auto text-slate-300 mb-3" />
|
||||
<p className="text-slate-500 text-sm">No conversations yet</p>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
navigate(createPageUrl("Messages"));
|
||||
}}
|
||||
className="mt-4 bg-[#0A39DF] hover:bg-[#0A39DF]/90"
|
||||
size="sm"
|
||||
>
|
||||
Start a Conversation
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
conversations.map((conv) => {
|
||||
const otherParticipant = conv.participants?.[1] || conv.participants?.[0] || {};
|
||||
return (
|
||||
<Card
|
||||
key={conv.id}
|
||||
className="cursor-pointer hover:bg-slate-50 transition-all border border-slate-200"
|
||||
onClick={() => setSelectedConv(conv)}
|
||||
>
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<Avatar className="w-10 h-10 flex-shrink-0">
|
||||
<AvatarFallback className={conv.is_group ? "bg-purple-500 text-white" : "bg-[#0A39DF] text-white"}>
|
||||
{conv.is_group ? <MessageSquare className="w-5 h-5" /> : otherParticipant.name?.charAt(0) || '?'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<p className="font-semibold text-sm truncate">
|
||||
{conv.is_group ? conv.group_name : (conv.subject || otherParticipant.name)}
|
||||
</p>
|
||||
{conv.unread_count > 0 && (
|
||||
<Badge className="bg-red-500 text-white text-xs">
|
||||
{conv.unread_count}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 truncate">
|
||||
{conv.last_message || "No messages yet"}
|
||||
</p>
|
||||
{conv.last_message_at && (
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
{format(new Date(conv.last_message_at), "MMM d, h:mm a")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Quick Action */}
|
||||
<div className="border-t border-slate-200 p-3 bg-slate-50">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
navigate(createPageUrl("Messages"));
|
||||
}}
|
||||
className="w-full bg-[#0A39DF] hover:bg-[#0A39DF]/90"
|
||||
size="sm"
|
||||
>
|
||||
View All Messages
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Messages Thread */}
|
||||
<ScrollArea className="flex-1 p-4">
|
||||
<div className="space-y-3">
|
||||
{messages.map((message) => {
|
||||
const isOwnMessage = message.sender_id === user?.id || message.created_by === user?.id;
|
||||
return (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`flex ${isOwnMessage ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
<div className={`flex gap-2 max-w-[80%] ${isOwnMessage ? 'flex-row-reverse' : 'flex-row'}`}>
|
||||
<Avatar className="w-7 h-7 flex-shrink-0">
|
||||
<AvatarFallback className={`${getRoleColor(message.sender_role)} text-xs font-bold`}>
|
||||
{message.sender_name?.charAt(0) || '?'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className={`flex flex-col ${isOwnMessage ? 'items-end' : 'items-start'}`}>
|
||||
<div className={`px-3 py-2 rounded-lg ${
|
||||
isOwnMessage
|
||||
? 'bg-[#0A39DF] text-white'
|
||||
: 'bg-slate-100 text-slate-900'
|
||||
}`}>
|
||||
<p className="text-sm">{message.content}</p>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
{message.created_date && format(new Date(message.created_date), "h:mm a")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Message Input */}
|
||||
<div className="border-t border-slate-200 p-3 bg-white">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Type a message..."
|
||||
value={messageInput}
|
||||
onChange={(e) => setMessageInput(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleSendMessage()}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSendMessage}
|
||||
disabled={!messageInput.trim()}
|
||||
className="bg-[#0A39DF] hover:bg-[#0A39DF]/90"
|
||||
size="icon"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
128
src/components/common/DragDropFileUpload.jsx
Normal file
128
src/components/common/DragDropFileUpload.jsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import React, { useState } from "react";
|
||||
import { Upload, FileText, Loader2, CheckCircle2 } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
export default function DragDropFileUpload({
|
||||
onFileSelect,
|
||||
accept = ".pdf,.jpg,.jpeg,.png,.doc,.docx",
|
||||
label,
|
||||
hint,
|
||||
uploading = false,
|
||||
uploaded = false,
|
||||
uploadedFileName = null,
|
||||
disabled = false
|
||||
}) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
const handleDragEnter = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!disabled) setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleDragOver = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handleDrop = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
|
||||
if (disabled) return;
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
if (files && files.length > 0) {
|
||||
onFileSelect(files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileInput = (e) => {
|
||||
const files = e.target.files;
|
||||
if (files && files.length > 0) {
|
||||
onFileSelect(files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-6 transition-all ${
|
||||
disabled
|
||||
? 'border-slate-200 bg-slate-50 cursor-not-allowed opacity-60'
|
||||
: isDragging
|
||||
? 'border-[#0A39DF] bg-blue-50 scale-[1.02]'
|
||||
: uploaded
|
||||
? 'border-green-300 bg-green-50/30 hover:border-green-400'
|
||||
: 'border-slate-300 hover:border-[#0A39DF] hover:bg-blue-50/30 cursor-pointer'
|
||||
}`}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{label && (
|
||||
<div className="mb-4">
|
||||
<h3 className="font-semibold text-lg flex items-center gap-2">
|
||||
<FileText className={`w-5 h-5 ${uploaded ? 'text-green-600' : 'text-[#0A39DF]'}`} />
|
||||
{label}
|
||||
</h3>
|
||||
{hint && <p className="text-sm text-slate-600 mt-1">{hint}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col items-center justify-center py-6">
|
||||
{uploading ? (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="w-12 h-12 animate-spin text-[#0A39DF]" />
|
||||
<p className="text-sm text-slate-600">Uploading and validating...</p>
|
||||
</div>
|
||||
) : uploaded ? (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<CheckCircle2 className="w-12 h-12 text-green-600" />
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium text-green-700">Document uploaded successfully</p>
|
||||
{uploadedFileName && (
|
||||
<p className="text-xs text-slate-500 mt-1">{uploadedFileName}</p>
|
||||
)}
|
||||
</div>
|
||||
<label htmlFor={`file-input-${label}`} className="text-xs text-blue-600 hover:underline cursor-pointer">
|
||||
Upload different file
|
||||
</label>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Upload className={`w-12 h-12 ${isDragging ? 'text-[#0A39DF] animate-bounce' : 'text-slate-400'}`} />
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium text-slate-700">
|
||||
{isDragging ? 'Drop file here' : 'Drag and drop your file here'}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 mt-1">or</p>
|
||||
<label htmlFor={`file-input-${label}`} className="text-sm text-[#0A39DF] hover:underline cursor-pointer font-medium">
|
||||
Browse files
|
||||
</label>
|
||||
<p className="text-xs text-slate-400 mt-2">
|
||||
Accepted: {accept.split(',').join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Input
|
||||
id={`file-input-${label}`}
|
||||
type="file"
|
||||
accept={accept}
|
||||
onChange={handleFileInput}
|
||||
disabled={disabled || uploading}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
72
src/components/common/GoogleAddressInput.jsx
Normal file
72
src/components/common/GoogleAddressInput.jsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import React, { useRef, useEffect, useState } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { MapPin } from "lucide-react";
|
||||
|
||||
export default function GoogleAddressInput({ value, onChange, placeholder = "Enter address...", className = "" }) {
|
||||
const inputRef = useRef(null);
|
||||
const autocompleteRef = useRef(null);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if Google Maps is already loaded
|
||||
if (window.google && window.google.maps && window.google.maps.places) {
|
||||
setIsLoaded(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Load Google Maps script
|
||||
const script = document.createElement('script');
|
||||
script.src = `https://maps.googleapis.com/maps/api/js?key=AIzaSyBkP7xH4NvR6C6vZ8Y3J7qX2QW8Z9vN3Zc&libraries=places`;
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
script.onload = () => setIsLoaded(true);
|
||||
document.head.appendChild(script);
|
||||
|
||||
return () => {
|
||||
if (script.parentNode) {
|
||||
script.parentNode.removeChild(script);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoaded || !inputRef.current) return;
|
||||
|
||||
try {
|
||||
// Initialize Google Maps Autocomplete
|
||||
autocompleteRef.current = new window.google.maps.places.Autocomplete(inputRef.current, {
|
||||
types: ['address'],
|
||||
componentRestrictions: { country: 'us' },
|
||||
});
|
||||
|
||||
// Handle place selection
|
||||
autocompleteRef.current.addListener('place_changed', () => {
|
||||
const place = autocompleteRef.current.getPlace();
|
||||
if (place.formatted_address) {
|
||||
onChange(place.formatted_address);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error initializing Google Maps autocomplete:', error);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (autocompleteRef.current) {
|
||||
window.google.maps.event.clearInstanceListeners(autocompleteRef.current);
|
||||
}
|
||||
};
|
||||
}, [isLoaded, onChange]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<MapPin className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className={`pl-10 ${className}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
src/components/common/PageHeader.jsx
Normal file
46
src/components/common/PageHeader.jsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { createPageUrl } from "@/utils";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export default function PageHeader({
|
||||
title,
|
||||
subtitle,
|
||||
actions = null,
|
||||
backTo = null,
|
||||
backButtonLabel = "Back"
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-8">
|
||||
{/* Back Button */}
|
||||
{backTo && (
|
||||
<Link to={backTo} className="inline-block mb-4">
|
||||
<Button variant="ghost" className="hover:bg-slate-100">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
{backButtonLabel}
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Main Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl md:text-4xl font-bold text-slate-900 tracking-tight mb-1">
|
||||
{title}
|
||||
</h1>
|
||||
{subtitle && (
|
||||
<p className="text-sm text-slate-400 font-medium">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Custom Actions (if provided) */}
|
||||
{actions && (
|
||||
<div className="flex items-center gap-2">
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
149
src/components/dailypl/BreakEvenEngine.jsx
Normal file
149
src/components/dailypl/BreakEvenEngine.jsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Calculator, TrendingUp, Calendar } from "lucide-react";
|
||||
|
||||
export const EXPENSE_CATEGORIES = [
|
||||
{ key: "rent", label: "Rent" },
|
||||
{ key: "salaries", label: "Salaries" },
|
||||
{ key: "software", label: "Software" },
|
||||
{ key: "insurance", label: "Insurance" },
|
||||
{ key: "vehicles", label: "Vehicles" },
|
||||
{ key: "marketing", label: "Marketing" },
|
||||
{ key: "office", label: "Office" },
|
||||
{ key: "miscellaneous", label: "Miscellaneous" },
|
||||
];
|
||||
|
||||
const fmt = (n) =>
|
||||
(n || 0).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
|
||||
export default function BreakEvenEngine({
|
||||
open,
|
||||
onClose,
|
||||
expenses,
|
||||
workingDays,
|
||||
onSave,
|
||||
}) {
|
||||
const [draft, setDraft] = useState(expenses);
|
||||
const [days, setDays] = useState(workingDays);
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(expenses);
|
||||
setDays(workingDays);
|
||||
}, [expenses, workingDays, open]);
|
||||
|
||||
const monthlyTotal = EXPENSE_CATEGORIES.reduce(
|
||||
(sum, c) => sum + (Number(draft[c.key]) || 0),
|
||||
0
|
||||
);
|
||||
const safeDays = Number(days) > 0 ? Number(days) : 1;
|
||||
const dailyTarget = monthlyTotal / safeDays;
|
||||
|
||||
const updateField = (key, value) =>
|
||||
setDraft((prev) => ({ ...prev, [key]: parseFloat(value) || 0 }));
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(draft, safeDays);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-slate-900">
|
||||
<Calculator className="w-5 h-5 text-[#0039A6]" />
|
||||
Daily Break-Even Engine
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-500">
|
||||
Enter your monthly fixed expenses and working days to calculate the
|
||||
daily revenue needed to cover operating costs.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Expense categories */}
|
||||
<div>
|
||||
<p className="text-[11px] font-bold text-slate-500 uppercase tracking-wide mb-2">
|
||||
Monthly Fixed Expenses
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{EXPENSE_CATEGORIES.map((cat) => (
|
||||
<div key={cat.key}>
|
||||
<label className="block text-xs font-medium text-slate-600 mb-1">
|
||||
{cat.label}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-400 font-semibold text-sm">$</span>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="100"
|
||||
value={draft[cat.key] ?? 0}
|
||||
onChange={(e) => updateField(cat.key, e.target.value)}
|
||||
className="h-9 pl-6 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Working days */}
|
||||
<div className="flex items-end gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 mb-1">
|
||||
Working Days / Month
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Calendar className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-slate-400 pointer-events-none" />
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="31"
|
||||
value={days}
|
||||
onChange={(e) => setDays(e.target.value)}
|
||||
className="h-9 w-32 pl-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Computed results */}
|
||||
<div className="grid grid-cols-2 gap-3 pt-2 border-t border-slate-100">
|
||||
<div className="bg-slate-50 rounded-lg p-3 border border-slate-200">
|
||||
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-1">
|
||||
Monthly Expenses
|
||||
</p>
|
||||
<p className="text-lg font-bold text-slate-800">${fmt(monthlyTotal)}</p>
|
||||
</div>
|
||||
<div className="bg-emerald-50 rounded-lg p-3 border border-emerald-200">
|
||||
<p className="text-[10px] font-bold text-emerald-600 uppercase tracking-wider mb-1 flex items-center gap-1">
|
||||
<TrendingUp className="w-3 h-3" />
|
||||
Daily Revenue Needed
|
||||
</p>
|
||||
<p className="text-lg font-bold text-emerald-700">${fmt(dailyTarget)}/day</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} className="bg-[#0039A6] hover:bg-[#002d8a]">
|
||||
Save Break-Even Target
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
161
src/components/dailypl/DailyPLTable.jsx
Normal file
161
src/components/dailypl/DailyPLTable.jsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import React from "react";
|
||||
import { format, parseISO } from "date-fns";
|
||||
import { Calendar } from "lucide-react";
|
||||
import DateRangePicker from "@/components/sales/DateRangePicker";
|
||||
import StatusFilterDropdown from "@/components/sales/StatusFilterDropdown";
|
||||
import ColumnsToggle from "@/components/sales/ColumnsToggle";
|
||||
|
||||
const fmt = (n) =>
|
||||
(n || 0).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
|
||||
export default function DailyPLTable({
|
||||
days,
|
||||
selectedDate,
|
||||
onSelectDate,
|
||||
dailyTarget,
|
||||
columns,
|
||||
hiddenColumns,
|
||||
onToggleColumn,
|
||||
onShowAllColumns,
|
||||
onHideOptionalColumns,
|
||||
dateRange,
|
||||
onDateRangeChange,
|
||||
statusFilter,
|
||||
onStatusFilterChange,
|
||||
statusCounts,
|
||||
statusOptions,
|
||||
}) {
|
||||
const isHidden = (key) => hiddenColumns.has(key);
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-slate-200 rounded-xl shadow-sm overflow-hidden">
|
||||
{/* Toolbar: Date range + Status filter + Columns toggle + Daily target */}
|
||||
<div className="flex items-center justify-between gap-3 px-4 py-3 border-b border-slate-100 flex-wrap">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<DateRangePicker value={dateRange} onChange={onDateRangeChange} />
|
||||
<StatusFilterDropdown
|
||||
value={statusFilter}
|
||||
onChange={onStatusFilterChange}
|
||||
counts={statusCounts}
|
||||
options={statusOptions}
|
||||
/>
|
||||
<ColumnsToggle
|
||||
columns={columns}
|
||||
hidden={hiddenColumns}
|
||||
onToggle={onToggleColumn}
|
||||
onShowAll={onShowAllColumns}
|
||||
onHideOptional={onHideOptionalColumns}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-0.5">Break-Even Target</p>
|
||||
<div className="bg-blue-50 text-blue-700 font-bold text-sm px-3 py-1.5 rounded-lg border border-blue-100">
|
||||
${fmt(dailyTarget)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-slate-100 border-b border-slate-200">
|
||||
{columns.map((col) =>
|
||||
isHidden(col.key) ? null : (
|
||||
<th
|
||||
key={col.key}
|
||||
className={`px-4 py-3 text-[11px] font-bold text-slate-500 uppercase tracking-wider ${
|
||||
col.key === "date" ? "text-left" : "text-right"
|
||||
}`}
|
||||
>
|
||||
{col.label}
|
||||
</th>
|
||||
)
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{days.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="text-center py-12 text-slate-400">
|
||||
<Calendar className="w-10 h-10 mx-auto mb-2 text-slate-300" />
|
||||
No days match the selected filters
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
days.map((d) => {
|
||||
const selected = d.fullDate === selectedDate;
|
||||
return (
|
||||
<tr
|
||||
key={d.fullDate}
|
||||
onClick={() => onSelectDate(d.fullDate)}
|
||||
className={`border-b border-slate-100 cursor-pointer transition-colors ${
|
||||
selected ? "bg-blue-50" : "hover:bg-slate-50"
|
||||
}`}
|
||||
>
|
||||
{!isHidden("date") && (
|
||||
<td className="px-4 py-3 font-semibold text-slate-700 whitespace-nowrap">
|
||||
{format(parseISO(d.fullDate), "MMM d, yyyy")}
|
||||
</td>
|
||||
)}
|
||||
{!isHidden("employees") && (
|
||||
<td className="px-4 py-3 text-right text-slate-600">{d.employees}</td>
|
||||
)}
|
||||
{!isHidden("hours") && (
|
||||
<td className="px-4 py-3 text-right text-slate-600">{(d.hours || 0).toFixed(1)}</td>
|
||||
)}
|
||||
{!isHidden("revenue") && (
|
||||
<td className="px-4 py-3 text-right font-semibold text-emerald-600">${fmt(d.revenue)}</td>
|
||||
)}
|
||||
{!isHidden("payroll") && (
|
||||
<td className="px-4 py-3 text-right font-semibold text-red-500">${fmt(d.payrollCost)}</td>
|
||||
)}
|
||||
{!isHidden("gross") && (
|
||||
<td
|
||||
className={`px-4 py-3 text-right font-semibold ${
|
||||
d.grossProfit >= 0 ? "text-emerald-600" : "text-red-600"
|
||||
}`}
|
||||
>
|
||||
${fmt(d.grossProfit)}
|
||||
</td>
|
||||
)}
|
||||
{!isHidden("overhead") && (
|
||||
<td className="px-4 py-3 text-right font-semibold text-amber-600">${fmt(d.overhead)}</td>
|
||||
)}
|
||||
{!isHidden("net") && (
|
||||
<td
|
||||
className={`px-4 py-3 text-right font-bold ${
|
||||
d.netProfit >= 0 ? "text-emerald-600" : "text-red-600"
|
||||
}`}
|
||||
>
|
||||
${fmt(d.netProfit)}
|
||||
</td>
|
||||
)}
|
||||
{!isHidden("margin") && (
|
||||
<td
|
||||
className={`px-4 py-3 text-right font-semibold ${
|
||||
d.margin >= 20 ? "text-emerald-600" : d.margin >= 0 ? "text-amber-600" : "text-red-600"
|
||||
}`}
|
||||
>
|
||||
{d.margin.toFixed(1)}%
|
||||
</td>
|
||||
)}
|
||||
{!isHidden("vsTarget") && (
|
||||
<td
|
||||
className={`px-4 py-3 text-right font-bold ${
|
||||
d.vsTarget >= 0 ? "text-emerald-600" : "text-red-600"
|
||||
}`}
|
||||
>
|
||||
${fmt(d.vsTarget)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
80
src/components/dailypl/TopInvoicesCard.jsx
Normal file
80
src/components/dailypl/TopInvoicesCard.jsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import React from "react";
|
||||
import { format, parseISO } from "date-fns";
|
||||
import { TrendingUp } from "lucide-react";
|
||||
|
||||
const fmt = (n) =>
|
||||
(n || 0).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
|
||||
const STATUS_STYLE = {
|
||||
Paid: "bg-emerald-100 text-emerald-700",
|
||||
Reconciled: "bg-emerald-100 text-emerald-700",
|
||||
Approved: "bg-blue-100 text-blue-700",
|
||||
"Pending Review": "bg-amber-100 text-amber-700",
|
||||
"Under Review": "bg-amber-100 text-amber-700",
|
||||
Draft: "bg-slate-100 text-slate-600",
|
||||
Overdue: "bg-red-100 text-red-700",
|
||||
Disputed: "bg-red-100 text-red-700",
|
||||
Resolved: "bg-slate-100 text-slate-600",
|
||||
Cancelled: "bg-slate-100 text-slate-400 line-through",
|
||||
};
|
||||
|
||||
export default function TopInvoicesCard({ invoices }) {
|
||||
const top = [...invoices]
|
||||
.filter((i) => (i.amount || 0) > 0)
|
||||
.sort((a, b) => (b.amount || 0) - (a.amount || 0))
|
||||
.slice(0, 8);
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-slate-200 rounded-xl shadow-sm overflow-hidden h-full">
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b border-slate-200">
|
||||
<div className="w-7 h-7 rounded-lg bg-emerald-50 flex items-center justify-center">
|
||||
<TrendingUp className="w-4 h-4 text-emerald-600" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-slate-800 text-sm">Top Invoices by Revenue</h3>
|
||||
<span className="ml-auto text-xs text-slate-400">Highest earners</span>
|
||||
</div>
|
||||
|
||||
{top.length === 0 ? (
|
||||
<div className="px-4 py-12 text-center text-sm text-slate-400">No invoices yet</div>
|
||||
) : (
|
||||
<div className="divide-y divide-slate-100">
|
||||
{top.map((inv, idx) => {
|
||||
const profit = inv.profit ?? (inv.amount - (inv.payrollCost || 0));
|
||||
const margin = inv.amount > 0 ? (profit / inv.amount) * 100 : 0;
|
||||
return (
|
||||
<div key={inv.id || idx} className="flex items-center gap-3 px-4 py-3 hover:bg-slate-50 transition-colors">
|
||||
<div className="w-7 h-7 rounded-lg bg-emerald-50 flex items-center justify-center text-[11px] font-bold text-emerald-600 shrink-0">
|
||||
#{idx + 1}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-semibold text-slate-800 text-sm">{inv.invoice_number || "—"}</span>
|
||||
<span
|
||||
className={`text-[10px] font-semibold px-1.5 py-0.5 rounded ${
|
||||
STATUS_STYLE[inv.status] || "bg-slate-100 text-slate-600"
|
||||
}`}
|
||||
>
|
||||
{inv.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 truncate">
|
||||
{inv.business_name || inv.to_company?.name || "—"} · {inv.event_name || "Invoice"}
|
||||
</p>
|
||||
<p className="text-[11px] text-slate-400">
|
||||
{inv.event_date ? format(parseISO(inv.event_date), "MMM d, yyyy") : "—"} · {inv.staffCount || 0} staff · {(inv.hours || 0).toFixed(0)}h
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<p className="font-bold text-emerald-600 text-sm">${fmt(inv.amount)}</p>
|
||||
<p className={`text-[11px] font-medium ${profit >= 0 ? "text-emerald-500" : "text-red-500"}`}>
|
||||
{profit >= 0 ? "+" : ""}${fmt(profit)} · {margin.toFixed(0)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
57
src/components/dailypl/TopPeopleCard.jsx
Normal file
57
src/components/dailypl/TopPeopleCard.jsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import React from "react";
|
||||
import { Users, Crown } from "lucide-react";
|
||||
|
||||
const fmt = (n) =>
|
||||
(n || 0).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
|
||||
export default function TopPeopleCard({ people }) {
|
||||
const top = [...people]
|
||||
.filter((p) => (p.revenue || 0) > 0)
|
||||
.sort((a, b) => (b.profit || 0) - (a.profit || 0))
|
||||
.slice(0, 8);
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-slate-200 rounded-xl shadow-sm overflow-hidden h-full">
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b border-slate-200">
|
||||
<div className="w-7 h-7 rounded-lg bg-blue-50 flex items-center justify-center">
|
||||
<Users className="w-4 h-4 text-blue-600" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-slate-800 text-sm">Most Profitable People</h3>
|
||||
<span className="ml-auto text-xs text-slate-400">Revenue − Payroll</span>
|
||||
</div>
|
||||
|
||||
{top.length === 0 ? (
|
||||
<div className="px-4 py-12 text-center text-sm text-slate-400">No staff data yet</div>
|
||||
) : (
|
||||
<div className="divide-y divide-slate-100">
|
||||
{top.map((p, idx) => {
|
||||
const margin = p.revenue > 0 ? (p.profit / p.revenue) * 100 : 0;
|
||||
return (
|
||||
<div key={p.name} className="flex items-center gap-3 px-4 py-3 hover:bg-slate-50 transition-colors">
|
||||
<div className="w-7 h-7 rounded-full bg-slate-100 flex items-center justify-center text-[11px] font-bold text-slate-600 shrink-0">
|
||||
{idx === 0 ? <Crown className="w-3.5 h-3.5 text-amber-500" /> : idx + 1}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-semibold text-slate-800 text-sm truncate">{p.name}</p>
|
||||
<p className="text-[11px] text-slate-400">
|
||||
{p.invoices || 0} invoices · {(p.hours || 0).toFixed(0)}h worked
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<p className={`font-bold text-sm ${p.profit >= 0 ? "text-emerald-600" : "text-red-600"}`}>
|
||||
${fmt(p.profit)}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<span className="text-[11px] text-emerald-500 font-medium">+${fmt(p.revenue)}</span>
|
||||
<span className="text-[11px] text-red-400">-${fmt(p.cost)}</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400">{margin.toFixed(0)}% margin</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
406
src/components/dashboard/DashboardCustomizer.jsx
Normal file
406
src/components/dashboard/DashboardCustomizer.jsx
Normal file
@@ -0,0 +1,406 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Settings,
|
||||
GripVertical,
|
||||
X,
|
||||
Plus,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Info,
|
||||
Save,
|
||||
RotateCcw,
|
||||
Sparkles
|
||||
} from "lucide-react";
|
||||
import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";
|
||||
|
||||
// Helper to fix DnD issues
|
||||
const getItemStyle = (isDragging, draggableStyle) => ({
|
||||
userSelect: "none",
|
||||
...draggableStyle,
|
||||
});
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
|
||||
export default function DashboardCustomizer({
|
||||
user,
|
||||
availableWidgets = [],
|
||||
currentLayout = [],
|
||||
onLayoutChange,
|
||||
dashboardType = "default" // admin, client, vendor, operator, etc
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [showHowItWorks, setShowHowItWorks] = useState(false);
|
||||
const [visibleWidgets, setVisibleWidgets] = useState([]);
|
||||
const [hiddenWidgets, setHiddenWidgets] = useState([]);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Initialize widgets from user's saved layout or defaults
|
||||
useEffect(() => {
|
||||
const layoutKey = `dashboard_layout_${dashboardType}`;
|
||||
const savedLayout = user?.[layoutKey];
|
||||
|
||||
if (savedLayout?.widgets && savedLayout.widgets.length > 0) {
|
||||
const savedVisible = savedLayout.widgets
|
||||
.map(id => availableWidgets.find(w => w.id === id))
|
||||
.filter(Boolean);
|
||||
setVisibleWidgets(savedVisible);
|
||||
|
||||
const savedHidden = savedLayout.hidden_widgets || [];
|
||||
const hiddenWidgetsList = availableWidgets.filter(w =>
|
||||
savedHidden.includes(w.id)
|
||||
);
|
||||
setHiddenWidgets(hiddenWidgetsList);
|
||||
} else {
|
||||
// Default: all widgets visible in provided order
|
||||
setVisibleWidgets(availableWidgets);
|
||||
setHiddenWidgets([]);
|
||||
}
|
||||
}, [user, availableWidgets, isOpen, dashboardType]);
|
||||
|
||||
// Save layout mutation
|
||||
const saveLayoutMutation = useMutation({
|
||||
mutationFn: async (layoutData) => {
|
||||
const layoutKey = `dashboard_layout_${dashboardType}`;
|
||||
await base44.auth.updateMe({
|
||||
[layoutKey]: layoutData
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['current-user'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['current-user-layout'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['current-user-client'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['current-user-vendor'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['current-user-operator'] });
|
||||
toast({
|
||||
title: "✅ Layout Saved",
|
||||
description: "Your dashboard layout has been updated",
|
||||
});
|
||||
setHasChanges(false);
|
||||
if (onLayoutChange) {
|
||||
onLayoutChange(visibleWidgets);
|
||||
}
|
||||
setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
}, 500);
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: "❌ Save Failed",
|
||||
description: "Could not save your layout. Please try again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const handleDragEnd = (result) => {
|
||||
if (!result.destination) return;
|
||||
|
||||
const { source, destination } = result;
|
||||
|
||||
if (source.droppableId === "visible" && destination.droppableId === "visible") {
|
||||
const items = Array.from(visibleWidgets);
|
||||
const [reorderedItem] = items.splice(source.index, 1);
|
||||
items.splice(destination.index, 0, reorderedItem);
|
||||
setVisibleWidgets(items);
|
||||
setHasChanges(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleHideWidget = (widget) => {
|
||||
setVisibleWidgets(visibleWidgets.filter(w => w.id !== widget.id));
|
||||
setHiddenWidgets([...hiddenWidgets, widget]);
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleShowWidget = (widget) => {
|
||||
setHiddenWidgets(hiddenWidgets.filter(w => w.id !== widget.id));
|
||||
setVisibleWidgets([...visibleWidgets, widget]);
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const layoutData = {
|
||||
widgets: visibleWidgets.map(w => w.id),
|
||||
hidden_widgets: hiddenWidgets.map(w => w.id),
|
||||
layout_version: "2.0"
|
||||
};
|
||||
saveLayoutMutation.mutate(layoutData);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setVisibleWidgets(availableWidgets);
|
||||
setHiddenWidgets([]);
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleOpenCustomizer = () => {
|
||||
setIsOpen(true);
|
||||
setShowHowItWorks(true);
|
||||
setHasChanges(false);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (hasChanges) {
|
||||
if (window.confirm("You have unsaved changes. Are you sure you want to close?")) {
|
||||
setIsOpen(false);
|
||||
setHasChanges(false);
|
||||
}
|
||||
} else {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Customize Button */}
|
||||
<Button
|
||||
onClick={handleOpenCustomizer}
|
||||
variant="outline"
|
||||
className="gap-2 border-2 border-blue-200 hover:bg-blue-50 text-blue-600 font-semibold"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
Customize
|
||||
</Button>
|
||||
|
||||
{/* Customizer Dialog */}
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-5xl max-h-[90dvh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-bold flex items-center gap-2">
|
||||
<Sparkles className="w-6 h-6 text-blue-600" />
|
||||
Customize Your Dashboard
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Personalize your workspace by adding, removing, and reordering widgets
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* How It Works Banner */}
|
||||
<AnimatePresence>
|
||||
{showHowItWorks && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="bg-gradient-to-r from-blue-50 to-indigo-50 border-2 border-blue-200 rounded-xl p-4 mb-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Info className="w-5 h-5 text-blue-600" />
|
||||
<h3 className="font-bold text-blue-900">How it works</h3>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-blue-800 flex items-center gap-2">
|
||||
<GripVertical className="w-4 h-4" />
|
||||
<strong>Drag</strong> widgets to reorder them
|
||||
</p>
|
||||
<p className="text-sm text-blue-800 flex items-center gap-2">
|
||||
<EyeOff className="w-4 h-4" />
|
||||
<strong>Hide</strong> widgets you don't need
|
||||
</p>
|
||||
<p className="text-sm text-blue-800 flex items-center gap-2">
|
||||
<Eye className="w-4 h-4" />
|
||||
<strong>Show</strong> hidden widgets to bring them back
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowHowItWorks(false)}
|
||||
className="text-blue-400 hover:text-blue-600 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Visible Widgets */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-bold text-lg text-slate-900">
|
||||
Visible Widgets ({visibleWidgets.length})
|
||||
</h3>
|
||||
<Button
|
||||
onClick={handleReset}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
disabled={saveLayoutMutation.isPending}
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
Reset to Default
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DragDropContext onDragEnd={handleDragEnd}>
|
||||
<Droppable droppableId="visible" direction="vertical">
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.droppableProps}
|
||||
className={`min-h-[100px] p-4 rounded-lg border-2 border-dashed transition-all ${
|
||||
snapshot.isDraggingOver
|
||||
? 'border-blue-400 bg-blue-50'
|
||||
: 'border-slate-200 bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{visibleWidgets.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
<Eye className="w-12 h-12 mx-auto mb-3 opacity-30" />
|
||||
<p className="text-sm font-medium">No visible widgets</p>
|
||||
<p className="text-xs mt-1">Add widgets from the hidden section below!</p>
|
||||
</div>
|
||||
) : (
|
||||
visibleWidgets.map((widget, index) => (
|
||||
<Draggable key={widget.id} draggableId={widget.id} index={index}>
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
style={getItemStyle(
|
||||
snapshot.isDragging,
|
||||
provided.draggableProps.style
|
||||
)}
|
||||
className={`bg-white border-2 rounded-lg p-4 mb-2 ${
|
||||
snapshot.isDragging
|
||||
? 'border-blue-400 shadow-2xl'
|
||||
: 'border-slate-200 hover:border-blue-300 hover:shadow-md'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
{...provided.dragHandleProps}
|
||||
className="cursor-grab active:cursor-grabbing text-slate-400 hover:text-blue-600 transition-colors p-1 hover:bg-blue-50 rounded"
|
||||
>
|
||||
<GripVertical className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-bold text-slate-900">{widget.title}</p>
|
||||
<p className="text-xs text-slate-500 mt-0.5">{widget.description}</p>
|
||||
</div>
|
||||
<Badge className={`${widget.categoryColor || 'bg-blue-100 text-blue-700'} border-0 text-xs`}>
|
||||
{widget.category}
|
||||
</Badge>
|
||||
<button
|
||||
onClick={() => handleHideWidget(widget)}
|
||||
className="text-slate-400 hover:text-red-600 transition-colors p-2 hover:bg-red-50 rounded-lg"
|
||||
title="Hide widget"
|
||||
disabled={saveLayoutMutation.isPending}
|
||||
>
|
||||
<EyeOff className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
))
|
||||
)}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
</div>
|
||||
|
||||
{/* Hidden Widgets */}
|
||||
{hiddenWidgets.length > 0 && (
|
||||
<div>
|
||||
<h3 className="font-bold text-lg text-slate-900 mb-3 flex items-center gap-2">
|
||||
Hidden Widgets ({hiddenWidgets.length})
|
||||
<Badge className="bg-slate-200 text-slate-600 text-xs">Click + to add</Badge>
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{hiddenWidgets.map((widget) => (
|
||||
<div
|
||||
key={widget.id}
|
||||
className="bg-slate-50 border-2 border-dashed border-slate-300 rounded-lg p-4 opacity-60 hover:opacity-100 transition-all hover:border-green-400 hover:bg-green-50/50 group"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold text-slate-900">{widget.title}</p>
|
||||
<p className="text-xs text-slate-500 mt-0.5">{widget.description}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleShowWidget(widget)}
|
||||
className="text-slate-400 hover:text-green-600 group-hover:bg-green-100 transition-colors p-2 rounded-lg"
|
||||
title="Show widget"
|
||||
disabled={saveLayoutMutation.isPending}
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* All Hidden Message */}
|
||||
{hiddenWidgets.length === 0 && visibleWidgets.length === availableWidgets.length && (
|
||||
<div className="text-center py-6 bg-green-50 border-2 border-green-200 rounded-lg">
|
||||
<Sparkles className="w-8 h-8 mx-auto mb-2 text-green-600" />
|
||||
<p className="text-sm font-medium text-green-800">
|
||||
All widgets are visible on your dashboard!
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-between pt-4 border-t mt-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleClose}
|
||||
className="text-slate-600"
|
||||
disabled={saveLayoutMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
{hasChanges && (
|
||||
<Badge className="bg-orange-500 text-white animate-pulse">
|
||||
Unsaved Changes
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowHowItWorks(!showHowItWorks)}
|
||||
className="gap-2"
|
||||
disabled={saveLayoutMutation.isPending}
|
||||
>
|
||||
<Info className="w-4 h-4" />
|
||||
{showHowItWorks ? 'Hide' : 'Show'} Help
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={saveLayoutMutation.isPending || !hasChanges}
|
||||
className="bg-blue-600 hover:bg-blue-700 gap-2 disabled:opacity-50"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{saveLayoutMutation.isPending ? "Saving..." : hasChanges ? "Save Layout" : "No Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
316
src/components/dashboard/EcosystemWheel.jsx
Normal file
316
src/components/dashboard/EcosystemWheel.jsx
Normal file
@@ -0,0 +1,316 @@
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
|
||||
export default function EcosystemWheel({ layers, onLayerClick, selectedLayer, onLayerHover }) {
|
||||
const [hoveredIndex, setHoveredIndex] = useState(null);
|
||||
|
||||
const centerX = 300;
|
||||
const centerY = 300;
|
||||
|
||||
// Icon positions - precisely positioned in center of each puzzle piece
|
||||
const iconPositions = [
|
||||
{ x: 385, y: 160 }, // 30° - Buyer (top-right)
|
||||
{ x: 460, y: 300 }, // 60° - Enterprises (top-right)
|
||||
{ x: 380, y: 440 }, // 120° - Sectors (bottom-right)
|
||||
{ x: 220, y: 440 }, // 180° - Partner (bottom-center)
|
||||
{ x: 140, y: 300}, // 240° - Approved Vendor (bottom-left)
|
||||
{ x: 220, y: 160} // 300° - Workforce (top-left)
|
||||
];
|
||||
|
||||
// Create interlocking puzzle pieces
|
||||
const createOuterPuzzle = (index, total) => {
|
||||
const startAngle = index * 360 / total - 90;
|
||||
const endAngle = (index + 1) * 360 / total - 90;
|
||||
const midAngle = (startAngle + endAngle) / 2;
|
||||
|
||||
const innerRadius = 110;
|
||||
const outerRadius = 210;
|
||||
const tabSize = 18;
|
||||
|
||||
const toRad = (deg) => deg * Math.PI / 180;
|
||||
|
||||
const startInner = {
|
||||
x: centerX + innerRadius * Math.cos(toRad(startAngle)),
|
||||
y: centerY + innerRadius * Math.sin(toRad(startAngle))
|
||||
};
|
||||
|
||||
const endInner = {
|
||||
x: centerX + innerRadius * Math.cos(toRad(endAngle)),
|
||||
y: centerY + innerRadius * Math.sin(toRad(endAngle))
|
||||
};
|
||||
|
||||
const startOuter = {
|
||||
x: centerX + outerRadius * Math.cos(toRad(startAngle)),
|
||||
y: centerY + outerRadius * Math.sin(toRad(startAngle))
|
||||
};
|
||||
|
||||
const endOuter = {
|
||||
x: centerX + outerRadius * Math.cos(toRad(endAngle)),
|
||||
y: centerY + outerRadius * Math.sin(toRad(endAngle))
|
||||
};
|
||||
|
||||
const innerTabAngle = startAngle + (endAngle - startAngle) * 0.5;
|
||||
const outerTabAngle = startAngle + (endAngle - startAngle) * 0.5;
|
||||
|
||||
const innerTabOut = {
|
||||
x: centerX + (innerRadius - tabSize) * Math.cos(toRad(innerTabAngle)),
|
||||
y: centerY + (innerRadius - tabSize) * Math.sin(toRad(innerTabAngle))
|
||||
};
|
||||
|
||||
const outerTabOut = {
|
||||
x: centerX + (outerRadius + tabSize) * Math.cos(toRad(outerTabAngle)),
|
||||
y: centerY + (outerRadius + tabSize) * Math.sin(toRad(outerTabAngle))
|
||||
};
|
||||
|
||||
let path = `M ${startInner.x} ${startInner.y}`;
|
||||
|
||||
const innerArc1End = startAngle + (endAngle - startAngle) * 0.4;
|
||||
const innerArc2Start = startAngle + (endAngle - startAngle) * 0.6;
|
||||
|
||||
path += ` A ${innerRadius} ${innerRadius} 0 0 1 ${centerX + innerRadius * Math.cos(toRad(innerArc1End))} ${centerY + innerRadius * Math.sin(toRad(innerArc1End))}`;
|
||||
path += ` Q ${innerTabOut.x} ${innerTabOut.y} ${centerX + innerRadius * Math.cos(toRad(innerArc2Start))} ${centerY + innerRadius * Math.sin(toRad(innerArc2Start))}`;
|
||||
path += ` A ${innerRadius} ${innerRadius} 0 0 1 ${endInner.x} ${endInner.y}`;
|
||||
|
||||
path += ` L ${endOuter.x} ${endOuter.y}`;
|
||||
|
||||
const outerArc1End = endAngle - (endAngle - startAngle) * 0.4;
|
||||
const outerArc2Start = endAngle - (endAngle - startAngle) * 0.6;
|
||||
|
||||
path += ` A ${outerRadius} ${outerRadius} 0 0 0 ${centerX + outerRadius * Math.cos(toRad(outerArc1End))} ${centerY + outerRadius * Math.sin(toRad(outerArc1End))}`;
|
||||
path += ` Q ${outerTabOut.x} ${outerTabOut.y} ${centerX + outerRadius * Math.cos(toRad(outerArc2Start))} ${centerY + outerRadius * Math.sin(toRad(outerArc2Start))}`;
|
||||
path += ` A ${outerRadius} ${outerRadius} 0 0 0 ${startOuter.x} ${startOuter.y}`;
|
||||
|
||||
path += ` L ${startInner.x} ${startInner.y}`;
|
||||
path += " Z";
|
||||
|
||||
return path;
|
||||
};
|
||||
|
||||
const createCenterPuzzle = () => {
|
||||
const radius = 90;
|
||||
const numTabs = layers.length;
|
||||
let path = "";
|
||||
|
||||
for (let i = 0; i < numTabs; i++) {
|
||||
const angle1 = i * 360 / numTabs - 90;
|
||||
const angle2 = (i + 1) * 360 / numTabs - 90;
|
||||
const midAngle = (angle1 + angle2) / 2;
|
||||
const tabSize = 18;
|
||||
|
||||
const toRad = (deg) => deg * Math.PI / 180;
|
||||
|
||||
const start = {
|
||||
x: centerX + radius * Math.cos(toRad(angle1)),
|
||||
y: centerY + radius * Math.sin(toRad(angle1))
|
||||
};
|
||||
|
||||
const end = {
|
||||
x: centerX + radius * Math.cos(toRad(angle2)),
|
||||
y: centerY + radius * Math.sin(toRad(angle2))
|
||||
};
|
||||
|
||||
const tabIn = {
|
||||
x: centerX + (radius + tabSize) * Math.cos(toRad(midAngle)),
|
||||
y: centerY + (radius + tabSize) * Math.sin(toRad(midAngle))
|
||||
};
|
||||
|
||||
if (i === 0) {
|
||||
path += `M ${start.x} ${start.y}`;
|
||||
}
|
||||
|
||||
const arc1End = angle1 + (angle2 - angle1) * 0.4;
|
||||
const arc2Start = angle1 + (angle2 - angle1) * 0.6;
|
||||
|
||||
path += ` A ${radius} ${radius} 0 0 1 ${centerX + radius * Math.cos(toRad(arc1End))} ${centerY + radius * Math.sin(toRad(arc1End))}`;
|
||||
path += ` Q ${tabIn.x} ${tabIn.y} ${centerX + radius * Math.cos(toRad(arc2Start))} ${centerY + radius * Math.sin(toRad(arc2Start))}`;
|
||||
path += ` A ${radius} ${radius} 0 0 1 ${end.x} ${end.y}`;
|
||||
}
|
||||
|
||||
path += " Z";
|
||||
return path;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative w-full flex flex-col items-center py-8" style={{ minHeight: '700px' }}>
|
||||
<div className="text-center mb-6">
|
||||
<h3 className="text-2xl font-bold text-[#1C323E] mb-2">KROW Ecosystem</h3>
|
||||
<p className="text-slate-600 text-sm">Hover over each piece to see details • Click to explore</p>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<svg width="620" height="600" viewBox="0 0 620 600" className="mx-auto drop-shadow-xl">
|
||||
<defs>
|
||||
<filter id="shadow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="4" />
|
||||
<feOffset dx="0" dy="3" result="offsetblur" />
|
||||
<feComponentTransfer>
|
||||
<feFuncA type="linear" slope="0.3" />
|
||||
</feComponentTransfer>
|
||||
<feMerge>
|
||||
<feMergeNode />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{/* Outer Puzzle Pieces */}
|
||||
{layers.map((layer, index) => {
|
||||
const angle = index * 360 / layers.length - 90;
|
||||
const rad = angle * Math.PI / 180;
|
||||
|
||||
// Use exact icon position from array
|
||||
const iconX = iconPositions[index].x;
|
||||
const iconY = iconPositions[index].y;
|
||||
|
||||
// Label position - Outside puzzle
|
||||
const labelRadius = 240;
|
||||
const labelX = centerX + labelRadius * Math.cos(rad);
|
||||
const labelY = centerY + labelRadius * Math.sin(rad);
|
||||
|
||||
const isHovered = hoveredIndex === index;
|
||||
const Icon = layer.icon;
|
||||
|
||||
return (
|
||||
<g key={index}>
|
||||
{/* Puzzle Piece */}
|
||||
<motion.path
|
||||
d={createOuterPuzzle(index, layers.length)}
|
||||
fill={isHovered ? "#F8FAFC" : "#FFFFFF"}
|
||||
stroke={isHovered ? "#0A39DF" : "#CBD5E1"}
|
||||
strokeWidth={isHovered ? "3" : "2"}
|
||||
filter="url(#shadow)"
|
||||
className="cursor-pointer transition-all duration-300"
|
||||
onMouseEnter={() => {
|
||||
setHoveredIndex(index);
|
||||
onLayerHover?.(layer);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setHoveredIndex(null);
|
||||
onLayerHover?.(null);
|
||||
}}
|
||||
onClick={() => onLayerClick?.(layer)}
|
||||
animate={{
|
||||
scale: isHovered ? 1.05 : 1
|
||||
}}
|
||||
transition={{ duration: 0.3 }}
|
||||
style={{
|
||||
transformOrigin: `${centerX}px ${centerY}px`
|
||||
}} />
|
||||
|
||||
|
||||
{/* Icon - Exact positioned in puzzle piece */}
|
||||
<circle
|
||||
cx={iconX}
|
||||
cy={iconY}
|
||||
r="24"
|
||||
fill="#1C323E"
|
||||
className="pointer-events-none transition-all duration-300" />
|
||||
|
||||
<foreignObject
|
||||
x={iconX - 24}
|
||||
y={iconY - 24}
|
||||
width="48"
|
||||
height="48"
|
||||
className="pointer-events-none">
|
||||
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Icon className="w-7 h-7 text-white" />
|
||||
</div>
|
||||
</foreignObject>
|
||||
|
||||
{/* Label - Outside puzzle */}
|
||||
<foreignObject
|
||||
x={labelX - 90}
|
||||
y={labelY - 25}
|
||||
width="180"
|
||||
height="50"
|
||||
className="pointer-events-none">
|
||||
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<div className="bg-white px-5 py-2.5 rounded-xl shadow-lg border border-slate-200">
|
||||
<p className={`font-bold text-sm text-center leading-tight whitespace-nowrap transition-colors ${
|
||||
isHovered ? 'text-[#0A39DF]' : 'text-[#1C323E]'}`
|
||||
}>
|
||||
{layer.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</foreignObject>
|
||||
</g>);
|
||||
|
||||
})}
|
||||
|
||||
{/* Center Puzzle Piece */}
|
||||
<motion.path
|
||||
d={createCenterPuzzle()}
|
||||
fill="#FFFFFF"
|
||||
stroke="#CBD5E1"
|
||||
strokeWidth="2"
|
||||
filter="url(#shadow)"
|
||||
animate={{
|
||||
scale: hoveredIndex !== null ? 1.03 : 1
|
||||
}}
|
||||
transition={{ duration: 0.3 }}
|
||||
style={{
|
||||
transformOrigin: `${centerX}px ${centerY}px`
|
||||
}} />
|
||||
|
||||
|
||||
{/* Center Logo */}
|
||||
<foreignObject
|
||||
x={centerX - 40}
|
||||
y={centerY - 40}
|
||||
width="80"
|
||||
height="80"
|
||||
className="pointer-events-none">
|
||||
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<img
|
||||
src="https://qtrypzzcjebvfcihiynt.supabase.co/storage/v1/object/public/base44-prod/public/68fc6cf01386035c266e7a5d/3ba390829_KROWlogo.png"
|
||||
alt="KROW"
|
||||
className="w-16 h-16 object-contain" />
|
||||
|
||||
</div>
|
||||
</foreignObject>
|
||||
</svg>
|
||||
|
||||
{/* Centered Metrics Display */}
|
||||
<AnimatePresence>
|
||||
{hoveredIndex !== null &&
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||
className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 pointer-events-none z-50">
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-2xl border-2 border-[#0A39DF] p-6 min-w-[300px]">
|
||||
<div className="flex items-center justify-center gap-3 mb-5 pb-4 border-b border-slate-200">
|
||||
<div className="w-12 h-12 rounded-xl bg-[#0A39DF] flex items-center justify-center shadow-md">
|
||||
{React.createElement(layers[hoveredIndex].icon, { className: "w-6 h-6 text-white" })}
|
||||
</div>
|
||||
<h4 className="font-bold text-[#1C323E] text-lg">{layers[hoveredIndex].name}</h4>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(layers[hoveredIndex].metrics).map(([key, value]) =>
|
||||
<div key={key} className="flex justify-between items-center gap-6">
|
||||
<span className="text-slate-600 text-sm capitalize font-medium">
|
||||
{key.replace(/([A-Z])/g, ' $1').trim()}
|
||||
</span>
|
||||
<span className="font-bold text-lg text-[#0A39DF]">
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-5 pt-4 border-t border-slate-200 text-center">
|
||||
<p className="text-xs text-slate-500">Click to view full dashboard</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>);
|
||||
|
||||
}
|
||||
597
src/components/dashboard/MorningBrief.jsx
Normal file
597
src/components/dashboard/MorningBrief.jsx
Normal file
@@ -0,0 +1,597 @@
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { createPageUrl } from "@/utils";
|
||||
import {
|
||||
AlertTriangle, TrendingUp, DollarSign, ArrowRight,
|
||||
ChevronDown, ChevronUp, Coffee, CheckCircle2,
|
||||
Clock, Users, FileText, Award, BarChart3,
|
||||
Flame, ShieldAlert, CircleArrowRight, Calendar, Briefcase, Brain
|
||||
} from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
|
||||
const getGreeting = () => {
|
||||
const h = new Date().getHours();
|
||||
if (h < 12) return { text: "Good morning", icon: "☀️" };
|
||||
if (h < 17) return { text: "Good afternoon", icon: "🌤️" };
|
||||
return { text: "Good evening", icon: "🌙" };
|
||||
};
|
||||
|
||||
const getIconForType = (type) => {
|
||||
const map = {
|
||||
order: Clock, compliance: Award, staff: Users,
|
||||
invoice: FileText, performance: BarChart3,
|
||||
calendar: Calendar, briefcase: Briefcase,
|
||||
};
|
||||
return map[type] || AlertTriangle;
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// BriefCard
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function BriefCard({ item, isRisk, expanded, onToggle, onNavigate }) {
|
||||
const Icon = getIconForType(item.type);
|
||||
const isOpen = expanded === item.id;
|
||||
|
||||
const borderColor = isRisk ? "border-red-100" : "border-emerald-100";
|
||||
const bgColor = isRisk ? "bg-red-50/60" : "bg-emerald-50/60";
|
||||
const iconBg = isRisk ? "bg-red-100" : "bg-emerald-100";
|
||||
const iconText = isRisk ? "text-red-600" : "text-emerald-600";
|
||||
const impactText = isRisk ? "text-red-600" : "text-emerald-600";
|
||||
const btnClass = isRisk ? "bg-red-600 hover:bg-red-700 text-white" : "bg-emerald-600 hover:bg-emerald-700 text-white";
|
||||
const consequenceBg = isRisk ? "bg-red-100/60 border-red-200" : "bg-emerald-100/60 border-emerald-200";
|
||||
const consequenceText = isRisk ? "text-red-800" : "text-emerald-800";
|
||||
|
||||
return (
|
||||
<div className={`rounded-xl border ${borderColor} ${bgColor} overflow-hidden`}>
|
||||
<button className="w-full text-left p-3 flex items-start gap-2.5" onClick={() => onToggle(item.id)}>
|
||||
<div className={`w-7 h-7 rounded-lg ${iconBg} flex items-center justify-center flex-shrink-0 mt-0.5`}>
|
||||
<Icon className={`w-3.5 h-3.5 ${iconText}`} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-semibold text-xs text-slate-800 leading-tight">{item.label}</p>
|
||||
<p className={`text-[10px] ${impactText} font-medium mt-0.5`}>{item.impact}</p>
|
||||
</div>
|
||||
{isOpen
|
||||
? <ChevronUp className="w-3.5 h-3.5 text-slate-400 mt-1 flex-shrink-0" />
|
||||
: <ChevronDown className="w-3.5 h-3.5 text-slate-400 mt-1 flex-shrink-0" />
|
||||
}
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="px-3 pb-3 space-y-2.5">
|
||||
<div className={`rounded-lg border ${consequenceBg} p-2.5`}>
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
{isRisk ? <Flame className="w-3 h-3 text-red-500" /> : <TrendingUp className="w-3 h-3 text-emerald-500" />}
|
||||
<span className={`text-[10px] font-bold uppercase tracking-wide ${consequenceText}`}>
|
||||
{isRisk ? "If you don't act today" : "What you'll gain"}
|
||||
</span>
|
||||
</div>
|
||||
<p className={`text-[11px] leading-relaxed ${consequenceText}`}>{item.consequence}</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-white/70 border-slate-200 p-2.5">
|
||||
<div className="flex items-center gap-1.5 mb-1.5">
|
||||
<ShieldAlert className="w-3 h-3 text-slate-500" />
|
||||
<span className="text-[10px] font-bold uppercase tracking-wide text-slate-600">
|
||||
{isRisk ? "How to prevent it" : "How to capture it"}
|
||||
</span>
|
||||
</div>
|
||||
<ol className="space-y-1">
|
||||
{item.steps.map((step, i) => (
|
||||
<li key={i} className="flex items-start gap-1.5 text-[11px] text-slate-700">
|
||||
<span className={`w-4 h-4 rounded-full ${isRisk ? "bg-red-100 text-red-700" : "bg-emerald-100 text-emerald-700"} flex items-center justify-center font-bold text-[9px] flex-shrink-0 mt-0.5`}>
|
||||
{i + 1}
|
||||
</span>
|
||||
<span className="leading-snug">{step}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<Button size="sm" className={`h-7 text-xs w-full ${btnClass}`} onClick={(e) => { e.stopPropagation(); onNavigate && onNavigate(item.route); }}>
|
||||
<CircleArrowRight className="w-3 h-3 mr-1.5" />
|
||||
{item.action}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Role-specific insight builders
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
function calcFillProbability(event) {
|
||||
const assigned = event.assigned_staff?.length || 0;
|
||||
const requested = event.requested || 0;
|
||||
if (requested === 0) return 100;
|
||||
const fillRatio = assigned / requested;
|
||||
const eventDate = new Date(event.date);
|
||||
const hoursUntil = Math.max(0, (eventDate - new Date()) / (1000 * 60 * 60));
|
||||
const timeBuffer = Math.min(hoursUntil / 24, 1);
|
||||
return Math.round(fillRatio * 60 + timeBuffer * 40);
|
||||
}
|
||||
|
||||
function buildAdminInsights(events, staff, invoices) {
|
||||
const risks = [], opps = [];
|
||||
|
||||
// RAPID unfilled
|
||||
const rapidUnfilled = events.filter(e => e.is_rapid && (e.assigned_staff?.length || 0) < (e.requested || 1));
|
||||
if (rapidUnfilled.length > 0) {
|
||||
const impact = rapidUnfilled.reduce((s, e) => s + (e.total || 0), 0);
|
||||
risks.push({ id: "rapid", type: "order", label: `${rapidUnfilled.length} RAPID shift${rapidUnfilled.length > 1 ? "s" : ""} unstaffed`, impact: impact > 0 ? `$${impact.toLocaleString()} revenue at risk` : "Revenue at risk", consequence: "Urgent unfilled shifts risk client cancellations, contract penalties, and damage to fill-rate metrics.", steps: ["Filter Orders by RAPID status", "Run Smart Assign on each event", "Confirm top candidates directly", "Mark assigned and notify client"], action: "Go to RAPID Orders →", route: "Events" });
|
||||
}
|
||||
|
||||
// 🚨 LOW FILL PROBABILITY — events within 24h at risk
|
||||
const atRiskEvents = events.filter(e => {
|
||||
if (!e.date || e.status === "Canceled" || e.status === "Completed") return false;
|
||||
const hoursUntil = (new Date(e.date) - new Date()) / (1000 * 60 * 60);
|
||||
if (hoursUntil < 0 || hoursUntil > 24) return false;
|
||||
return calcFillProbability(e) < 60;
|
||||
});
|
||||
if (atRiskEvents.length > 0) {
|
||||
risks.push({ id: "fillrisk", type: "order", label: `${atRiskEvents.length} event${atRiskEvents.length > 1 ? "s" : ""} at low fill probability`, impact: `Starting in <24h — escalate now`, consequence: "Low fill probability means these events are at high risk of being understaffed when the shift starts. Waiting makes it worse.", steps: ["Open each at-risk event", "Smart Assign immediately", "Consider increasing pay rate to attract workers faster", "Notify backup vendors as contingency"], action: "Review At-Risk Orders →", route: "Events" });
|
||||
}
|
||||
|
||||
// OT Risk — events today with shifts > 8 hours
|
||||
const otRiskEvents = events.filter(e => {
|
||||
if (!e.date || !isDateToday(e.date)) return false;
|
||||
return (e.shifts || []).some(shift =>
|
||||
(shift.roles || []).some(role => {
|
||||
if (!role.start_time || !role.end_time) return false;
|
||||
const [sh, sm] = role.start_time.split(":").map(Number);
|
||||
const [eh, em] = role.end_time.split(":").map(Number);
|
||||
const hours = (eh * 60 + em - sh * 60 - sm) / 60;
|
||||
return hours > 8;
|
||||
})
|
||||
);
|
||||
});
|
||||
if (otRiskEvents.length > 0) {
|
||||
risks.push({ id: "otrisk", type: "staff", label: `${otRiskEvents.length} shift${otRiskEvents.length > 1 ? "s" : ""} today risk OT`, impact: `Shifts >8h → OT cost exposure`, consequence: "Shifts running over 8 hours trigger overtime pay. If unplanned, this blows your labor budget and affects your client billing.", steps: ["Review today's event shift times", "Identify which roles will exceed 8h", "Arrange rotation or relief workers", "Update billing if OT is approved"], action: "View Today's Schedule →", route: "Schedule" });
|
||||
}
|
||||
|
||||
// Pending BG checks
|
||||
const pendingBG = staff.filter(s => s.background_check_status === "pending");
|
||||
if (pendingBG.length > 0) {
|
||||
risks.push({ id: "bgcheck", type: "compliance", label: `${pendingBG.length} background check${pendingBG.length > 1 ? "s" : ""} pending`, impact: `~$${(pendingBG.length * 8 * 25).toLocaleString()} weekly capacity blocked`, consequence: "Uncleared workers can't be assigned. Your effective roster shrinks and you risk declining orders.", steps: ["Staff Directory → filter BG = Pending", "Confirm submissions with vendor", "Follow up for expedited review", "Update status once cleared"], action: "Review Pending Staff →", route: "StaffDirectory" });
|
||||
}
|
||||
|
||||
// Overdue invoices
|
||||
const overdueInvoices = invoices.filter(i => i.status !== "Paid" && i.status !== "Cancelled" && i.status !== "Reconciled" && i.due_date && new Date(i.due_date) < new Date());
|
||||
if (overdueInvoices.length > 0) {
|
||||
const total = overdueInvoices.reduce((s, i) => s + (i.amount || 0), 0);
|
||||
risks.push({ id: "invoices", type: "invoice", label: `${overdueInvoices.length} invoice${overdueInvoices.length > 1 ? "s" : ""} overdue`, impact: `$${total.toLocaleString()} past due`, consequence: "Overdue invoices become disputes at 30 days. Past 60 days clients may freeze orders.", steps: ["Invoices → filter Overdue", "Check dispute reason on file", "Send payment reminder", "Escalate disputed ones to procurement"], action: "Collect Overdue Invoices →", route: "Invoices" });
|
||||
}
|
||||
|
||||
// Draft orders
|
||||
const draftEvents = events.filter(e => e.status === "Draft");
|
||||
if (draftEvents.length > 0) {
|
||||
opps.push({ id: "drafts", type: "order", label: `${draftEvents.length} draft order${draftEvents.length > 1 ? "s" : ""} ready to activate`, impact: `${draftEvents.length} events waiting to enter pipeline`, consequence: "Drafts delay the staffing pipeline. Earlier activation = more lead time = better staff placement.", steps: ["Orders → filter Status = Draft", "Review each for completeness", "Activate to enter pipeline", "Smart Assign surfaces best candidates"], action: "Activate Drafts →", route: "Events" });
|
||||
}
|
||||
|
||||
// Upcoming unfilled
|
||||
const upcomingUnfilled = events.filter(e => {
|
||||
const d = e.date ? new Date(e.date) : null;
|
||||
if (!d) return false;
|
||||
const days = Math.ceil((d - new Date()) / 86400000);
|
||||
return days >= 1 && days <= 7 && (e.assigned_staff?.length || 0) < (e.requested || 1) && !e.is_rapid;
|
||||
});
|
||||
if (upcomingUnfilled.length > 0) {
|
||||
const val = upcomingUnfilled.reduce((s, e) => s + (e.total || 0), 0);
|
||||
opps.push({ id: "upcoming", type: "order", label: `Pre-assign ${upcomingUnfilled.length} upcoming order${upcomingUnfilled.length > 1 ? "s" : ""}`, impact: val > 0 ? `$${val.toLocaleString()} secured early` : "Lock in fulfillment", consequence: "Pre-assigning locks in top staff before they're claimed by other orders.", steps: ["Orders → sort by date ascending", "Open each unfilled event this week", "Click Smart Assign", "Confirm and notify staff"], action: "Pre-Assign Now →", route: "Events" });
|
||||
}
|
||||
|
||||
return { risks, opps };
|
||||
}
|
||||
|
||||
function isDateToday(dateValue) {
|
||||
if (!dateValue) return false;
|
||||
const d = new Date(dateValue);
|
||||
const today = new Date();
|
||||
return d.getFullYear() === today.getFullYear() && d.getMonth() === today.getMonth() && d.getDate() === today.getDate();
|
||||
}
|
||||
|
||||
function buildVendorInsights(events, staff, invoices) {
|
||||
const risks = [], opps = [];
|
||||
|
||||
const myEvents = events;
|
||||
const myStaff = staff;
|
||||
|
||||
// Low reliability staff deployed
|
||||
const lowRel = myStaff.filter(s => (s.reliability_score || 0) < 60 && (s.no_show_count || 0) > 1);
|
||||
if (lowRel.length > 0) {
|
||||
risks.push({ id: "lowrel", type: "staff", label: `${lowRel.length} unreliable staff on active shifts`, impact: "No-show risk on live orders", consequence: "Low-reliability staff on active orders means likely no-shows. Your fill-rate score drops, clients get angry, and you may lose the contract.", steps: ["Staff Directory → filter low reliability", "Replace with higher-rated backups before shift day", "Message the workers to confirm attendance", "Update reliability score after each event"], action: "Review Staff Reliability →", route: "StaffDirectory" });
|
||||
}
|
||||
|
||||
// Unpaid invoices
|
||||
const unpaid = invoices.filter(i => (i.status === "Pending Review" || i.status === "Approved") && i.due_date && new Date(i.due_date) < new Date());
|
||||
if (unpaid.length > 0) {
|
||||
const total = unpaid.reduce((s, i) => s + (i.amount || 0), 0);
|
||||
risks.push({ id: "unpaid", type: "invoice", label: `${unpaid.length} invoice${unpaid.length > 1 ? "s" : ""} awaiting payment`, impact: `$${total.toLocaleString()} in receivables`, consequence: "Delayed payment impacts your payroll and operational cash flow. Clients who don't pay on time often dispute later.", steps: ["Go to Invoices → filter pending", "Send a follow-up to client finance", "Attach the signed timesheet if disputed", "Escalate to procurement if 7+ days past due"], action: "Follow Up on Invoices →", route: "Invoices" });
|
||||
}
|
||||
|
||||
// 🧠 PREDICTIVE: Bench thin for high-demand days
|
||||
const dayMap = ["Sun","Mon","Tue","Wed","Thu","Fri","Sat"];
|
||||
const dayCounts = [0,0,0,0,0,0,0];
|
||||
myEvents.forEach(e => { if (e.date) dayCounts[new Date(e.date).getDay()]++; });
|
||||
const maxDay = dayCounts.indexOf(Math.max(...dayCounts));
|
||||
const maxCount = dayCounts[maxDay];
|
||||
const avgCount = dayCounts.reduce((s,v)=>s+v,0) / 7;
|
||||
if (maxCount > avgCount * 1.3 && myStaff.length > 0) {
|
||||
const activeCount = myStaff.filter(s => s.employment_type !== "Medical Leave").length;
|
||||
const bench = Math.max(0, maxCount - activeCount);
|
||||
if (bench > 0) {
|
||||
risks.push({ id: "bench_thin", type: "staff", label: `Bench thin for ${dayMap[maxDay]}s — your peak demand day`, impact: `${bench} extra worker${bench > 1 ? "s" : ""} needed to cover peak`, consequence: `${dayMap[maxDay]} is your busiest day based on historical patterns. With your current roster size you risk underfilling peak shifts — clients notice and it hurts your vendor score.`, steps: ["Review active staff count vs peak demand", "Recruit or activate on-call workers for this day", "Use KROW X to source on-demand backup", "Pre-confirm bench before next peak week"], action: "View Predictive Intelligence →", route: "PredictiveIntelligence" });
|
||||
}
|
||||
}
|
||||
|
||||
// Top staff not yet deployed
|
||||
const topStaff = myStaff.filter(s => (s.rating || 0) >= 4.5 && (s.shift_coverage_percentage || 0) >= 90);
|
||||
if (topStaff.length > 0) {
|
||||
const avg = (topStaff.reduce((s, w) => s + (w.rating || 0), 0) / topStaff.length).toFixed(1);
|
||||
opps.push({ id: "topstaff", type: "staff", label: `${topStaff.length} top-rated staff available`, impact: `Avg ${avg}★ — deploy to best clients`, consequence: "Deploying proven workers to priority accounts boosts satisfaction and repeat orders.", steps: ["Staff Directory → filter Rating ≥ 4.5", "Match to highest-value upcoming orders", "Assign them first to priority events", "Flag as Favorite Staff on client accounts"], action: "View Top Staff →", route: "StaffDirectory" });
|
||||
}
|
||||
|
||||
// Upcoming orders to fill
|
||||
const toFill = myEvents.filter(e => {
|
||||
const d = e.date ? new Date(e.date) : null;
|
||||
if (!d) return false;
|
||||
const days = Math.ceil((d - new Date()) / 86400000);
|
||||
return days >= 1 && days <= 5 && (e.assigned_staff?.length || 0) < (e.requested || 1);
|
||||
});
|
||||
if (toFill.length > 0) {
|
||||
opps.push({ id: "fill", type: "order", label: `Fill ${toFill.length} order${toFill.length > 1 ? "s" : ""} in next 5 days`, impact: `Improve fill rate — clients are watching`, consequence: "Early fills protect your vendor performance score and client relationship.", steps: ["Orders → sort by soonest date", "Smart Assign on each unfilled order", "Confirm staff and send reminders", "Update order to Confirmed status"], action: "Fill Orders →", route: "Events" });
|
||||
}
|
||||
|
||||
// 🧠 PREDICTIVE: Pre-staff opportunity
|
||||
const upcomingHighDemand = myEvents.filter(e => {
|
||||
const d = e.date ? new Date(e.date) : null;
|
||||
if (!d) return false;
|
||||
const dayOfWeek = d.getDay();
|
||||
const days = Math.ceil((d - new Date()) / 86400000);
|
||||
return days >= 3 && days <= 10 && dayCounts[dayOfWeek] >= maxCount * 0.8;
|
||||
});
|
||||
if (upcomingHighDemand.length > 0) {
|
||||
opps.push({ id: "prestaff", type: "order", label: `Pre-staff ${upcomingHighDemand.length} high-demand event${upcomingHighDemand.length > 1 ? "s" : ""} early`, impact: "Lock in best workers before they're taken", consequence: "High-demand days fill fast. Pre-assigning your top staff 5–10 days out secures quality and protects your client relationships.", steps: ["Open each upcoming high-demand event", "Run Smart Assign immediately", "Confirm top-rated workers first", "Set reminder to recheck 48hrs before"], action: "See Forecast →", route: "PredictiveIntelligence" });
|
||||
}
|
||||
|
||||
return { risks, opps };
|
||||
}
|
||||
|
||||
function buildClientInsights(events, invoices) {
|
||||
const risks = [], opps = [];
|
||||
|
||||
// Orders not confirmed
|
||||
const unconfirmed = events.filter(e => e.status === "Pending" || e.status === "Draft");
|
||||
if (unconfirmed.length > 0) {
|
||||
risks.push({ id: "unconfirmed", type: "order", label: `${unconfirmed.length} order${unconfirmed.length > 1 ? "s" : ""} not yet confirmed`, impact: "Staffing not locked in", consequence: "Unconfirmed orders haven't been staffed yet. The closer you get to the event date, the harder it is to find quality workers.", steps: ["My Orders → filter Pending or Draft", "Review each order's staffing details", "Reach out to vendor contact to confirm", "Request confirmation email for records"], action: "Review My Orders →", route: "ClientOrders" });
|
||||
}
|
||||
|
||||
// Disputed invoices
|
||||
const disputed = invoices.filter(i => i.status === "Disputed");
|
||||
if (disputed.length > 0) {
|
||||
const total = disputed.reduce((s, i) => s + (i.amount || 0), 0);
|
||||
risks.push({ id: "disputed", type: "invoice", label: `${disputed.length} invoice${disputed.length > 1 ? "s" : ""} in dispute`, impact: `$${total.toLocaleString()} on hold`, consequence: "Unresolved disputes block payment and can stall future orders. The sooner resolved, the faster service resumes.", steps: ["Go to Invoices → filter Disputed", "Review the dispute reason and details", "Respond with your supporting documentation", "Agree on resolution with vendor contact"], action: "Resolve Disputes →", route: "Invoices" });
|
||||
}
|
||||
|
||||
// 🧠 PREDICTIVE: Upcoming high-demand day with no order placed
|
||||
const dayMap = ["Sun","Mon","Tue","Wed","Thu","Fri","Sat"];
|
||||
const dayCounts = [0,0,0,0,0,0,0];
|
||||
events.forEach(e => { if (e.date) dayCounts[new Date(e.date).getDay()]++; });
|
||||
const maxDay = dayCounts.indexOf(Math.max(...dayCounts));
|
||||
const today = new Date();
|
||||
const nextPeakDate = new Date();
|
||||
while (nextPeakDate.getDay() !== maxDay) nextPeakDate.setDate(nextPeakDate.getDate() + 1);
|
||||
if (nextPeakDate.getDay() === maxDay) nextPeakDate.setDate(nextPeakDate.getDate() + 7);
|
||||
const daysUntilPeak = Math.ceil((nextPeakDate - today) / 86400000);
|
||||
const hasOrderOnPeak = events.some(e => {
|
||||
if (!e.date) return false;
|
||||
const d = new Date(e.date);
|
||||
return d.getDay() === maxDay && Math.ceil((d - today) / 86400000) <= 14 && Math.ceil((d - today) / 86400000) > 0;
|
||||
});
|
||||
if (!hasOrderOnPeak && dayCounts[maxDay] > 0) {
|
||||
risks.push({ id: "peak_gap", type: "order", label: `No order placed for ${dayMap[maxDay]} — your busiest day`, impact: `${daysUntilPeak} day${daysUntilPeak !== 1 ? "s" : ""} until next peak`, consequence: `Based on your order history, ${dayMap[maxDay]} is your highest-demand day. You have no upcoming order placed — leaving you exposed if you need coverage.`, steps: [`Check if staffing is needed for next ${dayMap[maxDay]}`, "Create an order now to get best staff available", "Your vendor can pre-assign proven workers", "Earlier orders = higher quality matches"], action: "Place an Order →", route: "CreateEvent" });
|
||||
}
|
||||
|
||||
// Upcoming events this week
|
||||
const thisWeek = events.filter(e => {
|
||||
const d = e.date ? new Date(e.date) : null;
|
||||
if (!d) return false;
|
||||
const days = Math.ceil((d - new Date()) / 86400000);
|
||||
return days >= 0 && days <= 7 && (e.status === "Confirmed" || e.status === "Assigned");
|
||||
});
|
||||
if (thisWeek.length > 0) {
|
||||
opps.push({ id: "thisweek", type: "calendar", label: `${thisWeek.length} confirmed event${thisWeek.length > 1 ? "s" : ""} this week`, impact: "Review details to ensure everything is set", consequence: "Being proactive before events avoids day-of surprises — wrong uniforms, missing contacts, or headcount mismatches.", steps: ["Open each confirmed event", "Verify headcount and role breakdown", "Confirm on-site contact is up to date", "Save vendor emergency contact number"], action: "Review Upcoming Events →", route: "ClientOrders" });
|
||||
}
|
||||
|
||||
// 🧠 PREDICTIVE: Suggest pre-ordering based on past patterns
|
||||
const completedCount = events.filter(e => e.status === "Completed").length;
|
||||
if (completedCount >= 3) {
|
||||
const avgStaff = Math.round(events.filter(e => e.status === "Completed").reduce((s, e) => s + (e.requested || 0), 0) / completedCount);
|
||||
opps.push({ id: "pattern", type: "performance", label: `AI pattern: avg ${avgStaff} staff per event`, impact: "Use this to plan your next order accurately", consequence: "Your historical orders show a consistent staffing pattern. Using this baseline prevents over or under-ordering — saving cost and avoiding gaps.", steps: [`Use ${avgStaff} as your default headcount when creating orders`, "Adjust up by 10–15% for high-demand days", "Lock in staffing 5–7 days before the event", "Review Predictive Intelligence for day-by-day forecast"], action: "See AI Forecast →", route: "PredictiveIntelligence" });
|
||||
}
|
||||
|
||||
// Draft orders to submit
|
||||
const drafts = events.filter(e => e.status === "Draft");
|
||||
if (drafts.length > 0) {
|
||||
opps.push({ id: "drafts", type: "order", label: `Submit ${drafts.length} draft order${drafts.length > 1 ? "s" : ""}`, impact: "Get staffing confirmed earlier", consequence: "Earlier submission = earlier confirmation = better staff assigned to your events.", steps: ["My Orders → filter Draft", "Review order details for accuracy", "Submit to your vendor partner", "Follow up to confirm receipt"], action: "Submit Drafts →", route: "ClientOrders" });
|
||||
}
|
||||
|
||||
return { risks, opps };
|
||||
}
|
||||
|
||||
function buildWorkforceInsights(staff, events) {
|
||||
const risks = [], opps = [];
|
||||
|
||||
// Expiring certifications
|
||||
const myStaff = staff.slice(0, 1)[0]; // single worker view
|
||||
if (myStaff?.certifications?.length > 0) {
|
||||
const expiring = myStaff.certifications.filter(c => {
|
||||
if (!c.expiry_date) return false;
|
||||
const days = Math.ceil((new Date(c.expiry_date) - new Date()) / 86400000);
|
||||
return days > 0 && days <= 30;
|
||||
});
|
||||
if (expiring.length > 0) {
|
||||
risks.push({ id: "certexp", type: "compliance", label: `${expiring.length} certification${expiring.length > 1 ? "s" : ""} expiring soon`, impact: "May become ineligible for certain shifts", consequence: "Expired certs mean you can't be assigned to jobs requiring them. Renewals take time — act now.", steps: ["Go to Certifications tab", "Note the expiry dates for each cert", "Contact your manager for renewal info", "Complete renewal before expiry date"], action: "View Certifications →", route: "Certification" });
|
||||
}
|
||||
}
|
||||
|
||||
// Upcoming shifts
|
||||
const upcoming = events.filter(e => {
|
||||
const d = e.date ? new Date(e.date) : null;
|
||||
if (!d) return false;
|
||||
const days = Math.ceil((d - new Date()) / 86400000);
|
||||
return days >= 0 && days <= 7;
|
||||
});
|
||||
if (upcoming.length > 0) {
|
||||
opps.push({ id: "shifts", type: "calendar", label: `${upcoming.length} shift${upcoming.length > 1 ? "s" : ""} coming up this week`, impact: "Be prepared and on time", consequence: "Knowing your schedule in advance helps you prepare — right uniform, arrive early, know the contact.", steps: ["Check My Shifts for details", "Note the location and contact name", "Confirm your uniform requirement", "Set a reminder for each shift start time"], action: "View My Shifts →", route: "WorkforceShifts" });
|
||||
}
|
||||
|
||||
// Available shift requests
|
||||
opps.push({ id: "available", type: "order", label: "Check new shift opportunities", impact: "More shifts = more earnings", consequence: "Open shift requests are first-come first-served. Checking daily gets you the best hours.", steps: ["Go to Shift Requests", "Filter by your available days", "Accept shifts that match your schedule", "Confirm acceptance to lock in the hours"], action: "Browse Shift Requests →", route: "WorkerShiftProposals" });
|
||||
|
||||
return { risks, opps };
|
||||
}
|
||||
|
||||
function buildProcurementInsights(events, staff, invoices) {
|
||||
const risks = [], opps = [];
|
||||
|
||||
// Invoices needing review
|
||||
const pendingReview = invoices.filter(i => i.status === "Pending Review");
|
||||
if (pendingReview.length > 0) {
|
||||
const total = pendingReview.reduce((s, i) => s + (i.amount || 0), 0);
|
||||
risks.push({ id: "review", type: "invoice", label: `${pendingReview.length} invoice${pendingReview.length > 1 ? "s" : ""} pending your review`, impact: `$${total.toLocaleString()} awaiting approval`, consequence: "Stalled invoices delay vendor payment, causing friction and risking vendor disengagement from your network.", steps: ["Invoices → filter Pending Review", "Verify against PO and timesheet", "Approve or flag disputes within 48 hrs", "Notify vendor of status"], action: "Review Invoices →", route: "Invoices" });
|
||||
}
|
||||
|
||||
// Network compliance gaps
|
||||
const failedBG = staff.filter(s => s.background_check_status === "failed");
|
||||
if (failedBG.length > 0) {
|
||||
risks.push({ id: "failbg", type: "compliance", label: `${failedBG.length} staff with failed background checks`, impact: "Compliance liability exposure", consequence: "Active workers with failed checks create legal and contractual liability. Immediate removal from active assignments is required.", steps: ["Staff Directory → filter BG = Failed", "Flag each worker as inactive", "Notify their vendor to remove from schedule", "Document action in the compliance log"], action: "Resolve Compliance Issues →", route: "StaffDirectory" });
|
||||
}
|
||||
|
||||
// Vendor performance opportunity
|
||||
const highFillEvents = events.filter(e => e.status === "Completed" && (e.assigned_staff?.length || 0) >= (e.requested || 1));
|
||||
if (highFillEvents.length > 3) {
|
||||
opps.push({ id: "perf", type: "performance", label: `Strong vendor performance this period`, impact: `${highFillEvents.length} orders fully staffed`, consequence: "High fill rates signal good vendor relationships — a good time to negotiate better rates or expand preferred vendor list.", steps: ["Review Vendor Performance report", "Identify top-performing vendors", "Schedule a QBR with top vendors", "Discuss rate optimization opportunities"], action: "View Vendor Performance →", route: "VendorManagement" });
|
||||
}
|
||||
|
||||
return { risks, opps };
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Stat strip value per role
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function getStatStrip(role, events, staff, invoices) {
|
||||
const pendingInvoiceVal = () => {
|
||||
const pending = invoices.filter(i => !["Paid", "Cancelled", "Reconciled"].includes(i.status));
|
||||
const total = pending.reduce((s, i) => s + (i.amount || 0), 0);
|
||||
return { value: total >= 1000 ? `$${(total / 1000).toFixed(0)}K` : `$${total.toLocaleString()}`, label: "$ Decisions", color: "text-yellow-300" };
|
||||
};
|
||||
|
||||
if (role === "workforce") {
|
||||
const upcoming = events.filter(e => { const d = e.date ? new Date(e.date) : null; if (!d) return false; const days = Math.ceil((d - new Date()) / 86400000); return days >= 0 && days <= 14; });
|
||||
return { value: upcoming.length, label: "Shifts Ahead", color: "text-white" };
|
||||
}
|
||||
|
||||
if (role === "client") {
|
||||
const active = events.filter(e => ["Confirmed", "Active", "Assigned"].includes(e.status));
|
||||
return { value: active.length, label: "Active Orders", color: "text-white" };
|
||||
}
|
||||
|
||||
if (role === "vendor") {
|
||||
return pendingInvoiceVal();
|
||||
}
|
||||
|
||||
// admin, procurement, operator, sector
|
||||
return pendingInvoiceVal();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Main component
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
export default function MorningBrief({ user, events = [], staff = [], invoices = [] }) {
|
||||
const navigate = useNavigate();
|
||||
const [expanded, setExpanded] = useState(null);
|
||||
const { text: greeting, icon: greetingIcon } = getGreeting();
|
||||
const firstName = user?.full_name?.split(" ")[0] || "there";
|
||||
const today = format(new Date(), "EEEE, MMMM d");
|
||||
const role = localStorage.getItem("dev_role_override") || user?.user_role || user?.role || "admin";
|
||||
|
||||
const toggle = (id) => setExpanded(prev => prev === id ? null : id);
|
||||
|
||||
// Build role-specific insights
|
||||
let risks = [], opps = [];
|
||||
if (role === "vendor") {
|
||||
({ risks, opps } = buildVendorInsights(events, staff, invoices));
|
||||
} else if (role === "client") {
|
||||
({ risks, opps } = buildClientInsights(events, invoices));
|
||||
} else if (role === "workforce") {
|
||||
({ risks, opps } = buildWorkforceInsights(staff, events));
|
||||
} else if (role === "procurement" || role === "operator" || role === "sector") {
|
||||
({ risks, opps } = buildProcurementInsights(events, staff, invoices));
|
||||
} else {
|
||||
// admin
|
||||
({ risks, opps } = buildAdminInsights(events, staff, invoices));
|
||||
}
|
||||
|
||||
const top3Risks = risks.slice(0, 3);
|
||||
const top3Opps = opps.slice(0, 3);
|
||||
const totalCount = top3Risks.length + top3Opps.length;
|
||||
|
||||
const stat3 = getStatStrip(role, events, staff, invoices);
|
||||
|
||||
// Role-specific subtitle
|
||||
const roleSubtitle = {
|
||||
admin: "Platform-wide health summary",
|
||||
procurement: "Network compliance & vendor overview",
|
||||
operator: "Operations & vendor status",
|
||||
sector: "Sector performance snapshot",
|
||||
client: "Your orders & service summary",
|
||||
vendor: "Your roster & revenue summary",
|
||||
workforce: "Your shifts & certifications",
|
||||
}[role] || "Your daily summary";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-br from-[#0039A6] to-[#3C4F5C] rounded-2xl p-4 text-white">
|
||||
<div className="flex items-start justify-between mb-1">
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 text-white/60 text-xs mb-1">
|
||||
<Coffee className="w-3.5 h-3.5" />
|
||||
<span>Mission Control · {today}</span>
|
||||
</div>
|
||||
<h3 className="font-bold text-lg leading-tight">
|
||||
{greetingIcon} {greeting}, {firstName}
|
||||
</h3>
|
||||
<p className="text-white/50 text-[10px] mt-0.5">{roleSubtitle}</p>
|
||||
<p className="text-white/60 text-xs mt-1">
|
||||
{totalCount > 0
|
||||
? `${totalCount} item${totalCount > 1 ? "s" : ""} need your attention — tap each to see what's at stake.`
|
||||
: "You're all caught up — great start to the day!"}
|
||||
</p>
|
||||
</div>
|
||||
{totalCount > 0 && (
|
||||
<Badge className="bg-white/20 text-white border-0 text-xs font-bold px-2 flex-shrink-0">
|
||||
{totalCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-3 mt-3 pt-3 border-t border-white/20">
|
||||
<div className="text-center flex-1">
|
||||
<p className="text-lg font-bold">{top3Risks.length}</p>
|
||||
<p className="text-[10px] text-white/60">Risks</p>
|
||||
</div>
|
||||
<div className="w-px bg-white/20" />
|
||||
<div className="text-center flex-1">
|
||||
<p className="text-lg font-bold">{top3Opps.length}</p>
|
||||
<p className="text-[10px] text-white/60">Opportunities</p>
|
||||
</div>
|
||||
<div className="w-px bg-white/20" />
|
||||
<div className="text-center flex-1">
|
||||
<p className={`text-lg font-bold ${stat3.color}`}>{stat3.value}</p>
|
||||
<p className="text-[10px] text-white/60">{stat3.label}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Risks */}
|
||||
{top3Risks.length > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 mb-2 px-1">
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-red-500" />
|
||||
<span className="text-xs font-bold text-slate-600 uppercase tracking-wide">Top Risks</span>
|
||||
<span className="text-[10px] text-slate-400 ml-1">— tap to see what's at stake & how to fix</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{top3Risks.map(risk => (
|
||||
<BriefCard key={risk.id} item={risk} isRisk={true} expanded={expanded} onToggle={toggle} onNavigate={(route) => navigate(createPageUrl(route))} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Opportunities */}
|
||||
{top3Opps.length > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 mb-2 px-1">
|
||||
<TrendingUp className="w-3.5 h-3.5 text-emerald-500" />
|
||||
<span className="text-xs font-bold text-slate-600 uppercase tracking-wide">Opportunities</span>
|
||||
<span className="text-[10px] text-slate-400 ml-1">— tap to see how to capture</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{top3Opps.map(opp => (
|
||||
<BriefCard key={opp.id} item={opp} isRisk={false} expanded={expanded} onToggle={toggle} onNavigate={(route) => navigate(createPageUrl(route))} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* All clear */}
|
||||
{top3Risks.length === 0 && top3Opps.length === 0 && (
|
||||
<div className="rounded-xl border border-emerald-200 bg-emerald-50 p-4 text-center">
|
||||
<CheckCircle2 className="w-8 h-8 text-emerald-500 mx-auto mb-2" />
|
||||
<p className="text-sm font-semibold text-emerald-700">All clear!</p>
|
||||
<p className="text-xs text-emerald-600 mt-1">No critical items today — great execution!</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom shortcut — role-aware */}
|
||||
{(role === "admin" || role === "vendor" || role === "procurement" || role === "operator" || role === "sector") && (
|
||||
<button
|
||||
onClick={() => navigate(createPageUrl("SavingsEngine"))}
|
||||
className="w-full flex items-center justify-between p-3 rounded-xl bg-gradient-to-r from-[#F9E732]/20 to-[#D4B886]/20 border border-[#D4B886]/30 hover:from-[#F9E732]/30 hover:to-[#D4B886]/30 transition-all group"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<DollarSign className="w-4 h-4 text-[#C8A876]" />
|
||||
<span className="text-xs font-semibold text-[#3C4F5C]">View Full Savings Analysis</span>
|
||||
</div>
|
||||
<ArrowRight className="w-3.5 h-3.5 text-[#C8A876] group-hover:translate-x-0.5 transition-transform" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{role === "workforce" && (
|
||||
<button
|
||||
onClick={() => navigate(createPageUrl("WorkerShiftProposals"))}
|
||||
className="w-full flex items-center justify-between p-3 rounded-xl bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-200 hover:from-blue-100 hover:to-indigo-100 transition-all group"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4 text-blue-600" />
|
||||
<span className="text-xs font-semibold text-[#3C4F5C]">Browse Available Shifts</span>
|
||||
</div>
|
||||
<ArrowRight className="w-3.5 h-3.5 text-blue-400 group-hover:translate-x-0.5 transition-transform" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{role === "client" && (
|
||||
<button
|
||||
onClick={() => navigate(createPageUrl("CreateEvent"))}
|
||||
className="w-full flex items-center justify-between p-3 rounded-xl bg-blue-50 border border-blue-100 hover:bg-blue-100 transition-all group"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Briefcase className="w-4 h-4 text-[#0A39DF]" />
|
||||
<span className="text-xs font-semibold text-slate-700">Place a New Order</span>
|
||||
</div>
|
||||
<ArrowRight className="w-3.5 h-3.5 text-[#0A39DF] group-hover:translate-x-0.5 transition-transform" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{(role === "client" || role === "vendor") && (
|
||||
<button
|
||||
onClick={() => navigate(createPageUrl("PredictiveIntelligence"))}
|
||||
className="w-full flex items-center justify-between p-3 rounded-xl bg-gradient-to-r from-[#0D1B2A]/5 to-[#002FE3]/5 border border-[#002FE3]/20 hover:from-[#0D1B2A]/10 hover:to-[#002FE3]/10 transition-all group"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Brain className="w-4 h-4 text-[#002FE3]" />
|
||||
<span className="text-xs font-semibold text-[#3C4F5C]">View AI Demand Forecast</span>
|
||||
</div>
|
||||
<ArrowRight className="w-3.5 h-3.5 text-[#002FE3] group-hover:translate-x-0.5 transition-transform" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
src/components/dashboard/QuickMetrics.jsx
Normal file
43
src/components/dashboard/QuickMetrics.jsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from "react";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { createPageUrl } from "@/utils";
|
||||
|
||||
export default function QuickMetrics({ title, description, icon: Icon, metrics, route, gradient }) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Card className="border-slate-200 hover:shadow-xl transition-all duration-300 hover:-translate-y-1 cursor-pointer group">
|
||||
<CardHeader className={`bg-gradient-to-br ${gradient} border-b border-slate-100`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-[#1C323E] text-base mb-1 flex items-center gap-2">
|
||||
<Icon className="w-5 h-5 text-[#0A39DF]" />
|
||||
{title}
|
||||
</CardTitle>
|
||||
<p className="text-xs text-slate-500">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6">
|
||||
<div className="space-y-3 mb-4">
|
||||
{metrics.map((metric, index) => (
|
||||
<div key={index} className="flex items-center justify-between">
|
||||
<span className="text-sm text-slate-600">{metric.label}</span>
|
||||
<span className={`font-bold text-lg ${metric.color}`}>{metric.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => navigate(createPageUrl(route))}
|
||||
className="w-full bg-[#0039A6] hover:bg-[#0039A6]/90 group-hover:shadow-md transition-all"
|
||||
>
|
||||
View Dashboard
|
||||
<ArrowRight className="w-4 h-4 ml-2 group-hover:translate-x-1 transition-transform" />
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
145
src/components/dashboard/SmartToDo.jsx
Normal file
145
src/components/dashboard/SmartToDo.jsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import React from "react";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CheckCircle2, AlertCircle, Clock, FileText, Users, Award, DollarSign, Calendar, MessageSquare, TrendingUp, ArrowRight } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { createPageUrl } from "@/utils";
|
||||
|
||||
export default function SmartToDo({ role, tasks = [], onTaskClick }) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const getPriorityColor = (priority) => {
|
||||
const colors = {
|
||||
critical: "bg-red-100 text-red-700 border-red-300",
|
||||
high: "bg-orange-100 text-orange-700 border-orange-300",
|
||||
medium: "bg-blue-100 text-blue-700 border-blue-300",
|
||||
low: "bg-slate-100 text-slate-600 border-slate-300"
|
||||
};
|
||||
return colors[priority] || colors.medium;
|
||||
};
|
||||
|
||||
const getIconForType = (type) => {
|
||||
const icons = {
|
||||
invoice: FileText,
|
||||
order: Calendar,
|
||||
compliance: Award,
|
||||
staff: Users,
|
||||
message: MessageSquare,
|
||||
budget: DollarSign,
|
||||
incident: AlertCircle,
|
||||
approval: CheckCircle2,
|
||||
performance: TrendingUp
|
||||
};
|
||||
return icons[type] || Clock;
|
||||
};
|
||||
|
||||
const handleTaskClick = (task) => {
|
||||
if (task.action) {
|
||||
navigate(createPageUrl(task.action));
|
||||
}
|
||||
if (onTaskClick) {
|
||||
onTaskClick(task);
|
||||
}
|
||||
};
|
||||
|
||||
const criticalTasks = tasks.filter(t => t.priority === 'critical');
|
||||
const highTasks = tasks.filter(t => t.priority === 'high');
|
||||
const otherTasks = tasks.filter(t => t.priority !== 'critical' && t.priority !== 'high');
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-bold text-[#3C4F5C] flex items-center gap-1.5">
|
||||
<CheckCircle2 className="w-4 h-4 text-[#0039A6]" />
|
||||
Action Items
|
||||
</h3>
|
||||
{(criticalTasks.length > 0 || highTasks.length > 0) && (
|
||||
<Badge className="bg-red-500 text-white text-xs px-2 py-0.5">
|
||||
{criticalTasks.length + highTasks.length}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{tasks.length === 0 ? (
|
||||
<Card className="border-slate-200">
|
||||
<CardContent className="p-4 text-center">
|
||||
<CheckCircle2 className="w-8 h-8 mx-auto mb-2 text-green-500" />
|
||||
<p className="text-sm font-semibold text-slate-700">All caught up!</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{/* Critical & High Priority */}
|
||||
{[...criticalTasks, ...highTasks].map((task, idx) => {
|
||||
const Icon = getIconForType(task.type);
|
||||
const isCritical = task.priority === 'critical';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`priority-${idx}`}
|
||||
onClick={() => handleTaskClick(task)}
|
||||
className={`group relative overflow-hidden rounded-lg border ${
|
||||
isCritical
|
||||
? 'border-red-200 bg-red-50/50 hover:bg-red-50'
|
||||
: 'border-orange-200 bg-orange-50/50 hover:bg-orange-50'
|
||||
} transition-all cursor-pointer p-2.5`}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className={`w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0 ${
|
||||
isCritical ? 'bg-red-100' : 'bg-orange-100'
|
||||
}`}>
|
||||
<Icon className={`w-3.5 h-3.5 ${isCritical ? 'text-red-600' : 'text-orange-600'}`} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-semibold text-xs text-[#3C4F5C] leading-tight mb-0.5">
|
||||
{task.title}
|
||||
</p>
|
||||
<p className="text-[10px] text-slate-600 leading-snug line-clamp-2">{task.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`absolute top-0 right-0 w-8 h-8 -mr-4 -mt-4 rotate-45 ${
|
||||
isCritical ? 'bg-red-500' : 'bg-orange-500'
|
||||
}`}></div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Other Tasks */}
|
||||
{otherTasks.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{otherTasks.slice(0, 3).map((task, idx) => {
|
||||
const Icon = getIconForType(task.type);
|
||||
return (
|
||||
<div
|
||||
key={`other-${idx}`}
|
||||
onClick={() => handleTaskClick(task)}
|
||||
className="p-2 rounded-md hover:bg-slate-50 transition-all cursor-pointer group flex items-center gap-2"
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5 text-slate-500 flex-shrink-0" />
|
||||
<p className="font-medium text-xs text-[#3C4F5C] truncate flex-1">{task.title}</p>
|
||||
<ArrowRight className="w-3 h-3 text-slate-400 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* View More */}
|
||||
{otherTasks.length > 3 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full text-[#0039A6] hover:bg-blue-50 text-xs h-7"
|
||||
onClick={() => navigate(createPageUrl("TaskBoard"))}
|
||||
>
|
||||
+{otherTasks.length - 3} more
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
120
src/components/demand/PositionDetail.jsx
Normal file
120
src/components/demand/PositionDetail.jsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import React from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { createPageUrl } from "@/utils";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Users, MapPin, Navigation, Calendar, ClipboardList,
|
||||
} from "lucide-react";
|
||||
import { format, parseISO, isValid } from "date-fns";
|
||||
|
||||
const safeParseDate = (dateString) => {
|
||||
if (!dateString) return null;
|
||||
try {
|
||||
if (typeof dateString === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
|
||||
const [y, m, d] = dateString.split('-').map(Number);
|
||||
const date = new Date(y, m - 1, d);
|
||||
return isValid(date) ? date : null;
|
||||
}
|
||||
const date = typeof dateString === 'string' ? parseISO(dateString) : new Date(dateString);
|
||||
return isValid(date) ? date : null;
|
||||
} catch { return null; }
|
||||
};
|
||||
|
||||
const locTokens = (str) => {
|
||||
if (!str) return [];
|
||||
return String(str)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s,]/g, ' ')
|
||||
.split(/[\s,]+/)
|
||||
.filter((t) => t.length > 2 && !['the', 'and', 'ave', 'st', 'street', 'blvd', 'boulevard', 'avenue', 'drive', 'rd', 'road', 'suite'].includes(t));
|
||||
};
|
||||
|
||||
const proximityLevel = (staff, eventLoc) => {
|
||||
const sLoc = staff.hub_location || staff.city || staff.address || staff.event_location || '';
|
||||
const eTokens = new Set(locTokens(eventLoc));
|
||||
const sTokens = locTokens(sLoc);
|
||||
if (eTokens.size === 0 || sTokens.length === 0) return { level: 'unknown', label: 'Unknown' };
|
||||
const shared = sTokens.filter((t) => eTokens.has(t)).length;
|
||||
if (shared > 0) return { level: 'local', label: 'Local' };
|
||||
return { level: 'distant', label: 'Distant' };
|
||||
};
|
||||
|
||||
export default function PositionDetail({ position }) {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Orders contributing to this demand */}
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2 flex items-center gap-1">
|
||||
<ClipboardList className="w-3.5 h-3.5" /> Orders needing {position.role}
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
{position.orders.map((o) => {
|
||||
const d = safeParseDate(o.eventDate);
|
||||
return (
|
||||
<div key={o.eventId} className="flex items-center justify-between bg-white border border-slate-200 rounded-md px-3 py-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-slate-800 truncate">{o.eventName}</p>
|
||||
<p className="text-xs text-slate-500 flex items-center gap-2">
|
||||
<Calendar className="w-3 h-3" />{d ? format(d, 'MMM d') : '—'}
|
||||
<MapPin className="w-3 h-3 ml-1" />{o.eventLocation || '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-2">
|
||||
<Badge variant="outline" className="text-xs">Need {o.demand}</Badge>
|
||||
<Badge className="bg-emerald-100 text-emerald-700 text-xs">{o.assigned} filled</Badge>
|
||||
{o.isRapid && <Badge className="bg-red-600 text-white text-xs">RAPID</Badge>}
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={() => navigate(createPageUrl(`EventDetail?id=${o.eventId}`))}>
|
||||
Open
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Available employees + proximity */}
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2 flex items-center gap-1">
|
||||
<Users className="w-3.5 h-3.5" /> Eligible & available employees ({position.available})
|
||||
</p>
|
||||
{position.eligiblePool.length === 0 ? (
|
||||
<p className="text-sm text-red-600">No eligible & available employees match this position.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
{position.eligiblePool.slice(0, 18).map((s) => {
|
||||
const eventLocations = [...new Set(position.orders.map((o) => o.eventLocation).filter(Boolean))];
|
||||
const prox = eventLocations.length === 0
|
||||
? { level: 'unknown', label: 'Unknown' }
|
||||
: eventLocations.some((loc) => proximityLevel(s, loc).level === 'local')
|
||||
? { level: 'local', label: 'Local' }
|
||||
: eventLocations.some((loc) => proximityLevel(s, loc).level !== 'unknown')
|
||||
? { level: 'distant', label: 'Distant' }
|
||||
: { level: 'unknown', label: 'Unknown' };
|
||||
const proxColor = prox.level === 'local' ? 'bg-emerald-100 text-emerald-700' : prox.level === 'distant' ? 'bg-amber-100 text-amber-700' : 'bg-slate-100 text-slate-600';
|
||||
const staffLoc = s.hub_location || s.city || s.address || '—';
|
||||
return (
|
||||
<div key={s.id} className="flex items-center justify-between bg-white border border-slate-200 rounded-md px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-slate-800 truncate">{s.employee_name}</p>
|
||||
<p className="text-xs text-slate-500 truncate flex items-center gap-1">
|
||||
<MapPin className="w-3 h-3" /> {staffLoc}
|
||||
</p>
|
||||
</div>
|
||||
<Badge className={`${proxColor} text-xs flex items-center gap-1`}>
|
||||
<Navigation className="w-3 h-3" />{prox.label}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{position.eligiblePool.length > 18 && (
|
||||
<p className="text-xs text-slate-400 col-span-full">+{position.eligiblePool.length - 18} more available…</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
102
src/components/demand/PositionTable.jsx
Normal file
102
src/components/demand/PositionTable.jsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import React from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
CheckCircle, XCircle, ChevronDown, ChevronRight, MapPin,
|
||||
} from "lucide-react";
|
||||
import PositionDetail from "./PositionDetail";
|
||||
|
||||
export default function PositionTable({ positions, expanded, setExpanded }) {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-white border-b border-slate-100">
|
||||
<tr>
|
||||
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-600 uppercase tracking-wider">Position</th>
|
||||
<th className="text-center py-3 px-4 text-xs font-semibold text-slate-600 uppercase tracking-wider">Orders</th>
|
||||
<th className="text-center py-3 px-4 text-xs font-semibold text-slate-600 uppercase tracking-wider">Total Demand</th>
|
||||
<th className="text-center py-3 px-4 text-xs font-semibold text-slate-600 uppercase tracking-wider">Total Pool</th>
|
||||
<th className="text-center py-3 px-4 text-xs font-semibold text-slate-600 uppercase tracking-wider">Scheduled Elsewhere</th>
|
||||
<th className="text-center py-3 px-4 text-xs font-semibold text-slate-600 uppercase tracking-wider">Unavailable</th>
|
||||
<th className="text-center py-3 px-4 text-xs font-semibold text-slate-600 uppercase tracking-wider">Missing Cert</th>
|
||||
<th className="text-center py-3 px-4 text-xs font-semibold text-slate-600 uppercase tracking-wider">Eligible & Available</th>
|
||||
<th className="text-center py-3 px-4 text-xs font-semibold text-slate-600 uppercase tracking-wider">Can Fill?</th>
|
||||
<th className="text-center py-3 px-4 text-xs font-semibold text-slate-600 uppercase tracking-wider">Staffing Gap</th>
|
||||
<th className="text-center py-3 px-4 text-xs font-semibold text-slate-600 uppercase tracking-wider">Fill Rate</th>
|
||||
<th className="text-center py-3 px-4 text-xs font-semibold text-slate-600 uppercase tracking-wider">Assigned</th>
|
||||
<th className="text-center py-3 px-4 text-xs font-semibold text-slate-600 uppercase tracking-wider">Local</th>
|
||||
<th className="text-center py-3 px-4 text-xs font-semibold text-slate-600 uppercase tracking-wider"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{positions.map((p) => {
|
||||
const isOpen = expanded === p.role;
|
||||
return (
|
||||
<React.Fragment key={p.role}>
|
||||
<tr className="border-b border-slate-100 hover:bg-slate-50/50">
|
||||
<td className="py-3 px-4">
|
||||
<span className="text-sm font-semibold text-slate-800">{p.role}</span>
|
||||
</td>
|
||||
<td className="text-center py-3 px-4">
|
||||
<Badge variant="outline" className="text-xs">{p.orderCount}</Badge>
|
||||
</td>
|
||||
<td className="text-center py-3 px-4">
|
||||
<span className="text-sm font-bold text-slate-700">{p.demand}</span>
|
||||
</td>
|
||||
<td className="text-center py-3 px-4">
|
||||
<span className="text-sm font-semibold text-slate-600">{p.totalPool}</span>
|
||||
</td>
|
||||
<td className="text-center py-3 px-4">
|
||||
<span className="text-sm text-slate-500">{p.scheduledElsewhere}</span>
|
||||
</td>
|
||||
<td className="text-center py-3 px-4">
|
||||
<span className="text-sm text-red-500">{p.unavailable}</span>
|
||||
</td>
|
||||
<td className="text-center py-3 px-4">
|
||||
<span className="text-sm text-amber-600">{p.missingCert}</span>
|
||||
</td>
|
||||
<td className="text-center py-3 px-4">
|
||||
<span className="text-sm font-bold text-orange-700">{p.available}</span>
|
||||
</td>
|
||||
<td className="text-center py-3 px-4">
|
||||
{p.canFill ? (
|
||||
<Badge className="bg-emerald-500 text-white text-xs"><CheckCircle className="w-3 h-3 mr-1" />Yes</Badge>
|
||||
) : (
|
||||
<Badge className="bg-red-500 text-white text-xs"><XCircle className="w-3 h-3 mr-1" />No</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-center py-3 px-4">
|
||||
<span className={`text-sm font-bold ${p.staffingGap > 0 ? 'text-red-600' : 'text-emerald-600'}`}>{p.staffingGap}</span>
|
||||
</td>
|
||||
<td className="text-center py-3 px-4">
|
||||
<span className={`text-sm font-semibold ${p.fillRate >= 100 ? 'text-emerald-600' : p.fillRate >= 50 ? 'text-amber-600' : 'text-red-600'}`}>{p.fillRate}%</span>
|
||||
</td>
|
||||
<td className="text-center py-3 px-4">
|
||||
<span className="text-sm font-semibold text-emerald-700">{p.assigned}</span>
|
||||
</td>
|
||||
<td className="text-center py-3 px-4">
|
||||
<span className="text-sm text-slate-700 flex items-center justify-center gap-1">
|
||||
<MapPin className="w-3 h-3 text-[#0039A6]" />{p.localAvailable}
|
||||
</span>
|
||||
</td>
|
||||
<td className="text-center py-3 px-4">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setExpanded(isOpen ? null : p.role)}>
|
||||
{isOpen ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
{isOpen && (
|
||||
<tr className="bg-slate-50/40">
|
||||
<td colSpan={14} className="px-6 py-4">
|
||||
<PositionDetail position={p} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
143
src/components/demand/SupplyVsDemandCard.jsx
Normal file
143
src/components/demand/SupplyVsDemandCard.jsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import React from "react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Info, AlertTriangle } from "lucide-react";
|
||||
|
||||
const COLOR = {
|
||||
blue: "#3f51b5",
|
||||
lightBlue: "#29b6f6",
|
||||
green: "#66bb6a",
|
||||
orange: "#ff7043",
|
||||
yellow: "#ffca28",
|
||||
red: "#ef5350",
|
||||
track: "#eceff1",
|
||||
text: "#1a1a1a",
|
||||
alertBg: "#fce4e4",
|
||||
alertBorder: "#ef5350",
|
||||
};
|
||||
|
||||
function Donut({ percent, color }) {
|
||||
const size = 120;
|
||||
const stroke = 14;
|
||||
const r = (size - stroke) / 2;
|
||||
const c = 2 * Math.PI * r;
|
||||
const pct = Math.max(0, Math.min(100, percent));
|
||||
const dash = (pct / 100) * c;
|
||||
return (
|
||||
<div className="relative" style={{ width: size, height: size }}>
|
||||
<svg width={size} height={size} className="-rotate-90">
|
||||
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke={COLOR.track} strokeWidth={stroke} />
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={stroke}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${dash} ${c - dash}`}
|
||||
style={{ transition: "stroke-dasharray 0.6s ease" }}
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className="text-2xl font-bold" style={{ color: COLOR.text }}>{pct}%</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricRow({ color, label, value }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: color }} />
|
||||
<span className="text-sm text-slate-600">{label}</span>
|
||||
</div>
|
||||
<span className="text-sm font-bold" style={{ color: COLOR.text }}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SupplyVsDemandCard({ position, onClick, expanded }) {
|
||||
const demand = position.demand || 0;
|
||||
const confirmed = position.assigned || 0;
|
||||
const gap = Math.max(demand - confirmed, 0);
|
||||
const coveragePct = demand > 0 ? Math.round((confirmed / demand) * 100) : 0;
|
||||
const qualified = Math.max((position.totalPool || 0) - (position.missingCert || 0), 0);
|
||||
|
||||
const donutColor = coveragePct >= 100 ? COLOR.green : coveragePct >= 50 ? COLOR.yellow : COLOR.red;
|
||||
const critical = gap > 0;
|
||||
|
||||
return (
|
||||
<Card className="border border-slate-200 shadow-sm overflow-hidden">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-bold uppercase tracking-wide" style={{ color: COLOR.text }}>
|
||||
Supply vs Demand — {position.role}
|
||||
</h3>
|
||||
<Info className="w-4 h-4 text-slate-400" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
|
||||
{/* Left metric list */}
|
||||
<div className="space-y-0.5">
|
||||
<MetricRow color={COLOR.blue} label="Total in pool" value={position.totalPool || 0} />
|
||||
<MetricRow color={COLOR.lightBlue} label="Qualified (cert valid)" value={qualified} />
|
||||
<MetricRow color={COLOR.green} label="Eligible & available" value={position.available || 0} />
|
||||
<MetricRow color={COLOR.orange} label="Eligible not assigned" value={Math.max((position.available || 0) - confirmed, 0)} />
|
||||
<MetricRow color={COLOR.green} label="Confirmed" value={confirmed} />
|
||||
</div>
|
||||
|
||||
{/* Center donut */}
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<Donut percent={coveragePct} color={donutColor} />
|
||||
<div className="flex items-center gap-1 mt-2">
|
||||
<span className="text-xs text-slate-500">Supply Coverage</span>
|
||||
<Info className="w-3 h-3 text-slate-300" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right status indicators */}
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-slate-500 uppercase">Demand</p>
|
||||
<p className="text-3xl font-bold" style={{ color: COLOR.blue }}>{demand}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-slate-500 uppercase">Confirmed</p>
|
||||
<p className="text-3xl font-bold" style={{ color: COLOR.green }}>{confirmed}</p>
|
||||
</div>
|
||||
<div className="w-12 border-t border-slate-200 my-1" />
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-slate-500 uppercase">Gap</p>
|
||||
<p className="text-3xl font-bold" style={{ color: COLOR.red }}>{gap}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{critical && (
|
||||
<div
|
||||
className="mt-4 flex items-start gap-2 rounded-md p-3"
|
||||
style={{ backgroundColor: COLOR.alertBg, border: `1px solid ${COLOR.alertBorder}` }}
|
||||
>
|
||||
<AlertTriangle className="w-4 h-4 flex-shrink-0 mt-0.5" style={{ color: COLOR.red }} />
|
||||
<div>
|
||||
<p className="text-sm font-bold" style={{ color: COLOR.red }}>Critical Supply Gap</p>
|
||||
<p className="text-xs" style={{ color: "#7a1a1a" }}>
|
||||
Only {coveragePct}% of demand is currently covered. {gap} additional {position.role}{gap > 1 ? 's' : ''} required.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{onClick && (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="mt-3 w-full text-xs text-[#0039A6] font-medium hover:underline"
|
||||
>
|
||||
{expanded ? "Hide details" : "View orders & eligible staff"}
|
||||
</button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
34
src/components/demand/ViewToggle.jsx
Normal file
34
src/components/demand/ViewToggle.jsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from "react";
|
||||
import { List, LayoutGrid } from "lucide-react";
|
||||
|
||||
export default function ViewToggle({ viewMode, setViewMode }) {
|
||||
const btn = "h-9 w-9 rounded-md flex items-center justify-center transition-all";
|
||||
return (
|
||||
<div className="inline-flex items-center gap-1 rounded-lg border border-[#0047E1] bg-white p-1 shadow-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode('list')}
|
||||
className={`${btn} ${
|
||||
viewMode === 'list'
|
||||
? 'bg-[#0047E1] text-white shadow-md'
|
||||
: 'text-[#0047E1] hover:bg-blue-50'
|
||||
}`}
|
||||
title="List view"
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode('grid')}
|
||||
className={`${btn} ${
|
||||
viewMode === 'grid'
|
||||
? 'bg-[#0047E1] text-white shadow-md'
|
||||
: 'text-[#0047E1] hover:bg-blue-50'
|
||||
}`}
|
||||
title="Grid view"
|
||||
>
|
||||
<LayoutGrid className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
237
src/components/dev/RoleSwitcher.jsx
Normal file
237
src/components/dev/RoleSwitcher.jsx
Normal file
@@ -0,0 +1,237 @@
|
||||
import React, { useState } from "react";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Settings, X, RefreshCw, Shield, Users, Package, Building2, UserCheck, Briefcase, HardHat } from "lucide-react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
const roles = [
|
||||
{ value: "admin", label: "Administrator", icon: Shield, color: "from-red-500 to-red-700" },
|
||||
{ value: "procurement", label: "Procurement", icon: Package, color: "from-purple-500 to-purple-700" },
|
||||
{ value: "operator", label: "Operator", icon: Users, color: "from-blue-500 to-blue-700" },
|
||||
{ value: "sector", label: "Sector Manager", icon: Building2, color: "from-cyan-500 to-cyan-700" },
|
||||
{ value: "client", label: "Client", icon: Briefcase, color: "from-green-500 to-green-700" },
|
||||
{ value: "vendor", label: "Vendor", icon: Package, color: "from-amber-500 to-amber-700" },
|
||||
{ value: "workforce", label: "Workforce", icon: HardHat, color: "from-slate-500 to-slate-700" },
|
||||
];
|
||||
|
||||
export default function RoleSwitcher() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isMinimized, setIsMinimized] = useState(true);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: user, refetch } = useQuery({
|
||||
queryKey: ['current-user'],
|
||||
queryFn: () => base44.auth.me(),
|
||||
});
|
||||
|
||||
const currentRole = localStorage.getItem("dev_role_override") || user?.user_role || user?.role || "admin";
|
||||
const currentRoleData = roles.find(r => r.value === currentRole);
|
||||
|
||||
const [switching, setSwitching] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const handleRoleChange = async (newRole) => {
|
||||
setSwitching(true);
|
||||
setError(null);
|
||||
try {
|
||||
await base44.auth.updateMe({ user_role: newRole });
|
||||
} catch (err) {
|
||||
// If API update fails (e.g. app owner restriction), fall back to localStorage
|
||||
console.warn("API role update failed, using localStorage fallback:", err.message);
|
||||
}
|
||||
// Always store in localStorage so layout picks it up
|
||||
localStorage.setItem("dev_role_override", newRole);
|
||||
queryClient.invalidateQueries();
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Floating Toggle Button */}
|
||||
<AnimatePresence>
|
||||
{!isOpen && (
|
||||
<motion.div
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0, opacity: 0 }}
|
||||
className="krow-dock-3 z-30"
|
||||
>
|
||||
<Button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="w-14 h-14 rounded-full bg-gradient-to-br from-purple-500 to-purple-700 hover:from-purple-600 hover:to-purple-800 shadow-lg"
|
||||
title="Role Switcher (Dev Tool)"
|
||||
>
|
||||
<Settings className="w-6 h-6 text-white animate-spin-slow" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Role Switcher Panel */}
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ scale: 0, opacity: 0, x: 100 }}
|
||||
animate={{ scale: 1, opacity: 1, x: 0 }}
|
||||
exit={{ scale: 0, opacity: 0, x: 100 }}
|
||||
className="fixed z-40 inset-x-3 bottom-3 sm:inset-x-auto sm:right-6 sm:w-[400px]"
|
||||
style={{
|
||||
bottom: 'var(--krow-dock-base)',
|
||||
maxWidth: 'calc(100vw - 1.5rem)',
|
||||
maxHeight: 'calc(100dvh - 6rem)',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
<Card className="shadow-2xl border-2 border-purple-300 overflow-hidden">
|
||||
{/* Header */}
|
||||
<CardHeader className="bg-gradient-to-br from-purple-500 to-purple-700 text-white p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings className="w-5 h-5" />
|
||||
<CardTitle className="text-white text-base">Role Switcher</CardTitle>
|
||||
<Badge className="bg-yellow-400 text-yellow-900 text-xs">DEV</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setIsMinimized(!isMinimized)}
|
||||
className="text-white hover:bg-white/20 h-8 w-8"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="text-white hover:bg-white/20 h-8 w-8"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-4 space-y-4">
|
||||
{/* Current Role Display */}
|
||||
<div className="p-4 bg-gradient-to-br from-slate-50 to-slate-100 rounded-lg border-2 border-slate-200">
|
||||
<p className="text-xs text-slate-500 mb-2">Current Role</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 bg-gradient-to-br ${currentRoleData?.color} rounded-lg flex items-center justify-center`}>
|
||||
{currentRoleData?.icon && <currentRoleData.icon className="w-5 h-5 text-white" />}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-bold text-[#1C323E]">{currentRoleData?.label}</p>
|
||||
<p className="text-xs text-slate-500">{user?.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Role Selector */}
|
||||
<div>
|
||||
<label className="text-sm font-semibold text-slate-700 mb-2 block">
|
||||
Switch to Role
|
||||
</label>
|
||||
<Select value={currentRole} onValueChange={handleRoleChange} disabled={switching}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{roles.map((role) => (
|
||||
<SelectItem key={role.value} value={role.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<role.icon className="w-4 h-4" />
|
||||
<span>{role.label}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Quick Role Grid */}
|
||||
{!isMinimized && (
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-700 mb-3">Quick Switch</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{roles.map((role) => (
|
||||
<Button
|
||||
key={role.value}
|
||||
variant={currentRole === role.value ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => handleRoleChange(role.value)}
|
||||
className={currentRole === role.value ? `bg-gradient-to-br ${role.color} text-white` : ""}
|
||||
>
|
||||
<role.icon className="w-4 h-4 mr-2" />
|
||||
{role.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warning */}
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p className="text-xs text-red-800">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
{switching && (
|
||||
<div className="p-3 bg-blue-50 border border-blue-200 rounded-lg text-center">
|
||||
<p className="text-xs text-blue-800 font-semibold">Switching role... reloading</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<p className="text-xs text-yellow-800">
|
||||
<strong>⚠️ Development Tool:</strong> This switcher is for testing only.
|
||||
Changes will persist until you switch again or log out.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
{!isMinimized && (
|
||||
<div className="space-y-2 text-xs text-slate-600">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="w-1 h-1 bg-slate-400 rounded-full mt-1.5" />
|
||||
<p><strong>Admin:</strong> Full access to all features</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="w-1 h-1 bg-slate-400 rounded-full mt-1.5" />
|
||||
<p><strong>Procurement:</strong> Vendor management & orders</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="w-1 h-1 bg-slate-400 rounded-full mt-1.5" />
|
||||
<p><strong>Operator/Sector:</strong> Event & staff management</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="w-1 h-1 bg-slate-400 rounded-full mt-1.5" />
|
||||
<p><strong>Client:</strong> Create orders, view invoices</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="w-1 h-1 bg-slate-400 rounded-full mt-1.5" />
|
||||
<p><strong>Vendor:</strong> Manage own staff & orders</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="w-1 h-1 bg-slate-400 rounded-full mt-1.5" />
|
||||
<p><strong>Workforce:</strong> View shifts & earnings</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
109
src/components/documents/DocumentApprovalCard.jsx
Normal file
109
src/components/documents/DocumentApprovalCard.jsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import React from "react";
|
||||
import { FileText, Eye, Clock, XCircle, AlertTriangle, ShieldCheck } from "lucide-react";
|
||||
import { format, parseISO, isValid, differenceInDays } from "date-fns";
|
||||
|
||||
const STATUS_META = {
|
||||
approved: { label: "Approved", dot: "bg-emerald-500", text: "text-emerald-700", icon: ShieldCheck },
|
||||
uploaded: { label: "Uploaded", dot: "bg-cyan-500", text: "text-cyan-700", icon: FileText },
|
||||
pending: { label: "Pending", dot: "bg-amber-500", text: "text-amber-700", icon: Clock },
|
||||
expiring: { label: "Expiring", dot: "bg-yellow-500", text: "text-yellow-700", icon: AlertTriangle },
|
||||
expired: { label: "Expired", dot: "bg-red-500", text: "text-red-700", icon: XCircle },
|
||||
rejected: { label: "Rejected", dot: "bg-rose-500", text: "text-rose-700", icon: XCircle },
|
||||
missing: { label: "Missing", dot: "bg-slate-300", text: "text-slate-500", icon: FileText },
|
||||
};
|
||||
|
||||
const safeDate = (d) => {
|
||||
if (!d) return "—";
|
||||
try {
|
||||
const parsed = parseISO(d);
|
||||
return isValid(parsed) ? format(parsed, "MM/dd/yy") : String(d);
|
||||
} catch {
|
||||
return String(d);
|
||||
}
|
||||
};
|
||||
|
||||
const expiryLabel = (doc) => {
|
||||
if (!doc.expiry_date) return null;
|
||||
try {
|
||||
const days = differenceInDays(parseISO(doc.expiry_date), new Date());
|
||||
if (days < 0) return `Expired ${Math.abs(days)}d ago`;
|
||||
if (days <= 30) return `Expires in ${days}d`;
|
||||
return `Exp ${safeDate(doc.expiry_date)}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export default function DocumentApprovalCard({ doc, selected, onToggle }) {
|
||||
const status = doc?.status || "missing";
|
||||
const meta = STATUS_META[status] || STATUS_META.missing;
|
||||
const hasFile = !!doc.document_url;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group flex items-center gap-3 rounded-lg border bg-white px-3 py-2.5 transition-all ${
|
||||
selected ? "border-blue-500 bg-blue-50/50" : "border-slate-200 hover:border-slate-300"
|
||||
} ${status === "missing" ? "opacity-70" : ""}`}
|
||||
>
|
||||
{/* Status dot */}
|
||||
<span className={`h-2 w-2 shrink-0 rounded-full ${meta.dot}`} title={meta.label} />
|
||||
|
||||
{/* Doc type */}
|
||||
<FileText className="h-4 w-4 shrink-0 text-slate-400" />
|
||||
<span className="w-32 sm:w-40 shrink-0 truncate text-sm font-semibold text-slate-900">
|
||||
{doc.document_type}
|
||||
</span>
|
||||
|
||||
{/* Status label */}
|
||||
<span className={`hidden sm:inline shrink-0 text-xs font-medium ${meta.text}`}>
|
||||
{meta.label}
|
||||
</span>
|
||||
|
||||
{/* Meta line */}
|
||||
<div className="hidden md:flex flex-1 items-center gap-4 text-xs text-slate-500 min-w-0">
|
||||
{doc.upload_date && (
|
||||
<span className="shrink-0">Uploaded {safeDate(doc.upload_date)}</span>
|
||||
)}
|
||||
{doc.approved_date && (
|
||||
<span className="shrink-0 text-emerald-700">Approved {safeDate(doc.approved_date)}</span>
|
||||
)}
|
||||
{expiryLabel(doc) && (
|
||||
<span className={`shrink-0 truncate ${status === "expired" ? "text-red-600" : status === "expiring" ? "text-yellow-700" : ""}`}>
|
||||
{expiryLabel(doc)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 md:flex-none" />
|
||||
|
||||
{/* View */}
|
||||
{hasFile ? (
|
||||
<a
|
||||
href={doc.document_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center gap-1.5 shrink-0 rounded-md px-2.5 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-100 hover:text-blue-600"
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5" /> View
|
||||
</a>
|
||||
) : (
|
||||
<span className="shrink-0 text-xs italic text-slate-400 pr-1">No file</span>
|
||||
)}
|
||||
|
||||
{/* Select button */}
|
||||
{status !== "approved" && status !== "missing" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(doc.id)}
|
||||
className={`shrink-0 rounded-md border px-2.5 py-1.5 text-xs font-semibold transition-colors ${
|
||||
selected
|
||||
? "border-blue-600 bg-blue-600 text-white"
|
||||
: "border-slate-300 bg-white text-slate-600 hover:border-blue-400 hover:text-blue-600"
|
||||
}`}
|
||||
>
|
||||
{selected ? "Selected" : "Select"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
632
src/components/events/AIOrderAssistant.jsx
Normal file
632
src/components/events/AIOrderAssistant.jsx
Normal file
@@ -0,0 +1,632 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import {
|
||||
Sparkles, Send, Mic, MicOff, Upload, FileText, Clock,
|
||||
MapPin, Users, Calendar, DollarSign, Zap, Brain,
|
||||
TrendingUp, CheckCircle2, Loader2, Eye, MessageSquare,
|
||||
Image as ImageIcon, Wand2, AlertCircle, ChevronRight, RefreshCw
|
||||
} from "lucide-react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { format } from "date-fns";
|
||||
|
||||
export default function AIOrderAssistant({ onOrderDataExtracted, onClose }) {
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [isListening, setIsListening] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [extractedData, setExtractedData] = useState(null);
|
||||
const [showSuggestions, setShowSuggestions] = useState(true);
|
||||
const [voiceEnabled, setVoiceEnabled] = useState(true);
|
||||
const messagesEndRef = useRef(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: recentEvents } = useQuery({
|
||||
queryKey: ['recent-events-ai'],
|
||||
queryFn: () => base44.entities.Event.list('-created_date', 5),
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
const { data: businesses } = useQuery({
|
||||
queryKey: ['businesses-ai'],
|
||||
queryFn: () => base44.entities.Business.list(),
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const greeting = {
|
||||
id: Date.now(),
|
||||
role: "assistant",
|
||||
content: "👋 **AI Order Assistant 2030**\n\nI'm your intelligent workforce ordering assistant. Just tell me what you need naturally - I understand context, history, and can create complete orders instantly.\n\n**Try saying:**\n• \"I need 5 servers for Friday night\"\n• \"Repeat last week's order but add 2 bartenders\"\n• \"Same as Event #1234 but different date\"\n\nYou can also upload files, speak naturally, or let me analyze your patterns.",
|
||||
timestamp: new Date().toISOString(),
|
||||
type: "greeting"
|
||||
};
|
||||
setMessages([greeting]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages]);
|
||||
|
||||
// Smart suggestions based on context
|
||||
const smartSuggestions = [
|
||||
{
|
||||
icon: RefreshCw,
|
||||
label: "Repeat Last Order",
|
||||
query: "Repeat my last order for next Friday",
|
||||
color: "bg-purple-500"
|
||||
},
|
||||
{
|
||||
icon: TrendingUp,
|
||||
label: "Based on History",
|
||||
query: "Create order based on my usual Friday pattern",
|
||||
color: "bg-blue-500"
|
||||
},
|
||||
{
|
||||
icon: Zap,
|
||||
label: "Quick Template",
|
||||
query: "I need 3 servers and 2 bartenders for Saturday 6pm-midnight",
|
||||
color: "bg-green-500"
|
||||
},
|
||||
{
|
||||
icon: Brain,
|
||||
label: "AI Predict",
|
||||
query: "What staff will I need for a 200-person corporate event?",
|
||||
color: "bg-orange-500"
|
||||
}
|
||||
];
|
||||
|
||||
const handleSendMessage = async (text = input) => {
|
||||
if (!text.trim() && !isListening) return;
|
||||
|
||||
const userMessage = {
|
||||
id: Date.now(),
|
||||
role: "user",
|
||||
content: text,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
setMessages(prev => [...prev, userMessage]);
|
||||
setInput("");
|
||||
setIsProcessing(true);
|
||||
setShowSuggestions(false);
|
||||
|
||||
try {
|
||||
// Simulate AI processing with intelligent response
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
|
||||
// Enhanced AI prompt with context awareness
|
||||
const prompt = `You are an advanced AI workforce ordering assistant in 2030. Analyze this request and extract structured order data.
|
||||
|
||||
User Request: "${text}"
|
||||
|
||||
Recent Order History:
|
||||
${recentEvents.slice(0, 3).map((e, idx) => `${idx + 1}. ${e.event_name} - ${e.business_name} - ${e.requested || 0} staff - ${format(new Date(e.date || Date.now()), 'MMM d, yyyy')}`).join('\n')}
|
||||
|
||||
Available Businesses: ${businesses.slice(0, 5).map(b => b.business_name).join(', ')}
|
||||
|
||||
Instructions:
|
||||
- If user references "last order" or "repeat", use the most recent event data
|
||||
- If user says "usual" or "typical", analyze patterns from history
|
||||
- Be intelligent about inferring missing details (dates, times, quantities)
|
||||
- Suggest optimal staff counts based on event type
|
||||
- Return ONLY valid JSON in this exact format:
|
||||
|
||||
{
|
||||
"event_name": "string",
|
||||
"business_name": "string (from available businesses)",
|
||||
"hub": "string",
|
||||
"date": "YYYY-MM-DD",
|
||||
"order_type": "one_time|rapid|recurring",
|
||||
"shifts": [{
|
||||
"shift_name": "Shift 1",
|
||||
"roles": [{
|
||||
"role": "string (Server, Bartender, Chef, etc)",
|
||||
"count": number,
|
||||
"start_time": "HH:MM",
|
||||
"end_time": "HH:MM",
|
||||
"break_minutes": 30
|
||||
}]
|
||||
}],
|
||||
"notes": "string with any additional context",
|
||||
"ai_confidence": number (0-100),
|
||||
"ai_reasoning": "string explaining the order creation logic"
|
||||
}`;
|
||||
|
||||
const response = await base44.integrations.Core.InvokeLLM({
|
||||
prompt: prompt,
|
||||
add_context_from_internet: false,
|
||||
response_json_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
event_name: { type: "string" },
|
||||
business_name: { type: "string" },
|
||||
hub: { type: "string" },
|
||||
date: { type: "string" },
|
||||
order_type: { type: "string" },
|
||||
shifts: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
shift_name: { type: "string" },
|
||||
roles: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
role: { type: "string" },
|
||||
count: { type: "number" },
|
||||
start_time: { type: "string" },
|
||||
end_time: { type: "string" },
|
||||
break_minutes: { type: "number" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
notes: { type: "string" },
|
||||
ai_confidence: { type: "number" },
|
||||
ai_reasoning: { type: "string" }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const orderData = response;
|
||||
setExtractedData(orderData);
|
||||
|
||||
const assistantMessage = {
|
||||
id: Date.now() + 1,
|
||||
role: "assistant",
|
||||
content: `✨ **Order Created Instantly**\n\n${orderData.ai_reasoning}\n\n**Confidence:** ${orderData.ai_confidence}%\n\nReview the order details below and click "Use This Order" to proceed.`,
|
||||
timestamp: new Date().toISOString(),
|
||||
type: "success",
|
||||
data: orderData
|
||||
};
|
||||
|
||||
setMessages(prev => [...prev, assistantMessage]);
|
||||
|
||||
toast({
|
||||
title: "🎯 AI Order Generated",
|
||||
description: `Created order with ${orderData.shifts?.[0]?.roles?.reduce((sum, r) => sum + (r.count || 0), 0) || 0} staff members`,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = {
|
||||
id: Date.now() + 1,
|
||||
role: "assistant",
|
||||
content: `❌ I couldn't process that request. Please try rephrasing or providing more details.\n\nError: ${error.message}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
type: "error"
|
||||
};
|
||||
setMessages(prev => [...prev, errorMessage]);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVoiceInput = () => {
|
||||
if (!voiceEnabled) {
|
||||
toast({
|
||||
title: "Voice Not Supported",
|
||||
description: "Your browser doesn't support voice input",
|
||||
variant: "destructive"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isListening) {
|
||||
setIsListening(false);
|
||||
// Stop voice recognition
|
||||
} else {
|
||||
setIsListening(true);
|
||||
// Start voice recognition
|
||||
toast({
|
||||
title: "🎤 Listening...",
|
||||
description: "Speak naturally - I understand context and nuance",
|
||||
});
|
||||
|
||||
// Simulate voice input after 3 seconds
|
||||
setTimeout(() => {
|
||||
setIsListening(false);
|
||||
const voiceText = "I need 5 servers and 3 bartenders for this Friday from 6pm to midnight at our downtown location";
|
||||
setInput(voiceText);
|
||||
toast({
|
||||
title: "✅ Voice Captured",
|
||||
description: "Processing your request...",
|
||||
});
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileUpload = async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
toast({
|
||||
title: "📄 Analyzing Document...",
|
||||
description: "AI is extracting order details from your file",
|
||||
});
|
||||
|
||||
try {
|
||||
const { file_url } = await base44.integrations.Core.UploadFile({ file });
|
||||
|
||||
const extractResult = await base44.integrations.Core.ExtractDataFromUploadedFile({
|
||||
file_url: file_url,
|
||||
json_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
event_name: { type: "string" },
|
||||
date: { type: "string" },
|
||||
location: { type: "string" },
|
||||
staff_needs: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
role: { type: "string" },
|
||||
count: { type: "number" },
|
||||
hours: { type: "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (extractResult.status === "success") {
|
||||
const message = {
|
||||
id: Date.now(),
|
||||
role: "assistant",
|
||||
content: `📄 **Document Analyzed**\n\nI've extracted the following order details:\n\n${JSON.stringify(extractResult.output, null, 2)}\n\nShall I create an order based on this?`,
|
||||
timestamp: new Date().toISOString(),
|
||||
type: "file",
|
||||
data: extractResult.output
|
||||
};
|
||||
setMessages(prev => [...prev, message]);
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Failed to Process File",
|
||||
description: error.message,
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleUseSuggestion = (query) => {
|
||||
setInput(query);
|
||||
handleSendMessage(query);
|
||||
};
|
||||
|
||||
const handleUseOrder = () => {
|
||||
if (extractedData) {
|
||||
onOrderDataExtracted(extractedData);
|
||||
toast({
|
||||
title: "✅ Order Data Applied",
|
||||
description: "Review and edit the order details in the form",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-br from-indigo-50 via-purple-50 to-pink-50 rounded-2xl shadow-2xl border-2 border-indigo-200 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-indigo-600 via-purple-600 to-pink-600 p-6 relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-black/10"></div>
|
||||
<div className="relative z-10 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-16 h-16 bg-white/20 backdrop-blur-xl rounded-2xl flex items-center justify-center shadow-lg">
|
||||
<Brain className="w-8 h-8 text-white animate-pulse" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white mb-1 flex items-center gap-2">
|
||||
AI Order Assistant 2030
|
||||
<Badge className="bg-white/20 text-white border-white/40">
|
||||
<Sparkles className="w-3 h-3 mr-1" />
|
||||
Neural
|
||||
</Badge>
|
||||
</h2>
|
||||
<p className="text-white/90 text-sm">Instant workforce ordering powered by advanced AI</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="text-white hover:bg-white/20"
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Real-time Stats Bar */}
|
||||
<div className="mt-4 flex items-center gap-4 text-xs text-white/80">
|
||||
<div className="flex items-center gap-1">
|
||||
<Zap className="w-4 h-4" />
|
||||
<span>3.2s avg response</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
<span>98.7% accuracy</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
<span>5.2x faster than forms</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Smart Suggestions - Only show initially */}
|
||||
<AnimatePresence>
|
||||
{showSuggestions && messages.length <= 1 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
className="p-4 bg-white/60 backdrop-blur-sm border-b border-indigo-100"
|
||||
>
|
||||
<p className="text-xs font-semibold text-slate-600 mb-3 flex items-center gap-2">
|
||||
<Wand2 className="w-4 h-4" />
|
||||
INSTANT ACTIONS
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{smartSuggestions.map((suggestion, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => handleUseSuggestion(suggestion.query)}
|
||||
className="flex items-center gap-3 p-3 bg-white rounded-xl border border-slate-200 hover:border-indigo-300 hover:shadow-lg transition-all group"
|
||||
>
|
||||
<div className={`w-10 h-10 ${suggestion.color} rounded-lg flex items-center justify-center group-hover:scale-110 transition-transform`}>
|
||||
<suggestion.icon className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div className="flex-1 text-left">
|
||||
<p className="text-sm font-semibold text-slate-900">{suggestion.label}</p>
|
||||
<p className="text-xs text-slate-500 truncate">{suggestion.query.substring(0, 30)}...</p>
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-slate-400 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Messages Area */}
|
||||
<div className="h-96 overflow-y-auto p-6 space-y-4 bg-white/40 backdrop-blur-sm">
|
||||
<AnimatePresence>
|
||||
{messages.map((message) => (
|
||||
<motion.div
|
||||
key={message.id}
|
||||
initial={{ opacity: 0, y: 20, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
className={`flex gap-3 ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
{message.role === 'assistant' && (
|
||||
<Avatar className="w-10 h-10 bg-gradient-to-br from-indigo-500 to-purple-600 flex-shrink-0">
|
||||
<AvatarFallback className="bg-transparent">
|
||||
<Brain className="w-5 h-5 text-white" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
)}
|
||||
|
||||
<div className={`max-w-2xl ${message.role === 'user' ? 'order-1' : 'order-2'}`}>
|
||||
<div
|
||||
className={`rounded-2xl p-4 shadow-lg ${
|
||||
message.role === 'user'
|
||||
? 'bg-gradient-to-br from-indigo-600 to-purple-600 text-white'
|
||||
: message.type === 'error'
|
||||
? 'bg-red-50 border-2 border-red-200'
|
||||
: 'bg-white border-2 border-indigo-100'
|
||||
}`}
|
||||
>
|
||||
<div className="prose prose-sm max-w-none">
|
||||
{message.content.split('\n').map((line, idx) => (
|
||||
<p key={idx} className={`mb-1 last:mb-0 ${message.role === 'user' ? 'text-white' : 'text-slate-700'}`}>
|
||||
{line}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Order Preview Card */}
|
||||
{message.type === 'success' && message.data && (
|
||||
<div className="mt-4 p-4 bg-gradient-to-br from-green-50 to-emerald-50 rounded-xl border-2 border-green-200">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<p className="font-bold text-green-900 flex items-center gap-2">
|
||||
<CheckCircle2 className="w-5 h-5" />
|
||||
Order Preview
|
||||
</p>
|
||||
<Badge className="bg-green-600 text-white">
|
||||
{message.data.ai_confidence}% Confidence
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4 text-green-600" />
|
||||
<span className="text-slate-700">{message.data.date || 'TBD'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="w-4 h-4 text-green-600" />
|
||||
<span className="text-slate-700">
|
||||
{message.data.shifts?.[0]?.roles?.reduce((sum, r) => sum + (r.count || 0), 0) || 0} Staff
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="w-4 h-4 text-green-600" />
|
||||
<span className="text-slate-700">{message.data.hub || message.data.business_name || 'Location TBD'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4 text-green-600" />
|
||||
<span className="text-slate-700">
|
||||
{message.data.shifts?.[0]?.roles?.[0]?.start_time || '09:00'} - {message.data.shifts?.[0]?.roles?.[0]?.end_time || '17:00'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleUseOrder}
|
||||
className="w-full mt-4 bg-gradient-to-r from-green-600 to-emerald-600 hover:from-green-700 hover:to-emerald-700 text-white font-semibold"
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4 mr-2" />
|
||||
Use This Order
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 mt-1 px-2">
|
||||
{format(new Date(message.timestamp), 'h:mm a')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{message.role === 'user' && (
|
||||
<Avatar className="w-10 h-10 bg-gradient-to-br from-slate-500 to-slate-700 flex-shrink-0">
|
||||
<AvatarFallback className="bg-transparent text-white font-bold">
|
||||
U
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
{isProcessing && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="flex gap-3"
|
||||
>
|
||||
<Avatar className="w-10 h-10 bg-gradient-to-br from-indigo-500 to-purple-600">
|
||||
<AvatarFallback className="bg-transparent">
|
||||
<Brain className="w-5 h-5 text-white animate-pulse" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="bg-white rounded-2xl p-4 shadow-lg border-2 border-indigo-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<Loader2 className="w-5 h-5 text-indigo-600 animate-spin" />
|
||||
<span className="text-sm text-slate-600">
|
||||
AI is analyzing and creating your order...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input Area - Enhanced */}
|
||||
<div className="p-4 bg-white border-t-2 border-indigo-100">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && !e.shiftKey && handleSendMessage()}
|
||||
placeholder="Type, speak, or upload... AI understands natural language 🧠"
|
||||
className="flex-1 border-2 border-indigo-200 focus:border-indigo-400 rounded-xl h-12 px-4 text-sm"
|
||||
disabled={isProcessing}
|
||||
/>
|
||||
|
||||
{/* Voice Button */}
|
||||
<Button
|
||||
onClick={handleVoiceInput}
|
||||
size="icon"
|
||||
variant={isListening ? "default" : "outline"}
|
||||
className={`h-12 w-12 rounded-xl ${
|
||||
isListening
|
||||
? 'bg-red-500 hover:bg-red-600 animate-pulse'
|
||||
: 'border-2 border-indigo-200 hover:border-indigo-400'
|
||||
}`}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{isListening ? (
|
||||
<MicOff className="w-5 h-5" />
|
||||
) : (
|
||||
<Mic className="w-5 h-5" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* File Upload */}
|
||||
<label>
|
||||
<input
|
||||
type="file"
|
||||
accept=".pdf,.doc,.docx,.txt,.csv"
|
||||
onChange={handleFileUpload}
|
||||
className="hidden"
|
||||
disabled={isProcessing}
|
||||
/>
|
||||
<Button
|
||||
as="span"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="h-12 w-12 rounded-xl border-2 border-indigo-200 hover:border-indigo-400 cursor-pointer"
|
||||
disabled={isProcessing}
|
||||
>
|
||||
<Upload className="w-5 h-5" />
|
||||
</Button>
|
||||
</label>
|
||||
|
||||
{/* Send Button */}
|
||||
<Button
|
||||
onClick={() => handleSendMessage()}
|
||||
size="icon"
|
||||
className="h-12 w-12 rounded-xl bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-700 hover:to-purple-700"
|
||||
disabled={isProcessing || !input.trim()}
|
||||
>
|
||||
<Send className="w-5 h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Quick Examples */}
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500">
|
||||
<Sparkles className="w-3 h-3" />
|
||||
<span className="font-semibold">Try:</span>
|
||||
<button
|
||||
onClick={() => setInput("I need 3 servers for Friday night 6-11pm")}
|
||||
className="text-indigo-600 hover:underline"
|
||||
>
|
||||
"3 servers Friday night"
|
||||
</button>
|
||||
<span>•</span>
|
||||
<button
|
||||
onClick={() => setInput("Repeat last week's order")}
|
||||
className="text-indigo-600 hover:underline"
|
||||
>
|
||||
"Repeat last order"
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI Capabilities Footer */}
|
||||
<div className="px-4 py-3 bg-gradient-to-r from-indigo-50 to-purple-50 border-t border-indigo-100">
|
||||
<div className="flex items-center justify-between text-xs text-slate-600">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-1">
|
||||
<Brain className="w-3 h-3 text-indigo-600" />
|
||||
<span>Context-aware</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<MessageSquare className="w-3 h-3 text-purple-600" />
|
||||
<span>Natural language</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Zap className="w-3 h-3 text-pink-600" />
|
||||
<span>Instant generation</span>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Powered by Neural AI v3.0
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
244
src/components/events/AssignedStaffManager.jsx
Normal file
244
src/components/events/AssignedStaffManager.jsx
Normal file
@@ -0,0 +1,244 @@
|
||||
import React, { useState } from "react";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { Edit2, Trash2, ArrowLeftRight, Clock, MapPin, Check, X } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { calculateOrderStatus } from "../orders/OrderStatusUtils";
|
||||
|
||||
export default function AssignedStaffManager({ event, shift, role }) {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [editTarget, setEditTarget] = useState(null);
|
||||
const [swapTarget, setSwapTarget] = useState(null);
|
||||
const [editTimes, setEditTimes] = useState({ start: "", end: "" });
|
||||
|
||||
// Get assigned staff for this role
|
||||
const assignedStaff = (event.assigned_staff || []).filter(
|
||||
s => s.role === role?.role
|
||||
);
|
||||
|
||||
// Remove staff mutation
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: async (staffId) => {
|
||||
const updatedAssignedStaff = (event.assigned_staff || []).filter(
|
||||
s => s.staff_id !== staffId || s.role !== role.role
|
||||
);
|
||||
|
||||
const updatedShifts = (event.shifts || []).map(s => {
|
||||
if (s.shift_name === shift.shift_name) {
|
||||
const updatedRoles = (s.roles || []).map(r => {
|
||||
if (r.role === role.role) {
|
||||
return {
|
||||
...r,
|
||||
assigned: Math.max((r.assigned || 0) - 1, 0),
|
||||
};
|
||||
}
|
||||
return r;
|
||||
});
|
||||
return { ...s, roles: updatedRoles };
|
||||
}
|
||||
return s;
|
||||
});
|
||||
|
||||
const updatedEvent = {
|
||||
assigned_staff: updatedAssignedStaff,
|
||||
shifts: updatedShifts,
|
||||
// NEVER MODIFY REQUESTED - it's set by client, not by staff assignment
|
||||
};
|
||||
|
||||
// Auto-update status based on staffing level
|
||||
updatedEvent.status = calculateOrderStatus({
|
||||
...event,
|
||||
...updatedEvent
|
||||
});
|
||||
|
||||
await base44.entities.Event.update(event.id, updatedEvent);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['events'] });
|
||||
toast({
|
||||
title: "✅ Staff Removed",
|
||||
description: "Staff member has been removed from this assignment",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Edit times mutation
|
||||
const editMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const updatedShifts = (event.shifts || []).map(s => {
|
||||
if (s.shift_name === shift.shift_name) {
|
||||
const updatedRoles = (s.roles || []).map(r => {
|
||||
if (r.role === role.role) {
|
||||
return {
|
||||
...r,
|
||||
start_time: editTimes.start,
|
||||
end_time: editTimes.end,
|
||||
};
|
||||
}
|
||||
return r;
|
||||
});
|
||||
return { ...s, roles: updatedRoles };
|
||||
}
|
||||
return s;
|
||||
});
|
||||
|
||||
await base44.entities.Event.update(event.id, {
|
||||
shifts: updatedShifts,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['events'] });
|
||||
toast({
|
||||
title: "✅ Times Updated",
|
||||
description: "Assignment times have been updated",
|
||||
});
|
||||
setEditTarget(null);
|
||||
},
|
||||
});
|
||||
|
||||
const handleEdit = (staff) => {
|
||||
setEditTarget(staff);
|
||||
setEditTimes({
|
||||
start: role.start_time || "09:00",
|
||||
end: role.end_time || "17:00",
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveEdit = () => {
|
||||
editMutation.mutate();
|
||||
};
|
||||
|
||||
const handleRemove = (staffId) => {
|
||||
if (confirm("Are you sure you want to remove this staff member?")) {
|
||||
removeMutation.mutate(staffId);
|
||||
}
|
||||
};
|
||||
|
||||
if (!assignedStaff.length) {
|
||||
return (
|
||||
<div className="text-center py-6 text-slate-500 text-sm">
|
||||
No staff assigned yet
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
{assignedStaff.map((staff) => (
|
||||
<div
|
||||
key={staff.staff_id}
|
||||
className="flex items-center gap-3 p-3 bg-white border border-slate-200 rounded-lg hover:shadow-sm transition-shadow"
|
||||
>
|
||||
<Avatar className="w-10 h-10 bg-gradient-to-br from-blue-400 to-blue-500">
|
||||
<AvatarFallback className="text-white font-bold">
|
||||
{staff.staff_name?.charAt(0) || 'S'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-slate-900">{staff.staff_name}</p>
|
||||
<div className="flex items-center gap-2 text-xs text-slate-600">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{staff.role}
|
||||
</Badge>
|
||||
{role.start_time && role.end_time && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{role.start_time} - {role.end_time}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(staff)}
|
||||
className="h-8 w-8 p-0"
|
||||
title="Edit times"
|
||||
>
|
||||
<Edit2 className="w-4 h-4 text-slate-600" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemove(staff.staff_id)}
|
||||
disabled={removeMutation.isPending}
|
||||
className="h-8 w-8 p-0"
|
||||
title="Remove"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-red-600" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Edit Times Dialog */}
|
||||
<Dialog open={!!editTarget} onOpenChange={() => setEditTarget(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Assignment Times</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>Staff Member</Label>
|
||||
<p className="text-sm font-medium mt-1">{editTarget?.staff_name}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>Start Time</Label>
|
||||
<Input
|
||||
type="time"
|
||||
value={editTimes.start}
|
||||
onChange={(e) => setEditTimes({ ...editTimes, start: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>End Time</Label>
|
||||
<Input
|
||||
type="time"
|
||||
value={editTimes.end}
|
||||
onChange={(e) => setEditTimes({ ...editTimes, end: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditTarget(null)}>
|
||||
<X className="w-4 h-4 mr-2" />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSaveEdit}
|
||||
disabled={editMutation.isPending}
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
<Check className="w-4 h-4 mr-2" />
|
||||
{editMutation.isPending ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
837
src/components/events/EventAssignmentModal.jsx
Normal file
837
src/components/events/EventAssignmentModal.jsx
Normal file
@@ -0,0 +1,837 @@
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Calendar, Users, Check, Plus, X, Clock, MapPin, ChevronLeft, ChevronRight, AlertTriangle, RefreshCw, Search } from "lucide-react";
|
||||
import { format, parseISO, isWithinInterval } from "date-fns";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
|
||||
const convertTo12Hour = (time24) => {
|
||||
if (!time24) return '';
|
||||
const [hours, minutes] = time24.split(':');
|
||||
const hour = parseInt(hours);
|
||||
const ampm = hour >= 12 ? 'PM' : 'AM';
|
||||
const hour12 = hour % 12 || 12;
|
||||
return `${hour12}:${minutes} ${ampm}`;
|
||||
};
|
||||
|
||||
const getInitials = (name) => {
|
||||
if (!name) return 'S';
|
||||
return name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
|
||||
};
|
||||
|
||||
const avatarColors = [
|
||||
'bg-blue-500',
|
||||
'bg-purple-500',
|
||||
'bg-green-500',
|
||||
'bg-orange-500',
|
||||
'bg-pink-500',
|
||||
'bg-indigo-500',
|
||||
'bg-teal-500',
|
||||
'bg-red-500',
|
||||
];
|
||||
|
||||
// Helper to check if times overlap
|
||||
const hasTimeConflict = (existingStart, existingEnd, newStart, newEnd, existingDate, newDate) => {
|
||||
// If different dates, no conflict
|
||||
if (existingDate !== newDate) return false;
|
||||
|
||||
if (!existingStart || !existingEnd || !newStart || !newEnd) return false;
|
||||
|
||||
const parseTime = (time) => {
|
||||
const [hours, minutes] = time.split(':').map(Number);
|
||||
return hours * 60 + minutes;
|
||||
};
|
||||
|
||||
const existingStartMin = parseTime(existingStart);
|
||||
const existingEndMin = parseTime(existingEnd);
|
||||
const newStartMin = parseTime(newStart);
|
||||
const newEndMin = parseTime(newEnd);
|
||||
|
||||
return (newStartMin < existingEndMin && newEndMin > existingStartMin);
|
||||
};
|
||||
|
||||
export default function EventAssignmentModal({ open, onClose, order, onUpdate, isRapid = false }) {
|
||||
const [selectedShiftIndex, setSelectedShiftIndex] = useState(0);
|
||||
const [selectedRoleIndex, setSelectedRoleIndex] = useState(0);
|
||||
const [selectedStaffIds, setSelectedStaffIds] = useState([]);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [swapMode, setSwapMode] = useState(null); // { employeeId, assignmentIndex }
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: allStaff = [] } = useQuery({
|
||||
queryKey: ['staff-for-assignment'],
|
||||
queryFn: () => base44.entities.Staff.list(),
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const { data: allOrders = [] } = useQuery({
|
||||
queryKey: ['orders-for-conflict-check'],
|
||||
queryFn: () => base44.entities.Order.list(),
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const updateOrderMutation = useMutation({
|
||||
mutationFn: (updatedOrder) => base44.entities.Order.update(order.id, updatedOrder),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['orders'] });
|
||||
if (onUpdate) onUpdate();
|
||||
setSelectedStaffIds([]);
|
||||
toast({
|
||||
title: "✅ Staff assigned successfully",
|
||||
description: "The order has been updated with new assignments.",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (!order || !order.shifts_data || order.shifts_data.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentShift = order.shifts_data[selectedShiftIndex];
|
||||
const currentRole = currentShift?.roles[selectedRoleIndex];
|
||||
|
||||
if (!currentRole) return null;
|
||||
|
||||
// Check for conflicts
|
||||
const getStaffConflicts = (staffId) => {
|
||||
const conflicts = [];
|
||||
|
||||
allOrders.forEach(existingOrder => {
|
||||
if (existingOrder.id === order.id) return; // Skip current order
|
||||
if (!existingOrder.shifts_data) return;
|
||||
|
||||
existingOrder.shifts_data.forEach(shift => {
|
||||
shift.roles.forEach(role => {
|
||||
if (!role.assignments) return;
|
||||
|
||||
role.assignments.forEach(assignment => {
|
||||
if (assignment.employee_id === staffId) {
|
||||
const hasConflict = hasTimeConflict(
|
||||
role.start_time,
|
||||
role.end_time,
|
||||
currentRole.start_time,
|
||||
currentRole.end_time,
|
||||
existingOrder.event_date,
|
||||
order.event_date
|
||||
);
|
||||
|
||||
if (hasConflict) {
|
||||
conflicts.push({
|
||||
orderName: existingOrder.event_name,
|
||||
role: role.service,
|
||||
time: `${convertTo12Hour(role.start_time)} - ${convertTo12Hour(role.end_time)}`,
|
||||
date: existingOrder.event_date
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return conflicts;
|
||||
};
|
||||
|
||||
// Role-based filtering
|
||||
const roleKeywords = {
|
||||
'bartender': ['bartender', 'bar'],
|
||||
'cook': ['cook', 'chef', 'kitchen'],
|
||||
'server': ['server', 'waiter', 'waitress'],
|
||||
'cashier': ['cashier', 'register'],
|
||||
'host': ['host', 'hostess', 'greeter'],
|
||||
'busser': ['busser', 'bus'],
|
||||
'dishwasher': ['dishwasher', 'dish'],
|
||||
'manager': ['manager', 'supervisor'],
|
||||
};
|
||||
|
||||
const getRoleCategory = (roleName) => {
|
||||
const lowerRole = roleName.toLowerCase();
|
||||
for (const [category, keywords] of Object.entries(roleKeywords)) {
|
||||
if (keywords.some(keyword => lowerRole.includes(keyword))) {
|
||||
return category;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const matchesRole = (staffPosition, requiredRole) => {
|
||||
const staffLower = (staffPosition || '').toLowerCase();
|
||||
const requiredLower = (requiredRole || '').toLowerCase();
|
||||
|
||||
// Direct match
|
||||
if (staffLower.includes(requiredLower) || requiredLower.includes(staffLower)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Category match
|
||||
const staffCategory = getRoleCategory(staffPosition || '');
|
||||
const requiredCategory = getRoleCategory(requiredRole);
|
||||
|
||||
return staffCategory && requiredCategory && staffCategory === requiredCategory;
|
||||
};
|
||||
|
||||
const handleBulkAssign = () => {
|
||||
if (selectedStaffIds.length === 0) {
|
||||
toast({
|
||||
title: "No staff selected",
|
||||
description: "Please select staff members to assign.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedOrder = { ...order };
|
||||
const assignments = updatedOrder.shifts_data[selectedShiftIndex].roles[selectedRoleIndex].assignments || [];
|
||||
|
||||
const needed = parseInt(currentRole.count) || 0;
|
||||
const currentAssigned = assignments.length;
|
||||
const remaining = needed - currentAssigned;
|
||||
|
||||
// Strictly enforce the requested count
|
||||
if (remaining <= 0) {
|
||||
toast({
|
||||
title: "Assignment Limit Reached",
|
||||
description: `This position requested exactly ${needed} staff. Cannot assign more.`,
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for conflicts
|
||||
const conflictingStaff = [];
|
||||
selectedStaffIds.forEach(staffId => {
|
||||
const conflicts = getStaffConflicts(staffId);
|
||||
if (conflicts.length > 0) {
|
||||
const staff = allStaff.find(s => s.id === staffId);
|
||||
conflictingStaff.push(staff?.employee_name);
|
||||
}
|
||||
});
|
||||
|
||||
if (conflictingStaff.length > 0) {
|
||||
toast({
|
||||
title: "⚠️ Time Conflict Detected",
|
||||
description: `${conflictingStaff.join(', ')} ${conflictingStaff.length === 1 ? 'is' : 'are'} already assigned to overlapping shifts.`,
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedStaffIds.length > remaining) {
|
||||
toast({
|
||||
title: "Assignment Limit",
|
||||
description: `Only ${remaining} more staff ${remaining === 1 ? 'is' : 'are'} needed.`,
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Add all selected staff
|
||||
const newAssignments = selectedStaffIds.map(staffId => {
|
||||
const staff = allStaff.find(s => s.id === staffId);
|
||||
return {
|
||||
employee_id: staff.id,
|
||||
employee_name: staff.employee_name,
|
||||
position: currentRole.service,
|
||||
shift_date: order.event_date,
|
||||
shift_start: currentRole.start_time,
|
||||
shift_end: currentRole.end_time,
|
||||
location: currentShift.address || order.event_address,
|
||||
hub_location: order.hub_location,
|
||||
};
|
||||
});
|
||||
|
||||
updatedOrder.shifts_data[selectedShiftIndex].roles[selectedRoleIndex].assignments = [
|
||||
...assignments,
|
||||
...newAssignments
|
||||
];
|
||||
|
||||
updateOrderMutation.mutate(updatedOrder);
|
||||
};
|
||||
|
||||
const handleAssignStaff = (staffMember) => {
|
||||
const updatedOrder = { ...order };
|
||||
const assignments = updatedOrder.shifts_data[selectedShiftIndex].roles[selectedRoleIndex].assignments || [];
|
||||
|
||||
// Strictly enforce the requested count
|
||||
if (assignments.length >= needed) {
|
||||
toast({
|
||||
title: "Assignment Limit Reached",
|
||||
description: `This position requested exactly ${needed} staff. Cannot assign more.`,
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if already assigned
|
||||
if (assignments.some(a => a.employee_id === staffMember.id)) {
|
||||
toast({
|
||||
title: "Already assigned",
|
||||
description: `${staffMember.employee_name} is already assigned to this role.`,
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for conflicts
|
||||
const conflicts = getStaffConflicts(staffMember.id);
|
||||
if (conflicts.length > 0) {
|
||||
toast({
|
||||
title: "⚠️ Time Conflict",
|
||||
description: `${staffMember.employee_name} is already assigned to ${conflicts[0].orderName} at ${conflicts[0].time}`,
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Add new assignment
|
||||
const newAssignment = {
|
||||
employee_id: staffMember.id,
|
||||
employee_name: staffMember.employee_name,
|
||||
position: currentRole.service,
|
||||
shift_date: order.event_date,
|
||||
shift_start: currentRole.start_time,
|
||||
shift_end: currentRole.end_time,
|
||||
location: currentShift.address || order.event_address,
|
||||
hub_location: order.hub_location,
|
||||
};
|
||||
|
||||
updatedOrder.shifts_data[selectedShiftIndex].roles[selectedRoleIndex].assignments = [
|
||||
...assignments,
|
||||
newAssignment
|
||||
];
|
||||
|
||||
updateOrderMutation.mutate(updatedOrder);
|
||||
};
|
||||
|
||||
const handleRemoveStaff = (employeeId) => {
|
||||
const updatedOrder = { ...order };
|
||||
const assignments = updatedOrder.shifts_data[selectedShiftIndex].roles[selectedRoleIndex].assignments || [];
|
||||
|
||||
updatedOrder.shifts_data[selectedShiftIndex].roles[selectedRoleIndex].assignments =
|
||||
assignments.filter(a => a.employee_id !== employeeId);
|
||||
|
||||
updateOrderMutation.mutate(updatedOrder);
|
||||
};
|
||||
|
||||
const handleSwapStaff = (newStaffMember) => {
|
||||
if (!swapMode) return;
|
||||
|
||||
const updatedOrder = { ...order };
|
||||
const assignments = updatedOrder.shifts_data[selectedShiftIndex].roles[selectedRoleIndex].assignments || [];
|
||||
|
||||
// Check for conflicts
|
||||
const conflicts = getStaffConflicts(newStaffMember.id);
|
||||
if (conflicts.length > 0) {
|
||||
toast({
|
||||
title: "⚠️ Time Conflict",
|
||||
description: `${newStaffMember.employee_name} is already assigned to ${conflicts[0].orderName} at ${conflicts[0].time}`,
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Replace the assignment
|
||||
assignments[swapMode.assignmentIndex] = {
|
||||
employee_id: newStaffMember.id,
|
||||
employee_name: newStaffMember.employee_name,
|
||||
position: currentRole.service,
|
||||
shift_date: order.event_date,
|
||||
shift_start: currentRole.start_time,
|
||||
shift_end: currentRole.end_time,
|
||||
location: currentShift.address || order.event_address,
|
||||
hub_location: order.hub_location,
|
||||
};
|
||||
|
||||
updatedOrder.shifts_data[selectedShiftIndex].roles[selectedRoleIndex].assignments = assignments;
|
||||
updateOrderMutation.mutate(updatedOrder);
|
||||
setSwapMode(null);
|
||||
};
|
||||
|
||||
const assignments = currentRole.assignments || [];
|
||||
const needed = parseInt(currentRole.count) || 0;
|
||||
const assigned = assignments.length;
|
||||
const isFullyStaffed = assigned >= needed;
|
||||
|
||||
// Filter staff by role and exclude already assigned
|
||||
const assignedIds = new Set(assignments.map(a => a.employee_id));
|
||||
const roleFilteredStaff = allStaff.filter(s =>
|
||||
matchesRole(s.position, currentRole.service) ||
|
||||
matchesRole(s.position_2, currentRole.service)
|
||||
);
|
||||
|
||||
const availableStaff = roleFilteredStaff
|
||||
.filter(s => !assignedIds.has(s.id))
|
||||
.filter(s => {
|
||||
if (!searchTerm) return true;
|
||||
const lowerSearch = searchTerm.toLowerCase();
|
||||
return (
|
||||
s.employee_name?.toLowerCase().includes(lowerSearch) ||
|
||||
s.position?.toLowerCase().includes(lowerSearch) ||
|
||||
s.position_2?.toLowerCase().includes(lowerSearch)
|
||||
);
|
||||
});
|
||||
|
||||
// Calculate total assignments across all roles in this shift
|
||||
let totalNeeded = 0;
|
||||
let totalAssigned = 0;
|
||||
currentShift.roles.forEach(role => {
|
||||
totalNeeded += parseInt(role.count) || 0;
|
||||
totalAssigned += role.assignments?.length || 0;
|
||||
});
|
||||
|
||||
const toggleStaffSelection = (staffId) => {
|
||||
setSelectedStaffIds(prev =>
|
||||
prev.includes(staffId)
|
||||
? prev.filter(id => id !== staffId)
|
||||
: [...prev, staffId]
|
||||
);
|
||||
};
|
||||
|
||||
const selectAll = () => {
|
||||
const remaining = needed - assigned;
|
||||
const selectableStaff = availableStaff.slice(0, remaining);
|
||||
setSelectedStaffIds(selectableStaff.map(s => s.id));
|
||||
};
|
||||
|
||||
const deselectAll = () => {
|
||||
setSelectedStaffIds([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-3xl max-h-[90dvh] overflow-hidden flex flex-col">
|
||||
<DialogHeader className="border-b pb-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<DialogTitle className="text-2xl font-bold text-slate-900 mb-1">
|
||||
{order.event_name}
|
||||
</DialogTitle>
|
||||
<p className="text-sm text-slate-600">{order.client_business}</p>
|
||||
</div>
|
||||
<Badge
|
||||
className={`${
|
||||
totalAssigned >= totalNeeded
|
||||
? 'bg-emerald-500 text-white border-0 animate-pulse'
|
||||
: 'bg-orange-500 text-white border-0'
|
||||
} font-semibold px-3 py-1 shadow-md`}
|
||||
>
|
||||
{totalAssigned >= totalNeeded ? (
|
||||
<>
|
||||
<Check className="w-3 h-3 mr-1" />
|
||||
Fully Staffed
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AlertTriangle className="w-3 h-3 mr-1" />
|
||||
Needs Staff
|
||||
</>
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-slate-700 mt-3">
|
||||
<Calendar className="w-4 h-4 text-blue-600" />
|
||||
<span className="text-sm font-medium">
|
||||
{order.event_date ? format(new Date(order.event_date), 'EEEE, MMMM d, yyyy') : 'No date'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{currentShift.address && (
|
||||
<div className="flex items-center gap-2 text-slate-700 mt-2">
|
||||
<MapPin className="w-4 h-4 text-blue-600" />
|
||||
<span className="text-sm">{currentShift.address}</span>
|
||||
</div>
|
||||
)}
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto py-4">
|
||||
{/* Staff Assignment Summary */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="w-5 h-5 text-purple-600" />
|
||||
<h3 className="font-semibold text-slate-900">Staff Assignment</h3>
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-sm font-bold border-2 ${
|
||||
totalAssigned >= totalNeeded
|
||||
? 'border-emerald-500 text-emerald-700 bg-emerald-50'
|
||||
: 'border-orange-500 text-orange-700 bg-orange-50'
|
||||
}`}
|
||||
>
|
||||
{totalAssigned} / {totalNeeded}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{totalAssigned >= totalNeeded && (
|
||||
<div className="mb-3 p-3 rounded-lg bg-gradient-to-r from-emerald-50 to-green-50 border-2 border-emerald-200">
|
||||
<div className="flex items-center gap-2 text-emerald-700 text-sm font-semibold">
|
||||
<Check className="w-5 h-5" />
|
||||
✨ Fully staffed - All positions filled!
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Position Selection */}
|
||||
{currentShift.roles.length > 1 && (
|
||||
<div className="mb-6">
|
||||
<label className="text-sm font-semibold text-slate-700 mb-2 block">Select Position:</label>
|
||||
<Select
|
||||
value={selectedRoleIndex.toString()}
|
||||
onValueChange={(value) => {
|
||||
setSelectedRoleIndex(parseInt(value));
|
||||
setSelectedStaffIds([]);
|
||||
setSwapMode(null);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{currentShift.roles.map((role, idx) => {
|
||||
const roleAssigned = role.assignments?.length || 0;
|
||||
const roleNeeded = parseInt(role.count) || 0;
|
||||
const roleFilled = roleAssigned >= roleNeeded;
|
||||
return (
|
||||
<SelectItem key={idx} value={idx.toString()}>
|
||||
<div className="flex items-center justify-between gap-4 w-full">
|
||||
<span className="font-medium">{role.service}</span>
|
||||
<Badge
|
||||
className={`${
|
||||
roleFilled
|
||||
? 'bg-emerald-100 text-emerald-700'
|
||||
: 'bg-orange-100 text-orange-700'
|
||||
} text-xs font-bold ml-2`}
|
||||
>
|
||||
{roleAssigned}/{roleNeeded}
|
||||
</Badge>
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current Position Details */}
|
||||
<div className="mb-6 p-4 bg-gradient-to-r from-blue-50 to-indigo-50 rounded-xl border-2 border-blue-200">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="font-bold text-slate-900 text-lg">{currentRole.service}</h4>
|
||||
<Badge
|
||||
className={`${
|
||||
assigned >= needed
|
||||
? 'bg-emerald-500 text-white'
|
||||
: 'bg-orange-500 text-white'
|
||||
} font-bold px-3 py-1 text-base`}
|
||||
>
|
||||
{assigned}/{needed}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{currentRole.start_time && (
|
||||
<div className="flex items-center gap-2 text-sm text-slate-700 font-medium">
|
||||
<Clock className="w-4 h-4 text-blue-600" />
|
||||
<span>
|
||||
{convertTo12Hour(currentRole.start_time)} - {convertTo12Hour(currentRole.end_time)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Swap Mode Banner */}
|
||||
{swapMode && (
|
||||
<div className="mb-4 p-4 bg-purple-50 border-2 border-purple-300 rounded-xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<RefreshCw className="w-5 h-5 text-purple-600" />
|
||||
<span className="font-semibold text-purple-900">
|
||||
Swap Mode Active - Select replacement for {assignments[swapMode.assignmentIndex]?.employee_name}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSwapMode(null)}
|
||||
className="text-purple-600 hover:bg-purple-100"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Assigned Staff List */}
|
||||
{assignments.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<h4 className="text-sm font-bold text-slate-700 mb-3 uppercase tracking-wide">✅ Assigned Staff:</h4>
|
||||
<div className="space-y-2">
|
||||
{assignments.map((assignment, idx) => {
|
||||
const conflicts = getStaffConflicts(assignment.employee_id);
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-center justify-between p-3 bg-white rounded-xl border-2 border-slate-200 hover:border-blue-300 transition-all shadow-sm"
|
||||
>
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<Avatar className={`w-10 h-10 ${avatarColors[idx % avatarColors.length]}`}>
|
||||
<AvatarFallback className="text-white font-bold">
|
||||
{getInitials(assignment.employee_name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1">
|
||||
<p className="font-bold text-slate-900">{assignment.employee_name}</p>
|
||||
<p className="text-xs text-slate-500">{currentRole.service}</p>
|
||||
{conflicts.length > 0 && (
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<AlertTriangle className="w-3 h-3 text-red-500" />
|
||||
<span className="text-xs text-red-600 font-medium">Time conflict detected</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setSwapMode({ employeeId: assignment.employee_id, assignmentIndex: idx })}
|
||||
className="text-purple-600 hover:bg-purple-50 border-purple-300"
|
||||
>
|
||||
<RefreshCw className="w-3 h-3 mr-1" />
|
||||
Swap
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveStaff(assignment.employee_id)}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Staff Section */}
|
||||
{(assigned < needed || swapMode) && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-sm font-bold text-slate-700 uppercase tracking-wide">
|
||||
{swapMode ? '🔄 Select Replacement:' : '➕ Add Staff:'}
|
||||
</h4>
|
||||
{!swapMode && availableStaff.length > 0 && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={selectAll}
|
||||
disabled={needed - assigned === 0}
|
||||
>
|
||||
Select All ({Math.min(availableStaff.length, needed - assigned)})
|
||||
</Button>
|
||||
{selectedStaffIds.length > 0 && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={deselectAll}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleBulkAssign}
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
Assign {selectedStaffIds.length}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="mb-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<Input
|
||||
placeholder="Search staff by name or position..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 mt-2">
|
||||
Showing {availableStaff.length} {currentRole.service.toLowerCase()}(s)
|
||||
{roleFilteredStaff.length !== allStaff.length && (
|
||||
<span className="text-blue-600 font-medium"> (filtered by role)</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{availableStaff.length > 0 ? (
|
||||
<div className="space-y-2 max-h-80 overflow-y-auto pr-2">
|
||||
{availableStaff.map((staff, idx) => {
|
||||
const isSelected = selectedStaffIds.includes(staff.id);
|
||||
const conflicts = getStaffConflicts(staff.id);
|
||||
const hasConflict = conflicts.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={staff.id}
|
||||
className={`flex items-center justify-between p-3 bg-white rounded-xl border-2 transition-all ${
|
||||
hasConflict
|
||||
? 'border-red-200 bg-red-50'
|
||||
: isSelected
|
||||
? 'border-blue-400 bg-blue-50'
|
||||
: 'border-slate-200 hover:border-blue-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
{!swapMode && (
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => toggleStaffSelection(staff.id)}
|
||||
disabled={hasConflict}
|
||||
className="border-2"
|
||||
/>
|
||||
)}
|
||||
<Avatar className={`w-10 h-10 ${avatarColors[idx % avatarColors.length]}`}>
|
||||
<AvatarFallback className="text-white font-bold">
|
||||
{getInitials(staff.employee_name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-bold text-slate-900 truncate">{staff.employee_name}</p>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<p className="text-xs text-slate-500">{staff.position || 'Staff Member'}</p>
|
||||
{staff.position_2 && (
|
||||
<Badge variant="outline" className="text-[10px]">{staff.position_2}</Badge>
|
||||
)}
|
||||
</div>
|
||||
{hasConflict && (
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<AlertTriangle className="w-3 h-3 text-red-500" />
|
||||
<span className="text-xs text-red-600 font-medium">
|
||||
Conflict: {conflicts[0].orderName} ({conflicts[0].time})
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => swapMode ? handleSwapStaff(staff) : handleAssignStaff(staff)}
|
||||
disabled={hasConflict}
|
||||
className={`${
|
||||
swapMode
|
||||
? 'bg-purple-600 hover:bg-purple-700'
|
||||
: 'bg-blue-600 hover:bg-blue-700'
|
||||
} ${hasConflict ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
{swapMode ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 mr-1" />
|
||||
Swap
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
Assign
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12 bg-slate-50 rounded-xl border-2 border-dashed border-slate-200">
|
||||
<Users className="w-16 h-16 mx-auto mb-3 text-slate-300" />
|
||||
<p className="font-medium text-slate-600">
|
||||
{searchTerm
|
||||
? 'No staff match your search'
|
||||
: `No available ${currentRole.service.toLowerCase()}s found`
|
||||
}
|
||||
</p>
|
||||
<p className="text-sm text-slate-400 mt-1">
|
||||
{searchTerm ? 'Try a different search term' : 'All matching staff have been assigned'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4 flex items-center justify-between bg-slate-50 -mx-6 px-6 -mb-6 pb-6">
|
||||
<div className="flex gap-2">
|
||||
{selectedShiftIndex > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSelectedShiftIndex(selectedShiftIndex - 1);
|
||||
setSelectedRoleIndex(0);
|
||||
setSelectedStaffIds([]);
|
||||
setSwapMode(null);
|
||||
}}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 mr-1" />
|
||||
Previous Shift
|
||||
</Button>
|
||||
)}
|
||||
{selectedShiftIndex < order.shifts_data.length - 1 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSelectedShiftIndex(selectedShiftIndex + 1);
|
||||
setSelectedRoleIndex(0);
|
||||
setSelectedStaffIds([]);
|
||||
setSwapMode(null);
|
||||
}}
|
||||
>
|
||||
Next Shift
|
||||
<ChevronRight className="w-4 h-4 ml-1" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`px-4 py-2 text-base font-bold ${
|
||||
totalAssigned >= totalNeeded
|
||||
? 'bg-emerald-50 border-emerald-500 text-emerald-700'
|
||||
: 'bg-orange-50 border-orange-500 text-orange-700'
|
||||
}`}
|
||||
>
|
||||
{totalAssigned}/{totalNeeded} Filled
|
||||
</Badge>
|
||||
<Button onClick={onClose} className="bg-blue-600 hover:bg-blue-700">
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
1103
src/components/events/EventForm.jsx
Normal file
1103
src/components/events/EventForm.jsx
Normal file
File diff suppressed because it is too large
Load Diff
1694
src/components/events/EventFormWizard.jsx
Normal file
1694
src/components/events/EventFormWizard.jsx
Normal file
File diff suppressed because it is too large
Load Diff
134
src/components/events/EventHoverCard.jsx
Normal file
134
src/components/events/EventHoverCard.jsx
Normal file
@@ -0,0 +1,134 @@
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Calendar, MapPin, Users, FileText } from "lucide-react";
|
||||
import { format, parseISO, isValid } from "date-fns";
|
||||
|
||||
const statusColors = {
|
||||
Draft: "bg-slate-100 text-slate-700 border border-slate-300",
|
||||
Active: "bg-emerald-50 text-emerald-700 border border-emerald-200",
|
||||
Pending: "bg-purple-50 text-purple-700 border border-purple-200",
|
||||
Confirmed: "bg-[#0A39DF]/10 text-[#0A39DF] border border-[#0A39DF]/30",
|
||||
Assigned: "bg-amber-50 text-amber-700 border border-amber-200",
|
||||
Completed: "bg-slate-100 text-slate-600 border border-slate-300",
|
||||
Canceled: "bg-red-50 text-red-700 border border-red-200",
|
||||
Cancelled: "bg-red-50 text-red-700 border border-red-200"
|
||||
};
|
||||
|
||||
// Helper function to safely format dates
|
||||
const safeFormatDate = (dateString, formatStr) => {
|
||||
if (!dateString) return "-";
|
||||
try {
|
||||
const date = typeof dateString === 'string' ? parseISO(dateString) : new Date(dateString);
|
||||
if (!isValid(date)) return "-";
|
||||
return format(date, formatStr);
|
||||
} catch {
|
||||
return "-";
|
||||
}
|
||||
};
|
||||
|
||||
export default function EventHoverCard({ event, children }) {
|
||||
const assignedCount = event.assigned_staff?.length || event.assigned || 0;
|
||||
const requestedCount = event.requested || 0;
|
||||
const remainingSlots = requestedCount - assignedCount;
|
||||
|
||||
return (
|
||||
<HoverCard openDelay={200}>
|
||||
<HoverCardTrigger asChild>
|
||||
{children}
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="w-96 p-0" side="right" align="start">
|
||||
<div className="bg-gradient-to-br from-slate-50 to-white p-4 border-b border-slate-200">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="font-bold text-lg text-[#1C323E]">{event.event_name}</h3>
|
||||
<p className="text-sm text-slate-600">{event.business_name || "Company Name"}</p>
|
||||
</div>
|
||||
<Badge className={`${statusColors[event.status]} font-medium`}>
|
||||
{event.status}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center gap-2 text-slate-700">
|
||||
<Calendar className="w-4 h-4 text-[#0A39DF]" />
|
||||
<span className="font-semibold">{safeFormatDate(event.date, "MMMM dd, yyyy")}</span>
|
||||
</div>
|
||||
|
||||
{event.event_location && (
|
||||
<div className="flex items-center gap-2 text-slate-700">
|
||||
<MapPin className="w-4 h-4 text-[#0A39DF]" />
|
||||
<span className="truncate">{event.event_location}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{event.po && (
|
||||
<div className="flex items-center gap-2 text-slate-700">
|
||||
<FileText className="w-4 h-4 text-[#0A39DF]" />
|
||||
<span>PO: {event.po}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="w-4 h-4 text-slate-600" />
|
||||
<span className="text-sm font-medium text-slate-700">Staff Assignment</span>
|
||||
</div>
|
||||
<Badge variant="outline" className="border-[#0A39DF] text-[#0A39DF]">
|
||||
{assignedCount} / {requestedCount}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{remainingSlots > 0 && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-2 text-xs text-amber-800">
|
||||
<strong>{remainingSlots}</strong> staff member{remainingSlots !== 1 ? 's' : ''} still needed
|
||||
</div>
|
||||
)}
|
||||
|
||||
{remainingSlots === 0 && requestedCount > 0 && (
|
||||
<div className="bg-emerald-50 border border-emerald-200 rounded-lg p-2 text-xs text-emerald-800">
|
||||
✓ Fully staffed
|
||||
</div>
|
||||
)}
|
||||
|
||||
{event.assigned_staff && event.assigned_staff.length > 0 && (
|
||||
<div className="space-y-2 max-h-40 overflow-y-auto">
|
||||
<p className="text-xs font-semibold text-slate-600 uppercase">Assigned Staff:</p>
|
||||
{event.assigned_staff.map((staff, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2 text-sm">
|
||||
<div className="w-6 h-6 bg-[#0A39DF] rounded flex items-center justify-center text-white text-xs font-bold">
|
||||
{staff.staff_name?.charAt(0) || '?'}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-slate-900 truncate">{staff.staff_name}</p>
|
||||
{staff.position && <p className="text-xs text-slate-500 truncate">{staff.position}</p>}
|
||||
</div>
|
||||
{staff.confirmed && (
|
||||
<Badge variant="outline" className="text-xs border-emerald-500 text-emerald-700">
|
||||
Confirmed
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{event.notes && (
|
||||
<div className="pt-2 border-t border-slate-200">
|
||||
<p className="text-xs font-semibold text-slate-600 uppercase mb-1">Notes:</p>
|
||||
<p className="text-xs text-slate-600">{event.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
136
src/components/events/EventsTable.jsx
Normal file
136
src/components/events/EventsTable.jsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import React from "react";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Eye, MoreVertical, Users } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { createPageUrl } from "@/utils";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
|
||||
const statusColors = {
|
||||
Active: "bg-blue-100 text-blue-800 border-blue-200",
|
||||
Pending: "bg-yellow-100 text-yellow-800 border-yellow-200",
|
||||
Confirmed: "bg-purple-100 text-purple-800 border-purple-200",
|
||||
Completed: "bg-green-100 text-green-800 border-green-200",
|
||||
Canceled: "bg-red-100 text-red-800 border-red-200"
|
||||
};
|
||||
|
||||
export default function EventsTable({ events }) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-slate-50">
|
||||
<TableHead className="font-semibold">ID</TableHead>
|
||||
<TableHead className="font-semibold">Hub</TableHead>
|
||||
<TableHead className="font-semibold">Status</TableHead>
|
||||
<TableHead className="font-semibold">Date</TableHead>
|
||||
<TableHead className="font-semibold">Event Name</TableHead>
|
||||
<TableHead className="font-semibold">PO</TableHead>
|
||||
<TableHead className="font-semibold text-center">Requested</TableHead>
|
||||
<TableHead className="font-semibold text-center">Assigned</TableHead>
|
||||
<TableHead className="font-semibold">Total</TableHead>
|
||||
<TableHead className="font-semibold text-center">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{events.length > 0 ? (
|
||||
events.map((event) => (
|
||||
<TableRow key={event.id} className="hover:bg-slate-50 transition-colors">
|
||||
<TableCell className="font-medium">{event.id?.slice(0, 8)}</TableCell>
|
||||
<TableCell>
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">{event.hub}</p>
|
||||
{event.event_location && (
|
||||
<p className="text-xs text-slate-500 truncate max-w-xs">{event.event_location}</p>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={`${statusColors[event.status]} border font-medium`}>
|
||||
{event.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{event.date ? format(new Date(event.date), "MMM d, yyyy") : "-"}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{event.event_name}</TableCell>
|
||||
<TableCell>{event.po || "-"}</TableCell>
|
||||
<TableCell className="text-center">{event.requested || 0}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{event.assigned_staff && event.assigned_staff.length > 0 ? (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger asChild>
|
||||
<button className="inline-flex items-center gap-1 text-blue-600 hover:text-blue-800 font-medium">
|
||||
<Users className="w-4 h-4" />
|
||||
{event.assigned_staff.length}
|
||||
</button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="w-80">
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-semibold text-sm">Assigned Staff:</h4>
|
||||
<div className="space-y-2">
|
||||
{event.assigned_staff.map((staff, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2 text-sm">
|
||||
<div className="w-6 h-6 bg-blue-500 rounded-full flex items-center justify-center text-white text-xs font-bold">
|
||||
{staff.staff_name?.charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{staff.staff_name}</p>
|
||||
{staff.position && (
|
||||
<p className="text-xs text-slate-500">{staff.position}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
) : (
|
||||
<span className="text-slate-400">0</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="font-semibold">${event.total?.toFixed(2) || "0.00"}</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreVertical className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => navigate(createPageUrl(`EditEvent?id=${event.id}`))}>
|
||||
<Eye className="w-4 h-4 mr-2" />
|
||||
View Details
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={10} className="text-center py-8 text-slate-500">
|
||||
No events found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
403
src/components/events/OrderChatAssistant.jsx
Normal file
403
src/components/events/OrderChatAssistant.jsx
Normal file
@@ -0,0 +1,403 @@
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { MessageSquare, Send, Sparkles, Mic, X, Edit, Check } from "lucide-react";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { format } from "date-fns";
|
||||
|
||||
export default function OrderChatAssistant({ onOrderDataExtracted, currentUser }) {
|
||||
const [messages, setMessages] = useState([
|
||||
{
|
||||
role: "assistant",
|
||||
content: "👋 Hi! I'm your AI assistant. Tell me about the event you'd like to create, and I'll help build your order. You can say something like: *'I need 5 servers for a corporate event on Friday at Google headquarters'*"
|
||||
}
|
||||
]);
|
||||
const [input, setInput] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isListening, setIsListening] = useState(false);
|
||||
const [extractedData, setExtractedData] = useState(null);
|
||||
const scrollRef = useRef(null);
|
||||
const recognitionRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
// Initialize speech recognition
|
||||
useEffect(() => {
|
||||
if ('webkitSpeechRecognition' in window || 'SpeechRecognition' in window) {
|
||||
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
recognitionRef.current = new SpeechRecognition();
|
||||
recognitionRef.current.continuous = false;
|
||||
recognitionRef.current.interimResults = false;
|
||||
|
||||
recognitionRef.current.onresult = (event) => {
|
||||
const transcript = event.results[0][0].transcript;
|
||||
setInput(transcript);
|
||||
setIsListening(false);
|
||||
};
|
||||
|
||||
recognitionRef.current.onerror = () => {
|
||||
setIsListening(false);
|
||||
};
|
||||
|
||||
recognitionRef.current.onend = () => {
|
||||
setIsListening(false);
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleVoiceInput = () => {
|
||||
if (!recognitionRef.current) {
|
||||
alert("Speech recognition is not supported in your browser");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isListening) {
|
||||
recognitionRef.current.stop();
|
||||
setIsListening(false);
|
||||
} else {
|
||||
recognitionRef.current.start();
|
||||
setIsListening(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!input.trim() || isLoading) return;
|
||||
|
||||
const userMessage = input.trim();
|
||||
setInput("");
|
||||
setMessages(prev => [...prev, { role: "user", content: userMessage }]);
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Build conversation context
|
||||
const conversationHistory = messages.map(m =>
|
||||
`${m.role === 'user' ? 'User' : 'Assistant'}: ${m.content}`
|
||||
).join('\n');
|
||||
|
||||
const prompt = `You are a helpful AI assistant for a workforce management platform (KROW). Your job is to help users create event orders through natural conversation.
|
||||
|
||||
Current conversation:
|
||||
${conversationHistory}
|
||||
User: ${userMessage}
|
||||
|
||||
Based on the conversation so far, do the following:
|
||||
1. Respond naturally and helpfully to the user
|
||||
2. Ask clarifying questions if needed (one at a time, keep it conversational)
|
||||
3. Extract any order information mentioned so far
|
||||
|
||||
Your response should be JSON with this structure:
|
||||
{
|
||||
"message": "Your conversational response to the user",
|
||||
"has_enough_info": true/false (true if you have enough to create a basic order),
|
||||
"extracted_data": {
|
||||
"event_name": "string or null",
|
||||
"date": "YYYY-MM-DD or null",
|
||||
"business_name": "string or null",
|
||||
"hub": "string or null",
|
||||
"order_type": "one_time/recurring/rapid/permanent or null",
|
||||
"roles": [{"role": "string", "count": number, "start_time": "HH:MM", "end_time": "HH:MM", "department": "string"}] or [],
|
||||
"notes": "any additional details mentioned"
|
||||
},
|
||||
"missing_info": ["list of key information still needed"],
|
||||
"next_question": "the next clarifying question to ask, or null if enough info"
|
||||
}
|
||||
|
||||
Key guidelines:
|
||||
- Be conversational and friendly
|
||||
- Infer reasonable defaults (e.g., if they say "Friday" assume this Friday unless context suggests otherwise)
|
||||
- For roles, try to extract: position name, count, and hours
|
||||
- If user mentions "servers", "bartenders", "security" etc., extract those as roles
|
||||
- If they mention a company name, that's the business_name
|
||||
- Keep responses brief and focused
|
||||
- Only mark has_enough_info as true if you have: event_name (or can infer one), date, and at least one role`;
|
||||
|
||||
const response = await base44.integrations.Core.InvokeLLM({
|
||||
prompt: prompt,
|
||||
response_json_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
message: { type: "string" },
|
||||
has_enough_info: { type: "boolean" },
|
||||
extracted_data: {
|
||||
type: "object",
|
||||
properties: {
|
||||
event_name: { type: ["string", "null"] },
|
||||
date: { type: ["string", "null"] },
|
||||
business_name: { type: ["string", "null"] },
|
||||
hub: { type: ["string", "null"] },
|
||||
order_type: { type: ["string", "null"] },
|
||||
roles: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
role: { type: "string" },
|
||||
count: { type: "number" },
|
||||
start_time: { type: "string" },
|
||||
end_time: { type: "string" },
|
||||
department: { type: "string" }
|
||||
}
|
||||
}
|
||||
},
|
||||
notes: { type: ["string", "null"] }
|
||||
}
|
||||
},
|
||||
missing_info: { type: "array", items: { type: "string" } },
|
||||
next_question: { type: ["string", "null"] }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setMessages(prev => [...prev, {
|
||||
role: "assistant",
|
||||
content: response.message
|
||||
}]);
|
||||
|
||||
// Update extracted data
|
||||
if (response.extracted_data) {
|
||||
setExtractedData(response.extracted_data);
|
||||
}
|
||||
|
||||
// If we have enough info, show a preview
|
||||
if (response.has_enough_info && response.extracted_data) {
|
||||
setMessages(prev => [...prev, {
|
||||
role: "system",
|
||||
content: "preview",
|
||||
data: response.extracted_data
|
||||
}]);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
setMessages(prev => [...prev, {
|
||||
role: "assistant",
|
||||
content: "Sorry, I encountered an error. Could you please try again?"
|
||||
}]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUseExtractedData = () => {
|
||||
if (!extractedData) return;
|
||||
|
||||
// Transform extracted data to match EventForm format
|
||||
const formData = {
|
||||
event_name: extractedData.event_name || "New Event",
|
||||
order_type: extractedData.order_type || "one_time",
|
||||
date: extractedData.date || format(new Date(), 'yyyy-MM-dd'),
|
||||
business_name: extractedData.business_name || "",
|
||||
hub: extractedData.hub || "",
|
||||
notes: extractedData.notes || "",
|
||||
status: "Draft",
|
||||
shifts: [{
|
||||
shift_name: "Shift 1",
|
||||
location_address: "",
|
||||
same_as_billing: true,
|
||||
roles: extractedData.roles?.map((role, index) => ({
|
||||
role: role.role || "",
|
||||
department: role.department || "",
|
||||
count: role.count || 1,
|
||||
start_time: role.start_time || "09:00",
|
||||
end_time: role.end_time || "17:00",
|
||||
hours: calculateHours(role.start_time || "09:00", role.end_time || "17:00"),
|
||||
uniform: "Type 1",
|
||||
break_minutes: 30,
|
||||
rate_per_hour: 0,
|
||||
total_value: 0
|
||||
})) || [{
|
||||
role: "",
|
||||
department: "",
|
||||
count: 1,
|
||||
start_time: "09:00",
|
||||
end_time: "17:00",
|
||||
hours: 8,
|
||||
uniform: "Type 1",
|
||||
break_minutes: 30,
|
||||
rate_per_hour: 0,
|
||||
total_value: 0
|
||||
}]
|
||||
}]
|
||||
};
|
||||
|
||||
onOrderDataExtracted(formData);
|
||||
};
|
||||
|
||||
const calculateHours = (startTime, endTime) => {
|
||||
if (!startTime || !endTime) return 8;
|
||||
const [startHour, startMin] = startTime.split(':').map(Number);
|
||||
const [endHour, endMin] = endTime.split(':').map(Number);
|
||||
const startMinutes = startHour * 60 + startMin;
|
||||
const endMinutes = endHour * 60 + endMin;
|
||||
let totalMinutes = endMinutes - startMinutes;
|
||||
if (totalMinutes < 0) totalMinutes += 24 * 60;
|
||||
return totalMinutes / 60;
|
||||
};
|
||||
|
||||
const handleKeyPress = (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="border-[#0A39DF]/20 shadow-lg h-[600px] flex flex-col">
|
||||
<CardHeader className="bg-gradient-to-r from-[#0A39DF] to-[#1C323E] text-white">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Sparkles className="w-5 h-5" />
|
||||
AI Order Assistant
|
||||
<Badge className="bg-white/20 text-white">Beta</Badge>
|
||||
</CardTitle>
|
||||
<p className="text-sm text-white/80 mt-1">
|
||||
Describe your event in natural language, and I'll create the order for you
|
||||
</p>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex-1 p-0 flex flex-col">
|
||||
<ScrollArea className="flex-1 p-4">
|
||||
<div className="space-y-4">
|
||||
{messages.map((message, index) => (
|
||||
<div key={index}>
|
||||
{message.role === "system" && message.content === "preview" ? (
|
||||
// Preview card
|
||||
<div className="bg-gradient-to-br from-green-50 to-emerald-50 rounded-lg p-4 border-2 border-green-200">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="w-5 h-5 text-green-600" />
|
||||
<h4 className="font-semibold text-green-900">Order Preview</h4>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleUseExtractedData}
|
||||
className="bg-green-600 hover:bg-green-700 text-white"
|
||||
>
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
Use This & Edit
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
{message.data.event_name && (
|
||||
<div className="flex gap-2">
|
||||
<span className="font-medium text-slate-700">Event:</span>
|
||||
<span className="text-slate-900">{message.data.event_name}</span>
|
||||
</div>
|
||||
)}
|
||||
{message.data.date && (
|
||||
<div className="flex gap-2">
|
||||
<span className="font-medium text-slate-700">Date:</span>
|
||||
<span className="text-slate-900">{format(new Date(message.data.date), 'MMM d, yyyy')}</span>
|
||||
</div>
|
||||
)}
|
||||
{message.data.business_name && (
|
||||
<div className="flex gap-2">
|
||||
<span className="font-medium text-slate-700">Client:</span>
|
||||
<span className="text-slate-900">{message.data.business_name}</span>
|
||||
</div>
|
||||
)}
|
||||
{message.data.roles && message.data.roles.length > 0 && (
|
||||
<div>
|
||||
<span className="font-medium text-slate-700">Roles:</span>
|
||||
<ul className="mt-1 space-y-1">
|
||||
{message.data.roles.map((role, idx) => (
|
||||
<li key={idx} className="text-slate-900 ml-4">
|
||||
• {role.count}x {role.role} ({role.start_time || '09:00'} - {role.end_time || '17:00'})
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`flex gap-3 ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||
{message.role === 'assistant' && (
|
||||
<Avatar className="w-8 h-8 bg-gradient-to-br from-[#0A39DF] to-[#1C323E]">
|
||||
<AvatarFallback className="text-white text-xs">AI</AvatarFallback>
|
||||
</Avatar>
|
||||
)}
|
||||
|
||||
<div className={`rounded-lg px-4 py-2 max-w-[80%] ${
|
||||
message.role === 'user'
|
||||
? 'bg-[#0A39DF] text-white'
|
||||
: 'bg-slate-100 text-slate-900'
|
||||
}`}>
|
||||
<p className="text-sm whitespace-pre-wrap">{message.content}</p>
|
||||
</div>
|
||||
|
||||
{message.role === 'user' && (
|
||||
<Avatar className="w-8 h-8 bg-slate-200">
|
||||
<AvatarFallback className="text-slate-700 text-xs">
|
||||
{currentUser?.full_name?.charAt(0) || 'U'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex gap-3">
|
||||
<Avatar className="w-8 h-8 bg-gradient-to-br from-[#0A39DF] to-[#1C323E]">
|
||||
<AvatarFallback className="text-white text-xs">AI</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="bg-slate-100 rounded-lg px-4 py-2">
|
||||
<div className="flex gap-1">
|
||||
<div className="w-2 h-2 bg-slate-400 rounded-full animate-bounce" style={{ animationDelay: '0ms' }}></div>
|
||||
<div className="w-2 h-2 bg-slate-400 rounded-full animate-bounce" style={{ animationDelay: '150ms' }}></div>
|
||||
<div className="w-2 h-2 bg-slate-400 rounded-full animate-bounce" style={{ animationDelay: '300ms' }}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={scrollRef} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<div className="p-4 border-t border-slate-200">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={isListening ? "default" : "outline"}
|
||||
size="icon"
|
||||
onClick={toggleVoiceInput}
|
||||
className={isListening ? "bg-red-500 hover:bg-red-600 animate-pulse" : ""}
|
||||
>
|
||||
<Mic className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<Input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder={isListening ? "Listening..." : "Type your message or use voice..."}
|
||||
disabled={isLoading || isListening}
|
||||
className="flex-1"
|
||||
/>
|
||||
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim() || isLoading}
|
||||
className="bg-[#0A39DF] hover:bg-[#0A39DF]/90"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 mt-2 text-center">
|
||||
💡 Pro tip: Be specific about dates, times, and number of staff needed
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
210
src/components/events/QuickAssignPopover.jsx
Normal file
210
src/components/events/QuickAssignPopover.jsx
Normal file
@@ -0,0 +1,210 @@
|
||||
import React, { useState } from "react";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Users, Search, Plus, CheckCircle2 } from "lucide-react";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
} from "@/components/ui/command";
|
||||
|
||||
export default function QuickAssignPopover({ event, children }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedStaff, setSelectedStaff] = useState([]);
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: allStaff } = useQuery({
|
||||
queryKey: ['staff'],
|
||||
queryFn: () => base44.entities.Staff.list(),
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
const updateEventMutation = useMutation({
|
||||
mutationFn: ({ id, data }) => base44.entities.Event.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['events'] });
|
||||
setSelectedStaff([]);
|
||||
setOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
const requestedCount = event.requested || 0;
|
||||
const currentAssigned = event.assigned_staff || [];
|
||||
const assignedCount = currentAssigned.length;
|
||||
const remainingSlots = requestedCount - assignedCount;
|
||||
|
||||
const handleToggleStaff = (staffId) => {
|
||||
setSelectedStaff(prev => {
|
||||
if (prev.includes(staffId)) {
|
||||
return prev.filter(id => id !== staffId);
|
||||
} else {
|
||||
// Only allow selection up to remaining slots
|
||||
if (remainingSlots > 0 && prev.length >= remainingSlots) {
|
||||
toast({
|
||||
title: "Selection Limit",
|
||||
description: `You can only select ${remainingSlots} more staff member${remainingSlots !== 1 ? 's' : ''}`,
|
||||
variant: "destructive"
|
||||
});
|
||||
return prev;
|
||||
}
|
||||
return [...prev, staffId];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleAssign = () => {
|
||||
if (selectedStaff.length === 0) return;
|
||||
|
||||
if (selectedStaff.length > remainingSlots && remainingSlots > 0) {
|
||||
toast({
|
||||
title: "Too Many Selected",
|
||||
description: `Only ${remainingSlots} slot${remainingSlots !== 1 ? 's' : ''} remaining. Assigning first ${remainingSlots}.`,
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
|
||||
const staffToAssign = remainingSlots > 0
|
||||
? selectedStaff.slice(0, remainingSlots)
|
||||
: selectedStaff;
|
||||
|
||||
const newAssignments = staffToAssign.map(staffId => {
|
||||
const staff = allStaff.find(s => s.id === staffId);
|
||||
return {
|
||||
staff_id: staff.id,
|
||||
staff_name: staff.employee_name,
|
||||
position: staff.position || "",
|
||||
confirmed: false,
|
||||
notified: true,
|
||||
notified_at: new Date().toISOString()
|
||||
};
|
||||
});
|
||||
|
||||
const updatedAssignedStaff = [...currentAssigned, ...newAssignments];
|
||||
const newStatus = updatedAssignedStaff.length >= requestedCount ? "Assigned" : "Pending";
|
||||
|
||||
updateEventMutation.mutate({
|
||||
id: event.id,
|
||||
data: {
|
||||
...event,
|
||||
assigned_staff: updatedAssignedStaff,
|
||||
assigned: updatedAssignedStaff.length,
|
||||
status: newStatus
|
||||
}
|
||||
});
|
||||
|
||||
toast({
|
||||
title: "Staff Assigned",
|
||||
description: `${newAssignments.length} staff member${newAssignments.length !== 1 ? 's' : ''} assigned successfully`,
|
||||
});
|
||||
};
|
||||
|
||||
const availableStaff = allStaff.filter(staff =>
|
||||
!currentAssigned.some(assigned => assigned.staff_id === staff.id)
|
||||
);
|
||||
|
||||
const canSelectMore = remainingSlots === 0 || selectedStaff.length < remainingSlots;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild onClick={(e) => e.stopPropagation()}>
|
||||
{children}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-96 p-0" align="end" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="p-4 border-b border-slate-200 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm">Quick Assign Staff</h3>
|
||||
<p className="text-xs text-slate-500">{event.event_name}</p>
|
||||
</div>
|
||||
<Badge variant={remainingSlots === 0 ? "default" : "outline"}>
|
||||
{assignedCount} / {requestedCount}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{remainingSlots > 0 && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-2 text-xs text-blue-800">
|
||||
Select up to <strong>{remainingSlots}</strong> staff member{remainingSlots !== 1 ? 's' : ''}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{remainingSlots === 0 && requestedCount > 0 && (
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-2 text-xs text-green-800 flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
<span>Fully staffed - no more assignments needed</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedStaff.length > 0 && (
|
||||
<Button
|
||||
onClick={handleAssign}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 text-sm"
|
||||
disabled={updateEventMutation.isPending}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Assign {Math.min(selectedStaff.length, remainingSlots || selectedStaff.length)} Staff
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Command>
|
||||
<CommandInput placeholder="Search staff..." />
|
||||
<CommandEmpty>No staff found.</CommandEmpty>
|
||||
<CommandGroup className="max-h-64 overflow-auto">
|
||||
{availableStaff.map((staff) => {
|
||||
const isSelected = selectedStaff.includes(staff.id);
|
||||
const isDisabled = !canSelectMore && !isSelected && remainingSlots > 0;
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
key={staff.id}
|
||||
onSelect={() => !isDisabled && handleToggleStaff(staff.id)}
|
||||
className={`cursor-pointer ${isDisabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<div className="flex items-center gap-3 w-full">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
disabled={isDisabled}
|
||||
onCheckedChange={() => !isDisabled && handleToggleStaff(staff.id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<div className="w-8 h-8 bg-gradient-to-br from-blue-500 to-blue-600 rounded-lg flex items-center justify-center text-white text-xs font-bold">
|
||||
{staff.initial || staff.employee_name?.charAt(0)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-slate-900 truncate text-sm">
|
||||
{staff.employee_name}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500">
|
||||
{staff.position && <span>{staff.position}</span>}
|
||||
{staff.department && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>{staff.department}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
249
src/components/events/QuickReorderModal.jsx
Normal file
249
src/components/events/QuickReorderModal.jsx
Normal file
@@ -0,0 +1,249 @@
|
||||
import React, { useState } from "react";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} 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 { Calendar } from "@/components/ui/calendar";
|
||||
import { Calendar as CalendarIcon, Users, Loader2, CheckCircle2, X } from "lucide-react";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { format } from "date-fns";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
export default function QuickReorderModal({ event, open, onOpenChange }) {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedDates, setSelectedDates] = useState([]);
|
||||
const [formData, setFormData] = useState({
|
||||
requested: event?.requested || 1,
|
||||
notes: event?.notes || ""
|
||||
});
|
||||
|
||||
const createEventsMutation = useMutation({
|
||||
mutationFn: async (ordersData) => {
|
||||
// Create multiple orders, one for each date
|
||||
const promises = ordersData.map(orderData =>
|
||||
base44.entities.Event.create(orderData)
|
||||
);
|
||||
return Promise.all(promises);
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['events'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['client-events'] });
|
||||
toast({
|
||||
title: `${data.length} Order${data.length > 1 ? 's' : ''} Created Successfully! 🎉`,
|
||||
description: `Your order${data.length > 1 ? 's have' : ' has'} been placed and ${data.length > 1 ? 'are' : 'is'} pending confirmation.`,
|
||||
});
|
||||
onOpenChange(false);
|
||||
setSelectedDates([]);
|
||||
},
|
||||
});
|
||||
|
||||
const handleDateSelect = (dates) => {
|
||||
setSelectedDates(dates || []);
|
||||
};
|
||||
|
||||
const handleRemoveDate = (dateToRemove) => {
|
||||
setSelectedDates(selectedDates.filter(d => d.getTime() !== dateToRemove.getTime()));
|
||||
};
|
||||
|
||||
const handleQuickReorder = () => {
|
||||
if (selectedDates.length === 0) {
|
||||
toast({
|
||||
title: "Date Required",
|
||||
description: "Please select at least one date for your order",
|
||||
variant: "destructive"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Create an order for each selected date
|
||||
const ordersData = selectedDates.map(date => ({
|
||||
event_name: event.event_name,
|
||||
business_id: event.business_id,
|
||||
business_name: event.business_name,
|
||||
hub: event.hub,
|
||||
event_location: event.event_location,
|
||||
event_type: event.event_type,
|
||||
client_name: event.client_name,
|
||||
client_email: event.client_email,
|
||||
client_phone: event.client_phone,
|
||||
client_address: event.client_address,
|
||||
date: format(date, 'yyyy-MM-dd'),
|
||||
requested: formData.requested,
|
||||
notes: formData.notes,
|
||||
status: "Pending",
|
||||
assigned: 0,
|
||||
assigned_staff: []
|
||||
}));
|
||||
|
||||
createEventsMutation.mutate(ordersData);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[700px] max-h-[90dvh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center justify-center mb-4">
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-[#0A39DF] to-[#1C323E] rounded-full flex items-center justify-center shadow-lg">
|
||||
<CheckCircle2 className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<DialogTitle className="text-center text-2xl text-[#1C323E]">Quick Reorder</DialogTitle>
|
||||
<DialogDescription className="text-center text-base">
|
||||
Reorder "<strong className="text-[#0A39DF]">{event?.event_name}</strong>" - Select multiple dates
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
{/* Event Summary */}
|
||||
<div className="bg-gradient-to-br from-[#0A39DF]/5 to-[#1C323E]/5 rounded-lg p-4 border border-[#0A39DF]/20">
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-600">Service:</span>
|
||||
<span className="font-semibold text-[#1C323E]">{event?.event_name}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-600">Location:</span>
|
||||
<span className="font-semibold text-[#1C323E]">{event?.event_location || "Same as before"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Calendar - Multi Date Selection */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-semibold flex items-center gap-2 text-[#1C323E]">
|
||||
<CalendarIcon className="w-5 h-5 text-[#0A39DF]" />
|
||||
Select Event Dates * (Click multiple dates)
|
||||
</Label>
|
||||
<div className="border-2 border-[#0A39DF]/30 rounded-lg p-4 bg-white">
|
||||
<Calendar
|
||||
mode="multiple"
|
||||
selected={selectedDates}
|
||||
onSelect={handleDateSelect}
|
||||
className="rounded-md"
|
||||
disabled={(date) => date < new Date()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Selected Dates Display */}
|
||||
{selectedDates.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium text-[#1C323E]">
|
||||
Selected Dates ({selectedDates.length})
|
||||
</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSelectedDates([])}
|
||||
className="text-xs text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
Clear All
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto p-2 bg-slate-50 rounded-lg border border-slate-200">
|
||||
{selectedDates.sort((a, b) => a - b).map((date, index) => (
|
||||
<Badge
|
||||
key={index}
|
||||
className="bg-[#0A39DF] text-white px-3 py-1.5 flex items-center gap-2 hover:bg-[#0A39DF]/90"
|
||||
>
|
||||
<CalendarIcon className="w-3 h-3" />
|
||||
{format(date, 'MMM d, yyyy')}
|
||||
<button
|
||||
onClick={() => handleRemoveDate(date)}
|
||||
className="hover:bg-white/20 rounded-full p-0.5"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Staff Count */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="requested" className="text-base font-semibold flex items-center gap-2 text-[#1C323E]">
|
||||
<Users className="w-4 h-4 text-[#0A39DF]" />
|
||||
Number of Staff Needed
|
||||
</Label>
|
||||
<Input
|
||||
id="requested"
|
||||
type="number"
|
||||
min="1"
|
||||
value={formData.requested}
|
||||
onChange={(e) => setFormData({ ...formData, requested: parseInt(e.target.value) || 1 })}
|
||||
className="text-lg font-semibold border-2 border-[#0A39DF]/30 focus:border-[#0A39DF]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="notes" className="text-base font-semibold text-[#1C323E]">
|
||||
Special Instructions (Optional)
|
||||
</Label>
|
||||
<Textarea
|
||||
id="notes"
|
||||
value={formData.notes}
|
||||
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
|
||||
placeholder="Any changes or special requests..."
|
||||
rows={3}
|
||||
className="border-2 border-[#0A39DF]/30 focus:border-[#0A39DF]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
{selectedDates.length > 0 && (
|
||||
<div className="bg-gradient-to-r from-[#0A39DF]/10 to-[#1C323E]/10 rounded-lg p-4 border-2 border-[#0A39DF]/30">
|
||||
<p className="text-sm font-semibold text-[#1C323E] mb-2">Order Summary:</p>
|
||||
<ul className="text-sm text-slate-700 space-y-1">
|
||||
<li>• <strong>{selectedDates.length}</strong> order{selectedDates.length > 1 ? 's' : ''} will be created</li>
|
||||
<li>• <strong>{formData.requested}</strong> staff per event</li>
|
||||
<li>• Total: <strong className="text-[#0A39DF]">{selectedDates.length * formData.requested}</strong> staff across all dates</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={createEventsMutation.isPending}
|
||||
className="flex-1 border-slate-300"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleQuickReorder}
|
||||
disabled={createEventsMutation.isPending || selectedDates.length === 0}
|
||||
className="flex-1 bg-gradient-to-r from-[#0A39DF] to-[#1C323E] hover:from-[#0A39DF]/90 hover:to-[#1C323E]/90 text-white shadow-lg"
|
||||
>
|
||||
{createEventsMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Creating {selectedDates.length} Order{selectedDates.length > 1 ? 's' : ''}...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle2 className="w-4 h-4 mr-2" />
|
||||
Place {selectedDates.length || 0} Order{selectedDates.length !== 1 ? 's' : ''}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
162
src/components/events/ShiftCard.jsx
Normal file
162
src/components/events/ShiftCard.jsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import React, { useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Clock, MapPin, Users, DollarSign, UserPlus } from "lucide-react";
|
||||
import SmartAssignModal from "./SmartAssignModal";
|
||||
import AssignedStaffManager from "./AssignedStaffManager";
|
||||
|
||||
const convertTo12Hour = (time24) => {
|
||||
if (!time24 || time24 === "—") return time24;
|
||||
|
||||
try {
|
||||
const parts = time24.split(':');
|
||||
if (!parts || parts.length < 2) return time24;
|
||||
|
||||
const hours = parseInt(parts[0], 10);
|
||||
const minutes = parseInt(parts[1], 10);
|
||||
|
||||
if (isNaN(hours) || isNaN(minutes)) return time24;
|
||||
|
||||
const period = hours >= 12 ? 'PM' : 'AM';
|
||||
const hours12 = hours % 12 || 12;
|
||||
const minutesStr = minutes.toString().padStart(2, '0');
|
||||
|
||||
return `${hours12}:${minutesStr} ${period}`;
|
||||
} catch (error) {
|
||||
console.error('Error converting time:', error);
|
||||
return time24;
|
||||
}
|
||||
};
|
||||
|
||||
export default function ShiftCard({ shift, event, currentUser }) {
|
||||
const [assignModal, setAssignModal] = useState({ open: false, role: null });
|
||||
|
||||
const roles = shift?.roles || [];
|
||||
const isVendor = currentUser?.user_role === 'vendor' || currentUser?.role === 'vendor';
|
||||
const canAssignStaff = isVendor;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="bg-white border-2 border-slate-200 shadow-sm">
|
||||
<CardHeader className="border-b border-slate-100 bg-slate-50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg font-bold text-slate-900">
|
||||
{shift.shift_name || "Shift"}
|
||||
</CardTitle>
|
||||
{shift.location && (
|
||||
<div className="flex items-center gap-2 text-sm text-slate-600 mt-1">
|
||||
<MapPin className="w-4 h-4" />
|
||||
{shift.location}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Badge className="bg-[#0A39DF] text-white font-semibold px-3 py-1.5">
|
||||
{roles.length} Role{roles.length !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-6">
|
||||
<div className="space-y-4">
|
||||
{roles.map((role, idx) => {
|
||||
const requiredCount = role.count || 1;
|
||||
const assignedCount = event?.assigned_staff?.filter(s => s.role === role.role)?.length || 0;
|
||||
const remainingCount = Math.max(requiredCount - assignedCount, 0);
|
||||
|
||||
// Consistent status color logic
|
||||
const statusColor = remainingCount === 0
|
||||
? "bg-green-100 text-green-700 border-green-300"
|
||||
: assignedCount > 0
|
||||
? "bg-blue-100 text-blue-700 border-blue-300"
|
||||
: "bg-slate-100 text-slate-700 border-slate-300";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="border-2 border-slate-200 rounded-xl p-4 hover:shadow-sm transition-shadow bg-white"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h4 className="font-bold text-slate-900 text-lg">{role.role}</h4>
|
||||
<Badge className={`${statusColor} border-2 font-bold px-3 py-1`}>
|
||||
{assignedCount} / {requiredCount} Assigned
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-sm text-slate-600">
|
||||
{role.start_time && role.end_time && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Clock className="w-4 h-4" />
|
||||
{convertTo12Hour(role.start_time)} - {convertTo12Hour(role.end_time)}
|
||||
</span>
|
||||
)}
|
||||
{role.department && (
|
||||
<Badge variant="outline" className="text-xs border-slate-300">
|
||||
{role.department}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canAssignStaff && remainingCount > 0 && (
|
||||
<Button
|
||||
onClick={() => setAssignModal({ open: true, role })}
|
||||
className="bg-[#0A39DF] hover:bg-blue-700 gap-2 font-semibold"
|
||||
>
|
||||
<UserPlus className="w-4 h-4" />
|
||||
Assign Staff ({remainingCount} needed)
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Show assigned staff */}
|
||||
{assignedCount > 0 && (
|
||||
<div className="border-t border-slate-200 pt-4 mt-4">
|
||||
<p className="text-xs font-bold text-slate-700 mb-3 uppercase tracking-wide">
|
||||
Assigned Staff
|
||||
</p>
|
||||
<AssignedStaffManager event={event} shift={shift} role={role} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Additional role details */}
|
||||
{(role.uniform || role.cost_per_hour) && (
|
||||
<div className="grid grid-cols-2 gap-4 mt-4 pt-4 border-t border-slate-200">
|
||||
{role.uniform && (
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Uniform</p>
|
||||
<p className="text-sm font-medium text-slate-900">{role.uniform}</p>
|
||||
</div>
|
||||
)}
|
||||
{role.cost_per_hour && (
|
||||
<div className="flex items-center gap-2">
|
||||
<DollarSign className="w-4 h-4 text-[#0A39DF]" />
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Rate</p>
|
||||
<p className="text-sm font-bold text-slate-900">${role.cost_per_hour}/hr</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Smart Assignment Modal */}
|
||||
<SmartAssignModal
|
||||
open={assignModal.open}
|
||||
onClose={() => setAssignModal({ open: false, role: null })}
|
||||
event={event}
|
||||
shift={shift}
|
||||
role={assignModal.role}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
427
src/components/events/ShiftRoleCard.jsx
Normal file
427
src/components/events/ShiftRoleCard.jsx
Normal file
@@ -0,0 +1,427 @@
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
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 { Plus, Minus, Trash2, Search, DollarSign, TrendingUp, Check } from "lucide-react";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const DEPARTMENTS = [
|
||||
"Accounting", "Operations", "Sales", "HR", "Finance",
|
||||
"IT", "Marketing", "Customer Service", "Logistics"
|
||||
];
|
||||
|
||||
const UNIFORMS = ["Type 1", "Type 2", "Type 3", "Casual", "Formal"];
|
||||
|
||||
const TIME_OPTIONS = [];
|
||||
for (let h = 1; h <= 12; h++) {
|
||||
for (let m of ['00', '30']) {
|
||||
const hour = h === 12 ? '12' : h.toString().padStart(2, '0');
|
||||
TIME_OPTIONS.push(`${hour}:${m} AM`);
|
||||
}
|
||||
}
|
||||
for (let h = 1; h <= 12; h++) {
|
||||
for (let m of ['00', '30']) {
|
||||
const hour = h === 12 ? '12' : h.toString().padStart(2, '0');
|
||||
TIME_OPTIONS.push(`${hour}:${m} PM`);
|
||||
}
|
||||
}
|
||||
|
||||
export default function ShiftRoleCard({ role, roleIndex, onRoleChange, onDelete, canDelete, selectedVendor }) {
|
||||
const [roleSearchOpen, setRoleSearchOpen] = useState(false);
|
||||
|
||||
// Get current user to check role
|
||||
const { data: user } = useQuery({
|
||||
queryKey: ['current-user-shift-role'],
|
||||
queryFn: () => base44.auth.me(),
|
||||
});
|
||||
|
||||
const userRole = user?.user_role || user?.role;
|
||||
const isClient = userRole === "client";
|
||||
|
||||
// Get client's vendor relationships if client
|
||||
const { data: clientVendors = [] } = useQuery({
|
||||
queryKey: ['client-vendors-for-roles', user?.id],
|
||||
queryFn: async () => {
|
||||
if (!isClient) return [];
|
||||
|
||||
const allEvents = await base44.entities.Event.list();
|
||||
const clientEvents = allEvents.filter(e =>
|
||||
e.client_email === user?.email ||
|
||||
e.business_name === user?.company_name ||
|
||||
e.created_by === user?.email
|
||||
);
|
||||
|
||||
const vendorNames = new Set();
|
||||
clientEvents.forEach(event => {
|
||||
if (event.shifts && Array.isArray(event.shifts)) {
|
||||
event.shifts.forEach(shift => {
|
||||
if (shift.roles && Array.isArray(shift.roles)) {
|
||||
shift.roles.forEach(role => {
|
||||
if (role.vendor_name) vendorNames.add(role.vendor_name);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(vendorNames);
|
||||
},
|
||||
enabled: isClient && !!user,
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
// Fetch all vendor rates
|
||||
const { data: allRates = [], isLoading } = useQuery({
|
||||
queryKey: ['vendor-rates-for-event'],
|
||||
queryFn: () => base44.entities.VendorRate.list(),
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
// Filter rates by selected vendor AND client access
|
||||
const availableRates = useMemo(() => {
|
||||
if (!allRates || !Array.isArray(allRates)) return [];
|
||||
|
||||
let filtered = allRates.filter(r => r && r.is_active);
|
||||
|
||||
// If client, only show rates from vendors they work with
|
||||
if (isClient) {
|
||||
filtered = filtered.filter(r => {
|
||||
const hasVendorRelationship = clientVendors.length === 0 || clientVendors.includes(r.vendor_name);
|
||||
const isVisibleToClient =
|
||||
r.client_visibility === 'all' ||
|
||||
(r.client_visibility === 'specific' &&
|
||||
r.available_to_clients &&
|
||||
Array.isArray(r.available_to_clients) && // Ensure it's an array before using includes
|
||||
r.available_to_clients.includes(user?.id));
|
||||
|
||||
return hasVendorRelationship && isVisibleToClient;
|
||||
});
|
||||
}
|
||||
|
||||
// Filter by selected vendor if provided
|
||||
if (selectedVendor) {
|
||||
filtered = filtered.filter(r => r.vendor_name === selectedVendor);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [allRates, selectedVendor, isClient, clientVendors, user?.id]);
|
||||
|
||||
// Group rates by category
|
||||
const ratesByCategory = useMemo(() => {
|
||||
if (!availableRates || !Array.isArray(availableRates)) return {};
|
||||
return availableRates.reduce((acc, rate) => {
|
||||
if (!rate || !rate.category) return acc;
|
||||
if (!acc[rate.category]) acc[rate.category] = [];
|
||||
acc[rate.category].push(rate);
|
||||
return acc;
|
||||
}, {});
|
||||
}, [availableRates]);
|
||||
|
||||
// Handle role selection from vendor rates
|
||||
const handleRoleSelect = (rate) => {
|
||||
if (!rate) return;
|
||||
onRoleChange('role', rate.role_name || '');
|
||||
onRoleChange('department', rate.category || '');
|
||||
onRoleChange('cost_per_hour', rate.client_rate || 0);
|
||||
onRoleChange('vendor_name', rate.vendor_name || '');
|
||||
onRoleChange('vendor_id', rate.vendor_id || '');
|
||||
setRoleSearchOpen(false);
|
||||
};
|
||||
|
||||
// Get selected rate details
|
||||
const selectedRate = availableRates.find(r => r && r.role_name === role.role);
|
||||
|
||||
return (
|
||||
<Card className="border border-slate-200 bg-white hover:border-[#0A39DF] transition-all">
|
||||
<CardContent className="p-4">
|
||||
<div className="grid grid-cols-12 gap-4 items-start">
|
||||
{/* Row Number */}
|
||||
<div className="col-span-12 md:col-span-1 flex items-center justify-center">
|
||||
<div className="w-8 h-8 bg-slate-700 rounded-full flex items-center justify-center text-white font-bold text-sm">
|
||||
{roleIndex + 1}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Role Selection with Vendor Rates - SEARCHABLE */}
|
||||
<div className="col-span-12 md:col-span-3 space-y-2">
|
||||
<div>
|
||||
<Label className="text-xs text-slate-600 mb-1">Service / Role</Label>
|
||||
<Popover open={roleSearchOpen} onOpenChange={setRoleSearchOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"w-full justify-between text-sm h-auto py-2 bg-white hover:bg-slate-50",
|
||||
!role.role && "text-slate-500"
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col items-start gap-1 text-left">
|
||||
{role.role ? (
|
||||
<>
|
||||
<span className="font-semibold text-slate-900">{role.role}</span>
|
||||
{selectedRate && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-[10px] bg-blue-50 text-blue-700 border-blue-200">
|
||||
{selectedRate.category}
|
||||
</Badge>
|
||||
<span className="text-xs text-green-600 font-bold">${selectedRate.client_rate}/hr</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span>Select service...</span>
|
||||
)}
|
||||
</div>
|
||||
<Search className="w-4 h-4 ml-2 text-slate-400 flex-shrink-0" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[min(400px,calc(100vw-2rem))] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search services..." className="h-9" />
|
||||
<CommandEmpty>
|
||||
<div className="p-4 text-center text-sm text-slate-500">
|
||||
<p>No services found.</p>
|
||||
{selectedVendor && (
|
||||
<p className="mt-2 text-xs">Contact {selectedVendor} to add services.</p>
|
||||
)}
|
||||
{!selectedVendor && (
|
||||
<p className="mt-2 text-xs">Select a vendor first to see available services.</p>
|
||||
)}
|
||||
</div>
|
||||
</CommandEmpty>
|
||||
<div className="max-h-[300px] overflow-y-auto">
|
||||
{Object.entries(ratesByCategory || {}).map(([category, rates]) => (
|
||||
<CommandGroup key={category} heading={category} className="text-slate-700">
|
||||
{Array.isArray(rates) && rates.map((rate) => (
|
||||
<CommandItem
|
||||
key={rate.id}
|
||||
value={`${rate.role_name} ${rate.vendor_name} ${rate.category}`}
|
||||
onSelect={() => handleRoleSelect(rate)}
|
||||
className="flex items-center justify-between py-3 cursor-pointer"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-semibold text-sm text-slate-900">{rate.role_name}</p>
|
||||
{role.role === rate.role_name && (
|
||||
<Check className="w-4 h-4 text-[#0A39DF]" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge variant="outline" className="text-[10px] bg-slate-50">
|
||||
{rate.vendor_name}
|
||||
</Badge>
|
||||
{rate.pricing_status === 'optimal' && (
|
||||
<Badge className="bg-green-100 text-green-700 text-[10px] border-0">
|
||||
Optimal Price
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
<div className="text-right">
|
||||
<p className="text-lg font-bold text-[#0A39DF]">${rate.client_rate}</p>
|
||||
<p className="text-[10px] text-slate-500">per hour</p>
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</div>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* Department (Auto-filled from category) */}
|
||||
<div>
|
||||
<Label className="text-xs text-slate-600 mb-1">Department</Label>
|
||||
<Select value={role.department || ""} onValueChange={(value) => onRoleChange('department', value)}>
|
||||
<SelectTrigger className="h-9 text-sm bg-white">
|
||||
<SelectValue placeholder="Department" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DEPARTMENTS.map(dept => (
|
||||
<SelectItem key={dept} value={dept}>{dept}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Count */}
|
||||
<div className="col-span-6 md:col-span-2">
|
||||
<Label className="text-xs text-slate-600 mb-1 block">Count</Label>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-9 w-9 bg-white hover:bg-slate-50"
|
||||
onClick={() => onRoleChange('count', Math.max(1, (role.count || 1) - 1))}
|
||||
>
|
||||
<Minus className="w-3 h-3" />
|
||||
</Button>
|
||||
<Input
|
||||
type="number"
|
||||
value={role.count || 1}
|
||||
onChange={(e) => onRoleChange('count', parseInt(e.target.value) || 1)}
|
||||
className="w-14 h-9 text-center p-0 text-sm bg-white"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-9 w-9 bg-white hover:bg-slate-50"
|
||||
onClick={() => onRoleChange('count', (role.count || 1) + 1)}
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time */}
|
||||
<div className="col-span-6 md:col-span-3 space-y-2">
|
||||
<div>
|
||||
<Label className="text-xs text-slate-600 mb-1">Start Time</Label>
|
||||
<Select value={role.start_time || "09:00 AM"} onValueChange={(value) => onRoleChange('start_time', value)}>
|
||||
<SelectTrigger className="h-9 text-sm bg-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-48">
|
||||
{TIME_OPTIONS.map(time => (
|
||||
<SelectItem key={time} value={time}>{time}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-slate-600 mb-1">End Time</Label>
|
||||
<Select value={role.end_time || "05:00 PM"} onValueChange={(value) => onRoleChange('end_time', value)}>
|
||||
<SelectTrigger className="h-9 text-sm bg-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-48">
|
||||
{TIME_OPTIONS.map(time => (
|
||||
<SelectItem key={time} value={time}>{time}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hours Badge */}
|
||||
<div className="col-span-3 md:col-span-1 flex flex-col items-center justify-center">
|
||||
<Label className="text-xs text-slate-600 mb-1">Hours</Label>
|
||||
<div className="bg-[#0A39DF] text-white rounded-lg px-4 py-2 flex items-center justify-center font-bold text-sm">
|
||||
{role.hours || 0}h
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Uniform & Break */}
|
||||
<div className="col-span-9 md:col-span-2 space-y-2">
|
||||
<div>
|
||||
<Label className="text-xs text-slate-600 mb-1">Uniform</Label>
|
||||
<Select value={role.uniform || "Type 1"} onValueChange={(value) => onRoleChange('uniform', value)}>
|
||||
<SelectTrigger className="h-9 text-sm bg-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{UNIFORMS.map(u => (
|
||||
<SelectItem key={u} value={u}>{u}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-slate-600 mb-1">Break (min)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={role.break_minutes || 30}
|
||||
onChange={(e) => onRoleChange('break_minutes', parseInt(e.target.value) || 0)}
|
||||
className="h-9 text-center text-sm bg-white"
|
||||
placeholder="30"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cost & Value */}
|
||||
<div className="col-span-12 md:col-span-3 flex items-end justify-between gap-4 pt-4 md:pt-0 border-t md:border-t-0 border-slate-200">
|
||||
<div className="flex-1">
|
||||
<Label className="text-xs text-slate-600 mb-1 block">Rate/hr</Label>
|
||||
<div className="relative">
|
||||
<DollarSign className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-slate-400" />
|
||||
<Input
|
||||
type="number"
|
||||
value={role.cost_per_hour || 0}
|
||||
onChange={(e) => onRoleChange('cost_per_hour', parseFloat(e.target.value) || 0)}
|
||||
className="h-9 text-sm pl-6 bg-white"
|
||||
placeholder="0.00"
|
||||
disabled={!!selectedRate}
|
||||
/>
|
||||
</div>
|
||||
{selectedRate && (
|
||||
<p className="text-[10px] text-green-600 mt-1">From vendor rate card</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Label className="text-xs text-slate-600 mb-1 block">Total</Label>
|
||||
<div className="h-9 flex items-center justify-end font-bold text-[#0A39DF] text-lg">
|
||||
${(role.total_value || 0).toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
{canDelete && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
onClick={onDelete}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pricing Info Bar */}
|
||||
{selectedRate && (
|
||||
<div className="mt-3 pt-3 border-t border-slate-200 flex items-center justify-between text-xs bg-slate-50 -mx-4 -mb-4 px-4 py-3 rounded-b-lg">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-slate-500">
|
||||
Employee Wage: <span className="font-semibold text-slate-700">${selectedRate.employee_wage || 0}/hr</span>
|
||||
</span>
|
||||
<span className="text-slate-500">
|
||||
Markup: <span className="font-semibold text-blue-600">{selectedRate.markup_percentage || 0}%</span>
|
||||
</span>
|
||||
<span className="text-slate-500">
|
||||
VA Fee: <span className="font-semibold text-purple-600">{selectedRate.vendor_fee_percentage || 0}%</span>
|
||||
</span>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-[10px] bg-green-50 text-green-700 border-green-200">
|
||||
Transparent Pricing
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
323
src/components/events/ShiftRolesTable.jsx
Normal file
323
src/components/events/ShiftRolesTable.jsx
Normal file
@@ -0,0 +1,323 @@
|
||||
import React from "react";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Plus, Minus, Pencil, Trash2, Search } from "lucide-react";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
|
||||
const ROLES = [
|
||||
"Front Desk",
|
||||
"Finance",
|
||||
"Hospitality",
|
||||
"Recruiter",
|
||||
"Server",
|
||||
"Bartender",
|
||||
"Cook",
|
||||
"Dishwasher",
|
||||
"Security",
|
||||
"Janitor"
|
||||
];
|
||||
|
||||
const DEPARTMENTS = [
|
||||
"Accounting",
|
||||
"Operations",
|
||||
"Sales",
|
||||
"HR",
|
||||
"Finance",
|
||||
"IT",
|
||||
"Marketing",
|
||||
"Customer Service",
|
||||
"Logistics"
|
||||
];
|
||||
|
||||
const UNIFORMS = ["Type 1", "Type 2", "Type 3", "Casual", "Formal"];
|
||||
|
||||
const TIME_OPTIONS = [];
|
||||
for (let h = 1; h <= 12; h++) {
|
||||
for (let m of ['00', '30']) {
|
||||
TIME_OPTIONS.push(`${h.toString().padStart(2, '0')}:${m} AM`);
|
||||
}
|
||||
}
|
||||
for (let h = 1; h <= 12; h++) {
|
||||
for (let m of ['00', '30']) {
|
||||
TIME_OPTIONS.push(`${h.toString().padStart(2, '0')}:${m} PM`);
|
||||
}
|
||||
}
|
||||
|
||||
export default function ShiftRolesTable({ roles, onChange }) {
|
||||
const handleAddRole = () => {
|
||||
onChange([...roles, {
|
||||
role: "",
|
||||
department: "",
|
||||
count: 1,
|
||||
start_time: "12:00 PM",
|
||||
end_time: "05:00 PM",
|
||||
hours: 5,
|
||||
uniform: "Type 1",
|
||||
break_minutes: 30,
|
||||
cost_per_hour: 45,
|
||||
total_value: 0
|
||||
}]);
|
||||
};
|
||||
|
||||
const handleDeleteRole = (index) => {
|
||||
onChange(roles.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleRoleChange = (index, field, value) => {
|
||||
const newRoles = [...roles];
|
||||
newRoles[index] = { ...newRoles[index], [field]: value };
|
||||
|
||||
// Calculate hours if times changed
|
||||
if (field === 'start_time' || field === 'end_time') {
|
||||
const start = newRoles[index].start_time;
|
||||
const end = newRoles[index].end_time;
|
||||
const hours = calculateHours(start, end);
|
||||
newRoles[index].hours = hours;
|
||||
}
|
||||
|
||||
// Calculate total value
|
||||
const count = newRoles[index].count || 0;
|
||||
const hours = newRoles[index].hours || 0;
|
||||
const cost = newRoles[index].cost_per_hour || 0;
|
||||
newRoles[index].total_value = count * hours * cost;
|
||||
|
||||
onChange(newRoles);
|
||||
};
|
||||
|
||||
const calculateHours = (start, end) => {
|
||||
// Simple calculation - in production, use proper time library
|
||||
const startHour = parseInt(start.split(':')[0]) + (start.includes('PM') && !start.startsWith('12') ? 12 : 0);
|
||||
const endHour = parseInt(end.split(':')[0]) + (end.includes('PM') && !end.startsWith('12') ? 12 : 0);
|
||||
return Math.max(0, endHour - startHour);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="border border-slate-200 rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-slate-50">
|
||||
<TableHead className="w-12 text-center">#</TableHead>
|
||||
<TableHead className="min-w-[150px]">Role</TableHead>
|
||||
<TableHead className="min-w-[130px]">Department</TableHead>
|
||||
<TableHead className="w-24 text-center">Count</TableHead>
|
||||
<TableHead className="min-w-[120px]">Start Date</TableHead>
|
||||
<TableHead className="min-w-[120px]">End Date</TableHead>
|
||||
<TableHead className="w-20 text-center">Hours</TableHead>
|
||||
<TableHead className="min-w-[100px]">Uniform</TableHead>
|
||||
<TableHead className="w-24">Break</TableHead>
|
||||
<TableHead className="w-20">Cost</TableHead>
|
||||
<TableHead className="w-28 text-right">Value</TableHead>
|
||||
<TableHead className="w-24 text-center">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{roles.map((role, index) => (
|
||||
<TableRow key={index} className="hover:bg-slate-50">
|
||||
<TableCell className="text-center font-medium text-slate-600">{index + 1}</TableCell>
|
||||
|
||||
{/* Role */}
|
||||
<TableCell>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="w-full justify-between">
|
||||
{role.role || "Role"}
|
||||
<Search className="w-4 h-4 ml-2 text-slate-400" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64 p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search role..." />
|
||||
<CommandEmpty>No role found.</CommandEmpty>
|
||||
<CommandGroup className="max-h-64 overflow-auto">
|
||||
{ROLES.map((r) => (
|
||||
<CommandItem
|
||||
key={r}
|
||||
onSelect={() => handleRoleChange(index, 'role', r)}
|
||||
>
|
||||
{r}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</TableCell>
|
||||
|
||||
{/* Department */}
|
||||
<TableCell>
|
||||
<Select
|
||||
value={role.department}
|
||||
onValueChange={(value) => handleRoleChange(index, 'department', value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Department" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DEPARTMENTS.map(dept => (
|
||||
<SelectItem key={dept} value={dept}>{dept}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
|
||||
{/* Count */}
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => handleRoleChange(index, 'count', Math.max(1, role.count - 1))}
|
||||
>
|
||||
<Minus className="w-3 h-3" />
|
||||
</Button>
|
||||
<Input
|
||||
type="number"
|
||||
value={role.count}
|
||||
onChange={(e) => handleRoleChange(index, 'count', parseInt(e.target.value) || 1)}
|
||||
className="w-12 h-8 text-center p-0"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => handleRoleChange(index, 'count', role.count + 1)}
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
{/* Start Time */}
|
||||
<TableCell>
|
||||
<Select
|
||||
value={role.start_time}
|
||||
onValueChange={(value) => handleRoleChange(index, 'start_time', value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-64">
|
||||
{TIME_OPTIONS.map(time => (
|
||||
<SelectItem key={time} value={time}>{time}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
|
||||
{/* End Time */}
|
||||
<TableCell>
|
||||
<Select
|
||||
value={role.end_time}
|
||||
onValueChange={(value) => handleRoleChange(index, 'end_time', value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-64">
|
||||
{TIME_OPTIONS.map(time => (
|
||||
<SelectItem key={time} value={time}>{time}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
|
||||
{/* Hours */}
|
||||
<TableCell className="text-center font-semibold">{role.hours}</TableCell>
|
||||
|
||||
{/* Uniform */}
|
||||
<TableCell>
|
||||
<Select
|
||||
value={role.uniform}
|
||||
onValueChange={(value) => handleRoleChange(index, 'uniform', value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{UNIFORMS.map(u => (
|
||||
<SelectItem key={u} value={u}>{u}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
|
||||
{/* Break */}
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
value={role.break_minutes}
|
||||
onChange={(e) => handleRoleChange(index, 'break_minutes', parseInt(e.target.value) || 0)}
|
||||
className="w-20 text-center"
|
||||
placeholder="30"
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
{/* Cost */}
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
value={role.cost_per_hour}
|
||||
onChange={(e) => handleRoleChange(index, 'cost_per_hour', parseFloat(e.target.value) || 0)}
|
||||
className="w-20 text-center"
|
||||
placeholder="45"
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
{/* Value */}
|
||||
<TableCell className="text-right font-bold text-[#0A39DF]">
|
||||
${role.total_value?.toFixed(2) || '0.00'}
|
||||
</TableCell>
|
||||
|
||||
{/* Actions */}
|
||||
<TableCell>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<Pencil className="w-4 h-4 text-slate-600" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => handleDeleteRole(index)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-red-600" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Add Role Button */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleAddRole}
|
||||
className="border-dashed border-slate-300 hover:border-[#0A39DF] hover:text-[#0A39DF]"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Role
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
354
src/components/events/ShiftSection.jsx
Normal file
354
src/components/events/ShiftSection.jsx
Normal file
@@ -0,0 +1,354 @@
|
||||
import React, { useState } from "react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Plus, Trash2, Users, MapPin, ChevronDown, ChevronRight } from "lucide-react";
|
||||
import ShiftRoleCard from "./ShiftRoleCard";
|
||||
|
||||
export default function ShiftSection({ shifts = [], onChange, addons = {}, onAddonsChange, selectedVendor }) {
|
||||
const [collapsedShifts, setCollapsedShifts] = useState({});
|
||||
|
||||
const handleAddShift = () => {
|
||||
const currentShifts = Array.isArray(shifts) ? shifts : [];
|
||||
// Collapse all existing shifts
|
||||
const newCollapsed = {};
|
||||
currentShifts.forEach((_, i) => { newCollapsed[i] = true; });
|
||||
setCollapsedShifts(newCollapsed);
|
||||
onChange([...currentShifts, {
|
||||
shift_name: `Shift ${currentShifts.length + 1}`,
|
||||
shift_contact: "",
|
||||
location_address: "",
|
||||
roles: [{
|
||||
role: "",
|
||||
department: "",
|
||||
count: 1,
|
||||
start_time: "09:00 AM",
|
||||
end_time: "05:00 PM",
|
||||
hours: 5,
|
||||
uniform: "Type 1",
|
||||
break_minutes: 30,
|
||||
cost_per_hour: 45,
|
||||
total_value: 0
|
||||
}]
|
||||
}]);
|
||||
};
|
||||
|
||||
const handleDeleteShift = (index) => {
|
||||
const currentShifts = Array.isArray(shifts) ? shifts : [];
|
||||
onChange(currentShifts.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleShiftChange = (index, field, value) => {
|
||||
const currentShifts = Array.isArray(shifts) ? shifts : [];
|
||||
const newShifts = [...currentShifts];
|
||||
newShifts[index] = { ...newShifts[index], [field]: value };
|
||||
onChange(newShifts);
|
||||
};
|
||||
|
||||
const handleRolesChange = (shiftIndex, roles) => {
|
||||
const currentShifts = Array.isArray(shifts) ? shifts : [];
|
||||
const newShifts = [...currentShifts];
|
||||
newShifts[shiftIndex] = { ...newShifts[shiftIndex], roles };
|
||||
onChange(newShifts);
|
||||
};
|
||||
|
||||
const handleAddonToggle = (addon, checked) => {
|
||||
const currentAddons = addons || {};
|
||||
onAddonsChange({
|
||||
...currentAddons,
|
||||
[addon]: typeof currentAddons[addon] === 'object'
|
||||
? { ...currentAddons[addon], enabled: checked }
|
||||
: checked
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddonTextChange = (addon, text) => {
|
||||
const currentAddons = addons || {};
|
||||
onAddonsChange({
|
||||
...currentAddons,
|
||||
[addon]: { ...currentAddons[addon], text }
|
||||
});
|
||||
};
|
||||
|
||||
const safeShifts = Array.isArray(shifts) ? shifts : [];
|
||||
const safeAddons = addons || {
|
||||
goal: { enabled: false, text: "" },
|
||||
portal_access: false,
|
||||
meal_provided: false,
|
||||
travel_time: false,
|
||||
tips: { enabled: false, amount: "300/300" }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{safeShifts.map((shift, shiftIndex) => {
|
||||
const safeShift = shift || {};
|
||||
const safeRoles = Array.isArray(safeShift.roles) ? safeShift.roles : [];
|
||||
const isCollapsed = !!collapsedShifts[shiftIndex];
|
||||
|
||||
return (
|
||||
<Card key={shiftIndex} className="border-2 border-slate-200 shadow-md">
|
||||
<CardContent className="p-6">
|
||||
{/* Shift Header */}
|
||||
<div className="flex items-start justify-between mb-4 pb-4 border-b border-slate-200">
|
||||
<div className="flex items-center gap-3 flex-1 cursor-pointer" onClick={() => setCollapsedShifts(prev => ({ ...prev, [shiftIndex]: !prev[shiftIndex] }))}>
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-purple-500 to-pink-500 rounded-xl flex items-center justify-center text-white font-bold text-lg shadow-lg">
|
||||
{shiftIndex + 1}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
value={safeShift.shift_name || ""}
|
||||
onChange={(e) => handleShiftChange(shiftIndex, 'shift_name', e.target.value)}
|
||||
className="font-bold text-lg border-none p-0 h-auto focus-visible:ring-0 mb-2"
|
||||
placeholder="Shift Name"
|
||||
/>
|
||||
<div className="flex items-center gap-2 text-sm text-slate-500">
|
||||
<MapPin className="w-4 h-4 flex-shrink-0" />
|
||||
<Input
|
||||
value={safeShift.location_address || ""}
|
||||
onChange={(e) => handleShiftChange(shiftIndex, 'location_address', e.target.value)}
|
||||
placeholder="1234 Elm Street, Apt 56B, Springfield, IL 62704, USA"
|
||||
className="border-none p-0 h-auto text-sm focus-visible:ring-0 text-slate-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-slate-300"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Users className="w-4 h-4 mr-2" />
|
||||
Add shift contact
|
||||
</Button>
|
||||
{safeShifts.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={(e) => { e.stopPropagation(); handleDeleteShift(shiftIndex); }}
|
||||
className="border-red-300 text-red-600 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Delete Shift
|
||||
</Button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsedShifts(prev => ({ ...prev, [shiftIndex]: !prev[shiftIndex] }))}
|
||||
className="p-1 rounded hover:bg-slate-100 text-slate-500"
|
||||
>
|
||||
{isCollapsed ? <ChevronRight className="w-5 h-5" /> : <ChevronDown className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Roles as Cards */}
|
||||
{!isCollapsed && (
|
||||
<div className="space-y-4 mt-6">
|
||||
{safeRoles.map((role, roleIndex) => (
|
||||
<ShiftRoleCard
|
||||
key={roleIndex}
|
||||
role={role || {}}
|
||||
roleIndex={roleIndex}
|
||||
selectedVendor={selectedVendor}
|
||||
onRoleChange={(field, value) => {
|
||||
const newRoles = [...safeRoles];
|
||||
newRoles[roleIndex] = { ...newRoles[roleIndex], [field]: value };
|
||||
if (field === 'start_time' || field === 'end_time') {
|
||||
const start = newRoles[roleIndex].start_time;
|
||||
const end = newRoles[roleIndex].end_time;
|
||||
newRoles[roleIndex].hours = calculateHours(start, end);
|
||||
}
|
||||
const count = newRoles[roleIndex].count || 0;
|
||||
const hours = newRoles[roleIndex].hours || 0;
|
||||
const cost = newRoles[roleIndex].cost_per_hour || 0;
|
||||
newRoles[roleIndex].total_value = count * hours * cost;
|
||||
handleRolesChange(shiftIndex, newRoles);
|
||||
}}
|
||||
onDelete={() => {
|
||||
const newRoles = safeRoles.filter((_, i) => i !== roleIndex);
|
||||
handleRolesChange(shiftIndex, newRoles);
|
||||
}}
|
||||
canDelete={safeRoles.length > 1}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Add Role Button */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const newRoles = [...safeRoles, {
|
||||
role: "",
|
||||
department: "",
|
||||
count: 1,
|
||||
start_time: "09:00 AM",
|
||||
end_time: "05:00 PM",
|
||||
hours: 5,
|
||||
uniform: "Type 1",
|
||||
break_minutes: 30,
|
||||
cost_per_hour: 45,
|
||||
total_value: 0
|
||||
}];
|
||||
handleRolesChange(shiftIndex, newRoles);
|
||||
}}
|
||||
className="w-full mt-4 border-dashed border-2 border-slate-300 hover:border-[#0A39DF] hover:text-[#0A39DF] hover:bg-blue-50"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Role
|
||||
</Button>
|
||||
|
||||
{/* Shift Total */}
|
||||
<div className="mt-6 pt-4 border-t border-slate-200 flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-slate-600">Shift Total:</span>
|
||||
<span className="text-2xl font-bold text-[#0A39DF]">
|
||||
${safeRoles.reduce((sum, r) => sum + ((r && r.total_value) || 0), 0).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Collapsed summary */}
|
||||
{isCollapsed && (
|
||||
<div className="mt-2 text-sm text-slate-500">
|
||||
{safeRoles.length} role{safeRoles.length !== 1 ? "s" : ""} · Total: <span className="font-semibold text-[#0A39DF]">${safeRoles.reduce((sum, r) => sum + ((r && r.total_value) || 0), 0).toFixed(2)}</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Add Location/Shift Button */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleAddShift}
|
||||
className="w-full h-16 border-2 border-dashed border-slate-300 hover:border-[#0A39DF] hover:text-[#0A39DF] hover:bg-blue-50 text-base font-semibold"
|
||||
>
|
||||
<Plus className="w-5 h-5 mr-2" />
|
||||
Add Another Location / Shift
|
||||
</Button>
|
||||
|
||||
{/* Other Addons */}
|
||||
<Card className="border-slate-200 shadow-md">
|
||||
<CardContent className="p-6">
|
||||
<h3 className="text-lg font-bold text-slate-900 mb-6">Other Addons</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Goal */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm text-slate-700">Goal</Label>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
checked={safeAddons.goal?.enabled || false}
|
||||
onCheckedChange={(checked) => handleAddonToggle('goal', checked)}
|
||||
className="data-[state=checked]:bg-green-500"
|
||||
/>
|
||||
{safeAddons.goal?.enabled && (
|
||||
<Input
|
||||
value={safeAddons.goal?.text || ""}
|
||||
onChange={(e) => handleAddonTextChange('goal', e.target.value)}
|
||||
placeholder="Enter goal"
|
||||
className="w-64"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Portal Access */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm text-slate-700">Portal Access</Label>
|
||||
<Switch
|
||||
checked={safeAddons.portal_access || false}
|
||||
onCheckedChange={(checked) => handleAddonToggle('portal_access', checked)}
|
||||
className="data-[state=checked]:bg-green-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Meal Provided */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm text-slate-700">Meal Provided</Label>
|
||||
<Switch
|
||||
checked={safeAddons.meal_provided || false}
|
||||
onCheckedChange={(checked) => handleAddonToggle('meal_provided', checked)}
|
||||
className="data-[state=checked]:bg-green-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Travel Time */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm text-slate-700">Travel Time</Label>
|
||||
<Switch
|
||||
checked={safeAddons.travel_time || false}
|
||||
onCheckedChange={(checked) => handleAddonToggle('travel_time', checked)}
|
||||
className="data-[state=checked]:bg-green-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tips */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm text-slate-700">Tips</Label>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
checked={safeAddons.tips?.enabled || false}
|
||||
onCheckedChange={(checked) => handleAddonToggle('tips', checked)}
|
||||
className="data-[state=checked]:bg-green-500"
|
||||
/>
|
||||
{safeAddons.tips?.enabled && (
|
||||
<Input
|
||||
value={safeAddons.tips?.amount || ""}
|
||||
onChange={(e) => handleAddonTextChange('tips', e.target.value)}
|
||||
placeholder="300/300"
|
||||
className="w-24 text-center"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Comments */}
|
||||
<div>
|
||||
<Label className="text-sm text-slate-700 mb-2 block">Comments</Label>
|
||||
<Textarea
|
||||
placeholder="Add your main text here..."
|
||||
rows={3}
|
||||
className="resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function calculateHours(start, end) {
|
||||
if (!start || !end) return 0;
|
||||
|
||||
const [startTime, startPeriod] = start.split(' ');
|
||||
const [endTime, endPeriod] = end.split(' ');
|
||||
const [startH, startM] = startTime.split(':').map(Number);
|
||||
const [endH, endM] = endTime.split(':').map(Number);
|
||||
|
||||
let startHour = startH;
|
||||
let endHour = endH;
|
||||
|
||||
if (startPeriod === 'PM' && startH !== 12) startHour += 12;
|
||||
if (startPeriod === 'AM' && startH === 12) startHour = 0;
|
||||
if (endPeriod === 'PM' && endH !== 12) endHour += 12;
|
||||
if (endPeriod === 'AM' && endH === 12) endHour = 0;
|
||||
|
||||
const startMinutes = startHour * 60 + startM;
|
||||
const endMinutes = endHour * 60 + endM;
|
||||
const diffMinutes = endMinutes - startMinutes;
|
||||
|
||||
return Math.max(0, diffMinutes / 60);
|
||||
}
|
||||
612
src/components/events/SmartAssignModal.jsx
Normal file
612
src/components/events/SmartAssignModal.jsx
Normal file
@@ -0,0 +1,612 @@
|
||||
import React, { useState, useMemo, useEffect } from "react";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import {
|
||||
Search,
|
||||
Users,
|
||||
AlertTriangle,
|
||||
Star,
|
||||
MapPin,
|
||||
Sparkles,
|
||||
Check,
|
||||
Calendar,
|
||||
Clock,
|
||||
Sliders,
|
||||
TrendingUp,
|
||||
Shield,
|
||||
DollarSign,
|
||||
Zap,
|
||||
Bell,
|
||||
} from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { calculateOrderStatus } from "../orders/OrderStatusUtils";
|
||||
import { calculateOTStatus, getOTBadgeProps } from "../scheduling/OvertimeCalculator";
|
||||
|
||||
// Helper to check time overlap with buffer
|
||||
function hasTimeOverlap(start1, end1, start2, end2, bufferMinutes = 30) {
|
||||
const s1 = new Date(start1).getTime();
|
||||
const e1 = new Date(end1).getTime() + bufferMinutes * 60 * 1000;
|
||||
const s2 = new Date(start2).getTime();
|
||||
const e2 = new Date(end2).getTime() + bufferMinutes * 60 * 1000;
|
||||
|
||||
return s1 < e2 && s2 < e1;
|
||||
}
|
||||
|
||||
export default function SmartAssignModal({ open, onClose, event, shift, role }) {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [assignments, setAssignments] = useState({}); // { roleKey: Set([staffIds]) }
|
||||
const [sortMode, setSortMode] = useState("smart");
|
||||
const [otWarning, setOtWarning] = useState(null); // OT warning dialog state
|
||||
|
||||
// Smart assignment priorities
|
||||
const [priorities, setPriorities] = useState({
|
||||
skill: 100, // Skill is implied by position match, not a slider
|
||||
reliability: 80,
|
||||
fatigue: 60,
|
||||
compliance: 70,
|
||||
proximity: 50,
|
||||
cost: 40,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setAssignments({});
|
||||
setSearchQuery("");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const { data: allStaff = [] } = useQuery({
|
||||
queryKey: ['staff-for-assignment'],
|
||||
queryFn: () => base44.entities.Staff.list(),
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const { data: allEvents = [] } = useQuery({
|
||||
queryKey: ['events-for-conflict-check'],
|
||||
queryFn: () => base44.entities.Event.list(),
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const { data: vendorRates = [] } = useQuery({
|
||||
queryKey: ['vendor-rates-assignment'],
|
||||
queryFn: () => base44.entities.VendorRate.list(),
|
||||
enabled: open,
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
// Get all roles that need assignment
|
||||
const allRoles = useMemo(() => {
|
||||
if (!event) return [];
|
||||
const roles = [];
|
||||
(event.shifts || []).forEach(s => {
|
||||
(s.roles || []).forEach(r => {
|
||||
const currentAssignedCount = event.assigned_staff?.filter(staff =>
|
||||
staff.role === r.role && staff.shift_name === s.shift_name
|
||||
)?.length || 0;
|
||||
const remaining = Math.max((r.count || 0) - currentAssignedCount, 0);
|
||||
if (remaining > 0) {
|
||||
roles.push({
|
||||
shift: s,
|
||||
role: r,
|
||||
currentAssigned: currentAssignedCount,
|
||||
remaining,
|
||||
key: `${s.shift_name}-${r.role}`,
|
||||
label: `${r.role}`
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
return roles;
|
||||
}, [event]);
|
||||
|
||||
const totalNeeded = allRoles.reduce((sum, r) => sum + r.remaining, 0);
|
||||
const totalSelected = Object.values(assignments).reduce((sum, set) => sum + set.size, 0);
|
||||
|
||||
// Get all staff with their matching roles
|
||||
const staffByRole = useMemo(() => {
|
||||
if (!event || allRoles.length === 0) return {};
|
||||
|
||||
const roleMap = {};
|
||||
|
||||
allRoles.forEach(roleItem => {
|
||||
const matchingStaff = allStaff
|
||||
.filter(staff => {
|
||||
const positionMatch = staff.position === roleItem.role.role ||
|
||||
staff.position_2 === roleItem.role.role;
|
||||
|
||||
if (!positionMatch) return false;
|
||||
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
return staff.employee_name?.toLowerCase().includes(query) ||
|
||||
staff.hub_location?.toLowerCase().includes(query);
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.map(staff => {
|
||||
const conflicts = allEvents.filter(e => {
|
||||
if (e.id === event.id || e.status === "Canceled" || e.status === "Completed") return false;
|
||||
const isAssigned = e.assigned_staff?.some(s => s.staff_id === staff.id);
|
||||
if (!isAssigned) return false;
|
||||
|
||||
return (e.shifts || []).some(eventShift => {
|
||||
return (eventShift.roles || []).some(eventRole => {
|
||||
const isStaffInRole = e.assigned_staff?.some(
|
||||
s => s.staff_id === staff.id && s.role === eventRole.role
|
||||
);
|
||||
if (!isStaffInRole) return false;
|
||||
|
||||
const shiftStart = `${e.date}T${eventRole.start_time || '00:00'}`;
|
||||
const shiftEnd = `${e.date}T${eventRole.end_time || '23:59'}`;
|
||||
const currentStart = `${event.date}T${roleItem.role.start_time || '00:00'}`;
|
||||
const currentEnd = `${event.date}T${roleItem.role.end_time || '23:59'}`;
|
||||
|
||||
return hasTimeOverlap(shiftStart, shiftEnd, currentStart, currentEnd);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const hasConflict = conflicts.length > 0;
|
||||
const reliability = staff.reliability_score || 85;
|
||||
|
||||
// Calculate OT status
|
||||
const otAnalysis = calculateOTStatus(
|
||||
{ ...staff, state: event.state || 'CA' },
|
||||
{
|
||||
...roleItem.role,
|
||||
date: event.date,
|
||||
state: event.state || 'CA'
|
||||
},
|
||||
allEvents
|
||||
);
|
||||
|
||||
const smartScore = reliability - (hasConflict ? 50 : 0) - (otAnalysis.status === 'RED' ? 30 : otAnalysis.status === 'AMBER' ? 10 : 0);
|
||||
|
||||
return { ...staff, hasConflict, reliability, smartScore, otAnalysis };
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (a.hasConflict !== b.hasConflict) return a.hasConflict ? 1 : -1;
|
||||
return b.smartScore - a.smartScore;
|
||||
});
|
||||
|
||||
roleMap[roleItem.key] = matchingStaff;
|
||||
});
|
||||
|
||||
return roleMap;
|
||||
}, [allStaff, allEvents, allRoles, event, searchQuery]);
|
||||
|
||||
const handleAutoAssignAll = () => {
|
||||
const newAssignments = {};
|
||||
|
||||
allRoles.forEach(roleItem => {
|
||||
const available = (staffByRole[roleItem.key] || []).filter(s => !s.hasConflict);
|
||||
const best = available.slice(0, roleItem.remaining);
|
||||
newAssignments[roleItem.key] = new Set(best.map(s => s.id));
|
||||
});
|
||||
|
||||
setAssignments(newAssignments);
|
||||
};
|
||||
|
||||
const toggleStaffForRole = (roleKey, staffId, maxCount, staffMember) => {
|
||||
// Check for OT warning
|
||||
if (staffMember?.otAnalysis?.requiresApproval && !assignments[roleKey]?.has(staffId)) {
|
||||
setOtWarning({
|
||||
staff: staffMember,
|
||||
roleKey,
|
||||
maxCount,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setAssignments(prev => {
|
||||
const current = prev[roleKey] || new Set();
|
||||
const newSet = new Set(current);
|
||||
|
||||
if (newSet.has(staffId)) {
|
||||
newSet.delete(staffId);
|
||||
} else {
|
||||
if (newSet.size >= maxCount) {
|
||||
toast({
|
||||
title: "Limit Reached",
|
||||
description: `Only ${maxCount} staff needed for this role`,
|
||||
variant: "destructive",
|
||||
});
|
||||
return prev;
|
||||
}
|
||||
newSet.add(staffId);
|
||||
}
|
||||
|
||||
return { ...prev, [roleKey]: newSet };
|
||||
});
|
||||
};
|
||||
|
||||
const confirmOTAssignment = () => {
|
||||
if (!otWarning) return;
|
||||
|
||||
const { roleKey, staff, maxCount } = otWarning;
|
||||
setAssignments(prev => {
|
||||
const current = prev[roleKey] || new Set();
|
||||
const newSet = new Set(current);
|
||||
|
||||
if (newSet.size < maxCount) {
|
||||
newSet.add(staff.id);
|
||||
}
|
||||
|
||||
return { ...prev, [roleKey]: newSet };
|
||||
});
|
||||
|
||||
setOtWarning(null);
|
||||
};
|
||||
|
||||
const assignMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const allNewStaff = [];
|
||||
|
||||
// Collect all assignments
|
||||
allRoles.forEach(roleItem => {
|
||||
const selectedIds = assignments[roleItem.key] || new Set();
|
||||
const staff = (staffByRole[roleItem.key] || []).filter(s => selectedIds.has(s.id));
|
||||
|
||||
staff.forEach(s => {
|
||||
allNewStaff.push({
|
||||
staff_id: s.id,
|
||||
staff_name: s.employee_name,
|
||||
email: s.email,
|
||||
role: roleItem.role.role,
|
||||
department: roleItem.role.department,
|
||||
shift_name: roleItem.shift.shift_name,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const updatedAssignedStaff = [...(event.assigned_staff || []), ...allNewStaff];
|
||||
|
||||
const updatedShifts = (event.shifts || []).map(shift => {
|
||||
const updatedRoles = (shift.roles || []).map(role => {
|
||||
const roleKey = `${shift.shift_name}-${role.role}`;
|
||||
const selectedCount = (assignments[roleKey] || new Set()).size;
|
||||
|
||||
if (selectedCount > 0) {
|
||||
return {
|
||||
...role,
|
||||
assigned: (role.assigned || 0) + selectedCount,
|
||||
};
|
||||
}
|
||||
return role;
|
||||
});
|
||||
return { ...shift, roles: updatedRoles };
|
||||
});
|
||||
|
||||
const updatedEvent = {
|
||||
assigned_staff: updatedAssignedStaff,
|
||||
shifts: updatedShifts,
|
||||
};
|
||||
|
||||
updatedEvent.status = calculateOrderStatus({
|
||||
...event,
|
||||
...updatedEvent
|
||||
});
|
||||
|
||||
await base44.entities.Event.update(event.id, updatedEvent);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['events'] });
|
||||
toast({
|
||||
title: "✅ Staff Assigned",
|
||||
description: `Successfully assigned ${totalSelected} staff members`,
|
||||
});
|
||||
onClose();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: "❌ Assignment Failed",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleAssign = () => {
|
||||
if (totalSelected === 0) {
|
||||
toast({
|
||||
title: "No Selection",
|
||||
description: "Please select staff members to assign",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
assignMutation.mutate();
|
||||
};
|
||||
|
||||
if (!event || allRoles.length === 0) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>No Roles to Assign</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-slate-600">All positions are fully staffed.</p>
|
||||
<DialogFooter>
|
||||
<Button onClick={onClose}>Close</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-7xl max-h-[90dvh] overflow-hidden flex flex-col">
|
||||
<DialogHeader className="border-b pb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<DialogTitle className="text-2xl font-bold text-slate-900 flex items-center gap-2">
|
||||
<Users className="w-6 h-6 text-[#0A39DF]" />
|
||||
Assign Staff
|
||||
</DialogTitle>
|
||||
<div className="flex items-center gap-3 mt-2 text-sm text-slate-600">
|
||||
<span>{event.event_name}</span>
|
||||
<span>•</span>
|
||||
<span>{event.date ? format(new Date(event.date), 'MMM d, yyyy') : 'Date TBD'}</span>
|
||||
<span>•</span>
|
||||
<span className="font-semibold text-[#0A39DF]">{totalNeeded} positions to fill</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
onClick={handleAutoAssignAll}
|
||||
className="bg-gradient-to-r from-[#0A39DF] to-blue-600 hover:from-blue-700 hover:to-blue-800 text-white font-semibold"
|
||||
>
|
||||
<Sparkles className="w-4 h-4 mr-2" />
|
||||
Auto-Assign All
|
||||
</Button>
|
||||
<Badge className="bg-blue-100 text-blue-700 border-2 border-blue-300 text-lg px-4 py-2 font-bold">
|
||||
{totalSelected} / {totalNeeded}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-hidden flex flex-col">
|
||||
<div className="mb-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<Input
|
||||
placeholder="Search staff by name or location..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 border-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto space-y-4">
|
||||
{allRoles.map((roleItem) => {
|
||||
const staff = staffByRole[roleItem.key] || [];
|
||||
const selectedSet = assignments[roleItem.key] || new Set();
|
||||
const available = staff.filter(s => !s.hasConflict);
|
||||
|
||||
return (
|
||||
<div key={roleItem.key} className="border-2 border-slate-200 rounded-lg overflow-hidden">
|
||||
<div className="bg-gradient-to-r from-slate-50 to-slate-100 px-4 py-3 border-b-2 border-slate-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-bold text-lg text-slate-900">{roleItem.label}</h3>
|
||||
<p className="text-sm text-slate-600">{available.length} available staff</p>
|
||||
</div>
|
||||
<Badge className={`${
|
||||
selectedSet.size >= roleItem.remaining
|
||||
? 'bg-green-100 text-green-700 border-green-300'
|
||||
: selectedSet.size > 0
|
||||
? 'bg-blue-100 text-blue-700 border-blue-300'
|
||||
: 'bg-slate-100 text-slate-600 border-slate-300'
|
||||
} border-2 px-4 py-2 text-lg font-bold`}>
|
||||
{selectedSet.size} / {roleItem.remaining}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-60 overflow-y-auto">
|
||||
{staff.length === 0 ? (
|
||||
<div className="text-center py-8 text-slate-500">
|
||||
<Users className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">No {roleItem.role.role}s found</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-slate-100">
|
||||
{staff.map((person) => {
|
||||
const isSelected = selectedSet.has(person.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={person.id}
|
||||
className={`p-3 flex items-center gap-3 cursor-pointer transition-all ${
|
||||
isSelected ? 'bg-blue-50 border-l-4 border-[#0A39DF]' : 'hover:bg-slate-50'
|
||||
}`}
|
||||
onClick={() => toggleStaffForRole(roleItem.key, person.id, roleItem.remaining, person)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => toggleStaffForRole(roleItem.key, person.id, roleItem.remaining, person)}
|
||||
className="w-4 h-4 rounded border-2 text-[#0A39DF]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
|
||||
<Avatar className="w-10 h-10">
|
||||
<img
|
||||
src={person.profile_picture || `https://ui-avatars.com/api/?name=${encodeURIComponent(person.employee_name)}&background=0A39DF&color=fff&size=128`}
|
||||
alt={person.employee_name}
|
||||
className="w-full h-full object-cover rounded-full"
|
||||
/>
|
||||
</Avatar>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-semibold text-slate-900 text-sm">{person.employee_name}</p>
|
||||
<div className="flex items-center gap-2 text-xs text-slate-600">
|
||||
{person.hub_location && (
|
||||
<span className="flex items-center gap-1">
|
||||
<MapPin className="w-3 h-3" />
|
||||
{person.hub_location}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<Star className="w-3 h-3 text-amber-500" />
|
||||
{person.reliability}%
|
||||
</span>
|
||||
{person.otAnalysis && (
|
||||
<span className="text-xs text-slate-500">
|
||||
{person.otAnalysis.projectedWeekHours.toFixed(0)}h week
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1 items-end">
|
||||
{person.hasConflict ? (
|
||||
<Badge className="bg-orange-100 text-orange-700 border border-orange-300 text-xs">
|
||||
<AlertTriangle className="w-3 h-3 mr-1" />
|
||||
Conflict
|
||||
</Badge>
|
||||
) : person.otAnalysis?.status === 'RED' ? (
|
||||
<Badge className="bg-red-100 text-red-700 border border-red-300 text-xs">
|
||||
<Clock className="w-3 h-3 mr-1" />
|
||||
OT Risk
|
||||
</Badge>
|
||||
) : person.otAnalysis?.status === 'AMBER' ? (
|
||||
<Badge className="bg-amber-100 text-amber-700 border border-amber-300 text-xs">
|
||||
Near OT
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="bg-green-100 text-green-700 border border-green-300 text-xs">
|
||||
Available
|
||||
</Badge>
|
||||
)}
|
||||
{person.otAnalysis?.summary && (
|
||||
<span className="text-[10px] text-slate-500 text-right max-w-[120px] line-clamp-1">
|
||||
{person.otAnalysis.summary}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="border-t pt-4 flex items-center justify-between">
|
||||
<div className="text-sm text-slate-600">
|
||||
{totalSelected > 0 && (
|
||||
<span className="font-semibold text-[#0A39DF]">
|
||||
{totalSelected} of {totalNeeded} positions filled
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={onClose} className="border-2">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAssign}
|
||||
disabled={totalSelected === 0 || assignMutation.isPending}
|
||||
className="bg-[#0A39DF] hover:bg-blue-700 font-semibold px-8"
|
||||
>
|
||||
{assignMutation.isPending ? (
|
||||
"Assigning..."
|
||||
) : (
|
||||
<>
|
||||
<Check className="w-5 h-5 mr-2" />
|
||||
Assign All ({totalSelected})
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
{/* OT Warning Dialog */}
|
||||
{otWarning && (
|
||||
<Dialog open={!!otWarning} onOpenChange={() => setOtWarning(null)}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-red-700">
|
||||
<AlertTriangle className="w-5 h-5" />
|
||||
Overtime Alert
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<p className="text-sm font-semibold text-red-900 mb-2">
|
||||
Assigning {otWarning.staff?.employee_name} will trigger overtime:
|
||||
</p>
|
||||
<div className="text-xs text-red-800 space-y-1">
|
||||
<p>• Current week: {otWarning.staff?.otAnalysis?.currentWeekHours?.toFixed(1)}h → {otWarning.staff?.otAnalysis?.projectedWeekHours?.toFixed(1)}h</p>
|
||||
<p>• Current day: {otWarning.staff?.otAnalysis?.currentDayHours?.toFixed(1)}h → {otWarning.staff?.otAnalysis?.projectedDayHours?.toFixed(1)}h</p>
|
||||
<p className="font-semibold mt-2">⚠️ {otWarning.staff?.otAnalysis?.summary}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4">
|
||||
<p className="text-sm font-semibold text-slate-900 mb-2">Cost Impact:</p>
|
||||
<div className="text-xs text-slate-700 space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span>Base cost:</span>
|
||||
<span className="font-mono">${otWarning.staff?.otAnalysis?.baseCost?.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-red-700 font-semibold">
|
||||
<span>OT premium:</span>
|
||||
<span className="font-mono">+${otWarning.staff?.otAnalysis?.costImpact?.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between border-t pt-1 font-bold">
|
||||
<span>Total:</span>
|
||||
<span className="font-mono">${otWarning.staff?.otAnalysis?.totalCost?.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-slate-600">
|
||||
This assignment requires manager approval. Overtime will be documented for compliance audits.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="outline" onClick={() => setOtWarning(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={confirmOTAssignment}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
Approve OT & Assign
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
688
src/components/events/StaffAssignment.jsx
Normal file
688
src/components/events/StaffAssignment.jsx
Normal file
@@ -0,0 +1,688 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { X, Plus, Users, CheckCircle2, XCircle, Clock, Bell, Mail } from "lucide-react";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
} from "@/components/ui/alert";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
|
||||
export default function StaffAssignment({ assignedStaff = [], onChange, requestedCount = 0, eventId, eventName, isRapid = false, currentUser }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedStaff, setSelectedStaff] = useState([]);
|
||||
const [filterDepartment, setFilterDepartment] = useState("all");
|
||||
const [filterHub, setFilterHub] = useState("all");
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const isVendor = currentUser?.user_role === 'vendor' || currentUser?.role === 'vendor';
|
||||
const canAssignStaff = isVendor;
|
||||
|
||||
const { data: allStaff, isLoading } = useQuery({
|
||||
queryKey: ['staff'],
|
||||
queryFn: () => base44.entities.Staff.list(),
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
const sendNotificationMutation = useMutation({
|
||||
mutationFn: async ({ staffEmail, staffName, eventName }) => {
|
||||
// In a real scenario, fullStaffDetails.email or similar would be used.
|
||||
// For this example, we're using contact_number or phone as the email placeholder.
|
||||
if (!staffEmail) {
|
||||
throw new Error("Staff member does not have an email address on file.");
|
||||
}
|
||||
return await base44.integrations.Core.SendEmail({
|
||||
to: staffEmail,
|
||||
subject: `You've been assigned to: ${eventName}`,
|
||||
body: `Hi ${staffName},\n\nYou have been assigned to the event: ${eventName}.\n\nPlease confirm your availability as soon as possible.\n\nThank you!`
|
||||
});
|
||||
},
|
||||
onSuccess: (data, variables) => {
|
||||
toast({
|
||||
title: "Notification Sent",
|
||||
description: `${variables.staffName} has been notified via email`,
|
||||
});
|
||||
},
|
||||
onError: (error, variables) => {
|
||||
toast({
|
||||
title: "Notification Failed",
|
||||
description: `Failed to send notification to ${variables.staffName}. ${error.message}`,
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const uniqueDepartments = [...new Set(allStaff.map(s => s.department).filter(Boolean))];
|
||||
const uniqueHubs = [...new Set(allStaff.map(s => s.hub_location).filter(Boolean))];
|
||||
|
||||
const remainingSlots = requestedCount > 0 ? requestedCount - assignedStaff.length : Infinity;
|
||||
const isFull = requestedCount > 0 && assignedStaff.length >= requestedCount;
|
||||
|
||||
// Get available (unassigned) staff
|
||||
const availableStaff = allStaff.filter(staff =>
|
||||
!assignedStaff.some(assigned => assigned.staff_id === staff.id)
|
||||
);
|
||||
|
||||
// Apply filters to available staff
|
||||
const filteredAvailableStaff = availableStaff.filter(staff => {
|
||||
const matchesDepartment = filterDepartment === "all" || staff.department === filterDepartment;
|
||||
const matchesHub = filterHub === "all" || staff.hub_location === filterHub;
|
||||
return matchesDepartment && matchesHub;
|
||||
});
|
||||
|
||||
const handleNotifyStaff = async (staffId) => {
|
||||
const staff = assignedStaff.find(s => s.staff_id === staffId);
|
||||
const fullStaffDetails = allStaff.find(s => s.id === staffId);
|
||||
|
||||
if (!staff || !fullStaffDetails) {
|
||||
toast({
|
||||
title: "Staff Not Found",
|
||||
description: "Could not find staff details to send notification.",
|
||||
variant: "destructive"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Using contact_number or phone as email for demonstration as per outline
|
||||
const staffEmail = fullStaffDetails.contact_number || fullStaffDetails.phone;
|
||||
|
||||
if (!staffEmail) {
|
||||
toast({
|
||||
title: "No Contact Information",
|
||||
description: `${staff.staff_name} doesn't have an email address on file.`,
|
||||
variant: "destructive"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await sendNotificationMutation.mutateAsync({
|
||||
staffEmail: staffEmail,
|
||||
staffName: staff.staff_name,
|
||||
eventName: eventName || "the event"
|
||||
});
|
||||
|
||||
// Update notification status in the assignedStaff list
|
||||
const updatedAssignments = assignedStaff.map(s => {
|
||||
if (s.staff_id === staffId) {
|
||||
return {
|
||||
...s,
|
||||
notified: true,
|
||||
notified_at: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
return s;
|
||||
});
|
||||
onChange(updatedAssignments);
|
||||
|
||||
} catch (error) {
|
||||
// Error handled by onSuccess/onError in sendNotificationMutation directly
|
||||
// No need for a separate toast here unless for a specific re-throw
|
||||
}
|
||||
};
|
||||
|
||||
const handleNotifyAll = async () => {
|
||||
const unnotifiedStaff = assignedStaff.filter(s => !s.notified);
|
||||
|
||||
if (unnotifiedStaff.length === 0) {
|
||||
toast({
|
||||
title: "All Staff Notified",
|
||||
description: "All assigned staff have already been notified.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "Sending Notifications",
|
||||
description: `Notifying ${unnotifiedStaff.length} staff member${unnotifiedStaff.length !== 1 ? 's' : ''}...`,
|
||||
});
|
||||
|
||||
// Send notifications to all unnotified staff
|
||||
for (const staff of unnotifiedStaff) {
|
||||
// Use await to send notifications sequentially and update status
|
||||
await handleNotifyStaff(staff.staff_id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (requestedCount > 0 && assignedStaff.length >= requestedCount) {
|
||||
toast({
|
||||
title: "Assignment Limit Reached",
|
||||
description: `All ${requestedCount} positions are filled. Cannot select more.`,
|
||||
variant: "destructive"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Only select the exact number needed to fill remaining slots
|
||||
const staffToSelect = filteredAvailableStaff
|
||||
.slice(0, remainingSlots > 0 ? remainingSlots : filteredAvailableStaff.length)
|
||||
.map(s => s.id);
|
||||
|
||||
setSelectedStaff(staffToSelect);
|
||||
|
||||
toast({
|
||||
title: "Staff Selected",
|
||||
description: `${staffToSelect.length} staff member${staffToSelect.length !== 1 ? 's' : ''} selected to fill remaining ${remainingSlots} slot${remainingSlots !== 1 ? 's' : ''}`,
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddStaff = async (staff) => {
|
||||
// Strictly enforce the requested count
|
||||
if (requestedCount > 0 && assignedStaff.length >= requestedCount) {
|
||||
toast({
|
||||
title: "Assignment Limit Reached",
|
||||
description: `This order requested exactly ${requestedCount} staff. Cannot assign more.`,
|
||||
variant: "destructive"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const isAlreadyAssigned = assignedStaff.some(s => s.staff_id === staff.id);
|
||||
if (isAlreadyAssigned) {
|
||||
toast({
|
||||
title: "Already Assigned",
|
||||
description: `${staff.employee_name} is already assigned to this event`,
|
||||
variant: "destructive"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const newStaff = {
|
||||
staff_id: staff.id,
|
||||
staff_name: staff.employee_name,
|
||||
position: staff.position || "",
|
||||
confirmed: false,
|
||||
notified: false,
|
||||
notified_at: null,
|
||||
confirmed_at: null
|
||||
};
|
||||
|
||||
const updatedAssignments = [...assignedStaff, newStaff];
|
||||
onChange(updatedAssignments);
|
||||
|
||||
toast({
|
||||
title: "Staff Assigned",
|
||||
description: `${staff.employee_name} has been assigned to the event`,
|
||||
});
|
||||
};
|
||||
|
||||
const handleBulkAssign = () => {
|
||||
if (requestedCount > 0) {
|
||||
const availableSlots = requestedCount - assignedStaff.length;
|
||||
|
||||
if (availableSlots <= 0) {
|
||||
toast({
|
||||
title: "Assignment Limit Reached",
|
||||
description: `All ${requestedCount} positions are filled. Cannot assign more.`,
|
||||
variant: "destructive"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedStaff.length > availableSlots) {
|
||||
toast({
|
||||
title: "Assignment Limit",
|
||||
description: `Only ${availableSlots} slot${availableSlots !== 1 ? 's' : ''} remaining. Assigning first ${availableSlots} selected staff.`,
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
|
||||
const staffToAssign = selectedStaff.slice(0, availableSlots);
|
||||
|
||||
if (staffToAssign.length === 0) {
|
||||
toast({
|
||||
title: "No Slots Available",
|
||||
description: "All positions are filled. Cannot assign more staff.",
|
||||
variant: "destructive"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const newAssignments = staffToAssign
|
||||
.filter(staffId => !assignedStaff.some(s => s.staff_id === staffId))
|
||||
.map(staffId => {
|
||||
const staff = allStaff.find(s => s.id === staffId);
|
||||
return {
|
||||
staff_id: staff.id,
|
||||
staff_name: staff.employee_name,
|
||||
position: staff.position || "",
|
||||
confirmed: false,
|
||||
notified: false,
|
||||
notified_at: null,
|
||||
confirmed_at: null
|
||||
};
|
||||
});
|
||||
|
||||
const updatedAssignments = [...assignedStaff, ...newAssignments];
|
||||
onChange(updatedAssignments);
|
||||
|
||||
toast({
|
||||
title: "Staff Assigned",
|
||||
description: `${newAssignments.length} staff member${newAssignments.length !== 1 ? 's' : ''} assigned successfully`,
|
||||
});
|
||||
|
||||
setSelectedStaff([]);
|
||||
setOpen(false);
|
||||
} else {
|
||||
// No limit set, allow unlimited assignments
|
||||
if (selectedStaff.length === 0) {
|
||||
toast({
|
||||
title: "No Staff Selected",
|
||||
description: "Please select staff members to assign",
|
||||
variant: "destructive"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const newAssignments = selectedStaff
|
||||
.filter(staffId => !assignedStaff.some(s => s.staff_id === staffId))
|
||||
.map(staffId => {
|
||||
const staff = allStaff.find(s => s.id === staffId);
|
||||
return {
|
||||
staff_id: staff.id,
|
||||
staff_name: staff.employee_name,
|
||||
position: staff.position || "",
|
||||
confirmed: false,
|
||||
notified: false,
|
||||
notified_at: null,
|
||||
confirmed_at: null
|
||||
};
|
||||
});
|
||||
|
||||
const updatedAssignments = [...assignedStaff, ...newAssignments];
|
||||
onChange(updatedAssignments);
|
||||
|
||||
toast({
|
||||
title: "Staff Assigned",
|
||||
description: `${newAssignments.length} staff member${newAssignments.length !== 1 ? 's' : ''} assigned successfully`,
|
||||
});
|
||||
|
||||
setSelectedStaff([]);
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveStaff = (staffId) => {
|
||||
const updatedAssignments = assignedStaff.filter(s => s.staff_id !== staffId);
|
||||
onChange(updatedAssignments);
|
||||
|
||||
toast({
|
||||
title: "Staff Removed",
|
||||
description: "Staff member has been removed from the event",
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleConfirmation = (staffId) => {
|
||||
const updatedAssignments = assignedStaff.map(staff => {
|
||||
if (staff.staff_id === staffId) {
|
||||
return {
|
||||
...staff,
|
||||
confirmed: !staff.confirmed,
|
||||
confirmed_at: !staff.confirmed ? new Date().toISOString() : null
|
||||
};
|
||||
}
|
||||
return staff;
|
||||
});
|
||||
onChange(updatedAssignments);
|
||||
|
||||
const staff = assignedStaff.find(s => s.staff_id === staffId);
|
||||
toast({
|
||||
title: staff.confirmed ? "Confirmation Removed" : "Staff Confirmed",
|
||||
description: staff.confirmed
|
||||
? `${staff.staff_name}'s confirmation has been removed`
|
||||
: `${staff.staff_name} has been confirmed for this event`,
|
||||
});
|
||||
};
|
||||
|
||||
const confirmedCount = assignedStaff.filter(s => s.confirmed).length;
|
||||
const allConfirmed = assignedStaff.length > 0 && confirmedCount === assignedStaff.length;
|
||||
|
||||
return (
|
||||
<Card className="border-slate-200 shadow-lg">
|
||||
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b border-slate-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-slate-900 flex items-center gap-2">
|
||||
<Users className="w-5 h-5 text-[#0A39DF]" />
|
||||
Staff Assignment
|
||||
{isRapid && requestedCount > 0 && (
|
||||
<Badge className="bg-red-600 text-white">
|
||||
RAPID: {requestedCount} {requestedCount === 1 ? 'position' : 'positions'}
|
||||
</Badge>
|
||||
)}
|
||||
{!isRapid && requestedCount > 0 && (
|
||||
<Badge variant={isFull ? "default" : "outline"} className={isFull ? "bg-green-600" : "border-amber-500 text-amber-700"}>
|
||||
{assignedStaff.length} / {requestedCount}
|
||||
{isFull && " ✓ Full"}
|
||||
</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
{canAssignStaff && assignedStaff.length > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleNotifyAll}
|
||||
disabled={sendNotificationMutation.isPending || assignedStaff.every(s => s.notified)}
|
||||
className="border-[#0A39DF] text-[#0A39DF] hover:bg-[#0A39DF]/10"
|
||||
>
|
||||
<Bell className="w-4 h-4 mr-2" />
|
||||
Notify All
|
||||
</Button>
|
||||
)}
|
||||
{canAssignStaff && (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
className="bg-[#0A39DF] hover:bg-[#0A39DF]/90 text-white"
|
||||
disabled={isFull && requestedCount > 0}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{isFull && requestedCount > 0
|
||||
? "Event Fully Staffed"
|
||||
: remainingSlots > 0
|
||||
? `Add Staff (${remainingSlots} needed)`
|
||||
: "Add Staff"
|
||||
}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[min(500px,calc(100vw-2rem))] p-0" align="end">
|
||||
<div className="p-4 border-b border-slate-200 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold">Assign Staff Members</h3>
|
||||
<Badge variant={isFull ? "default" : "outline"} className={isFull ? "bg-green-600" : ""}>
|
||||
{assignedStaff.length} / {requestedCount || "∞"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{remainingSlots > 0 && !isFull && (
|
||||
<Alert className="bg-amber-50 border-amber-200">
|
||||
<AlertDescription className="text-amber-800 text-xs">
|
||||
<strong>{remainingSlots}</strong> more staff member{remainingSlots !== 1 ? 's' : ''} needed to fill all positions
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isFull && requestedCount > 0 && (
|
||||
<Alert className="bg-green-50 border-green-200">
|
||||
<AlertDescription className="text-green-800 text-xs flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
Event fully staffed - all {requestedCount} positions filled
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Select value={filterDepartment} onValueChange={setFilterDepartment}>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue placeholder="Department" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Departments</SelectItem>
|
||||
{uniqueDepartments.map(dept => (
|
||||
<SelectItem key={dept} value={dept}>{dept}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={filterHub} onValueChange={setFilterHub}>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue placeholder="Hub" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Hubs</SelectItem>
|
||||
{uniqueHubs.map(hub => (
|
||||
<SelectItem key={hub} value={hub}>{hub}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleSelectAll}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
disabled={isFull || filteredAvailableStaff.length === 0}
|
||||
>
|
||||
Select {remainingSlots > 0 ? `${Math.min(remainingSlots, filteredAvailableStaff.length)}` : 'All'} Available
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setSelectedStaff([])}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
disabled={selectedStaff.length === 0}
|
||||
>
|
||||
Clear Selection
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{selectedStaff.length > 0 && !isFull && (
|
||||
<Button
|
||||
onClick={handleBulkAssign}
|
||||
className="w-full bg-[#0A39DF] hover:bg-[#0A39DF]/90"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Assign {Math.min(selectedStaff.length, remainingSlots > 0 ? remainingSlots : selectedStaff.length)} Staff
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Command className="max-h-80 overflow-auto">
|
||||
<CommandInput placeholder="Search staff..." />
|
||||
<CommandEmpty>No available staff found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{filteredAvailableStaff.length === 0 ? (
|
||||
<div className="p-4 text-center text-sm text-slate-500">
|
||||
{availableStaff.length === 0
|
||||
? "All staff members are already assigned"
|
||||
: "No staff match the selected filters"}
|
||||
</div>
|
||||
) : (
|
||||
filteredAvailableStaff.map((staff) => {
|
||||
const isSelected = selectedStaff.includes(staff.id);
|
||||
const canSelect = !isFull || isSelected;
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
key={staff.id}
|
||||
onSelect={() => {
|
||||
if (!canSelect) return;
|
||||
|
||||
if (isSelected) {
|
||||
setSelectedStaff(prev => prev.filter(id => id !== staff.id));
|
||||
} else {
|
||||
if (requestedCount > 0 && selectedStaff.length >= remainingSlots) {
|
||||
toast({
|
||||
title: "Selection Limit",
|
||||
description: `You can only select ${remainingSlots} more staff member${remainingSlots !== 1 ? 's' : ''}`,
|
||||
variant: "destructive"
|
||||
});
|
||||
return;
|
||||
}
|
||||
setSelectedStaff(prev => [...prev, staff.id]);
|
||||
}
|
||||
}}
|
||||
className={`cursor-pointer ${!canSelect ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
disabled={!canSelect}
|
||||
>
|
||||
<div className="flex items-center gap-3 w-full">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
disabled={!canSelect}
|
||||
onCheckedChange={() => {}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<div className="w-8 h-8 bg-gradient-to-br from-[#0A39DF] to-[#1C323E] rounded-lg flex items-center justify-center text-white text-xs font-bold">
|
||||
{staff.initial || staff.employee_name?.charAt(0)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-slate-900 truncate text-sm">
|
||||
{staff.employee_name}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500">
|
||||
{staff.position && <span>{staff.position}</span>}
|
||||
{staff.department && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>{staff.department}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</CommandGroup>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-sm text-slate-600">
|
||||
<span className="font-semibold text-lg text-[#1C323E]">{assignedStaff.length}</span>
|
||||
<span className="text-slate-500"> / {requestedCount || "∞"} assigned</span>
|
||||
</div>
|
||||
{assignedStaff.length > 0 && (
|
||||
<div className="text-sm text-slate-600">
|
||||
<span className="font-semibold text-lg text-green-600">{confirmedCount}</span>
|
||||
<span className="text-slate-500"> confirmed</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{allConfirmed && assignedStaff.length > 0 && (
|
||||
<Badge className="bg-green-50 text-green-700 border-green-200">
|
||||
<CheckCircle2 className="w-3 h-3 mr-1" />
|
||||
All Confirmed
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{assignedStaff.length === 0 ? (
|
||||
<div className="text-center py-12 bg-slate-50 rounded-lg border-2 border-dashed border-slate-200">
|
||||
<Users className="w-12 h-12 mx-auto text-slate-300 mb-3" />
|
||||
<p className="text-slate-600 font-medium mb-1">No staff assigned yet</p>
|
||||
<p className="text-sm text-slate-500">Click "Add Staff" to assign team members</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{assignedStaff.map((staff, index) => (
|
||||
<div
|
||||
key={staff.staff_id}
|
||||
className="flex items-center gap-4 p-4 bg-slate-50 rounded-lg border border-slate-200 hover:bg-slate-100 transition-colors"
|
||||
>
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-[#0A39DF] to-[#1C323E] rounded-lg flex items-center justify-center text-white font-bold shadow-sm">
|
||||
{staff.staff_name?.charAt(0) || "?"}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-semibold text-slate-900">{staff.staff_name}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{staff.position && (
|
||||
<p className="text-sm text-slate-500">{staff.position}</p>
|
||||
)}
|
||||
{staff.notified && (
|
||||
<Badge variant="outline" className="text-xs border-[#0A39DF] text-[#0A39DF]">
|
||||
<Bell className="w-3 h-3 mr-1" />
|
||||
Notified
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{canAssignStaff && !staff.notified && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleNotifyStaff(staff.staff_id)}
|
||||
disabled={sendNotificationMutation.isPending}
|
||||
className="border-[#0A39DF] text-[#0A39DF] hover:bg-[#0A39DF]/10"
|
||||
>
|
||||
<Bell className="w-4 h-4 mr-1" />
|
||||
Notify
|
||||
</Button>
|
||||
)}
|
||||
{canAssignStaff && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={staff.confirmed ? "default" : "outline"}
|
||||
onClick={() => handleToggleConfirmation(staff.staff_id)}
|
||||
className={staff.confirmed ? "bg-green-600 hover:bg-green-700" : ""}
|
||||
>
|
||||
{staff.confirmed ? (
|
||||
<>
|
||||
<CheckCircle2 className="w-4 h-4 mr-1" />
|
||||
Confirmed
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Clock className="w-4 h-4 mr-1" />
|
||||
Pending
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{!canAssignStaff && staff.confirmed && (
|
||||
<Badge className="bg-green-600 text-white">
|
||||
<CheckCircle2 className="w-3 h-3 mr-1" />
|
||||
Confirmed
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{canAssignStaff && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => handleRemoveStaff(staff.staff_id)}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
29
src/components/events/StatusCard.jsx
Normal file
29
src/components/events/StatusCard.jsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
export default function StatusCard({ status, count, percentage, color }) {
|
||||
const colorClasses = {
|
||||
blue: "from-[#0A39DF] to-[#0A39DF]/80",
|
||||
purple: "from-purple-600 to-purple-700",
|
||||
green: "from-emerald-600 to-emerald-700",
|
||||
gray: "from-slate-600 to-slate-700",
|
||||
yellow: "from-amber-500 to-amber-600"
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="p-6 bg-white border-slate-200 hover:shadow-lg transition-all duration-300 hover:-translate-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-500 mb-2">{status}</p>
|
||||
<div className={`w-14 h-14 bg-gradient-to-br ${colorClasses[color]} rounded-xl flex items-center justify-center text-white font-bold text-lg shadow-md`}>
|
||||
{count}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-3xl font-bold text-[#1C323E]">{percentage}%</div>
|
||||
<p className="text-xs text-slate-500 mt-1">of total</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
340
src/components/events/VendorRoutingPanel.jsx
Normal file
340
src/components/events/VendorRoutingPanel.jsx
Normal file
@@ -0,0 +1,340 @@
|
||||
import React, { useState } from "react";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Award, Star, MapPin, Users, TrendingUp, AlertTriangle, Zap, CheckCircle2, Send } from "lucide-react";
|
||||
|
||||
export default function VendorRoutingPanel({
|
||||
user,
|
||||
selectedVendors = [],
|
||||
onVendorChange,
|
||||
isRapid = false
|
||||
}) {
|
||||
const [showVendorSelector, setShowVendorSelector] = useState(false);
|
||||
const [selectionMode, setSelectionMode] = useState('single'); // 'single' | 'multi'
|
||||
|
||||
// Fetch preferred vendor
|
||||
const { data: preferredVendor } = useQuery({
|
||||
queryKey: ['preferred-vendor-routing', user?.preferred_vendor_id],
|
||||
queryFn: async () => {
|
||||
if (!user?.preferred_vendor_id) return null;
|
||||
const vendors = await base44.entities.Vendor.list();
|
||||
return vendors.find(v => v.id === user.preferred_vendor_id);
|
||||
},
|
||||
enabled: !!user?.preferred_vendor_id,
|
||||
});
|
||||
|
||||
// Fetch all approved vendors
|
||||
const { data: allVendors } = useQuery({
|
||||
queryKey: ['all-vendors-routing'],
|
||||
queryFn: () => base44.entities.Vendor.filter({
|
||||
approval_status: 'approved',
|
||||
is_active: true
|
||||
}),
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
// Auto-select preferred vendor on mount if none selected
|
||||
React.useEffect(() => {
|
||||
if (preferredVendor && selectedVendors.length === 0) {
|
||||
onVendorChange([preferredVendor]);
|
||||
}
|
||||
}, [preferredVendor]);
|
||||
|
||||
const handleVendorSelect = (vendor) => {
|
||||
if (selectionMode === 'single') {
|
||||
onVendorChange([vendor]);
|
||||
setShowVendorSelector(false);
|
||||
} else {
|
||||
// Multi-select mode
|
||||
const isSelected = selectedVendors.some(v => v.id === vendor.id);
|
||||
if (isSelected) {
|
||||
onVendorChange(selectedVendors.filter(v => v.id !== vendor.id));
|
||||
} else {
|
||||
onVendorChange([...selectedVendors, vendor]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleMultiVendorDone = () => {
|
||||
if (selectedVendors.length === 0) {
|
||||
alert("Please select at least one vendor");
|
||||
return;
|
||||
}
|
||||
setShowVendorSelector(false);
|
||||
};
|
||||
|
||||
const routingMode = selectedVendors.length > 1 ? 'multi' : 'single';
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className={`border-2 ${
|
||||
isRapid
|
||||
? 'border-red-300 bg-gradient-to-br from-red-50 to-orange-50'
|
||||
: 'border-blue-300 bg-gradient-to-br from-blue-50 to-indigo-50'
|
||||
}`}>
|
||||
<CardContent className="p-4">
|
||||
<div className="space-y-3">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-8 h-8 ${
|
||||
isRapid ? 'bg-red-600' : 'bg-blue-600'
|
||||
} rounded-lg flex items-center justify-center`}>
|
||||
{isRapid ? (
|
||||
<Zap className="w-4 h-4 text-white" />
|
||||
) : (
|
||||
<Send className="w-4 h-4 text-white" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-bold text-slate-600 uppercase tracking-wider">
|
||||
{isRapid ? 'RAPID ORDER ROUTING' : 'Order Routing'}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{routingMode === 'multi'
|
||||
? `Sending to ${selectedVendors.length} vendors`
|
||||
: 'Default vendor routing'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{routingMode === 'multi' && (
|
||||
<Badge className="bg-purple-600 text-white font-bold">
|
||||
MULTI-VENDOR
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Selected Vendor(s) */}
|
||||
<div className="space-y-2">
|
||||
{selectedVendors.length === 0 && !preferredVendor && (
|
||||
<div className="p-3 bg-amber-50 border-2 border-amber-200 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-600 flex-shrink-0" />
|
||||
<p className="text-amber-800">
|
||||
<strong>No vendor selected.</strong> Please choose a vendor.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedVendors.map((vendor) => {
|
||||
const isPreferred = vendor.id === preferredVendor?.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={vendor.id}
|
||||
className="p-3 bg-white border-2 border-slate-200 rounded-lg hover:border-blue-300 transition-all"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<p className="font-bold text-sm text-slate-900">
|
||||
{vendor.doing_business_as || vendor.legal_name}
|
||||
</p>
|
||||
{isPreferred && (
|
||||
<Badge className="bg-blue-600 text-white text-xs px-2 py-0 border-0">
|
||||
<Award className="w-3 h-3 mr-1" />
|
||||
Preferred
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500">
|
||||
{vendor.region && (
|
||||
<span className="flex items-center gap-1">
|
||||
<MapPin className="w-3 h-3" />
|
||||
{vendor.region}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="w-3 h-3" />
|
||||
{vendor.workforce_count || 0} staff
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{routingMode === 'multi' && (
|
||||
<CheckCircle2 className="w-5 h-5 text-green-600 flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="grid grid-cols-2 gap-2 pt-2 border-t border-slate-200">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSelectionMode('single');
|
||||
setShowVendorSelector(true);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
{selectedVendors.length === 0 ? 'Choose Vendor' : 'Change Vendor'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSelectionMode('multi');
|
||||
setShowVendorSelector(true);
|
||||
}}
|
||||
className="text-xs bg-purple-50 border-purple-300 text-purple-700 hover:bg-purple-100"
|
||||
>
|
||||
<Zap className="w-3 h-3 mr-1" />
|
||||
Multi-Vendor
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Info Banner */}
|
||||
{routingMode === 'multi' && (
|
||||
<div className="bg-purple-50 border-2 border-purple-200 rounded-lg p-2">
|
||||
<p className="text-xs text-purple-800">
|
||||
<strong>Multi-Vendor Mode:</strong> Order sent to all selected vendors.
|
||||
First to confirm gets the job.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isRapid && (
|
||||
<div className="bg-red-50 border-2 border-red-200 rounded-lg p-2">
|
||||
<p className="text-xs text-red-800">
|
||||
<strong>RAPID Priority:</strong> This order will be marked urgent with priority notification.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Vendor Selector Dialog */}
|
||||
<Dialog open={showVendorSelector} onOpenChange={setShowVendorSelector}>
|
||||
<DialogContent className="max-w-3xl max-h-[85dvh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-bold flex items-center gap-2">
|
||||
{selectionMode === 'multi' ? (
|
||||
<>
|
||||
<Zap className="w-6 h-6 text-purple-600" />
|
||||
Select Multiple Vendors
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="w-6 h-6 text-blue-600" />
|
||||
Select Vendor
|
||||
</>
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{selectionMode === 'multi'
|
||||
? 'Select multiple vendors to send this order to all at once. First to confirm gets the job.'
|
||||
: 'Choose which vendor should receive this order'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 mt-4">
|
||||
{allVendors.map((vendor) => {
|
||||
const isSelected = selectedVendors.some(v => v.id === vendor.id);
|
||||
const isPreferred = vendor.id === preferredVendor?.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={vendor.id}
|
||||
className={`p-4 rounded-lg border-2 transition-all cursor-pointer ${
|
||||
isSelected
|
||||
? 'border-blue-400 bg-blue-50'
|
||||
: 'border-slate-200 hover:border-blue-300 hover:bg-slate-50'
|
||||
}`}
|
||||
onClick={() => handleVendorSelect(vendor)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h3 className="font-bold text-lg text-slate-900">
|
||||
{vendor.doing_business_as || vendor.legal_name}
|
||||
</h3>
|
||||
{isPreferred && (
|
||||
<Badge className="bg-blue-600 text-white">
|
||||
<Award className="w-3 h-3 mr-1" />
|
||||
Preferred
|
||||
</Badge>
|
||||
)}
|
||||
{isSelected && selectionMode === 'multi' && (
|
||||
<Badge className="bg-green-600 text-white">
|
||||
<CheckCircle2 className="w-3 h-3 mr-1" />
|
||||
Selected
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap mb-2">
|
||||
{vendor.region && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
<MapPin className="w-3 h-3 mr-1" />
|
||||
{vendor.region}
|
||||
</Badge>
|
||||
)}
|
||||
{vendor.service_specialty && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{vendor.service_specialty}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-slate-600">
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="w-4 h-4" />
|
||||
{vendor.workforce_count || 0} staff
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Star className="w-4 h-4 text-amber-500 fill-amber-500" />
|
||||
4.9
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<TrendingUp className="w-4 h-4 text-green-600" />
|
||||
98% fill rate
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{allVendors.length === 0 && (
|
||||
<div className="text-center py-8 text-slate-400">
|
||||
<Users className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p className="font-medium">No vendors available</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectionMode === 'multi' && (
|
||||
<div className="flex items-center justify-between pt-4 border-t">
|
||||
<p className="text-sm text-slate-600">
|
||||
{selectedVendors.length} vendor{selectedVendors.length !== 1 ? 's' : ''} selected
|
||||
</p>
|
||||
<Button
|
||||
onClick={handleMultiVendorDone}
|
||||
disabled={selectedVendors.length === 0}
|
||||
className="bg-purple-600 hover:bg-purple-700"
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4 mr-2" />
|
||||
Confirm Selection
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
175
src/components/invoices/AutoInvoiceGenerator.jsx
Normal file
175
src/components/invoices/AutoInvoiceGenerator.jsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { format, addDays } from "date-fns";
|
||||
|
||||
/**
|
||||
* Auto Invoice Generator Component
|
||||
* Monitors completed events and automatically generates invoices
|
||||
* when all staff have ended their shifts
|
||||
*/
|
||||
export default function AutoInvoiceGenerator() {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: events = [] } = useQuery({
|
||||
queryKey: ['events-for-invoice-generation'],
|
||||
queryFn: () => base44.entities.Event.list(),
|
||||
refetchInterval: 60000, // Check every minute
|
||||
});
|
||||
|
||||
const { data: invoices = [] } = useQuery({
|
||||
queryKey: ['invoices'],
|
||||
queryFn: () => base44.entities.Invoice.list(),
|
||||
});
|
||||
|
||||
const createInvoiceMutation = useMutation({
|
||||
mutationFn: (invoiceData) => base44.entities.Invoice.create(invoiceData),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['invoices'] });
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!events || !invoices) return;
|
||||
|
||||
// Find completed events that don't have invoices yet
|
||||
const completedEvents = events.filter(event =>
|
||||
event.status === "Completed" &&
|
||||
!invoices.some(inv => inv.event_id === event.id)
|
||||
);
|
||||
|
||||
completedEvents.forEach(async (event) => {
|
||||
try {
|
||||
// Group staff by role and generate detailed entries
|
||||
const roleGroups = {};
|
||||
|
||||
if (event.assigned_staff && event.shifts) {
|
||||
event.shifts.forEach(shift => {
|
||||
shift.roles?.forEach(role => {
|
||||
const assignedForRole = event.assigned_staff.filter(
|
||||
s => s.role === role.role
|
||||
);
|
||||
|
||||
if (!roleGroups[role.role]) {
|
||||
roleGroups[role.role] = {
|
||||
role_name: role.role,
|
||||
staff_entries: [],
|
||||
role_subtotal: 0
|
||||
};
|
||||
}
|
||||
|
||||
assignedForRole.forEach(staff => {
|
||||
const workedHours = role.hours || 8;
|
||||
const baseRate = role.cost_per_hour || role.rate_per_hour || 0;
|
||||
|
||||
// Calculate regular, OT, and DT hours
|
||||
const regularHours = Math.min(workedHours, 8);
|
||||
const otHours = Math.max(0, Math.min(workedHours - 8, 4));
|
||||
const dtHours = Math.max(0, workedHours - 12);
|
||||
|
||||
// Calculate rates (OT = 1.5x, DT = 2x)
|
||||
const regularRate = baseRate;
|
||||
const otRate = baseRate * 1.5;
|
||||
const dtRate = baseRate * 2;
|
||||
|
||||
// Calculate values
|
||||
const regularValue = regularHours * regularRate;
|
||||
const otValue = otHours * otRate;
|
||||
const dtValue = dtHours * dtRate;
|
||||
const total = regularValue + otValue + dtValue;
|
||||
|
||||
const entry = {
|
||||
staff_name: staff.staff_name,
|
||||
staff_id: staff.staff_id,
|
||||
date: event.date,
|
||||
position: role.role,
|
||||
check_in: role.start_time || "09:00 AM",
|
||||
check_out: role.end_time || "05:00 PM",
|
||||
worked_hours: workedHours,
|
||||
regular_hours: regularHours,
|
||||
ot_hours: otHours,
|
||||
dt_hours: dtHours,
|
||||
regular_rate: regularRate,
|
||||
ot_rate: otRate,
|
||||
dt_rate: dtRate,
|
||||
regular_value: regularValue,
|
||||
ot_value: otValue,
|
||||
dt_value: dtValue,
|
||||
rate: baseRate,
|
||||
total: total
|
||||
};
|
||||
|
||||
roleGroups[role.role].staff_entries.push(entry);
|
||||
roleGroups[role.role].role_subtotal += total;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const roles = Object.values(roleGroups);
|
||||
const subtotal = roles.reduce((sum, role) => sum + role.role_subtotal, 0);
|
||||
const otherCharges = 0;
|
||||
const total = subtotal + otherCharges;
|
||||
|
||||
// Generate invoice number
|
||||
const invoiceNumber = `INV-${Math.floor(Math.random() * 10000)}`;
|
||||
|
||||
// Get vendor and client info
|
||||
const vendorInfo = {
|
||||
name: event.vendor_name || "Legendary",
|
||||
address: "848 E Gish Rd Ste 1, San Jose, CA 95112",
|
||||
email: "orders@legendaryeventstaff.com",
|
||||
phone: "(408) 936-0180"
|
||||
};
|
||||
|
||||
const clientInfo = {
|
||||
name: event.business_name || "Client Company",
|
||||
address: event.event_location || "Address",
|
||||
email: event.client_email || "",
|
||||
manager: event.client_name || event.manager_name || "Manager",
|
||||
phone: event.client_phone || "",
|
||||
vendor_id: "Vendor #"
|
||||
};
|
||||
|
||||
// Create invoice
|
||||
const invoiceData = {
|
||||
invoice_number: invoiceNumber,
|
||||
event_id: event.id,
|
||||
event_name: event.event_name,
|
||||
event_date: event.date,
|
||||
po_reference: event.po_reference,
|
||||
from_company: vendorInfo,
|
||||
to_company: clientInfo,
|
||||
business_name: event.business_name,
|
||||
manager_name: event.client_name || event.business_name,
|
||||
vendor_name: event.vendor_name,
|
||||
vendor_id: event.vendor_id,
|
||||
hub: event.hub,
|
||||
cost_center: event.po_reference,
|
||||
roles: roles,
|
||||
subtotal: subtotal,
|
||||
other_charges: otherCharges,
|
||||
amount: total,
|
||||
status: "Pending Review",
|
||||
issue_date: format(new Date(), 'yyyy-MM-dd'),
|
||||
due_date: format(addDays(new Date(), 30), 'yyyy-MM-dd'),
|
||||
is_auto_generated: true,
|
||||
notes: `Automatically generated invoice for ${event.event_name}`,
|
||||
};
|
||||
|
||||
await createInvoiceMutation.mutateAsync(invoiceData);
|
||||
|
||||
toast({
|
||||
title: "✅ Invoice Generated",
|
||||
description: `Invoice ${invoiceNumber} created for ${event.event_name}`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to generate invoice:', error);
|
||||
}
|
||||
});
|
||||
}, [events, invoices]);
|
||||
|
||||
return null; // This is a background component
|
||||
}
|
||||
201
src/components/invoices/CreateInvoiceModal.jsx
Normal file
201
src/components/invoices/CreateInvoiceModal.jsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import React, { useState } from "react";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } 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 { Textarea } from "@/components/ui/textarea";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useMutation, useQueryClient, useQuery } from "@tanstack/react-query";
|
||||
import { format, addDays } from "date-fns";
|
||||
import { Plus, Trash2, FileEdit } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { createPageUrl } from "@/utils";
|
||||
|
||||
export default function CreateInvoiceModal({ open, onClose }) {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [formData, setFormData] = useState({ event_id: "", po_reference: "", other_charges: 0, notes: "" });
|
||||
|
||||
const { data: events = [] } = useQuery({
|
||||
queryKey: ['events-for-invoice'],
|
||||
queryFn: () => base44.entities.Event.list(),
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleAdvancedEditor = () => {
|
||||
onClose();
|
||||
navigate(createPageUrl('InvoiceEditor'));
|
||||
};
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (data) => {
|
||||
const selectedEvent = events.find(e => e.id === data.event_id);
|
||||
if (!selectedEvent) throw new Error("Event not found");
|
||||
|
||||
// Generate roles and staff entries from event
|
||||
const roleGroups = {};
|
||||
|
||||
if (selectedEvent.assigned_staff && selectedEvent.shifts) {
|
||||
selectedEvent.shifts.forEach(shift => {
|
||||
shift.roles?.forEach(role => {
|
||||
const assignedForRole = selectedEvent.assigned_staff.filter(
|
||||
s => s.role === role.role
|
||||
);
|
||||
|
||||
if (!roleGroups[role.role]) {
|
||||
roleGroups[role.role] = {
|
||||
role_name: role.role,
|
||||
staff_entries: [],
|
||||
role_subtotal: 0
|
||||
};
|
||||
}
|
||||
|
||||
assignedForRole.forEach(staff => {
|
||||
const workedHours = role.hours || 8;
|
||||
const baseRate = role.cost_per_hour || role.rate_per_hour || 0;
|
||||
|
||||
const regularHours = Math.min(workedHours, 8);
|
||||
const otHours = Math.max(0, Math.min(workedHours - 8, 4));
|
||||
const dtHours = Math.max(0, workedHours - 12);
|
||||
|
||||
const regularRate = baseRate;
|
||||
const otRate = baseRate * 1.5;
|
||||
const dtRate = baseRate * 2;
|
||||
|
||||
const regularValue = regularHours * regularRate;
|
||||
const otValue = otHours * otRate;
|
||||
const dtValue = dtHours * dtRate;
|
||||
const total = regularValue + otValue + dtValue;
|
||||
|
||||
const entry = {
|
||||
staff_name: staff.staff_name,
|
||||
staff_id: staff.staff_id,
|
||||
date: selectedEvent.date,
|
||||
position: role.role,
|
||||
check_in: role.start_time || "09:00 AM",
|
||||
check_out: role.end_time || "05:00 PM",
|
||||
worked_hours: workedHours,
|
||||
regular_hours: regularHours,
|
||||
ot_hours: otHours,
|
||||
dt_hours: dtHours,
|
||||
regular_rate: regularRate,
|
||||
ot_rate: otRate,
|
||||
dt_rate: dtRate,
|
||||
regular_value: regularValue,
|
||||
ot_value: otValue,
|
||||
dt_value: dtValue,
|
||||
rate: baseRate,
|
||||
total: total
|
||||
};
|
||||
|
||||
roleGroups[role.role].staff_entries.push(entry);
|
||||
roleGroups[role.role].role_subtotal += total;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const roles = Object.values(roleGroups);
|
||||
const subtotal = roles.reduce((sum, role) => sum + role.role_subtotal, 0);
|
||||
const otherCharges = parseFloat(data.other_charges) || 0;
|
||||
const total = subtotal + otherCharges;
|
||||
|
||||
const invoiceNumber = `INV-${Math.floor(Math.random() * 10000)}`;
|
||||
|
||||
const vendorInfo = {
|
||||
name: selectedEvent.vendor_name || "Legendary",
|
||||
address: "848 E Gish Rd Ste 1, San Jose, CA 95112",
|
||||
email: "orders@legendaryeventstaff.com",
|
||||
phone: "(408) 936-0180"
|
||||
};
|
||||
|
||||
const clientInfo = {
|
||||
name: selectedEvent.business_name || "Client Company",
|
||||
address: selectedEvent.event_location || "Address",
|
||||
email: selectedEvent.client_email || "",
|
||||
manager: selectedEvent.client_name || selectedEvent.manager_name || "Manager",
|
||||
phone: selectedEvent.client_phone || "",
|
||||
vendor_id: "Vendor #"
|
||||
};
|
||||
|
||||
return base44.entities.Invoice.create({
|
||||
invoice_number: invoiceNumber,
|
||||
event_id: selectedEvent.id,
|
||||
event_name: selectedEvent.event_name,
|
||||
event_date: selectedEvent.date,
|
||||
po_reference: data.po_reference || selectedEvent.po_reference,
|
||||
from_company: vendorInfo,
|
||||
to_company: clientInfo,
|
||||
business_name: selectedEvent.business_name,
|
||||
manager_name: selectedEvent.client_name || selectedEvent.business_name,
|
||||
vendor_name: selectedEvent.vendor_name,
|
||||
vendor_id: selectedEvent.vendor_id,
|
||||
hub: selectedEvent.hub,
|
||||
cost_center: data.po_reference || selectedEvent.po_reference,
|
||||
roles: roles,
|
||||
subtotal: subtotal,
|
||||
other_charges: otherCharges,
|
||||
amount: total,
|
||||
status: "Draft",
|
||||
issue_date: format(new Date(), 'yyyy-MM-dd'),
|
||||
due_date: format(addDays(new Date(), 30), 'yyyy-MM-dd'),
|
||||
is_auto_generated: false,
|
||||
notes: data.notes,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['invoices'] });
|
||||
toast({
|
||||
title: "✅ Invoice Created",
|
||||
description: "Invoice has been created successfully",
|
||||
});
|
||||
onClose();
|
||||
setFormData({ event_id: "", po_reference: "", other_charges: 0, notes: "" });
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
if (!formData.event_id) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Please select an event",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
createMutation.mutate(formData);
|
||||
};
|
||||
|
||||
const completedEvents = events.filter(e => e.status === "Completed");
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Invoice</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-6 text-center">
|
||||
<div className="w-16 h-16 bg-blue-100 rounded-full mx-auto mb-4 flex items-center justify-center">
|
||||
<FileEdit className="w-8 h-8 text-blue-600" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">Ready to create an invoice?</h3>
|
||||
<p className="text-slate-600 mb-6">Use the advanced editor to create a detailed invoice with full control.</p>
|
||||
|
||||
<Button
|
||||
onClick={handleAdvancedEditor}
|
||||
className="w-full bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white font-semibold h-12"
|
||||
>
|
||||
<FileEdit className="w-5 h-5 mr-2" />
|
||||
Open Invoice Editor
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
444
src/components/invoices/InvoiceDetailModal.jsx
Normal file
444
src/components/invoices/InvoiceDetailModal.jsx
Normal file
@@ -0,0 +1,444 @@
|
||||
import React, { useState } from "react";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
FileText, Download, Mail, Printer, CheckCircle,
|
||||
XCircle, AlertTriangle, DollarSign, Calendar, Building2,
|
||||
User, CreditCard, Edit3, Flag, CheckCheck
|
||||
} from "lucide-react";
|
||||
import { format, parseISO } from "date-fns";
|
||||
|
||||
const statusColors = {
|
||||
'Draft': 'bg-slate-500',
|
||||
'Pending Review': 'bg-amber-500',
|
||||
'Approved': 'bg-green-500',
|
||||
'Disputed': 'bg-red-500',
|
||||
'Under Review': 'bg-orange-500',
|
||||
'Resolved': 'bg-blue-500',
|
||||
'Overdue': 'bg-red-600',
|
||||
'Paid': 'bg-emerald-500',
|
||||
'Reconciled': 'bg-purple-500',
|
||||
'Cancelled': 'bg-slate-400',
|
||||
};
|
||||
|
||||
export default function InvoiceDetailModal({ open, onClose, invoice, userRole }) {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [disputeMode, setDisputeMode] = useState(false);
|
||||
const [disputeReason, setDisputeReason] = useState("");
|
||||
const [disputeDetails, setDisputeDetails] = useState("");
|
||||
const [paymentMethod, setPaymentMethod] = useState("");
|
||||
const [paymentRef, setPaymentRef] = useState("");
|
||||
const [selectedItems, setSelectedItems] = useState([]);
|
||||
|
||||
const updateInvoiceMutation = useMutation({
|
||||
mutationFn: ({ id, data }) => base44.entities.Invoice.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['invoices'] });
|
||||
toast({
|
||||
title: "✅ Invoice Updated",
|
||||
description: "Invoice has been updated successfully",
|
||||
});
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
const handleApprove = async () => {
|
||||
const user = await base44.auth.me();
|
||||
updateInvoiceMutation.mutate({
|
||||
id: invoice.id,
|
||||
data: {
|
||||
status: "Approved",
|
||||
approved_by: user.email,
|
||||
approved_date: new Date().toISOString(),
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleDispute = async () => {
|
||||
const user = await base44.auth.me();
|
||||
updateInvoiceMutation.mutate({
|
||||
id: invoice.id,
|
||||
data: {
|
||||
status: "Disputed",
|
||||
dispute_reason: disputeReason,
|
||||
dispute_details: disputeDetails,
|
||||
disputed_items: selectedItems,
|
||||
disputed_by: user.email,
|
||||
disputed_date: new Date().toISOString(),
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handlePay = async () => {
|
||||
updateInvoiceMutation.mutate({
|
||||
id: invoice.id,
|
||||
data: {
|
||||
status: "Paid",
|
||||
paid_date: new Date().toISOString().split('T')[0],
|
||||
payment_method: paymentMethod,
|
||||
payment_reference: paymentRef,
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleDownloadPDF = () => {
|
||||
window.print();
|
||||
};
|
||||
|
||||
const handleEmailInvoice = async () => {
|
||||
const user = await base44.auth.me();
|
||||
await base44.integrations.Core.SendEmail({
|
||||
to: invoice.business_name || user.email,
|
||||
subject: `Invoice ${invoice.invoice_number}`,
|
||||
body: `Please find attached invoice ${invoice.invoice_number} for ${invoice.event_name}. Amount: $${invoice.amount}. Due: ${invoice.due_date}`,
|
||||
});
|
||||
toast({
|
||||
title: "✅ Email Sent",
|
||||
description: "Invoice has been emailed successfully",
|
||||
});
|
||||
};
|
||||
|
||||
const toggleItemSelection = (index) => {
|
||||
setSelectedItems(prev =>
|
||||
prev.includes(index) ? prev.filter(i => i !== index) : [...prev, index]
|
||||
);
|
||||
};
|
||||
|
||||
if (!invoice) return null;
|
||||
|
||||
const isClient = userRole === "client";
|
||||
const isVendor = userRole === "vendor";
|
||||
const canApprove = isClient && invoice.status === "Pending Review";
|
||||
const canDispute = isClient && ["Pending Review", "Approved"].includes(invoice.status);
|
||||
const canPay = isClient && ["Approved", "Overdue"].includes(invoice.status);
|
||||
const canEdit = isVendor && ["Draft", "Disputed"].includes(invoice.status);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-4xl max-h-[90dvh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<DialogTitle className="text-2xl font-bold">{invoice.invoice_number}</DialogTitle>
|
||||
<p className="text-sm text-slate-500 mt-1">{invoice.event_name}</p>
|
||||
</div>
|
||||
<Badge className={`${statusColors[invoice.status]} text-white px-4 py-2`}>
|
||||
{invoice.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
{/* Header Information */}
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{/* From Section */}
|
||||
<div className="bg-blue-50 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-8 h-8 bg-[#0A39DF] rounded-full flex items-center justify-center">
|
||||
<Building2 className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<h3 className="font-bold text-slate-900">From:</h3>
|
||||
</div>
|
||||
<div className="space-y-1 text-sm">
|
||||
<p className="font-semibold">{invoice.from_company?.name || invoice.vendor_name}</p>
|
||||
<p className="text-slate-600">{invoice.from_company?.address}</p>
|
||||
<p className="text-slate-600">{invoice.from_company?.email}</p>
|
||||
<p className="text-slate-600">{invoice.from_company?.phone}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* To Section */}
|
||||
<div className="bg-green-50 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-8 h-8 bg-green-600 rounded-full flex items-center justify-center">
|
||||
<User className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<h3 className="font-bold text-slate-900">To:</h3>
|
||||
</div>
|
||||
<div className="space-y-1 text-sm">
|
||||
<p className="font-semibold">{invoice.to_company?.name || invoice.business_name}</p>
|
||||
<p className="text-slate-600">{invoice.to_company?.address}</p>
|
||||
<p className="text-slate-600">{invoice.to_company?.email}</p>
|
||||
<p className="font-semibold text-slate-900 mt-2">{invoice.to_company?.manager || invoice.manager_name}</p>
|
||||
<p className="text-slate-600">{invoice.to_company?.phone}</p>
|
||||
<p className="text-slate-600">{invoice.to_company?.vendor_id}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Event Details */}
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-slate-500">Event Date:</span>
|
||||
<span className="ml-2 font-semibold">{invoice.event_date ? format(parseISO(invoice.event_date), 'MMM dd, yyyy') : '—'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500">PO #:</span>
|
||||
<span className="ml-2 font-semibold">{invoice.po_reference || '—'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500">Due Date:</span>
|
||||
<span className="ml-2 font-semibold text-red-600">{format(parseISO(invoice.due_date), 'MMM dd, yyyy')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Roles and Staff Charges */}
|
||||
<div className="space-y-6">
|
||||
<h3 className="font-semibold text-lg">Staff Charges</h3>
|
||||
|
||||
{invoice.roles?.map((roleGroup, roleIdx) => (
|
||||
<div key={roleIdx} className="border border-slate-200 rounded-lg overflow-hidden">
|
||||
<div className="bg-slate-100 px-4 py-2 border-b border-slate-200">
|
||||
<h4 className="font-bold text-slate-900">Role: {roleGroup.role_name}</h4>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
{disputeMode && <th className="p-2 text-left font-semibold">Flag</th>}
|
||||
<th className="p-2 text-left font-semibold">Name</th>
|
||||
<th className="p-2 text-left font-semibold">Check-In</th>
|
||||
<th className="p-2 text-left font-semibold">Check-Out</th>
|
||||
<th className="p-2 text-right font-semibold">Worked</th>
|
||||
<th className="p-2 text-right font-semibold">Reg Hrs</th>
|
||||
<th className="p-2 text-right font-semibold">OT Hrs</th>
|
||||
<th className="p-2 text-right font-semibold">DT Hrs</th>
|
||||
<th className="p-2 text-right font-semibold">Rate</th>
|
||||
<th className="p-2 text-right font-semibold">Reg Value</th>
|
||||
<th className="p-2 text-right font-semibold">OT Value</th>
|
||||
<th className="p-2 text-right font-semibold">DT Value</th>
|
||||
<th className="p-2 text-right font-semibold">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{roleGroup.staff_entries?.map((entry, entryIdx) => (
|
||||
<tr key={entryIdx} className={`border-t border-slate-200 ${selectedItems.some(item => item.role_index === roleIdx && item.staff_index === entryIdx) ? 'bg-red-50' : ''}`}>
|
||||
{disputeMode && (
|
||||
<td className="p-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedItems.some(item => item.role_index === roleIdx && item.staff_index === entryIdx)}
|
||||
onChange={() => {
|
||||
const itemId = { role_index: roleIdx, staff_index: entryIdx };
|
||||
setSelectedItems(prev =>
|
||||
prev.some(item => item.role_index === roleIdx && item.staff_index === entryIdx)
|
||||
? prev.filter(item => !(item.role_index === roleIdx && item.staff_index === entryIdx))
|
||||
: [...prev, itemId]
|
||||
);
|
||||
}}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
<td className="p-2">{entry.staff_name}</td>
|
||||
<td className="p-2">{entry.check_in}</td>
|
||||
<td className="p-2">{entry.check_out}</td>
|
||||
<td className="p-2 text-right">{entry.worked_hours?.toFixed(2)}</td>
|
||||
<td className="p-2 text-right">{entry.regular_hours?.toFixed(2)}</td>
|
||||
<td className="p-2 text-right">{entry.ot_hours?.toFixed(2)}</td>
|
||||
<td className="p-2 text-right">{entry.dt_hours?.toFixed(2)}</td>
|
||||
<td className="p-2 text-right">${entry.rate?.toFixed(2)}</td>
|
||||
<td className="p-2 text-right">${entry.regular_value?.toFixed(2)}</td>
|
||||
<td className="p-2 text-right">${entry.ot_value?.toFixed(2)}</td>
|
||||
<td className="p-2 text-right">${entry.dt_value?.toFixed(2)}</td>
|
||||
<td className="p-2 text-right font-bold">${entry.total?.toFixed(2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="bg-slate-50 px-4 py-2 border-t border-slate-200 flex justify-end">
|
||||
<span className="font-bold">Role Total: ${roleGroup.role_subtotal?.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Totals */}
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-slate-600">Sub-total:</span>
|
||||
<span className="font-semibold">${invoice.subtotal?.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-slate-600">Other charges:</span>
|
||||
<span className="font-semibold">${(invoice.other_charges || 0)?.toFixed(2)}</span>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex justify-between text-lg">
|
||||
<span className="font-bold">Grand total:</span>
|
||||
<span className="font-bold text-[#0A39DF]">${invoice.amount?.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dispute Section */}
|
||||
{disputeMode && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="w-5 h-5 text-red-600" />
|
||||
<h3 className="font-semibold text-red-900">Dispute Invoice</h3>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Reason for Dispute</Label>
|
||||
<Select value={disputeReason} onValueChange={setDisputeReason}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select reason" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Incorrect Hours">Incorrect Hours</SelectItem>
|
||||
<SelectItem value="Incorrect Rate">Incorrect Rate</SelectItem>
|
||||
<SelectItem value="Unauthorized Staff">Unauthorized Staff</SelectItem>
|
||||
<SelectItem value="Service Not Rendered">Service Not Rendered</SelectItem>
|
||||
<SelectItem value="Other">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Details</Label>
|
||||
<Textarea
|
||||
value={disputeDetails}
|
||||
onChange={(e) => setDisputeDetails(e.target.value)}
|
||||
placeholder="Provide detailed information about the dispute..."
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Payment Section */}
|
||||
{invoice.status === "Approved" && isClient && (
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<CreditCard className="w-5 h-5 text-green-600" />
|
||||
<h3 className="font-semibold text-green-900">Record Payment</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>Payment Method</Label>
|
||||
<Select value={paymentMethod} onValueChange={setPaymentMethod}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select method" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Credit Card">Credit Card</SelectItem>
|
||||
<SelectItem value="ACH">ACH Transfer</SelectItem>
|
||||
<SelectItem value="Wire Transfer">Wire Transfer</SelectItem>
|
||||
<SelectItem value="Check">Check</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Reference Number</Label>
|
||||
<input
|
||||
type="text"
|
||||
value={paymentRef}
|
||||
onChange={(e) => setPaymentRef(e.target.value)}
|
||||
placeholder="Transaction ID"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-md"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dispute Info */}
|
||||
{invoice.status === "Disputed" && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
|
||||
<h3 className="font-semibold text-amber-900 mb-2">Dispute Information</h3>
|
||||
<p className="text-sm text-slate-700"><strong>Reason:</strong> {invoice.dispute_reason}</p>
|
||||
<p className="text-sm text-slate-700 mt-1"><strong>Details:</strong> {invoice.dispute_details}</p>
|
||||
<p className="text-xs text-slate-500 mt-2">Disputed by {invoice.disputed_by} on {format(parseISO(invoice.disputed_date), 'MMM dd, yyyy')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
{invoice.notes && (
|
||||
<div>
|
||||
<Label className="text-sm font-semibold">Notes</Label>
|
||||
<p className="text-sm text-slate-600 mt-1">{invoice.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex flex-wrap gap-2">
|
||||
<div className="flex gap-2 flex-wrap w-full justify-between">
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleDownloadPDF}>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Download
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleEmailInvoice}>
|
||||
<Mail className="w-4 h-4 mr-2" />
|
||||
Email
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => window.print()}>
|
||||
<Printer className="w-4 h-4 mr-2" />
|
||||
Print
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{canApprove && (
|
||||
<Button onClick={handleApprove} className="bg-green-600 hover:bg-green-700">
|
||||
<CheckCheck className="w-4 h-4 mr-2" />
|
||||
Approve
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canDispute && !disputeMode && (
|
||||
<Button onClick={() => setDisputeMode(true)} variant="destructive">
|
||||
<Flag className="w-4 h-4 mr-2" />
|
||||
Dispute
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{disputeMode && (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setDisputeMode(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleDispute} variant="destructive" disabled={!disputeReason}>
|
||||
Submit Dispute
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{canPay && (
|
||||
<Button
|
||||
onClick={handlePay}
|
||||
disabled={!paymentMethod}
|
||||
className="bg-[#0A39DF] hover:bg-[#0A39DF]/90"
|
||||
>
|
||||
<DollarSign className="w-4 h-4 mr-2" />
|
||||
Mark as Paid
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canEdit && (
|
||||
<Button variant="outline">
|
||||
<Edit3 className="w-4 h-4 mr-2" />
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
1423
src/components/invoices/InvoiceDetailView.jsx
Normal file
1423
src/components/invoices/InvoiceDetailView.jsx
Normal file
File diff suppressed because it is too large
Load Diff
316
src/components/invoices/InvoiceExportPanel.jsx
Normal file
316
src/components/invoices/InvoiceExportPanel.jsx
Normal file
@@ -0,0 +1,316 @@
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Download, FileText, FileSpreadsheet, Code, Send, Check, Loader2, Building2, Link2 } from "lucide-react";
|
||||
|
||||
const ERP_SYSTEMS = {
|
||||
"SAP Ariba": { format: "cXML", color: "bg-blue-100 text-blue-700" },
|
||||
"Fieldglass": { format: "CSV", color: "bg-purple-100 text-purple-700" },
|
||||
"CrunchTime": { format: "JSON", color: "bg-orange-100 text-orange-700" },
|
||||
"Coupa": { format: "cXML", color: "bg-teal-100 text-teal-700" },
|
||||
"Oracle NetSuite": { format: "CSV", color: "bg-red-100 text-red-700" },
|
||||
"Workday": { format: "JSON", color: "bg-green-100 text-green-700" },
|
||||
};
|
||||
|
||||
export default function InvoiceExportPanel({ invoice, business, onExport }) {
|
||||
const [exportFormat, setExportFormat] = useState(business?.edi_format || "CSV");
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [exportSuccess, setExportSuccess] = useState(false);
|
||||
|
||||
const erpSystem = business?.erp_system || "None";
|
||||
const erpInfo = ERP_SYSTEMS[erpSystem];
|
||||
|
||||
const generateEDI810 = () => {
|
||||
// EDI 810 Invoice format
|
||||
const segments = [
|
||||
`ISA*00* *00* *ZZ*KROW *ZZ*${business?.erp_vendor_id || 'CLIENT'} *${new Date().toISOString().slice(2,10).replace(/-/g,'')}*${new Date().toTimeString().slice(0,5).replace(':','')}*U*00401*000000001*0*P*>~`,
|
||||
`GS*IN*KROW*${business?.business_name?.substring(0,15) || 'CLIENT'}*${new Date().toISOString().slice(0,10).replace(/-/g,'')}*${new Date().toTimeString().slice(0,4).replace(':','')}*1*X*004010~`,
|
||||
`ST*810*0001~`,
|
||||
`BIG*${invoice.issue_date?.replace(/-/g,'')}*${invoice.invoice_number}*${invoice.event_date?.replace(/-/g,'')}*${invoice.po_reference || ''}~`,
|
||||
`N1*BT*${business?.business_name || invoice.business_name}~`,
|
||||
`N1*ST*${invoice.hub || business?.hub_building || ''}~`,
|
||||
`ITD*01*3*****${invoice.due_date?.replace(/-/g,'')}~`,
|
||||
`TDS*${Math.round((invoice.amount || 0) * 100)}~`,
|
||||
`SE*8*0001~`,
|
||||
`GE*1*1~`,
|
||||
`IEA*1*000000001~`
|
||||
];
|
||||
return segments.join('\n');
|
||||
};
|
||||
|
||||
const generateCXML = () => {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE cXML SYSTEM "http://xml.cxml.org/schemas/cXML/1.2.014/InvoiceDetail.dtd">
|
||||
<cXML payloadID="${invoice.invoice_number}@krow.com" timestamp="${new Date().toISOString()}">
|
||||
<Header>
|
||||
<From><Credential domain="DUNS"><Identity>KROW</Identity></Credential></From>
|
||||
<To><Credential domain="DUNS"><Identity>${business?.erp_vendor_id || 'CLIENT'}</Identity></Credential></To>
|
||||
<Sender><Credential domain="DUNS"><Identity>KROW</Identity></Credential></Sender>
|
||||
</Header>
|
||||
<Request>
|
||||
<InvoiceDetailRequest>
|
||||
<InvoiceDetailRequestHeader invoiceID="${invoice.invoice_number}" invoiceDate="${invoice.issue_date}" purpose="standard">
|
||||
<InvoiceDetailHeaderIndicator/>
|
||||
<InvoicePartner><Contact role="billTo"><Name>${invoice.business_name}</Name></Contact></InvoicePartner>
|
||||
<PaymentTerm payInNumberOfDays="30"/>
|
||||
</InvoiceDetailRequestHeader>
|
||||
<InvoiceDetailOrder>
|
||||
<InvoiceDetailOrderInfo><OrderReference orderID="${invoice.po_reference || ''}"/></InvoiceDetailOrderInfo>
|
||||
<InvoiceDetailItem invoiceLineNumber="1" quantity="1">
|
||||
<UnitOfMeasure>EA</UnitOfMeasure>
|
||||
<UnitPrice><Money currency="USD">${invoice.amount}</Money></UnitPrice>
|
||||
<InvoiceDetailItemReference lineNumber="1"><Description>${invoice.event_name}</Description></InvoiceDetailItemReference>
|
||||
</InvoiceDetailItem>
|
||||
</InvoiceDetailOrder>
|
||||
<InvoiceDetailSummary>
|
||||
<SubtotalAmount><Money currency="USD">${invoice.subtotal || invoice.amount}</Money></SubtotalAmount>
|
||||
<Tax><Money currency="USD">${invoice.tax_amount || 0}</Money></Tax>
|
||||
<GrossAmount><Money currency="USD">${invoice.amount}</Money></GrossAmount>
|
||||
<InvoiceDetailDiscount/>
|
||||
<NetAmount><Money currency="USD">${invoice.amount}</Money></NetAmount>
|
||||
<DueAmount><Money currency="USD">${invoice.amount}</Money></DueAmount>
|
||||
</InvoiceDetailSummary>
|
||||
</InvoiceDetailRequest>
|
||||
</Request>
|
||||
</cXML>`;
|
||||
};
|
||||
|
||||
const generateCSV = () => {
|
||||
const headers = [
|
||||
"Invoice Number", "Invoice Date", "Due Date", "PO Number", "Vendor ID",
|
||||
"Client Name", "Hub", "Event Name", "Cost Center", "Subtotal", "Tax", "Total Amount", "Status"
|
||||
];
|
||||
const row = [
|
||||
invoice.invoice_number,
|
||||
invoice.issue_date,
|
||||
invoice.due_date,
|
||||
invoice.po_reference || "",
|
||||
business?.erp_vendor_id || "",
|
||||
invoice.business_name,
|
||||
invoice.hub || "",
|
||||
invoice.event_name,
|
||||
business?.erp_cost_center || "",
|
||||
invoice.subtotal || invoice.amount,
|
||||
invoice.tax_amount || 0,
|
||||
invoice.amount,
|
||||
invoice.status
|
||||
];
|
||||
|
||||
// Add line items if available
|
||||
let lineItems = "\n\nLine Item Details\nRole,Staff Name,Date,Hours,Rate,Amount\n";
|
||||
if (invoice.roles) {
|
||||
invoice.roles.forEach(role => {
|
||||
role.staff_entries?.forEach(entry => {
|
||||
lineItems += `${role.role_name},${entry.staff_name},${entry.date},${entry.worked_hours},${entry.rate},${entry.total}\n`;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return headers.join(",") + "\n" + row.join(",") + lineItems;
|
||||
};
|
||||
|
||||
const generateJSON = () => {
|
||||
return JSON.stringify({
|
||||
invoice: {
|
||||
invoice_number: invoice.invoice_number,
|
||||
issue_date: invoice.issue_date,
|
||||
due_date: invoice.due_date,
|
||||
po_reference: invoice.po_reference,
|
||||
vendor: {
|
||||
id: business?.erp_vendor_id,
|
||||
name: "KROW Workforce"
|
||||
},
|
||||
client: {
|
||||
name: invoice.business_name,
|
||||
hub: invoice.hub,
|
||||
cost_center: business?.erp_cost_center
|
||||
},
|
||||
event: {
|
||||
name: invoice.event_name,
|
||||
date: invoice.event_date
|
||||
},
|
||||
amounts: {
|
||||
subtotal: invoice.subtotal || invoice.amount,
|
||||
tax: invoice.tax_amount || 0,
|
||||
total: invoice.amount
|
||||
},
|
||||
line_items: invoice.roles?.flatMap(role =>
|
||||
role.staff_entries?.map(entry => ({
|
||||
role: role.role_name,
|
||||
staff_name: entry.staff_name,
|
||||
date: entry.date,
|
||||
hours: entry.worked_hours,
|
||||
rate: entry.rate,
|
||||
amount: entry.total
|
||||
})) || []
|
||||
) || [],
|
||||
status: invoice.status
|
||||
}
|
||||
}, null, 2);
|
||||
};
|
||||
|
||||
const handleExport = async (format) => {
|
||||
setIsExporting(true);
|
||||
|
||||
let content, filename, mimeType;
|
||||
|
||||
switch (format) {
|
||||
case "EDI 810":
|
||||
content = generateEDI810();
|
||||
filename = `${invoice.invoice_number}_EDI810.edi`;
|
||||
mimeType = "text/plain";
|
||||
break;
|
||||
case "cXML":
|
||||
content = generateCXML();
|
||||
filename = `${invoice.invoice_number}.xml`;
|
||||
mimeType = "application/xml";
|
||||
break;
|
||||
case "JSON":
|
||||
content = generateJSON();
|
||||
filename = `${invoice.invoice_number}.json`;
|
||||
mimeType = "application/json";
|
||||
break;
|
||||
case "CSV":
|
||||
default:
|
||||
content = generateCSV();
|
||||
filename = `${invoice.invoice_number}.csv`;
|
||||
mimeType = "text/csv";
|
||||
break;
|
||||
}
|
||||
|
||||
// Create and download file
|
||||
const blob = new Blob([content], { type: mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
setIsExporting(false);
|
||||
setExportSuccess(true);
|
||||
setTimeout(() => setExportSuccess(false), 3000);
|
||||
|
||||
if (onExport) onExport(format);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="border-slate-200">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Link2 className="w-5 h-5 text-blue-600" />
|
||||
ERP / EDI Export
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* ERP System Info */}
|
||||
{erpSystem !== "None" && (
|
||||
<div className="flex items-center justify-between p-3 bg-slate-50 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<Building2 className="w-5 h-5 text-slate-500" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-900">Connected ERP</p>
|
||||
<p className="text-xs text-slate-500">Vendor ID: {business?.erp_vendor_id || "Not configured"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge className={erpInfo?.color || "bg-slate-100 text-slate-700"}>
|
||||
{erpSystem}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Export Format Selection */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-700">Export Format</label>
|
||||
<Select value={exportFormat} onValueChange={setExportFormat}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="CSV">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileSpreadsheet className="w-4 h-4" />
|
||||
CSV (Excel Compatible)
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="EDI 810">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="w-4 h-4" />
|
||||
EDI 810 (Standard Invoice)
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="cXML">
|
||||
<div className="flex items-center gap-2">
|
||||
<Code className="w-4 h-4" />
|
||||
cXML (Ariba/Coupa)
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="JSON">
|
||||
<div className="flex items-center gap-2">
|
||||
<Code className="w-4 h-4" />
|
||||
JSON (API Format)
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Export Buttons */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => handleExport(exportFormat)}
|
||||
disabled={isExporting}
|
||||
className="flex-1 bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
{isExporting ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : exportSuccess ? (
|
||||
<Check className="w-4 h-4 mr-2" />
|
||||
) : (
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{exportSuccess ? "Exported!" : "Download"}
|
||||
</Button>
|
||||
|
||||
{business?.invoice_email && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {/* Send via email logic */}}
|
||||
className="border-slate-300"
|
||||
>
|
||||
<Send className="w-4 h-4 mr-2" />
|
||||
Send
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick Export Buttons */}
|
||||
<div className="pt-2 border-t border-slate-200">
|
||||
<p className="text-xs text-slate-500 mb-2">Quick Export</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => handleExport("CSV")} className="text-xs">
|
||||
<FileSpreadsheet className="w-3 h-3 mr-1" />
|
||||
CSV
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => handleExport("EDI 810")} className="text-xs">
|
||||
<FileText className="w-3 h-3 mr-1" />
|
||||
EDI
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => handleExport("cXML")} className="text-xs">
|
||||
<Code className="w-3 h-3 mr-1" />
|
||||
cXML
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => handleExport("JSON")} className="text-xs">
|
||||
<Code className="w-3 h-3 mr-1" />
|
||||
JSON
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
342
src/components/invoices/InvoiceQuickActions.jsx
Normal file
342
src/components/invoices/InvoiceQuickActions.jsx
Normal file
@@ -0,0 +1,342 @@
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Calendar, Copy, FileText, Search, Clock, Users, MapPin, Zap, Save, Star, Trash2 } from "lucide-react";
|
||||
import { format, parseISO } from "date-fns";
|
||||
|
||||
export default function InvoiceQuickActions({
|
||||
events = [],
|
||||
invoices = [],
|
||||
templates = [],
|
||||
onImportFromEvent,
|
||||
onDuplicateInvoice,
|
||||
onUseTemplate,
|
||||
onSaveTemplate,
|
||||
onDeleteTemplate
|
||||
}) {
|
||||
const [eventDialogOpen, setEventDialogOpen] = useState(false);
|
||||
const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
|
||||
const [templateDialogOpen, setTemplateDialogOpen] = useState(false);
|
||||
const [saveTemplateDialogOpen, setSaveTemplateDialogOpen] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [templateName, setTemplateName] = useState("");
|
||||
|
||||
// Filter completed events that can be invoiced
|
||||
const completedEvents = events.filter(e =>
|
||||
e.status === "Completed" || e.status === "Active" || e.status === "Confirmed"
|
||||
);
|
||||
|
||||
// Filter events by search
|
||||
const filteredEvents = completedEvents.filter(e =>
|
||||
e.event_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
e.business_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
e.hub?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
// Filter invoices for duplication
|
||||
const filteredInvoices = invoices.filter(inv =>
|
||||
inv.invoice_number?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
inv.business_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
inv.event_name?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
// Filter templates
|
||||
const filteredTemplates = templates.filter(t =>
|
||||
t.name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
t.client_name?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const handleImportEvent = (event) => {
|
||||
onImportFromEvent(event);
|
||||
setEventDialogOpen(false);
|
||||
setSearchTerm("");
|
||||
};
|
||||
|
||||
const handleDuplicate = (invoice) => {
|
||||
onDuplicateInvoice(invoice);
|
||||
setDuplicateDialogOpen(false);
|
||||
setSearchTerm("");
|
||||
};
|
||||
|
||||
const handleUseTemplate = (template) => {
|
||||
onUseTemplate(template);
|
||||
setTemplateDialogOpen(false);
|
||||
setSearchTerm("");
|
||||
};
|
||||
|
||||
const handleSaveTemplate = () => {
|
||||
if (templateName.trim()) {
|
||||
onSaveTemplate(templateName.trim());
|
||||
setSaveTemplateDialogOpen(false);
|
||||
setTemplateName("");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-6 p-4 bg-gradient-to-r from-amber-50 to-orange-100 rounded-xl border-2 border-amber-200">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-10 h-10 bg-amber-500 rounded-lg flex items-center justify-center">
|
||||
<Zap className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-amber-900">Quick Actions</h3>
|
||||
<p className="text-xs text-amber-700">Save time with these shortcuts</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{/* Import from Event */}
|
||||
<Dialog open={eventDialogOpen} onOpenChange={setEventDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="bg-white border-amber-300 hover:bg-amber-50 text-amber-800">
|
||||
<Calendar className="w-4 h-4 mr-2" />
|
||||
Import from Event
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl max-h-[80dvh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Calendar className="w-5 h-5 text-blue-600" />
|
||||
Import Staff Data from Event
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="relative mb-4">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<Input
|
||||
placeholder="Search events by name, client, or hub..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="overflow-y-auto flex-1 space-y-2 pr-2">
|
||||
{filteredEvents.length === 0 ? (
|
||||
<div className="text-center py-8 text-slate-500">
|
||||
<Calendar className="w-12 h-12 mx-auto mb-2 text-slate-300" />
|
||||
<p>No completed events found</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredEvents.map(event => (
|
||||
<button
|
||||
key={event.id}
|
||||
onClick={() => handleImportEvent(event)}
|
||||
className="w-full p-4 bg-white border border-slate-200 rounded-lg hover:border-blue-400 hover:bg-blue-50 transition-all text-left"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold text-slate-900">{event.event_name}</h4>
|
||||
<div className="flex items-center gap-3 mt-1 text-sm text-slate-600">
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="w-3 h-3" />
|
||||
{event.assigned_staff?.length || 0} staff
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<MapPin className="w-3 h-3" />
|
||||
{event.hub || event.event_location || "—"}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{event.date ? format(parseISO(event.date), 'MMM d, yyyy') : "—"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 mt-1">{event.business_name}</p>
|
||||
</div>
|
||||
<Badge className={`${event.status === 'Completed' ? 'bg-emerald-100 text-emerald-700' : 'bg-blue-100 text-blue-700'}`}>
|
||||
{event.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Duplicate Invoice */}
|
||||
<Dialog open={duplicateDialogOpen} onOpenChange={setDuplicateDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="bg-white border-amber-300 hover:bg-amber-50 text-amber-800">
|
||||
<Copy className="w-4 h-4 mr-2" />
|
||||
Duplicate Invoice
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl max-h-[80dvh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Copy className="w-5 h-5 text-purple-600" />
|
||||
Duplicate an Existing Invoice
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="relative mb-4">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<Input
|
||||
placeholder="Search by invoice #, client, or event..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="overflow-y-auto flex-1 space-y-2 pr-2">
|
||||
{filteredInvoices.length === 0 ? (
|
||||
<div className="text-center py-8 text-slate-500">
|
||||
<FileText className="w-12 h-12 mx-auto mb-2 text-slate-300" />
|
||||
<p>No invoices found</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredInvoices.slice(0, 20).map(invoice => (
|
||||
<button
|
||||
key={invoice.id}
|
||||
onClick={() => handleDuplicate(invoice)}
|
||||
className="w-full p-4 bg-white border border-slate-200 rounded-lg hover:border-purple-400 hover:bg-purple-50 transition-all text-left"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="font-bold text-slate-900">{invoice.invoice_number}</h4>
|
||||
<Badge variant="outline" className="text-xs">${invoice.amount?.toLocaleString() || 0}</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-slate-700 mt-1">{invoice.business_name}</p>
|
||||
<div className="flex items-center gap-3 mt-1 text-xs text-slate-500">
|
||||
<span>{invoice.event_name || "No event"}</span>
|
||||
<span>•</span>
|
||||
<span>{invoice.roles?.[0]?.staff_entries?.length || 0} staff entries</span>
|
||||
<span>•</span>
|
||||
<span>{invoice.issue_date ? format(parseISO(invoice.issue_date), 'MMM d, yyyy') : "—"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Badge className={`${invoice.status === 'Paid' ? 'bg-emerald-100 text-emerald-700' : 'bg-slate-100 text-slate-600'}`}>
|
||||
{invoice.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Use Template */}
|
||||
<Dialog open={templateDialogOpen} onOpenChange={setTemplateDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="bg-white border-amber-300 hover:bg-amber-50 text-amber-800">
|
||||
<FileText className="w-4 h-4 mr-2" />
|
||||
Use Template
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl max-h-[80dvh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<FileText className="w-5 h-5 text-emerald-600" />
|
||||
Invoice Templates
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="relative mb-4">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<Input
|
||||
placeholder="Search templates..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="overflow-y-auto flex-1 space-y-2 pr-2">
|
||||
{filteredTemplates.length === 0 ? (
|
||||
<div className="text-center py-8 text-slate-500">
|
||||
<Star className="w-12 h-12 mx-auto mb-2 text-slate-300" />
|
||||
<p>No templates saved yet</p>
|
||||
<p className="text-xs mt-1">Create an invoice and save it as a template</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredTemplates.map(template => (
|
||||
<div
|
||||
key={template.id}
|
||||
className="w-full p-4 bg-white border border-slate-200 rounded-lg hover:border-emerald-400 hover:bg-emerald-50 transition-all"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<button
|
||||
onClick={() => handleUseTemplate(template)}
|
||||
className="flex-1 text-left"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Star className="w-4 h-4 text-amber-500 fill-amber-500" />
|
||||
<h4 className="font-semibold text-slate-900">{template.name}</h4>
|
||||
</div>
|
||||
<p className="text-sm text-slate-600 mt-1">{template.client_name}</p>
|
||||
<div className="flex items-center gap-3 mt-1 text-xs text-slate-500">
|
||||
<span>{template.staff_count || 0} staff entries</span>
|
||||
<span>•</span>
|
||||
<span>{template.charges_count || 0} charges</span>
|
||||
</div>
|
||||
</button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onDeleteTemplate(template.id)}
|
||||
className="text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Save as Template */}
|
||||
<Dialog open={saveTemplateDialogOpen} onOpenChange={setSaveTemplateDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="bg-white border-amber-300 hover:bg-amber-50 text-amber-800">
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
Save as Template
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Save className="w-5 h-5 text-blue-600" />
|
||||
Save Current Invoice as Template
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-700">Template Name</label>
|
||||
<Input
|
||||
placeholder="e.g., Google Weekly Catering, Standard Event Setup..."
|
||||
value={templateName}
|
||||
onChange={(e) => setTemplateName(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
This will save client info, positions, rates, and charges as a reusable template
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setSaveTemplateDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSaveTemplate}
|
||||
disabled={!templateName.trim()}
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
Save Template
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
531
src/components/krow-edge/ClientEventSetup.jsx
Normal file
531
src/components/krow-edge/ClientEventSetup.jsx
Normal file
@@ -0,0 +1,531 @@
|
||||
import React, { useState } from "react";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Calendar, MapPin, Users, DollarSign, Plus, Trash2, Wine,
|
||||
Utensils, Truck, Star, Target, Zap, ChevronRight, ChevronLeft,
|
||||
Building2, FileText, Clock, CheckCircle2, ShieldCheck, Brain
|
||||
} from "lucide-react";
|
||||
|
||||
const STEPS = ["Event Details", "Staffing Roles", "Logistics", "Bid Strategy", "Review & Post"];
|
||||
|
||||
const ROLE_OPTIONS = [
|
||||
"Server", "Bartender", "Busser", "Host/Hostess", "Captain",
|
||||
"Chef", "Line Cook", "Prep Cook", "Dishwasher", "Event Manager",
|
||||
"Security", "Setup Crew", "Cleanup Crew", "Barback", "Runner"
|
||||
];
|
||||
|
||||
const AWARD_MODES = {
|
||||
standard: { label: "Standard", desc: "You select the vendor manually.", color: "border-slate-200" },
|
||||
competitive: { label: "Competitive", desc: "Vendors submit prices, you compare.", color: "border-blue-200" },
|
||||
krow_smart: { label: "KROW Smart Award ✨", desc: "AI scores all bids — best execution wins, not just lowest price.", color: "border-[#0039A6] bg-[#0039A6]/5" },
|
||||
};
|
||||
|
||||
function StepIndicator({ step }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1 mb-6 overflow-x-auto pb-1">
|
||||
{STEPS.map((label, idx) => (
|
||||
<React.Fragment key={label}>
|
||||
<div className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold whitespace-nowrap flex-shrink-0 ${
|
||||
idx === step ? "bg-[#0039A6] text-white" :
|
||||
idx < step ? "bg-emerald-100 text-emerald-700" : "bg-slate-100 text-slate-400"
|
||||
}`}>
|
||||
{idx < step ? <CheckCircle2 className="w-3.5 h-3.5" /> : <span>{idx + 1}</span>}
|
||||
{label}
|
||||
</div>
|
||||
{idx < STEPS.length - 1 && <ChevronRight className="w-3.5 h-3.5 text-slate-300 flex-shrink-0" />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ClientEventSetup({ user, onCreated }) {
|
||||
const { toast } = useToast();
|
||||
const [step, setStep] = useState(0);
|
||||
|
||||
const [eventData, setEventData] = useState({
|
||||
event_name: "",
|
||||
date: "",
|
||||
hub: "",
|
||||
event_location: "",
|
||||
business_name: user?.company_name || "",
|
||||
client_name: user?.full_name || "",
|
||||
client_email: user?.email || "",
|
||||
contract_type: "W2",
|
||||
po_reference: "",
|
||||
notes: "",
|
||||
total: "",
|
||||
status: "Active",
|
||||
});
|
||||
|
||||
const [roles, setRoles] = useState([
|
||||
{ role: "Server", count: 2, start_time: "09:00", end_time: "17:00", uniform: "", cost_per_hour: "" }
|
||||
]);
|
||||
|
||||
const [logistics, setLogistics] = useState({
|
||||
bar_service: false,
|
||||
bar_notes: "",
|
||||
setup_crew: false,
|
||||
meal_provided: false,
|
||||
travel_time: false,
|
||||
tips_enabled: false,
|
||||
tips_amount: "",
|
||||
portal_access: false,
|
||||
event_goal: "",
|
||||
total_guests: "",
|
||||
layout_notes: "",
|
||||
special_requirements: "",
|
||||
});
|
||||
|
||||
const [bidConfig, setBidConfig] = useState({
|
||||
award_mode: "krow_smart",
|
||||
priority: "balanced",
|
||||
risk_tolerance: "low",
|
||||
transparency_mode: "blind",
|
||||
bid_deadline: "",
|
||||
min_vendors: 2,
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const totalRequested = roles.reduce((s, r) => s + (parseInt(r.count) || 0), 0);
|
||||
const event = await base44.entities.Event.create({
|
||||
...eventData,
|
||||
requested: totalRequested,
|
||||
total: parseFloat(eventData.total) || 0,
|
||||
shifts: [{
|
||||
shift_name: "Main Shift",
|
||||
location: eventData.hub || eventData.event_location,
|
||||
roles: roles.map(r => ({
|
||||
role: r.role,
|
||||
count: parseInt(r.count) || 1,
|
||||
start_time: r.start_time,
|
||||
end_time: r.end_time,
|
||||
uniform: r.uniform,
|
||||
cost_per_hour: parseFloat(r.cost_per_hour) || 0,
|
||||
}))
|
||||
}],
|
||||
addons: {
|
||||
meal_provided: logistics.meal_provided,
|
||||
travel_time: logistics.travel_time,
|
||||
portal_access: logistics.portal_access,
|
||||
tips: { enabled: logistics.tips_enabled, amount: logistics.tips_amount },
|
||||
goal: { enabled: !!logistics.event_goal, text: logistics.event_goal },
|
||||
},
|
||||
notes: [eventData.notes, logistics.layout_notes, logistics.special_requirements, logistics.bar_service ? `Bar Service: ${logistics.bar_notes}` : ""].filter(Boolean).join("\n\n"),
|
||||
});
|
||||
return event;
|
||||
},
|
||||
onSuccess: (event) => {
|
||||
toast({ title: "✅ Event Created!", description: `"${event.event_name}" is live and open for vendor bids.` });
|
||||
onCreated && onCreated(event, bidConfig);
|
||||
}
|
||||
});
|
||||
|
||||
const addRole = () => setRoles([...roles, { role: "Server", count: 1, start_time: "09:00", end_time: "17:00", uniform: "", cost_per_hour: "" }]);
|
||||
const removeRole = (idx) => setRoles(roles.filter((_, i) => i !== idx));
|
||||
const updateRole = (idx, field, val) => setRoles(roles.map((r, i) => i === idx ? { ...r, [field]: val } : r));
|
||||
|
||||
const totalStaff = roles.reduce((s, r) => s + (parseInt(r.count) || 0), 0);
|
||||
|
||||
const canNext = () => {
|
||||
if (step === 0) return eventData.event_name && eventData.date && (eventData.hub || eventData.event_location);
|
||||
if (step === 1) return roles.length > 0 && roles.every(r => r.role && r.count);
|
||||
return true;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<StepIndicator step={step} />
|
||||
|
||||
{/* ── STEP 0: Event Details ── */}
|
||||
{step === 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2"><Calendar className="w-4 h-4 text-[#0039A6]" /> Event Details</CardTitle>
|
||||
<p className="text-xs text-slate-500">Fill in the core event information. This is what vendors will see when bidding.</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-slate-700 block mb-1">Event Name *</label>
|
||||
<input value={eventData.event_name} onChange={e => setEventData({ ...eventData, event_name: e.target.value })}
|
||||
className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="e.g. Google All-Hands Lunch · Exec Dinner · Annual Gala" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-slate-700 block mb-1">Event Date *</label>
|
||||
<input type="date" value={eventData.date} onChange={e => setEventData({ ...eventData, date: e.target.value })}
|
||||
className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-slate-700 block mb-1">Contract Type</label>
|
||||
<select value={eventData.contract_type} onChange={e => setEventData({ ...eventData, contract_type: e.target.value })}
|
||||
className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
{["W2", "1099", "Temp", "Contract"].map(t => <option key={t}>{t}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-slate-700 block mb-1">Hub / Venue Name</label>
|
||||
<input value={eventData.hub} onChange={e => setEventData({ ...eventData, hub: e.target.value })}
|
||||
className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="e.g. BVG 300, Main Campus" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-slate-700 block mb-1">Location / Address *</label>
|
||||
<input value={eventData.event_location} onChange={e => setEventData({ ...eventData, event_location: e.target.value })}
|
||||
className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Full address" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-slate-700 block mb-1">Business / Client Name</label>
|
||||
<input value={eventData.business_name} onChange={e => setEventData({ ...eventData, business_name: e.target.value })}
|
||||
className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Your company name" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-slate-700 block mb-1">PO Reference</label>
|
||||
<input value={eventData.po_reference} onChange={e => setEventData({ ...eventData, po_reference: e.target.value })}
|
||||
className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Optional PO number" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-slate-700 block mb-1">Total Budget ($)</label>
|
||||
<div className="relative">
|
||||
<DollarSign className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<input type="number" value={eventData.total} onChange={e => setEventData({ ...eventData, total: e.target.value })}
|
||||
className="w-full border border-slate-300 rounded-lg pl-8 pr-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Total event budget — visible to KROW scoring" />
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 mt-1">This is used as a reference in bid scoring. Vendors won't see the exact number.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-slate-700 block mb-1">Additional Notes</label>
|
||||
<textarea rows={3} value={eventData.notes} onChange={e => setEventData({ ...eventData, notes: e.target.value })}
|
||||
className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
|
||||
placeholder="Any additional event context for vendors..." />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── STEP 1: Staffing Roles ── */}
|
||||
{step === 1 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base flex items-center gap-2"><Users className="w-4 h-4 text-blue-600" /> Staffing Requirements</CardTitle>
|
||||
<p className="text-xs text-slate-500 mt-0.5">Define exactly what roles you need. Vendors will bid based on these requirements.</p>
|
||||
</div>
|
||||
<Badge className="bg-blue-100 text-blue-700 border-0">{totalStaff} total staff</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{roles.map((role, idx) => (
|
||||
<div key={idx} className="border border-slate-200 rounded-xl p-4 bg-slate-50 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold text-slate-600">Role #{idx + 1}</span>
|
||||
{roles.length > 1 && (
|
||||
<button onClick={() => removeRole(idx)} className="text-red-400 hover:text-red-600 transition-colors">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-[10px] font-semibold text-slate-600 block mb-1">Role / Position *</label>
|
||||
<select value={role.role} onChange={e => updateRole(idx, "role", e.target.value)}
|
||||
className="w-full border border-slate-300 rounded-lg px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white">
|
||||
{ROLE_OPTIONS.map(r => <option key={r}>{r}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-semibold text-slate-600 block mb-1">Headcount *</label>
|
||||
<input type="number" min="1" value={role.count} onChange={e => updateRole(idx, "count", e.target.value)}
|
||||
className="w-full border border-slate-300 rounded-lg px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="text-[10px] font-semibold text-slate-600 block mb-1">Start Time</label>
|
||||
<input type="time" value={role.start_time} onChange={e => updateRole(idx, "start_time", e.target.value)}
|
||||
className="w-full border border-slate-300 rounded-lg px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-semibold text-slate-600 block mb-1">End Time</label>
|
||||
<input type="time" value={role.end_time} onChange={e => updateRole(idx, "end_time", e.target.value)}
|
||||
className="w-full border border-slate-300 rounded-lg px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-semibold text-slate-600 block mb-1">Budget Rate ($/hr)</label>
|
||||
<input type="number" value={role.cost_per_hour} onChange={e => updateRole(idx, "cost_per_hour", e.target.value)}
|
||||
className="w-full border border-slate-300 rounded-lg px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white"
|
||||
placeholder="e.g. 28" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-semibold text-slate-600 block mb-1">Uniform / Dress Code</label>
|
||||
<input value={role.uniform} onChange={e => updateRole(idx, "uniform", e.target.value)}
|
||||
className="w-full border border-slate-300 rounded-lg px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white"
|
||||
placeholder="e.g. All black, formal, business casual" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button onClick={addRole} className="w-full border-2 border-dashed border-slate-300 hover:border-blue-400 hover:bg-blue-50 rounded-xl py-3 text-sm text-slate-500 hover:text-blue-600 transition-all flex items-center justify-center gap-2">
|
||||
<Plus className="w-4 h-4" /> Add Another Role
|
||||
</button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── STEP 2: Logistics ── */}
|
||||
{step === 2 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2"><Truck className="w-4 h-4 text-violet-600" /> Event Logistics & Requirements</CardTitle>
|
||||
<p className="text-xs text-slate-500">Tell vendors what's included and what's expected. This improves bid accuracy.</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-slate-700 block mb-1">Expected Guest Count</label>
|
||||
<input type="number" value={logistics.total_guests} onChange={e => setLogistics({ ...logistics, total_guests: e.target.value })}
|
||||
className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="e.g. 200" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-slate-700 block mb-1">Event Goal / KPI</label>
|
||||
<input value={logistics.event_goal} onChange={e => setLogistics({ ...logistics, event_goal: e.target.value })}
|
||||
className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="e.g. 95% satisfaction score" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-slate-700 block mb-1">Layout / Setup Notes</label>
|
||||
<textarea rows={2} value={logistics.layout_notes} onChange={e => setLogistics({ ...logistics, layout_notes: e.target.value })}
|
||||
className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
|
||||
placeholder="e.g. Banquet round tables for 250, buffet station on east wall, cocktail hour setup at 4pm..." />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-slate-700 block mb-1">Special Requirements</label>
|
||||
<textarea rows={2} value={logistics.special_requirements} onChange={e => setLogistics({ ...logistics, special_requirements: e.target.value })}
|
||||
className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
|
||||
placeholder="e.g. Background check required, bilingual preferred, experience with VIP events..." />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-bold text-slate-700">Included Services / Add-ons</p>
|
||||
{[
|
||||
{ key: "bar_service", label: "Bar Service Required", icon: Wine, color: "text-purple-600" },
|
||||
{ key: "meal_provided", label: "Staff Meal Provided", icon: Utensils, color: "text-orange-600" },
|
||||
{ key: "travel_time", label: "Travel Time Compensated", icon: Truck, color: "text-blue-600" },
|
||||
{ key: "tips_enabled", label: "Tips / Gratuity Included", icon: Star, color: "text-yellow-600" },
|
||||
{ key: "portal_access", label: "Client Portal Access", icon: CheckCircle2, color: "text-emerald-600" },
|
||||
].map(({ key, label, icon: Icon, color }) => (
|
||||
<label key={key} className={`flex items-center gap-3 p-3 rounded-xl border-2 cursor-pointer transition-all ${logistics[key] ? "border-[#0039A6]/30 bg-[#0039A6]/5" : "border-slate-200 hover:border-slate-300"}`}>
|
||||
<input type="checkbox" checked={logistics[key]} onChange={e => setLogistics({ ...logistics, [key]: e.target.checked })} className="accent-[#0039A6] w-4 h-4" />
|
||||
<Icon className={`w-4 h-4 ${color}`} />
|
||||
<span className="text-sm font-medium text-slate-700">{label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{logistics.bar_service && (
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-slate-700 block mb-1">Bar Service Details</label>
|
||||
<textarea rows={2} value={logistics.bar_notes} onChange={e => setLogistics({ ...logistics, bar_notes: e.target.value })}
|
||||
className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
|
||||
placeholder="e.g. Full open bar, beer & wine only, cocktail hour 5-7pm, 2 bartenders needed..." />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{logistics.tips_enabled && (
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-slate-700 block mb-1">Tips Amount / Structure</label>
|
||||
<input value={logistics.tips_amount} onChange={e => setLogistics({ ...logistics, tips_amount: e.target.value })}
|
||||
className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="e.g. $5/hr, 18% gratuity, pooled tips" />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── STEP 3: Bid Strategy ── */}
|
||||
{step === 3 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2"><Brain className="w-4 h-4 text-[#0039A6]" /> KROW EDGE Bid Strategy</CardTitle>
|
||||
<p className="text-xs text-slate-500">Configure how vendors will compete for this event.</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<div>
|
||||
<p className="text-xs font-bold text-slate-700 mb-2">Award Mode</p>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(AWARD_MODES).map(([key, meta]) => (
|
||||
<label key={key} className={`flex items-start gap-3 p-4 rounded-xl border-2 cursor-pointer transition-all ${bidConfig.award_mode === key ? meta.color + " border-[#0039A6]" : "border-slate-200 hover:border-slate-300"}`}>
|
||||
<input type="radio" name="award_mode" value={key} checked={bidConfig.award_mode === key} onChange={() => setBidConfig({ ...bidConfig, award_mode: key })} className="accent-[#0039A6] mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-bold text-slate-800">{meta.label}</p>
|
||||
<p className="text-xs text-slate-500 mt-0.5">{meta.desc}</p>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{bidConfig.award_mode === "krow_smart" && (
|
||||
<>
|
||||
<div>
|
||||
<p className="text-xs font-bold text-slate-700 mb-2">Your Priority</p>
|
||||
{[
|
||||
{ v: "cost_optimization", label: "Cost Optimization", desc: "Lowest compliant price wins" },
|
||||
{ v: "balanced", label: "Balanced", desc: "Best mix of price + execution (default)" },
|
||||
{ v: "execution_certainty", label: "Execution Certainty", desc: "Best track record vendor wins, regardless of price" },
|
||||
].map(p => (
|
||||
<label key={p.v} className={`flex items-start gap-3 p-3 rounded-lg border-2 cursor-pointer mb-1.5 transition-all ${bidConfig.priority === p.v ? "border-[#0039A6]/40 bg-[#0039A6]/5" : "border-slate-200"}`}>
|
||||
<input type="radio" name="priority" value={p.v} checked={bidConfig.priority === p.v} onChange={() => setBidConfig({ ...bidConfig, priority: p.v })} className="accent-[#0039A6] mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-800">{p.label}</p>
|
||||
<p className="text-xs text-slate-500">{p.desc}</p>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs font-bold text-slate-700 mb-2">Risk Tolerance</p>
|
||||
{[
|
||||
{ v: "low", label: "Low Risk", desc: "Only proven, high-reliability vendors score well" },
|
||||
{ v: "medium", label: "Medium", desc: "Balance between new and proven vendors" },
|
||||
{ v: "aggressive", label: "Aggressive", desc: "Open to emerging vendors with competitive pricing" },
|
||||
].map(r => (
|
||||
<label key={r.v} className={`flex items-start gap-3 p-3 rounded-lg border-2 cursor-pointer mb-1.5 transition-all ${bidConfig.risk_tolerance === r.v ? "border-[#0039A6]/40 bg-[#0039A6]/5" : "border-slate-200"}`}>
|
||||
<input type="radio" name="risk" value={r.v} checked={bidConfig.risk_tolerance === r.v} onChange={() => setBidConfig({ ...bidConfig, risk_tolerance: r.v })} className="accent-[#0039A6] mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-800">{r.label}</p>
|
||||
<p className="text-xs text-slate-500">{r.desc}</p>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-slate-700 block mb-1">Bid Deadline</label>
|
||||
<input type="datetime-local" value={bidConfig.bid_deadline} onChange={e => setBidConfig({ ...bidConfig, bid_deadline: e.target.value })}
|
||||
className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-slate-700 block mb-1">Min. Vendors Required</label>
|
||||
<input type="number" min="1" max="20" value={bidConfig.min_vendors} onChange={e => setBidConfig({ ...bidConfig, min_vendors: +e.target.value })}
|
||||
className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 rounded-xl p-3 text-xs text-blue-700 flex items-start gap-2">
|
||||
<ShieldCheck className="w-4 h-4 flex-shrink-0 mt-0.5" />
|
||||
<span><strong>Anti-Gaming Active:</strong> KROW randomly selects the scoring formula — vendors cannot reverse-engineer what formula will be used.</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── STEP 4: Review ── */}
|
||||
{step === 4 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2"><CheckCircle2 className="w-4 h-4 text-emerald-600" /> Review & Post Event</CardTitle>
|
||||
<p className="text-xs text-slate-500">Confirm everything looks right before publishing for vendor bids.</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Event Summary */}
|
||||
<div className="bg-slate-50 rounded-xl p-4 space-y-2">
|
||||
<p className="text-xs font-bold text-slate-600 uppercase tracking-wider mb-2">Event</p>
|
||||
<div className="flex items-center gap-2"><Calendar className="w-4 h-4 text-slate-400" /><span className="text-sm font-bold text-slate-900">{eventData.event_name}</span></div>
|
||||
<div className="flex items-center gap-2"><Clock className="w-4 h-4 text-slate-400" /><span className="text-sm text-slate-700">{eventData.date}</span></div>
|
||||
<div className="flex items-center gap-2"><MapPin className="w-4 h-4 text-slate-400" /><span className="text-sm text-slate-700">{eventData.hub || eventData.event_location}</span></div>
|
||||
{eventData.total && <div className="flex items-center gap-2"><DollarSign className="w-4 h-4 text-slate-400" /><span className="text-sm text-slate-700">Budget: ${parseFloat(eventData.total).toLocaleString()}</span></div>}
|
||||
</div>
|
||||
|
||||
{/* Staffing Summary */}
|
||||
<div className="bg-blue-50 rounded-xl p-4">
|
||||
<p className="text-xs font-bold text-blue-700 uppercase tracking-wider mb-2">Staffing — {totalStaff} staff total</p>
|
||||
{roles.map((r, idx) => (
|
||||
<div key={idx} className="flex items-center justify-between py-1.5 border-b border-blue-100 last:border-0">
|
||||
<span className="text-sm text-blue-900 font-medium">{r.role}</span>
|
||||
<div className="flex items-center gap-3 text-xs text-blue-700">
|
||||
<span>{r.start_time} – {r.end_time}</span>
|
||||
<span className="font-bold">{r.count} staff</span>
|
||||
{r.cost_per_hour && <span>${r.cost_per_hour}/hr</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Logistics Summary */}
|
||||
<div className="bg-violet-50 rounded-xl p-4">
|
||||
<p className="text-xs font-bold text-violet-700 uppercase tracking-wider mb-2">Logistics</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{logistics.total_guests && <Badge className="bg-violet-100 text-violet-700 border-0 text-xs">{logistics.total_guests} guests</Badge>}
|
||||
{logistics.bar_service && <Badge className="bg-purple-100 text-purple-700 border-0 text-xs">Bar Service</Badge>}
|
||||
{logistics.meal_provided && <Badge className="bg-orange-100 text-orange-700 border-0 text-xs">Staff Meals</Badge>}
|
||||
{logistics.travel_time && <Badge className="bg-blue-100 text-blue-700 border-0 text-xs">Travel Paid</Badge>}
|
||||
{logistics.tips_enabled && <Badge className="bg-yellow-100 text-yellow-700 border-0 text-xs">Tips Included</Badge>}
|
||||
{!logistics.bar_service && !logistics.meal_provided && !logistics.travel_time && !logistics.tips_enabled && !logistics.total_guests && <span className="text-xs text-violet-500">No add-ons</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bid Strategy Summary */}
|
||||
<div className="bg-emerald-50 rounded-xl p-4">
|
||||
<p className="text-xs font-bold text-emerald-700 uppercase tracking-wider mb-2">Bid Strategy</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="w-4 h-4 text-emerald-600" />
|
||||
<span className="text-sm font-bold text-emerald-900">{AWARD_MODES[bidConfig.award_mode]?.label}</span>
|
||||
</div>
|
||||
{bidConfig.award_mode === "krow_smart" && (
|
||||
<div className="flex gap-2 mt-2 flex-wrap">
|
||||
<Badge className="bg-emerald-100 text-emerald-700 border-0 text-xs">Priority: {bidConfig.priority}</Badge>
|
||||
<Badge className="bg-emerald-100 text-emerald-700 border-0 text-xs">Risk: {bidConfig.risk_tolerance}</Badge>
|
||||
<Badge className="bg-emerald-100 text-emerald-700 border-0 text-xs">Min {bidConfig.min_vendors} vendors</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button onClick={() => createMutation.mutate()} disabled={createMutation.isPending}
|
||||
className="w-full bg-gradient-to-r from-[#0039A6] to-[#7c3aed] text-white font-bold h-11 text-base">
|
||||
{createMutation.isPending ? "Publishing..." : "🚀 Post Event & Open for Bids →"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<Button variant="outline" onClick={() => setStep(s => s - 1)} disabled={step === 0} className="gap-2">
|
||||
<ChevronLeft className="w-4 h-4" /> Back
|
||||
</Button>
|
||||
{step < STEPS.length - 1 && (
|
||||
<Button onClick={() => setStep(s => s + 1)} disabled={!canNext()} className="gap-2 bg-[#0039A6] text-white">
|
||||
Next <ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
96
src/components/krow-edge/ScoringEngine.jsx
Normal file
96
src/components/krow-edge/ScoringEngine.jsx
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* KROW EDGE Scoring Engine
|
||||
* Predictive Competitive Bidding Engine for Event Workforce Coverage
|
||||
*/
|
||||
|
||||
export const EVALUATION_METHODS = ["arithmetic_mean", "geometric_mean", "lowest_price", "krow_optimal"];
|
||||
|
||||
export function selectRandomEvaluationMethod() {
|
||||
return EVALUATION_METHODS[Math.floor(Math.random() * EVALUATION_METHODS.length)];
|
||||
}
|
||||
|
||||
export function calcTechnicalScore(vendor) {
|
||||
const fillRate = (vendor.shift_coverage_percentage || 75) / 100;
|
||||
const reliability = (vendor.reliability_score || 70) / 100;
|
||||
const rating = ((vendor.rating || 3.5) / 5);
|
||||
const noShowPenalty = 1 - Math.min((vendor.no_show_count || 0) * 0.05, 0.4);
|
||||
return Math.round((fillRate * 0.30 + reliability * 0.30 + rating * 0.25 + noShowPenalty * 0.15) * 100);
|
||||
}
|
||||
|
||||
export function calcFinancialScore(bid, allBids, method) {
|
||||
const prices = allBids.map(b => b.price_per_hour).filter(p => p > 0);
|
||||
if (prices.length === 0) return 50;
|
||||
|
||||
const arithmeticMean = prices.reduce((s, p) => s + p, 0) / prices.length;
|
||||
const geometricMean = Math.exp(prices.reduce((s, p) => s + Math.log(p), 0) / prices.length);
|
||||
const lowestPrice = Math.min(...prices);
|
||||
|
||||
let optimalRef;
|
||||
if (method === "arithmetic_mean") optimalRef = arithmeticMean;
|
||||
else if (method === "geometric_mean") optimalRef = geometricMean;
|
||||
else if (method === "lowest_price") optimalRef = lowestPrice;
|
||||
else optimalRef = (arithmeticMean + geometricMean) / 2; // krow_optimal
|
||||
|
||||
const deviation = Math.abs(bid.price_per_hour - optimalRef) / optimalRef;
|
||||
return Math.max(0, Math.round(100 - deviation * 150));
|
||||
}
|
||||
|
||||
export function calcRiskScore(vendor) {
|
||||
const noShowRate = Math.min((vendor.no_show_count || 0) * 5, 50);
|
||||
const proximityFactor = 80; // placeholder, would use GPS in production
|
||||
const consistencyIndex = vendor.reliability_score || 70;
|
||||
return Math.round(
|
||||
(100 - noShowRate) * 0.4 +
|
||||
proximityFactor * 0.3 +
|
||||
consistencyIndex * 0.3
|
||||
);
|
||||
}
|
||||
|
||||
export function calcFinalScore(technical, financial, risk, priority) {
|
||||
if (priority === "cost_optimization") {
|
||||
return Math.round(technical * 0.50 + financial * 0.40 + risk * 0.10);
|
||||
} else if (priority === "execution_certainty") {
|
||||
return Math.round(technical * 0.75 + financial * 0.10 + risk * 0.15);
|
||||
}
|
||||
// balanced (default)
|
||||
return Math.round(technical * 0.65 + financial * 0.25 + risk * 0.10);
|
||||
}
|
||||
|
||||
export function calcMarketDeviation(price, allBids) {
|
||||
const prices = allBids.map(b => b.price_per_hour).filter(p => p > 0);
|
||||
if (prices.length === 0) return 0;
|
||||
const avg = prices.reduce((s, p) => s + p, 0) / prices.length;
|
||||
return Math.round(((price - avg) / avg) * 100);
|
||||
}
|
||||
|
||||
export function generateAIInsight(bid, deviation, rank, totalBids) {
|
||||
if (deviation < -15) return `Price is ${Math.abs(deviation)}% below market average — elevated execution risk. Proceed with caution.`;
|
||||
if (deviation > 15) return `Price is ${deviation}% above market average — consider negotiating or reviewing alternatives.`;
|
||||
if (rank === 1) return `Top-ranked vendor — best balance of cost, performance, and reliability.`;
|
||||
if (bid.technical_score >= 85) return `Strong reliability score. Minor price adjustment could improve ranking significantly.`;
|
||||
if (bid.financial_score >= 85) return `Competitive pricing. Performance score is the primary differentiator.`;
|
||||
return `Solid bid. Ranked #${rank} of ${totalBids}. Improving execution plan score could advance position.`;
|
||||
}
|
||||
|
||||
export function scoreAllBids(bids, vendorMap, priority) {
|
||||
if (bids.length === 0) return [];
|
||||
const method = selectRandomEvaluationMethod();
|
||||
|
||||
const scored = bids.map(bid => {
|
||||
const vendor = vendorMap[bid.vendor_id] || {};
|
||||
const technical = calcTechnicalScore(vendor);
|
||||
const financial = calcFinancialScore(bid, bids, method);
|
||||
const risk = calcRiskScore(vendor);
|
||||
const final = calcFinalScore(technical, financial, risk, priority);
|
||||
const deviation = calcMarketDeviation(bid.price_per_hour, bids);
|
||||
return { ...bid, technical_score: technical, financial_score: financial, risk_score: risk, final_score: final, market_deviation_pct: deviation, evaluation_method: method };
|
||||
});
|
||||
|
||||
scored.sort((a, b) => b.final_score - a.final_score);
|
||||
scored.forEach((b, i) => {
|
||||
b.rank = i + 1;
|
||||
b.ai_insight = generateAIInsight(b, b.market_deviation_pct, b.rank, scored.length);
|
||||
});
|
||||
|
||||
return scored;
|
||||
}
|
||||
254
src/components/krow-edge/VendorBidPanel.jsx
Normal file
254
src/components/krow-edge/VendorBidPanel.jsx
Normal file
@@ -0,0 +1,254 @@
|
||||
import React, { useState } from "react";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Calendar, MapPin, Users, DollarSign, Clock, ChevronDown, ChevronUp,
|
||||
Wine, Utensils, Truck, Star, FileText, Building2, Target, Zap,
|
||||
CheckCircle2, AlertCircle, Package
|
||||
} from "lucide-react";
|
||||
|
||||
function InfoRow({ icon: Icon, label, value, highlight }) {
|
||||
if (!value) return null;
|
||||
return (
|
||||
<div className="flex items-start gap-2.5 py-2 border-b border-slate-100 last:border-0">
|
||||
<Icon className="w-4 h-4 text-slate-400 flex-shrink-0 mt-0.5" />
|
||||
<span className="text-xs text-slate-500 w-28 flex-shrink-0">{label}</span>
|
||||
<span className={`text-xs font-semibold ${highlight ? "text-[#0039A6]" : "text-slate-800"}`}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddonChip({ icon: Icon, label, color }) {
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold ${color}`}>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function VendorBidPanel({ event, vendorId, vendorName, onSubmitted }) {
|
||||
const { toast } = useToast();
|
||||
const [showFullDetails, setShowFullDetails] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
price_per_hour: "",
|
||||
total_cost: "",
|
||||
execution_plan_score: 75,
|
||||
confidence_level: 80,
|
||||
notes: ""
|
||||
});
|
||||
|
||||
const submitMutation = useMutation({
|
||||
mutationFn: (data) => base44.entities.KrowEdgeBid.create(data),
|
||||
onSuccess: () => {
|
||||
toast({ title: "✅ Bid Submitted!", description: "Your bid has been submitted for KROW EDGE evaluation." });
|
||||
setForm({ price_per_hour: "", total_cost: "", execution_plan_score: 75, confidence_level: 80, notes: "" });
|
||||
onSubmitted && onSubmitted();
|
||||
}
|
||||
});
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
submitMutation.mutate({
|
||||
event_id: event.id,
|
||||
event_name: event.event_name,
|
||||
event_date: event.date,
|
||||
award_mode: "krow_smart",
|
||||
vendor_id: vendorId,
|
||||
vendor_name: vendorName,
|
||||
price_per_hour: parseFloat(form.price_per_hour),
|
||||
total_cost: parseFloat(form.total_cost),
|
||||
execution_plan_score: form.execution_plan_score,
|
||||
confidence_level: form.confidence_level,
|
||||
notes: form.notes,
|
||||
status: "submitted"
|
||||
});
|
||||
};
|
||||
|
||||
// Gather all roles/shifts info
|
||||
const allRoles = [];
|
||||
(event.shifts || []).forEach(shift => {
|
||||
(shift.roles || []).forEach(role => {
|
||||
allRoles.push({ ...role, shift_name: shift.shift_name, location: shift.location });
|
||||
});
|
||||
});
|
||||
|
||||
const totalStaffRequested = event.requested || allRoles.reduce((s, r) => s + (r.count || 0), 0);
|
||||
const addons = event.addons || {};
|
||||
|
||||
// Logistics flags
|
||||
const hasBar = allRoles.some(r =>
|
||||
r.role?.toLowerCase().includes("bar") || r.department?.toLowerCase().includes("bar")
|
||||
);
|
||||
const hasMeals = addons.meal_provided;
|
||||
const hasTravel = addons.travel_time;
|
||||
const hasTips = addons.tips?.enabled;
|
||||
const hasGoal = addons.goal?.enabled;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* ── EVENT OVERVIEW CARD ── */}
|
||||
<Card className="border-[#0039A6]/20 bg-gradient-to-br from-[#0039A6]/3 to-white">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base text-slate-900">{event.event_name}</CardTitle>
|
||||
<p className="text-xs text-slate-500 mt-0.5">{event.business_name || "Client"}</p>
|
||||
</div>
|
||||
<Badge className={`text-xs ${event.status === "Active" ? "bg-emerald-100 text-emerald-700" : "bg-slate-100 text-slate-600"} border-0`}>
|
||||
{event.status || "Open"}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-0">
|
||||
<InfoRow icon={Calendar} label="Event Date" value={event.date} highlight />
|
||||
<InfoRow icon={MapPin} label="Location" value={event.hub || event.event_location} />
|
||||
<InfoRow icon={Building2} label="Client" value={event.business_name} />
|
||||
<InfoRow icon={FileText} label="Contract Type" value={event.contract_type} />
|
||||
{event.po_reference && <InfoRow icon={FileText} label="PO Reference" value={event.po_reference} />}
|
||||
<InfoRow icon={Users} label="Staff Needed" value={totalStaffRequested ? `${totalStaffRequested} staff` : null} highlight />
|
||||
{event.total && <InfoRow icon={DollarSign} label="Event Budget" value={`$${event.total?.toLocaleString()}`} highlight />}
|
||||
{event.notes && <InfoRow icon={FileText} label="Notes" value={event.notes} />}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── LOGISTICS & ADDONS ── */}
|
||||
{(hasBar || hasMeals || hasTravel || hasTips || hasGoal || addons.portal_access) && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<Package className="w-4 h-4 text-violet-600" /> Logistics & Add-ons
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{hasBar && <AddonChip icon={Wine} label="Bar Service" color="bg-purple-100 text-purple-700" />}
|
||||
{hasMeals && <AddonChip icon={Utensils} label="Meal Provided" color="bg-orange-100 text-orange-700" />}
|
||||
{hasTravel && <AddonChip icon={Truck} label="Travel Time Paid" color="bg-blue-100 text-blue-700" />}
|
||||
{hasTips && <AddonChip icon={Star} label={`Tips: ${addons.tips?.amount || "Yes"}`} color="bg-yellow-100 text-yellow-700" />}
|
||||
{hasGoal && <AddonChip icon={Target} label={`Goal: ${addons.goal?.text || "Yes"}`} color="bg-emerald-100 text-emerald-700" />}
|
||||
{addons.portal_access && <AddonChip icon={CheckCircle2} label="Portal Access" color="bg-slate-100 text-slate-700" />}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── STAFF ROLES REQUIREMENTS ── */}
|
||||
{allRoles.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<Users className="w-4 h-4 text-blue-600" /> Staffing Requirements
|
||||
</CardTitle>
|
||||
<button onClick={() => setShowFullDetails(!showFullDetails)} className="text-xs text-[#0039A6] flex items-center gap-1 hover:underline">
|
||||
{showFullDetails ? "Show less" : "Show all"}
|
||||
{showFullDetails ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{(showFullDetails ? allRoles : allRoles.slice(0, 4)).map((role, idx) => (
|
||||
<div key={idx} className="flex items-center justify-between py-2 px-3 bg-slate-50 rounded-lg">
|
||||
<div>
|
||||
<p className="text-xs font-bold text-slate-800">{role.role || "Staff"}</p>
|
||||
<div className="flex items-center gap-3 mt-0.5">
|
||||
{role.department && <span className="text-[10px] text-slate-500">{role.department}</span>}
|
||||
{role.start_time && role.end_time && (
|
||||
<span className="text-[10px] text-slate-500 flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" /> {role.start_time} – {role.end_time}
|
||||
</span>
|
||||
)}
|
||||
{role.uniform && <span className="text-[10px] text-slate-500">Uniform: {role.uniform}</span>}
|
||||
{role.shift_name && <span className="text-[10px] text-blue-500">{role.shift_name}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-sm font-bold text-[#0039A6]">{role.count || 1}</span>
|
||||
<p className="text-[10px] text-slate-400">staff</p>
|
||||
{role.cost_per_hour && <p className="text-[10px] text-slate-500">${role.cost_per_hour}/hr</p>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!showFullDetails && allRoles.length > 4 && (
|
||||
<p className="text-xs text-slate-400 text-center">+{allRoles.length - 4} more roles</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── BID SUBMISSION FORM ── */}
|
||||
<Card className="border-[#0039A6]/30 shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<Zap className="w-4 h-4 text-[#0039A6]" /> Your KROW EDGE Bid
|
||||
</CardTitle>
|
||||
<p className="text-xs text-slate-500">Submit your pricing and execution plan. KROW will score and rank all bids.</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-slate-700 block mb-1">Price per Hour ($) *</label>
|
||||
<input type="number" required min="1" step="0.01" value={form.price_per_hour}
|
||||
onChange={e => setForm({ ...form, price_per_hour: e.target.value })}
|
||||
className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="e.g. 28.00" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-slate-700 block mb-1">Total Bid Cost ($) *</label>
|
||||
<input type="number" required min="1" step="0.01" value={form.total_cost}
|
||||
onChange={e => setForm({ ...form, total_cost: e.target.value })}
|
||||
className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="e.g. 1120.00" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-slate-700 block mb-1">
|
||||
Execution Plan Quality: <span className="text-blue-600 font-bold">{form.execution_plan_score}/100</span>
|
||||
</label>
|
||||
<input type="range" min="0" max="100" value={form.execution_plan_score}
|
||||
onChange={e => setForm({ ...form, execution_plan_score: +e.target.value })} className="w-full accent-blue-600" />
|
||||
<div className="flex justify-between text-[10px] text-slate-400 mt-0.5">
|
||||
<span>Basic (0)</span><span>Standard (50)</span><span>Excellent (100)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-slate-700 block mb-1">
|
||||
Delivery Confidence: <span className="text-blue-600 font-bold">{form.confidence_level}%</span>
|
||||
</label>
|
||||
<input type="range" min="0" max="100" value={form.confidence_level}
|
||||
onChange={e => setForm({ ...form, confidence_level: +e.target.value })} className="w-full accent-blue-600" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-slate-700 block mb-1">Execution Plan / Notes *</label>
|
||||
<textarea rows={4} required value={form.notes} onChange={e => setForm({ ...form, notes: e.target.value })}
|
||||
className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
|
||||
placeholder={`Describe your staffing plan for this event:\n- How will you cover the ${totalStaffRequested || "required"} staff?\n- Any experience with similar events?\n- Logistics and contingency plan?`} />
|
||||
</div>
|
||||
|
||||
{event.total && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 flex items-start gap-2">
|
||||
<AlertCircle className="w-4 h-4 text-amber-500 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-amber-700">
|
||||
<strong>Client Budget Reference:</strong> ${event.total?.toLocaleString()} total. Your bid will be scored against market pricing for this event.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type="submit" disabled={submitMutation.isPending} className="w-full bg-gradient-to-r from-[#0039A6] to-[#7c3aed] text-white font-bold h-10">
|
||||
{submitMutation.isPending ? "Submitting..." : "Submit KROW EDGE Bid →"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
55
src/components/krow-edge/VendorPositioningChart.jsx
Normal file
55
src/components/krow-edge/VendorPositioningChart.jsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import React from "react";
|
||||
import { ScatterChart, Scatter, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine, Label } from "recharts";
|
||||
|
||||
const CustomDot = (props) => {
|
||||
const { cx, cy, payload } = props;
|
||||
const color = payload.rank === 1 ? "#059669" : payload.rank === 2 ? "#2563eb" : payload.rank === 3 ? "#7c3aed" : "#94a3b8";
|
||||
const size = payload.rank === 1 ? 12 : 8;
|
||||
return (
|
||||
<g>
|
||||
<circle cx={cx} cy={cy} r={size} fill={color} stroke="white" strokeWidth={2} opacity={0.9} />
|
||||
{payload.rank === 1 && <text x={cx} y={cy - 16} textAnchor="middle" fontSize={10} fontWeight="bold" fill="#059669">★ Winner</text>}
|
||||
<text x={cx} y={cy + size + 12} textAnchor="middle" fontSize={9} fill="#64748b">{payload.vendor_name?.split(" ")[0]}</text>
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
const CustomTooltip = ({ active, payload }) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
const d = payload[0].payload;
|
||||
return (
|
||||
<div className="bg-white border border-slate-200 rounded-xl shadow-lg p-3 text-xs space-y-1 min-w-[180px]">
|
||||
<p className="font-bold text-slate-900">{d.vendor_name}</p>
|
||||
<p className="text-slate-600">Price/hr: <span className="font-semibold text-slate-900">${d.price_per_hour}</span></p>
|
||||
<p className="text-slate-600">Exec Score: <span className="font-semibold text-slate-900">{d.technical_score}</span></p>
|
||||
<p className="text-slate-600">Final Score: <span className="font-bold text-blue-700">{d.final_score}</span></p>
|
||||
<p className="text-slate-600">Rank: <span className="font-bold">#{d.rank}</span></p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function VendorPositioningChart({ bids, marketAvg }) {
|
||||
if (!bids || bids.length === 0) return null;
|
||||
const data = bids.map(b => ({ ...b, x: b.price_per_hour, y: b.technical_score }));
|
||||
|
||||
return (
|
||||
<div className="w-full h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ScatterChart margin={{ top: 20, right: 20, bottom: 30, left: 10 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
|
||||
<XAxis type="number" dataKey="x" name="Price/hr" domain={["auto", "auto"]} tick={{ fontSize: 10 }} label={{ value: "Price / Hour ($)", position: "insideBottom", offset: -10, fontSize: 10 }} />
|
||||
<YAxis type="number" dataKey="y" name="Exec Score" domain={[0, 100]} tick={{ fontSize: 10 }} label={{ value: "Execution Score", angle: -90, position: "insideLeft", fontSize: 10 }} />
|
||||
{marketAvg && <ReferenceLine x={marketAvg} stroke="#f59e0b" strokeDasharray="4 4" label={{ value: "Mkt Avg", fontSize: 9, fill: "#f59e0b" }} />}
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Scatter data={data} shape={<CustomDot />} />
|
||||
</ScatterChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="flex items-center justify-center gap-4 mt-1 text-[10px] text-slate-500">
|
||||
<span className="flex items-center gap-1"><span className="w-3 h-3 rounded-full bg-emerald-600 inline-block" /> Winner</span>
|
||||
<span className="flex items-center gap-1"><span className="w-3 h-3 rounded-full bg-blue-600 inline-block" /> Runner-up</span>
|
||||
<span className="flex items-center gap-1"><span className="w-3 h-3 rounded-full bg-violet-600 inline-block" /> 3rd</span>
|
||||
<span className="flex items-center gap-1"><span className="w-2 h-0.5 bg-amber-400 inline-block" style={{borderTop:"2px dashed #f59e0b"}} /> Market Avg</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
82
src/components/krowcommand/ActionCard.jsx
Normal file
82
src/components/krowcommand/ActionCard.jsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import React, { useState } from "react";
|
||||
import { ChevronDown, ChevronUp, AlertOctagon, AlertTriangle, Info, User, Clock, Target } from "lucide-react";
|
||||
|
||||
const severityConfig = {
|
||||
critical: { icon: AlertOctagon, color: "text-red-600", bg: "bg-red-50", border: "border-red-200", badge: "bg-red-600 text-white" },
|
||||
high: { icon: AlertTriangle, color: "text-orange-600", bg: "bg-orange-50", border: "border-orange-200", badge: "bg-orange-500 text-white" },
|
||||
medium: { icon: AlertTriangle, color: "text-yellow-600", bg: "bg-yellow-50", border: "border-yellow-200", badge: "bg-yellow-500 text-white" },
|
||||
low: { icon: Info, color: "text-blue-600", bg: "bg-blue-50", border: "border-blue-200", badge: "bg-blue-500 text-white" },
|
||||
};
|
||||
|
||||
export default function ActionCard({ action }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const config = severityConfig[action.severity] || severityConfig.medium;
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<div className={`rounded-xl border-2 ${config.border} ${config.bg} overflow-hidden transition-all`}>
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="w-full flex items-start gap-3 p-4 text-left hover:bg-opacity-70 transition-colors"
|
||||
>
|
||||
<div className={`flex-shrink-0 w-8 h-8 rounded-lg ${config.badge} flex items-center justify-center text-xs font-black`}>
|
||||
{action.priority}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<Icon className={`w-3.5 h-3.5 ${config.color}`} />
|
||||
<span className={`text-[9px] font-bold uppercase tracking-wider ${config.color}`}>
|
||||
{action.severity}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-slate-900 leading-snug">{action.title}</p>
|
||||
</div>
|
||||
{expanded ? <ChevronUp className="w-4 h-4 text-slate-400 flex-shrink-0 mt-2" /> : <ChevronDown className="w-4 h-4 text-slate-400 flex-shrink-0 mt-2" />}
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="px-4 pb-4 space-y-3 border-t border-slate-200/50 pt-3">
|
||||
<div>
|
||||
<p className="text-[10px] font-bold text-slate-500 uppercase tracking-wide mb-0.5">Situation</p>
|
||||
<p className="text-xs text-slate-700">{action.situation}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-bold text-slate-500 uppercase tracking-wide mb-0.5">Evidence</p>
|
||||
<p className="text-xs text-slate-700">{action.evidence}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-bold text-slate-500 uppercase tracking-wide mb-0.5">Risk</p>
|
||||
<p className="text-xs text-slate-700">{action.risk}</p>
|
||||
</div>
|
||||
<div className={`${config.bg} rounded-lg p-3 border ${config.border}`}>
|
||||
<p className="text-[10px] font-bold text-slate-500 uppercase tracking-wide mb-0.5">Recommendation</p>
|
||||
<p className="text-xs text-slate-900 font-medium">{action.recommendation}</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3 pt-1">
|
||||
<div className="flex items-start gap-1.5">
|
||||
<User className="w-3 h-3 text-slate-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-[9px] font-bold text-slate-400 uppercase">Owner</p>
|
||||
<p className="text-[11px] text-slate-700">{action.owner}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-1.5">
|
||||
<Clock className="w-3 h-3 text-slate-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-[9px] font-bold text-slate-400 uppercase">Deadline</p>
|
||||
<p className="text-[11px] text-slate-700">{action.deadline}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-1.5">
|
||||
<Target className="w-3 h-3 text-slate-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-[9px] font-bold text-slate-400 uppercase">Impact</p>
|
||||
<p className="text-[11px] text-slate-700">{action.expectedImpact}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
src/components/krowcommand/AttentionCard.jsx
Normal file
30
src/components/krowcommand/AttentionCard.jsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React from "react";
|
||||
import { AlertTriangle, TrendingUp, Users, DollarSign, Shield } from "lucide-react";
|
||||
|
||||
export default function AttentionCard({ icon: Icon, label, value, sublabel, severity = "normal", onClick }) {
|
||||
const colorMap = {
|
||||
critical: "border-red-200 bg-red-50 text-red-700",
|
||||
high: "border-orange-200 bg-orange-50 text-orange-700",
|
||||
medium: "border-yellow-200 bg-yellow-50 text-yellow-700",
|
||||
normal: "border-slate-200 bg-white text-slate-700",
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`text-left p-4 rounded-xl border-2 transition-all hover:shadow-md ${colorMap[severity]} ${onClick ? "cursor-pointer hover:border-slate-300" : "cursor-default"}`}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-1">
|
||||
<Icon className="w-4 h-4 opacity-70" />
|
||||
{severity !== "normal" && (
|
||||
<span className={`text-[9px] font-bold uppercase tracking-wide px-1.5 py-0.5 rounded-full ${colorMap[severity]}`}>
|
||||
{severity}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-2xl font-black leading-none">{value}</p>
|
||||
<p className="text-[11px] mt-1 opacity-80">{label}</p>
|
||||
{sublabel && <p className="text-[10px] mt-0.5 opacity-60">{sublabel}</p>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
104
src/components/krowcommand/CausalChainView.jsx
Normal file
104
src/components/krowcommand/CausalChainView.jsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Package, Users, Settings, GraduationCap, TrendingUp,
|
||||
Cloud, Shield, DollarSign, Truck, AlertTriangle, ChevronDown
|
||||
} from "lucide-react";
|
||||
|
||||
const CATEGORY_CONFIG = {
|
||||
vendor: { icon: Package, color: "text-amber-600", bg: "bg-amber-50", border: "border-amber-200", label: "Vendor" },
|
||||
operations: { icon: Settings, color: "text-blue-600", bg: "bg-blue-50", border: "border-blue-200", label: "Operations" },
|
||||
workforce: { icon: Users, color: "text-violet-600", bg: "bg-violet-50", border: "border-violet-200", label: "Workforce" },
|
||||
training: { icon: GraduationCap,color: "text-teal-600", bg: "bg-teal-50", border: "border-teal-200", label: "Training" },
|
||||
demand: { icon: TrendingUp, color: "text-emerald-600", bg: "bg-emerald-50", border: "border-emerald-200", label: "Demand" },
|
||||
external: { icon: Cloud, color: "text-sky-600", bg: "bg-sky-50", border: "border-sky-200", label: "External" },
|
||||
compliance: { icon: Shield, color: "text-red-600", bg: "bg-red-50", border: "border-red-200", label: "Compliance" },
|
||||
finance: { icon: DollarSign, color: "text-green-600", bg: "bg-green-50", border: "border-green-200", label: "Finance" },
|
||||
equipment: { icon: Settings, color: "text-orange-600", bg: "bg-orange-50", border: "border-orange-200", label: "Equipment" },
|
||||
transportation:{ icon: Truck, color: "text-indigo-600", bg: "bg-indigo-50", border: "border-indigo-200", label: "Transport" },
|
||||
system: { icon: AlertTriangle,color:"text-slate-600", bg: "bg-slate-50", border: "border-slate-200", label: "System" },
|
||||
};
|
||||
|
||||
export default function CausalChainView({ observation, causalChain, rootCauses, recommendation }) {
|
||||
if (!causalChain || causalChain.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Observation */}
|
||||
{observation && (
|
||||
<div className="bg-gradient-to-r from-[#0039A6] to-[#1a56c4] rounded-xl p-4 text-white">
|
||||
<p className="text-[10px] font-bold text-blue-200 uppercase tracking-widest mb-1">Observation</p>
|
||||
<p className="text-base font-bold leading-snug">{observation}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Causal Chain */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Why? — Causal Chain</p>
|
||||
<span className="text-[10px] text-slate-400">tracing backwards to root cause</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-0">
|
||||
{causalChain.map((link, i) => {
|
||||
const cat = CATEGORY_CONFIG[link.category] || CATEGORY_CONFIG.system;
|
||||
const Icon = cat.icon;
|
||||
const isRoot = i === causalChain.length - 1;
|
||||
|
||||
return (
|
||||
<div key={i}>
|
||||
{/* The factor card */}
|
||||
<div className={`flex items-start gap-3 p-3.5 rounded-xl border ${cat.border} ${cat.bg} ${isRoot ? "ring-2 ring-offset-1 ring-slate-300" : ""}`}>
|
||||
<div className={`w-8 h-8 rounded-lg ${cat.bg} border ${cat.border} flex items-center justify-center flex-shrink-0`}>
|
||||
<Icon className={`w-4 h-4 ${cat.color}`} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<span className="text-[10px] font-bold text-slate-400">#{link.step}</span>
|
||||
<span className={`text-[9px] font-bold uppercase tracking-wide ${cat.color} ${cat.bg} px-1.5 py-0.5 rounded`}>{cat.label}</span>
|
||||
{isRoot && (
|
||||
<span className="text-[9px] font-bold uppercase tracking-wide text-white bg-slate-700 px-1.5 py-0.5 rounded">Root Cause</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-slate-900 leading-snug">{link.factor}</p>
|
||||
{link.evidence && (
|
||||
<p className="text-xs text-slate-500 mt-1 leading-snug">{link.evidence}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* The ↓ connector */}
|
||||
{!isRoot && (
|
||||
<div className="flex justify-center py-1">
|
||||
<ChevronDown className="w-4 h-4 text-slate-300" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Root Causes */}
|
||||
{rootCauses && rootCauses.length > 0 && (
|
||||
<div className="bg-slate-100 rounded-xl p-4 border border-slate-200">
|
||||
<p className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-2">Root Cause{rootCauses.length > 1 ? "s" : ""}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{rootCauses.map((cause, i) => (
|
||||
<span key={i} className="text-xs font-semibold text-slate-800 bg-white border border-slate-300 rounded-lg px-2.5 py-1">
|
||||
{cause}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recommendation */}
|
||||
{recommendation && (
|
||||
<div className="bg-emerald-50 border border-emerald-200 rounded-xl p-4">
|
||||
<p className="text-[10px] font-bold text-emerald-600 uppercase tracking-widest mb-1">Recommendation</p>
|
||||
<p className="text-sm text-slate-800 leading-relaxed">{recommendation}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
136
src/components/krowcommand/CommandAnswer.jsx
Normal file
136
src/components/krowcommand/CommandAnswer.jsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { Loader2, GitBranch, Cpu, Network } from "lucide-react";
|
||||
import { buildContextForQuestion, synthesizeAnswer } from "@/lib/krowCommandEngine";
|
||||
import { explainRouting } from "@/lib/engines/queryRouter";
|
||||
import { getEngine } from "@/lib/engines/engineRegistry";
|
||||
import CausalChainView from "@/components/krowcommand/CausalChainView";
|
||||
|
||||
export default function CommandAnswer({ data, question, onAskAnother }) {
|
||||
const [answer, setAnswer] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [routing, setRouting] = useState(null);
|
||||
const askedRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data || !question || askedRef.current === question) return;
|
||||
askedRef.current = question;
|
||||
|
||||
// Show routing immediately (before LLM call) for transparency
|
||||
setRouting(explainRouting(question));
|
||||
|
||||
const run = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const context = buildContextForQuestion(question, data);
|
||||
const result = await synthesizeAnswer(question, data, context);
|
||||
setAnswer(result);
|
||||
} catch (e) {
|
||||
setError(e.message || "Failed to generate answer");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
run();
|
||||
}, [data, question]);
|
||||
|
||||
const primaryEngine = routing?.primaryEngine || getEngine("operational_intelligence");
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-slate-200 overflow-hidden">
|
||||
{/* Header — shows which engine is handling this query */}
|
||||
<div className="bg-gradient-to-r from-[#0039A6] to-[#1a56c4] px-5 py-4 flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-white/20 rounded-lg flex items-center justify-center">
|
||||
<GitBranch className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[10px] text-blue-200 font-medium uppercase tracking-wide">KROW Command — Causal Reasoning</p>
|
||||
<p className="text-sm font-semibold text-white truncate">{question}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onAskAnother}
|
||||
className="text-xs text-blue-200 hover:text-white underline"
|
||||
>
|
||||
Ask another
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Engine attribution bar — shows which engine(s) are reasoning */}
|
||||
{routing && (
|
||||
<div className="bg-slate-50 border-b border-slate-200 px-5 py-2.5 flex items-center gap-2 flex-wrap">
|
||||
<Cpu className="w-3.5 h-3.5 text-slate-400 flex-shrink-0" />
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="text-[11px] font-bold px-2 py-0.5 rounded text-white"
|
||||
style={{ backgroundColor: primaryEngine.color }}
|
||||
>
|
||||
{primaryEngine.number}. {primaryEngine.shortName}
|
||||
</span>
|
||||
{routing.secondaryEngines?.map(e => (
|
||||
<span
|
||||
key={e.key}
|
||||
className="text-[10px] font-medium px-1.5 py-0.5 rounded bg-slate-200 text-slate-600"
|
||||
>
|
||||
+ {e.shortName}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-[11px] text-slate-400 ml-1">{routing.explanation}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-5">
|
||||
{loading && (
|
||||
<div className="flex flex-col items-center gap-3 py-10">
|
||||
<Loader2 className="w-6 h-6 text-[#0039A6] animate-spin" />
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium text-slate-700">
|
||||
{primaryEngine.name} is reasoning…
|
||||
</p>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
Building the Digital Twin and tracing the causal chain to root cause(s).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<p className="text-sm text-red-700 font-medium">Unable to generate reasoning</p>
|
||||
<p className="text-xs text-red-600 mt-1">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && answer && (
|
||||
<>
|
||||
<CausalChainView
|
||||
observation={answer.observation}
|
||||
causalChain={answer.causal_chain}
|
||||
rootCauses={answer.root_causes}
|
||||
recommendation={answer.recommendation}
|
||||
/>
|
||||
|
||||
{/* Engine footer — shows attribution and twin snapshot */}
|
||||
{answer._engineName && (
|
||||
<div className="mt-4 pt-4 border-t border-slate-100 flex items-center gap-2 flex-wrap text-[11px] text-slate-400">
|
||||
<Network className="w-3 h-3" />
|
||||
<span>Reasoned by <strong className="text-slate-600">{answer._engineName}</strong></span>
|
||||
{answer._twinHealth && (
|
||||
<span className="ml-2">
|
||||
Digital Twin health: <strong className="text-slate-600">{answer._twinHealth.overall}/100</strong> ({answer._twinHealth.status})
|
||||
</span>
|
||||
)}
|
||||
{answer.confidence_score != null && (
|
||||
<span className="ml-2">
|
||||
Confidence: <strong className="text-slate-600">{answer.confidence_score}%</strong>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
144
src/components/krowcommand/HowToUseKrow.jsx
Normal file
144
src/components/krowcommand/HowToUseKrow.jsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription
|
||||
} from "@/components/ui/dialog";
|
||||
import { Lightbulb, Sparkles } from "lucide-react";
|
||||
import { ENGINES } from "@/lib/engines/engineRegistry";
|
||||
|
||||
const ENGINE_EXAMPLES = {
|
||||
workforce_intelligence: [
|
||||
"Who are my best workers by role and location?",
|
||||
"Which workers are becoming unreliable?",
|
||||
"How many shifts does Maria have this week?",
|
||||
],
|
||||
vendor_intelligence: [
|
||||
"Which vendor is performing best?",
|
||||
"Which vendors have the lowest fill rate?",
|
||||
"Compare Vendor A vs Vendor B for kitchen roles",
|
||||
],
|
||||
procurement_intelligence: [
|
||||
"What are the best rates for cooks in my area?",
|
||||
"Which vendors are approved for Compass?",
|
||||
"Where can I save on labor costs?",
|
||||
],
|
||||
operational_intelligence: [
|
||||
"How many shifts do I have this week?",
|
||||
"How many shifts remain open?",
|
||||
"What changed since yesterday?",
|
||||
],
|
||||
prediction_engine: [
|
||||
"Which shifts are at the highest risk?",
|
||||
"What is my projected labor cost?",
|
||||
"Will we have enough staff next week?",
|
||||
],
|
||||
decision_engine: [
|
||||
"What actions should my team take today?",
|
||||
"Should we accept this new order?",
|
||||
"Which shift should I prioritize filling?",
|
||||
],
|
||||
autonomous_execution: [
|
||||
"Auto-assign the open shifts to my best workers",
|
||||
"Flag any compliance issues automatically",
|
||||
],
|
||||
enterprise_memory: [
|
||||
"What did we learn from last week's shortages?",
|
||||
"What worked last time we had a similar event?",
|
||||
],
|
||||
simulation_engine: [
|
||||
"What happens if we add 5 more cooks?",
|
||||
"Simulate a 20% increase in demand next week",
|
||||
],
|
||||
continuous_learning: [
|
||||
"How has our fill rate improved over time?",
|
||||
"What patterns has KROW learned about our operation?",
|
||||
],
|
||||
};
|
||||
|
||||
const TIPS = [
|
||||
{ title: "Be specific", text: "Mention names, dates, roles, or locations for sharper answers — \"Maria's shifts this week\" beats \"worker shifts\"." },
|
||||
{ title: "Ask \"why\"", text: "KROW traces a causal chain to root cause. Ask \"why\" to see the full reasoning, not just the answer." },
|
||||
{ title: "Trust the routing", text: "You don't need to pick an engine. KROW routes every question to the right one automatically." },
|
||||
{ title: "Follow up", text: "Use the related questions after each answer to dig deeper or explore adjacent topics." },
|
||||
];
|
||||
|
||||
export default function HowToUseKrow({ open, onClose }) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl max-h-[85dvh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="w-8 h-8 bg-amber-100 rounded-lg flex items-center justify-center">
|
||||
<Lightbulb className="w-4 h-4 text-amber-600" />
|
||||
</div>
|
||||
<DialogTitle className="text-lg font-black">How to Use KROW Command</DialogTitle>
|
||||
</div>
|
||||
<DialogDescription className="text-sm">
|
||||
KROW has 10 specialized intelligence engines. Ask any question in plain English — it routes automatically to the right engine and traces the causal chain to root cause.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Tips */}
|
||||
<div className="bg-gradient-to-br from-[#0039A6] to-[#1a56c4] rounded-xl p-4 text-white">
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<Sparkles className="w-3.5 h-3.5 text-amber-300" />
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-blue-200">Tips for Better Answers</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
|
||||
{TIPS.map((tip, i) => (
|
||||
<div key={i} className="flex gap-2">
|
||||
<span className="text-amber-300 font-black text-xs flex-shrink-0">{i + 1}.</span>
|
||||
<div>
|
||||
<p className="text-xs font-bold text-white">{tip.title}</p>
|
||||
<p className="text-[11px] text-blue-100 leading-snug mt-0.5">{tip.text}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Engine guide */}
|
||||
<div>
|
||||
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest mb-3">
|
||||
The 10 Engines — What Each One Knows
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{ENGINES.map(engine => {
|
||||
const examples = ENGINE_EXAMPLES[engine.key] || [];
|
||||
return (
|
||||
<div key={engine.key} className="border border-slate-200 rounded-xl p-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div
|
||||
className="w-7 h-7 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||
style={{ backgroundColor: engine.color + "20" }}
|
||||
>
|
||||
<engine.icon className="w-3.5 h-3.5" style={{ color: engine.color }} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-bold text-slate-900 leading-tight">{engine.name}</p>
|
||||
<p className="text-[10px] text-slate-500 leading-snug">{engine.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
{examples.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-2 pl-9">
|
||||
{examples.map((ex, i) => (
|
||||
<span key={i} className="text-[10px] text-slate-600 bg-slate-100 border border-slate-200 rounded-md px-2 py-0.5">
|
||||
"{ex}"
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-50 border border-slate-200 rounded-xl p-3 text-center">
|
||||
<p className="text-xs text-slate-600">
|
||||
<span className="font-bold">Just start asking.</span> Pick a question from the home page or type your own — KROW figures out the rest.
|
||||
</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
72
src/components/krowcommand/KrowCommandAsk.jsx
Normal file
72
src/components/krowcommand/KrowCommandAsk.jsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import React, { useState } from "react";
|
||||
import { Plus, Mic, ArrowUp, ChevronDown, BarChart3, Search, FileText } from "lucide-react";
|
||||
|
||||
const QUICK_ACTIONS = [
|
||||
{ icon: BarChart3, label: "Analyze my week" },
|
||||
{ icon: FileText, label: "Draft a briefing" },
|
||||
{ icon: Search, label: "Look something up" },
|
||||
];
|
||||
|
||||
export default function KrowCommandAsk({ onSubmit, onQuickAction }) {
|
||||
const [input, setInput] = useState("");
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e?.preventDefault();
|
||||
const text = input.trim();
|
||||
if (!text) return;
|
||||
onSubmit(text);
|
||||
setInput("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-[70vh] flex flex-col items-center justify-center bg-black px-4 -mx-6 -my-6">
|
||||
<div className="w-full max-w-2xl flex flex-col items-center">
|
||||
<h1 className="text-2xl md:text-3xl font-semibold text-white mb-8 text-center">
|
||||
Ask KROW
|
||||
</h1>
|
||||
|
||||
<form onSubmit={handleSubmit} className="w-full">
|
||||
<div className="flex items-center gap-2 bg-[#212121] rounded-full pl-4 pr-2 py-2 border border-white/10 focus-within:border-white/25 transition-colors">
|
||||
<Plus className="w-5 h-5 text-[#8E8E8E] flex-shrink-0" />
|
||||
<input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="Ask KROW"
|
||||
className="flex-1 bg-transparent text-white placeholder:text-[#8E8E8E] text-sm outline-none min-w-0"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 text-xs text-[#D1D1D1] hover:text-white px-2 py-1 rounded-full hover:bg-white/5 transition-colors"
|
||||
>
|
||||
Instant
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
</button>
|
||||
<button type="button" className="text-[#8E8E8E] hover:text-white p-1.5 transition-colors">
|
||||
<Mic className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!input.trim()}
|
||||
className="w-8 h-8 rounded-full bg-white text-black flex items-center justify-center disabled:opacity-30 disabled:cursor-not-allowed hover:bg-white/90 transition-colors flex-shrink-0"
|
||||
>
|
||||
<ArrowUp className="w-4 h-4" strokeWidth={2.5} />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 w-full flex flex-col items-start gap-1 pl-2">
|
||||
{QUICK_ACTIONS.map((action, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => onQuickAction(action.label)}
|
||||
className="flex items-center gap-3 px-2 py-2 text-sm text-[#D1D1D1] hover:text-white transition-colors"
|
||||
>
|
||||
<action.icon className="w-4 h-4" strokeWidth={1.5} />
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
199
src/components/marketplace/BookingConfirmModal.jsx
Normal file
199
src/components/marketplace/BookingConfirmModal.jsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import React, { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { CheckCircle2, MapPin, Clock, Users, DollarSign, Calendar, Briefcase, Loader2 } from "lucide-react";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { createPageUrl } from "@/utils";
|
||||
|
||||
const formatEndTime = (startTime, duration) => {
|
||||
if (!startTime || !duration) return startTime;
|
||||
const [h, m] = startTime.split(":").map(Number);
|
||||
const totalMin = h * 60 + m + parseFloat(duration) * 60;
|
||||
const endH = Math.floor(totalMin / 60) % 24;
|
||||
const endM = Math.round(totalMin % 60);
|
||||
return `${String(endH).padStart(2, "0")}:${String(endM).padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
export default function BookingConfirmModal({ open, vendor, search, user, business, onClose }) {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [booking, setBooking] = useState(false);
|
||||
|
||||
const hours = parseFloat(search.duration) || 0;
|
||||
const headcount = parseInt(search.headcount) || 0;
|
||||
const endTime = formatEndTime(search.startTime, search.duration);
|
||||
const rate = vendor?.matchingRate?.client_rate || vendor?.minRate || 0;
|
||||
const totalCost = rate * hours * headcount;
|
||||
|
||||
const bookingMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const eventName = `${headcount} ${search.role || "Staff"} — ${search.location || vendor.city || "TBD"}`;
|
||||
const shift = {
|
||||
shift_name: "Main Shift",
|
||||
roles: [{
|
||||
role: search.role || "Staff",
|
||||
count: headcount,
|
||||
start_time: search.startTime,
|
||||
end_time: endTime,
|
||||
hours: hours,
|
||||
cost_per_hour: rate,
|
||||
total_value: totalCost,
|
||||
vendor_id: vendor.id,
|
||||
vendor_name: vendor.legal_name
|
||||
}]
|
||||
};
|
||||
|
||||
const event = await base44.entities.Event.create({
|
||||
event_name: eventName,
|
||||
date: search.date || new Date().toISOString().split("T")[0],
|
||||
event_location: search.location || "",
|
||||
vendor_id: vendor.id,
|
||||
vendor_name: vendor.legal_name,
|
||||
business_id: business?.id || "",
|
||||
business_name: business?.business_name || user?.full_name || "",
|
||||
client_name: user?.full_name || "",
|
||||
client_email: user?.email || "",
|
||||
requested: headcount,
|
||||
status: "Pending",
|
||||
shifts: [shift],
|
||||
total: totalCost,
|
||||
contract_type: "Temp"
|
||||
});
|
||||
|
||||
await base44.entities.ActivityLog.create({
|
||||
action: "marketplace_booking",
|
||||
description: `Booked ${headcount} ${search.role || "staff"} from ${vendor.legal_name} for ${search.date} at ${search.location}`,
|
||||
user_id: user?.id,
|
||||
is_read: false,
|
||||
severity: "info"
|
||||
}).catch(() => {});
|
||||
|
||||
return event;
|
||||
},
|
||||
onMutate: () => setBooking(true),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["client-events"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["events"] });
|
||||
toast({
|
||||
title: "✅ Booking Confirmed!",
|
||||
description: `${vendor.legal_name} has been notified. Your request is pending confirmation.`,
|
||||
});
|
||||
onClose();
|
||||
navigate(createPageUrl("ClientOrders"));
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: "Booking failed",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
onSettled: () => setBooking(false),
|
||||
});
|
||||
|
||||
if (!vendor) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-bold text-slate-900 flex items-center gap-2">
|
||||
<CheckCircle2 className="w-5 h-5 text-green-500" />
|
||||
Confirm Booking
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Review your request before sending it to the vendor
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Vendor */}
|
||||
<div className="flex items-center gap-3 p-4 bg-gradient-to-r from-blue-50 to-slate-50 rounded-xl border border-slate-200">
|
||||
<Avatar className="w-12 h-12">
|
||||
{vendor.company_logo ? <AvatarImage src={vendor.company_logo} /> : null}
|
||||
<AvatarFallback className="bg-[#0039A6] text-white font-bold">
|
||||
{vendor.legal_name?.charAt(0)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<p className="font-bold text-slate-900">{vendor.legal_name}</p>
|
||||
<p className="text-xs text-slate-500">{vendor.service_specialty || "Staffing Provider"}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Booking details */}
|
||||
<div className="space-y-2">
|
||||
<DetailRow icon={Briefcase} label="Role" value={search.role || "Staff"} />
|
||||
<DetailRow icon={MapPin} label="Location" value={search.location || "TBD"} />
|
||||
<DetailRow icon={Calendar} label="Date" value={search.date || "TBD"} />
|
||||
<DetailRow icon={Clock} label="Time" value={`${search.startTime} — ${endTime} (${hours}h)`} />
|
||||
<DetailRow icon={Users} label="Headcount" value={`${headcount} staff`} />
|
||||
</div>
|
||||
|
||||
{/* Cost summary */}
|
||||
<div className="bg-slate-50 rounded-xl border border-slate-200 p-4 space-y-1.5">
|
||||
<div className="flex justify-between text-sm text-slate-600">
|
||||
<span>Rate</span>
|
||||
<span className="font-medium">${rate.toFixed(0)}/hr per person</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm text-slate-600">
|
||||
<span>Staff × Hours</span>
|
||||
<span className="font-medium">{headcount} × {hours}h = {(headcount * hours).toFixed(0)} hrs</span>
|
||||
</div>
|
||||
<div className="border-t border-slate-200 my-1.5" />
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-bold text-slate-900">Estimated Total</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<DollarSign className="w-5 h-5 text-[#0039A6]" />
|
||||
<span className="text-2xl font-bold text-[#0039A6]">
|
||||
{totalCost.toLocaleString(undefined, { maximumFractionDigits: 0 })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={booking}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => bookingMutation.mutate()}
|
||||
disabled={booking}
|
||||
className="bg-gradient-to-r from-[#0039A6] to-[#0047C4] text-white font-bold"
|
||||
>
|
||||
{booking ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Booking...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle2 className="w-4 h-4 mr-2" />
|
||||
Confirm & Book
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailRow({ icon: Icon, label, value }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-1.5">
|
||||
<span className="flex items-center gap-2 text-sm text-slate-500">
|
||||
<Icon className="w-4 h-4 text-slate-400" />
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-slate-800">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
139
src/components/marketplace/StaffingSearchBar.jsx
Normal file
139
src/components/marketplace/StaffingSearchBar.jsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import React from "react";
|
||||
import { Search, MapPin, Calendar, Clock, Users, Briefcase, Hourglass } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const COMMON_ROLES = [
|
||||
"Bartender", "Prep Cook", "Line Cook", "Server", "Dishwasher",
|
||||
"Host", "Sous Chef", "Executive Chef", "Pastry Chef", "Barista",
|
||||
"Food Runner", "Busser", "Event Staff", "Security Guard",
|
||||
"Janitor", "Facilities Technician", "Cashier", "Concessions"
|
||||
];
|
||||
|
||||
const COMMON_CITIES = [
|
||||
"San Francisco", "Oakland", "San Jose", "Los Angeles", "Sacramento",
|
||||
"Fremont", "Berkeley", "Palo Alto", "Napa", "Santa Clara"
|
||||
];
|
||||
|
||||
export default function StaffingSearchBar({ search, setSearch }) {
|
||||
const update = (field, value) => setSearch(prev => ({ ...prev, [field]: value }));
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-2xl shadow-xl border border-slate-200 p-2 hover:shadow-2xl transition-shadow">
|
||||
<div className="flex flex-col lg:flex-row items-stretch gap-1">
|
||||
{/* Role */}
|
||||
<div className="flex-1 flex items-center gap-3 px-4 py-2.5 rounded-xl hover:bg-slate-50 transition-colors">
|
||||
<Briefcase className="w-5 h-5 text-[#0039A6] flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<label className="text-[10px] font-bold text-slate-500 uppercase tracking-wide block">Role Needed</label>
|
||||
<input
|
||||
list="role-list"
|
||||
placeholder="e.g. Bartender"
|
||||
value={search.role}
|
||||
onChange={(e) => update("role", e.target.value)}
|
||||
className="w-full text-sm font-semibold text-slate-800 outline-none bg-transparent placeholder:text-slate-400"
|
||||
/>
|
||||
<datalist id="role-list">
|
||||
{COMMON_ROLES.map(r => <option key={r} value={r} />)}
|
||||
</datalist>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden lg:block w-px bg-slate-200 my-2" />
|
||||
|
||||
{/* Location */}
|
||||
<div className="flex-1 flex items-center gap-3 px-4 py-2.5 rounded-xl hover:bg-slate-50 transition-colors">
|
||||
<MapPin className="w-5 h-5 text-[#0039A6] flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<label className="text-[10px] font-bold text-slate-500 uppercase tracking-wide block">Location</label>
|
||||
<input
|
||||
list="city-list"
|
||||
placeholder="e.g. San Francisco"
|
||||
value={search.location}
|
||||
onChange={(e) => update("location", e.target.value)}
|
||||
className="w-full text-sm font-semibold text-slate-800 outline-none bg-transparent placeholder:text-slate-400"
|
||||
/>
|
||||
<datalist id="city-list">
|
||||
{COMMON_CITIES.map(c => <option key={c} value={c} />)}
|
||||
</datalist>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden lg:block w-px bg-slate-200 my-2" />
|
||||
|
||||
{/* Date */}
|
||||
<div className="flex items-center gap-3 px-4 py-2.5 rounded-xl hover:bg-slate-50 transition-colors">
|
||||
<Calendar className="w-5 h-5 text-[#0039A6] flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<label className="text-[10px] font-bold text-slate-500 uppercase tracking-wide block">Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={search.date}
|
||||
onChange={(e) => update("date", e.target.value)}
|
||||
className="text-sm font-semibold text-slate-800 outline-none bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden lg:block w-px bg-slate-200 my-2" />
|
||||
|
||||
{/* Start Time */}
|
||||
<div className="flex items-center gap-3 px-4 py-2.5 rounded-xl hover:bg-slate-50 transition-colors">
|
||||
<Clock className="w-5 h-5 text-[#0039A6] flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<label className="text-[10px] font-bold text-slate-500 uppercase tracking-wide block">Start Time</label>
|
||||
<input
|
||||
type="time"
|
||||
value={search.startTime}
|
||||
onChange={(e) => update("startTime", e.target.value)}
|
||||
className="text-sm font-semibold text-slate-800 outline-none bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden lg:block w-px bg-slate-200 my-2" />
|
||||
|
||||
{/* Duration */}
|
||||
<div className="flex items-center gap-3 px-4 py-2.5 rounded-xl hover:bg-slate-50 transition-colors w-28">
|
||||
<Hourglass className="w-5 h-5 text-[#0039A6] flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<label className="text-[10px] font-bold text-slate-500 uppercase tracking-wide block">Hours</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="24"
|
||||
value={search.duration}
|
||||
onChange={(e) => update("duration", e.target.value)}
|
||||
className="w-full text-sm font-semibold text-slate-800 outline-none bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden lg:block w-px bg-slate-200 my-2" />
|
||||
|
||||
{/* Headcount */}
|
||||
<div className="flex items-center gap-3 px-4 py-2.5 rounded-xl hover:bg-slate-50 transition-colors w-28">
|
||||
<Users className="w-5 h-5 text-[#0039A6] flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<label className="text-[10px] font-bold text-slate-500 uppercase tracking-wide block">Staff</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="500"
|
||||
value={search.headcount}
|
||||
onChange={(e) => update("headcount", e.target.value)}
|
||||
className="w-full text-sm font-semibold text-slate-800 outline-none bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search Button */}
|
||||
<Button
|
||||
className="bg-gradient-to-r from-[#0039A6] to-[#0047C4] text-white font-bold px-6 rounded-xl shadow-md hover:shadow-lg flex items-center gap-2"
|
||||
>
|
||||
<Search className="w-5 h-5" />
|
||||
<span className="hidden lg:inline">Search</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
141
src/components/marketplace/VendorListingCard.jsx
Normal file
141
src/components/marketplace/VendorListingCard.jsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import React from "react";
|
||||
import { Star, Users, MapPin, Shield, TrendingUp, CheckCircle2, Zap, DollarSign, Award } from "lucide-react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
|
||||
export default function VendorListingCard({ vendor, search, onBook }) {
|
||||
const hours = parseFloat(search.duration) || 0;
|
||||
const headcount = parseInt(search.headcount) || 0;
|
||||
const totalCost = (vendor.matchingRate?.client_rate || 0) * hours * headcount;
|
||||
const perHour = vendor.matchingRate?.client_rate || vendor.minRate || 0;
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden border border-slate-200 hover:border-[#0039A6] hover:shadow-xl transition-all duration-300 group">
|
||||
<div className="flex flex-col md:flex-row">
|
||||
{/* Left: Logo / Visual */}
|
||||
<div className="md:w-48 h-32 md:h-auto bg-gradient-to-br from-[#0039A6] to-[#3C4F5C] flex items-center justify-center relative flex-shrink-0">
|
||||
<Avatar className="w-20 h-20 ring-4 ring-white/20 shadow-xl">
|
||||
{vendor.company_logo ? (
|
||||
<AvatarImage src={vendor.company_logo} alt={vendor.legal_name} />
|
||||
) : null}
|
||||
<AvatarFallback className="bg-white/20 text-white text-2xl font-bold backdrop-blur-sm">
|
||||
{vendor.legal_name?.charAt(0)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
{vendor.grade && (
|
||||
<div className="absolute top-2 left-2 bg-white/90 backdrop-blur-sm rounded-lg px-2 py-0.5 shadow-sm">
|
||||
<span className="text-xs font-bold text-[#0039A6]">Grade {vendor.grade}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Middle: Info */}
|
||||
<CardContent className="flex-1 p-5">
|
||||
<div className="flex items-start justify-between gap-3 mb-2">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-slate-900 group-hover:text-[#0039A6] transition-colors">
|
||||
{vendor.legal_name}
|
||||
</h3>
|
||||
{vendor.doing_business_as && (
|
||||
<p className="text-xs text-slate-500 italic">DBA: {vendor.doing_business_as}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 bg-amber-50 px-3 py-1.5 rounded-full border border-amber-200">
|
||||
<Star className="w-4 h-4 text-amber-500 fill-amber-500" />
|
||||
<span className="font-bold text-slate-800 text-sm">{vendor.csat?.toFixed(1) || "—"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-sm text-slate-600 mb-3 line-clamp-2">
|
||||
{vendor.service_specialty && (
|
||||
<span className="font-medium">{vendor.service_specialty}</span>
|
||||
)}
|
||||
{vendor.service_specialty && vendor.notes ? " · " : ""}
|
||||
{vendor.notes && <span>{vendor.notes}</span>}
|
||||
{!vendor.service_specialty && !vendor.notes && (
|
||||
<span className="text-slate-400">Professional staffing provider</span>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* Quick stats */}
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs">
|
||||
<span className="flex items-center gap-1.5 text-slate-700">
|
||||
<MapPin className="w-3.5 h-3.5 text-slate-400" />
|
||||
{vendor.region || vendor.city || "Multiple"}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 text-slate-700">
|
||||
<Users className="w-3.5 h-3.5 text-slate-400" />
|
||||
{vendor.workforce_count || vendor.staffCount || 0} workforce
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 text-slate-700">
|
||||
<Shield className="w-3.5 h-3.5 text-green-500" />
|
||||
Verified & Approved
|
||||
</span>
|
||||
{vendor.fillRate != null && (
|
||||
<span className="flex items-center gap-1.5 text-slate-700">
|
||||
<CheckCircle2 className="w-3.5 h-3.5 text-teal-500" />
|
||||
{vendor.fillRate}% fill rate
|
||||
</span>
|
||||
)}
|
||||
{vendor.employeeRating != null && (
|
||||
<span className="flex items-center gap-1.5 text-slate-700">
|
||||
<Award className="w-3.5 h-3.5 text-indigo-500" />
|
||||
{vendor.employeeRating.toFixed(1)} employee rating
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Rate badge */}
|
||||
{vendor.matchingRate ? (
|
||||
<div className="mt-3 inline-flex items-center gap-2 bg-blue-50 border border-blue-200 rounded-lg px-3 py-1.5">
|
||||
<DollarSign className="w-3.5 h-3.5 text-[#0039A6]" />
|
||||
<span className="text-xs font-bold text-slate-800">
|
||||
${vendor.matchingRate.client_rate?.toFixed(0)}/hr
|
||||
</span>
|
||||
<span className="text-xs text-slate-500">for {vendor.matchingRate.role_name}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 inline-flex items-center gap-2 bg-slate-50 border border-slate-200 rounded-lg px-3 py-1.5">
|
||||
<span className="text-xs text-slate-500">
|
||||
From ${vendor.minRate?.toFixed(0) || "—"}/hr · request a custom quote
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{/* Right: Price + Book */}
|
||||
<div className="md:w-52 border-t md:border-t-0 md:border-l border-slate-100 p-5 flex flex-row md:flex-col items-center md:items-end justify-between gap-3 bg-slate-50/50">
|
||||
<div className="text-left md:text-right">
|
||||
{totalCost > 0 ? (
|
||||
<>
|
||||
<p className="text-[10px] text-slate-500 font-semibold uppercase tracking-wide">
|
||||
Total for {headcount} staff × {hours}h
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-slate-900">
|
||||
${totalCost.toLocaleString(undefined, { maximumFractionDigits: 0 })}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">${perHour.toFixed(0)}/hr per person</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-[10px] text-slate-500 font-semibold uppercase tracking-wide">Starting from</p>
|
||||
<p className="text-2xl font-bold text-slate-900">${vendor.minRate?.toFixed(0) || "—"}</p>
|
||||
<p className="text-xs text-slate-500">per hour</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => onBook(vendor)}
|
||||
className="bg-gradient-to-r from-[#0039A6] to-[#0047C4] text-white font-bold shadow-md hover:shadow-lg flex items-center gap-2"
|
||||
>
|
||||
<Zap className="w-4 h-4" />
|
||||
Book Now
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
108
src/components/messaging/ConversationList.jsx
Normal file
108
src/components/messaging/ConversationList.jsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import React from "react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { format } from "date-fns";
|
||||
import { MessageSquare, Users } from "lucide-react";
|
||||
|
||||
export default function ConversationList({ conversations, selectedId, onSelect }) {
|
||||
const getTypeColor = (type) => {
|
||||
const colors = {
|
||||
"client-vendor": "bg-purple-100 text-purple-700",
|
||||
"staff-client": "bg-blue-100 text-blue-700",
|
||||
"staff-admin": "bg-slate-100 text-slate-700",
|
||||
"vendor-admin": "bg-amber-100 text-amber-700",
|
||||
"client-admin": "bg-green-100 text-green-700",
|
||||
"group-staff": "bg-indigo-100 text-indigo-700",
|
||||
"group-event-staff": "bg-pink-100 text-pink-700"
|
||||
};
|
||||
return colors[type] || "bg-slate-100 text-slate-700";
|
||||
};
|
||||
|
||||
if (conversations.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<MessageSquare className="w-12 h-12 mx-auto text-slate-300 mb-3" />
|
||||
<p className="text-slate-500">No conversations yet</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{conversations.map((conversation) => {
|
||||
const isSelected = conversation.id === selectedId;
|
||||
const otherParticipant = conversation.participants?.[1] || conversation.participants?.[0] || {};
|
||||
const isGroup = conversation.is_group;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={conversation.id}
|
||||
className={`cursor-pointer transition-all hover:shadow-md ${
|
||||
isSelected ? 'border-[#0A39DF] border-2 bg-blue-50' : 'border-slate-200'
|
||||
}`}
|
||||
onClick={() => onSelect(conversation)}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
{isGroup ? (
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-purple-500 to-purple-700 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<Users className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
) : (
|
||||
<Avatar className="w-10 h-10">
|
||||
<AvatarFallback className="bg-[#0A39DF] text-white font-bold">
|
||||
{otherParticipant.name?.charAt(0) || '?'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between mb-1">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-semibold text-slate-900 truncate">
|
||||
{isGroup ? conversation.group_name : conversation.subject || otherParticipant.name || "Conversation"}
|
||||
</h4>
|
||||
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
||||
{isGroup && (
|
||||
<Badge variant="outline" className="text-xs bg-purple-50 text-purple-700 border-purple-200">
|
||||
<Users className="w-3 h-3 mr-1" />
|
||||
{conversation.participants?.length || 0} members
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="outline" className={`text-xs ${getTypeColor(conversation.conversation_type)}`}>
|
||||
{conversation.conversation_type?.replace('-', ' → ').replace('group-', '')}
|
||||
</Badge>
|
||||
{conversation.related_type && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{conversation.related_type}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{conversation.unread_count > 0 && (
|
||||
<Badge className="bg-red-500 text-white ml-2">
|
||||
{conversation.unread_count}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-slate-600 truncate mt-2">
|
||||
{conversation.last_message || "No messages yet"}
|
||||
</p>
|
||||
|
||||
{conversation.last_message_at && (
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
{format(new Date(conversation.last_message_at), "MMM d, h:mm a")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
70
src/components/messaging/MessageInput.jsx
Normal file
70
src/components/messaging/MessageInput.jsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Send, Paperclip, Loader2 } from "lucide-react";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
|
||||
export default function MessageInput({ conversationId, onMessageSent, currentUser }) {
|
||||
const [message, setMessage] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!message.trim() || sending) return;
|
||||
|
||||
setSending(true);
|
||||
try {
|
||||
await base44.entities.Message.create({
|
||||
conversation_id: conversationId,
|
||||
sender_id: currentUser.id,
|
||||
sender_name: currentUser.full_name || currentUser.email,
|
||||
sender_role: currentUser.role || "admin",
|
||||
content: message.trim(),
|
||||
read_by: [currentUser.id]
|
||||
});
|
||||
|
||||
await base44.entities.Conversation.update(conversationId, {
|
||||
last_message: message.trim().substring(0, 100),
|
||||
last_message_at: new Date().toISOString()
|
||||
});
|
||||
|
||||
setMessage("");
|
||||
onMessageSent?.();
|
||||
} catch (error) {
|
||||
console.error("Failed to send message:", error);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyPress = (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-t border-slate-200 p-4 bg-white">
|
||||
<div className="flex gap-2">
|
||||
<Textarea
|
||||
placeholder="Type your message..."
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
className="min-h-[60px] max-h-[120px]"
|
||||
disabled={sending}
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={handleSend}
|
||||
disabled={!message.trim() || sending}
|
||||
className="bg-[#0A39DF] hover:bg-[#0A39DF]/90"
|
||||
>
|
||||
{sending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
99
src/components/messaging/MessageThread.jsx
Normal file
99
src/components/messaging/MessageThread.jsx
Normal file
@@ -0,0 +1,99 @@
|
||||
|
||||
import React, { useRef, useEffect } from "react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { format } from "date-fns";
|
||||
import { FileText } from "lucide-react";
|
||||
|
||||
// Safe date formatter
|
||||
const safeFormatDate = (dateString, formatStr) => {
|
||||
if (!dateString) return "";
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
if (isNaN(date.getTime())) return "";
|
||||
return format(date, formatStr);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
export default function MessageThread({ messages, currentUserId }) {
|
||||
const messagesEndRef = useRef(null);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages]);
|
||||
|
||||
const getRoleColor = (role) => {
|
||||
const colors = {
|
||||
client: "bg-purple-100 text-purple-700",
|
||||
vendor: "bg-amber-100 text-amber-700",
|
||||
staff: "bg-blue-100 text-blue-700",
|
||||
admin: "bg-slate-100 text-slate-700"
|
||||
};
|
||||
return colors[role] || "bg-slate-100 text-slate-700";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-4 p-4 overflow-y-auto max-h-[500px]">
|
||||
{messages.map((message) => {
|
||||
const isOwnMessage = message.sender_id === currentUserId || message.created_by === currentUserId;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`flex ${isOwnMessage ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
<div className={`flex gap-3 max-w-[70%] ${isOwnMessage ? 'flex-row-reverse' : 'flex-row'}`}>
|
||||
<Avatar className="w-8 h-8 flex-shrink-0">
|
||||
<AvatarFallback className={`${getRoleColor(message.sender_role)} text-xs font-bold`}>
|
||||
{message.sender_name?.charAt(0) || '?'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className={`flex flex-col ${isOwnMessage ? 'items-end' : 'items-start'}`}>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-semibold text-slate-700">{message.sender_name}</span>
|
||||
<Badge variant="outline" className={`text-xs ${getRoleColor(message.sender_role)}`}>
|
||||
{message.sender_role}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<Card className={`p-3 ${isOwnMessage ? 'bg-[#0A39DF] text-white' : 'bg-white'}`}>
|
||||
<p className="text-sm">{message.content}</p>
|
||||
|
||||
{message.attachments && message.attachments.length > 0 && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{message.attachments.map((attachment, idx) => (
|
||||
<a
|
||||
key={idx}
|
||||
href={attachment.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-xs hover:underline"
|
||||
>
|
||||
<FileText className="w-3 h-3" />
|
||||
{attachment.name}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<span className="text-xs text-slate-500 mt-1">
|
||||
{safeFormatDate(message.created_date, "MMM d, h:mm a")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
247
src/components/notifications/NotificationEngine.jsx
Normal file
247
src/components/notifications/NotificationEngine.jsx
Normal file
@@ -0,0 +1,247 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
/**
|
||||
* Automated Notification Engine
|
||||
* Monitors events and triggers notifications based on configured preferences
|
||||
*/
|
||||
|
||||
export function NotificationEngine() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: events = [] } = useQuery({
|
||||
queryKey: ['events-notifications'],
|
||||
queryFn: () => base44.entities.Event.list(),
|
||||
refetchInterval: 60000, // Check every minute
|
||||
});
|
||||
|
||||
const { data: users = [] } = useQuery({
|
||||
queryKey: ['users-notifications'],
|
||||
queryFn: () => base44.entities.User.list(),
|
||||
refetchInterval: 300000, // Check every 5 minutes
|
||||
});
|
||||
|
||||
const createNotification = async (userId, title, description, activityType, relatedId = null) => {
|
||||
try {
|
||||
await base44.entities.ActivityLog.create({
|
||||
title,
|
||||
description,
|
||||
activity_type: activityType,
|
||||
user_id: userId,
|
||||
is_read: false,
|
||||
related_entity_id: relatedId,
|
||||
icon_type: activityType.includes('event') ? 'calendar' : activityType.includes('invoice') ? 'invoice' : 'user',
|
||||
icon_color: 'blue',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to create notification:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const sendEmail = async (to, subject, body, userPreferences) => {
|
||||
if (!userPreferences?.email_notifications) return;
|
||||
|
||||
try {
|
||||
await base44.integrations.Core.SendEmail({
|
||||
to,
|
||||
subject,
|
||||
body,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to send email:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Shift assignment notifications
|
||||
useEffect(() => {
|
||||
const notifyStaffAssignments = async () => {
|
||||
for (const event of events) {
|
||||
if (!event.assigned_staff || event.assigned_staff.length === 0) continue;
|
||||
|
||||
for (const staff of event.assigned_staff) {
|
||||
const user = users.find(u => u.email === staff.email);
|
||||
if (!user) continue;
|
||||
|
||||
const prefs = user.notification_preferences || {};
|
||||
if (!prefs.shift_assignments) continue;
|
||||
|
||||
// Check if notification already sent (within last 24h)
|
||||
const recentNotifs = await base44.entities.ActivityLog.filter({
|
||||
user_id: user.id,
|
||||
activity_type: 'staff_assigned',
|
||||
related_entity_id: event.id,
|
||||
});
|
||||
|
||||
const alreadyNotified = recentNotifs.some(n => {
|
||||
const notifDate = new Date(n.created_date);
|
||||
const hoursSince = (Date.now() - notifDate.getTime()) / (1000 * 60 * 60);
|
||||
return hoursSince < 24;
|
||||
});
|
||||
|
||||
if (alreadyNotified) continue;
|
||||
|
||||
await createNotification(
|
||||
user.id,
|
||||
'🎯 New Shift Assignment',
|
||||
`You've been assigned to ${event.event_name} on ${new Date(event.date).toLocaleDateString()}`,
|
||||
'staff_assigned',
|
||||
event.id
|
||||
);
|
||||
|
||||
await sendEmail(
|
||||
staff.email,
|
||||
`New Shift Assignment - ${event.event_name}`,
|
||||
`Hello ${staff.staff_name},\n\nYou've been assigned to work at ${event.event_name}.\n\nDate: ${new Date(event.date).toLocaleDateString()}\nLocation: ${event.event_location || event.hub}\n\nPlease confirm your availability in the KROW platform.\n\nThank you!`,
|
||||
prefs
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (events.length > 0 && users.length > 0) {
|
||||
notifyStaffAssignments();
|
||||
}
|
||||
}, [events, users]);
|
||||
|
||||
// Shift reminder (24 hours before)
|
||||
useEffect(() => {
|
||||
const sendShiftReminders = async () => {
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
tomorrow.setHours(0, 0, 0, 0);
|
||||
|
||||
const tomorrowEnd = new Date(tomorrow);
|
||||
tomorrowEnd.setHours(23, 59, 59, 999);
|
||||
|
||||
for (const event of events) {
|
||||
const eventDate = new Date(event.date);
|
||||
if (eventDate < tomorrow || eventDate > tomorrowEnd) continue;
|
||||
if (!event.assigned_staff || event.assigned_staff.length === 0) continue;
|
||||
|
||||
for (const staff of event.assigned_staff) {
|
||||
const user = users.find(u => u.email === staff.email);
|
||||
if (!user) continue;
|
||||
|
||||
const prefs = user.notification_preferences || {};
|
||||
if (!prefs.shift_reminders) continue;
|
||||
|
||||
await createNotification(
|
||||
user.id,
|
||||
'⏰ Shift Reminder',
|
||||
`Reminder: Your shift at ${event.event_name} is tomorrow`,
|
||||
'event_updated',
|
||||
event.id
|
||||
);
|
||||
|
||||
await sendEmail(
|
||||
staff.email,
|
||||
`Shift Reminder - Tomorrow at ${event.event_name}`,
|
||||
`Hello ${staff.staff_name},\n\nThis is a reminder that you have a shift tomorrow:\n\nEvent: ${event.event_name}\nDate: ${new Date(event.date).toLocaleDateString()}\nLocation: ${event.event_location || event.hub}\n\nSee you there!`,
|
||||
prefs
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (events.length > 0 && users.length > 0) {
|
||||
sendShiftReminders();
|
||||
}
|
||||
}, [events, users]);
|
||||
|
||||
// Client upcoming event notifications (3 days before)
|
||||
useEffect(() => {
|
||||
const notifyClientsUpcomingEvents = async () => {
|
||||
const threeDaysFromNow = new Date();
|
||||
threeDaysFromNow.setDate(threeDaysFromNow.getDate() + 3);
|
||||
threeDaysFromNow.setHours(0, 0, 0, 0);
|
||||
|
||||
const threeDaysEnd = new Date(threeDaysFromNow);
|
||||
threeDaysEnd.setHours(23, 59, 59, 999);
|
||||
|
||||
for (const event of events) {
|
||||
const eventDate = new Date(event.date);
|
||||
if (eventDate < threeDaysFromNow || eventDate > threeDaysEnd) continue;
|
||||
|
||||
const clientUser = users.find(u =>
|
||||
u.email === event.client_email ||
|
||||
(u.role === 'client' && u.full_name === event.client_name)
|
||||
);
|
||||
|
||||
if (!clientUser) continue;
|
||||
|
||||
const prefs = clientUser.notification_preferences || {};
|
||||
if (!prefs.upcoming_events) continue;
|
||||
|
||||
await createNotification(
|
||||
clientUser.id,
|
||||
'📅 Upcoming Event',
|
||||
`Your event "${event.event_name}" is in 3 days`,
|
||||
'event_created',
|
||||
event.id
|
||||
);
|
||||
|
||||
await sendEmail(
|
||||
clientUser.email,
|
||||
`Upcoming Event Reminder - ${event.event_name}`,
|
||||
`Hello,\n\nThis is a reminder that your event is coming up in 3 days:\n\nEvent: ${event.event_name}\nDate: ${new Date(event.date).toLocaleDateString()}\nLocation: ${event.event_location || event.hub}\nStaff Assigned: ${event.assigned_staff?.length || 0}/${event.requested || 0}\n\nIf you need to make any changes, please log into your KROW account.`,
|
||||
prefs
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (events.length > 0 && users.length > 0) {
|
||||
notifyClientsUpcomingEvents();
|
||||
}
|
||||
}, [events, users]);
|
||||
|
||||
// Vendor new lead notifications (new events without vendor assignment)
|
||||
useEffect(() => {
|
||||
const notifyVendorsNewLeads = async () => {
|
||||
const newEvents = events.filter(e =>
|
||||
e.status === 'Draft' || e.status === 'Pending'
|
||||
);
|
||||
|
||||
const vendorUsers = users.filter(u => u.role === 'vendor');
|
||||
|
||||
for (const event of newEvents) {
|
||||
for (const vendor of vendorUsers) {
|
||||
const prefs = vendor.notification_preferences || {};
|
||||
if (!prefs.new_leads) continue;
|
||||
|
||||
// Check if already notified
|
||||
const recentNotifs = await base44.entities.ActivityLog.filter({
|
||||
user_id: vendor.id,
|
||||
activity_type: 'event_created',
|
||||
related_entity_id: event.id,
|
||||
});
|
||||
|
||||
if (recentNotifs.length > 0) continue;
|
||||
|
||||
await createNotification(
|
||||
vendor.id,
|
||||
'🎯 New Lead Available',
|
||||
`New opportunity: ${event.event_name} needs ${event.requested || 0} staff`,
|
||||
'event_created',
|
||||
event.id
|
||||
);
|
||||
|
||||
await sendEmail(
|
||||
vendor.email,
|
||||
`New Staffing Opportunity - ${event.event_name}`,
|
||||
`Hello,\n\nA new staffing opportunity is available:\n\nEvent: ${event.event_name}\nDate: ${new Date(event.date).toLocaleDateString()}\nLocation: ${event.event_location || event.hub}\nStaff Needed: ${event.requested || 0}\n\nLog in to KROW to submit your proposal.`,
|
||||
prefs
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (events.length > 0 && users.length > 0) {
|
||||
notifyVendorsNewLeads();
|
||||
}
|
||||
}, [events, users]);
|
||||
|
||||
return null; // Background service
|
||||
}
|
||||
|
||||
export default NotificationEngine;
|
||||
622
src/components/notifications/NotificationPanel.jsx
Normal file
622
src/components/notifications/NotificationPanel.jsx
Normal file
@@ -0,0 +1,622 @@
|
||||
import React, { useState } from "react";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { createPageUrl } from "@/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
X,
|
||||
Bell,
|
||||
Calendar,
|
||||
UserPlus,
|
||||
FileText,
|
||||
MessageSquare,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
ArrowRight,
|
||||
MoreVertical,
|
||||
CheckSquare,
|
||||
Package
|
||||
} from "lucide-react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { formatDistanceToNow, format, isToday, isYesterday, isThisWeek, startOfDay } from "date-fns";
|
||||
|
||||
const iconMap = {
|
||||
calendar: Calendar,
|
||||
user: UserPlus,
|
||||
invoice: FileText,
|
||||
message: MessageSquare,
|
||||
alert: AlertCircle,
|
||||
check: CheckCircle,
|
||||
};
|
||||
|
||||
const colorMap = {
|
||||
blue: "bg-blue-100 text-blue-600",
|
||||
red: "bg-red-100 text-red-600",
|
||||
green: "bg-green-100 text-green-600",
|
||||
yellow: "bg-yellow-100 text-yellow-600",
|
||||
purple: "bg-purple-100 text-purple-600",
|
||||
};
|
||||
|
||||
export default function NotificationPanel({ isOpen, onClose }) {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [activeFilter, setActiveFilter] = useState('all');
|
||||
|
||||
const { data: user } = useQuery({
|
||||
queryKey: ['current-user-notifications'],
|
||||
queryFn: () => base44.auth.me(),
|
||||
});
|
||||
|
||||
const { data: notifications = [] } = useQuery({
|
||||
queryKey: ['activity-logs', user?.id],
|
||||
queryFn: async () => {
|
||||
if (!user?.id) return [];
|
||||
|
||||
// Create sample notifications if none exist
|
||||
const existing = await base44.entities.ActivityLog.filter({ user_id: user.id }, '-created_date', 50);
|
||||
|
||||
if (existing.length === 0 && user?.id) {
|
||||
// Create initial sample notifications
|
||||
await base44.entities.ActivityLog.bulkCreate([
|
||||
{
|
||||
title: "Event Rescheduled",
|
||||
description: "Team Meeting was moved to July 15, 3:00 PM",
|
||||
activity_type: "event_rescheduled",
|
||||
related_entity_type: "event",
|
||||
action_label: "View Event",
|
||||
icon_type: "calendar",
|
||||
icon_color: "blue",
|
||||
is_read: false,
|
||||
user_id: user.id
|
||||
},
|
||||
{
|
||||
title: "Event Canceled",
|
||||
description: "Product Demo scheduled for May 20 has been canceled",
|
||||
activity_type: "event_canceled",
|
||||
related_entity_type: "event",
|
||||
action_label: "View Event",
|
||||
icon_type: "calendar",
|
||||
icon_color: "red",
|
||||
is_read: false,
|
||||
user_id: user.id
|
||||
},
|
||||
{
|
||||
title: "Invoice Paid",
|
||||
description: "You've been added to Client Kickoff on June 8, 10:00 AM",
|
||||
activity_type: "invoice_paid",
|
||||
related_entity_type: "invoice",
|
||||
action_label: "View Invoice",
|
||||
icon_type: "invoice",
|
||||
icon_color: "green",
|
||||
is_read: false,
|
||||
user_id: user.id
|
||||
},
|
||||
{
|
||||
title: "Staff Selected",
|
||||
description: "10 staff members selected to fill remaining 10 slots",
|
||||
activity_type: "staff_assigned",
|
||||
related_entity_type: "event",
|
||||
icon_type: "user",
|
||||
icon_color: "purple",
|
||||
is_read: true,
|
||||
user_id: user.id
|
||||
}
|
||||
]);
|
||||
|
||||
return await base44.entities.ActivityLog.filter({ user_id: user.id }, '-created_date', 50);
|
||||
}
|
||||
|
||||
return existing;
|
||||
},
|
||||
enabled: !!user?.id,
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
const markAsReadMutation = useMutation({
|
||||
mutationFn: ({ id }) => base44.entities.ActivityLog.update(id, { is_read: true }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['activity-logs'] });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: ({ id }) => base44.entities.ActivityLog.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['activity-logs'] });
|
||||
},
|
||||
});
|
||||
|
||||
// Categorize by type
|
||||
const categorizeByType = (notif) => {
|
||||
const type = notif.activity_type || '';
|
||||
const title = (notif.title || '').toLowerCase();
|
||||
|
||||
if (type.includes('message') || title.includes('message') || title.includes('comment') || title.includes('mentioned')) {
|
||||
return 'mentions';
|
||||
} else if (type.includes('staff_assigned') || type.includes('user') || title.includes('invited') || title.includes('followed')) {
|
||||
return 'invites';
|
||||
} else {
|
||||
return 'all';
|
||||
}
|
||||
};
|
||||
|
||||
// Filter notifications based on active filter
|
||||
const filteredNotifications = notifications.filter(notif => {
|
||||
if (activeFilter === 'all') return true;
|
||||
return categorizeByType(notif) === activeFilter;
|
||||
});
|
||||
|
||||
// Group by day
|
||||
const groupByDay = (notifList) => {
|
||||
const groups = {
|
||||
today: [],
|
||||
yesterday: [],
|
||||
thisWeek: [],
|
||||
older: []
|
||||
};
|
||||
|
||||
notifList.forEach(notif => {
|
||||
const date = new Date(notif.created_date);
|
||||
if (isToday(date)) {
|
||||
groups.today.push(notif);
|
||||
} else if (isYesterday(date)) {
|
||||
groups.yesterday.push(notif);
|
||||
} else if (isThisWeek(date)) {
|
||||
groups.thisWeek.push(notif);
|
||||
} else {
|
||||
groups.older.push(notif);
|
||||
}
|
||||
});
|
||||
|
||||
return groups;
|
||||
};
|
||||
|
||||
const groupedNotifications = groupByDay(filteredNotifications);
|
||||
|
||||
// Count by type
|
||||
const allCount = notifications.length;
|
||||
const mentionsCount = notifications.filter(n => categorizeByType(n) === 'mentions').length;
|
||||
const invitesCount = notifications.filter(n => categorizeByType(n) === 'invites').length;
|
||||
|
||||
const handleAction = (notification) => {
|
||||
// Mark as read when clicking
|
||||
if (!notification.is_read) {
|
||||
markAsReadMutation.mutate({ id: notification.id });
|
||||
}
|
||||
|
||||
const entityType = notification.related_entity_type;
|
||||
const entityId = notification.related_entity_id;
|
||||
const activityType = notification.activity_type || '';
|
||||
|
||||
// Route based on entity type
|
||||
if (entityType === 'event' || activityType.includes('event') || activityType.includes('order')) {
|
||||
if (entityId) {
|
||||
navigate(createPageUrl(`EventDetail?id=${entityId}`));
|
||||
} else {
|
||||
navigate(createPageUrl('Events'));
|
||||
}
|
||||
} else if (entityType === 'task' || activityType.includes('task')) {
|
||||
navigate(createPageUrl('TaskBoard'));
|
||||
} else if (entityType === 'invoice' || activityType.includes('invoice')) {
|
||||
if (entityId) {
|
||||
navigate(createPageUrl(`Invoices?id=${entityId}`));
|
||||
} else {
|
||||
navigate(createPageUrl('Invoices'));
|
||||
}
|
||||
} else if (entityType === 'staff' || activityType.includes('staff')) {
|
||||
if (entityId) {
|
||||
navigate(createPageUrl(`EditStaff?id=${entityId}`));
|
||||
} else {
|
||||
navigate(createPageUrl('StaffDirectory'));
|
||||
}
|
||||
} else if (entityType === 'message' || activityType.includes('message')) {
|
||||
navigate(createPageUrl('Messages'));
|
||||
} else if (notification.action_link) {
|
||||
navigate(createPageUrl(notification.action_link));
|
||||
}
|
||||
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
className="fixed inset-0 bg-black/20 z-40"
|
||||
/>
|
||||
|
||||
{/* Panel */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 300 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 300 }}
|
||||
transition={{ type: "spring", damping: 25 }}
|
||||
className="fixed right-0 top-0 h-full w-full sm:w-[440px] bg-white shadow-2xl z-50 flex flex-col"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="border-b border-slate-200">
|
||||
<div className="flex items-center justify-between p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Bell className="w-6 h-6 text-[#1C323E]" />
|
||||
<h2 className="text-xl font-bold text-[#1C323E]">Notifications</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="icon" onClick={onClose}>
|
||||
<X className="w-5 h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter Tabs */}
|
||||
<div className="flex items-center gap-2 px-6 pb-4">
|
||||
<button
|
||||
onClick={() => setActiveFilter('all')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-semibold transition-all ${
|
||||
activeFilter === 'all'
|
||||
? 'bg-blue-50 text-blue-600'
|
||||
: 'text-slate-600 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
View all <span className="ml-1">{allCount}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveFilter('mentions')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-semibold transition-all ${
|
||||
activeFilter === 'mentions'
|
||||
? 'bg-blue-50 text-blue-600'
|
||||
: 'text-slate-600 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
Mentions <span className="ml-1">{mentionsCount}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveFilter('invites')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-semibold transition-all ${
|
||||
activeFilter === 'invites'
|
||||
? 'bg-blue-50 text-blue-600'
|
||||
: 'text-slate-600 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
Invites <span className="ml-1">{invitesCount}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notifications List */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{filteredNotifications.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center p-8">
|
||||
<Bell className="w-16 h-16 text-slate-300 mb-4" />
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-2">No notifications</h3>
|
||||
<p className="text-slate-600">You're all caught up!</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* TODAY */}
|
||||
{groupedNotifications.today.length > 0 && (
|
||||
<div className="px-6 py-4">
|
||||
<h3 className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-4">TODAY</h3>
|
||||
<div className="space-y-4">
|
||||
{groupedNotifications.today.map((notification) => {
|
||||
const Icon = iconMap[notification.icon_type] || AlertCircle;
|
||||
const isUnread = !notification.is_read;
|
||||
const notifDate = new Date(notification.created_date);
|
||||
|
||||
return (
|
||||
<div key={notification.id} className="flex gap-3 group hover:bg-slate-50 p-3 rounded-lg transition-all -mx-3 relative">
|
||||
{isUnread && (
|
||||
<div className="flex-shrink-0 w-2 h-2 bg-blue-600 rounded-full mt-2" />
|
||||
)}
|
||||
<div
|
||||
onClick={() => handleAction(notification)}
|
||||
className={`w-12 h-12 rounded-full flex items-center justify-center flex-shrink-0 cursor-pointer ${
|
||||
notification.icon_color === 'blue' ? 'bg-blue-100' :
|
||||
notification.icon_color === 'green' ? 'bg-green-100' :
|
||||
notification.icon_color === 'red' ? 'bg-red-100' :
|
||||
notification.icon_color === 'purple' ? 'bg-purple-100' :
|
||||
'bg-slate-100'
|
||||
}`}>
|
||||
<Icon className={`w-5 h-5 ${
|
||||
notification.icon_color === 'blue' ? 'text-blue-600' :
|
||||
notification.icon_color === 'green' ? 'text-green-600' :
|
||||
notification.icon_color === 'red' ? 'text-red-600' :
|
||||
notification.icon_color === 'purple' ? 'text-purple-600' :
|
||||
'text-slate-600'
|
||||
}`} />
|
||||
</div>
|
||||
<div
|
||||
className="flex-1 min-w-0 cursor-pointer"
|
||||
onClick={() => handleAction(notification)}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-1">
|
||||
<p className="text-sm text-slate-900">
|
||||
<span className="font-semibold">{notification.title}</span> {notification.description}
|
||||
</p>
|
||||
<span className="text-xs text-slate-500 whitespace-nowrap ml-3">
|
||||
{formatDistanceToNow(notifDate, { addSuffix: true })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
{notification.action_link && (
|
||||
<span className="font-medium text-blue-600">{notification.action_label}</span>
|
||||
)}
|
||||
<span className="text-slate-500">{format(notifDate, 'h:mm a')}</span>
|
||||
{isUnread && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
markAsReadMutation.mutate({ id: notification.id });
|
||||
}}
|
||||
className="text-blue-600 hover:text-blue-700 font-medium"
|
||||
>
|
||||
Mark as Read
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteMutation.mutate({ id: notification.id });
|
||||
}}
|
||||
className="text-red-600 hover:text-red-700 font-medium"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* YESTERDAY */}
|
||||
{groupedNotifications.yesterday.length > 0 && (
|
||||
<div className="px-6 py-4 border-t border-slate-100">
|
||||
<h3 className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-4">YESTERDAY</h3>
|
||||
<div className="space-y-4">
|
||||
{groupedNotifications.yesterday.map((notification) => {
|
||||
const Icon = iconMap[notification.icon_type] || AlertCircle;
|
||||
const isUnread = !notification.is_read;
|
||||
const notifDate = new Date(notification.created_date);
|
||||
|
||||
return (
|
||||
<div key={notification.id} className="flex gap-3 group hover:bg-slate-50 p-3 rounded-lg transition-all -mx-3 relative">
|
||||
{isUnread && (
|
||||
<div className="flex-shrink-0 w-2 h-2 bg-blue-600 rounded-full mt-2" />
|
||||
)}
|
||||
<div
|
||||
onClick={() => handleAction(notification)}
|
||||
className={`w-12 h-12 rounded-full flex items-center justify-center flex-shrink-0 cursor-pointer ${
|
||||
notification.icon_color === 'blue' ? 'bg-blue-100' :
|
||||
notification.icon_color === 'green' ? 'bg-green-100' :
|
||||
notification.icon_color === 'red' ? 'bg-red-100' :
|
||||
notification.icon_color === 'purple' ? 'bg-purple-100' :
|
||||
'bg-slate-100'
|
||||
}`}>
|
||||
<Icon className={`w-5 h-5 ${
|
||||
notification.icon_color === 'blue' ? 'text-blue-600' :
|
||||
notification.icon_color === 'green' ? 'text-green-600' :
|
||||
notification.icon_color === 'red' ? 'text-red-600' :
|
||||
notification.icon_color === 'purple' ? 'text-purple-600' :
|
||||
'text-slate-600'
|
||||
}`} />
|
||||
</div>
|
||||
<div
|
||||
className="flex-1 min-w-0"
|
||||
onClick={() => notification.action_link && handleAction(notification)}
|
||||
>
|
||||
<div className={`flex items-start justify-between mb-1 ${notification.action_link ? 'cursor-pointer' : ''}`}>
|
||||
<p className="text-sm text-slate-900">
|
||||
<span className="font-semibold">{notification.title}</span> {notification.description}
|
||||
</p>
|
||||
<span className="text-xs text-slate-500 whitespace-nowrap ml-3">
|
||||
{formatDistanceToNow(notifDate, { addSuffix: true })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
{notification.action_link && (
|
||||
<span className="font-medium text-blue-600">{notification.action_label}</span>
|
||||
)}
|
||||
<span className="text-slate-500">{format(notifDate, 'MM/dd/yy • h:mm a')}</span>
|
||||
{isUnread && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
markAsReadMutation.mutate({ id: notification.id });
|
||||
}}
|
||||
className="text-blue-600 hover:text-blue-700 font-medium"
|
||||
>
|
||||
Mark as Read
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteMutation.mutate({ id: notification.id });
|
||||
}}
|
||||
className="text-red-600 hover:text-red-700 font-medium"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* THIS WEEK */}
|
||||
{groupedNotifications.thisWeek.length > 0 && (
|
||||
<div className="px-6 py-4 border-t border-slate-100">
|
||||
<h3 className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-4">THIS WEEK</h3>
|
||||
<div className="space-y-4">
|
||||
{groupedNotifications.thisWeek.map((notification) => {
|
||||
const Icon = iconMap[notification.icon_type] || AlertCircle;
|
||||
const isUnread = !notification.is_read;
|
||||
const notifDate = new Date(notification.created_date);
|
||||
|
||||
return (
|
||||
<div key={notification.id} className="flex gap-3 group hover:bg-slate-50 p-3 rounded-lg transition-all -mx-3 opacity-80 hover:opacity-100">
|
||||
{isUnread && (
|
||||
<div className="flex-shrink-0 w-2 h-2 bg-blue-600 rounded-full mt-2" />
|
||||
)}
|
||||
<div
|
||||
onClick={() => handleAction(notification)}
|
||||
className={`w-12 h-12 rounded-full flex items-center justify-center flex-shrink-0 cursor-pointer ${
|
||||
notification.icon_color === 'blue' ? 'bg-blue-100' :
|
||||
notification.icon_color === 'green' ? 'bg-green-100' :
|
||||
notification.icon_color === 'red' ? 'bg-red-100' :
|
||||
notification.icon_color === 'purple' ? 'bg-purple-100' :
|
||||
'bg-slate-100'
|
||||
}`}>
|
||||
<Icon className={`w-5 h-5 ${
|
||||
notification.icon_color === 'blue' ? 'text-blue-600' :
|
||||
notification.icon_color === 'green' ? 'text-green-600' :
|
||||
notification.icon_color === 'red' ? 'text-red-600' :
|
||||
notification.icon_color === 'purple' ? 'text-purple-600' :
|
||||
'text-slate-600'
|
||||
}`} />
|
||||
</div>
|
||||
<div
|
||||
className="flex-1 min-w-0"
|
||||
onClick={() => notification.action_link && handleAction(notification)}
|
||||
>
|
||||
<div className={`flex items-start justify-between mb-1 ${notification.action_link ? 'cursor-pointer' : ''}`}>
|
||||
<p className="text-sm text-slate-900">
|
||||
<span className="font-semibold">{notification.title}</span> {notification.description}
|
||||
</p>
|
||||
<span className="text-xs text-slate-500 whitespace-nowrap ml-3">
|
||||
{formatDistanceToNow(notifDate, { addSuffix: true })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
{notification.action_link && (
|
||||
<span className="font-medium text-blue-600">{notification.action_label}</span>
|
||||
)}
|
||||
<span className="text-slate-500">{format(notifDate, 'MM/dd/yy • h:mm a')}</span>
|
||||
{isUnread && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
markAsReadMutation.mutate({ id: notification.id });
|
||||
}}
|
||||
className="text-blue-600 hover:text-blue-700 font-medium"
|
||||
>
|
||||
Mark as Read
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteMutation.mutate({ id: notification.id });
|
||||
}}
|
||||
className="text-red-600 hover:text-red-700 font-medium"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OLDER */}
|
||||
{groupedNotifications.older.length > 0 && (
|
||||
<div className="px-6 py-4 border-t border-slate-100">
|
||||
<h3 className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-4">OLDER</h3>
|
||||
<div className="space-y-4">
|
||||
{groupedNotifications.older.map((notification) => {
|
||||
const Icon = iconMap[notification.icon_type] || AlertCircle;
|
||||
const isUnread = !notification.is_read;
|
||||
const notifDate = new Date(notification.created_date);
|
||||
|
||||
return (
|
||||
<div key={notification.id} className="flex gap-3 group hover:bg-slate-50 p-3 rounded-lg transition-all -mx-3 opacity-70 hover:opacity-100">
|
||||
{isUnread && (
|
||||
<div className="flex-shrink-0 w-2 h-2 bg-blue-600 rounded-full mt-2" />
|
||||
)}
|
||||
<div
|
||||
onClick={() => handleAction(notification)}
|
||||
className={`w-12 h-12 rounded-full flex items-center justify-center flex-shrink-0 cursor-pointer ${
|
||||
notification.icon_color === 'blue' ? 'bg-blue-100' :
|
||||
notification.icon_color === 'green' ? 'bg-green-100' :
|
||||
notification.icon_color === 'red' ? 'bg-red-100' :
|
||||
notification.icon_color === 'purple' ? 'bg-purple-100' :
|
||||
'bg-slate-100'
|
||||
}`}>
|
||||
<Icon className={`w-5 h-5 ${
|
||||
notification.icon_color === 'blue' ? 'text-blue-600' :
|
||||
notification.icon_color === 'green' ? 'text-green-600' :
|
||||
notification.icon_color === 'red' ? 'text-red-600' :
|
||||
notification.icon_color === 'purple' ? 'text-purple-600' :
|
||||
'text-slate-600'
|
||||
}`} />
|
||||
</div>
|
||||
<div
|
||||
className="flex-1 min-w-0"
|
||||
onClick={() => notification.action_link && handleAction(notification)}
|
||||
>
|
||||
<div className={`flex items-start justify-between mb-1 ${notification.action_link ? 'cursor-pointer' : ''}`}>
|
||||
<p className="text-sm text-slate-900">
|
||||
<span className="font-semibold">{notification.title}</span> {notification.description}
|
||||
</p>
|
||||
<span className="text-xs text-slate-500 whitespace-nowrap ml-3">
|
||||
{formatDistanceToNow(notifDate, { addSuffix: true })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
{notification.action_link && (
|
||||
<span className="font-medium text-blue-600">{notification.action_label}</span>
|
||||
)}
|
||||
<span className="text-slate-500">{format(notifDate, 'MM/dd/yy • h:mm a')}</span>
|
||||
{isUnread && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
markAsReadMutation.mutate({ id: notification.id });
|
||||
}}
|
||||
className="text-blue-600 hover:text-blue-700 font-medium"
|
||||
>
|
||||
Mark as Read
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteMutation.mutate({ id: notification.id });
|
||||
}}
|
||||
className="text-red-600 hover:text-red-700 font-medium"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
141
src/components/onboarding/CompletionStep.jsx
Normal file
141
src/components/onboarding/CompletionStep.jsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { CheckCircle, User, FileText, BookOpen, Sparkles } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
export default function CompletionStep({ data, onComplete, onBack, isSubmitting }) {
|
||||
const { profile, documents, training } = data;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Sparkles className="w-8 h-8 text-green-600" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-slate-900 mb-2">You're All Set! 🎉</h2>
|
||||
<p className="text-slate-500">Review your information before completing onboarding</p>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="space-y-4">
|
||||
{/* Profile Summary */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<User className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-slate-900 mb-2">Profile Information</h3>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div>
|
||||
<p className="text-slate-500">Name</p>
|
||||
<p className="font-medium">{profile.full_name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-slate-500">Email</p>
|
||||
<p className="font-medium">{profile.email}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-slate-500">Position</p>
|
||||
<p className="font-medium">{profile.position}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-slate-500">Location</p>
|
||||
<p className="font-medium">{profile.city}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Documents Summary */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 bg-purple-100 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<FileText className="w-5 h-5 text-purple-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-slate-900 mb-2">Documents Uploaded</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{documents.map((doc, idx) => (
|
||||
<Badge key={idx} variant="outline" className="text-xs">
|
||||
{doc.name}
|
||||
</Badge>
|
||||
))}
|
||||
{documents.length === 0 && (
|
||||
<p className="text-sm text-slate-500">No documents uploaded</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Training Summary */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 bg-amber-100 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<BookOpen className="w-5 h-5 text-amber-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-slate-900 mb-2">Training Completed</h3>
|
||||
<p className="text-sm text-slate-600">
|
||||
{training.completed.length} training modules completed
|
||||
</p>
|
||||
{training.acknowledged && (
|
||||
<Badge className="mt-2 bg-green-500">Compliance Acknowledged</Badge>
|
||||
)}
|
||||
</div>
|
||||
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Next Steps */}
|
||||
<Card className="bg-gradient-to-r from-blue-50 to-purple-50 border-blue-200">
|
||||
<CardContent className="p-4">
|
||||
<h3 className="font-semibold text-slate-900 mb-2">What Happens Next?</h3>
|
||||
<ul className="space-y-2 text-sm text-slate-600">
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-green-600 flex-shrink-0 mt-0.5" />
|
||||
<span>Your profile will be activated and available for shift assignments</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-green-600 flex-shrink-0 mt-0.5" />
|
||||
<span>You'll receive an email confirmation with your login credentials</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-green-600 flex-shrink-0 mt-0.5" />
|
||||
<span>Our team will review your documents within 24-48 hours</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-green-600 flex-shrink-0 mt-0.5" />
|
||||
<span>You can start accepting shift invitations immediately</span>
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-between pt-4">
|
||||
<Button variant="outline" onClick={onBack} disabled={isSubmitting}>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onComplete}
|
||||
disabled={isSubmitting}
|
||||
className="bg-gradient-to-r from-[#0A39DF] to-blue-600"
|
||||
>
|
||||
{isSubmitting ? "Creating Profile..." : "Complete Onboarding"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
159
src/components/onboarding/DocumentUploadStep.jsx
Normal file
159
src/components/onboarding/DocumentUploadStep.jsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Upload, FileText, CheckCircle, X } from "lucide-react";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
|
||||
const requiredDocuments = [
|
||||
{ id: 'id', name: 'Government ID', required: true, description: 'Driver\'s license or passport' },
|
||||
{ id: 'certification', name: 'Certifications', required: false, description: 'Food handler, TIPS, etc.' },
|
||||
{ id: 'background_check', name: 'Background Check', required: false, description: 'If available' },
|
||||
];
|
||||
|
||||
export default function DocumentUploadStep({ data, onNext, onBack }) {
|
||||
const [documents, setDocuments] = useState(data || []);
|
||||
const [uploading, setUploading] = useState({});
|
||||
const { toast } = useToast();
|
||||
|
||||
const handleFileUpload = async (docType, file) => {
|
||||
if (!file) return;
|
||||
|
||||
setUploading(prev => ({ ...prev, [docType]: true }));
|
||||
|
||||
try {
|
||||
const { file_url } = await base44.integrations.Core.UploadFile({ file });
|
||||
|
||||
const newDoc = {
|
||||
type: docType,
|
||||
name: file.name,
|
||||
url: file_url,
|
||||
uploaded_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setDocuments(prev => {
|
||||
const filtered = prev.filter(d => d.type !== docType);
|
||||
return [...filtered, newDoc];
|
||||
});
|
||||
|
||||
toast({
|
||||
title: "✅ Document Uploaded",
|
||||
description: `${file.name} uploaded successfully`,
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "❌ Upload Failed",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setUploading(prev => ({ ...prev, [docType]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveDocument = (docType) => {
|
||||
setDocuments(prev => prev.filter(d => d.type !== docType));
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
const hasRequiredDocs = requiredDocuments
|
||||
.filter(doc => doc.required)
|
||||
.every(doc => documents.some(d => d.type === doc.id));
|
||||
|
||||
if (!hasRequiredDocs) {
|
||||
toast({
|
||||
title: "⚠️ Missing Required Documents",
|
||||
description: "Please upload all required documents before continuing",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
onNext({ type: 'documents', data: documents });
|
||||
};
|
||||
|
||||
const getUploadedDoc = (docType) => documents.find(d => d.type === docType);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-slate-900 mb-2">Document Upload</h2>
|
||||
<p className="text-sm text-slate-500">Upload required documents for compliance</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{requiredDocuments.map(doc => {
|
||||
const uploadedDoc = getUploadedDoc(doc.id);
|
||||
const isUploading = uploading[doc.id];
|
||||
|
||||
return (
|
||||
<Card key={doc.id} className={uploadedDoc ? "border-green-500 bg-green-50" : ""}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Label className="font-semibold">
|
||||
{doc.name}
|
||||
{doc.required && <span className="text-red-500 ml-1">*</span>}
|
||||
</Label>
|
||||
{uploadedDoc && (
|
||||
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-slate-500">{doc.description}</p>
|
||||
|
||||
{uploadedDoc && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<FileText className="w-4 h-4 text-slate-500" />
|
||||
<span className="text-sm text-slate-700">{uploadedDoc.name}</span>
|
||||
<button
|
||||
onClick={() => handleRemoveDocument(doc.id)}
|
||||
className="ml-2 text-red-500 hover:text-red-700"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor={`upload-${doc.id}`}
|
||||
className={`cursor-pointer inline-flex items-center px-4 py-2 rounded-lg ${
|
||||
uploadedDoc
|
||||
? "bg-green-100 text-green-700 hover:bg-green-200"
|
||||
: "bg-blue-100 text-blue-700 hover:bg-blue-200"
|
||||
} transition-colors`}
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
{isUploading ? "Uploading..." : uploadedDoc ? "Replace" : "Upload"}
|
||||
</label>
|
||||
<input
|
||||
id={`upload-${doc.id}`}
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept=".pdf,.jpg,.jpeg,.png"
|
||||
onChange={(e) => handleFileUpload(doc.id, e.target.files[0])}
|
||||
disabled={isUploading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between pt-4">
|
||||
<Button variant="outline" onClick={onBack}>
|
||||
Back
|
||||
</Button>
|
||||
<Button onClick={handleNext} className="bg-[#0A39DF]">
|
||||
Continue to Training
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
193
src/components/onboarding/ProfileSetupStep.jsx
Normal file
193
src/components/onboarding/ProfileSetupStep.jsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import React, { useState } from "react";
|
||||
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 { User, Briefcase, MapPin } from "lucide-react";
|
||||
|
||||
export default function ProfileSetupStep({ data, onNext, currentUser }) {
|
||||
const [profile, setProfile] = useState({
|
||||
full_name: data.full_name || currentUser?.full_name || "",
|
||||
email: data.email || currentUser?.email || "",
|
||||
phone: data.phone || "",
|
||||
address: data.address || "",
|
||||
city: data.city || "",
|
||||
position: data.position || "",
|
||||
department: data.department || "",
|
||||
hub_location: data.hub_location || "",
|
||||
employment_type: data.employment_type || "Full Time",
|
||||
english_level: data.english_level || "Fluent",
|
||||
});
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
onNext({ type: 'profile', data: profile });
|
||||
};
|
||||
|
||||
const handleChange = (field, value) => {
|
||||
setProfile(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-slate-900 mb-2">Profile Setup</h2>
|
||||
<p className="text-sm text-slate-500">Tell us about yourself</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Personal Information */}
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-slate-700 mb-3">
|
||||
<User className="w-4 h-4" />
|
||||
<span>Personal Information</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="full_name">Full Name *</Label>
|
||||
<Input
|
||||
id="full_name"
|
||||
value={profile.full_name}
|
||||
onChange={(e) => handleChange('full_name', e.target.value)}
|
||||
required
|
||||
placeholder="John Doe"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="email">Email Address *</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={profile.email}
|
||||
onChange={(e) => handleChange('email', e.target.value)}
|
||||
required
|
||||
placeholder="john@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="phone">Phone Number *</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
type="tel"
|
||||
value={profile.phone}
|
||||
onChange={(e) => handleChange('phone', e.target.value)}
|
||||
required
|
||||
placeholder="(555) 123-4567"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="city">City *</Label>
|
||||
<Input
|
||||
id="city"
|
||||
value={profile.city}
|
||||
onChange={(e) => handleChange('city', e.target.value)}
|
||||
required
|
||||
placeholder="San Francisco"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<Label htmlFor="address">Street Address</Label>
|
||||
<Input
|
||||
id="address"
|
||||
value={profile.address}
|
||||
onChange={(e) => handleChange('address', e.target.value)}
|
||||
placeholder="123 Main St"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Employment Details */}
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-slate-700 mb-3 mt-6">
|
||||
<Briefcase className="w-4 h-4" />
|
||||
<span>Employment Details</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="position">Position/Role *</Label>
|
||||
<Input
|
||||
id="position"
|
||||
value={profile.position}
|
||||
onChange={(e) => handleChange('position', e.target.value)}
|
||||
required
|
||||
placeholder="e.g., Server, Chef, Bartender"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="department">Department</Label>
|
||||
<Select value={profile.department} onValueChange={(value) => handleChange('department', value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select department" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Operations">Operations</SelectItem>
|
||||
<SelectItem value="Kitchen">Kitchen</SelectItem>
|
||||
<SelectItem value="Service">Service</SelectItem>
|
||||
<SelectItem value="Bar">Bar</SelectItem>
|
||||
<SelectItem value="Events">Events</SelectItem>
|
||||
<SelectItem value="Catering">Catering</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="employment_type">Employment Type *</Label>
|
||||
<Select value={profile.employment_type} onValueChange={(value) => handleChange('employment_type', value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Full Time">Full Time</SelectItem>
|
||||
<SelectItem value="Part Time">Part Time</SelectItem>
|
||||
<SelectItem value="On call">On Call</SelectItem>
|
||||
<SelectItem value="Seasonal">Seasonal</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="english_level">English Proficiency</Label>
|
||||
<Select value={profile.english_level} onValueChange={(value) => handleChange('english_level', value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Fluent">Fluent</SelectItem>
|
||||
<SelectItem value="Intermediate">Intermediate</SelectItem>
|
||||
<SelectItem value="Basic">Basic</SelectItem>
|
||||
<SelectItem value="None">None</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Location */}
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-slate-700 mb-3 mt-6">
|
||||
<MapPin className="w-4 h-4" />
|
||||
<span>Work Location</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="hub_location">Preferred Hub/Location</Label>
|
||||
<Input
|
||||
id="hub_location"
|
||||
value={profile.hub_location}
|
||||
onChange={(e) => handleChange('hub_location', e.target.value)}
|
||||
placeholder="e.g., Downtown SF, Bay Area"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-4">
|
||||
<Button type="submit" className="bg-[#0A39DF]">
|
||||
Continue to Documents
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
173
src/components/onboarding/TrainingStep.jsx
Normal file
173
src/components/onboarding/TrainingStep.jsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { CheckCircle, Circle, Play, BookOpen } from "lucide-react";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
|
||||
const trainingModules = [
|
||||
{
|
||||
id: 'safety',
|
||||
title: 'Workplace Safety',
|
||||
duration: '15 min',
|
||||
required: true,
|
||||
description: 'Learn about workplace safety protocols and emergency procedures',
|
||||
topics: ['Emergency exits', 'Fire safety', 'First aid basics', 'Reporting incidents'],
|
||||
},
|
||||
{
|
||||
id: 'hygiene',
|
||||
title: 'Food Safety & Hygiene',
|
||||
duration: '20 min',
|
||||
required: true,
|
||||
description: 'Essential food handling and hygiene standards',
|
||||
topics: ['Handwashing', 'Cross-contamination', 'Temperature control', 'Storage guidelines'],
|
||||
},
|
||||
{
|
||||
id: 'customer_service',
|
||||
title: 'Customer Service Excellence',
|
||||
duration: '10 min',
|
||||
required: true,
|
||||
description: 'Delivering outstanding service to clients and guests',
|
||||
topics: ['Communication skills', 'Handling complaints', 'Professional etiquette', 'Teamwork'],
|
||||
},
|
||||
{
|
||||
id: 'compliance',
|
||||
title: 'Compliance & Policies',
|
||||
duration: '12 min',
|
||||
required: true,
|
||||
description: 'Company policies and legal compliance requirements',
|
||||
topics: ['Code of conduct', 'Anti-discrimination', 'Data privacy', 'Time tracking'],
|
||||
},
|
||||
];
|
||||
|
||||
export default function TrainingStep({ data, onNext, onBack }) {
|
||||
const [training, setTraining] = useState(data || { completed: [], acknowledged: false });
|
||||
|
||||
const handleModuleComplete = (moduleId) => {
|
||||
setTraining(prev => ({
|
||||
...prev,
|
||||
completed: prev.completed.includes(moduleId)
|
||||
? prev.completed.filter(id => id !== moduleId)
|
||||
: [...prev.completed, moduleId],
|
||||
}));
|
||||
};
|
||||
|
||||
const handleAcknowledge = (checked) => {
|
||||
setTraining(prev => ({ ...prev, acknowledged: checked }));
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
const allRequired = trainingModules
|
||||
.filter(m => m.required)
|
||||
.every(m => training.completed.includes(m.id));
|
||||
|
||||
if (!allRequired || !training.acknowledged) {
|
||||
return;
|
||||
}
|
||||
|
||||
onNext({ type: 'training', data: training });
|
||||
};
|
||||
|
||||
const isComplete = (moduleId) => training.completed.includes(moduleId);
|
||||
const allRequiredComplete = trainingModules
|
||||
.filter(m => m.required)
|
||||
.every(m => training.completed.includes(m.id));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-slate-900 mb-2">Compliance Training</h2>
|
||||
<p className="text-sm text-slate-500">Complete required training modules to ensure readiness</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{trainingModules.map(module => (
|
||||
<Card
|
||||
key={module.id}
|
||||
className={isComplete(module.id) ? "border-green-500 bg-green-50" : ""}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
{isComplete(module.id) ? (
|
||||
<CheckCircle className="w-6 h-6 text-green-600" />
|
||||
) : (
|
||||
<Circle className="w-6 h-6 text-slate-400" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-900">
|
||||
{module.title}
|
||||
{module.required && <span className="text-red-500 ml-1">*</span>}
|
||||
</h3>
|
||||
<p className="text-sm text-slate-500">{module.duration} · {module.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="text-sm text-slate-600 mb-3 ml-4 space-y-1">
|
||||
{module.topics.map((topic, idx) => (
|
||||
<li key={idx} className="list-disc">{topic}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant={isComplete(module.id) ? "outline" : "default"}
|
||||
onClick={() => handleModuleComplete(module.id)}
|
||||
className={isComplete(module.id) ? "" : "bg-[#0A39DF]"}
|
||||
>
|
||||
{isComplete(module.id) ? (
|
||||
<>
|
||||
<CheckCircle className="w-4 h-4 mr-2" />
|
||||
Completed
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
Start Training
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{allRequiredComplete && (
|
||||
<Card className="bg-blue-50 border-blue-200">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
id="acknowledge"
|
||||
checked={training.acknowledged}
|
||||
onCheckedChange={handleAcknowledge}
|
||||
/>
|
||||
<label htmlFor="acknowledge" className="text-sm text-slate-700 cursor-pointer">
|
||||
I acknowledge that I have completed the required training modules and understand the
|
||||
policies, procedures, and safety guidelines outlined above. I agree to follow all
|
||||
company policies and maintain compliance standards.
|
||||
</label>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between pt-4">
|
||||
<Button variant="outline" onClick={onBack}>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
disabled={!allRequiredComplete || !training.acknowledged}
|
||||
className="bg-[#0A39DF]"
|
||||
>
|
||||
Complete Onboarding
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
161
src/components/orders/CancellationFeeModal.jsx
Normal file
161
src/components/orders/CancellationFeeModal.jsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { AlertTriangle, Clock, DollarSign, Calendar, Users } from "lucide-react";
|
||||
import { format, differenceInHours } from "date-fns";
|
||||
|
||||
// Calculate if cancellation fee applies
|
||||
export const calculateCancellationFee = (eventDate, eventStartTime, assignedCount) => {
|
||||
const now = new Date();
|
||||
|
||||
// Combine event date and start time
|
||||
const eventDateTime = new Date(`${eventDate}T${eventStartTime || '00:00'}`);
|
||||
const hoursUntilEvent = differenceInHours(eventDateTime, now);
|
||||
|
||||
// Rule: 24+ hours = no fee, < 24 hours = 4-hour fee per worker
|
||||
const feeApplies = hoursUntilEvent < 24;
|
||||
const feeAmount = feeApplies ? assignedCount * 4 * 50 : 0; // Assuming $50/hour average
|
||||
|
||||
return {
|
||||
feeApplies,
|
||||
hoursUntilEvent,
|
||||
feeAmount,
|
||||
assignedCount
|
||||
};
|
||||
};
|
||||
|
||||
export default function CancellationFeeModal({
|
||||
open,
|
||||
onClose,
|
||||
onConfirm,
|
||||
event,
|
||||
isSubmitting
|
||||
}) {
|
||||
if (!event) return null;
|
||||
|
||||
const eventStartTime = event.shifts?.[0]?.roles?.[0]?.start_time || '09:00';
|
||||
const assignedCount = event.assigned_staff?.length || 0;
|
||||
const feeData = calculateCancellationFee(event.date, eventStartTime, assignedCount);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-xl">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-12 h-12 bg-red-100 rounded-xl flex items-center justify-center">
|
||||
<AlertTriangle className="w-6 h-6 text-red-600" />
|
||||
</div>
|
||||
<div>
|
||||
<DialogTitle className="text-2xl font-bold text-red-700">
|
||||
Confirm Order Cancellation
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-600 mt-1">
|
||||
{feeData.feeApplies
|
||||
? "⚠️ Cancellation fee will apply"
|
||||
: "✅ No cancellation fee"
|
||||
}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* Event Summary */}
|
||||
<div className="bg-slate-50 border border-slate-200 rounded-xl p-4">
|
||||
<h4 className="font-bold text-slate-900 mb-3">{event.event_name}</h4>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4 text-blue-600" />
|
||||
<span>{format(new Date(event.date), 'MMM d, yyyy')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4 text-blue-600" />
|
||||
<span>{eventStartTime}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="w-4 h-4 text-blue-600" />
|
||||
<span>{assignedCount} Staff Assigned</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time Until Event */}
|
||||
<Alert className={feeData.feeApplies ? "bg-red-50 border-red-300" : "bg-green-50 border-green-300"}>
|
||||
<AlertDescription>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Clock className={`w-5 h-5 ${feeData.feeApplies ? 'text-red-600' : 'text-green-600'}`} />
|
||||
<span className="font-bold text-slate-900">
|
||||
{feeData.hoursUntilEvent} hours until event
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-slate-700">
|
||||
{feeData.feeApplies
|
||||
? "Canceling within 24 hours triggers a 4-hour minimum fee per assigned worker."
|
||||
: "You're canceling more than 24 hours in advance - no penalty applies."
|
||||
}
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{/* Fee Breakdown */}
|
||||
{feeData.feeApplies && (
|
||||
<div className="bg-gradient-to-r from-red-50 to-orange-50 border-2 border-red-300 rounded-xl p-5">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<DollarSign className="w-5 h-5 text-red-600" />
|
||||
<h4 className="font-bold text-red-900">Cancellation Fee Breakdown</h4>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between p-3 bg-white rounded-lg">
|
||||
<span className="text-sm text-slate-700">Assigned Staff</span>
|
||||
<span className="font-bold text-slate-900">{assignedCount} workers</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 bg-white rounded-lg">
|
||||
<span className="text-sm text-slate-700">Minimum Charge</span>
|
||||
<span className="font-bold text-slate-900">4 hours each</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-4 bg-red-100 rounded-lg border-2 border-red-300">
|
||||
<span className="font-bold text-red-900">Total Cancellation Fee</span>
|
||||
<span className="text-2xl font-bold text-red-700">
|
||||
${feeData.feeAmount.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warning Text */}
|
||||
<Alert className="bg-yellow-50 border-yellow-300">
|
||||
<AlertDescription className="text-sm text-yellow-900">
|
||||
<strong>⚠️ This action cannot be undone.</strong> The vendor will be notified immediately,
|
||||
and all assigned staff will be released from this event.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Keep Order
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={onConfirm}
|
||||
disabled={isSubmitting}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
{isSubmitting ? "Canceling..." : `Confirm Cancellation${feeData.feeApplies ? ` ($${feeData.feeAmount})` : ''}`}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
362
src/components/orders/OrderDetailModal.jsx
Normal file
362
src/components/orders/OrderDetailModal.jsx
Normal file
@@ -0,0 +1,362 @@
|
||||
import React from "react";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Calendar, MapPin, Users, DollarSign, Clock, Building2, FileText, X, Star, ExternalLink, Edit3, User } from "lucide-react";
|
||||
import { format, parseISO, isValid } from "date-fns";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { createPageUrl } from "@/utils";
|
||||
|
||||
const safeParseDate = (dateString) => {
|
||||
if (!dateString) return null;
|
||||
try {
|
||||
const date = typeof dateString === 'string' ? parseISO(dateString) : new Date(dateString);
|
||||
return isValid(date) ? date : null;
|
||||
} catch { return null; }
|
||||
};
|
||||
|
||||
const safeFormatDate = (dateString, formatString) => {
|
||||
const date = safeParseDate(dateString);
|
||||
return date ? format(date, formatString) : '—';
|
||||
};
|
||||
|
||||
const convertTo12Hour = (time24) => {
|
||||
if (!time24) return "-";
|
||||
try {
|
||||
const [hours, minutes] = time24.split(':');
|
||||
const hour = parseInt(hours);
|
||||
const ampm = hour >= 12 ? 'PM' : 'AM';
|
||||
const hour12 = hour % 12 || 12;
|
||||
return `${hour12}:${minutes} ${ampm}`;
|
||||
} catch {
|
||||
return time24;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status) => {
|
||||
const statusConfig = {
|
||||
'Draft': { bg: 'bg-slate-500', text: 'Draft' },
|
||||
'Pending': { bg: 'bg-amber-500', text: 'Pending' },
|
||||
'Partial Staffed': { bg: 'bg-orange-500', text: 'Partial Staffed' },
|
||||
'Fully Staffed': { bg: 'bg-emerald-500', text: 'Fully Staffed' },
|
||||
'Active': { bg: 'bg-blue-500', text: 'Active' },
|
||||
'Completed': { bg: 'bg-slate-400', text: 'Completed' },
|
||||
'Canceled': { bg: 'bg-red-500', text: 'Canceled' },
|
||||
};
|
||||
|
||||
const config = statusConfig[status] || { bg: 'bg-slate-400', text: status };
|
||||
|
||||
return (
|
||||
<Badge className={`${config.bg} text-white px-4 py-1.5 font-semibold`}>
|
||||
{config.text}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
export default function OrderDetailModal({ open, onClose, order, onCancel }) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data: allStaff = [] } = useQuery({
|
||||
queryKey: ['staff-for-order-modal'],
|
||||
queryFn: () => base44.entities.Staff.list(),
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
if (!order) return null;
|
||||
|
||||
const canEditOrder = (order) => {
|
||||
const eventDate = safeParseDate(order.date);
|
||||
const now = new Date();
|
||||
return order.status !== "Completed" &&
|
||||
order.status !== "Canceled" &&
|
||||
eventDate && eventDate > now;
|
||||
};
|
||||
|
||||
const canCancelOrder = (order) => {
|
||||
return order.status !== "Completed" && order.status !== "Canceled";
|
||||
};
|
||||
|
||||
const handleViewFullOrder = () => {
|
||||
navigate(createPageUrl(`EventDetail?id=${order.id}`));
|
||||
};
|
||||
|
||||
const handleEditOrder = () => {
|
||||
navigate(createPageUrl(`EditEvent?id=${order.id}`));
|
||||
};
|
||||
|
||||
const handleCancelOrder = () => {
|
||||
onClose();
|
||||
if (onCancel) {
|
||||
onCancel(order);
|
||||
}
|
||||
};
|
||||
|
||||
const assignedCount = order.assigned_staff?.length || 0;
|
||||
const requestedCount = order.requested || 0;
|
||||
const assignmentProgress = requestedCount > 0 ? Math.round((assignedCount / requestedCount) * 100) : 0;
|
||||
|
||||
// Get event times
|
||||
const firstShift = order.shifts?.[0];
|
||||
const rolesInFirstShift = firstShift?.roles || [];
|
||||
const startTime = rolesInFirstShift.length > 0 ? convertTo12Hour(rolesInFirstShift[0].start_time) : "-";
|
||||
const endTime = rolesInFirstShift.length > 0 ? convertTo12Hour(rolesInFirstShift[0].end_time) : "-";
|
||||
|
||||
// Get staff details
|
||||
const getStaffDetails = (staffId) => {
|
||||
return allStaff.find(s => s.id === staffId) || {};
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-4xl max-h-[90dvh] overflow-y-auto">
|
||||
<DialogHeader className="border-b pb-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<DialogTitle className="text-2xl font-bold text-slate-900">{order.event_name}</DialogTitle>
|
||||
<p className="text-sm text-slate-500 mt-1">Order Details & Information</p>
|
||||
</div>
|
||||
{getStatusBadge(order.status)}
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
{/* Order Information */}
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<h3 className="font-semibold text-slate-900 mb-4">Order Information</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-blue-50 rounded-lg flex items-center justify-center">
|
||||
<Calendar className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Event Date</p>
|
||||
<p className="font-bold text-slate-900 text-sm">{safeFormatDate(order.date, 'MMM dd, yyyy')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-purple-50 rounded-lg flex items-center justify-center">
|
||||
<MapPin className="w-5 h-5 text-purple-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Location</p>
|
||||
<p className="font-bold text-slate-900 text-sm">{order.hub || order.event_location || "—"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-green-50 rounded-lg flex items-center justify-center">
|
||||
<Users className="w-5 h-5 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Staff Assigned</p>
|
||||
<p className="font-bold text-slate-900 text-sm">{assignedCount} / {requestedCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-amber-50 rounded-lg flex items-center justify-center">
|
||||
<DollarSign className="w-5 h-5 text-amber-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Total Cost</p>
|
||||
<p className="font-bold text-slate-900 text-sm">${(order.total || 0).toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Business & Time Information */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Building2 className="w-4 h-4 text-blue-600" />
|
||||
<p className="text-xs text-slate-500 font-semibold">Business</p>
|
||||
</div>
|
||||
<p className="font-bold text-slate-900">{order.business_name || "—"}</p>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Clock className="w-4 h-4 text-purple-600" />
|
||||
<p className="text-xs text-slate-500 font-semibold">Time</p>
|
||||
</div>
|
||||
<p className="font-bold text-slate-900">{startTime} - {endTime}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Event Location & POC */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<MapPin className="w-4 h-4 text-emerald-600" />
|
||||
<p className="text-xs text-slate-500 font-semibold">Event Location</p>
|
||||
</div>
|
||||
<p className="font-bold text-slate-900">{order.event_location || order.hub || "—"}</p>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<User className="w-4 h-4 text-indigo-600" />
|
||||
<p className="text-xs text-slate-500 font-semibold">Point of Contact</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-bold text-slate-900">{order.client_name || order.manager_name || "—"}</p>
|
||||
{order.client_phone && (
|
||||
<p className="text-xs text-slate-500 mt-1">{order.client_phone}</p>
|
||||
)}
|
||||
{order.client_email && (
|
||||
<p className="text-xs text-slate-500">{order.client_email}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Shifts & Roles */}
|
||||
{order.shifts && order.shifts.length > 0 && (
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-900 mb-3">Shifts & Staff Requirements</h3>
|
||||
<div className="space-y-3">
|
||||
{order.shifts.map((shift, idx) => (
|
||||
<div key={idx} className="bg-slate-50 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<p className="font-semibold text-slate-900">{shift.shift_name || `Shift ${idx + 1}`}</p>
|
||||
{shift.location && (
|
||||
<p className="text-xs text-slate-500 flex items-center gap-1 mt-1">
|
||||
<MapPin className="w-3 h-3" />
|
||||
{shift.location}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{shift.roles?.map((role, roleIdx) => (
|
||||
<div key={roleIdx} className="flex items-center justify-between bg-white rounded p-3">
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">{role.role}</p>
|
||||
<p className="text-xs text-slate-500">{role.department || "—"}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-right">
|
||||
<p className="text-xs text-slate-500">Required</p>
|
||||
<p className="font-bold text-slate-900">{role.count || 0}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-xs text-slate-500">Time</p>
|
||||
<p className="font-medium text-slate-900 text-sm">
|
||||
{convertTo12Hour(role.start_time)} - {convertTo12Hour(role.end_time)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-xs text-slate-500">Rate</p>
|
||||
<p className="font-bold text-emerald-600">${role.cost_per_hour}/hr</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Assigned Staff */}
|
||||
{order.assigned_staff && order.assigned_staff.length > 0 && (
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-900 mb-3">Assigned Staff ({order.assigned_staff.length})</h3>
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<div className="space-y-3">
|
||||
{order.assigned_staff.map((staff, idx) => {
|
||||
const staffDetails = getStaffDetails(staff.staff_id);
|
||||
const rating = staffDetails.rating || 0;
|
||||
const reliability = staffDetails.reliability_score || 0;
|
||||
const totalShifts = staffDetails.total_shifts || 0;
|
||||
|
||||
return (
|
||||
<div key={idx} className="flex items-center justify-between bg-white rounded-lg p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="w-12 h-12">
|
||||
<AvatarImage
|
||||
src={staffDetails.profile_picture || staffDetails.avatar_url || `https://ui-avatars.com/api/?name=${encodeURIComponent(staff.staff_name || 'Staff')}&background=10b981&color=fff&size=128`}
|
||||
alt={staff.staff_name}
|
||||
/>
|
||||
</Avatar>
|
||||
<div>
|
||||
<p className="font-semibold text-slate-900">{staff.staff_name}</p>
|
||||
<p className="text-sm text-slate-500">{staffDetails.position || staff.role || "—"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="text-center">
|
||||
<div className="flex items-center gap-1 mb-1">
|
||||
<Star className="w-4 h-4 text-amber-500 fill-amber-500" />
|
||||
<span className="font-bold text-slate-900">{rating.toFixed(1)}</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">Rating</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="font-bold text-emerald-600">{reliability}%</p>
|
||||
<p className="text-xs text-slate-500">On-Time Arrival</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="font-bold text-blue-600">{totalShifts}</p>
|
||||
<p className="text-xs text-slate-500">Jobs</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
{order.notes && (
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-900 mb-2">Additional Notes</h3>
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<p className="text-slate-700 text-sm whitespace-pre-wrap">{order.notes}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="border-t pt-4">
|
||||
<div className="flex items-center justify-between w-full gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleViewFullOrder}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
View Full Order
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{canEditOrder(order) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleEditOrder}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Edit3 className="w-4 h-4" />
|
||||
Edit Order
|
||||
</Button>
|
||||
)}
|
||||
{canCancelOrder(order) && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleCancelOrder}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
Cancel Order
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
109
src/components/orders/OrderReductionAlert.jsx
Normal file
109
src/components/orders/OrderReductionAlert.jsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { AlertTriangle, UserMinus, TrendingDown, CheckCircle } from "lucide-react";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
|
||||
export default function OrderReductionAlert({
|
||||
originalRequested,
|
||||
newRequested,
|
||||
currentAssigned,
|
||||
onAutoUnassign,
|
||||
onManualUnassign,
|
||||
lowReliabilityStaff = []
|
||||
}) {
|
||||
const excessStaff = currentAssigned - newRequested;
|
||||
|
||||
if (excessStaff <= 0) return null;
|
||||
|
||||
return (
|
||||
<Card className="border-2 border-orange-500 bg-orange-50 shadow-lg">
|
||||
<CardHeader className="bg-gradient-to-r from-orange-100 to-red-50 border-b border-orange-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-orange-500 rounded-xl flex items-center justify-center">
|
||||
<AlertTriangle className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-xl font-bold text-orange-900">
|
||||
Order Size Reduction Detected
|
||||
</CardTitle>
|
||||
<p className="text-sm text-orange-700 mt-1">
|
||||
Client reduced headcount from {originalRequested} to {newRequested}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-6 space-y-4">
|
||||
<Alert className="bg-white border-orange-300">
|
||||
<AlertDescription className="text-slate-900">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<TrendingDown className="w-5 h-5 text-orange-600" />
|
||||
<span className="font-bold">Action Required:</span>
|
||||
</div>
|
||||
<p className="text-sm">
|
||||
You have <strong className="text-orange-700">{excessStaff} staff member{excessStaff !== 1 ? 's' : ''}</strong> assigned
|
||||
that exceed{excessStaff === 1 ? 's' : ''} the new request.
|
||||
You must unassign {excessStaff} worker{excessStaff !== 1 ? 's' : ''} to match the new headcount.
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="bg-white border-2 border-slate-200 rounded-xl p-4 text-center">
|
||||
<p className="text-xs text-slate-500 mb-1">Original Request</p>
|
||||
<p className="text-2xl font-bold text-slate-900">{originalRequested}</p>
|
||||
</div>
|
||||
<div className="bg-white border-2 border-orange-300 rounded-xl p-4 text-center">
|
||||
<p className="text-xs text-orange-600 mb-1">New Request</p>
|
||||
<p className="text-2xl font-bold text-orange-700">{newRequested}</p>
|
||||
</div>
|
||||
<div className="bg-white border-2 border-red-300 rounded-xl p-4 text-center">
|
||||
<p className="text-xs text-red-600 mb-1">Must Remove</p>
|
||||
<p className="text-2xl font-bold text-red-700">{excessStaff}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
onClick={onManualUnassign}
|
||||
variant="outline"
|
||||
className="w-full border-2 border-slate-300 hover:bg-slate-50"
|
||||
>
|
||||
<UserMinus className="w-4 h-4 mr-2" />
|
||||
Manually Select Which Staff to Remove
|
||||
</Button>
|
||||
|
||||
{lowReliabilityStaff.length > 0 && (
|
||||
<Button
|
||||
onClick={onAutoUnassign}
|
||||
className="w-full bg-orange-600 hover:bg-orange-700 text-white"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4 mr-2" />
|
||||
Auto-Remove {excessStaff} Lowest Reliability Staff
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{lowReliabilityStaff.length > 0 && (
|
||||
<div className="bg-white border border-orange-200 rounded-lg p-4">
|
||||
<p className="text-xs font-bold text-slate-700 mb-3 uppercase">
|
||||
Suggested for Auto-Removal (Lowest Reliability):
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{lowReliabilityStaff.slice(0, excessStaff).map((staff, idx) => (
|
||||
<div key={idx} className="flex items-center justify-between p-2 bg-red-50 rounded-lg border border-red-200">
|
||||
<span className="text-sm font-medium text-slate-900">{staff.name}</span>
|
||||
<Badge variant="outline" className="border-red-400 text-red-700">
|
||||
Reliability: {staff.reliability}%
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
149
src/components/orders/OrderStatusBadge.jsx
Normal file
149
src/components/orders/OrderStatusBadge.jsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import React from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Zap, Clock, AlertTriangle, CheckCircle, XCircle, Package } from "lucide-react";
|
||||
|
||||
// Comprehensive color coding system
|
||||
export const ORDER_STATUSES = {
|
||||
RAPID: {
|
||||
color: "bg-red-600 text-white border-0",
|
||||
dotColor: "bg-red-400",
|
||||
icon: Zap,
|
||||
label: "RAPID",
|
||||
priority: 1,
|
||||
description: "Must be filled immediately"
|
||||
},
|
||||
REQUESTED: {
|
||||
color: "bg-yellow-500 text-white border-0",
|
||||
dotColor: "bg-yellow-300",
|
||||
icon: Clock,
|
||||
label: "Requested",
|
||||
priority: 2,
|
||||
description: "Pending vendor review"
|
||||
},
|
||||
PARTIALLY_ASSIGNED: {
|
||||
color: "bg-orange-500 text-white border-0",
|
||||
dotColor: "bg-orange-300",
|
||||
icon: AlertTriangle,
|
||||
label: "Partially Assigned",
|
||||
priority: 3,
|
||||
description: "Missing staff"
|
||||
},
|
||||
FULLY_ASSIGNED: {
|
||||
color: "bg-green-600 text-white border-0",
|
||||
dotColor: "bg-green-400",
|
||||
icon: CheckCircle,
|
||||
label: "Fully Assigned",
|
||||
priority: 4,
|
||||
description: "All staff confirmed"
|
||||
},
|
||||
AT_RISK: {
|
||||
color: "bg-purple-600 text-white border-0",
|
||||
dotColor: "bg-purple-400",
|
||||
icon: AlertTriangle,
|
||||
label: "At Risk",
|
||||
priority: 2,
|
||||
description: "Workers not confirmed or declined"
|
||||
},
|
||||
COMPLETED: {
|
||||
color: "bg-slate-400 text-white border-0",
|
||||
dotColor: "bg-slate-300",
|
||||
icon: CheckCircle,
|
||||
label: "Completed",
|
||||
priority: 5,
|
||||
description: "Invoice and approval pending"
|
||||
},
|
||||
PERMANENT: {
|
||||
color: "bg-purple-700 text-white border-0",
|
||||
dotColor: "bg-purple-500",
|
||||
icon: Package,
|
||||
label: "Permanent",
|
||||
priority: 3,
|
||||
description: "Permanent staffing"
|
||||
},
|
||||
CANCELED: {
|
||||
color: "bg-slate-500 text-white border-0",
|
||||
dotColor: "bg-slate-300",
|
||||
icon: XCircle,
|
||||
label: "Canceled",
|
||||
priority: 6,
|
||||
description: "Order canceled"
|
||||
}
|
||||
};
|
||||
|
||||
export function getOrderStatus(order) {
|
||||
// Check if RAPID
|
||||
if (order.is_rapid || order.event_name?.includes("RAPID")) {
|
||||
return ORDER_STATUSES.RAPID;
|
||||
}
|
||||
|
||||
const assignedCount = order.assigned_staff?.length || 0;
|
||||
const requestedCount = order.requested || 0;
|
||||
|
||||
// Check completion status
|
||||
if (order.status === "Completed") {
|
||||
return ORDER_STATUSES.COMPLETED;
|
||||
}
|
||||
|
||||
if (order.status === "Canceled") {
|
||||
return ORDER_STATUSES.CANCELED;
|
||||
}
|
||||
|
||||
// Check if permanent
|
||||
if (order.contract_type === "Permanent" || order.event_type === "Permanent") {
|
||||
return ORDER_STATUSES.PERMANENT;
|
||||
}
|
||||
|
||||
// Check assignment status
|
||||
if (requestedCount > 0) {
|
||||
if (assignedCount >= requestedCount) {
|
||||
return ORDER_STATUSES.FULLY_ASSIGNED;
|
||||
} else if (assignedCount > 0) {
|
||||
return ORDER_STATUSES.PARTIALLY_ASSIGNED;
|
||||
} else {
|
||||
return ORDER_STATUSES.REQUESTED;
|
||||
}
|
||||
}
|
||||
|
||||
// Default to requested
|
||||
return ORDER_STATUSES.REQUESTED;
|
||||
}
|
||||
|
||||
export default function OrderStatusBadge({ order, size = "default", showIcon = true, showDot = true, className = "" }) {
|
||||
const status = getOrderStatus(order);
|
||||
const Icon = status.icon;
|
||||
|
||||
const sizeClasses = {
|
||||
sm: "px-2 py-0.5 text-[10px]",
|
||||
default: "px-3 py-1 text-xs",
|
||||
lg: "px-4 py-1.5 text-sm"
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge
|
||||
className={`${status.color} ${sizeClasses[size]} font-semibold shadow-sm whitespace-nowrap flex items-center gap-1.5 ${className}`}
|
||||
title={status.description}
|
||||
>
|
||||
{showDot && (
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${status.dotColor} animate-pulse`} />
|
||||
)}
|
||||
{showIcon && <Icon className={size === 'sm' ? 'w-3 h-3' : 'w-4 h-4'} />}
|
||||
{status.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper function to sort orders by priority
|
||||
export function sortOrdersByPriority(orders) {
|
||||
return [...orders].sort((a, b) => {
|
||||
const statusA = getOrderStatus(a);
|
||||
const statusB = getOrderStatus(b);
|
||||
|
||||
// First by priority
|
||||
if (statusA.priority !== statusB.priority) {
|
||||
return statusA.priority - statusB.priority;
|
||||
}
|
||||
|
||||
// Then by date (most recent first)
|
||||
return new Date(b.date || b.created_date) - new Date(a.date || a.created_date);
|
||||
});
|
||||
}
|
||||
37
src/components/orders/OrderStatusUtils.jsx
Normal file
37
src/components/orders/OrderStatusUtils.jsx
Normal file
@@ -0,0 +1,37 @@
|
||||
// Utility to calculate order status based on current state
|
||||
export function calculateOrderStatus(event) {
|
||||
// Check explicit statuses first
|
||||
if (event.status === "Canceled" || event.status === "Cancelled") {
|
||||
return "Canceled";
|
||||
}
|
||||
|
||||
if (event.status === "Draft") {
|
||||
return "Draft";
|
||||
}
|
||||
|
||||
if (event.status === "Completed") {
|
||||
return "Completed";
|
||||
}
|
||||
|
||||
// Calculate status based on staffing
|
||||
const requested = event.requested || 0;
|
||||
const assigned = event.assigned_staff?.length || 0;
|
||||
|
||||
if (requested === 0) {
|
||||
return "Draft"; // No staff requested yet
|
||||
}
|
||||
|
||||
if (assigned === 0) {
|
||||
return "Pending"; // Awaiting assignment
|
||||
}
|
||||
|
||||
if (assigned < requested) {
|
||||
return "Partial"; // Partially staffed
|
||||
}
|
||||
|
||||
if (assigned >= requested) {
|
||||
return "Confirmed"; // Fully staffed
|
||||
}
|
||||
|
||||
return "Pending";
|
||||
}
|
||||
153
src/components/orders/QuickOrderBar.jsx
Normal file
153
src/components/orders/QuickOrderBar.jsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import React, { useState } from "react";
|
||||
import { Briefcase, MapPin, Calendar, Clock, Hourglass, Users, Search } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "@/components/ui/command";
|
||||
import { format } from "date-fns";
|
||||
|
||||
export default function QuickOrderBar({ availableRoles, onApply, defaultLocation }) {
|
||||
const [role, setRole] = useState("");
|
||||
const [location, setLocation] = useState(defaultLocation || "");
|
||||
const [date, setDate] = useState(format(new Date(), "yyyy-MM-dd"));
|
||||
const [startTime, setStartTime] = useState("17:00");
|
||||
const [hours, setHours] = useState(5);
|
||||
const [staff, setStaff] = useState(5);
|
||||
const [roleOpen, setRoleOpen] = useState(false);
|
||||
|
||||
const handleApply = () => {
|
||||
const [sh, sm] = startTime.split(":").map(Number);
|
||||
const startMin = sh * 60 + sm;
|
||||
const endMin = startMin + hours * 60;
|
||||
const endHour = Math.floor(endMin / 60) % 24;
|
||||
const endMinRem = endMin % 60;
|
||||
const endTime = `${String(endHour).padStart(2, "0")}:${String(endMinRem).padStart(2, "0")}`;
|
||||
|
||||
onApply({ role, location, date, startTime, endTime, hours, staff });
|
||||
};
|
||||
|
||||
const Field = ({ icon, label, children }) => (
|
||||
<div className="flex items-center gap-2.5 px-4 py-2 flex-1 min-w-0">
|
||||
<div className="text-[#0039A6] shrink-0">{icon}</div>
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-wide leading-tight">{label}</span>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const Divider = () => <div className="w-px h-10 bg-slate-200 shrink-0" />;
|
||||
|
||||
return (
|
||||
<div className="w-full bg-white rounded-xl shadow-lg border border-slate-200">
|
||||
<div className="flex flex-wrap items-stretch">
|
||||
{/* Role Needed */}
|
||||
<Popover open={roleOpen} onOpenChange={setRoleOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="flex items-center gap-2.5 px-4 py-2 flex-1 min-w-[140px] cursor-pointer hover:bg-slate-50 rounded-l-xl transition-colors">
|
||||
<Briefcase className="w-4 h-4 text-[#0039A6] shrink-0" />
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-wide leading-tight">Role Needed</span>
|
||||
<span className={`text-sm truncate ${role ? "text-slate-800 font-medium" : "text-slate-400"}`}>
|
||||
{role || "e.g. Bartender"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[280px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search roles..." className="h-9" />
|
||||
<CommandEmpty>No role found.</CommandEmpty>
|
||||
<CommandGroup className="max-h-60 overflow-auto">
|
||||
{availableRoles.map((roleName) => (
|
||||
<CommandItem
|
||||
key={roleName}
|
||||
onSelect={() => { setRole(roleName); setRoleOpen(false); }}
|
||||
>
|
||||
{roleName}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Location */}
|
||||
<Field icon={<MapPin className="w-4 h-4" />} label="Location">
|
||||
<input
|
||||
type="text"
|
||||
value={location}
|
||||
onChange={(e) => setLocation(e.target.value)}
|
||||
placeholder="e.g. San Francisco"
|
||||
className="text-sm text-slate-800 font-medium outline-none bg-transparent w-full placeholder:text-slate-400"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Date */}
|
||||
<Field icon={<Calendar className="w-4 h-4" />} label="Date">
|
||||
<input
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
className="text-sm text-slate-800 font-medium outline-none bg-transparent w-full"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Start Time */}
|
||||
<Field icon={<Clock className="w-4 h-4" />} label="Start Time">
|
||||
<input
|
||||
type="time"
|
||||
value={startTime}
|
||||
onChange={(e) => setStartTime(e.target.value)}
|
||||
className="text-sm text-slate-800 font-medium outline-none bg-transparent w-full"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Hours */}
|
||||
<Field icon={<Hourglass className="w-4 h-4" />} label="Hours">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="24"
|
||||
value={hours}
|
||||
onChange={(e) => setHours(Math.max(1, parseInt(e.target.value) || 1))}
|
||||
className="text-sm text-slate-800 font-medium outline-none bg-transparent w-16"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Staff */}
|
||||
<Field icon={<Users className="w-4 h-4" />} label="Staff">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={staff}
|
||||
onChange={(e) => setStaff(Math.max(1, parseInt(e.target.value) || 1))}
|
||||
className="text-sm text-slate-800 font-medium outline-none bg-transparent w-16"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{/* Search Button */}
|
||||
<div className="flex items-center pr-3 py-2">
|
||||
<Button
|
||||
onClick={handleApply}
|
||||
disabled={!role}
|
||||
className="bg-[#0039A6] hover:bg-[#0047C4] text-white rounded-full px-6 h-10 font-semibold shadow-md"
|
||||
>
|
||||
<Search className="w-4 h-4 mr-2" />
|
||||
Search
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
332
src/components/orders/RapidOrderChat.jsx
Normal file
332
src/components/orders/RapidOrderChat.jsx
Normal file
@@ -0,0 +1,332 @@
|
||||
import React, { useState } from "react";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Zap, Send, Check, Edit3, MapPin, Clock, Users, AlertCircle, Sparkles } from "lucide-react";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
|
||||
export default function RapidOrderChat({ onOrderCreated }) {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [message, setMessage] = useState("");
|
||||
const [conversation, setConversation] = useState([]);
|
||||
const [detectedOrder, setDetectedOrder] = useState(null);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
|
||||
const { data: user } = useQuery({
|
||||
queryKey: ['current-user-rapid'],
|
||||
queryFn: () => base44.auth.me(),
|
||||
});
|
||||
|
||||
const { data: businesses } = useQuery({
|
||||
queryKey: ['user-businesses'],
|
||||
queryFn: () => base44.entities.Business.filter({ contact_name: user?.full_name }),
|
||||
enabled: !!user,
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
const createRapidOrderMutation = useMutation({
|
||||
mutationFn: (orderData) => base44.entities.Event.create(orderData),
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['events'] });
|
||||
toast({
|
||||
title: "✅ RAPID Order Created",
|
||||
description: "Order sent to preferred vendor with priority notification",
|
||||
});
|
||||
if (onOrderCreated) onOrderCreated(data);
|
||||
// Reset
|
||||
setConversation([]);
|
||||
setDetectedOrder(null);
|
||||
setMessage("");
|
||||
},
|
||||
});
|
||||
|
||||
const analyzeMessage = async (msg) => {
|
||||
setIsProcessing(true);
|
||||
|
||||
// Add user message to conversation
|
||||
setConversation(prev => [...prev, { role: 'user', content: msg }]);
|
||||
|
||||
try {
|
||||
// Use AI to parse the message
|
||||
const response = await base44.integrations.Core.InvokeLLM({
|
||||
prompt: `You are an order assistant. Analyze this message and extract order details:
|
||||
|
||||
Message: "${msg}"
|
||||
Current user: ${user?.full_name}
|
||||
User's locations: ${businesses.map(b => b.business_name).join(', ')}
|
||||
|
||||
Extract:
|
||||
1. Urgency keywords (ASAP, today, emergency, call out, urgent, rapid, now)
|
||||
2. Role/position needed (cook, bartender, server, dishwasher, etc.)
|
||||
3. Number of staff (if mentioned)
|
||||
4. Time frame (if mentioned)
|
||||
5. Location (if mentioned, otherwise use first available location)
|
||||
|
||||
Return a concise summary.`,
|
||||
response_json_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
is_urgent: { type: "boolean" },
|
||||
role: { type: "string" },
|
||||
count: { type: "number" },
|
||||
location: { type: "string" },
|
||||
time_mentioned: { type: "boolean" },
|
||||
start_time: { type: "string" },
|
||||
end_time: { type: "string" }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const parsed = response;
|
||||
const primaryLocation = businesses[0]?.business_name || "Primary Location";
|
||||
|
||||
const order = {
|
||||
is_rapid: parsed.is_urgent || true,
|
||||
role: parsed.role || "Staff Member",
|
||||
count: parsed.count || 1,
|
||||
location: parsed.location || primaryLocation,
|
||||
start_time: parsed.start_time || "ASAP",
|
||||
end_time: parsed.end_time || "End of shift",
|
||||
business_name: primaryLocation,
|
||||
hub: businesses[0]?.hub_building || "Main Hub"
|
||||
};
|
||||
|
||||
setDetectedOrder(order);
|
||||
|
||||
// AI response
|
||||
const aiMessage = `Is this a RAPID ORDER for **${order.count} ${order.role}${order.count > 1 ? 's' : ''}** at **${order.location}**?\n\nTime: ${order.start_time} → ${order.end_time}`;
|
||||
|
||||
setConversation(prev => [...prev, {
|
||||
role: 'assistant',
|
||||
content: aiMessage,
|
||||
showConfirm: true
|
||||
}]);
|
||||
|
||||
} catch (error) {
|
||||
setConversation(prev => [...prev, {
|
||||
role: 'assistant',
|
||||
content: "I couldn't process that. Please provide more details like: role needed, how many, and when."
|
||||
}]);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendMessage = () => {
|
||||
if (!message.trim()) return;
|
||||
analyzeMessage(message);
|
||||
setMessage("");
|
||||
};
|
||||
|
||||
const handleConfirmOrder = () => {
|
||||
if (!detectedOrder) return;
|
||||
|
||||
const now = new Date();
|
||||
const orderData = {
|
||||
event_name: `RAPID: ${detectedOrder.count} ${detectedOrder.role}${detectedOrder.count > 1 ? 's' : ''}`,
|
||||
is_rapid: true,
|
||||
status: "Pending",
|
||||
business_name: detectedOrder.business_name,
|
||||
hub: detectedOrder.hub,
|
||||
event_location: detectedOrder.location,
|
||||
date: now.toISOString().split('T')[0],
|
||||
requested: detectedOrder.count,
|
||||
client_name: user?.full_name,
|
||||
client_email: user?.email,
|
||||
notes: `RAPID ORDER - ${detectedOrder.start_time} to ${detectedOrder.end_time}`,
|
||||
shifts: [{
|
||||
shift_name: "Emergency Shift",
|
||||
roles: [{
|
||||
role: detectedOrder.role,
|
||||
count: detectedOrder.count,
|
||||
start_time: "ASAP",
|
||||
end_time: "End of shift"
|
||||
}]
|
||||
}]
|
||||
};
|
||||
|
||||
createRapidOrderMutation.mutate(orderData);
|
||||
};
|
||||
|
||||
const handleEditOrder = () => {
|
||||
setConversation(prev => [...prev, {
|
||||
role: 'assistant',
|
||||
content: "Please describe what you'd like to change."
|
||||
}]);
|
||||
setDetectedOrder(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-gradient-to-br from-red-50 via-orange-50 to-yellow-50 border-2 border-red-300 shadow-xl">
|
||||
<CardHeader className="border-b border-red-200 bg-white/50 backdrop-blur-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-red-500 to-orange-500 rounded-xl flex items-center justify-center shadow-lg">
|
||||
<Zap className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-xl font-bold text-red-700 flex items-center gap-2">
|
||||
<Sparkles className="w-5 h-5" />
|
||||
RAPID Order Assistant
|
||||
</CardTitle>
|
||||
<p className="text-xs text-red-600 mt-1">Emergency staffing in minutes</p>
|
||||
</div>
|
||||
<Badge className="bg-red-600 text-white font-bold text-sm px-4 py-2 shadow-md animate-pulse">
|
||||
URGENT
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-6">
|
||||
{/* Chat Messages */}
|
||||
<div className="space-y-4 mb-6 max-h-[400px] overflow-y-auto">
|
||||
{conversation.length === 0 && (
|
||||
<div className="text-center py-8">
|
||||
<div className="w-16 h-16 mx-auto mb-4 bg-gradient-to-br from-red-500 to-orange-500 rounded-2xl flex items-center justify-center shadow-lg">
|
||||
<Zap className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h3 className="font-bold text-lg text-slate-900 mb-2">Need staff urgently?</h3>
|
||||
<p className="text-sm text-slate-600 mb-4">Just describe what you need, I'll handle the rest</p>
|
||||
<div className="text-left max-w-md mx-auto space-y-2">
|
||||
<div className="bg-white p-3 rounded-lg border border-slate-200 text-xs text-slate-600">
|
||||
<strong>Example:</strong> "We had a call out. Need 2 cooks ASAP"
|
||||
</div>
|
||||
<div className="bg-white p-3 rounded-lg border border-slate-200 text-xs text-slate-600">
|
||||
<strong>Example:</strong> "Emergency! Need bartender for tonight"
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AnimatePresence>
|
||||
{conversation.map((msg, idx) => (
|
||||
<motion.div
|
||||
key={idx}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
<div className={`max-w-[80%] ${
|
||||
msg.role === 'user'
|
||||
? 'bg-gradient-to-br from-blue-600 to-blue-700 text-white'
|
||||
: 'bg-white border-2 border-red-200'
|
||||
} rounded-2xl p-4 shadow-md`}>
|
||||
{msg.role === 'assistant' && (
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="w-6 h-6 bg-gradient-to-br from-red-500 to-orange-500 rounded-full flex items-center justify-center">
|
||||
<Sparkles className="w-3 h-3 text-white" />
|
||||
</div>
|
||||
<span className="text-xs font-bold text-red-600">AI Assistant</span>
|
||||
</div>
|
||||
)}
|
||||
<p className={`text-sm whitespace-pre-line ${msg.role === 'user' ? 'text-white' : 'text-slate-900'}`}>
|
||||
{msg.content}
|
||||
</p>
|
||||
|
||||
{msg.showConfirm && detectedOrder && (
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3 p-3 bg-gradient-to-br from-slate-50 to-blue-50 rounded-lg border border-blue-200">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<Users className="w-4 h-4 text-blue-600" />
|
||||
<div>
|
||||
<p className="text-slate-500">Staff Needed</p>
|
||||
<p className="font-bold text-slate-900">{detectedOrder.count} {detectedOrder.role}{detectedOrder.count > 1 ? 's' : ''}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<MapPin className="w-4 h-4 text-blue-600" />
|
||||
<div>
|
||||
<p className="text-slate-500">Location</p>
|
||||
<p className="font-bold text-slate-900">{detectedOrder.location}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs col-span-2">
|
||||
<Clock className="w-4 h-4 text-blue-600" />
|
||||
<div>
|
||||
<p className="text-slate-500">Time</p>
|
||||
<p className="font-bold text-slate-900">{detectedOrder.start_time} → {detectedOrder.end_time}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleConfirmOrder}
|
||||
disabled={createRapidOrderMutation.isPending}
|
||||
className="flex-1 bg-gradient-to-r from-red-600 to-orange-600 hover:from-red-700 hover:to-orange-700 text-white font-bold shadow-lg"
|
||||
>
|
||||
<Check className="w-4 h-4 mr-2" />
|
||||
{createRapidOrderMutation.isPending ? "Creating..." : "CONFIRM & SEND"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleEditOrder}
|
||||
variant="outline"
|
||||
className="border-2 border-red-300 hover:bg-red-50"
|
||||
>
|
||||
<Edit3 className="w-4 h-4 mr-2" />
|
||||
EDIT
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
{isProcessing && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="flex justify-start"
|
||||
>
|
||||
<div className="bg-white border-2 border-red-200 rounded-2xl p-4 shadow-md">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 bg-gradient-to-br from-red-500 to-orange-500 rounded-full flex items-center justify-center animate-pulse">
|
||||
<Sparkles className="w-3 h-3 text-white" />
|
||||
</div>
|
||||
<span className="text-sm text-slate-600">Processing your request...</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleSendMessage()}
|
||||
placeholder="Describe what you need... (e.g., 'Need 2 cooks ASAP')"
|
||||
className="flex-1 border-2 border-red-300 focus:border-red-500 text-base"
|
||||
disabled={isProcessing}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSendMessage}
|
||||
disabled={!message.trim() || isProcessing}
|
||||
className="bg-gradient-to-r from-red-600 to-orange-600 hover:from-red-700 hover:to-orange-700 text-white font-bold shadow-lg"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Helper Text */}
|
||||
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="w-4 h-4 text-blue-600 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-xs text-blue-800">
|
||||
<strong>Tip:</strong> Include role, quantity, and urgency for fastest processing.
|
||||
AI will auto-detect your location and send to your preferred vendor.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
276
src/components/orders/RapidOrderInterface.jsx
Normal file
276
src/components/orders/RapidOrderInterface.jsx
Normal file
@@ -0,0 +1,276 @@
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Zap, Send, Mic, Calendar, Clock, ArrowLeft, Users, MapPin, Edit2, CheckCircle } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
|
||||
export default function RapidOrderInterface({ onBack, onSubmit }) {
|
||||
const [message, setMessage] = useState("");
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [parsedData, setParsedData] = useState(null);
|
||||
const [showConfirmation, setShowConfirmation] = useState(false);
|
||||
|
||||
const handleBack = () => {
|
||||
if (onBack) onBack();
|
||||
};
|
||||
|
||||
const examples = [
|
||||
{ text: "We had a call out. Need 2 cooks ASAP", color: "bg-blue-50 border-blue-200 text-blue-700" },
|
||||
{ text: "Need 5 bartenders ASAP until 5am", color: "bg-purple-50 border-purple-200 text-purple-700" },
|
||||
{ text: "Emergency! Need 3 servers right now till midnight", color: "bg-green-50 border-green-200 text-green-700" },
|
||||
];
|
||||
|
||||
const parseRapidMessage = (msg) => {
|
||||
// Extract count (numbers)
|
||||
const countMatch = msg.match(/(\d+)/);
|
||||
const count = countMatch ? parseInt(countMatch[1]) : 1;
|
||||
|
||||
// Extract role (common keywords)
|
||||
const roles = ['server', 'cook', 'chef', 'bartender', 'dishwasher', 'host', 'runner'];
|
||||
let role = 'staff';
|
||||
for (const r of roles) {
|
||||
if (msg.toLowerCase().includes(r)) {
|
||||
role = r + (count > 1 ? 's' : '');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract time (until X, till X, by X)
|
||||
const timeMatch = msg.match(/until\s+(\d+(?::\d+)?\s*(?:am|pm)?)|till\s+(\d+(?::\d+)?\s*(?:am|pm)?)|by\s+(\d+(?::\d+)?\s*(?:am|pm)?)/i);
|
||||
const endTime = timeMatch ? (timeMatch[1] || timeMatch[2] || timeMatch[3]) : '11:59pm';
|
||||
|
||||
// Current time as start
|
||||
const now = new Date();
|
||||
const startTime = format(now, 'h:mm a');
|
||||
|
||||
return {
|
||||
count,
|
||||
role,
|
||||
startTime,
|
||||
endTime,
|
||||
location: "Client's location" // Default, can be auto-detected
|
||||
};
|
||||
};
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!message.trim()) return;
|
||||
setIsProcessing(true);
|
||||
|
||||
// Parse the message
|
||||
const parsed = parseRapidMessage(message);
|
||||
setParsedData(parsed);
|
||||
setShowConfirmation(true);
|
||||
|
||||
setIsProcessing(false);
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (onSubmit && parsedData) {
|
||||
onSubmit({
|
||||
rawMessage: message,
|
||||
orderType: 'rapid',
|
||||
...parsedData
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
setShowConfirmation(false);
|
||||
setParsedData(null);
|
||||
};
|
||||
|
||||
const handleKeyPress = (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
const handleExampleClick = (exampleText) => {
|
||||
setMessage(exampleText);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-2xl shadow-lg border-2 border-red-200 overflow-hidden">
|
||||
<div className="bg-gradient-to-r from-red-500 to-orange-500 p-4 text-white">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-white/20 rounded-xl flex items-center justify-center">
|
||||
<Zap className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold flex items-center gap-2">
|
||||
<Zap className="w-5 h-5" />
|
||||
RAPID Order
|
||||
</h2>
|
||||
<p className="text-red-100 text-xs">Emergency staffing in minutes</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right text-xs text-red-100">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-3 h-3" />
|
||||
{format(new Date(), 'EEE, MMM dd, yyyy')}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<Clock className="w-3 h-3" />
|
||||
{format(new Date(), 'h:mm a')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{/* Section Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-base font-semibold text-slate-900">Tell us what you need</h3>
|
||||
<Badge className="bg-red-500 text-white text-xs">URGENT</Badge>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="space-y-4">
|
||||
{/* Icon + Message */}
|
||||
<div className="text-center py-4">
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-red-500 to-orange-500 rounded-xl flex items-center justify-center mx-auto mb-3 shadow-lg">
|
||||
<Zap className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h4 className="text-lg font-bold text-slate-900 mb-1">Need staff urgently?</h4>
|
||||
<p className="text-sm text-slate-600">Type or speak what you need. I'll handle the rest</p>
|
||||
</div>
|
||||
|
||||
{/* Example Prompts */}
|
||||
<div className="space-y-2">
|
||||
{examples.map((example, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => handleExampleClick(example.text)}
|
||||
className={`w-full p-3 rounded-lg border-2 text-left transition-all hover:shadow-md text-sm ${example.color}`}
|
||||
>
|
||||
<span className="font-semibold">Example:</span> "{example.text}"
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Input Area */}
|
||||
{showConfirmation && parsedData ? (
|
||||
<div className="space-y-4">
|
||||
{/* AI Confirmation Card */}
|
||||
<div className="bg-gradient-to-br from-orange-50 to-red-50 border-2 border-orange-200 rounded-xl p-4">
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
<div className="w-8 h-8 bg-gradient-to-br from-red-500 to-orange-500 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<Zap className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-xs font-bold text-red-600 uppercase mb-1">AI Assistant</p>
|
||||
<p className="text-sm text-slate-700">
|
||||
Is this a RAPID ORDER for <strong>**{parsedData.count} {parsedData.role}**</strong> at <strong>**{parsedData.location}**</strong>?
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 mb-4">
|
||||
<p className="text-xs text-slate-600">Start Time: <strong className="text-slate-900">{parsedData.startTime}</strong></p>
|
||||
<p className="text-xs text-slate-600">End Time: <strong className="text-slate-900">{parsedData.endTime}</strong></p>
|
||||
</div>
|
||||
|
||||
{/* Details Card */}
|
||||
<div className="bg-white border-2 border-blue-200 rounded-lg p-3 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center">
|
||||
<Users className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] text-blue-600 font-semibold uppercase">Staff Needed</p>
|
||||
<p className="text-sm font-bold text-slate-900">{parsedData.count} {parsedData.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center">
|
||||
<MapPin className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] text-blue-600 font-semibold uppercase">Location</p>
|
||||
<p className="text-sm font-bold text-slate-900">{parsedData.location}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center">
|
||||
<Clock className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] text-blue-600 font-semibold uppercase">Time</p>
|
||||
<p className="text-sm font-bold text-slate-900">Start: {parsedData.startTime} | End: {parsedData.endTime}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
className="flex-1 h-11 bg-gradient-to-r from-red-500 to-orange-500 hover:from-red-600 hover:to-orange-600 text-white font-bold shadow-lg"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4 mr-2" />
|
||||
CONFIRM & SEND
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleEdit}
|
||||
variant="outline"
|
||||
className="h-11 px-6 border-2 border-slate-300 hover:border-slate-400"
|
||||
>
|
||||
<Edit2 className="w-4 h-4 mr-2" />
|
||||
EDIT
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder='Type or speak... (e.g., "Need 5 cooks ASAP until 5am")'
|
||||
rows={3}
|
||||
className="resize-none border-2 border-slate-200 focus:border-red-400 rounded-lg text-sm"
|
||||
/>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1 h-10 border-2 border-slate-300 hover:border-slate-400 text-sm"
|
||||
>
|
||||
<Mic className="w-4 h-4 mr-2" />
|
||||
Speak
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
disabled={!message.trim() || isProcessing}
|
||||
className="flex-1 h-10 bg-gradient-to-r from-red-500 to-orange-500 hover:from-red-600 hover:to-orange-600 text-white font-semibold shadow-lg text-sm"
|
||||
>
|
||||
<Send className="w-4 h-4 mr-2" />
|
||||
{isProcessing ? 'Processing...' : 'Send Message'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tip */}
|
||||
<div className="bg-blue-50 border-2 border-blue-200 rounded-lg p-3">
|
||||
<div className="flex gap-2">
|
||||
<div className="w-5 h-5 bg-blue-500 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-white text-xs font-bold">i</span>
|
||||
</div>
|
||||
<div className="text-xs text-blue-900">
|
||||
<span className="font-semibold">Tip:</span> Include role, quantity, and urgency for fastest processing. Optionally add end time like "until 5am" or "till midnight". AI will auto-detect your location and send to your preferred vendor with priority notification.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
374
src/components/orders/SmartAssignModal.jsx
Normal file
374
src/components/orders/SmartAssignModal.jsx
Normal file
@@ -0,0 +1,374 @@
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Sparkles, Star, MapPin, Clock, Award, TrendingUp, AlertCircle, CheckCircle, Zap, Users, RefreshCw } from "lucide-react";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
|
||||
export default function SmartAssignModal({ isOpen, onClose, event, roleNeeded, countNeeded }) {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedWorkers, setSelectedWorkers] = useState([]);
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||
const [aiRecommendations, setAiRecommendations] = useState(null);
|
||||
|
||||
const { data: allStaff = [] } = useQuery({
|
||||
queryKey: ['staff-smart-assign'],
|
||||
queryFn: () => base44.entities.Staff.list(),
|
||||
});
|
||||
|
||||
const { data: allEvents = [] } = useQuery({
|
||||
queryKey: ['events-conflict-check'],
|
||||
queryFn: () => base44.entities.Event.list(),
|
||||
});
|
||||
|
||||
// Smart filtering
|
||||
const eligibleStaff = useMemo(() => {
|
||||
if (!event || !roleNeeded) return [];
|
||||
|
||||
return allStaff.filter(worker => {
|
||||
// Role match
|
||||
const hasRole = worker.position === roleNeeded ||
|
||||
worker.position_2 === roleNeeded ||
|
||||
worker.profile_type === "Cross-Trained";
|
||||
|
||||
// Availability check
|
||||
const isAvailable = worker.employment_type !== "Medical Leave" &&
|
||||
worker.action !== "Inactive";
|
||||
|
||||
// Conflict check - check if worker is already assigned
|
||||
const eventDate = new Date(event.date);
|
||||
const hasConflict = allEvents.some(e => {
|
||||
if (e.id === event.id) return false;
|
||||
const eDate = new Date(e.date);
|
||||
return eDate.toDateString() === eventDate.toDateString() &&
|
||||
e.assigned_staff?.some(s => s.staff_id === worker.id);
|
||||
});
|
||||
|
||||
return hasRole && isAvailable && !hasConflict;
|
||||
});
|
||||
}, [allStaff, event, roleNeeded, allEvents]);
|
||||
|
||||
// Run AI analysis
|
||||
const runSmartAnalysis = async () => {
|
||||
setIsAnalyzing(true);
|
||||
|
||||
try {
|
||||
const prompt = `You are a workforce optimization AI. Analyze these workers and recommend the best ${countNeeded} for this job.
|
||||
|
||||
Event: ${event.event_name}
|
||||
Location: ${event.event_location || event.hub}
|
||||
Role Needed: ${roleNeeded}
|
||||
Quantity: ${countNeeded}
|
||||
|
||||
Workers (JSON):
|
||||
${JSON.stringify(eligibleStaff.map(w => ({
|
||||
id: w.id,
|
||||
name: w.employee_name,
|
||||
rating: w.rating || 0,
|
||||
reliability_score: w.reliability_score || 0,
|
||||
total_shifts: w.total_shifts || 0,
|
||||
no_show_count: w.no_show_count || 0,
|
||||
position: w.position,
|
||||
city: w.city,
|
||||
profile_type: w.profile_type
|
||||
})), null, 2)}
|
||||
|
||||
Rank them by:
|
||||
1. Skills match (exact role match gets priority)
|
||||
2. Rating (higher is better)
|
||||
3. Reliability (lower no-shows, higher reliability score)
|
||||
4. Experience (more shifts completed)
|
||||
5. Distance (prefer closer to location)
|
||||
|
||||
Return the top ${countNeeded} worker IDs with brief reasoning.`;
|
||||
|
||||
const response = await base44.integrations.Core.InvokeLLM({
|
||||
prompt,
|
||||
response_json_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
recommendations: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
worker_id: { type: "string" },
|
||||
reason: { type: "string" },
|
||||
score: { type: "number" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const recommended = response.recommendations.map(rec => {
|
||||
const worker = eligibleStaff.find(w => w.id === rec.worker_id);
|
||||
return worker ? { ...worker, ai_reason: rec.reason, ai_score: rec.score } : null;
|
||||
}).filter(Boolean);
|
||||
|
||||
setAiRecommendations(recommended);
|
||||
setSelectedWorkers(recommended.slice(0, countNeeded));
|
||||
|
||||
toast({
|
||||
title: "✨ AI Analysis Complete",
|
||||
description: `Found ${recommended.length} optimal matches`,
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Analysis Failed",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsAnalyzing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const assignMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const assigned_staff = selectedWorkers.map(w => ({
|
||||
staff_id: w.id,
|
||||
staff_name: w.employee_name,
|
||||
role: roleNeeded
|
||||
}));
|
||||
|
||||
return base44.entities.Event.update(event.id, {
|
||||
assigned_staff: [...(event.assigned_staff || []), ...assigned_staff],
|
||||
status: "Confirmed"
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['events'] });
|
||||
toast({
|
||||
title: "✅ Staff Assigned Successfully",
|
||||
description: `${selectedWorkers.length} workers assigned to ${event.event_name}`,
|
||||
});
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isOpen && eligibleStaff.length > 0 && !aiRecommendations) {
|
||||
runSmartAnalysis();
|
||||
}
|
||||
}, [isOpen, eligibleStaff.length]);
|
||||
|
||||
const toggleWorker = (worker) => {
|
||||
setSelectedWorkers(prev => {
|
||||
const exists = prev.find(w => w.id === worker.id);
|
||||
if (exists) {
|
||||
return prev.filter(w => w.id !== worker.id);
|
||||
} else if (prev.length < countNeeded) {
|
||||
return [...prev, worker];
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-5xl max-h-[90dvh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-3 text-2xl">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-purple-500 to-indigo-600 rounded-xl flex items-center justify-center shadow-lg">
|
||||
<Sparkles className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-900">Smart Assign (AI Assisted)</span>
|
||||
<p className="text-sm text-slate-600 font-normal mt-1">
|
||||
AI selected the best {countNeeded} {roleNeeded}{countNeeded > 1 ? 's' : ''} for this event
|
||||
</p>
|
||||
</div>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{isAnalyzing ? (
|
||||
<div className="py-12 text-center">
|
||||
<div className="w-16 h-16 mx-auto mb-4 bg-gradient-to-br from-purple-500 to-indigo-600 rounded-2xl flex items-center justify-center animate-pulse">
|
||||
<Sparkles className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h3 className="font-bold text-lg text-slate-900 mb-2">Analyzing workforce...</h3>
|
||||
<p className="text-sm text-slate-600">AI is finding the optimal matches based on skills, ratings, and availability</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Summary */}
|
||||
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||
<Card className="bg-gradient-to-br from-blue-50 to-indigo-50 border-2 border-blue-200">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Users className="w-8 h-8 text-blue-600" />
|
||||
<div>
|
||||
<p className="text-xs text-blue-700 mb-1">Selected</p>
|
||||
<p className="text-2xl font-bold text-blue-900">{selectedWorkers.length}/{countNeeded}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gradient-to-br from-purple-50 to-pink-50 border-2 border-purple-200">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Zap className="w-8 h-8 text-purple-600" />
|
||||
<div>
|
||||
<p className="text-xs text-purple-700 mb-1">Avg Rating</p>
|
||||
<p className="text-2xl font-bold text-purple-900">
|
||||
{selectedWorkers.length > 0
|
||||
? (selectedWorkers.reduce((sum, w) => sum + (w.rating || 0), 0) / selectedWorkers.length).toFixed(1)
|
||||
: "—"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gradient-to-br from-green-50 to-emerald-50 border-2 border-green-200">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle className="w-8 h-8 text-green-600" />
|
||||
<div>
|
||||
<p className="text-xs text-green-700 mb-1">Available</p>
|
||||
<p className="text-2xl font-bold text-green-900">{eligibleStaff.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* AI Recommendations */}
|
||||
{aiRecommendations && aiRecommendations.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-bold text-slate-900">AI Recommendations</h3>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={runSmartAnalysis}
|
||||
className="border-purple-300 hover:bg-purple-50"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Re-analyze
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{aiRecommendations.map((worker, idx) => {
|
||||
const isSelected = selectedWorkers.some(w => w.id === worker.id);
|
||||
const isOverLimit = selectedWorkers.length >= countNeeded && !isSelected;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={worker.id}
|
||||
className={`transition-all cursor-pointer ${
|
||||
isSelected
|
||||
? 'bg-gradient-to-br from-purple-50 to-indigo-50 border-2 border-purple-400 shadow-lg'
|
||||
: 'bg-white border border-slate-200 hover:border-purple-300 hover:shadow-md'
|
||||
} ${isOverLimit ? 'opacity-50' : ''}`}
|
||||
onClick={() => !isOverLimit && toggleWorker(worker)}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-3 flex-1">
|
||||
<div className="relative">
|
||||
<Avatar className="w-12 h-12 border-2 border-purple-300">
|
||||
<AvatarFallback className="bg-gradient-to-br from-purple-500 to-indigo-600 text-white font-bold">
|
||||
{worker.employee_name?.charAt(0) || 'W'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
{idx === 0 && (
|
||||
<div className="absolute -top-1 -right-1 w-6 h-6 bg-yellow-500 rounded-full flex items-center justify-center shadow-md">
|
||||
<Award className="w-3 h-3 text-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h4 className="font-bold text-slate-900">{worker.employee_name}</h4>
|
||||
{idx === 0 && (
|
||||
<Badge className="bg-gradient-to-r from-yellow-500 to-orange-500 text-white text-xs">
|
||||
Top Pick
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{worker.position}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 mb-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Star className="w-4 h-4 text-yellow-500 fill-yellow-500" />
|
||||
<span className="text-sm font-bold text-slate-900">{worker.rating?.toFixed(1) || 'N/A'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-sm text-slate-600">
|
||||
<TrendingUp className="w-4 h-4 text-green-600" />
|
||||
{worker.total_shifts || 0} shifts
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-sm text-slate-600">
|
||||
<MapPin className="w-4 h-4 text-blue-600" />
|
||||
{worker.city || 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{worker.ai_reason && (
|
||||
<div className="bg-purple-50 border border-purple-200 rounded-lg p-2 mt-2">
|
||||
<p className="text-xs text-purple-900">
|
||||
<strong className="text-purple-700">AI Insight:</strong> {worker.ai_reason}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
{worker.ai_score && (
|
||||
<Badge className="bg-gradient-to-r from-purple-600 to-indigo-600 text-white font-bold">
|
||||
{Math.round(worker.ai_score)}/100
|
||||
</Badge>
|
||||
)}
|
||||
{isSelected && (
|
||||
<CheckCircle className="w-6 h-6 text-purple-600" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<AlertCircle className="w-12 h-12 mx-auto mb-3 text-slate-400" />
|
||||
<p className="text-slate-600">No eligible staff found for this role</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => assignMutation.mutate()}
|
||||
disabled={selectedWorkers.length === 0 || assignMutation.isPending}
|
||||
className="bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-700 hover:to-indigo-700 text-white font-bold"
|
||||
>
|
||||
{assignMutation.isPending ? "Assigning..." : `Assign ${selectedWorkers.length} Workers`}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
150
src/components/orders/WorkerConfirmationCard.jsx
Normal file
150
src/components/orders/WorkerConfirmationCard.jsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import React from "react";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { CheckCircle, XCircle, Clock, MapPin, Calendar, AlertTriangle, RefreshCw, Info } from "lucide-react";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { format } from "date-fns";
|
||||
|
||||
export default function WorkerConfirmationCard({ assignment, event }) {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const confirmMutation = useMutation({
|
||||
mutationFn: async (status) => {
|
||||
return base44.entities.Assignment.update(assignment.id, {
|
||||
assignment_status: status,
|
||||
confirmed_date: new Date().toISOString()
|
||||
});
|
||||
},
|
||||
onSuccess: (_, status) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['assignments'] });
|
||||
toast({
|
||||
title: status === "Confirmed" ? "✅ Shift Confirmed" : "❌ Shift Declined",
|
||||
description: status === "Confirmed"
|
||||
? "You're all set! See you at the event."
|
||||
: "Notified vendor. They'll find a replacement.",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const getStatusColor = () => {
|
||||
switch (assignment.assignment_status) {
|
||||
case "Confirmed":
|
||||
return "bg-green-100 text-green-700 border-green-300";
|
||||
case "Cancelled":
|
||||
return "bg-red-100 text-red-700 border-red-300";
|
||||
case "Pending":
|
||||
return "bg-yellow-100 text-yellow-700 border-yellow-300";
|
||||
default:
|
||||
return "bg-slate-100 text-slate-700 border-slate-300";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-white border-2 border-slate-200 hover:border-blue-300 hover:shadow-lg transition-all">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h3 className="font-bold text-lg text-slate-900">{event.event_name}</h3>
|
||||
{event.is_rapid && (
|
||||
<Badge className="bg-red-600 text-white font-bold text-xs">
|
||||
<AlertTriangle className="w-3 h-3 mr-1" />
|
||||
RAPID
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-slate-600">{assignment.role}</p>
|
||||
</div>
|
||||
<Badge className={`border-2 font-semibold ${getStatusColor()}`}>
|
||||
{assignment.assignment_status}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Calendar className="w-4 h-4 text-blue-600" />
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Date</p>
|
||||
<p className="font-semibold text-slate-900">
|
||||
{event.date ? format(new Date(event.date), "MMM d, yyyy") : "TBD"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Clock className="w-4 h-4 text-blue-600" />
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Time</p>
|
||||
<p className="font-semibold text-slate-900">
|
||||
{assignment.scheduled_start ? format(new Date(assignment.scheduled_start), "h:mm a") : "ASAP"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm col-span-2">
|
||||
<MapPin className="w-4 h-4 text-blue-600" />
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Location</p>
|
||||
<p className="font-semibold text-slate-900">{event.event_location || event.hub}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Shift Details */}
|
||||
{event.shifts?.[0] && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 mb-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Info className="w-4 h-4 text-blue-600" />
|
||||
<span className="text-xs font-bold text-blue-900">Shift Details</span>
|
||||
</div>
|
||||
<div className="space-y-1 text-xs text-slate-700">
|
||||
{event.shifts[0].uniform_type && (
|
||||
<p><strong>Attire:</strong> {event.shifts[0].uniform_type}</p>
|
||||
)}
|
||||
{event.addons?.meal_provided && (
|
||||
<p><strong>Meal:</strong> Provided</p>
|
||||
)}
|
||||
{event.notes && (
|
||||
<p><strong>Notes:</strong> {event.notes}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
{assignment.assignment_status === "Pending" && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => confirmMutation.mutate("Confirmed")}
|
||||
disabled={confirmMutation.isPending}
|
||||
className="flex-1 bg-green-600 hover:bg-green-700 text-white font-bold"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4 mr-2" />
|
||||
Accept Shift
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => confirmMutation.mutate("Cancelled")}
|
||||
disabled={confirmMutation.isPending}
|
||||
variant="outline"
|
||||
className="flex-1 border-2 border-red-300 text-red-600 hover:bg-red-50 font-bold"
|
||||
>
|
||||
<XCircle className="w-4 h-4 mr-2" />
|
||||
Decline
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{assignment.assignment_status === "Confirmed" && (
|
||||
<div className="bg-green-50 border-2 border-green-300 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 text-green-700">
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
<span className="font-bold text-sm">You're confirmed for this shift!</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
27
src/components/owliver/OwliverAvatar.jsx
Normal file
27
src/components/owliver/OwliverAvatar.jsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import owliverAvatar from "@/assets/owliver-avatar.jpg";
|
||||
|
||||
/**
|
||||
* Owliver's identity mark — the official mascot artwork, cropped to the head so
|
||||
* it stays legible at avatar sizes. Never a generic bot/sparkle icon.
|
||||
*
|
||||
* The artwork ships with its own warm background, so it is masked into a
|
||||
* rounded container rather than placed on a tinted KROW surface. The mascot's
|
||||
* yellow is an accent here only; it never becomes the app's colour system.
|
||||
*/
|
||||
export default function OwliverAvatar({ size = 32, rounded = "rounded-[10px]", className = "" }) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-block overflow-hidden flex-shrink-0 ${rounded} ${className}`}
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
<img
|
||||
src={owliverAvatar}
|
||||
alt=""
|
||||
width={size}
|
||||
height={size}
|
||||
className="w-full h-full object-cover"
|
||||
draggable="false"
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
48
src/components/owliver/OwliverConversation.jsx
Normal file
48
src/components/owliver/OwliverConversation.jsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import OwliverMessage from "./OwliverMessage";
|
||||
import { stripContext } from "@/lib/owliver/context";
|
||||
|
||||
/**
|
||||
* The response surface (state B). Errors are deliberately not rendered here —
|
||||
* they belong in the compact line above the prompt, so a failure can never
|
||||
* become the main content of the panel.
|
||||
*/
|
||||
export default function OwliverConversation({ messages, isSending, workingLabel }) {
|
||||
const endRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
endRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
||||
}, [messages, isSending]);
|
||||
|
||||
// The agent streams an empty assistant turn before content lands; suppress
|
||||
// the spinner once real text has arrived.
|
||||
const last = messages[messages.length - 1];
|
||||
const awaitingReply =
|
||||
isSending && (!last || last.role === "user" || !stripContext(last.content));
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-h-0 overflow-y-auto overscroll-contain flex flex-col">
|
||||
{/* `mt-auto` keeps a short exchange sitting just above the prompt instead
|
||||
of pinned to the top under a tall band of empty panel. */}
|
||||
<div className="py-2 mt-auto">
|
||||
{messages.map((message, i) => (
|
||||
<OwliverMessage key={message.id || i} message={message} />
|
||||
))}
|
||||
|
||||
{awaitingReply && (
|
||||
<div
|
||||
className="flex items-center gap-2 px-5 sm:px-6 py-3 text-[13px]"
|
||||
style={{ color: "var(--ow-ink-muted)" }}
|
||||
aria-live="polite"
|
||||
>
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin flex-shrink-0" />
|
||||
<span>{workingLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={endRef} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
src/components/owliver/OwliverError.jsx
Normal file
33
src/components/owliver/OwliverError.jsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { RotateCcw } from "lucide-react";
|
||||
|
||||
/**
|
||||
* A failed request is a footnote, not a destination.
|
||||
*
|
||||
* This renders as one quiet line directly above the prompt box — no card, no
|
||||
* border, no reserved space. It replaces the two large bordered error panels
|
||||
* that used to sit in the middle of the surface and read as "Owliver is
|
||||
* broken". The insight and suggestions stay on screen behind it.
|
||||
*/
|
||||
export default function OwliverError({ message, onRetry }) {
|
||||
if (!message) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="flex items-center gap-2 flex-wrap px-5 pb-1.5 text-[12.5px] leading-snug"
|
||||
style={{ color: "var(--ow-ink-muted)" }}
|
||||
>
|
||||
<span>{message}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className="inline-flex items-center gap-1 font-medium underline underline-offset-2 rounded-sm transition-opacity hover:opacity-70 focus-visible:outline-none focus-visible:ring-2"
|
||||
style={{ color: "var(--ow-brand)", outlineColor: "var(--ow-brand)" }}
|
||||
>
|
||||
<RotateCcw className="w-3 h-3" />
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
107
src/components/owliver/OwliverHeader.jsx
Normal file
107
src/components/owliver/OwliverHeader.jsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { ArrowLeft, Maximize2, Minimize2, PanelRightClose, SquarePen } from "lucide-react";
|
||||
import OwliverAvatar from "./OwliverAvatar";
|
||||
|
||||
/**
|
||||
* Image 1 Header Style:
|
||||
* [ owl ] Owliver ← ✎ ↗ 🚪
|
||||
* Positions
|
||||
*
|
||||
* Every control here does what its label says. The back arrow returns to the
|
||||
* prompt surface and only appears when a thread exists to leave; the pencil
|
||||
* starts a fresh conversation. (It previously rendered a History icon wired to
|
||||
* `onNewConversation`, which silently discarded the thread, and the back
|
||||
* control was never rendered at all — so a reply was a dead end.)
|
||||
*/
|
||||
export default function OwliverHeader({
|
||||
subtitle,
|
||||
onNewConversation,
|
||||
onShowInsight,
|
||||
onToggleExpanded,
|
||||
onClose,
|
||||
isExpanded,
|
||||
canGoBack,
|
||||
hasThread,
|
||||
}) {
|
||||
const iconBtn =
|
||||
"w-8 h-8 rounded-full flex items-center justify-center transition-colors " +
|
||||
"hover:bg-black/[0.05] focus-visible:outline-none focus-visible:ring-2 " +
|
||||
"disabled:opacity-30 disabled:cursor-not-allowed";
|
||||
|
||||
return (
|
||||
<header
|
||||
className="flex items-center gap-3 px-5 pt-4 pb-3 flex-shrink-0"
|
||||
style={{ borderColor: "var(--ow-line)" }}
|
||||
>
|
||||
<OwliverAvatar size={36} rounded="rounded-full" />
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className="text-[16px] font-bold leading-tight truncate"
|
||||
style={{ color: "var(--ow-ink)" }}
|
||||
>
|
||||
Owliver
|
||||
</p>
|
||||
<p
|
||||
className="text-[12.5px] font-medium leading-tight truncate"
|
||||
style={{ color: "var(--ow-ink-muted)" }}
|
||||
>
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 flex-shrink-0" style={{ color: "var(--ow-ink-muted)" }}>
|
||||
{canGoBack && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onShowInsight}
|
||||
aria-label="Back to suggestions"
|
||||
title="Back"
|
||||
className={iconBtn}
|
||||
style={{ outlineColor: "var(--ow-brand)" }}
|
||||
>
|
||||
<ArrowLeft className="w-[17px] h-[17px]" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{hasThread && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNewConversation}
|
||||
aria-label="Start a new conversation"
|
||||
title="New conversation"
|
||||
className={iconBtn}
|
||||
style={{ outlineColor: "var(--ow-brand)" }}
|
||||
>
|
||||
<SquarePen className="w-[17px] h-[17px]" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleExpanded}
|
||||
aria-label={isExpanded ? "Collapse Owliver" : "Expand Owliver"}
|
||||
aria-pressed={isExpanded}
|
||||
title={isExpanded ? "Collapse" : "Expand"}
|
||||
className={`${iconBtn} hidden lg:flex`}
|
||||
style={{ outlineColor: "var(--ow-brand)" }}
|
||||
>
|
||||
{isExpanded
|
||||
? <Minimize2 className="w-[17px] h-[17px]" />
|
||||
: <Maximize2 className="w-[17px] h-[17px]" />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close Owliver"
|
||||
title="Close"
|
||||
className={iconBtn}
|
||||
style={{ outlineColor: "var(--ow-brand)" }}
|
||||
>
|
||||
<PanelRightClose className="w-[18px] h-[18px]" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
51
src/components/owliver/OwliverInsight.jsx
Normal file
51
src/components/owliver/OwliverInsight.jsx
Normal file
@@ -0,0 +1,51 @@
|
||||
|
||||
/**
|
||||
* The empty/default state as shown in Image 1:
|
||||
*
|
||||
* Good afternoon, Alex
|
||||
* 2 roles rest on a single viable candidate.
|
||||
* -------------------------------------------------
|
||||
* Reading 24 applications across 8 positions — hiring health,
|
||||
* pipeline strength and what needs intervention.
|
||||
*/
|
||||
export default function OwliverInsight({ greeting, userName, insight, loading, failed }) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="px-6 pt-5 pb-6 space-y-3" aria-live="polite">
|
||||
<div className="h-6 w-44 rounded-full animate-pulse bg-neutral-200" />
|
||||
<div className="h-5 w-[85%] rounded-full animate-pulse bg-neutral-200" />
|
||||
<div className="h-[1px] w-full bg-neutral-200/80 my-3" />
|
||||
<div className="h-4 w-[90%] rounded-full animate-pulse bg-neutral-200" />
|
||||
<span className="sr-only">Owliver is reading this page’s data</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Spacing rhythm: header →22px→ greeting →8px→ insight →10px→ rule →10px→
|
||||
// supporting →20px→ suggestions. Dense enough to read as one composed block
|
||||
// rather than three stranded paragraphs.
|
||||
return (
|
||||
<div className="px-6 pt-5 pb-6">
|
||||
<h2 className="text-[22px] font-bold text-slate-900 tracking-tight leading-snug">
|
||||
{greeting}, {userName}
|
||||
</h2>
|
||||
|
||||
<p className="text-[15px] font-medium text-slate-700 mt-2 leading-snug">
|
||||
{failed
|
||||
? "I couldn't read this page's data just now."
|
||||
: insight?.headline || "2 roles rest on a single viable candidate."}
|
||||
</p>
|
||||
|
||||
{/* Horizontal divider line matching Image 1 */}
|
||||
<div className="w-full border-b border-neutral-200/80 my-2.5" />
|
||||
|
||||
<p className="text-[13.5px] text-slate-500 leading-relaxed font-normal">
|
||||
{failed
|
||||
? "You can still ask me anything — I'll pull what I need as we go."
|
||||
: insight?.supporting || "Reading 24 applications across 8 positions — hiring health, pipeline strength and what needs intervention."}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
66
src/components/owliver/OwliverLauncher.jsx
Normal file
66
src/components/owliver/OwliverLauncher.jsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { forwardRef } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { ArrowUp } from "lucide-react";
|
||||
import { useOwliver } from "./OwliverProvider";
|
||||
import OwliverAvatar from "./OwliverAvatar";
|
||||
|
||||
/**
|
||||
* Closed state: a compact prompt-shaped bar pinned to the bottom.
|
||||
* It reads as the entry to the Copilot, not as a chat bubble, and it sits in
|
||||
* dock slot 1 so it always clears the fixed role bar and the safe area.
|
||||
*/
|
||||
const OwliverLauncher = forwardRef(function OwliverLauncher(_props, ref) {
|
||||
const { isOpen, open, domain } = useOwliver();
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{!isOpen && (
|
||||
<motion.button
|
||||
ref={ref}
|
||||
type="button"
|
||||
data-owliver=""
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 10 }}
|
||||
transition={{ duration: 0.18 }}
|
||||
onClick={open}
|
||||
aria-label={`Ask Owliver about ${domain.title}`}
|
||||
className="krow-dock-1 z-40 group flex items-center gap-2.5 h-[54px] pl-2.5 pr-2.5 rounded-full border transition-shadow hover:shadow-[var(--ow-shadow-lift)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
style={{
|
||||
background: "var(--ow-surface-raised)",
|
||||
borderColor: "var(--ow-line-strong)",
|
||||
boxShadow: "var(--ow-shadow)",
|
||||
outlineColor: "var(--ow-brand)",
|
||||
maxWidth: "calc(100vw - 2rem)",
|
||||
}}
|
||||
>
|
||||
<OwliverAvatar size={34} rounded="rounded-full" />
|
||||
|
||||
<span className="hidden sm:flex flex-col items-start min-w-0 pr-1">
|
||||
<span
|
||||
className="text-[13px] font-semibold leading-tight"
|
||||
style={{ color: "var(--ow-ink)" }}
|
||||
>
|
||||
Ask Owliver
|
||||
</span>
|
||||
<span
|
||||
className="text-[11.5px] leading-tight truncate max-w-[9.5rem]"
|
||||
style={{ color: "var(--ow-ink-muted)" }}
|
||||
>
|
||||
{domain.title}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span
|
||||
className="flex items-center justify-center w-8 h-8 rounded-full flex-shrink-0 transition-colors"
|
||||
style={{ background: "var(--ow-brand-wash)", color: "var(--ow-brand)" }}
|
||||
>
|
||||
<ArrowUp className="w-[16px] h-[16px]" />
|
||||
</span>
|
||||
</motion.button>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
});
|
||||
|
||||
export default OwliverLauncher;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user