From f449272ef011bebf6cd117e9a0d73d9f45d21bc7 Mon Sep 17 00:00:00 2001 From: bwnyasse <5323628+bwnyasse@users.noreply.github.com> Date: Thu, 13 Nov 2025 13:44:17 -0500 Subject: [PATCH 1/2] feat(Makefile): patch base44Client.js for local development This commit introduces a script to patch the base44Client.js file within the frontend-web directory. This patch is specifically designed to facilitate local development by mocking user roles and authentication, allowing developers to test role-based functionality without needing a full Base44 SDK setup. The Makefile is updated to include a call to this script during the `integrate-export` target, ensuring that the patch is applied automatically when integrating a fresh Base44 export. This change streamlines the local development process and enhances the testing capabilities for role-based features. --- Makefile | 2 + scripts/patch-base44-client.js | 123 +++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 scripts/patch-base44-client.js diff --git a/Makefile b/Makefile index 2c7f1231..38c57f0f 100644 --- a/Makefile +++ b/Makefile @@ -39,6 +39,8 @@ integrate-export: @cp -R ../krow-workforce-export-latest/src ./frontend-web/src @echo " - Copying new index.html..." @cp ../krow-workforce-export-latest/index.html ./frontend-web/index.html + @echo " - Patching base44Client.js for local development..." + @node scripts/patch-base44-client.js @echo "--> Integration complete. Next step: 'make prepare-export'." # Applies all necessary patches to a fresh Base44 export to run it locally. diff --git a/scripts/patch-base44-client.js b/scripts/patch-base44-client.js new file mode 100644 index 00000000..01fda976 --- /dev/null +++ b/scripts/patch-base44-client.js @@ -0,0 +1,123 @@ +const fs = require('fs'); +const path = require('path'); + +const projectRoot = path.resolve(__dirname, '..'); +const clientFilePath = path.join(projectRoot, 'frontend-web', 'src', 'api', 'base44Client.js'); + +const originalMock = `// import { createClient } from '@base44/sdk'; + +// --- MIGRATION MOCK --- +// This mock completely disables the Base44 SDK to allow for local development. +export const base44 = { + auth: { + me: () => Promise.resolve(null), + logout: () => {}, + }, + entities: { + ActivityLog: { + filter: () => Promise.resolve([]), + }, + }, +};`; + +const patchedMock = `// import { createClient } from '@base44/sdk'; + +// --- MIGRATION MOCK --- +// This mock completely disables the Base44 SDK to allow for local development. +// It also simulates user roles for the RoleSwitcher component. + +const MOCK_USER_KEY = 'krow_mock_user_role'; + +// Default mock user data +const DEFAULT_MOCK_USER = { + id: "mock-user-123", + email: "dev@example.com", + full_name: "Dev User", + // 'role' is the Base44 default, 'user_role' is our custom field + role: "admin", + user_role: "admin", // Default role for testing + profile_picture: "https://i.pravatar.cc/150?u=a042581f4e29026024d", +}; + +// Function to get the current mock user state +const getMockUser = () => { + try { + const storedRole = localStorage.getItem(MOCK_USER_KEY); + if (storedRole) { + return { ...DEFAULT_MOCK_USER, user_role: storedRole, role: storedRole }; + } + // If no role is stored, set the default and return it + localStorage.setItem(MOCK_USER_KEY, DEFAULT_MOCK_USER.user_role); + return DEFAULT_MOCK_USER; + } catch (e) { + // localStorage is not available (e.g., in SSR) + return DEFAULT_MOCK_USER; + } +}; + +export const base44 = { + auth: { + me: () => Promise.resolve(getMockUser()), + logout: () => { + try { + localStorage.removeItem(MOCK_USER_KEY); // Clear role on logout + // Optionally, redirect to login page or reload + window.location.reload(); + } catch (e) { + // localStorage is not available + } + return Promise.resolve(); + }, + updateMe: (data) => { + try { + if (data.user_role) { + localStorage.setItem(MOCK_USER_KEY, data.user_role); + } + } catch (e) { + // localStorage is not available + } + // Simulate a successful update + return Promise.resolve({ ...getMockUser(), ...data }); + }, + }, + entities: { + ActivityLog: { + filter: () => Promise.resolve([]), + }, + // Add other entity mocks as needed for the RoleSwitcher to function + // For now, the RoleSwitcher only updates the user role, so other entities might not be critical. + }, + integrations: { + Core: { + SendEmail: () => Promise.resolve({ status: "sent" }), + UploadFile: () => Promise.resolve({ file_url: "mock-file-url" }), + InvokeLLM: () => Promise.resolve({ result: "mock-ai-response" }), + // Add other integration mocks if the RoleSwitcher indirectly calls them + } + } +};`; + +try { + let content = fs.readFileSync(clientFilePath, 'utf8'); + + if (content.includes('me: () => Promise.resolve(null),')) { + content = content.replace(originalMock.trim(), patchedMock.trim()); + fs.writeFileSync(clientFilePath, content, 'utf8'); + console.log('✅ Successfully patched frontend-web/src/api/base44Client.js'); + } else if (content.includes('const MOCK_USER_KEY')) { + console.log('ℹ️ base44Client.js is already patched. Skipping.'); + } else { + // Fallback to a simpler, more brittle replacement if the full mock doesn't match + const simpleOriginal = 'me: () => Promise.resolve(null),'; + if (content.includes(simpleOriginal)) { + fs.writeFileSync(clientFilePath, patchedMock, 'utf8'); + console.log('✅ Successfully patched frontend-web/src/api/base44Client.js using fallback.'); + } else { + console.error('❌ Patching failed: Could not find the original mock code in base44Client.js.'); + process.exit(1); + } + } +} catch (error) { + console.error('❌ An error occurred during patching:', error); + process.exit(1); +} From 80cd49deb567fc77f0a2dfcbb036ddf24c367583 Mon Sep 17 00:00:00 2001 From: bwnyasse <5323628+bwnyasse@users.noreply.github.com> Date: Thu, 13 Nov 2025 14:56:31 -0500 Subject: [PATCH 2/2] feat(Makefile): install frontend dependencies on dev command feat(Makefile): patch Layout.jsx queryKey for local development feat(frontend-web): mock base44 client for local development with role switching feat(frontend-web): add event assignment modal with conflict detection and bulk assign feat(frontend-web): add client dashboard with key metrics and quick actions feat(frontend-web): add layout component with role-based navigation feat(frontend-web): update various pages to use "@/components" alias feat(frontend-web): update create event page with ai assistant toggle feat(frontend-web): update dashboard page with new components feat(frontend-web): update events page with quick assign popover feat(frontend-web): update invite vendor page with hover card feat(frontend-web): update messages page with conversation list and message thread feat(frontend-web): update operator dashboard page with new components feat(frontend-web): update partner management page with new components feat(frontend-web): update permissions page with new components feat(frontend-web): update procurement dashboard page with new components feat(frontend-web): update smart vendor onboarding page with new components feat(frontend-web): update staff directory page with new components feat(frontend-web): update teams page with new components feat(frontend-web): update user management page with new components feat(frontend-web): update vendor compliance page with new components feat(frontend-web): update main.jsx to include react query provider feat: add vendor marketplace page feat: add global import fix to prepare-export script feat: add patch-layout-query-key script to fix query key feat: update patch-base44-client script to use a more robust method --- Makefile | 4 + frontend-web/src/api/base44Client.js | 68 +- .../events/EventAssignmentModal.jsx | 642 +++++++-- frontend-web/src/main.jsx | 4 +- frontend-web/src/pages/ActivityLog.jsx | 2 +- frontend-web/src/pages/AddEnterprise.jsx | 2 +- frontend-web/src/pages/AddPartner.jsx | 2 +- frontend-web/src/pages/AddSector.jsx | 2 +- frontend-web/src/pages/AddStaff.jsx | 2 +- frontend-web/src/pages/Business.jsx | 4 +- frontend-web/src/pages/ClientDashboard.jsx | 1134 ++++++++------- frontend-web/src/pages/ClientOrders.jsx | 2 +- frontend-web/src/pages/CreateEvent.jsx | 4 +- frontend-web/src/pages/Dashboard.jsx | 8 +- frontend-web/src/pages/EditEnterprise.jsx | 2 +- frontend-web/src/pages/EditEvent.jsx | 2 +- frontend-web/src/pages/EditPartner.jsx | 2 +- frontend-web/src/pages/EditSector.jsx | 2 +- frontend-web/src/pages/EditStaff.jsx | 2 +- frontend-web/src/pages/EditVendor.jsx | 2 +- .../src/pages/EnterpriseManagement.jsx | 2 +- frontend-web/src/pages/EventDetail.jsx | 2 +- frontend-web/src/pages/Events.jsx | 8 +- frontend-web/src/pages/InviteVendor.jsx | 2 +- frontend-web/src/pages/Invoices.jsx | 2 +- frontend-web/src/pages/Layout.jsx | 48 +- frontend-web/src/pages/Messages.jsx | 8 +- frontend-web/src/pages/OperatorDashboard.jsx | 2 +- frontend-web/src/pages/PartnerManagement.jsx | 2 +- frontend-web/src/pages/Permissions.jsx | 2 +- .../src/pages/ProcurementDashboard.jsx | 14 +- frontend-web/src/pages/SectorManagement.jsx | 2 +- .../src/pages/SmartVendorOnboarding.jsx | 4 +- frontend-web/src/pages/StaffDirectory.jsx | 8 +- frontend-web/src/pages/Teams.jsx | 2 +- frontend-web/src/pages/UserManagement.jsx | 2 +- frontend-web/src/pages/VendorCompliance.jsx | 2 +- frontend-web/src/pages/VendorDashboard.jsx | 1240 +++++++++-------- .../src/pages/VendorDocumentReview.jsx | 4 +- frontend-web/src/pages/VendorManagement.jsx | 6 +- frontend-web/src/pages/VendorMarketplace.jsx | 922 ++++++++++++ frontend-web/src/pages/VendorOnboarding.jsx | 2 +- frontend-web/src/pages/VendorOrders.jsx | 2 +- frontend-web/src/pages/VendorRates.jsx | 2 +- frontend-web/src/pages/WorkforceDashboard.jsx | 2 +- frontend-web/src/pages/index.jsx | 6 + scripts/patch-base44-client.js | 39 +- scripts/patch-layout-query-key.js | 26 + scripts/prepare-export.js | 190 +-- 49 files changed, 2937 insertions(+), 1508 deletions(-) create mode 100644 frontend-web/src/pages/VendorMarketplace.jsx create mode 100644 scripts/patch-layout-query-key.js diff --git a/Makefile b/Makefile index 38c57f0f..97783865 100644 --- a/Makefile +++ b/Makefile @@ -16,6 +16,8 @@ install: # Starts the local development server. dev: + @echo "--> Ensuring web frontend dependencies are installed..." + @cd frontend-web && npm install @echo "--> Starting web frontend development server on http://localhost:5173 ..." @cd frontend-web && npm run dev @@ -41,6 +43,8 @@ integrate-export: @cp ../krow-workforce-export-latest/index.html ./frontend-web/index.html @echo " - Patching base44Client.js for local development..." @node scripts/patch-base44-client.js + @echo " - Patching queryKey in Layout.jsx for local development..." + @node scripts/patch-layout-query-key.js @echo "--> Integration complete. Next step: 'make prepare-export'." # Applies all necessary patches to a fresh Base44 export to run it locally. diff --git a/frontend-web/src/api/base44Client.js b/frontend-web/src/api/base44Client.js index 5aad6666..d9c4ab2b 100644 --- a/frontend-web/src/api/base44Client.js +++ b/frontend-web/src/api/base44Client.js @@ -2,15 +2,75 @@ // --- MIGRATION MOCK --- // This mock completely disables the Base44 SDK to allow for local development. +// It also simulates user roles for the RoleSwitcher component. + +const MOCK_USER_KEY = 'krow_mock_user_role'; + +// Default mock user data +const DEFAULT_MOCK_USER = { + id: "mock-user-123", + email: "dev@example.com", + full_name: "Dev User", + // 'role' is the Base44 default, 'user_role' is our custom field + role: "admin", + user_role: "admin", // Default role for testing + profile_picture: "https://i.pravatar.cc/150?u=a042581f4e29026024d", +}; + +// Function to get the current mock user state +const getMockUser = () => { + try { + const storedRole = localStorage.getItem(MOCK_USER_KEY); + if (storedRole) { + return { ...DEFAULT_MOCK_USER, user_role: storedRole, role: storedRole }; + } + // If no role is stored, set the default and return it + localStorage.setItem(MOCK_USER_KEY, DEFAULT_MOCK_USER.user_role); + return DEFAULT_MOCK_USER; + } catch (e) { + // localStorage is not available (e.g., in SSR) + return DEFAULT_MOCK_USER; + } +}; + export const base44 = { auth: { - me: () => Promise.resolve(null), - logout: () => {}, + me: () => Promise.resolve(getMockUser()), + logout: () => { + try { + localStorage.removeItem(MOCK_USER_KEY); // Clear role on logout + // Optionally, redirect to login page or reload + window.location.reload(); + } catch (e) { + // localStorage is not available + } + return Promise.resolve(); + }, + updateMe: (data) => { + try { + if (data.user_role) { + localStorage.setItem(MOCK_USER_KEY, data.user_role); + } + } catch (e) { + // localStorage is not available + } + // Simulate a successful update + return Promise.resolve({ ...getMockUser(), ...data }); + }, }, entities: { ActivityLog: { filter: () => Promise.resolve([]), }, + // Add other entity mocks as needed for the RoleSwitcher to function + // For now, the RoleSwitcher only updates the user role, so other entities might not be critical. }, -}; - + integrations: { + Core: { + SendEmail: () => Promise.resolve({ status: "sent" }), + UploadFile: () => Promise.resolve({ file_url: "mock-file-url" }), + InvokeLLM: () => Promise.resolve({ result: "mock-ai-response" }), + // Add other integration mocks if the RoleSwitcher indirectly calls them + } + } +}; \ No newline at end of file diff --git a/frontend-web/src/components/events/EventAssignmentModal.jsx b/frontend-web/src/components/events/EventAssignmentModal.jsx index 95f24e57..733b8d13 100644 --- a/frontend-web/src/components/events/EventAssignmentModal.jsx +++ b/frontend-web/src/components/events/EventAssignmentModal.jsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useState, useMemo } from "react"; import { base44 } from "@/api/base44Client"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { @@ -10,9 +10,11 @@ import { import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; -import { Calendar, Users, Check, Plus, X, Clock, MapPin, ChevronLeft, ChevronRight } from "lucide-react"; -import { format } from "date-fns"; +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) => { @@ -40,9 +42,32 @@ const avatarColors = [ '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 }) { 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(); @@ -52,13 +77,20 @@ export default function EventAssignmentModal({ open, onClose, order, onUpdate }) 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", + title: "✅ Staff assigned successfully", description: "The order has been updated with new assignments.", }); }, @@ -73,6 +105,152 @@ export default function EventAssignmentModal({ open, onClose, order, onUpdate }) 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 || []; + + // 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; + } + + const needed = parseInt(currentRole.count) || 0; + const currentAssigned = assignments.length; + const remaining = needed - currentAssigned; + + if (selectedStaffIds.length > remaining) { + toast({ + title: "Too many selected", + 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 || []; @@ -87,6 +265,17 @@ export default function EventAssignmentModal({ open, onClose, order, onUpdate }) 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, @@ -117,14 +306,63 @@ export default function EventAssignmentModal({ open, onClose, order, onUpdate }) 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 available staff (not already assigned to this role) + // Filter staff by role and exclude already assigned const assignedIds = new Set(assignments.map(a => a.employee_id)); - const availableStaff = allStaff.filter(s => !assignedIds.has(s.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; @@ -134,9 +372,27 @@ export default function EventAssignmentModal({ open, onClose, order, onUpdate }) 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 ( - +
@@ -147,12 +403,22 @@ export default function EventAssignmentModal({ open, onClose, order, onUpdate })
= 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`} > - {isFullyStaffed ? 'Fully Staffed' : 'Needs Staff'} + {totalAssigned >= totalNeeded ? ( + <> + + Fully Staffed + + ) : ( + <> + + Needs Staff + + )}
@@ -181,21 +447,21 @@ export default function EventAssignmentModal({ open, onClose, order, onUpdate }) = totalNeeded ? '#10b981' : '#f97316', - color: totalAssigned >= totalNeeded ? '#10b981' : '#f97316' - }} + 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} {totalAssigned >= totalNeeded && ( -
-
- - Fully staffed +
+
+ + ✨ Fully staffed - All positions filled!
)} @@ -207,7 +473,11 @@ export default function EventAssignmentModal({ open, onClose, order, onUpdate }) setSearchTerm(e.target.value)} + className="pl-10" + /> +
+

+ Showing {availableStaff.length} {currentRole.service.toLowerCase()}(s) + {roleFilteredStaff.length !== allStaff.length && ( + (filtered by role) + )} +

+
+ + {availableStaff.length > 0 ? ( +
+ {availableStaff.map((staff, idx) => { + const isSelected = selectedStaffIds.includes(staff.id); + const conflicts = getStaffConflicts(staff.id); + const hasConflict = conflicts.length > 0; + + return ( +
- - Assign - -
- ))} +
+ {!swapMode && ( + toggleStaffSelection(staff.id)} + disabled={hasConflict} + className="border-2" + /> + )} + + + {getInitials(staff.employee_name)} + + +
+

{staff.employee_name}

+
+

{staff.position || 'Staff Member'}

+ {staff.position_2 && ( + {staff.position_2} + )} +
+ {hasConflict && ( +
+ + + Conflict: {conflicts[0].orderName} ({conflicts[0].time}) + +
+ )} +
+
+ +
+ ); + })} ) : ( -
- -

All available staff have been assigned

+
+ +

+ {searchTerm + ? 'No staff match your search' + : `No available ${currentRole.service.toLowerCase()}s found` + } +

+

+ {searchTerm ? 'Try a different search term' : 'All matching staff have been assigned'} +

)}
)} -
+
{selectedShiftIndex > 0 && ( )} {selectedShiftIndex < order.shifts_data.length - 1 && ( )}
- +
+ = totalNeeded + ? 'bg-emerald-50 border-emerald-500 text-emerald-700' + : 'bg-orange-50 border-orange-500 text-orange-700' + }`} + > + {totalAssigned}/{totalNeeded} Filled + + +
diff --git a/frontend-web/src/main.jsx b/frontend-web/src/main.jsx index c9d099e1..0190100d 100644 --- a/frontend-web/src/main.jsx +++ b/frontend-web/src/main.jsx @@ -2,6 +2,7 @@ import React from 'react' import ReactDOM from 'react-dom/client' import App from '@/App.jsx' import '@/index.css' + import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; const queryClient = new QueryClient(); @@ -12,5 +13,4 @@ ReactDOM.createRoot(document.getElementById('root')).render( , -) - \ No newline at end of file +) \ No newline at end of file diff --git a/frontend-web/src/pages/ActivityLog.jsx b/frontend-web/src/pages/ActivityLog.jsx index c03ba2aa..695162cb 100644 --- a/frontend-web/src/pages/ActivityLog.jsx +++ b/frontend-web/src/pages/ActivityLog.jsx @@ -19,7 +19,7 @@ import { Filter } from "lucide-react"; import { formatDistanceToNow } from "date-fns"; -import PageHeader from "../components/common/PageHeader"; +import PageHeader from "@/components/common/PageHeader"; const iconMap = { calendar: Calendar, diff --git a/frontend-web/src/pages/AddEnterprise.jsx b/frontend-web/src/pages/AddEnterprise.jsx index d937c4aa..7306b7a3 100644 --- a/frontend-web/src/pages/AddEnterprise.jsx +++ b/frontend-web/src/pages/AddEnterprise.jsx @@ -9,7 +9,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { Building2, ArrowLeft, Check } from "lucide-react"; -import PageHeader from "../components/common/PageHeader"; +import PageHeader from "@/components/common/PageHeader"; import { useToast } from "@/components/ui/use-toast"; export default function AddEnterprise() { diff --git a/frontend-web/src/pages/AddPartner.jsx b/frontend-web/src/pages/AddPartner.jsx index 07828208..bcd231c8 100644 --- a/frontend-web/src/pages/AddPartner.jsx +++ b/frontend-web/src/pages/AddPartner.jsx @@ -10,7 +10,7 @@ import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; import { Briefcase, ArrowLeft, Check, Plus, X } from "lucide-react"; -import PageHeader from "../components/common/PageHeader"; +import PageHeader from "@/components/common/PageHeader"; import { useToast } from "@/components/ui/use-toast"; export default function AddPartner() { diff --git a/frontend-web/src/pages/AddSector.jsx b/frontend-web/src/pages/AddSector.jsx index 23b3a364..101bc509 100644 --- a/frontend-web/src/pages/AddSector.jsx +++ b/frontend-web/src/pages/AddSector.jsx @@ -9,7 +9,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { MapPin, ArrowLeft, Check } from "lucide-react"; -import PageHeader from "../components/common/PageHeader"; +import PageHeader from "@/components/common/PageHeader"; import { useToast } from "@/components/ui/use-toast"; export default function AddSector() { diff --git a/frontend-web/src/pages/AddStaff.jsx b/frontend-web/src/pages/AddStaff.jsx index 131b765b..807f402b 100644 --- a/frontend-web/src/pages/AddStaff.jsx +++ b/frontend-web/src/pages/AddStaff.jsx @@ -5,7 +5,7 @@ import { useNavigate } from "react-router-dom"; import { createPageUrl } from "@/utils"; import { Button } from "@/components/ui/button"; import { ArrowLeft } from "lucide-react"; -import StaffForm from "../components/staff/StaffForm"; +import StaffForm from "@/components/staff/StaffForm"; export default function AddStaff() { const navigate = useNavigate(); diff --git a/frontend-web/src/pages/Business.jsx b/frontend-web/src/pages/Business.jsx index 49d954d7..269c59b4 100644 --- a/frontend-web/src/pages/Business.jsx +++ b/frontend-web/src/pages/Business.jsx @@ -10,13 +10,13 @@ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Plus, Building2, Mail, Phone, MapPin, Search, Eye, Trash2, ChevronDown, ChevronRight } from "lucide-react"; import { Link, useNavigate } from "react-router-dom"; import { createPageUrl } from "@/utils"; -import PageHeader from "../components/common/PageHeader"; +import PageHeader from "@/components/common/PageHeader"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; -import CreateBusinessModal from "../components/business/CreateBusinessModal"; +import CreateBusinessModal from "@/components/business/CreateBusinessModal"; export default function Business() { const navigate = useNavigate(); diff --git a/frontend-web/src/pages/ClientDashboard.jsx b/frontend-web/src/pages/ClientDashboard.jsx index d7c50dac..9ccfd321 100644 --- a/frontend-web/src/pages/ClientDashboard.jsx +++ b/frontend-web/src/pages/ClientDashboard.jsx @@ -1,15 +1,17 @@ -import React, { useState } from "react"; +import React, { useState, useMemo } from "react"; import { base44 } from "@/api/base44Client"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { Link, useNavigate } from "react-router-dom"; import { createPageUrl } from "@/utils"; import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; -import { Calendar, Plus, Clock, DollarSign, MessageSquare, RefreshCw, ArrowRight, Users, CloudOff, MapPin, AlertTriangle, Package, Download, TrendingUp, TrendingDown, BarChart3, Sparkles } from "lucide-react"; -import { format, parseISO, differenceInDays, startOfMonth, endOfMonth, subMonths } from "date-fns"; +import { Calendar, Plus, Clock, DollarSign, MessageSquare, RefreshCw, ArrowRight, Users, TrendingUp, TrendingDown, BarChart3, Sparkles, Zap, CheckCircle, AlertCircle, Coffee, ChevronRight, User } from "lucide-react"; +import { format, parseISO, startOfMonth, endOfMonth, isToday, isTomorrow, addDays, differenceInDays } from "date-fns"; import { useToast } from "@/components/ui/use-toast"; -import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; +import { PieChart, Pie, Cell, ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, Legend } from 'recharts'; + +const COLORS = ['#0A39DF', '#6366f1', '#8b5cf6', '#a855f7', '#c026d3', '#d946ef']; export default function ClientDashboard() { const navigate = useNavigate(); @@ -31,11 +33,9 @@ export default function ClientDashboard() { e.business_name === user?.company_name || e.created_by === user?.email ); - if (clientEvents.length === 0) { return allEvents.filter(e => e.status === "Completed"); } - return clientEvents; }, initialData: [], @@ -62,41 +62,31 @@ export default function ClientDashboard() { }, }); - // Enhanced Analytics Calculations - const todayOrders = events.filter(e => { - const eventDate = new Date(e.date); - const today = new Date(); - return eventDate.toDateString() === today.toDateString(); - }); - - const activeOrders = events.filter(e => e.status === "Active" || e.status === "Confirmed").length; - const completedOrders = events.filter(e => e.status === "Completed"); - const totalSpending = completedOrders.reduce((sum, e) => sum + (e.total || 0), 0); - const needsAttention = events.filter(e => e.status === "Pending" || e.status === "Draft").length; - - // Monthly spending trend (last 6 months) - const last6Months = Array.from({ length: 6 }, (_, i) => { - const date = subMonths(new Date(), 5 - i); - return { - month: format(date, 'MMM'), - fullDate: date - }; - }); - - const spendingTrend = last6Months.map(({ month, fullDate }) => { - const monthStart = startOfMonth(fullDate); - const monthEnd = endOfMonth(fullDate); - const monthOrders = events.filter(e => { + // Today's orders + const todayOrders = useMemo(() => { + return events.filter(e => { const eventDate = new Date(e.date); - return eventDate >= monthStart && eventDate <= monthEnd && e.status === "Completed"; + return isToday(eventDate); }); - const spend = monthOrders.reduce((sum, e) => sum + (e.total || 0), 0); - const orderCount = monthOrders.length; - const staffCount = monthOrders.reduce((sum, e) => sum + (e.requested || 0), 0); - return { month, spend, orderCount, staffCount }; - }); + }, [events]); - // Cost breakdown by category + // Upcoming orders (next 7 days) + const upcomingOrders = useMemo(() => { + return events + .filter(e => { + const eventDate = new Date(e.date); + const today = new Date(); + const daysUntil = differenceInDays(eventDate, today); + return daysUntil > 0 && daysUntil <= 7 && e.status !== "Canceled"; + }) + .sort((a, b) => new Date(a.date) - new Date(b.date)) + .slice(0, 5); + }, [events]); + + // Completed orders for analytics + const completedOrders = events.filter(e => e.status === "Completed"); + + // Current month data const currentMonth = new Date().getMonth(); const currentYear = new Date().getFullYear(); const thisMonthOrders = events.filter(e => { @@ -106,41 +96,85 @@ export default function ClientDashboard() { e.status === "Completed"; }); + // Calculate labor summary by position + const laborByPosition = useMemo(() => { + const summary = {}; + + thisMonthOrders.forEach(order => { + if (order.shifts_data) { + order.shifts_data.forEach(shift => { + shift.roles?.forEach(role => { + const position = role.service || 'Other'; + const count = parseInt(role.count) || 0; + const startTime = role.start_time || '00:00'; + const endTime = role.end_time || '00:00'; + + // Calculate hours + const [startHour, startMin] = startTime.split(':').map(Number); + const [endHour, endMin] = endTime.split(':').map(Number); + const hours = (endHour * 60 + endMin - startHour * 60 - startMin) / 60; + const totalHours = hours * count; + + const rate = role.bill_rate || role.pay_rate || 25; + const cost = totalHours * rate; + + if (!summary[position]) { + summary[position] = { + position, + headcount: 0, + hours: 0, + cost: 0 + }; + } + + summary[position].headcount += count; + summary[position].hours += totalHours; + summary[position].cost += cost; + }); + }); + } else if (order.requested) { + const position = 'Staff'; + const count = order.requested; + const hours = 8 * count; + const cost = hours * 25; + + if (!summary[position]) { + summary[position] = { + position, + headcount: 0, + hours: 0, + cost: 0 + }; + } + + summary[position].headcount += count; + summary[position].hours += hours; + summary[position].cost += cost; + } + }); + + return Object.values(summary).sort((a, b) => b.cost - a.cost); + }, [thisMonthOrders]); + + // Cost breakdown + const totalLaborCost = laborByPosition.reduce((sum, p) => sum + p.cost, 0); + const totalHours = laborByPosition.reduce((sum, p) => sum + p.hours, 0); + const totalHeadcount = laborByPosition.reduce((sum, p) => sum + p.headcount, 0); + const avgCostPerHour = totalHours > 0 ? totalLaborCost / totalHours : 0; + + // Last month comparison + const lastMonth = new Date(); + lastMonth.setMonth(lastMonth.getMonth() - 1); const lastMonthOrders = events.filter(e => { const eventDate = new Date(e.date); - const lastMonth = subMonths(new Date(), 1); return eventDate.getMonth() === lastMonth.getMonth() && eventDate.getFullYear() === lastMonth.getFullYear() && e.status === "Completed"; }); + const lastMonthCost = lastMonthOrders.reduce((sum, e) => sum + (e.total || 0), 0); + const costChange = lastMonthCost > 0 ? ((totalLaborCost - lastMonthCost) / lastMonthCost) * 100 : 0; - const thisMonthSpend = thisMonthOrders.reduce((sum, e) => sum + (e.total || 0), 0); - const lastMonthSpend = lastMonthOrders.reduce((sum, e) => sum + (e.total || 0), 0); - const spendChange = lastMonthSpend > 0 ? ((thisMonthSpend - lastMonthSpend) / lastMonthSpend) * 100 : 0; - - // Labor analytics - const totalStaffHired = completedOrders.reduce((sum, e) => sum + (e.requested || 0), 0); - const thisMonthStaff = thisMonthOrders.reduce((sum, e) => sum + (e.requested || 0), 0); - const lastMonthStaff = lastMonthOrders.reduce((sum, e) => sum + (e.requested || 0), 0); - const staffChange = lastMonthStaff > 0 ? ((thisMonthStaff - lastMonthStaff) / lastMonthStaff) * 100 : 0; - - const avgStaffPerOrder = completedOrders.length > 0 ? totalStaffHired / completedOrders.length : 0; - const costPerStaff = totalStaffHired > 0 ? totalSpending / totalStaffHired : 0; - - // Sales analytics - const avgOrderValue = completedOrders.length > 0 ? totalSpending / completedOrders.length : 0; - - // Upcoming - const upcomingEvents = events - .filter(e => { - const eventDate = new Date(e.date); - const today = new Date(); - return eventDate > today && e.status !== "Canceled"; - }) - .sort((a, b) => new Date(a.date) - new Date(b.date)) - .slice(0, 5); - - // Frequent orders + // Frequent orders for quick reorder const pastOrders = events.filter(e => e.status === "Completed"); const orderFrequency = pastOrders.reduce((acc, event) => { const key = event.event_name; @@ -148,15 +182,12 @@ export default function ClientDashboard() { acc[key] = { event, count: 0, - lastOrdered: event.date, - totalSpent: 0, - avgCost: 0, - totalStaff: 0 + lastOrdered: event.date, + totalCost: 0 }; } acc[key].count++; - acc[key].totalSpent += (event.total || 0); - acc[key].totalStaff += (event.requested || 0); + acc[key].totalCost += (event.total || 0); if (new Date(event.date) > new Date(acc[key].lastOrdered)) { acc[key].lastOrdered = event.date; acc[key].event = event; @@ -164,22 +195,12 @@ export default function ClientDashboard() { return acc; }, {}); - Object.values(orderFrequency).forEach(item => { - item.avgCost = item.count > 0 ? item.totalSpent / item.count : 0; - }); - - const frequentOrders = Object.values(orderFrequency) + const favoriteOrders = Object.values(orderFrequency) .sort((a, b) => b.count - a.count) - .slice(0, 3); - - const totalReorders = frequentOrders.reduce((sum, item) => sum + item.count, 0); - const timeSavedMinutes = totalReorders * 15; - const timeSavedHours = Math.floor(timeSavedMinutes / 60); - const remainingMinutes = timeSavedMinutes % 60; + .slice(0, 4); const handleQuickReorder = (event) => { setReorderingId(event.id); - const reorderData = { event_name: event.event_name, business_id: event.business_id, @@ -190,556 +211,513 @@ export default function ClientDashboard() { shifts: event.shifts, notes: `Reorder of: ${event.event_name}`, }; - createEventMutation.mutate(reorderData); }; - const handleRefresh = () => { - window.location.reload(); + const handleRapidOrder = () => { + navigate(createPageUrl("CreateEvent") + "?rapid=true"); }; const hour = new Date().getHours(); const greeting = hour < 12 ? "Good morning" : hour < 18 ? "Good afternoon" : "Good evening"; + // Prepare data for pie chart + const pieChartData = laborByPosition.slice(0, 5).map(item => ({ + name: item.position, + value: item.cost + })); + return ( -
-
+
+
- {/* Header */} + {/* Hero Header */}
-

- {greeting}, {user?.full_name?.split(' ')[0] || 'Client'} -

-

Here's what's happening with your orders

+
+

+ {greeting}, {user?.full_name?.split(' ')[0] || 'there'} +

+ +
+

Your staffing operations at a glance

- - + +
- {/* Hero Stats - 4 Cards */} -
- - - -
-
- + {/* Today's Orders - Prominent Section */} + {todayOrders.length > 0 && ( + + +
+
+
+
- -
-

Create New

-

Order Now

- - - - - - -
-
- -
- {todayOrders.length > 0 ? 'Active' : 'None'} -
-

Today's Orders

-

{todayOrders.length}

-
-
- - - -
-
- -
- Active -
-

In Progress

-

{activeOrders}

-
-
- - {needsAttention > 0 ? ( - - - -
-
- -
- -
-

Needs Attention

-

{needsAttention}

-
-
- - ) : ( - - -
-
- -
- All Clear -
-

Status

-

All Good!

-
-
- )} -
- - {/* Main Content Grid */} -
- - {/* Left Column - Analytics (2 cols) */} -
- - {/* Big Analytics Cards */} -
- {/* Cost Analysis */} - - -
-
- -
-
-

Cost

-

Analysis

-
-
- -
-
-

This Month

-
-

- ${(thisMonthSpend / 1000).toFixed(1)}k -

- {spendChange !== 0 && ( -
0 ? 'text-green-600' : 'text-red-600'}`}> - {spendChange > 0 ? : } - {Math.abs(spendChange).toFixed(0)}% -
- )} -
-
- -
-
- Avg Order - ${Math.round(avgOrderValue).toLocaleString()} -
-
- Cost/Staff - ${Math.round(costPerStaff)} -
-
- -
- - - - - -
-
-
-
- - {/* Labor Summary */} - - -
-
- -
-
-

Labor

-

Summary

-
-
- -
-
-

This Month

-
-

{thisMonthStaff}

- {staffChange !== 0 && ( -
0 ? 'text-blue-600' : 'text-red-600'}`}> - {staffChange > 0 ? : } - {Math.abs(staffChange).toFixed(0)}% -
- )} -
-
- -
-
- Total Staff - {totalStaffHired} -
-
- Avg/Order - {Math.round(avgStaffPerOrder)} -
-
- -
- - - - - -
-
-
-
- - {/* Sales Analytics */} - - -
-
- -
-
-

Sales

-

Analytics

-
-
- -
-
-

This Month

-
-

{thisMonthOrders.length}

- orders -
-
- -
-
- Total Orders - {events.length} -
-
- Completed - {completedOrders.length} -
-
- -
- - - - - -
-
-
-
-
- - {/* 6-Month Trend */} - - -
-

Spending Trend

-

Last 6 months overview

-
- 6 Months -
- - - - - `$${(value / 1000).toFixed(0)}k`} - /> - [`$${Math.round(value).toLocaleString()}`, 'Spend']} - /> - - - -
-
- - {/* Quick Reorder */} - - -
-
-

- - Order it again -

-

- One tap to reorder • Saved {timeSavedHours}h {remainingMinutes}m + Today's Orders +

+ {format(new Date(), 'EEEE, MMMM d, yyyy')}

- Top 3
- - {frequentOrders.length > 0 ? ( -
- {frequentOrders.map((item, index) => { - const { event, count, avgCost, totalStaff, lastOrdered } = item; - const isReordering = reorderingId === event.id; - const medals = [ - { icon: "🥇", color: "from-yellow-400 to-amber-500" }, - { icon: "🥈", color: "from-slate-400 to-slate-500" }, - { icon: "🥉", color: "from-amber-600 to-orange-700" } - ]; - const medal = medals[index]; - - return ( -
-
-
- {medal.icon} + + {todayOrders.length} Active + +
+ + +
+ {todayOrders.map((order) => { + const assignedCount = order.assigned_staff?.length || 0; + const requestedCount = order.requested || 0; + const fillPercent = requestedCount > 0 ? Math.round((assignedCount / requestedCount) * 100) : 0; + + return ( + + + +
+

+ {order.event_name} +

+ {fillPercent >= 100 ? ( + + ) : ( + + )} +
+ +
+
+ + {order.shifts?.[0]?.roles?.[0]?.start_time || 'Time TBD'} +
+
+ + {assignedCount}/{requestedCount} Staff
-
- - Ordered {count}x - +
+
+ Staffing Progress + {fillPercent}% +
+
+
= 100 ? 'bg-green-500' : + fillPercent >= 50 ? 'bg-blue-500' : + 'bg-orange-500' + }`} + style={{ width: `${fillPercent}%` }} + /> +
+
+ + + + ); + })} +
+ + + )} -

+ {/* Main Dashboard Grid */} +
+ + {/* Left Column - Labor & Cost Analytics */} +
+ + {/* Labor Summary */} + + +
+
+
+ +
+
+ Labor Summary +

This month breakdown

+
+
+ + {thisMonthOrders.length} Orders + +
+
+ +
+ + + + + + + + + + + + {laborByPosition.length > 0 ? ( + laborByPosition.map((item, idx) => ( + + + + + + + + )) + ) : ( + + + + )} + {laborByPosition.length > 0 && ( + + + + + + + + )} + +
PositionHeadcountHoursTotal CostAvg/Hour
+
+
+ {item.position} +
+
+ + {item.headcount} + + + + {Math.round(item.hours)}h + + + + ${Math.round(item.cost).toLocaleString()} + + + + ${Math.round(item.cost / item.hours)}/hr + +
+ No labor data for this month +
TOTAL{totalHeadcount}{Math.round(totalHours)}h + ${Math.round(totalLaborCost).toLocaleString()} + + ${Math.round(avgCostPerHour)}/hr +
+
+
+
+ + {/* Cost Analysis */} +
+ {/* Pie Chart */} + + + Cost Distribution + + + {pieChartData.length > 0 ? ( + <> + + + + {pieChartData.map((entry, index) => ( + + ))} + + `$${Math.round(value).toLocaleString()}`} + /> + + +
+ {pieChartData.map((item, idx) => ( +
+
+
+ {item.name} +
+ + ${Math.round(item.value).toLocaleString()} + +
+ ))} +
+ + ) : ( +
+ No cost data available +
+ )} + + + + {/* Cost Summary Cards */} +
+ + +
+
+ +
+
+

This Month

+

+ ${Math.round(totalLaborCost / 1000)}k +

+
+
+
+ {costChange >= 0 ? ( + + ) : ( + + )} + = 0 ? 'text-red-600' : 'text-green-600' + }`}> + {Math.abs(costChange).toFixed(1)}% vs last month + +
+
+
+ + + +

Avg Cost per Hour

+

+ ${Math.round(avgCostPerHour)} +

+

+ Across {totalHours.toFixed(0)} hours +

+
+
+ + + +

Total Staff Hired

+

+ {totalHeadcount} +

+

+ This month +

+
+
+
+
+
+ + {/* Right Column - Quick Actions */} +
+ + {/* Quick Reorder - DoorDash Style */} + + +
+
+ +
+
+ Reorder Favorites +

One tap to reorder

+
+
+
+ + {favoriteOrders.length > 0 ? ( + favoriteOrders.map((item) => { + const { event, count } = item; + const isReordering = reorderingId === event.id; + + return ( +
+
+
+
+ + Ordered {count}x + +
+

{event.event_name}

- - {event.hub && ( -
- - {event.hub} -
- )} - -
-
-

Avg Cost

-

- ${(avgCost / 1000).toFixed(1)}k -

-
-
-

Staff

-

- {Math.round(totalStaff / count)} -

-
-
- -
- - Last ordered {format(parseISO(lastOrdered), "MMM d")} -
- - +

+ {event.hub || 'No location'} +

- ); - })} -
+ +
+
+ Last: + + {format(parseISO(event.date), "MMM d")} + +
+ +
+
+ ); + }) ) : ( -
-
- -
-

No previous orders yet

-

Complete your first order to see recommendations

+
+ +

No previous orders

+

Your favorites will appear here

)} -
- {/* Right Column - Quick Access (1 col) */} -
- - {/* Financial Card */} - - -
-

Total Spending

-
-
-
-
+ {/* Upcoming Orders */} + + +
+
+
+ +
+
+ Coming Up +

Next 7 days

+
-

- ${Math.round(totalSpending / 1000)}k -

-

Lifetime value • {completedOrders.length} orders

+
+ + {upcomingOrders.length > 0 ? ( + upcomingOrders.map((order) => { + const eventDate = new Date(order.date); + const daysUntil = differenceInDays(eventDate, new Date()); + const isUrgent = daysUntil <= 2; + + return ( + +
+
+

+ {order.event_name} +

+ {isUrgent && ( + + {daysUntil === 0 ? 'Today' : daysUntil === 1 ? 'Tomorrow' : `${daysUntil}d`} + + )} +
+
+ {format(eventDate, "MMM d, h:mm a")} + +
+
+ + ); + }) + ) : ( +
+ +

No upcoming orders

+
+ )}
{/* Quick Actions */}
- - + + -
- +
+
-

All Orders

-

{events.length} total

+

Find Vendors

+

Browse marketplace

- + -
- +
+
-

Messages

-

Get support

+

Messages

+

Chat with vendors

- - {/* Coming Up */} - - -
-

- - Coming Up -

- - - -
- - {upcomingEvents.length > 0 ? ( -
- {upcomingEvents.map((event) => { - const daysUntil = differenceInDays(new Date(event.date), new Date()); - const isUrgent = daysUntil <= 3; - const isToday = daysUntil === 0; - - return ( - -
-

- {event.event_name} -

- {(isToday || isUrgent) && ( - - {isToday ? "Today" : `${daysUntil}d`} - - )} -
-
- - {format(new Date(event.date), "MMM d, h:mm a")} - - - {event.requested || 0} staff -
- - ); - })} -
- ) : ( -
-
- -
-

No upcoming orders

-
- )} -
-
diff --git a/frontend-web/src/pages/ClientOrders.jsx b/frontend-web/src/pages/ClientOrders.jsx index b9041722..17a281e7 100644 --- a/frontend-web/src/pages/ClientOrders.jsx +++ b/frontend-web/src/pages/ClientOrders.jsx @@ -9,7 +9,7 @@ import { useNavigate } from "react-router-dom"; import { createPageUrl } from "@/utils"; import { format, addDays } from "date-fns"; import { useToast } from "@/components/ui/use-toast"; -import QuickReorderModal from "../components/events/QuickReorderModal"; +import QuickReorderModal from "@/components/events/QuickReorderModal"; export default function ClientOrders() { const navigate = useNavigate(); diff --git a/frontend-web/src/pages/CreateEvent.jsx b/frontend-web/src/pages/CreateEvent.jsx index 6511c47a..744a32a5 100644 --- a/frontend-web/src/pages/CreateEvent.jsx +++ b/frontend-web/src/pages/CreateEvent.jsx @@ -3,8 +3,8 @@ import { base44 } from "@/api/base44Client"; import { useMutation, useQueryClient, useQuery } from "@tanstack/react-query"; import { useNavigate } from "react-router-dom"; import { createPageUrl } from "@/utils"; -import EventFormWizard from "../components/events/EventFormWizard"; -import AIOrderAssistant from "../components/events/AIOrderAssistant"; +import EventFormWizard from "@/components/events/EventFormWizard"; +import AIOrderAssistant from "@/components/events/AIOrderAssistant"; import { useToast } from "@/components/ui/use-toast"; import { Button } from "@/components/ui/button"; import { Sparkles, FileText, X } from "lucide-react"; diff --git a/frontend-web/src/pages/Dashboard.jsx b/frontend-web/src/pages/Dashboard.jsx index dd7bf6a2..7cf606f1 100644 --- a/frontend-web/src/pages/Dashboard.jsx +++ b/frontend-web/src/pages/Dashboard.jsx @@ -7,10 +7,10 @@ import { createPageUrl } from "@/utils"; import { Button } from "@/components/ui/button"; import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; import { Users, Building2, UserPlus, TrendingUp, MapPin, Calendar, DollarSign, Award, Target, BarChart3, Shield, Leaf } from "lucide-react"; -import StatsCard from "../components/staff/StatsCard"; -import EcosystemWheel from "../components/dashboard/EcosystemWheel"; -import QuickMetrics from "../components/dashboard/QuickMetrics"; -import PageHeader from "../components/common/PageHeader"; +import StatsCard from "@/components/staff/StatsCard"; +import EcosystemWheel from "@/components/dashboard/EcosystemWheel"; +import QuickMetrics from "@/components/dashboard/QuickMetrics"; +import PageHeader from "@/components/common/PageHeader"; export default function Dashboard() { const navigate = useNavigate(); diff --git a/frontend-web/src/pages/EditEnterprise.jsx b/frontend-web/src/pages/EditEnterprise.jsx index 64270aad..f6b28cd0 100644 --- a/frontend-web/src/pages/EditEnterprise.jsx +++ b/frontend-web/src/pages/EditEnterprise.jsx @@ -9,7 +9,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { Building2, ArrowLeft, Save, Loader2 } from "lucide-react"; -import PageHeader from "../components/common/PageHeader"; +import PageHeader from "@/components/common/PageHeader"; import { useToast } from "@/components/ui/use-toast"; export default function EditEnterprise() { diff --git a/frontend-web/src/pages/EditEvent.jsx b/frontend-web/src/pages/EditEvent.jsx index 91755d51..fd9e9862 100644 --- a/frontend-web/src/pages/EditEvent.jsx +++ b/frontend-web/src/pages/EditEvent.jsx @@ -5,7 +5,7 @@ import { useNavigate } from "react-router-dom"; import { createPageUrl } from "@/utils"; import { Button } from "@/components/ui/button"; import { ArrowLeft, Loader2 } from "lucide-react"; -import EventForm from "../components/events/EventForm"; +import EventForm from "@/components/events/EventForm"; export default function EditEvent() { const navigate = useNavigate(); diff --git a/frontend-web/src/pages/EditPartner.jsx b/frontend-web/src/pages/EditPartner.jsx index 3829d161..abff6377 100644 --- a/frontend-web/src/pages/EditPartner.jsx +++ b/frontend-web/src/pages/EditPartner.jsx @@ -11,7 +11,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { Textarea } from "@/components/ui/textarea"; import { Switch } from "@/components/ui/switch"; import { Briefcase, ArrowLeft, Save, Plus, X, Loader2 } from "lucide-react"; -import PageHeader from "../components/common/PageHeader"; +import PageHeader from "@/components/common/PageHeader"; import { useToast } from "@/components/ui/use-toast"; export default function EditPartner() { diff --git a/frontend-web/src/pages/EditSector.jsx b/frontend-web/src/pages/EditSector.jsx index e74a2531..41b9626c 100644 --- a/frontend-web/src/pages/EditSector.jsx +++ b/frontend-web/src/pages/EditSector.jsx @@ -9,7 +9,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { MapPin, ArrowLeft, Save, Loader2 } from "lucide-react"; -import PageHeader from "../components/common/PageHeader"; +import PageHeader from "@/components/common/PageHeader"; import { useToast } from "@/components/ui/use-toast"; export default function EditSector() { diff --git a/frontend-web/src/pages/EditStaff.jsx b/frontend-web/src/pages/EditStaff.jsx index 7cca7879..6b97a9d0 100644 --- a/frontend-web/src/pages/EditStaff.jsx +++ b/frontend-web/src/pages/EditStaff.jsx @@ -5,7 +5,7 @@ import { useNavigate } from "react-router-dom"; import { createPageUrl } from "@/utils"; import { Button } from "@/components/ui/button"; import { ArrowLeft, Loader2 } from "lucide-react"; -import StaffForm from "../components/staff/StaffForm"; +import StaffForm from "@/components/staff/StaffForm"; export default function EditStaff() { const navigate = useNavigate(); diff --git a/frontend-web/src/pages/EditVendor.jsx b/frontend-web/src/pages/EditVendor.jsx index 6c2719b5..0d86bfa1 100644 --- a/frontend-web/src/pages/EditVendor.jsx +++ b/frontend-web/src/pages/EditVendor.jsx @@ -10,7 +10,7 @@ import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Building2, ArrowLeft, Save, Loader2 } from "lucide-react"; -import PageHeader from "../components/common/PageHeader"; +import PageHeader from "@/components/common/PageHeader"; import { useToast } from "@/components/ui/use-toast"; export default function EditVendor() { diff --git a/frontend-web/src/pages/EnterpriseManagement.jsx b/frontend-web/src/pages/EnterpriseManagement.jsx index 22dbf597..1b34ca2e 100644 --- a/frontend-web/src/pages/EnterpriseManagement.jsx +++ b/frontend-web/src/pages/EnterpriseManagement.jsx @@ -8,7 +8,7 @@ import { Card, CardContent } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; import { Building2, Plus, Search, Users, Edit } from "lucide-react"; -import PageHeader from "../components/common/PageHeader"; +import PageHeader from "@/components/common/PageHeader"; export default function EnterpriseManagement() { const [searchTerm, setSearchTerm] = useState(""); diff --git a/frontend-web/src/pages/EventDetail.jsx b/frontend-web/src/pages/EventDetail.jsx index 44cab379..177410eb 100644 --- a/frontend-web/src/pages/EventDetail.jsx +++ b/frontend-web/src/pages/EventDetail.jsx @@ -9,7 +9,7 @@ import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { ArrowLeft, Bell, RefreshCw } from "lucide-react"; import { format } from "date-fns"; -import ShiftCard from "../components/events/ShiftCard"; +import ShiftCard from "@/components/events/ShiftCard"; import { Dialog, DialogContent, diff --git a/frontend-web/src/pages/Events.jsx b/frontend-web/src/pages/Events.jsx index 30386343..a6dbb374 100644 --- a/frontend-web/src/pages/Events.jsx +++ b/frontend-web/src/pages/Events.jsx @@ -7,18 +7,18 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Plus, Search, Calendar as CalendarIcon, Eye, Edit, Copy, X, RefreshCw } from "lucide-react"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import StatusCard from "../components/events/StatusCard"; +import StatusCard from "@/components/events/StatusCard"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Badge } from "@/components/ui/badge"; import { format, isSameDay, parseISO, isWithinInterval, startOfDay, endOfDay, isValid } from "date-fns"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; -import EventHoverCard from "../components/events/EventHoverCard"; -import QuickAssignPopover from "../components/events/QuickAssignPopover"; +import EventHoverCard from "@/components/events/EventHoverCard"; +import QuickAssignPopover from "@/components/events/QuickAssignPopover"; import { Calendar } from "@/components/ui/calendar"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { useToast } from "@/components/ui/use-toast"; -import PageHeader from "../components/common/PageHeader"; +import PageHeader from "@/components/common/PageHeader"; const statusColors = { Draft: "bg-gray-100 text-gray-800", diff --git a/frontend-web/src/pages/InviteVendor.jsx b/frontend-web/src/pages/InviteVendor.jsx index cd25adf9..b2112a94 100644 --- a/frontend-web/src/pages/InviteVendor.jsx +++ b/frontend-web/src/pages/InviteVendor.jsx @@ -20,7 +20,7 @@ import { HoverCardContent, HoverCardTrigger, } from "@/components/ui/hover-card"; -import PageHeader from "../components/common/PageHeader"; +import PageHeader from "@/components/common/PageHeader"; export default function InviteVendor() { const { toast } = useToast(); diff --git a/frontend-web/src/pages/Invoices.jsx b/frontend-web/src/pages/Invoices.jsx index 635d836d..a9138855 100644 --- a/frontend-web/src/pages/Invoices.jsx +++ b/frontend-web/src/pages/Invoices.jsx @@ -12,7 +12,7 @@ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { FileText, Plus, DollarSign, Search, Eye, Download } from "lucide-react"; import { format, parseISO, isPast } from "date-fns"; -import PageHeader from "../components/common/PageHeader"; +import PageHeader from "@/components/common/PageHeader"; import { Dialog, DialogContent, diff --git a/frontend-web/src/pages/Layout.jsx b/frontend-web/src/pages/Layout.jsx index 55475df6..f5d49e06 100644 --- a/frontend-web/src/pages/Layout.jsx +++ b/frontend-web/src/pages/Layout.jsx @@ -1,4 +1,5 @@ + import React from "react"; import { Link, useLocation } from "react-router-dom"; import { createPageUrl } from "@/utils"; @@ -9,7 +10,7 @@ import { DollarSign, Award, HelpCircle, BarChart3, Activity, Menu, MessageSquare, Package, TrendingUp, Clipboard, LogOut, Shield, MapPin, Bell, CloudOff, RefreshCw, User, Search, ShoppingCart, Home, Settings as SettingsIcon, MoreVertical, - Building2, Sparkles, CheckSquare, UserCheck + Building2, Sparkles, CheckSquare, UserCheck, Store } from "lucide-react"; import { Button } from "@/components/ui/button"; import { @@ -33,7 +34,7 @@ import RoleSwitcher from "@/components/dev/RoleSwitcher"; import NotificationPanel from "@/components/notifications/NotificationPanel"; import { Toaster } from "@/components/ui/toaster"; -// Navigation items for each role (removed Control Tower) +// Navigation items for each role const roleNavigationMap = { admin: [ { title: "Dashboard", url: createPageUrl("Dashboard"), icon: LayoutDashboard }, @@ -97,9 +98,9 @@ const roleNavigationMap = { { title: "Dashboard", url: createPageUrl("ClientDashboard"), icon: LayoutDashboard }, { title: "My Orders", url: createPageUrl("ClientOrders"), icon: Clipboard }, { title: "New Order", url: createPageUrl("CreateEvent"), icon: UserPlus }, - { title: "Vendors", url: createPageUrl("VendorManagement"), icon: Package }, + { title: "Vendor Marketplace", url: createPageUrl("VendorMarketplace"), icon: Store }, + { title: "Compare Rates", url: createPageUrl("VendorRateCard"), icon: DollarSign }, { title: "Teams", url: createPageUrl("Teams"), icon: UserCheck }, - { title: "Partner Rates", url: createPageUrl("VendorRateCard"), icon: DollarSign }, { title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare }, { title: "Invoices", url: createPageUrl("Invoices"), icon: FileText }, { title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 }, @@ -134,8 +135,6 @@ const roleNavigationMap = { ], }; -// ... keep all existing helper functions (getRoleName, etc.) ... - const getRoleName = (role) => { const names = { admin: "KROW Admin", @@ -232,31 +231,32 @@ function NavigationMenu({ location, userRole, closeSheet }) { } export default function Layout({ children }) { - // ... keep ALL existing Layout code (state, queries, handlers) ... - const location = useLocation(); const [showNotifications, setShowNotifications] = React.useState(false); const [mobileMenuOpen, setMobileMenuOpen] = React.useState(false); - // const { data: user } = useQuery({ - // queryKey: ['current-user-layout'], - // queryFn: () => base44.auth.me(), - // }); - - // Mock user data to prevent redirection and allow local development - const user = { - full_name: "Dev User", - email: "dev@example.com", - user_role: "admin", // You can change this to 'procurement', 'operator', 'client', etc. to test different navigation menus - profile_picture: "https://i.pravatar.cc/150?u=a042581f4e29026024d", - }; + const { data: user } = useQuery({ + queryKey: ['current-user'], + 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; - // Get unread notification count - // const { data: unreadCount = 0 } = useQuery({ - const unreadCount = 0; // Mocked value + 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 = user?.user_role || user?.role || "admin"; const userName = user?.full_name || user?.email || "User"; @@ -272,7 +272,6 @@ export default function Layout({ children }) { return (
- {/* ... keep ALL existing Layout structure (header, sidebar, main, footer) ... */} + +
- {/* Header */} -
-
-

- {greeting}, {user?.full_name?.split(' ')[0] || 'Partner'} -

-

Here's your performance overview

-
-
- - -
+
+

+ {greeting} here's what matters today +

- {/* Top KPI Metrics - NEW DESIGN */} + {/* Top 4 KPI Cards */}
- {/* Sales */} - - -
-
- -
-
-

Sales (M1D)

+ + +
+
+
+

Orders Today

+
+

{todayOrders.length}

+
+ Active
-

- ${Math.round(thisMonthRevenue / 1000)},000 -

-

+5% vs last month

- {/* Payroll */} - - -
-
- -
-
-

Payroll (M1D)

+ + +
+
+
+

In Progress

+
+

{inProgressOrders.length}

+
+ {inProgressCompletion}% + Coverage Rate
-

- ${Math.round(thisMonthRevenue * 0.68 / 1000)},000 -

-

68% of sales

- {/* Active Workforce */} - - -
-
- + + +
+

Order Type

+ {rapidOrders.length > 0 && ( + + {rapidOrders.length} Urgent + + )} +
+
+
+
-

Active Workforce

+

RAPID

+

Click to view

-

- {activeStaff} -

-

+3 this week

- {/* Avg Speed to Fill */} - - -
-
- -
-
-

Avg Speed-to-Fill

+ + +
+
{/* Changed bg-amber-50 to bg-indigo-50 */} + {/* Changed text-amber-600 to text-indigo-600 */}
+

Staff Assigned

+ Today {/* Changed bg-amber-100 to bg-indigo-100 and text-amber-700 to text-indigo-700 */}
-

- {avgSpeedToFill} -

-

{speedChange} vs last week

+

{staffAssignedToday}

+

{staffAssignedTodayCompleted}/{staffAssignedToday} filled

- {/* AI Insights */} -
- {}} - /> - {}} - /> -
- {/* Main Content Grid */}
- {/* Left Column (2 cols) */} -
- - {/* RAPID ORDERS - NEW SECTION */} + {/* Orders Table (2 cols) */} +
- -
-
- - - Rapid Orders - -

Urgent orders within 24 hours

-
- {rapidOrders.length > 0 && ( - - {rapidOrders.length} Urgent - - )} -
-
- - {rapidOrders.length > 0 ? ( -
- {rapidOrders.map((order) => { - const eventDate = order.date ? parseISO(order.date) : new Date(); - const hoursUntil = differenceInHours(eventDate, new Date()); - const assignedCount = order.assigned_staff?.length || 0; - const requestedCount = order.requested || 0; - const fillPercentage = requestedCount > 0 ? Math.round((assignedCount / requestedCount) * 100) : 0; - - return ( -
-
-
-
-

{order.business_name || "Client"} – {order.event_name}

- - {hoursUntil}h left + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + {recentOrders.length > 0 ? ( + recentOrders.map((order, index) => { + const assignedCount = order.assigned_staff?.length || 0; + const requestedCount = order.requested || 0; + const isFull = assignedCount >= requestedCount && requestedCount > 0; + const statusConfig = getStatusBadge(order.status, isFull); + + return ( + + + + + + + + + + + + ); + }) + ) : ( + + + + )} + +
BusinessHubEvent NameStatusDateRequestedAssignedInvoiceActions
+
+
+ + {order.business_name || "Sports Arena LLC"} + +
+
+ + {order.hub || "Downtown"} + + + + {order.event_name} + + + +
+ {statusConfig.label} -
-
-
- - {assignedCount}/{requestedCount} filled +
+ + {order.date ? format(new Date(order.date), "MM/dd/yy") : "-"} + + + + {requestedCount} + + + 0 ? 'bg-blue-100 text-blue-700' : + 'bg-slate-100 text-slate-600' + }`}> + {assignedCount} + + + + + + +
+ + + + + + + + + handleViewOrder(order)}> + + View Details + + handleCopyOrder(order)}> + + Copy ID + + +
-
- - {format(eventDate, "h:mm a")} -
- - -
- - - - -
- -
-
-
-
- {fillPercentage}% -
-
- ); - })} - - ) : ( -
- -

No urgent orders

-

Orders within 24h will appear here

-
- )} +
+ +

No orders to display

+

Your recent orders will appear here

+
+
- {/* Sales vs Payroll Chart */} - - - - - Sales vs Payroll - - - - - - - - `${(value / 1000).toFixed(0)}k`} - /> - [`$${Math.round(value).toLocaleString()}`, '']} - /> - - - - - - - - {/* Client Analysis */} - - - - - Client Analysis - - - - {topClients.length > 0 ? ( -
- {topClients.map((client) => ( -
-
-
-

{client.name}

-

Fill

+ {/* Bottom Stats Row */} +
+ + + +
+ +
+ Top Clients +
+
+ + {topClients.length > 0 ? ( + topClients.map((client, idx) => ( +
+
+
+ {idx + 1}
-
-

${(client.revenue / 1000).toFixed(0)}k

-

{Math.round(client.fillRate)}%

+
+

{client.name}

+

+{client.orders}% growth

-
-
+

${(client.revenue / 1000).toFixed(0)}k

+
+ )) + ) : ( +

No client data

+ )} + + + + + + +
+ +
+ Top Performers +
+
+ + {topPerformers.length > 0 ? ( + topPerformers.map((member, idx) => ( +
+
+
+ {idx + 1} +
+
+

+ {member.employee_name} +

+

{member.shifts} shifts

+
+
+
+ {(member.rating || 0).toFixed(1)} +
- ))} + )) + ) : ( +

No staff data

+ )} +
+
+ + + + +
+ +
+ Gold Vendors +
+
+ +
+
+
+ +
+
+

Legendary Staffing

+

Premier vendor

+
+
+
+

98

+

Score

+
- ) : ( -
- -

No client data

+
+
+
+ +
+
+

Epic Workforce

+

Gold tier

+
+
+
+

96

+

Score

+
- )} - - + + +
- {/* Right Column (1 col) */} -
+ {/* Right Sidebar */} +
- {/* Total Revenue Card - NEW DESIGN */} - - -
-
-
-
+ {/* Enhanced Carousel Card */} + + + + +
+ {carouselSlides.map((_, index) => ( +
-

Total Revenue

-

- ${Math.round(totalRevenue / 1000)}k -

-

All time earnings

+ + + +
+ {React.createElement(carouselSlides[carouselIndex].icon, { + className: "w-6 h-6 text-white/80" + })} +

+ {carouselSlides[carouselIndex].title} +

+
+

+ {carouselSlides[carouselIndex].value} +

+

+ {carouselSlides[carouselIndex].subtitle} +

+
+
- {/* Quick Actions - NEW DESIGN */} + {/* Quick Action Buttons */}
- - -
- + + +
+
-

All Orders

-

View & manage

+

All Orders

+

View & manage

- -
- + +
+
-

My Orders

-

Manage staff

+

My Staff

+

Manage staff

- - {/* Today's Metrics */} -
- - -
-
- -
- -
-

{todayOrders.length}

-

Orders Today

-
-
- - - -
-
- -
- Active -
-

{activeOrders.length}

-

In Progress

-
-
-
- - {/* Staff Assigned Today */} - - -
-
- -
- Today -
-

{staffAssignedToday}

-

Staff Assigned

-
-
- - {/* Top Clients - NEW DESIGN */} - - - - - Top Clients - - - - {topClients.slice(0, 3).length > 0 ? ( -
- {topClients.slice(0, 3).map((client) => ( -
-
-

{client.name}

-

+{client.orders}%

-
-

${(client.revenue / 1000).toFixed(0)}k

-
- ))} -
- ) : ( -
- -

No client data

-
- )} -
-
- - {/* Top Performers - NEW DESIGN */} - - - - - Top Performers - - - - {topPerformers.length > 0 ? ( -
- {topPerformers.map((member) => ( -
-
-

- {member.employee_name} - {member.position || "Staff"} -

-

{member.shifts} shifts

-
-
- {(member.rating || 0).toFixed(1)} - -
-
- ))} -
- ) : ( -
- -

No staff data

-
- )} -
-
- - {/* Gold Vendors - NEW SECTION */} - - - - - Gold Vendors - - - -
-
-
-

Legendary Staffing

-

Score

-
-

98

-
-
-
-

Epic Workforce

-

Score

-
-

96

-
-
-
-
+ + {/* Rapid Orders Modal */} + + + + +
+ +
+
+ Urgent Orders +

+ {rapidOrders.length} order{rapidOrders.length !== 1 ? 's' : ''} need immediate attention +

+
+
+
+ +
+ {rapidOrders.map((order) => { + const eventDate = new Date(order.date); + const now = new Date(); + const hoursUntil = Math.round(differenceInHours(eventDate, now)); + const assignedCount = order.assigned_staff?.length || 0; + const requestedCount = order.requested || 0; + + return ( +
{ + setShowRapidModal(false); + navigate(createPageUrl(`EventDetail?id=${order.id}`)); + }} + > +
+
+
+ + {hoursUntil}h away + + + {order.business_name || "Client"} + +
+

+ {order.event_name} +

+
+ + + {format(eventDate, "MMM d, h:mm a")} + + + + {order.hub || "No hub"} + +
+
+ +
+ +
+
+

Staff Assignment

+
+
+
0 ? (assignedCount / requestedCount) * 100 : 0}%` }} + /> +
+ + {assignedCount}/{requestedCount} + +
+
+ +
+
+ ); + })} +
+ +
); -} \ No newline at end of file +} diff --git a/frontend-web/src/pages/VendorDocumentReview.jsx b/frontend-web/src/pages/VendorDocumentReview.jsx index a857251f..d4b108f5 100644 --- a/frontend-web/src/pages/VendorDocumentReview.jsx +++ b/frontend-web/src/pages/VendorDocumentReview.jsx @@ -9,8 +9,8 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; import { ArrowLeft, FileText, Shield, CheckCircle2, Clock, Eye } from "lucide-react"; -import PageHeader from "../components/common/PageHeader"; -import DocumentViewer from "../components/vendor/DocumentViewer"; +import PageHeader from "@/components/common/PageHeader"; +import DocumentViewer from "@/components/vendor/DocumentViewer"; import { useToast } from "@/components/ui/use-toast"; const ONBOARDING_DOCUMENTS = [ diff --git a/frontend-web/src/pages/VendorManagement.jsx b/frontend-web/src/pages/VendorManagement.jsx index ec62ad2f..51cf5f4a 100644 --- a/frontend-web/src/pages/VendorManagement.jsx +++ b/frontend-web/src/pages/VendorManagement.jsx @@ -15,9 +15,9 @@ import { Building2, DollarSign, Mail, CheckCircle2, XCircle, Clock, Eye, Archive, LayoutGrid, List as ListIcon } from "lucide-react"; -import PageHeader from "../components/common/PageHeader"; -import VendorScoreHoverCard from "../components/procurement/VendorScoreHoverCard"; -import VendorDetailModal from "../components/procurement/VendorDetailModal"; +import PageHeader from "@/components/common/PageHeader"; +import VendorScoreHoverCard from "@/components/procurement/VendorScoreHoverCard"; +import VendorDetailModal from "@/components/procurement/VendorDetailModal"; import { useToast } from "@/components/ui/use-toast"; import { Dialog, diff --git a/frontend-web/src/pages/VendorMarketplace.jsx b/frontend-web/src/pages/VendorMarketplace.jsx new file mode 100644 index 00000000..75bff304 --- /dev/null +++ b/frontend-web/src/pages/VendorMarketplace.jsx @@ -0,0 +1,922 @@ +import React, { useState, useMemo } from "react"; +import { base44 } from "@/api/base44Client"; +import { useQuery } from "@tanstack/react-query"; +import { Link, useNavigate } from "react-router-dom"; +import { createPageUrl } from "@/utils"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { Textarea } from "@/components/ui/textarea"; +import { Search, MapPin, Users, Star, DollarSign, TrendingUp, MessageSquare, CheckCircle, Award, Filter, Grid, List, Phone, Mail, Building2, Zap, ArrowRight, ChevronDown, ChevronUp, UserCheck, Briefcase } from "lucide-react"; +import { useToast } from "@/components/ui/use-toast"; + +export default function VendorMarketplace() { + const navigate = useNavigate(); + const { toast } = useToast(); + const [searchTerm, setSearchTerm] = useState(""); + const [regionFilter, setRegionFilter] = useState("all"); + const [categoryFilter, setCategoryFilter] = useState("all"); + const [sortBy, setSortBy] = useState("rating"); + const [viewMode, setViewMode] = useState("grid"); + const [contactModal, setContactModal] = useState({ open: false, vendor: null }); + const [message, setMessage] = useState(""); + const [expandedVendors, setExpandedVendors] = useState({}); + + const { data: user } = useQuery({ + queryKey: ['current-user-marketplace'], + queryFn: () => base44.auth.me(), + }); + + const { data: vendors = [] } = useQuery({ + queryKey: ['approved-vendors'], + queryFn: async () => { + const allVendors = await base44.entities.Vendor.list(); + return allVendors.filter(v => v.approval_status === 'approved' && v.is_active); + }, + }); + + const { data: vendorRates = [] } = useQuery({ + queryKey: ['vendor-rates-marketplace'], + queryFn: () => base44.entities.VendorRate.list(), + }); + + const { data: staff = [] } = useQuery({ + queryKey: ['vendor-staff-count'], + queryFn: () => base44.entities.Staff.list(), + }); + + const { data: events = [] } = useQuery({ + queryKey: ['events-vendor-marketplace'], + queryFn: () => base44.entities.Event.list(), + initialData: [], + }); + + const { data: businesses = [] } = useQuery({ + queryKey: ['businesses-vendor-marketplace'], + queryFn: () => base44.entities.Business.list(), + initialData: [], + }); + + // Calculate vendor metrics + const vendorsWithMetrics = useMemo(() => { + return vendors.map(vendor => { + const rates = vendorRates.filter(r => r.vendor_name === vendor.legal_name || r.vendor_id === vendor.id); + const vendorStaff = staff.filter(s => s.vendor_name === vendor.legal_name); + + const avgRate = rates.length > 0 + ? rates.reduce((sum, r) => sum + (r.client_rate || 0), 0) / rates.length + : 0; + + const minRate = rates.length > 0 + ? Math.min(...rates.map(r => r.client_rate || 999)) + : 0; + + const rating = 4.5 + (Math.random() * 0.5); + const completedJobs = Math.floor(Math.random() * 200) + 50; + + // Calculate how many clients in user's sector are using this vendor + const vendorEvents = events.filter(e => + e.vendor_name === vendor.legal_name || + e.vendor_id === vendor.id + ); + + const uniqueClients = new Set( + vendorEvents.map(e => e.business_name || e.client_email) + ).size; + + // Calculate sector-specific usage + const userSector = user?.sector || user?.company_name; + const sectorClients = businesses.filter(b => + b.sector === userSector || b.area === user?.area + ); + + const clientsInSector = new Set( + vendorEvents + .filter(e => sectorClients.some(sc => + sc.business_name === e.business_name || + sc.contact_name === e.client_name + )) + .map(e => e.business_name || e.client_email) + ).size; + + // Group rates by category + const ratesByCategory = rates.reduce((acc, rate) => { + const category = rate.category || 'Other'; + if (!acc[category]) { + acc[category] = []; + } + acc[category].push(rate); + return acc; + }, {}); + + return { + ...vendor, + rates, + ratesByCategory, + avgRate, + minRate, + rating, + completedJobs, + staffCount: vendorStaff.length, + responseTime: `${Math.floor(Math.random() * 3) + 1}h`, + totalClients: uniqueClients, + clientsInSector: clientsInSector, + }; + }); + }, [vendors, vendorRates, staff, events, businesses, user]); + + // Filtering and sorting + const filteredVendors = useMemo(() => { + let filtered = vendorsWithMetrics; + + // Search + if (searchTerm) { + const lower = searchTerm.toLowerCase(); + filtered = filtered.filter(v => + v.legal_name?.toLowerCase().includes(lower) || + v.doing_business_as?.toLowerCase().includes(lower) || + v.service_specialty?.toLowerCase().includes(lower) + ); + } + + // Region filter + if (regionFilter !== "all") { + filtered = filtered.filter(v => v.region === regionFilter); + } + + // Category filter + if (categoryFilter !== "all") { + filtered = filtered.filter(v => v.service_specialty === categoryFilter); + } + + // Sorting + filtered.sort((a, b) => { + switch (sortBy) { + case "rating": + return b.rating - a.rating; + case "price-low": + return a.minRate - b.minRate; + case "price-high": + return b.avgRate - a.avgRate; + case "staff": + return b.staffCount - a.staffCount; + default: + return 0; + } + }); + + return filtered; + }, [vendorsWithMetrics, searchTerm, regionFilter, categoryFilter, sortBy]); + + const uniqueRegions = [...new Set(vendors.map(v => v.region).filter(Boolean))]; + const uniqueCategories = [...new Set(vendors.map(v => v.service_specialty).filter(Boolean))]; + + const handleContactVendor = (vendor) => { + setContactModal({ open: true, vendor }); + setMessage(`Hi ${vendor.legal_name},\n\nI'm interested in your services for an upcoming event. Could you please provide more information about your availability and pricing?\n\nBest regards,\n${user?.full_name || 'Client'}`); + }; + + const handleSendMessage = async () => { + if (!message.trim()) { + toast({ + title: "Message required", + description: "Please enter a message to send.", + variant: "destructive", + }); + return; + } + + // Create conversation + try { + await base44.entities.Conversation.create({ + participants: [ + { id: user?.id, name: user?.full_name, role: "client" }, + { id: contactModal.vendor.id, name: contactModal.vendor.legal_name, role: "vendor" } + ], + conversation_type: "client-vendor", + is_group: false, + subject: `Inquiry from ${user?.full_name || 'Client'}`, + last_message: message.substring(0, 100), + last_message_at: new Date().toISOString(), + status: "active" + }); + + toast({ + title: "✅ Message sent!", + description: `Your message has been sent to ${contactModal.vendor.legal_name}`, + }); + + setContactModal({ open: false, vendor: null }); + setMessage(""); + } catch (error) { + toast({ + title: "Failed to send message", + description: error.message, + variant: "destructive", + }); + } + }; + + const handleCreateOrder = (vendor) => { + sessionStorage.setItem('selectedVendor', JSON.stringify({ + id: vendor.id, + name: vendor.legal_name, + rates: vendor.rates + })); + navigate(createPageUrl("CreateEvent")); + toast({ + title: "Vendor selected", + description: `${vendor.legal_name} will be used for this order.`, + }); + }; + + const toggleVendorRates = (vendorId) => { + setExpandedVendors(prev => ({ + ...prev, + [vendorId]: !prev[vendorId] + })); + }; + + return ( +
+
+ + {/* Hero Header */} +
+
+
+
+
+ +
+
+

Vendor Marketplace

+

Compare rates • See who others trust • Order instantly

+
+
+ + {filteredVendors.length} Vendors + +
+
+ + {/* Stats Cards */} +
+ +
+ +
+
+

Vendors

+

{vendors.length}

+

Approved

+
+
+ +
+
+
+ + + +
+ +
+
+

Staff

+

{staff.length}

+

Available

+
+
+ +
+
+
+ + + +
+ +
+
+

Avg Rate

+

+ ${Math.round(vendorsWithMetrics.reduce((sum, v) => sum + v.avgRate, 0) / vendorsWithMetrics.length || 0)} +

+

Per hour

+
+
+ +
+
+
+ + + +
+ +
+
+

Rating

+
+

4.7

+ +
+

Average

+
+
+ +
+
+
+ +
+ + {/* Enhanced Filters */} + + +
+ {/* Search - Takes more space */} +
+ +
+ + setSearchTerm(e.target.value)} + className="pl-10 h-11 border-2 border-slate-200 focus:border-[#0A39DF] text-sm rounded-lg shadow-sm" + /> +
+
+ + {/* Region Filter */} +
+ + +
+ + {/* Category Filter */} +
+ + +
+ + {/* Sort */} +
+ + +
+ + {/* View Toggle */} +
+
+ + +
+
+
+
+
+ + {/* Vendors Grid/List */} + {viewMode === "grid" ? ( +
+ {filteredVendors.map((vendor) => { + const isExpanded = expandedVendors[vendor.id]; + + return ( + + {/* Vendor Header */} + +
+
+
+ + + {vendor.legal_name?.charAt(0)} + + +
+ +
+
+ +
+
+ + {vendor.legal_name} + +
+ + {vendor.rating.toFixed(1)} +
+
+ + {vendor.doing_business_as && ( +

DBA: {vendor.doing_business_as}

+ )} + +
+ {vendor.service_specialty && ( +
+ + {vendor.service_specialty} +
+ )} +
+ + {vendor.region || vendor.city} +
+
+ + {vendor.staffCount} Staff +
+
+ + {vendor.responseTime} +
+
+
+
+ +
+
+

Starting from

+

${vendor.minRate}

+

per hour

+
+ + {/* Social Proof */} + {vendor.clientsInSector > 0 && ( +
+
+ + {vendor.clientsInSector} +
+

+ clients in your area +

+
+ )} + +
+ + + {vendor.completedJobs} jobs + + + {vendor.rates.length} services + +
+
+
+
+ + {/* Actions Bar */} +
+
+ toggleVendorRates(vendor.id)} className="flex-1"> + + + + + +
+ + +
+
+
+ + {/* Rate Comparison Section */} + + + +
+ {Object.entries(vendor.ratesByCategory).map(([category, categoryRates], catIdx) => ( +
+
+

+
+ +
+ {category} + + {categoryRates.length} + +

+
+
+ {categoryRates.map((rate, rateIdx) => { + const baseWage = rate.employee_wage || 0; + const markupAmount = baseWage * ((rate.markup_percentage || 0) / 100); + const subtotal = baseWage + markupAmount; + const feeAmount = subtotal * ((rate.vendor_fee_percentage || 0) / 100); + + return ( +
+
+
+
+
+ {rateIdx + 1} +
+
+ {rate.role_name} +
+
+ + {/* Visual Price Breakdown */} +
+
+ Base Wage: +
+ ${baseWage.toFixed(2)}/hr + Employee +
+
+
+ + Markup: +
+ {rate.markup_percentage}% + +${markupAmount.toFixed(2)} +
+
+
+ + Admin Fee: +
+ {rate.vendor_fee_percentage}% + +${feeAmount.toFixed(2)} +
+
+
+
+ +
+

You Pay

+
+

+ ${rate.client_rate?.toFixed(0)} +

+

per hour

+
+
+
+
+ ); + })} +
+
+ ))} + + {/* Summary Footer */} +
+
+
+
+ +
+
+

Average Rate

+

+ ${Math.round(vendor.avgRate)}/hr +

+
+
+
+

Price Range

+

+ ${vendor.minRate} - ${Math.round(Math.max(...vendor.rates.map(r => r.client_rate || 0)))} +

+
+
+
+
+
+
+
+
+ ); + })} +
+ ) : ( + + + + + + + + + + + + + + + + + {filteredVendors.map((vendor) => ( + + + + + + + + + + + ))} + +
VendorSpecialtyLocationRatingClientsStaffMin RateActions
+
+ + + {vendor.legal_name?.charAt(0)} + + +
+

{vendor.legal_name}

+

+ + {vendor.completedJobs} jobs completed +

+
+
+
+ {vendor.service_specialty || '—'} + +
+ + {vendor.region} +
+
+
+ + {vendor.rating.toFixed(1)} +
+
+ {vendor.clientsInSector > 0 ? ( + + + {vendor.clientsInSector} + + ) : ( + + )} + + {vendor.staffCount} + +
+ ${vendor.minRate} + /hour +
+
+
+ + +
+
+
+
+ )} + + {filteredVendors.length === 0 && ( +
+
+ +
+

No vendors found

+

Try adjusting your filters to see more results

+ +
+ )} +
+ + {/* Contact Modal */} + setContactModal({ open, vendor: null })}> + + + + Contact {contactModal.vendor?.legal_name} + + + Start a conversation and get staffing help within hours + + + +
+ {/* Vendor Info Card */} +
+ + + {contactModal.vendor?.legal_name?.charAt(0)} + + +
+

{contactModal.vendor?.legal_name}

+
+
+ + {contactModal.vendor?.region} +
+
+ + {contactModal.vendor?.staffCount} staff +
+
+ + {contactModal.vendor?.rating?.toFixed(1)} +
+ {contactModal.vendor?.clientsInSector > 0 && ( + + + {contactModal.vendor?.clientsInSector} in your area + + )} +
+
+
+ + {/* Message Input */} +
+ +