first commit

This commit is contained in:
2026-08-17 19:45:29 +05:30
commit 3c27da77a8
510 changed files with 137749 additions and 0 deletions

0
src/API_documentation.md Normal file
View File

0
src/App.css Normal file
View File

142
src/App.jsx Normal file
View 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
View 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 &amp; 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
View File

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

9
src/api/entities.js Normal file
View 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
View File

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

BIN
src/assets/krow-logo.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

1
src/assets/react.svg Normal file
View File

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

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,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 />;
}

View File

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

View File

@@ -0,0 +1,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>
);
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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 &amp; available employees ({position.available})
</p>
{position.eligiblePool.length === 0 ? (
<p className="text-sm text-red-600">No eligible &amp; 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>
);
}

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

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

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

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

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

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

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

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

File diff suppressed because it is too large Load Diff

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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