From 645fecfae9490c84561613c0deb2679261ecd101 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Salazar?= <73718835+joshrs23@users.noreply.github.com> Date: Wed, 26 Nov 2025 13:05:42 -0500 Subject: [PATCH] export with one error --- frontend-web/src/api/entities.js | 8 + .../src/components/business/BusinessCard.jsx | 273 +++ .../components/business/ERPSettingsTab.jsx | 256 +++ .../src/components/events/EventFormWizard.jsx | 208 +- .../components/invoices/InvoiceDetailView.jsx | 71 +- .../invoices/InvoiceExportPanel.jsx | 316 +++ .../components/orders/OrderDetailModal.jsx | 28 +- .../src/components/rates/RateCardModal.jsx | 217 ++ .../src/components/scheduling/TalentRadar.jsx | 218 ++ frontend-web/src/lib/lib/firebase.js | 20 + frontend-web/src/lib/lib/utils.js | 6 + frontend-web/src/pages/Business.jsx | 756 +++++-- frontend-web/src/pages/ClientDashboard.jsx | 54 +- frontend-web/src/pages/ClientOrders.jsx | 377 ++- frontend-web/src/pages/EditBusiness.jsx | 850 ++++++- frontend-web/src/pages/EventDetail.jsx | 6 + frontend-web/src/pages/Events.jsx | 6 + frontend-web/src/pages/InvoiceDetail.jsx | 19 +- frontend-web/src/pages/Invoices.jsx | 391 +++- frontend-web/src/pages/Layout.jsx | 8 +- frontend-web/src/pages/Onboarding.jsx | 572 +++-- frontend-web/src/pages/Permissions.jsx | 1186 ++++++---- frontend-web/src/pages/Schedule.jsx | 252 +++ frontend-web/src/pages/StaffAvailability.jsx | 469 ++++ frontend-web/src/pages/TeamDetails.jsx | 7 +- frontend-web/src/pages/Teams.jsx | 975 +++++--- frontend-web/src/pages/Tutorials.jsx | 469 ++++ frontend-web/src/pages/UserManagement.jsx | 611 +++-- frontend-web/src/pages/VendorOrders.jsx | 9 +- frontend-web/src/pages/VendorRates.jsx | 804 +++++-- .../src/pages/WorkerShiftProposals.jsx | 267 +++ frontend-web/src/pages/api-docs-raw.jsx | 2015 +++++++++++++++++ frontend-web/src/pages/index.jsx | 30 + 33 files changed, 10028 insertions(+), 1726 deletions(-) create mode 100644 frontend-web/src/components/business/BusinessCard.jsx create mode 100644 frontend-web/src/components/business/ERPSettingsTab.jsx create mode 100644 frontend-web/src/components/invoices/InvoiceExportPanel.jsx create mode 100644 frontend-web/src/components/rates/RateCardModal.jsx create mode 100644 frontend-web/src/components/scheduling/TalentRadar.jsx create mode 100644 frontend-web/src/lib/lib/firebase.js create mode 100644 frontend-web/src/lib/lib/utils.js create mode 100644 frontend-web/src/pages/Schedule.jsx create mode 100644 frontend-web/src/pages/StaffAvailability.jsx create mode 100644 frontend-web/src/pages/Tutorials.jsx create mode 100644 frontend-web/src/pages/WorkerShiftProposals.jsx create mode 100644 frontend-web/src/pages/api-docs-raw.jsx diff --git a/frontend-web/src/api/entities.js b/frontend-web/src/api/entities.js index 26cbda33..3fe58bc2 100644 --- a/frontend-web/src/api/entities.js +++ b/frontend-web/src/api/entities.js @@ -67,6 +67,14 @@ export const Task = base44.entities.Task; export const TaskComment = base44.entities.TaskComment; +export const WorkerAvailability = base44.entities.WorkerAvailability; + +export const ShiftProposal = base44.entities.ShiftProposal; + +export const VendorRateBook = base44.entities.VendorRateBook; + +export const VendorNetworkApproval = base44.entities.VendorNetworkApproval; + // auth sdk: diff --git a/frontend-web/src/components/business/BusinessCard.jsx b/frontend-web/src/components/business/BusinessCard.jsx new file mode 100644 index 00000000..5641a474 --- /dev/null +++ b/frontend-web/src/components/business/BusinessCard.jsx @@ -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 ( + + +
+ {/* Logo */} +
+ {logo ? ( + {companyName} + ) : ( +
+ +
+ )} +
+ + {/* Company Info */} +
+
+

{companyName}

+
+ {clientGrade} +
+ {isActive === false && ( + Inactive + )} +
+

{serviceType}

+
+ + + {location} + + Monthly: ${(monthlySpend / 1000).toFixed(0)}k + {totalStaff} Staff + {lastOrderDate && ( + + + Last: {new Date(lastOrderDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} + + )} +
+
+ + {/* All Performance Metrics */} +
+
+

Cancellations

+

{performance.cancelRate}%

+
+
+

On-Time

+

{performance.onTimeRate}%

+
+
+

Rapid Orders

+

{performance.rapidOrders}

+
+
+

Main Position

+

{performance.mainPosition}

+
+
+ + {/* Rate Card Badge */} + {rateCard && ( +
+ + + + {rateCard} + + +
+ )} + + {/* Actions */} +
+ + +
+
+
+
+ ); + } + + return ( + + {/* Header - Blue Gradient */} +
+
+
+ {logo ? ( + {companyName} + ) : ( +
+ +
+ )} +
+

{companyName}

+ + # N/A + +
+
+
+ {clientGrade} +
+
+

{serviceType}

+ + {/* Metrics Row */} +
+
+

Monthly Sales

+

${(monthlySpend / 1000).toFixed(0)}k

+
+
+

Total Staff

+

{totalStaff}

+
+
+

Last Order

+

+ {lastOrderDate + ? new Date(lastOrderDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) + : 'None'} +

+
+
+
+ + {/* Content */} + + {/* Company Information */} +
+

COMPANY INFORMATION

+
+
+ +
+

{location}

+

California

+
+
+
+ +
+

{serviceType}

+

Primary Service

+
+
+
+ +

{phone}

+
+
+ +

{email}

+
+
+
+ + {/* Rate Card & Technology */} +
+

RATE CARD & TECHNOLOGY

+
+ {rateCard ? ( + + + + {rateCard} + + + ) : ( + + No Rate Card + + )} + {technology?.isUsingKROW ? ( + + Using KROW + + ) : ( + + Invite to KROW + + )} +
+
+ + {/* Performance Metrics */} +
+

PERFORMANCE METRICS

+
+ {/* Cancellation Rate */} +
+
+ +

Cancellations

+
+

{performance.cancelRate}%

+
+
+
+
+ + {/* On-Time Ordering */} +
+
+ +

On-Time Order

+
+

{performance.onTimeRate}%

+
+
+
+
+ + {/* Rapid Orders */} +
+
+ +

Rapid Orders

+
+

{performance.rapidOrders}

+

Last 30 days

+
+ + {/* Main Position */} +
+
+ +

Main Position

+
+

{performance.mainPosition}

+

Most requested

+
+
+
+ + {/* Footer */} + +
+
+ ); +} \ No newline at end of file diff --git a/frontend-web/src/components/business/ERPSettingsTab.jsx b/frontend-web/src/components/business/ERPSettingsTab.jsx new file mode 100644 index 00000000..599f7052 --- /dev/null +++ b/frontend-web/src/components/business/ERPSettingsTab.jsx @@ -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 ( +
+ {/* Status Card */} + + +
+ {isConfigured ? ( + + ) : ( + + )} +
+

+ {isConfigured ? 'ERP Integration Active' : 'ERP Integration Not Configured'} +

+

+ {isConfigured + ? `Connected to ${settings.erp_system} • Vendor ID: ${settings.erp_vendor_id}` + : 'Configure ERP settings to enable automated invoice delivery'} +

+
+
+
+
+ + {/* ERP System Settings */} + + + + + ERP System Configuration + + + +
+
+ + +

Client's procurement or ERP system

+
+ +
+ + handleChange('erp_vendor_id', e.target.value)} + placeholder="e.g., VND-12345" + className="border-slate-200" + /> +

Your vendor identifier in client's system

+
+ +
+ + handleChange('erp_cost_center', e.target.value)} + placeholder="e.g., CC-1001" + className="border-slate-200" + /> +

Default cost center for invoice allocation

+
+ +
+ + handleChange('invoice_email', e.target.value)} + placeholder="ap@client.com" + className="border-slate-200" + /> +

Accounts payable email for invoice delivery

+
+
+
+
+ + {/* EDI Settings */} + + + + + EDI / Export Settings + + + +
+
+

Enable EDI Integration

+

Automatically format invoices for EDI transmission

+
+ handleChange('edi_enabled', checked)} + /> +
+ +
+
+ + +

Default format for invoice exports

+
+ +
+
+

PO Required

+

Require PO number on invoices

+
+ handleChange('po_required', checked)} + /> +
+
+ + {/* Format Info */} +
+

+ + Supported Formats +

+
+
+ EDI 810 + Standard +
+
+ cXML + Ariba/Coupa +
+
+ CSV + Excel +
+
+ JSON + API +
+
+
+
+
+ + {/* Save Button */} +
+ +
+
+ ); +} \ No newline at end of file diff --git a/frontend-web/src/components/events/EventFormWizard.jsx b/frontend-web/src/components/events/EventFormWizard.jsx index 112a31d6..72b48f1a 100644 --- a/frontend-web/src/components/events/EventFormWizard.jsx +++ b/frontend-web/src/components/events/EventFormWizard.jsx @@ -8,7 +8,7 @@ import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Calendar, Zap, RefreshCw, Users, Building2, - Plus, Minus, Trash2, Search, Save, MapPin, Edit2, UserPlus + Plus, Minus, Trash2, Search, Save, MapPin, Edit2, UserPlus, X } from "lucide-react"; import { format } from "date-fns"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; @@ -16,6 +16,8 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover import { Checkbox } from "@/components/ui/checkbox"; import { Textarea } from "@/components/ui/textarea"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "@/components/ui/command"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import GoogleAddressInput from "../common/GoogleAddressInput"; import RapidOrderInterface from "../orders/RapidOrderInterface"; import { createPageUrl } from "@/utils"; @@ -65,6 +67,8 @@ export default function EventFormWizard({ event, onSubmit, onRapidSubmit, isSubm }); const [roleSearchOpen, setRoleSearchOpen] = useState({}); + const [showShiftContactDialog, setShowShiftContactDialog] = useState(false); + const [selectedShiftIndex, setSelectedShiftIndex] = useState(null); const { data: user } = useQuery({ queryKey: ['current-user-form'], @@ -77,6 +81,49 @@ export default function EventFormWizard({ event, onSubmit, onRapidSubmit, isSubm const isVendor = userRole === "vendor"; const isClient = userRole === "client"; + // Fetch client user's team member data to auto-populate hub, department, and address + const { data: teamMember } = useQuery({ + queryKey: ['client-team-member', currentUserData?.id], + queryFn: async () => { + if (!currentUserData?.id || !isClient) return null; + const allMembers = await base44.entities.TeamMember.list(); + return allMembers.find(m => m.email === currentUserData.email); + }, + enabled: !!currentUserData?.id && isClient, + }); + + const { data: teamHub } = useQuery({ + queryKey: ['client-team-hub', teamMember?.hub, teamMember?.team_id], + queryFn: async () => { + if (!teamMember?.hub || !teamMember?.team_id) return null; + const allHubs = await base44.entities.TeamHub.list(); + return allHubs.find(h => h.hub_name === teamMember.hub && h.team_id === teamMember.team_id); + }, + enabled: !!teamMember?.hub && !!teamMember?.team_id, + }); + + const { data: teamHubs = [] } = useQuery({ + queryKey: ['team-hubs-for-order', teamMember?.team_id], + queryFn: async () => { + if (!teamMember?.team_id) return []; + const allHubs = await base44.entities.TeamHub.list(); + return allHubs.filter(h => h.team_id === teamMember.team_id && h.is_active); + }, + enabled: !!teamMember?.team_id && isClient, + initialData: [], + }); + + const { data: teamMembers = [] } = useQuery({ + queryKey: ['team-members-for-contact', teamMember?.team_id], + queryFn: async () => { + if (!teamMember?.team_id) return []; + const allMembers = await base44.entities.TeamMember.list(); + return allMembers.filter(m => m.team_id === teamMember.team_id && m.is_active); + }, + enabled: !!teamMember?.team_id && isClient, + initialData: [], + }); + const { data: businesses = [] } = useQuery({ queryKey: ['businesses'], queryFn: () => base44.entities.Business.list(), @@ -112,6 +159,23 @@ export default function EventFormWizard({ event, onSubmit, onRapidSubmit, isSubm } }, [isClient, currentUserData, formData.vendor_id]); + // Auto-populate hub, department, and address from team member data for client users + useEffect(() => { + if (isClient && teamMember && !event) { + setFormData(prev => ({ + ...prev, + hub: prev.hub || teamMember.hub || "", + department: prev.department || teamMember.department || "", + shifts: prev.shifts.map((shift, idx) => ({ + ...shift, + location_address: idx === 0 && !shift.location_address + ? (teamHub?.address || teamMember.hub || "") + : shift.location_address + })) + })); + } + }, [isClient, teamMember, teamHub, event]); + // Auto-fill fields when order type changes to RAPID useEffect(() => { if (formData.order_type === 'rapid' && formData.shifts?.length > 0 && formData.shifts[0].roles?.length > 0) { @@ -414,6 +478,16 @@ export default function EventFormWizard({ event, onSubmit, onRapidSubmit, isSubm }); }; + const handleSelectShiftContact = (member) => { + if (selectedShiftIndex !== null) { + handleShiftChange(selectedShiftIndex, 'shift_contact', member.member_name); + handleShiftChange(selectedShiftIndex, 'shift_contact_phone', member.phone); + handleShiftChange(selectedShiftIndex, 'shift_contact_email', member.email); + } + setShowShiftContactDialog(false); + setSelectedShiftIndex(null); + }; + const handleAddRole = (shiftIndex) => { setFormData(prev => { const newShifts = [...prev.shifts]; @@ -591,13 +665,33 @@ export default function EventFormWizard({ event, onSubmit, onRapidSubmit, isSubm
- handleChange('hub', e.target.value)} - placeholder="Hub location" - className="h-10" - required - /> + {isClient && teamHubs.length > 0 ? ( + + ) : ( + handleChange('hub', e.target.value)} + placeholder="Hub location" + className="h-10" + required + /> + )}
@@ -731,6 +825,7 @@ export default function EventFormWizard({ event, onSubmit, onRapidSubmit, isSubm value={formData.date || ""} onChange={(e) => { const dateValue = e.target.value; + // Store date as-is without timezone conversion handleChange('date', dateValue); }} className="h-10" @@ -861,14 +956,40 @@ export default function EventFormWizard({ event, onSubmit, onRapidSubmit, isSubm
- + {shift.shift_contact ? ( +
+
+

{shift.shift_contact}

+

{shift.shift_contact_phone || shift.shift_contact_email}

+
+ +
+ ) : ( + + )} {formData.shifts.length > 1 && (
- - ); - } \ No newline at end of file + + + {/* Shift Contact Dialog */} + + + + Select Shift Contact + +
+ {teamMembers.length > 0 ? ( + teamMembers.map((member) => ( + handleSelectShiftContact(member)} + > + +
+ + + + {member.member_name?.charAt(0)} + + +
+

{member.member_name}

+

{member.title || member.role}

+
+ {member.phone && ( +

{member.phone}

+ )} + {member.email && ( +

{member.email}

+ )} +
+
+
+
+
+ )) + ) : ( +
+

No team members available

+
+ )} +
+
+
+ + ); +} \ No newline at end of file diff --git a/frontend-web/src/components/invoices/InvoiceDetailView.jsx b/frontend-web/src/components/invoices/InvoiceDetailView.jsx index f259d691..cc86a6a4 100644 --- a/frontend-web/src/components/invoices/InvoiceDetailView.jsx +++ b/frontend-web/src/components/invoices/InvoiceDetailView.jsx @@ -8,8 +8,10 @@ import { useToast } from "@/components/ui/use-toast"; import { base44 } from "@/api/base44Client"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { - Printer, Flag, CheckCircle, MoreVertical, FileText + Printer, Flag, CheckCircle, MoreVertical, FileText, Edit, CreditCard } from "lucide-react"; +import { useNavigate } from "react-router-dom"; +import { createPageUrl } from "@/utils"; import { format, parseISO } from "date-fns"; import { DropdownMenu, @@ -41,6 +43,7 @@ const statusColors = { export default function InvoiceDetailView({ invoice, userRole, onClose }) { const { toast } = useToast(); const queryClient = useQueryClient(); + const navigate = useNavigate(); const [showDisputeDialog, setShowDisputeDialog] = useState(false); const [disputeReason, setDisputeReason] = useState(""); const [disputeDetails, setDisputeDetails] = useState(""); @@ -70,6 +73,23 @@ export default function InvoiceDetailView({ invoice, userRole, onClose }) { }); }; + const handleMarkPaid = async () => { + const user = await base44.auth.me(); + updateInvoiceMutation.mutate({ + id: invoice.id, + data: { + status: "Paid", + paid_date: new Date().toISOString().split('T')[0], + payment_method: "Credit Card", + payment_reference: `PAY-${Date.now()}`, + } + }); + }; + + const handleEditInvoice = () => { + navigate(createPageUrl(`InvoiceEditor?id=${invoice.id}`)); + }; + const handleDispute = async () => { const user = await base44.auth.me(); updateInvoiceMutation.mutate({ @@ -104,7 +124,11 @@ export default function InvoiceDetailView({ invoice, userRole, onClose }) { if (!invoice) return null; const isClient = userRole === "client"; + const isVendor = userRole === "vendor"; + const isAdmin = userRole === "admin"; + const canEdit = (isVendor || isAdmin) && ["Draft", "Pending Review", "Disputed"].includes(invoice.status); const canApprove = isClient && invoice.status === "Pending Review"; + const canPay = isClient && invoice.status === "Approved"; const canDispute = isClient && ["Pending Review", "Approved"].includes(invoice.status); return ( @@ -127,6 +151,12 @@ export default function InvoiceDetailView({ invoice, userRole, onClose }) { Print + {canEdit && ( + + )} {canDispute && ( + )} + {canPay && ( + )} @@ -177,11 +213,32 @@ export default function InvoiceDetailView({ invoice, userRole, onClose }) {

From:

-
-

{invoice.from_company?.name || invoice.vendor_name}

-

{invoice.from_company?.address}

-

{invoice.from_company?.email}

-

{invoice.from_company?.phone}

+
+

{invoice.from_company?.name || invoice.vendor_name || "Vendor Name"}

+ {(invoice.from_company?.address || invoice.vendor_address) && ( +
+

Address

+

{invoice.from_company?.address || invoice.vendor_address}

+
+ )} + {(invoice.from_company?.email || invoice.vendor_email) && ( +
+

Email

+

{invoice.from_company?.email || invoice.vendor_email}

+
+ )} + {(invoice.from_company?.phone || invoice.vendor_phone) && ( +
+

Phone

+

{invoice.from_company?.phone || invoice.vendor_phone}

+
+ )} + {(invoice.from_company?.contact || invoice.vendor_contact) && ( +
+

Point of Contact

+

{invoice.from_company?.contact || invoice.vendor_contact}

+
+ )}
diff --git a/frontend-web/src/components/invoices/InvoiceExportPanel.jsx b/frontend-web/src/components/invoices/InvoiceExportPanel.jsx new file mode 100644 index 00000000..d2fdf456 --- /dev/null +++ b/frontend-web/src/components/invoices/InvoiceExportPanel.jsx @@ -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 ` + + +
+ KROW + ${business?.erp_vendor_id || 'CLIENT'} + KROW +
+ + + + + ${invoice.business_name} + + + + + + EA + ${invoice.amount} + ${invoice.event_name} + + + + ${invoice.subtotal || invoice.amount} + ${invoice.tax_amount || 0} + ${invoice.amount} + + ${invoice.amount} + ${invoice.amount} + + + +
`; + }; + + 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 ( + + + + + ERP / EDI Export + + + + {/* ERP System Info */} + {erpSystem !== "None" && ( +
+
+ +
+

Connected ERP

+

Vendor ID: {business?.erp_vendor_id || "Not configured"}

+
+
+ + {erpSystem} + +
+ )} + + {/* Export Format Selection */} +
+ + +
+ + {/* Export Buttons */} +
+ + + {business?.invoice_email && ( + + )} +
+ + {/* Quick Export Buttons */} +
+

Quick Export

+
+ + + + +
+
+
+
+ ); +} \ No newline at end of file diff --git a/frontend-web/src/components/orders/OrderDetailModal.jsx b/frontend-web/src/components/orders/OrderDetailModal.jsx index 89c777a8..39dea130 100644 --- a/frontend-web/src/components/orders/OrderDetailModal.jsx +++ b/frontend-web/src/components/orders/OrderDetailModal.jsx @@ -3,7 +3,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from " 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 } from "lucide-react"; +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"; @@ -184,6 +184,32 @@ export default function OrderDetailModal({ open, onClose, order, onCancel }) { + {/* Event Location & POC */} +
+
+
+ +

Event Location

+
+

{order.event_location || order.hub || "—"}

+
+
+
+ +

Point of Contact

+
+
+

{order.client_name || order.manager_name || "—"}

+ {order.client_phone && ( +

{order.client_phone}

+ )} + {order.client_email && ( +

{order.client_email}

+ )} +
+
+
+ {/* Shifts & Roles */} {order.shifts && order.shifts.length > 0 && (
diff --git a/frontend-web/src/components/rates/RateCardModal.jsx b/frontend-web/src/components/rates/RateCardModal.jsx new file mode 100644 index 00000000..f7fad7d6 --- /dev/null +++ b/frontend-web/src/components/rates/RateCardModal.jsx @@ -0,0 +1,217 @@ +import React, { useState, useMemo } from "react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Slider } from "@/components/ui/slider"; +import { Sparkles, DollarSign } from "lucide-react"; + +const APPROVED_BASE_RATES = { + "FoodBuy": { + "Banquet Captain": 47.76, "Barback": 36.13, "Barista": 43.11, "Busser": 39.23, "BW Bartender": 41.15, + "Cashier/Standworker": 38.05, "Cook": 44.58, "Dinning Attendant": 41.56, "Dishwasher/ Steward": 38.38, + "Executive Chef": 70.60, "FOH Cafe Attendant": 41.56, "Full Bartender": 47.76, "Grill Cook": 44.58, + "Host/Hostess/Greeter": 41.56, "Internal Support": 41.56, "Lead Cook": 52.00, "Line Cook": 44.58, + "Premium Server": 47.76, "Prep Cook": 37.98, "Receiver": 40.01, "Server": 41.56, "Sous Chef": 59.75, + "Warehouse Worker": 41.15, "Baker": 44.58, "Janitor": 38.38, "Mixologist": 71.30, "Utilities": 38.38, + "Scullery": 38.38, "Runner": 39.23, "Pantry Cook": 44.58, "Supervisor": 47.76, "Steward": 38.38, "Steward Supervisor": 41.15 + }, + "Aramark": { + "Banquet Captain": 46.37, "Barback": 33.11, "Barista": 36.87, "Busser": 33.11, "BW Bartender": 36.12, + "Cashier/Standworker": 33.11, "Cook": 36.12, "Dinning Attendant": 34.62, "Dishwasher/ Steward": 33.11, + "Executive Chef": 76.76, "FOH Cafe Attendant": 34.62, "Full Bartender": 45.15, "Grill Cook": 36.12, + "Host/Hostess/Greeter": 34.62, "Internal Support": 37.63, "Lead Cook": 52.68, "Line Cook": 36.12, + "Premium Server": 40.64, "Prep Cook": 34.62, "Receiver": 34.62, "Server": 34.62, "Sous Chef": 60.20, + "Warehouse Worker": 34.62, "Baker": 45.15, "Janitor": 34.62, "Mixologist": 60.20, "Utilities": 33.11, + "Scullery": 33.11, "Runner": 33.11, "Pantry Cook": 36.12, "Supervisor": 45.15, "Steward": 33.11, "Steward Supervisor": 34.10 + } +}; + +export default function RateCardModal({ isOpen, onClose, onSave, editingCard = null }) { + const [cardName, setCardName] = useState(""); + const [baseRateBook, setBaseRateBook] = useState("FoodBuy"); + const [discountPercent, setDiscountPercent] = useState(0); + + // Reset form when modal opens/closes or editingCard changes + React.useEffect(() => { + if (isOpen) { + setCardName(editingCard?.name || ""); + setBaseRateBook(editingCard?.baseBook || "FoodBuy"); + setDiscountPercent(editingCard?.discount || 0); + } + }, [isOpen, editingCard]); + + const baseRates = APPROVED_BASE_RATES[baseRateBook] || {}; + const positions = Object.keys(baseRates); + + const stats = useMemo(() => { + const rates = Object.values(baseRates); + const avgBase = rates.length > 0 ? rates.reduce((a, b) => a + b, 0) / rates.length : 0; + const avgNew = avgBase * (1 - discountPercent / 100); + const totalSavings = rates.reduce((sum, r) => sum + (r * discountPercent / 100), 0); + return { avgBase, avgNew, totalSavings, count: rates.length }; + }, [baseRates, discountPercent]); + + const handleSave = () => { + if (!cardName.trim()) return; + + const discountedRates = {}; + Object.entries(baseRates).forEach(([pos, rate]) => { + discountedRates[pos] = Math.round(rate * (1 - discountPercent / 100) * 100) / 100; + }); + + onSave({ + name: cardName, + baseBook: baseRateBook, + discount: discountPercent, + rates: discountedRates + }); + onClose(); + }; + + return ( + + + + +
+ +
+ {editingCard ? "Edit Rate Card" : "Create Custom Rate Card"} +
+

+ Build a custom rate card by selecting an approved rate book as your base and applying your competitive discount +

+
+ +
+ {/* Step 1: Name */} +
+
+
1
+

+ {editingCard ? "Rename Rate Card" : "Name Your Rate Card"} +

+
+ setCardName(e.target.value)} + placeholder="e.g., Google Campus, Meta HQ, Salesforce Tower..." + className="bg-white text-lg font-medium" + autoFocus={!!editingCard} + /> + {editingCard && editingCard.name !== cardName && cardName.trim() && ( +

+ Will rename from "{editingCard.name}" to "{cardName}" +

+ )} +
+ + {/* Step 2: Base Rates */} +
+
+
2
+

Choose Approved Base Rates

+
+ +

+ Using {baseRateBook} as your baseline • Avg Rate: ${stats.avgBase.toFixed(2)}/hr +

+
+ + {/* Step 3: Discount */} +
+
+
3
+

Apply Your Discount

+
+
+
+ setDiscountPercent(val)} + min={0} + max={25} + step={0.5} + className="[&_[role=slider]]:bg-green-500 [&_[role=slider]]:border-green-600" + /> +
+
+ % {discountPercent.toFixed(1)}% +
+
+
+ + New Avg Rate: ${stats.avgNew.toFixed(2)}/hr + + + + Total Savings: ${stats.totalSavings.toFixed(2)}/position + +
+
+ + {/* Rate Preview */} +
+
+ +

Rate Preview ({stats.count} positions)

+
+
+ + + + + + + + + + + {positions.map((position) => { + const baseRate = baseRates[position]; + const newRate = baseRate * (1 - discountPercent / 100); + const savings = baseRate - newRate; + return ( + + + + + + + ); + })} + +
PositionBase RateYour RateSavings
{position}${baseRate.toFixed(2)}${newRate.toFixed(2)} + + -${savings.toFixed(2)} + +
+
+
+
+ + {/* Footer */} +
+ + +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend-web/src/components/scheduling/TalentRadar.jsx b/frontend-web/src/components/scheduling/TalentRadar.jsx new file mode 100644 index 00000000..1839c893 --- /dev/null +++ b/frontend-web/src/components/scheduling/TalentRadar.jsx @@ -0,0 +1,218 @@ +import React, { useMemo } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { Users, TrendingUp, UserPlus, AlertCircle, CheckCircle } from "lucide-react"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; + +export default function TalentRadar({ event, allStaff, availabilityData, onAssign }) { + // Calculate three intelligent buckets + const buckets = useMemo(() => { + const readyNow = []; + const likelyAvailable = []; + const needsWork = []; + + allStaff.forEach(staff => { + const availability = availabilityData.find(a => a.staff_id === staff.id); + + // Skip if already assigned to this event + if (event.assigned_staff?.some(a => a.staff_id === staff.id)) return; + + // Skip if blocked + if (availability?.availability_status === 'BLOCKED') return; + + // Calculate match score + const matchScore = calculateMatchScore(staff, event, availability); + + // Ready Now: Confirmed available + good match + if (availability?.availability_status === 'CONFIRMED_AVAILABLE' && matchScore >= 70) { + readyNow.push({ staff, availability, matchScore }); + } + // Likely Available: Unknown but high prediction score + else if ( + availability?.availability_status === 'UNKNOWN' && + availability?.predicted_availability_score >= 70 + ) { + likelyAvailable.push({ staff, availability, matchScore }); + } + // Needs Work: High need index + if (availability && availability.need_work_index >= 60) { + needsWork.push({ staff, availability, matchScore }); + } + }); + + // Sort by match score and need + readyNow.sort((a, b) => { + const scoreA = a.matchScore + (a.availability?.need_work_index || 0) * 0.3; + const scoreB = b.matchScore + (b.availability?.need_work_index || 0) * 0.3; + return scoreB - scoreA; + }); + + likelyAvailable.sort((a, b) => b.matchScore - a.matchScore); + needsWork.sort((a, b) => b.availability.need_work_index - a.availability.need_work_index); + + return { readyNow, likelyAvailable, needsWork }; + }, [allStaff, availabilityData, event]); + + const renderWorkerCard = (item, bucket) => { + const { staff, availability, matchScore } = item; + + return ( + + +
+
+ + + {staff.employee_name?.charAt(0)?.toUpperCase()} + + +
+

{staff.employee_name}

+

{staff.position || 'Staff'}

+ +
+ + {matchScore}% match + + {availability?.scheduled_hours_this_period > 0 && ( + + {availability.scheduled_hours_this_period}h scheduled + + )} + {bucket === 'needs' && ( + + Needs work + + )} + {bucket === 'likely' && ( + + {availability?.predicted_availability_score}% likely + + )} +
+
+
+ +
+
+
+ ); + }; + + return ( +
+
+
+

Talent Radar

+

Smart worker recommendations for this shift

+
+
+ + + + + + Ready Now ({buckets.readyNow.length}) + + + + Likely Available ({buckets.likelyAvailable.length}) + + + + Needs Work ({buckets.needsWork.length}) + + + + + + +

+ Ready Now: These workers have confirmed their availability and are the best match for this shift. +

+
+
+ {buckets.readyNow.length === 0 ? ( +

No workers confirmed available

+ ) : ( +
+ {buckets.readyNow.map(item => renderWorkerCard(item, 'ready'))} +
+ )} +
+ + + + +

+ Likely Available: These workers haven't confirmed availability but historically accept similar shifts. Assignment requires worker confirmation. +

+
+
+ {buckets.likelyAvailable.length === 0 ? ( +

No predictions available

+ ) : ( +
+ {buckets.likelyAvailable.map(item => renderWorkerCard(item, 'likely'))} +
+ )} +
+ + + + +

+ Needs Work: These workers are under-scheduled and could benefit from additional hours. They may accept even if not explicitly available. +

+
+
+ {buckets.needsWork.length === 0 ? ( +

No under-utilized workers

+ ) : ( +
+ {buckets.needsWork.map(item => renderWorkerCard(item, 'needs'))} +
+ )} +
+
+
+ ); +} + +// Helper function to calculate match score +function calculateMatchScore(staff, event, availability) { + let score = 50; // Base score + + // Skill match + const eventRole = event.shifts?.[0]?.roles?.[0]?.role; + if (eventRole && staff.position === eventRole) { + score += 20; + } else if (eventRole && staff.position_2 === eventRole) { + score += 10; + } + + // Reliability + if (staff.reliability_score) { + score += (staff.reliability_score / 100) * 15; + } + + // Compliance + if (staff.background_check_status === 'cleared') { + score += 10; + } + + // Acceptance rate + if (availability?.acceptance_rate) { + score += (availability.acceptance_rate / 100) * 5; + } + + return Math.min(100, Math.round(score)); +} \ No newline at end of file diff --git a/frontend-web/src/lib/lib/firebase.js b/frontend-web/src/lib/lib/firebase.js new file mode 100644 index 00000000..39df1281 --- /dev/null +++ b/frontend-web/src/lib/lib/firebase.js @@ -0,0 +1,20 @@ +// Import the functions you need from the SDKs you need +import { initializeApp } from "firebase/app"; +import { getAuth } from "firebase/auth"; +import { getDataConnect } from 'firebase/data-connect'; +import { connectorConfig } from '@dataconnect/generated'; + +// Your web app's Firebase configuration +const firebaseConfig = { + /*apiKey: import.meta.env.VITE_HARNESS_FIREBASE_API_KEY, + authDomain: import.meta.env.VITE_HARNESS_FIREBASE_AUTH_DOMAIN, + projectId: import.meta.env.VITE_HARNESS_FIREBASE_PROJECT_ID, + storageBucket: import.meta.env.VITE_HARNESS_FIREBASE_STORAGE_BUCKET, + messagingSenderId: import.meta.env.VITE_HARNESS_FIREBASE_MESSAGING_SENDER_ID, + appId: import.meta.env.VITE_HARNESS_FIREBASE_APP_ID*/ +}; + +// Initialize Firebase +const app = initializeApp(firebaseConfig); +export const dataConnect = getDataConnect(app, connectorConfig); +export const auth = getAuth(app); \ No newline at end of file diff --git a/frontend-web/src/lib/lib/utils.js b/frontend-web/src/lib/lib/utils.js new file mode 100644 index 00000000..6f706bfa --- /dev/null +++ b/frontend-web/src/lib/lib/utils.js @@ -0,0 +1,6 @@ +import { clsx } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs) { + return twMerge(clsx(inputs)) +} \ No newline at end of file diff --git a/frontend-web/src/pages/Business.jsx b/frontend-web/src/pages/Business.jsx index 269c59b4..faf2dfb5 100644 --- a/frontend-web/src/pages/Business.jsx +++ b/frontend-web/src/pages/Business.jsx @@ -1,4 +1,3 @@ - import React, { useState, useMemo } from "react"; import { base44 } from "@/api/base44Client"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; @@ -7,24 +6,24 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Plus, Building2, Mail, Phone, MapPin, Search, Eye, Trash2, ChevronDown, ChevronRight } from "lucide-react"; +import { Plus, Building2, Mail, Phone, MapPin, Search, Eye, Trash2, ChevronDown, ChevronRight, Users, Star, Ban, Briefcase, Settings, UserPlus, UserMinus, Shield, Edit2, TrendingUp, Clock, Award, Grid3x3, List } from "lucide-react"; +import BusinessCard from "@/components/business/BusinessCard"; import { Link, useNavigate } from "react-router-dom"; import { createPageUrl } from "@/utils"; import PageHeader from "@/components/common/PageHeader"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@/components/ui/collapsible"; + import CreateBusinessModal from "@/components/business/CreateBusinessModal"; export default function Business() { const navigate = useNavigate(); const queryClient = useQueryClient(); const [searchTerm, setSearchTerm] = useState(""); - const [statusFilter, setStatusFilter] = useState("active"); - const [expandedCompanies, setExpandedCompanies] = useState({}); const [createModalOpen, setCreateModalOpen] = useState(false); + const [filterManager, setFilterManager] = useState("all"); + const [filterHub, setFilterHub] = useState("all"); + const [filterGrade, setFilterGrade] = useState("all"); + const [filterCancelRate, setFilterCancelRate] = useState("all"); + const [viewMode, setViewMode] = useState("grid"); // grid or list const { data: user } = useQuery({ queryKey: ['current-user-business'], @@ -37,6 +36,23 @@ export default function Business() { initialData: [], }); + const { data: allStaff = [] } = useQuery({ + queryKey: ['staff-for-business'], + queryFn: () => base44.entities.Staff.list(), + }); + + const { data: allEvents = [] } = useQuery({ + queryKey: ['events-for-business-metrics'], + queryFn: () => base44.entities.Event.list(), + initialData: [], + }); + + const { data: invoices = [] } = useQuery({ + queryKey: ['invoices-for-business'], + queryFn: () => base44.entities.Invoice.list(), + initialData: [], + }); + const userRole = user?.user_role || user?.role || "admin"; const isVendor = userRole === "vendor"; @@ -48,10 +64,97 @@ export default function Business() { }, }); + const updateBusinessMutation = useMutation({ + mutationFn: ({ id, data }) => base44.entities.Business.update(id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['businesses'] }); + }, + }); + const handleCreateBusiness = (businessData) => { createBusinessMutation.mutate(businessData); }; + const handleAddFavoriteStaff = async (businessId, staff) => { + const business = businesses.find(b => b.id === businessId); + const favoriteStaff = business.favorite_staff || []; + const alreadyFavorite = favoriteStaff.some(s => s.staff_id === staff.id); + + if (alreadyFavorite) return; + + const updatedFavorites = [ + ...favoriteStaff, + { + staff_id: staff.id, + staff_name: staff.employee_name, + position: staff.position, + added_date: new Date().toISOString(), + } + ]; + + await updateBusinessMutation.mutateAsync({ + id: businessId, + data: { + favorite_staff: updatedFavorites, + favorite_staff_count: updatedFavorites.length, + } + }); + }; + + const handleRemoveFavoriteStaff = async (businessId, staffId) => { + const business = businesses.find(b => b.id === businessId); + const favoriteStaff = business.favorite_staff || []; + const updatedFavorites = favoriteStaff.filter(s => s.staff_id !== staffId); + + await updateBusinessMutation.mutateAsync({ + id: businessId, + data: { + favorite_staff: updatedFavorites, + favorite_staff_count: updatedFavorites.length, + } + }); + }; + + const handleBlockStaff = async (businessId, staff, reason) => { + const business = businesses.find(b => b.id === businessId); + const blockedStaff = business.blocked_staff || []; + const alreadyBlocked = blockedStaff.some(s => s.staff_id === staff.id); + + if (alreadyBlocked) return; + + const updatedBlocked = [ + ...blockedStaff, + { + staff_id: staff.id, + staff_name: staff.employee_name, + reason: reason || "No reason provided", + blocked_date: new Date().toISOString(), + } + ]; + + await updateBusinessMutation.mutateAsync({ + id: businessId, + data: { + blocked_staff: updatedBlocked, + blocked_staff_count: updatedBlocked.length, + } + }); + }; + + const handleUnblockStaff = async (businessId, staffId) => { + const business = businesses.find(b => b.id === businessId); + const blockedStaff = business.blocked_staff || []; + const updatedBlocked = blockedStaff.filter(s => s.staff_id !== staffId); + + await updateBusinessMutation.mutateAsync({ + id: businessId, + data: { + blocked_staff: updatedBlocked, + blocked_staff_count: updatedBlocked.length, + } + }); + }; + // Consolidate businesses by company name const consolidatedBusinesses = useMemo(() => { const grouped = {}; @@ -94,6 +197,10 @@ export default function Business() { return Object.values(grouped); }, [businesses]); + // Get unique managers and hubs for filters + const allManagers = [...new Set(consolidatedBusinesses.flatMap(c => c.hubs.map(h => h.contact_name).filter(Boolean)))]; + const allHubs = [...new Set(consolidatedBusinesses.flatMap(c => c.hubs.map(h => h.hub_name)))]; + const filteredBusinesses = consolidatedBusinesses.filter(company => { const matchesSearch = !searchTerm || company.company_name?.toLowerCase().includes(searchTerm.toLowerCase()) || @@ -103,31 +210,123 @@ export default function Business() { hub.address?.toLowerCase().includes(searchTerm.toLowerCase()) ); - return matchesSearch; + const matchesManager = filterManager === "all" || + company.hubs.some(hub => hub.contact_name === filterManager); + + const matchesHub = filterHub === "all" || + company.hubs.some(hub => hub.hub_name === filterHub); + + // Calculate metrics for filtering + const clientEvents = allEvents.filter(e => + e.business_name === company.company_name || + company.hubs.some(h => h.hub_name === e.business_name) + ); + const totalOrders = clientEvents.length; + const canceledOrders = clientEvents.filter(e => e.status === 'Canceled').length; + const cancelationRate = totalOrders > 0 ? ((canceledOrders / totalOrders) * 100) : 0; + + const totalStaffRequested = clientEvents.reduce((sum, e) => sum + (e.requested || 0), 0); + const totalStaffAssigned = clientEvents.reduce((sum, e) => sum + (e.assigned_staff?.length || 0), 0); + const fillRate = totalStaffRequested > 0 ? ((totalStaffAssigned / totalStaffRequested) * 100) : 0; + + const onTimeOrders = clientEvents.filter(e => { + const eventDate = new Date(e.date); + const createdDate = new Date(e.created_date); + const daysDiff = (eventDate - createdDate) / (1000 * 60 * 60 * 24); + return daysDiff >= 3; + }).length; + const onTimeRate = totalOrders > 0 ? ((onTimeOrders / totalOrders) * 100) : 0; + + const metrics = [ + parseFloat(fillRate), + parseFloat(onTimeRate), + 100 - parseFloat(cancelationRate), + Math.min((totalOrders / 10) * 100, 100) + ]; + const avgScore = metrics.reduce((a, b) => a + b, 0) / metrics.length; + let clientGrade = 'C'; + if (avgScore >= 90) clientGrade = 'A+'; + else if (avgScore >= 85) clientGrade = 'A'; + else if (avgScore >= 80) clientGrade = 'A-'; + else if (avgScore >= 75) clientGrade = 'B+'; + else if (avgScore >= 70) clientGrade = 'B'; + + const matchesGrade = filterGrade === "all" || clientGrade === filterGrade; + + const matchesCancelRate = + filterCancelRate === "all" || + (filterCancelRate === "low" && cancelationRate < 5) || + (filterCancelRate === "medium" && cancelationRate >= 5 && cancelationRate < 15) || + (filterCancelRate === "high" && cancelationRate >= 15); + + return matchesSearch && matchesManager && matchesHub && matchesGrade && matchesCancelRate; }); - const toggleCompany = (companyName) => { - setExpandedCompanies(prev => ({ - ...prev, - [companyName]: !prev[companyName] - })); - }; + const canAddBusiness = ["admin", "procurement", "operator", "vendor"].includes(userRole); const totalHubs = filteredBusinesses.reduce((sum, company) => sum + company.hubs.length, 0); + // Calculate KPIs + const totalCompanies = filteredBusinesses.length; + const goldClients = filteredBusinesses.filter(company => { + const clientEvents = allEvents.filter(e => + e.business_name === company.company_name || + company.hubs.some(h => h.hub_name === e.business_name) + ); + const totalOrders = clientEvents.length; + const canceledOrders = clientEvents.filter(e => e.status === 'Canceled').length; + const cancelationRate = totalOrders > 0 ? ((canceledOrders / totalOrders) * 100) : 0; + const totalStaffRequested = clientEvents.reduce((sum, e) => sum + (e.requested || 0), 0); + const totalStaffAssigned = clientEvents.reduce((sum, e) => sum + (e.assigned_staff?.length || 0), 0); + const fillRate = totalStaffRequested > 0 ? ((totalStaffAssigned / totalStaffRequested) * 100) : 0; + const onTimeOrders = clientEvents.filter(e => { + const eventDate = new Date(e.date); + const createdDate = new Date(e.created_date); + const daysDiff = (eventDate - createdDate) / (1000 * 60 * 60 * 24); + return daysDiff >= 3; + }).length; + const onTimeRate = totalOrders > 0 ? ((onTimeOrders / totalOrders) * 100) : 0; + const metrics = [parseFloat(fillRate), parseFloat(onTimeRate), 100 - parseFloat(cancelationRate), Math.min((totalOrders / 10) * 100, 100)]; + const avgScore = metrics.reduce((a, b) => a + b, 0) / metrics.length; + return avgScore >= 90; + }).length; + + const totalMonthlySpend = filteredBusinesses.reduce((sum, company) => { + const clientInvoices = invoices.filter(i => + i.business_name === company.company_name || + company.hubs.some(h => h.hub_name === i.business_name) + ); + const monthlySpend = clientInvoices + .filter(i => { + const invoiceDate = new Date(i.created_date); + const now = new Date(); + return invoiceDate.getMonth() === now.getMonth() && invoiceDate.getFullYear() === now.getFullYear(); + }) + .reduce((s, i) => s + (i.amount || 0), 0); + return sum + monthlySpend; + }, 0); + + const totalOrders = filteredBusinesses.reduce((sum, company) => { + const clientEvents = allEvents.filter(e => + e.business_name === company.company_name || + company.hubs.some(h => h.hub_name === e.business_name) + ); + return sum + clientEvents.length; + }, 0); + return (
setCreateModalOpen(true)} - className="bg-gradient-to-r from-[#0A39DF] to-[#1C323E] hover:from-[#0A39DF]/90 hover:to-[#1C323E]/90 text-white shadow-lg" + className="bg-blue-600 hover:bg-blue-700 text-white shadow-lg" > Add Business @@ -136,179 +335,360 @@ export default function Business() { } /> - {/* Status Tabs */} - - - - Active - - {filteredBusinesses.length} - - - - Pending - - 0 - - - - Deactivated - - 0 - - - - + {/* KPI Dashboard */} +
+ + +
+
+

Total Companies

+

{totalCompanies}

+
+
+ +
+
+
+
- {/* Search Bar */} -
-
- - setSearchTerm(e.target.value)} - className="pl-10 bg-white border-slate-200" - /> -
+ + +
+
+

Gold Clients

+

{goldClients}

+

A+ Performance

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

Monthly Sales

+

${(totalMonthlySpend / 1000).toFixed(0)}k

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

Total Orders

+

{totalOrders}

+

{totalHubs} Hubs

+
+
+ +
+
+
+
- {/* Consolidated Business List */} - {filteredBusinesses.length > 0 ? ( -
- {filteredBusinesses.map((company) => ( - - toggleCompany(company.company_name)} - > -
- -
-
- {expandedCompanies[company.company_name] ? ( - - ) : ( - - )} -
- {company.company_name?.charAt(0) || 'B'} -
-
-

{company.company_name}

-
- - - {company.hubs.length} {company.hubs.length === 1 ? 'hub' : 'hubs'} - - {company.sector && ( - - {company.sector} - - )} -
-
-
-
-
- {company.primary_contact || '—'} -
- {company.primary_email && ( -
- - {company.primary_email} -
- )} - {company.primary_phone && ( -
- - {company.primary_phone} -
- )} -
-
-
-
+ {/* Filters Section */} +
+ {/* Search Bar */} +
+ + setSearchTerm(e.target.value)} + className="pl-12 h-12 bg-white border-slate-300 text-base shadow-sm" + /> +
- -
- - - - - - - - - - - - {company.hubs.map((hub, idx) => ( - - - - - - - - ))} - -
Hub NameContactAddressCityActions
-
- - {hub.hub_name} -
-
-
-

{hub.contact_name || '—'}

- {hub.email && ( -

- - {hub.email} -

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

- - {hub.phone} -

- )} -
-
-

{hub.address || '—'}

-
-

{hub.city || '—'}

-
-
- - -
-
-
-
- - - ))} + {/* Filter Pills */} +
+ + + + + {isVendor && ( + <> + + + + + )} + + {(searchTerm || filterManager !== "all" || filterHub !== "all" || filterGrade !== "all" || filterCancelRate !== "all") && ( + + )} +
+ + {/* View Mode Toggle */} +
+ + +
+
+ + {/* Business List */} + {filteredBusinesses.length > 0 ? ( +
+ {filteredBusinesses.map((company) => { + // Get all businesses that belong to this company + const companyBusinesses = businesses.filter(b => + b.business_name === company.company_name || + b.business_name.startsWith(company.company_name + ' - ') + ); + + const firstBusiness = companyBusinesses[0]; + + // Collect team members from all businesses in this company + const teamMembers = []; + companyBusinesses.forEach(bus => { + if (bus.team_members) { + bus.team_members.forEach(member => { + if (!teamMembers.some(m => m.email === member.email)) { + teamMembers.push(member); + } + }); + } + }); + + // Extract hub managers from company.hubs (which contains hub info from consolidated structure) + const hubContacts = company.hubs + .filter(hub => hub.contact_name) + .map(hub => ({ + member_id: `hub-${hub.hub_name}`, + member_name: hub.contact_name, + email: hub.email || '', + phone: hub.phone, + role: 'Hub Manager', + title: 'Hub Manager', + hub: hub.hub_name, + is_active: true + })); + + // Combine team members and hub contacts, removing duplicates + const allTeamMembers = [...teamMembers]; + hubContacts.forEach(hubContact => { + const alreadyExists = allTeamMembers.some(m => + (hubContact.email && m.email === hubContact.email) || + (!hubContact.email && m.member_name === hubContact.member_name) + ); + if (!alreadyExists) { + allTeamMembers.push(hubContact); + } + }); + + const activeMembers = allTeamMembers.filter(m => m.is_active !== false).length; + const totalMembers = allTeamMembers.length; + + // Calculate client metrics for vendor view + const clientEvents = allEvents.filter(e => + e.business_name === company.company_name || + company.hubs.some(h => h.hub_name === e.business_name) + ); + + const totalOrders = clientEvents.length; + const completedOrders = clientEvents.filter(e => e.status === 'Completed').length; + const canceledOrders = clientEvents.filter(e => e.status === 'Canceled').length; + const cancelationRate = totalOrders > 0 ? ((canceledOrders / totalOrders) * 100).toFixed(0) : 0; + + const totalStaffRequested = clientEvents.reduce((sum, e) => sum + (e.requested || 0), 0); + const totalStaffAssigned = clientEvents.reduce((sum, e) => sum + (e.assigned_staff?.length || 0), 0); + const fillRate = totalStaffRequested > 0 ? ((totalStaffAssigned / totalStaffRequested) * 100).toFixed(0) : 0; + + const clientInvoices = invoices.filter(i => + i.business_name === company.company_name || + company.hubs.some(h => h.hub_name === i.business_name) + ); + const monthlySpend = clientInvoices + .filter(i => { + const invoiceDate = new Date(i.created_date); + const now = new Date(); + return invoiceDate.getMonth() === now.getMonth() && invoiceDate.getFullYear() === now.getFullYear(); + }) + .reduce((sum, i) => sum + (i.amount || 0), 0); + + const avgOrderValue = totalOrders > 0 ? clientInvoices.reduce((sum, i) => sum + (i.amount || 0), 0) / totalOrders : 0; + + const onTimeOrders = clientEvents.filter(e => { + const eventDate = new Date(e.date); + const createdDate = new Date(e.created_date); + const daysDiff = (eventDate - createdDate) / (1000 * 60 * 60 * 24); + return daysDiff >= 3; + }).length; + const onTimeRate = totalOrders > 0 ? ((onTimeOrders / totalOrders) * 100).toFixed(0) : 0; + + // Calculate grade based on metrics + const getClientGrade = () => { + const metrics = [ + parseFloat(fillRate), + parseFloat(onTimeRate), + 100 - parseFloat(cancelationRate), + Math.min((totalOrders / 10) * 100, 100) + ]; + const avgScore = metrics.reduce((a, b) => a + b, 0) / metrics.length; + if (avgScore >= 90) return 'A+'; + if (avgScore >= 85) return 'A'; + if (avgScore >= 80) return 'A-'; + if (avgScore >= 75) return 'B+'; + if (avgScore >= 70) return 'B'; + return 'C'; + }; + + const clientGrade = getClientGrade(); + const gradeColor = clientGrade.startsWith('A') ? 'bg-green-500' : clientGrade.startsWith('B') ? 'bg-blue-500' : 'bg-orange-500'; + + // Calculate rapid orders (less than 3 days notice) + const rapidOrders = clientEvents.filter(e => { + const eventDate = new Date(e.date); + const createdDate = new Date(e.created_date); + const daysDiff = (eventDate - createdDate) / (1000 * 60 * 60 * 24); + return daysDiff < 3; + }).length; + + // Get most requested position + const roleCount = {}; + clientEvents.forEach(e => { + if (e.shifts) { + e.shifts.forEach(shift => { + if (shift.roles) { + shift.roles.forEach(role => { + const roleName = role.role || 'Staff'; + roleCount[roleName] = (roleCount[roleName] || 0) + (role.count || 1); + }); + } + }); + } + }); + const mainPosition = Object.keys(roleCount).length > 0 + ? Object.keys(roleCount).reduce((a, b) => roleCount[a] > roleCount[b] ? a : b) + : 'Line Cook'; + + // Check if business is using KROW (based on having platform integration or active usage) + const isUsingKROW = firstBusiness?.notes?.toLowerCase().includes('krow') || + totalOrders > 5 || + Math.random() > 0.5; // Randomize for demo - replace with actual logic + + // Calculate last order date + const lastOrderDate = clientEvents.length > 0 + ? clientEvents.reduce((latest, event) => { + const eventDate = new Date(event.date || event.created_date); + return eventDate > latest ? eventDate : latest; + }, new Date(0)).toISOString() + : null; + + const cardData = { + companyName: company.company_name, + logo: firstBusiness?.company_logo, + sector: company.sector, + monthlySpend: monthlySpend, + totalStaff: totalStaffRequested, + location: firstBusiness?.city || 'Bay Area', + serviceType: firstBusiness?.service_specialty || 'Full Service Events', + phone: company.primary_phone || '(555) 123-4567', + email: company.primary_email || 'contact@company.com', + technology: { isUsingKROW }, + performance: { + fillRate: fillRate, + onTimeRate: onTimeRate, + cancelRate: cancelationRate, + rapidOrders: rapidOrders, + mainPosition: mainPosition + }, + gradeColor: gradeColor, + clientGrade: clientGrade, + isActive: firstBusiness?.is_active, + lastOrderDate: lastOrderDate, + rateCard: firstBusiness?.rate_card, + businessId: company.hubs[0]?.id + }; + + return ( + navigate(createPageUrl(`EditBusiness?id=${company.hubs[0]?.id}`))} + onEdit={() => navigate(createPageUrl(`EditBusiness?id=${company.hubs[0]?.id}`))} + /> + ); + })}
) : (
@@ -340,4 +720,4 @@ export default function Business() { />
); -} +} \ No newline at end of file diff --git a/frontend-web/src/pages/ClientDashboard.jsx b/frontend-web/src/pages/ClientDashboard.jsx index 9ea75f69..35d98456 100644 --- a/frontend-web/src/pages/ClientDashboard.jsx +++ b/frontend-web/src/pages/ClientDashboard.jsx @@ -1,4 +1,3 @@ - import React, { useState, useMemo, useEffect } from "react"; import { base44 } from "@/api/base44Client"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; @@ -140,6 +139,13 @@ const AVAILABLE_WIDGETS = [ description: 'Browse vendor marketplace', category: 'Actions', categoryColor: 'bg-blue-100 text-blue-700', + }, + { + id: 'invoices', + title: 'Invoices', + description: 'View and manage invoices', + category: 'Actions', + categoryColor: 'bg-blue-100 text-blue-700', } ]; @@ -953,6 +959,19 @@ export default function ClientDashboard() { ); + const renderInvoices = () => ( + + + +
+ +
+

Invoices

+
+
+ + ); + const renderWidget = (widgetId) => { switch (widgetId) { case 'order-now': @@ -973,6 +992,8 @@ export default function ClientDashboard() { return renderSalesAnalytics(); case 'vendor-marketplace': return renderVendorMarketplace(); + case 'invoices': + return renderInvoices(); default: return null; } @@ -982,8 +1003,13 @@ export default function ClientDashboard() { const availableToAdd = AVAILABLE_WIDGETS.filter(w => hiddenWidgets.includes(w.id)); const quickActionWidgets = ['order-now', 'rapid-order', 'today-count', 'in-progress', 'needs-attention']; + const gridPairWidgets = ['vendor-marketplace', 'invoices']; const visibleQuickActions = visibleWidgetIds.filter(id => quickActionWidgets.includes(id)); const visibleOtherWidgets = visibleWidgetIds.filter(id => !quickActionWidgets.includes(id)); + + // Group grid pair widgets together + const gridPairVisible = visibleOtherWidgets.filter(id => gridPairWidgets.includes(id)); + const otherWidgetsFullWidth = visibleOtherWidgets.filter(id => !gridPairWidgets.includes(id)); return (
@@ -1106,7 +1132,7 @@ export default function ClientDashboard() { ref={provided.innerRef} className="space-y-6" > - {visibleOtherWidgets.map((widgetId, index) => ( + {otherWidgetsFullWidth.map((widgetId, index) => ( ))} + + {gridPairVisible.length > 0 && ( +
+ {gridPairVisible.map((widgetId) => ( +
+ {isCustomizing && ( + + )} +
+ {renderWidget(widgetId)} +
+
+ ))} +
+ )} + {provided.placeholder}
)} @@ -1222,4 +1270,4 @@ export default function ClientDashboard() {
); -} +} \ No newline at end of file diff --git a/frontend-web/src/pages/ClientOrders.jsx b/frontend-web/src/pages/ClientOrders.jsx index 9b80ac03..4673a672 100644 --- a/frontend-web/src/pages/ClientOrders.jsx +++ b/frontend-web/src/pages/ClientOrders.jsx @@ -21,9 +21,29 @@ import { TabsList, // New import TabsTrigger, // New import } from "@/components/ui/tabs"; // New import +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Calendar as CalendarComponent } from "@/components/ui/calendar"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, +} from "@/components/ui/command"; import { Search, Calendar, MapPin, Users, Eye, Edit, X, Trash2, FileText, // Edit instead of Edit2 - Clock, DollarSign, Package, CheckCircle, AlertTriangle, Grid, List, Zap, Plus, Building2, Bell, Edit3 + Clock, DollarSign, Package, CheckCircle, AlertTriangle, Grid, List, Zap, Plus, Building2, Bell, Edit3, Filter, CalendarIcon, Check, ChevronsUpDown } from "lucide-react"; import { useToast } from "@/components/ui/use-toast"; import { format, parseISO, isValid } from "date-fns"; @@ -92,10 +112,18 @@ export default function ClientOrders() { const { toast } = useToast(); const [searchTerm, setSearchTerm] = useState(""); const [statusFilter, setStatusFilter] = useState("all"); // Updated values for Tabs + const [dateFilter, setDateFilter] = useState("all"); + const [specificDate, setSpecificDate] = useState(null); + const [tempDate, setTempDate] = useState(null); + const [locationFilter, setLocationFilter] = useState("all"); + const [managerFilter, setManagerFilter] = useState("all"); + const [locationOpen, setLocationOpen] = useState(false); + const [managerOpen, setManagerOpen] = useState(false); const [cancelDialogOpen, setCancelDialogOpen] = useState(false); // Changed from cancelDialog.open const [orderToCancel, setOrderToCancel] = useState(null); // Changed from cancelDialog.order const [viewOrderModal, setViewOrderModal] = useState(false); const [selectedOrder, setSelectedOrder] = useState(null); + const [calendarOpen, setCalendarOpen] = useState(false); const { data: user } = useQuery({ queryKey: ['current-user-client-orders'], @@ -135,6 +163,28 @@ export default function ClientOrders() { }, }); + // Get unique locations and managers for filters + const uniqueLocations = useMemo(() => { + const locations = new Set(); + clientEvents.forEach(e => { + if (e.hub) locations.add(e.hub); + if (e.event_location) locations.add(e.event_location); + }); + return Array.from(locations).sort(); + }, [clientEvents]); + + const uniqueManagers = useMemo(() => { + const managers = new Set(); + clientEvents.forEach(e => { + if (e.manager_name) managers.add(e.manager_name); + // Also check in shifts for manager names + e.shifts?.forEach(shift => { + if (shift.manager_name) managers.add(shift.manager_name); + }); + }); + return Array.from(managers).sort(); + }, [clientEvents]); + const filteredOrders = useMemo(() => { // Renamed from filteredEvents let filtered = clientEvents; @@ -166,8 +216,61 @@ export default function ClientOrders() { return true; // For "all" or other statuses }); + // Specific date filter (from calendar) + if (specificDate) { + filtered = filtered.filter(e => { + const eventDate = safeParseDate(e.date); + if (!eventDate) return false; + const selectedDateNormalized = new Date(specificDate); + selectedDateNormalized.setHours(0, 0, 0, 0); + eventDate.setHours(0, 0, 0, 0); + return eventDate.getTime() === selectedDateNormalized.getTime(); + }); + } + // Date range filter + else if (dateFilter !== "all") { + filtered = filtered.filter(e => { + const eventDate = safeParseDate(e.date); + if (!eventDate) return false; + + const now = new Date(); + now.setHours(0, 0, 0, 0); + + if (dateFilter === "today") { + return eventDate.toDateString() === now.toDateString(); + } else if (dateFilter === "week") { + const weekFromNow = new Date(now); + weekFromNow.setDate(now.getDate() + 7); + return eventDate >= now && eventDate <= weekFromNow; + } else if (dateFilter === "month") { + const monthFromNow = new Date(now); + monthFromNow.setMonth(now.getMonth() + 1); + return eventDate >= now && eventDate <= monthFromNow; + } else if (dateFilter === "past") { + return eventDate < now; + } + return true; + }); + } + + // Location filter + if (locationFilter !== "all") { + filtered = filtered.filter(e => + e.hub === locationFilter || e.event_location === locationFilter + ); + } + + // Manager filter + if (managerFilter !== "all") { + filtered = filtered.filter(e => { + if (e.manager_name === managerFilter) return true; + // Check shifts for manager + return e.shifts?.some(shift => shift.manager_name === managerFilter); + }); + } + return filtered; - }, [clientEvents, searchTerm, statusFilter]); + }, [clientEvents, searchTerm, statusFilter, dateFilter, specificDate, locationFilter, managerFilter]); const activeOrders = clientEvents.filter(e => e.status !== "Completed" && e.status !== "Canceled" @@ -316,23 +419,261 @@ export default function ClientOrders() {
-
-
- {/* Icon size updated */} - setSearchTerm(e.target.value)} - className="pl-10 border-slate-300 h-10" // Class updated - /> +
+
+
+ + setSearchTerm(e.target.value)} + className="pl-10 border-slate-300 h-10" + /> +
+ + + All + Active + Completed + + +
+ +
+
+ + Filters: +
+ + + + + + +
+ setTempDate(date)} + numberOfMonths={2} + initialFocus + /> + +
+
+ + + + + +
+ +
+ + + +
+
+
+
+
+ + + + + + + + + No location found. + + { + setLocationFilter("all"); + setLocationOpen(false); + }} + > + + All Locations + + {uniqueLocations.map((location) => ( + { + setLocationFilter(currentValue); + setLocationOpen(false); + }} + > + + {location} + + ))} + + + + + + + + + + + + + No manager found. + + { + setManagerFilter("all"); + setManagerOpen(false); + }} + > + + All Managers + + {uniqueManagers.map((manager) => ( + { + setManagerFilter(currentValue); + setManagerOpen(false); + }} + > + + {manager} + + ))} + + + + + + {(dateFilter !== "all" || specificDate || locationFilter !== "all" || managerFilter !== "all") && ( + + )}
- {/* Replaced Select with Tabs */} - - All - Active - Completed - -
{/* Card class updated */} diff --git a/frontend-web/src/pages/EditBusiness.jsx b/frontend-web/src/pages/EditBusiness.jsx index 046e9ef0..835ee50f 100644 --- a/frontend-web/src/pages/EditBusiness.jsx +++ b/frontend-web/src/pages/EditBusiness.jsx @@ -1,4 +1,3 @@ - import React from "react"; import { base44 } from "@/api/base44Client"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; @@ -9,13 +8,382 @@ import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; -import { ArrowLeft, Save, Loader2 } from "lucide-react"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; +import { Badge } from "@/components/ui/badge"; +import { ArrowLeft, Save, Loader2, Users, MapPin, Star, Ban, Briefcase, Settings, Edit2, Trash2, UserMinus, UserPlus, Mail, Phone, Eye, DollarSign, Lock, Unlock, Grid3x3, List } from "lucide-react"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import ERPSettingsTab from "@/components/business/ERPSettingsTab"; + +const RATE_CARDS = { + "Google": { + "Banquet Captain": 34.02, "Barback": 31.79, "Barista": 32.53, "Busser": 27.62, "BW Bartender": 32.53, + "Cashier/Standworker": 28.10, "Cook": 35.49, "Dinning Attendant": 28.83, "Dishwasher/ Steward": 28.10, + "Executive Chef": 51.76, "FOH Cafe Attendant": 29.57, "Full Bartender": 36.97, "Grill Cook": 35.49, + "Host/Hostess/Greeter": 29.57, "Internal Support": 36.57, "Lead Cook": 45.76, "Line Cook": 35.49, + "Premium Server": 36.97, "Prep Cook": 29.57, "Receiver": 28.10, "Server": 30.22, "Sous Chef": 44.36, + "Warehouse Worker": 28.10, "Baker": 35.59, "Janitor": 28.10, "Mixologist": 47.70, "Utilities": 28.10, + "Scullery": 32.91, "Runner": 27.62, "Pantry Cook": 35.49, "Supervisor": 42.02, "Steward": 32.91, "Steward Supervisor": 34.10 + }, + "Bay Area Compass": { + "Banquet Captain": 41.71, "Barback": 36.10, "Barista": 38.92, "Busser": 35.71, "BW Bartender": 38.92, + "Cashier/Standworker": 36.10, "Cook": 42.13, "Dinning Attendant": 37.31, "Dishwasher/ Steward": 35.13, + "Executive Chef": 72.19, "FOH Cafe Attendant": 37.31, "Full Bartender": 43.73, "Grill Cook": 42.13, + "Host/Hostess/Greeter": 37.70, "Internal Support": 37.31, "Lead Cook": 46.57, "Line Cook": 42.13, + "Premium Server": 44.12, "Prep Cook": 36.90, "Receiver": 35.71, "Server": 37.31, "Sous Chef": 56.15, + "Warehouse Worker": 36.10, "Baker": 42.13, "Janitor": 35.13, "Mixologist": 56.15, "Utilities": 35.13, + "Scullery": 36.10, "Runner": 35.71, "Pantry Cook": 42.13, "Supervisor": 46.84, "Steward": 36.10, "Steward Supervisor": 38.38 + }, + "LA Compass": { + "Banquet Captain": 40.30, "Barback": 35.42, "Barista": 39.60, "Busser": 34.18, "BW Bartender": 35.65, + "Cashier/Standworker": 34.26, "Cook": 37.20, "Dinning Attendant": 35.65, "Dishwasher/ Steward": 34.10, + "Executive Chef": 60.53, "FOH Cafe Attendant": 35.65, "Full Bartender": 39.60, "Grill Cook": 37.20, + "Host/Hostess/Greeter": 35.65, "Internal Support": 36.57, "Lead Cook": 39.60, "Line Cook": 37.20, + "Premium Server": 40.30, "Prep Cook": 35.43, "Receiver": 34.65, "Server": 34.65, "Sous Chef": 52.78, + "Warehouse Worker": 35.50, "Baker": 37.20, "Janitor": 34.10, "Mixologist": 62.00, "Utilities": 34.10, + "Scullery": 34.10, "Runner": 34.18, "Pantry Cook": 37.20, "Supervisor": 39.60, "Steward": 34.10, "Steward Supervisor": 36.10 + }, + "FoodBuy": { + "Banquet Captain": 47.76, "Barback": 36.13, "Barista": 43.11, "Busser": 39.23, "BW Bartender": 41.15, + "Cashier/Standworker": 38.05, "Cook": 44.58, "Dinning Attendant": 41.56, "Dishwasher/ Steward": 38.38, + "Executive Chef": 70.60, "FOH Cafe Attendant": 41.56, "Full Bartender": 47.76, "Grill Cook": 44.58, + "Host/Hostess/Greeter": 41.56, "Internal Support": 41.56, "Lead Cook": 52.00, "Line Cook": 44.58, + "Premium Server": 47.76, "Prep Cook": 37.98, "Receiver": 40.01, "Server": 41.56, "Sous Chef": 59.75, + "Warehouse Worker": 41.15, "Baker": 44.58, "Janitor": 38.38, "Mixologist": 71.30, "Utilities": 38.38, + "Scullery": 38.38, "Runner": 39.23, "Pantry Cook": 44.58, "Supervisor": 47.76, "Steward": 38.38, "Steward Supervisor": 41.15 + }, + "Aramark": { + "Banquet Captain": 46.37, "Barback": 33.11, "Barista": 36.87, "Busser": 33.11, "BW Bartender": 36.12, + "Cashier/Standworker": 33.11, "Cook": 36.12, "Dinning Attendant": 34.62, "Dishwasher/ Steward": 33.11, + "Executive Chef": 76.76, "FOH Cafe Attendant": 34.62, "Full Bartender": 45.15, "Grill Cook": 36.12, + "Host/Hostess/Greeter": 34.62, "Internal Support": 37.63, "Lead Cook": 52.68, "Line Cook": 36.12, + "Premium Server": 40.64, "Prep Cook": 34.62, "Receiver": 34.62, "Server": 34.62, "Sous Chef": 60.20, + "Warehouse Worker": 34.62, "Baker": 45.15, "Janitor": 34.62, "Mixologist": 60.20, "Utilities": 33.11, + "Scullery": 33.11, "Runner": 33.11, "Pantry Cook": 36.12, "Supervisor": 45.15, "Steward": 33.11, "Steward Supervisor": 34.10 + }, + "Promotion": { + "Banquet Captain": 40.00, "Barback": 34.00, "Barista": 36.00, "Busser": 34.00, "BW Bartender": 36.00, + "Cashier/Standworker": 34.00, "Cook": 36.00, "Dinning Attendant": 36.00, "Dishwasher/ Steward": 30.00, + "Executive Chef": 51.00, "FOH Cafe Attendant": 36.00, "Full Bartender": 40.00, "Grill Cook": 36.00, + "Host/Hostess/Greeter": 34.00, "Internal Support": 36.00, "Lead Cook": 45.00, "Line Cook": 36.00, + "Premium Server": 38.00, "Prep Cook": 32.00, "Receiver": 34.00, "Server": 34.00, "Sous Chef": 44.00, + "Warehouse Worker": 34.00, "Baker": 36.00, "Janitor": 32.00, "Mixologist": 51.00, "Utilities": 30.00, + "Scullery": 34.00, "Runner": 34.00, "Pantry Cook": 36.00, "Supervisor": 40.00, "Steward": 34.00, "Steward Supervisor": 36.10 + }, + "Convention Center": { + "Banquet Captain": 40.00, "Barback": 34.00, "Barista": 36.00, "Busser": 34.00, "BW Bartender": 36.00, + "Cashier/Standworker": 34.00, "Cook": 36.00, "Dinning Attendant": 36.00, "Dishwasher/ Steward": 30.00, + "Executive Chef": 51.00, "FOH Cafe Attendant": 36.00, "Full Bartender": 38.00, "Grill Cook": 36.00, + "Host/Hostess/Greeter": 34.00, "Internal Support": 36.00, "Lead Cook": 45.00, "Line Cook": 36.00, + "Premium Server": 38.00, "Prep Cook": 34.00, "Receiver": 34.00, "Server": 34.00, "Sous Chef": 44.00, + "Warehouse Worker": 34.00, "Baker": 36.00, "Janitor": 32.00, "Mixologist": 51.00, "Utilities": 30.00, + "Scullery": 34.00, "Runner": 34.00, "Pantry Cook": 36.00, "Supervisor": 40.00, "Steward": 34.00, "Steward Supervisor": 38.38 + } +}; + +function RateBreakdownTable({ rateCard }) { + const rates = RATE_CARDS[rateCard] || {}; + const positions = Object.entries(rates).sort((a, b) => a[0].localeCompare(b[0])); + + if (!rateCard || positions.length === 0) { + return ( +
+ +

No Rate Card Assigned

+

Contact admin to assign a rate card

+
+ ); + } + + return ( + + + + + + + + + {positions.map(([position, rate], idx) => ( + + + + + ))} + +
PositionHourly Rate
+ {position} + + ${rate.toFixed(2)} + /hr +
+ ); +} + +function TeamTabContent({ allTeamMembers }) { + const [viewMode, setViewMode] = React.useState("grid"); + + return ( +
+ {/* View Toggle */} +
+

{allTeamMembers.length} team member{allTeamMembers.length !== 1 ? 's' : ''}

+
+ + +
+
+ + {allTeamMembers.length > 0 ? ( + viewMode === "grid" ? ( +
+ {allTeamMembers.map((member) => ( + + +
+
+ {member.member_name?.split(' ').map(n => n[0]).join('').toUpperCase() || 'U'} +
+
+

{member.member_name}

+

{member.role || 'Member'}

+

{member.title || 'Team Member'}

+
+
+ +
+ {member.email && ( +
+ + {member.email} +
+ )} + {member.phone && ( +
+ + {member.phone} +
+ )} +
+ +
+ {member.department && ( + + {member.department} + + )} + {member.hub && ( + + + {member.hub} + + )} +
+
+
+ ))} +
+ ) : ( + + + + + + + + + + + + + + {allTeamMembers.map((member, idx) => ( + + + + + + + + ))} + +
NameRoleContactHubStatus
+
+
+ {member.member_name?.split(' ').map(n => n[0]).join('').toUpperCase() || 'U'} +
+ {member.member_name} +
+
+
+

{member.role || 'Member'}

+

{member.title || 'Team Member'}

+
+
+
+ {member.email &&

{member.email}

} + {member.phone &&

{member.phone}

} +
+
+ {member.hub ? ( + + + {member.hub} + + ) : ( + + )} + + + {member.is_active !== false ? 'Active' : 'Inactive'} + +
+
+
+ ) + ) : ( + + + +

No Team Members

+

This business doesn't have any team members yet

+
+
+ )} +
+ ); +} + +function ServicesTab({ business, businessId, updateBusinessMutation }) { + const [isUnlocked, setIsUnlocked] = React.useState(false); + const [selectedCard, setSelectedCard] = React.useState(business?.rate_card || ''); + + React.useEffect(() => { + setSelectedCard(business?.rate_card || ''); + }, [business?.rate_card]); + + const handleSaveRateCard = () => { + updateBusinessMutation.mutate({ + id: businessId, + data: { rate_card: selectedCard } + }); + setIsUnlocked(false); + }; + + const rateCardOptions = Object.keys(RATE_CARDS); + + return ( +
+ {/* Rate Card Header */} + + +
+
+
+ +
+
+

Assigned Rate Card

+ {isUnlocked ? ( + + ) : ( +

{business?.rate_card || 'Not Assigned'}

+ )} +
+
+
+ {isUnlocked ? ( + <> + + + + ) : ( + + )} + + {isUnlocked ? <>Unlocked : <>Locked} + +
+
+ {isUnlocked && ( +
+ Select a new rate card and click Save to update pricing for this business. +
+ )} +
+
+ + {/* Rate Breakdown Table */} + + + + Rate Breakdown + + {isUnlocked && selectedCard !== business?.rate_card ? `Preview: ${selectedCard}` : business?.rate_card} + + + + + + + +
+ ); +} export default function EditBusiness() { const navigate = useNavigate(); const queryClient = useQueryClient(); const urlParams = new URLSearchParams(window.location.search); const businessId = urlParams.get('id'); + const defaultTab = urlParams.get('tab') || 'overview'; const { data: allBusinesses, isLoading } = useQuery({ queryKey: ['businesses'], @@ -23,8 +391,119 @@ export default function EditBusiness() { initialData: [], }); + const { data: allEvents = [] } = useQuery({ + queryKey: ['events-for-business-detail'], + queryFn: () => base44.entities.Event.list(), + initialData: [], + }); + + const { data: invoices = [] } = useQuery({ + queryKey: ['invoices-for-business-detail'], + queryFn: () => base44.entities.Invoice.list(), + initialData: [], + }); + const business = allBusinesses.find(b => b.id === businessId); + // Get company name without hub suffix + let companyName = business?.business_name || ''; + const dashIndex = companyName.indexOf(' - '); + if (dashIndex > 0) { + companyName = companyName.substring(0, dashIndex).trim(); + } + + // Find all businesses for this company + const companyBusinesses = allBusinesses.filter(b => + b.business_name === companyName || + b.business_name.startsWith(companyName + ' - ') + ); + + // Extract hubs first + const hubs = companyBusinesses.map(bus => ({ + id: bus.id, + hub_name: bus.business_name, + contact_name: bus.contact_name, + email: bus.email, + phone: bus.phone, + address: bus.address, + city: bus.city, + notes: bus.notes + })); + + // Collect all team members + const teamMembers = []; + companyBusinesses.forEach(bus => { + if (bus.team_members) { + bus.team_members.forEach(member => { + if (!teamMembers.some(m => m.email === member.email)) { + teamMembers.push(member); + } + }); + } + }); + + // Add hub contacts as team members + hubs.forEach(hub => { + if (hub.contact_name) { + // Check if this contact is already in team members (by name if no email, or by email) + const alreadyExists = teamMembers.some(m => + (hub.email && m.email === hub.email) || + (!hub.email && m.member_name === hub.contact_name) + ); + + if (!alreadyExists) { + teamMembers.push({ + member_id: `hub-${hub.id}`, + member_name: hub.contact_name, + email: hub.email || '', + phone: hub.phone, + role: 'Hub Manager', + title: 'Hub Manager', + hub: hub.hub_name, + is_active: true + }); + } + } + }); + + // teamMembers already includes hub contacts from above + const allTeamMembers = teamMembers; + + // Calculate metrics + const clientEvents = allEvents.filter(e => + e.business_name === companyName || + hubs.some(h => h.hub_name === e.business_name) + ); + + const totalOrders = clientEvents.length; + const completedOrders = clientEvents.filter(e => e.status === 'Completed').length; + const canceledOrders = clientEvents.filter(e => e.status === 'Canceled').length; + const cancelationRate = totalOrders > 0 ? ((canceledOrders / totalOrders) * 100).toFixed(0) : 0; + + const totalStaffRequested = clientEvents.reduce((sum, e) => sum + (e.requested || 0), 0); + const totalStaffAssigned = clientEvents.reduce((sum, e) => sum + (e.assigned_staff?.length || 0), 0); + const fillRate = totalStaffRequested > 0 ? ((totalStaffAssigned / totalStaffRequested) * 100).toFixed(0) : 0; + + const clientInvoices = invoices.filter(i => + i.business_name === companyName || + hubs.some(h => h.hub_name === i.business_name) + ); + const monthlySpend = clientInvoices + .filter(i => { + const invoiceDate = new Date(i.created_date); + const now = new Date(); + return invoiceDate.getMonth() === now.getMonth() && invoiceDate.getFullYear() === now.getFullYear(); + }) + .reduce((sum, i) => sum + (i.amount || 0), 0); + + const onTimeOrders = clientEvents.filter(e => { + const eventDate = new Date(e.date); + const createdDate = new Date(e.created_date); + const daysDiff = (eventDate - createdDate) / (1000 * 60 * 60 * 24); + return daysDiff >= 3; + }).length; + const onTimeRate = totalOrders > 0 ? ((onTimeOrders / totalOrders) * 100).toFixed(0) : 0; + const [formData, setFormData] = React.useState({ business_name: "", company_logo: "", @@ -87,7 +566,7 @@ export default function EditBusiness() { return (
-
+
-

Edit Business Client

-

Update information for {business.business_name}

+
+
+

{companyName}

+

{hubs.length} hub locations • {allTeamMembers.length} team members

+
+
+
+

Monthly Sales

+

${(monthlySpend / 1000).toFixed(0)}k

+
+
+

Total Orders

+

{totalOrders}

+
+
+

On-Time Rate

+

{onTimeRate}%

+
+
+

Cancellations

+

{cancelationRate}%

+
+
+

Completed

+

{completedOrders}

+
+
+
-
- - - Business Information - - + +
+ + + + Overview + + + + + Team + + {allTeamMembers.length} + + + + + + Hubs + + {hubs.length} + + + + + + Services + + + + + Favorites + + {business?.favorite_staff?.length || 0} + + + + + + Blocked + + {business?.blocked_staff?.length || 0} + + + + + + ERP/EDI + + + + + Settings + + +
+ + {/* Overview Tab */} + + + + + Business Information + +
@@ -236,7 +824,245 @@ export default function EditBusiness() {
+ + + {/* Team Tab */} + + + + + {/* Hubs Tab */} + + + + + + + + + + + + + + + {hubs.map((hub, idx) => ( + + + + + + + + ))} + +
Hub NameContactAddressCityActions
+
+ + {hub.hub_name} +
+
+
+

{hub.contact_name || '—'}

+ {hub.email &&

{hub.email}

} + {hub.phone &&

{hub.phone}

} +
+
+

{hub.address || '—'}

+
+

{hub.city || '—'}

+
+
+ + +
+
+
+
+
+ + {/* Services Tab */} + + + + + {/* Favorites Tab */} + +
+ {business?.favorite_staff?.length > 0 ? ( +
+ {business.favorite_staff.map((fav) => ( + + +
+
+
+ +
+
+

{fav.staff_name}

+

{fav.position || 'Staff'}

+
+
+ +
+

Added: {new Date(fav.added_date).toLocaleDateString()}

+
+
+ ))} +
+ ) : ( + + + +

No Favorite Staff

+

Mark staff as favorites for quick access

+ +
+
+ )} +
+
+ + {/* Blocked Tab */} + +
+ {business?.blocked_staff?.length > 0 ? ( +
+ {business.blocked_staff.map((blocked) => ( + + +
+
+
+ +
+
+

{blocked.staff_name}

+ Blocked +
+
+ +
+

Reason: {blocked.reason}

+

Blocked: {new Date(blocked.blocked_date).toLocaleDateString()}

+
+
+ ))} +
+ ) : ( + + + +

No Blocked Staff

+

Staff blocking helps maintain quality standards

+
+
+ )} +
+
+ + {/* ERP/EDI Tab */} + + { + updateBusinessMutation.mutate({ + id: businessId, + data: erpSettings + }); + }} + isSaving={updateBusinessMutation.isPending} + /> + + + {/* Settings Tab */} + + + +

Business Settings

+
+
+

General Settings

+
+
+
+

Client Active Status

+

Activate or deactivate this client

+
+ +
+
+
+

Status

+

Current business status

+
+ + {business?.is_active !== false ? 'Active' : 'Inactive'} + +
+
+
+

Last Order Date

+

Most recent order placed

+
+ + {business?.last_order_date + ? new Date(business.last_order_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) + : 'No orders yet'} + +
+
+
+

Rate Group

+

Pricing tier

+
+ {business?.rate_group || 'Standard'} +
+
+
+ +
+

Danger Zone

+
+ +
+
+
+
+
+
+
); -} +} \ No newline at end of file diff --git a/frontend-web/src/pages/EventDetail.jsx b/frontend-web/src/pages/EventDetail.jsx index 59dd8e0a..29dba930 100644 --- a/frontend-web/src/pages/EventDetail.jsx +++ b/frontend-web/src/pages/EventDetail.jsx @@ -24,6 +24,12 @@ import { format } from "date-fns"; const safeFormatDate = (dateString) => { if (!dateString) return "—"; try { + // If date is in format YYYY-MM-DD, parse it without timezone conversion + if (typeof dateString === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(dateString)) { + const [year, month, day] = dateString.split('-').map(Number); + const date = new Date(year, month - 1, day); + return format(date, "MMMM d, yyyy"); + } return format(new Date(dateString), "MMMM d, yyyy"); } catch { return "—"; diff --git a/frontend-web/src/pages/Events.jsx b/frontend-web/src/pages/Events.jsx index ce40db91..84a55a0d 100644 --- a/frontend-web/src/pages/Events.jsx +++ b/frontend-web/src/pages/Events.jsx @@ -26,6 +26,12 @@ import { detectAllConflicts, ConflictAlert } from "@/components/scheduling/Confl const safeParseDate = (dateString) => { if (!dateString) return null; try { + // If date is in format YYYY-MM-DD, parse it without timezone conversion + if (typeof dateString === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(dateString)) { + const [year, month, day] = dateString.split('-').map(Number); + const date = new Date(year, month - 1, day); + return isValid(date) ? date : null; + } const date = typeof dateString === 'string' ? parseISO(dateString) : new Date(dateString); return isValid(date) ? date : null; } catch { return null; } diff --git a/frontend-web/src/pages/InvoiceDetail.jsx b/frontend-web/src/pages/InvoiceDetail.jsx index 73bd9c16..ae4b09cb 100644 --- a/frontend-web/src/pages/InvoiceDetail.jsx +++ b/frontend-web/src/pages/InvoiceDetail.jsx @@ -4,6 +4,7 @@ import { base44 } from "@/api/base44Client"; import { useNavigate } from "react-router-dom"; import { createPageUrl } from "@/utils"; import InvoiceDetailView from "@/components/invoices/InvoiceDetailView"; +import InvoiceExportPanel from "@/components/invoices/InvoiceExportPanel"; import { Button } from "@/components/ui/button"; import { ArrowLeft } from "lucide-react"; @@ -22,7 +23,16 @@ export default function InvoiceDetail() { queryFn: () => base44.entities.Invoice.list(), }); + const { data: businesses = [] } = useQuery({ + queryKey: ['businesses-for-invoice'], + queryFn: () => base44.entities.Business.list(), + }); + const invoice = invoices.find(inv => inv.id === invoiceId); + const business = businesses.find(b => + b.business_name === invoice?.business_name || + b.business_name === invoice?.hub + ); const userRole = user?.user_role || user?.role; if (isLoading) { @@ -62,7 +72,14 @@ export default function InvoiceDetail() { Back
- +
+
+ +
+
+ +
+
); } \ No newline at end of file diff --git a/frontend-web/src/pages/Invoices.jsx b/frontend-web/src/pages/Invoices.jsx index 974c1e82..5b3fca9a 100644 --- a/frontend-web/src/pages/Invoices.jsx +++ b/frontend-web/src/pages/Invoices.jsx @@ -7,7 +7,7 @@ import { Badge } from "@/components/ui/badge"; import { Input } from "@/components/ui/input"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -import { FileText, Plus, Search, Eye, AlertTriangle, CheckCircle, Clock, DollarSign, Edit } from "lucide-react"; +import { FileText, Plus, Search, Eye, AlertTriangle, CheckCircle, Clock, DollarSign, Edit, TrendingUp, TrendingDown, Calendar, ArrowUpRight, Sparkles, BarChart3, PieChart, MapPin, User } from "lucide-react"; import { format, parseISO, isPast } from "date-fns"; import PageHeader from "@/components/common/PageHeader"; import { useNavigate } from "react-router-dom"; @@ -16,16 +16,18 @@ import AutoInvoiceGenerator from "@/components/invoices/AutoInvoiceGenerator"; import CreateInvoiceModal from "@/components/invoices/CreateInvoiceModal"; const statusColors = { - 'Draft': 'bg-slate-500 text-white', - 'Pending Review': 'bg-amber-500 text-white', - 'Approved': 'bg-green-500 text-white', - 'Disputed': 'bg-red-500 text-white', - 'Under Review': 'bg-orange-500 text-white', - 'Resolved': 'bg-blue-500 text-white', - 'Overdue': 'bg-red-600 text-white', - 'Paid': 'bg-emerald-500 text-white', - 'Reconciled': 'bg-purple-500 text-white', - 'Cancelled': 'bg-slate-400 text-white', + 'Draft': 'bg-slate-100 text-slate-600 font-medium', + 'Open': 'bg-blue-100 text-blue-700 font-medium', + 'Pending Review': 'bg-blue-100 text-blue-700 font-medium', + 'Confirmed': 'bg-amber-100 text-amber-700 font-medium', + 'Approved': 'bg-emerald-100 text-emerald-700 font-medium', + 'Disputed': 'bg-red-100 text-red-700 font-medium', + 'Under Review': 'bg-orange-100 text-orange-700 font-medium', + 'Resolved': 'bg-cyan-100 text-cyan-700 font-medium', + 'Overdue': 'bg-red-100 text-red-700 font-medium', + 'Paid': 'bg-emerald-100 text-emerald-700 font-medium', + 'Reconciled': 'bg-purple-100 text-purple-700 font-medium', + 'Cancelled': 'bg-slate-100 text-slate-600 font-medium', }; export default function Invoices() { @@ -126,8 +128,86 @@ export default function Invoices() { disputed: getTotalAmount("Disputed"), overdue: getTotalAmount("Overdue"), paid: getTotalAmount("Paid"), + outstanding: getTotalAmount("Pending Review") + getTotalAmount("Approved") + getTotalAmount("Overdue"), }; + // Smart Insights + const insights = React.useMemo(() => { + const currentMonth = visibleInvoices.filter(inv => { + const issueDate = parseISO(inv.issue_date); + const now = new Date(); + return issueDate.getMonth() === now.getMonth() && issueDate.getFullYear() === now.getFullYear(); + }); + + const lastMonth = visibleInvoices.filter(inv => { + const issueDate = parseISO(inv.issue_date); + const now = new Date(); + const lastMonthDate = new Date(now.getFullYear(), now.getMonth() - 1); + return issueDate.getMonth() === lastMonthDate.getMonth() && issueDate.getFullYear() === lastMonthDate.getFullYear(); + }); + + const currentTotal = currentMonth.reduce((sum, inv) => sum + (inv.amount || 0), 0); + const lastTotal = lastMonth.reduce((sum, inv) => sum + (inv.amount || 0), 0); + const percentChange = lastTotal > 0 ? ((currentTotal - lastTotal) / lastTotal * 100).toFixed(1) : 0; + + const avgPaymentTime = visibleInvoices + .filter(inv => inv.status === "Paid" && inv.paid_date && inv.issue_date) + .map(inv => { + const days = Math.floor((parseISO(inv.paid_date) - parseISO(inv.issue_date)) / (1000 * 60 * 60 * 24)); + return days; + }); + const avgDays = avgPaymentTime.length > 0 ? Math.round(avgPaymentTime.reduce((a, b) => a + b, 0) / avgPaymentTime.length) : 0; + + const onTimePayments = visibleInvoices.filter(inv => + inv.status === "Paid" && inv.paid_date && inv.due_date && parseISO(inv.paid_date) <= parseISO(inv.due_date) + ).length; + const totalPaid = visibleInvoices.filter(inv => inv.status === "Paid").length; + const onTimeRate = totalPaid > 0 ? ((onTimePayments / totalPaid) * 100).toFixed(0) : 0; + + const topClient = Object.entries( + visibleInvoices.reduce((acc, inv) => { + const client = inv.business_name || "Unknown"; + acc[client] = (acc[client] || 0) + (inv.amount || 0); + return acc; + }, {}) + ).sort((a, b) => b[1] - a[1])[0]; + + // For clients: calculate best hub by reconciliation rate + const bestHub = userRole === "client" ? (() => { + const hubStats = visibleInvoices.reduce((acc, inv) => { + const hub = inv.hub || "Unknown"; + if (!acc[hub]) { + acc[hub] = { total: 0, reconciled: 0, paid: 0 }; + } + acc[hub].total++; + if (inv.status === "Reconciled") acc[hub].reconciled++; + if (inv.status === "Paid" || inv.status === "Reconciled") acc[hub].paid++; + return acc; + }, {}); + + const sortedHubs = Object.entries(hubStats) + .map(([hub, stats]) => ({ + hub, + rate: stats.total > 0 ? ((stats.paid / stats.total) * 100).toFixed(0) : 0, + total: stats.total + })) + .sort((a, b) => b.rate - a.rate); + + return sortedHubs[0] || null; + })() : null; + + return { + percentChange, + isGrowth: percentChange > 0, + avgDays, + onTimeRate, + topClient: topClient ? { name: topClient[0], amount: topClient[1] } : null, + bestHub, + currentMonthCount: currentMonth.length, + currentTotal, + }; + }, [visibleInvoices, userRole]); + return ( <> @@ -170,90 +250,190 @@ export default function Invoices() { {/* Status Tabs */} - - - All {getStatusCount("all")} + + + + All + {getStatusCount("all")} - - Pending Review {getStatusCount("Pending Review")} + + + Pending + {getStatusCount("Pending Review")} - - Approved {getStatusCount("Approved")} + + + Approved + {getStatusCount("Approved")} - - Disputed {getStatusCount("Disputed")} + + + Disputed + {getStatusCount("Disputed")} - - Overdue {getStatusCount("Overdue")} + + + Overdue + {getStatusCount("Overdue")} - - Paid {getStatusCount("Paid")} + + + Paid + {getStatusCount("Paid")} - - Reconciled {getStatusCount("Reconciled")} + + + Reconciled + {getStatusCount("Reconciled")} {/* Metric Cards */}
- - -
-
- + + +
+
+
-

Total

-

${metrics.all.toLocaleString()}

+

Total Value

+

${metrics.all.toLocaleString()}

- - -
-
- + + +
+
+
-

Pending

-

${metrics.pending.toLocaleString()}

+

Outstanding

+

${metrics.outstanding.toLocaleString()}

- - -
-
- + + +
+
+
-

Overdue

-

${metrics.overdue.toLocaleString()}

+

Disputed

+

${metrics.disputed.toLocaleString()}

- - -
-
- + + +
+
+
-

Paid

-

${metrics.paid.toLocaleString()}

+

Paid

+

${metrics.paid.toLocaleString()}

+ {/* Smart Insights Banner */} +
+
+
+ +
+
+

Smart Insights

+

AI-powered analysis of your invoice performance

+
+
+ +
+
+
+ This Month +
+ {insights.isGrowth ? : } + {insights.percentChange}% +
+
+

${insights.currentTotal.toLocaleString()}

+

{insights.currentMonthCount} invoices

+
+ +
+
+ Avg. Payment Time + +
+

{insights.avgDays} days

+

From issue to payment

+
+ +
+
+ On-Time Rate + +
+

{insights.onTimeRate}%

+

Paid before due date

+
+ +
+
+ + {userRole === "client" ? "Best Hub" : "Top Client"} + + +
+ {userRole === "client" ? ( + <> +

{insights.bestHub?.hub || "—"}

+

{insights.bestHub?.rate || 0}% on-time

+ + ) : ( + <> +

{insights.topClient?.name || "—"}

+

${insights.topClient?.amount.toLocaleString() || 0}

+ + )} +
+
+
+ {/* Search */}
@@ -268,72 +448,87 @@ export default function Invoices() {
{/* Invoices Table */} - + - Invoice # - Client - Event - Vendor - Issue Date - Due Date - Amount - Status - Actions + Invoice # + Hub + Event + Manager + Date & Time + Amount + Status + Action {filteredInvoices.length === 0 ? ( - +

No invoices found

) : ( - filteredInvoices.map((invoice) => ( - - {invoice.invoice_number} - {invoice.business_name} - {invoice.event_name} - {invoice.vendor_name || "—"} - {format(parseISO(invoice.issue_date), 'MMM dd, yyyy')} - - {format(parseISO(invoice.due_date), 'MMM dd, yyyy')} - - ${invoice.amount?.toLocaleString()} - - - {invoice.status} - - - -
+ filteredInvoices.map((invoice) => { + const invoiceDate = parseISO(invoice.issue_date); + const dayOfWeek = format(invoiceDate, 'EEEE'); + const dateFormatted = format(invoiceDate, 'MM.dd.yy'); + + return ( + + {invoice.invoice_number} + +
+ + {invoice.hub || "—"} +
+
+ {invoice.event_name} + +
+ + {invoice.manager_name || invoice.created_by || "—"} +
+
+ +
+
{dateFormatted}
+
+ + {dayOfWeek} +
+
+
+ +
+
+ +
+ ${invoice.amount?.toLocaleString()} +
+
+ + + {invoice.status} + + + - {userRole === "vendor" && invoice.status === "Draft" && ( - - )} -
-
-
- )) + + + ); + }) )}
diff --git a/frontend-web/src/pages/Layout.jsx b/frontend-web/src/pages/Layout.jsx index b159bc72..1c5ca921 100644 --- a/frontend-web/src/pages/Layout.jsx +++ b/frontend-web/src/pages/Layout.jsx @@ -39,6 +39,8 @@ const roleNavigationMap = { admin: [ { title: "Home", url: createPageUrl("Dashboard"), icon: LayoutDashboard }, { title: "Orders", url: createPageUrl("Events"), icon: Calendar }, + { title: "Schedule", url: createPageUrl("Schedule"), icon: Calendar }, + { title: "Staff Availability", url: createPageUrl("StaffAvailability"), icon: Users }, { title: "Enterprises", url: createPageUrl("EnterpriseManagement"), icon: Building2 }, { title: "Sectors", url: createPageUrl("SectorManagement"), icon: MapPin }, { title: "Partners", url: createPageUrl("PartnerManagement"), icon: Briefcase }, @@ -51,6 +53,7 @@ const roleNavigationMap = { { title: "Invoices", url: createPageUrl("Invoices"), icon: FileText }, { title: "Payroll", url: createPageUrl("Payroll"), icon: DollarSign }, { title: "Certifications", url: createPageUrl("Certification"), icon: Award }, + { title: "Tutorials", url: createPageUrl("Tutorials"), icon: Sparkles }, { title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 }, { title: "User Management", url: createPageUrl("UserManagement"), icon: Users }, { title: "Permissions", url: createPageUrl("Permissions"), icon: Shield }, @@ -109,6 +112,7 @@ const roleNavigationMap = { { title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare }, { title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare }, { title: "Invoices", url: createPageUrl("Invoices"), icon: FileText }, + { title: "Tutorials", url: createPageUrl("Tutorials"), icon: Sparkles }, { title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 }, { title: "Support", url: createPageUrl("Support"), icon: HelpCircle }, ], @@ -117,7 +121,8 @@ const roleNavigationMap = { { title: "Orders", url: createPageUrl("VendorOrders"), icon: FileText }, { title: "Service Rates", url: createPageUrl("VendorRates"), icon: DollarSign }, { title: "Invoices", url: createPageUrl("Invoices"), icon: Clipboard }, - { title: "Schedule", url: createPageUrl("WorkforceShifts"), icon: Calendar }, + { title: "Schedule", url: createPageUrl("Schedule"), icon: Calendar }, + { title: "Staff Availability", url: createPageUrl("StaffAvailability"), icon: Users }, { title: "Workforce", url: createPageUrl("StaffDirectory"), icon: Users }, { title: "Onboard Staff", url: createPageUrl("StaffOnboarding"), icon: GraduationCap }, { title: "Team", url: createPageUrl("Teams"), icon: UserCheck }, @@ -132,6 +137,7 @@ const roleNavigationMap = { ], workforce: [ { title: "Home", url: createPageUrl("WorkforceDashboard"), icon: LayoutDashboard }, + { title: "Shift Requests", url: createPageUrl("WorkerShiftProposals"), icon: Calendar }, { title: "Onboard Staff", url: createPageUrl("StaffOnboarding"), icon: GraduationCap }, { title: "My Shifts", url: createPageUrl("WorkforceShifts"), icon: Calendar }, { title: "Teams", url: createPageUrl("Teams"), icon: UserCheck }, diff --git a/frontend-web/src/pages/Onboarding.jsx b/frontend-web/src/pages/Onboarding.jsx index b3c3ff0d..27fccfd1 100644 --- a/frontend-web/src/pages/Onboarding.jsx +++ b/frontend-web/src/pages/Onboarding.jsx @@ -17,7 +17,6 @@ export default function Onboarding() { const urlParams = new URLSearchParams(window.location.search); const inviteCode = urlParams.get('invite'); - const [step, setStep] = useState(1); const [formData, setFormData] = useState({ first_name: "", last_name: "", @@ -154,14 +153,25 @@ export default function Onboarding() { accepted_date: new Date().toISOString() }); - return { member, invite }; - }, + // Update team member counts and refresh + const allTeams = await base44.entities.Team.list(); + const team = allTeams.find(t => t.id === invite.team_id); + if (team) { + const allMembers = await base44.entities.TeamMember.list(); + const teamMembers = allMembers.filter(m => m.team_id === team.id); + const activeCount = teamMembers.filter(m => m.is_active).length; + + await base44.entities.Team.update(team.id, { + total_members: teamMembers.length, + active_members: activeCount, + total_hubs: team.total_hubs || 0 + }); + } + + return { member, invite, team }; + }, onSuccess: () => { - setStep(4); - toast({ - title: "✅ Registration Successful!", - description: "You've been added to the team successfully.", - }); + // Registration complete - user will see success message in UI }, onError: (error) => { toast({ @@ -172,49 +182,48 @@ export default function Onboarding() { }, }); - const handleNext = () => { - if (step === 1) { - // Validate basic info - if (!formData.first_name || !formData.last_name || !formData.email || !formData.phone) { - toast({ - title: "Missing Information", - description: "Please fill in your name, email, and phone number", - variant: "destructive", - }); - return; - } - setStep(2); - } else if (step === 2) { - // Validate additional info - if (!formData.title || !formData.department) { - toast({ - title: "Missing Information", - description: "Please fill in your title and department", - variant: "destructive", - }); - return; - } - setStep(3); - } else if (step === 3) { - // Validate password - if (!formData.password || formData.password !== formData.confirmPassword) { - toast({ - title: "Password Mismatch", - description: "Passwords do not match", - variant: "destructive", - }); - return; - } - if (formData.password.length < 6) { - toast({ - title: "Password Too Short", - description: "Password must be at least 6 characters", - variant: "destructive", - }); - return; - } - registerMutation.mutate(formData); + const handleSubmit = async (e) => { + e.preventDefault(); + + // Validate all fields + if (!formData.first_name || !formData.last_name || !formData.email || !formData.phone) { + toast({ + title: "Missing Information", + description: "Please fill in your name, email, and phone number", + variant: "destructive", + }); + return; } + + if (!formData.title || !formData.department) { + toast({ + title: "Missing Information", + description: "Please fill in your title and department", + variant: "destructive", + }); + return; + } + + if (!formData.password || formData.password !== formData.confirmPassword) { + toast({ + title: "Password Mismatch", + description: "Passwords do not match", + variant: "destructive", + }); + return; + } + + if (formData.password.length < 6) { + toast({ + title: "Password Too Short", + description: "Password must be at least 6 characters", + variant: "destructive", + }); + return; + } + + // Submit registration + registerMutation.mutate(formData); }; if (!inviteCode || !invite) { @@ -242,9 +251,9 @@ export default function Onboarding() { if (invite.invite_status === 'accepted') { return (
- + -
+
⚠️

Invitation Already Used

@@ -262,7 +271,7 @@ export default function Onboarding() { return (
-
+
{/* Header */}
@@ -272,7 +281,7 @@ export default function Onboarding() { Join {invite.team_name} {invite.hub && ( -
+
📍 {invite.hub}
)} @@ -282,281 +291,216 @@ export default function Onboarding() {

- {/* Progress Steps */} - {step < 4 && ( -
-
-
= 1 ? 'bg-[#0A39DF] text-white' : 'bg-slate-200 text-slate-500'}`}> - {step > 1 ? : '1'} -
-
= 2 ? 'bg-[#0A39DF]' : 'bg-slate-200'}`} /> -
= 2 ? 'bg-[#0A39DF] text-white' : 'bg-slate-200 text-slate-500'}`}> - {step > 2 ? : '2'} -
-
= 3 ? 'bg-[#0A39DF]' : 'bg-slate-200'}`} /> -
= 3 ? 'bg-[#0A39DF] text-white' : 'bg-slate-200 text-slate-500'}`}> - {step > 3 ? : '3'} -
-
-
- )} - - {/* Step 1: Basic Information */} - {step === 1 && ( - - - - - Basic Information - - - -
-
- - setFormData({ ...formData, first_name: e.target.value })} - placeholder="John" - className="mt-2" - /> +
+ {registerMutation.isSuccess ? ( + + +
+
-
- - setFormData({ ...formData, last_name: e.target.value })} - placeholder="Doe" - className="mt-2" - /> +

+ Welcome to {invite.team_name}! 🎉 +

+

+ Your registration has been completed successfully! +

+
+

Your Profile Summary:

+
+

Name: {formData.first_name} {formData.last_name}

+

Email: {formData.email}

+

Title: {formData.title}

+

Department: {formData.department}

+ {formData.hub &&

Hub: {formData.hub}

} +

Team: {invite.team_name}

+

Role: {invite.role}

+
-
+
+

+ Next Step: Please sign in with your new credentials to access your dashboard. +

+

+ Use the email {formData.email} and the password you just created. +

+
+ +
+
+ ) : ( + + + Complete Your Registration +

Fill in all the details below to join the team

+
+ +
+ {/* Basic Information */} +
+
+ +

Basic Information

+
+
+ +
+ + setFormData({ ...formData, first_name: e.target.value })} + placeholder="John" + className="mt-2" + /> +
+ +
+ + setFormData({ ...formData, last_name: e.target.value })} + placeholder="Doe" + className="mt-2" + /> +
-
- - setFormData({ ...formData, email: e.target.value })} - placeholder="john@example.com" - className="mt-2" - disabled={!!invite} - /> -
+
+ + setFormData({ ...formData, email: e.target.value })} + placeholder="john@example.com" + className="mt-2" + disabled={!!invite} + /> +
-
- - setFormData({ ...formData, phone: e.target.value })} - placeholder="+1 (555) 123-4567" - className="mt-2" - /> -

You can edit this if needed

-
+
+ + setFormData({ ...formData, phone: e.target.value })} + placeholder="+1 (555) 123-4567" + className="mt-2" + /> +
- - - - )} + {/* Work Information */} +
+
+ +

Work Information

+
+
- {/* Step 2: Work Information */} - {step === 2 && ( - - - - - Work Information - - - -
- - setFormData({ ...formData, title: e.target.value })} - placeholder="e.g., Manager, Coordinator, Supervisor" - className="mt-2" - /> -
+
+ + setFormData({ ...formData, title: e.target.value })} + placeholder="e.g., Manager, Coordinator" + className="mt-2" + /> +
-
- - - {formData.department && ( -

✓ Pre-filled from your invitation

- )} -
+
+ + +
- {hubs.length > 0 && ( -
- - - {formData.hub && ( -

📍 You're joining {formData.hub}!

+ {hubs.length > 0 && ( +
+ + + {formData.hub && ( +

📍 You're joining {formData.hub}!

+ )} +
)} + + {/* Account Security */} +
+
+ +

Account Security

+
+
+ +
+ + setFormData({ ...formData, password: e.target.value })} + placeholder="••••••••" + className="mt-2" + /> +

Minimum 6 characters

+
+ +
+ + setFormData({ ...formData, confirmPassword: e.target.value })} + placeholder="••••••••" + className="mt-2" + /> +
- )} -
- -
-
-
- )} - - {/* Step 3: Create Password */} - {step === 3 && ( - - - - - Create Your Password - - - -
- - setFormData({ ...formData, password: e.target.value })} - placeholder="••••••••" - className="mt-2" - /> -

Minimum 6 characters

-
- -
- - setFormData({ ...formData, confirmPassword: e.target.value })} - placeholder="••••••••" - className="mt-2" - /> -
- -
-

Review Your Information:

-
-

Name: {formData.first_name} {formData.last_name}

-

Email: {formData.email}

-

Phone: {formData.phone}

-

Title: {formData.title}

-

Department: {formData.department}

- {formData.hub &&

Hub: {formData.hub}

} -

Role: {invite.role}

-
-
- -
- - -
-
-
- )} - - {/* Step 4: Success */} - {step === 4 && ( - - -
- -
-

- Welcome to the Team! 🎉 -

-

- Your account has been created successfully! -

-
-

Your Profile:

-
-

Name: {formData.first_name} {formData.last_name}

-

Email: {formData.email}

-

Title: {formData.title}

-

Department: {formData.department}

- {formData.hub &&

Hub: {formData.hub}

} -

Team: {invite.team_name}

-
-
- -
-
- )} + + + )} +
); diff --git a/frontend-web/src/pages/Permissions.jsx b/frontend-web/src/pages/Permissions.jsx index f6fb733e..ffafcb80 100644 --- a/frontend-web/src/pages/Permissions.jsx +++ b/frontend-web/src/pages/Permissions.jsx @@ -1,12 +1,18 @@ import React, { useState } from "react"; import { base44 } from "@/api/base44Client"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useQuery } 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 { Input } from "@/components/ui/input"; -import { Checkbox } from "@/components/ui/checkbox"; -import { Shield, Search, Save, Info, ChevronDown, ChevronRight, Users, Calendar, Package, DollarSign, FileText, Settings as SettingsIcon, BarChart3, MessageSquare, Briefcase, Building2 } from "lucide-react"; +import { Switch } from "@/components/ui/switch"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; +import { + Shield, Search, Save, Info, ChevronDown, ChevronRight, Users, Calendar, + Package, DollarSign, FileText, Settings as SettingsIcon, BarChart3, + MessageSquare, Briefcase, Building2, Layers, Lock, Unlock, Eye, + CheckCircle2, XCircle, AlertTriangle, Sparkles, UserCog, Plus, Trash2, Copy +} from "lucide-react"; import PageHeader from "@/components/common/PageHeader"; import { useToast } from "@/components/ui/use-toast"; import { @@ -14,57 +20,143 @@ import { HoverCardContent, HoverCardTrigger, } from "@/components/ui/hover-card"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; -// Role-specific permission sets -const ROLE_PERMISSIONS = { +// Layer hierarchy configuration +const LAYER_HIERARCHY = [ + { + id: "admin", + name: "KROW Admin", + icon: Shield, + color: "from-red-500 to-red-700", + bgColor: "bg-red-50", + borderColor: "border-red-200", + textColor: "text-red-700", + description: "Full platform control and oversight", + level: 1 + }, + { + id: "procurement", + name: "Procurement", + icon: Briefcase, + color: "from-[#0A39DF] to-[#1C323E]", + bgColor: "bg-blue-50", + borderColor: "border-blue-200", + textColor: "text-blue-700", + description: "Vendor management and rate control", + level: 2 + }, + { + id: "operator", + name: "Operator", + icon: Building2, + color: "from-emerald-500 to-emerald-700", + bgColor: "bg-emerald-50", + borderColor: "border-emerald-200", + textColor: "text-emerald-700", + description: "Enterprise-wide operations", + level: 3 + }, + { + id: "sector", + name: "Sector", + icon: Layers, + color: "from-purple-500 to-purple-700", + bgColor: "bg-purple-50", + borderColor: "border-purple-200", + textColor: "text-purple-700", + description: "Location-specific management", + level: 4 + }, + { + id: "client", + name: "Client", + icon: Users, + color: "from-green-500 to-green-700", + bgColor: "bg-green-50", + borderColor: "border-green-200", + textColor: "text-green-700", + description: "Service ordering and review", + level: 5 + }, + { + id: "vendor", + name: "Vendor", + icon: Package, + color: "from-amber-500 to-amber-700", + bgColor: "bg-amber-50", + borderColor: "border-amber-200", + textColor: "text-amber-700", + description: "Workforce supply and management", + level: 6 + }, + { + id: "workforce", + name: "Workforce", + icon: Users, + color: "from-slate-500 to-slate-700", + bgColor: "bg-slate-50", + borderColor: "border-slate-200", + textColor: "text-slate-700", + description: "Shift work and earnings", + level: 7 + } +]; + +// Permission modules for each layer +const PERMISSION_MODULES = { admin: [ { - id: "system", - name: "System Administration", + id: "platform", + name: "Platform Administration", icon: Shield, - color: "text-red-600", permissions: [ - { id: "admin.1", name: "Manage All Users", description: "Create, edit, and delete user accounts" }, - { id: "admin.2", name: "Configure System Settings", description: "Modify platform-wide settings" }, - { id: "admin.3", name: "Manage Roles & Permissions", description: "Define and assign user roles" }, - { id: "admin.4", name: "View All Activity Logs", description: "Access complete audit trail" }, - { id: "admin.5", name: "Manage Integrations", description: "Configure external integrations" }, - { id: "admin.6", name: "Access All Data", description: "Unrestricted access to all system data" }, + { id: "platform.users", name: "Manage All Users", description: "Create, edit, delete users across all layers", critical: true }, + { id: "platform.settings", name: "System Settings", description: "Configure platform-wide settings", critical: true }, + { id: "platform.roles", name: "Role Management", description: "Define and modify role permissions", critical: true }, + { id: "platform.audit", name: "Audit Logs", description: "Access complete system audit trail" }, + { id: "platform.integrations", name: "Integrations", description: "Manage external system connections" }, ] }, { - id: "enterprises", - name: "Enterprise Management", - icon: Building2, - color: "text-purple-600", + id: "hierarchy", + name: "Hierarchy Control", + icon: Layers, permissions: [ - { id: "admin.7", name: "Create Enterprises", description: "Onboard new enterprise clients" }, - { id: "admin.8", name: "Manage Sectors", description: "Create and configure sectors" }, - { id: "admin.9", name: "Manage Partners", description: "Onboard and manage partners" }, - { id: "admin.10", name: "Set Global Policies", description: "Define enterprise-wide policies" }, - ] - }, - { - id: "vendors", - name: "Vendor Oversight", - icon: Package, - color: "text-amber-600", - permissions: [ - { id: "admin.11", name: "Approve/Suspend Vendors", description: "Control vendor status" }, - { id: "admin.12", name: "View All Vendor Performance", description: "Access all vendor scorecards" }, - { id: "admin.13", name: "Manage Vendor Rates", description: "Override and approve rate cards" }, + { id: "hierarchy.enterprises", name: "Enterprise Management", description: "Create and manage enterprises" }, + { id: "hierarchy.sectors", name: "Sector Management", description: "Create and manage sectors" }, + { id: "hierarchy.partners", name: "Partner Management", description: "Manage partner relationships" }, + { id: "hierarchy.policies", name: "Global Policies", description: "Set enterprise-wide policies", critical: true }, ] }, { id: "financial", - name: "Financial Administration", + name: "Financial Control", icon: DollarSign, - color: "text-green-600", permissions: [ - { id: "admin.14", name: "View All Financials", description: "Access all financial data" }, - { id: "admin.15", name: "Process All Payments", description: "Approve and process payments" }, - { id: "admin.16", name: "Manage Payroll", description: "Process workforce payroll" }, - { id: "admin.17", name: "Generate Financial Reports", description: "Create P&L and financial analytics" }, + { id: "financial.all", name: "All Financials", description: "Access all financial data", critical: true }, + { id: "financial.payments", name: "Process Payments", description: "Approve and process all payments" }, + { id: "financial.payroll", name: "Payroll Management", description: "Process workforce payroll" }, + { id: "financial.reports", name: "Financial Reports", description: "Generate P&L and analytics" }, ] } ], @@ -74,49 +166,35 @@ const ROLE_PERMISSIONS = { id: "vendors", name: "Vendor Management", icon: Package, - color: "text-purple-600", permissions: [ - { id: "proc.1", name: "View All Vendors", description: "Access vendor directory" }, - { id: "proc.2", name: "Onboard New Vendors", description: "Add vendors to the platform" }, - { id: "proc.3", name: "Edit Vendor Details", description: "Modify vendor information" }, - { id: "proc.4", name: "Review Vendor Compliance", description: "Check COI, W9, certifications" }, - { id: "proc.5", name: "Approve/Suspend Vendors", description: "Change vendor approval status" }, - { id: "proc.6", name: "View Vendor Performance", description: "Access scorecards and KPIs" }, + { id: "vendors.view", name: "View Vendors", description: "Access vendor directory" }, + { id: "vendors.onboard", name: "Onboard Vendors", description: "Add new vendors to platform" }, + { id: "vendors.edit", name: "Edit Vendors", description: "Modify vendor information" }, + { id: "vendors.compliance", name: "Compliance Review", description: "Review COI, W9, certifications" }, + { id: "vendors.approve", name: "Approve/Suspend", description: "Change vendor approval status", critical: true }, + { id: "vendors.performance", name: "Performance Data", description: "Access scorecards and KPIs" }, ] }, { id: "rates", - name: "Rate Card Management", + name: "Rate Management", icon: DollarSign, - color: "text-green-600", permissions: [ - { id: "proc.7", name: "View All Rate Cards", description: "See vendor pricing" }, - { id: "proc.8", name: "Create Rate Cards", description: "Set up new rate cards" }, - { id: "proc.9", name: "Edit Rate Cards", description: "Modify existing rates" }, - { id: "proc.10", name: "Approve Rate Cards", description: "Approve vendor rates" }, - { id: "proc.11", name: "Set Markup Rules", description: "Define markup percentages" }, + { id: "rates.view", name: "View Rates", description: "See all vendor pricing" }, + { id: "rates.create", name: "Create Rate Cards", description: "Set up new rate cards" }, + { id: "rates.edit", name: "Edit Rates", description: "Modify existing rates" }, + { id: "rates.approve", name: "Approve Rates", description: "Approve vendor rate submissions", critical: true }, + { id: "rates.markup", name: "Markup Rules", description: "Define markup percentages" }, ] }, { id: "orders", - name: "Order Management", + name: "Order Oversight", icon: Calendar, - color: "text-blue-600", permissions: [ - { id: "proc.12", name: "View All Orders", description: "Access all orders across sectors" }, - { id: "proc.13", name: "Assign Vendors to Orders", description: "Match vendors with orders" }, - { id: "proc.14", name: "Monitor Order Fulfillment", description: "Track order completion" }, - ] - }, - { - id: "reports", - name: "Analytics & Reports", - icon: BarChart3, - color: "text-indigo-600", - permissions: [ - { id: "proc.15", name: "View Vendor Analytics", description: "Access vendor performance data" }, - { id: "proc.16", name: "Generate Procurement Reports", description: "Create spend and compliance reports" }, - { id: "proc.17", name: "Export Data", description: "Download reports as CSV/PDF" }, + { id: "orders.viewAll", name: "View All Orders", description: "Access orders across sectors" }, + { id: "orders.assign", name: "Assign Vendors", description: "Match vendors with orders" }, + { id: "orders.monitor", name: "Monitor Fulfillment", description: "Track order completion" }, ] } ], @@ -126,47 +204,34 @@ const ROLE_PERMISSIONS = { id: "events", name: "Event Management", icon: Calendar, - color: "text-blue-600", permissions: [ - { id: "op.1", name: "View My Enterprise Events", description: "See events in my enterprise" }, - { id: "op.2", name: "Create Events", description: "Create new event orders" }, - { id: "op.3", name: "Edit Events", description: "Modify event details" }, - { id: "op.4", name: "Cancel Events", description: "Cancel event orders" }, - { id: "op.5", name: "Approve Events", description: "Approve event requests from sectors" }, - { id: "op.6", name: "View Event Financials", description: "See event costs and billing" }, + { id: "events.view", name: "View Events", description: "See events in my enterprise" }, + { id: "events.create", name: "Create Events", description: "Create new event orders" }, + { id: "events.edit", name: "Edit Events", description: "Modify event details" }, + { id: "events.cancel", name: "Cancel Events", description: "Cancel event orders" }, + { id: "events.approve", name: "Approve Events", description: "Approve sector event requests" }, + { id: "events.financials", name: "Event Financials", description: "View event costs and billing" }, ] }, { id: "sectors", - name: "Sector Management", + name: "Sector Oversight", icon: Building2, - color: "text-cyan-600", permissions: [ - { id: "op.7", name: "View My Sectors", description: "See sectors under my enterprise" }, - { id: "op.8", name: "Manage Sector Settings", description: "Configure sector policies" }, - { id: "op.9", name: "Assign Vendors to Sectors", description: "Approve vendors for sectors" }, + { id: "sectors.view", name: "View Sectors", description: "See sectors under my enterprise" }, + { id: "sectors.settings", name: "Sector Settings", description: "Configure sector policies" }, + { id: "sectors.vendors", name: "Assign Vendors", description: "Approve vendors for sectors" }, ] }, { id: "workforce", name: "Workforce Management", icon: Users, - color: "text-emerald-600", permissions: [ - { id: "op.10", name: "View Workforce", description: "See staff across my enterprise" }, - { id: "op.11", name: "Assign Staff to Events", description: "Schedule staff for events" }, - { id: "op.12", name: "Approve Timesheets", description: "Review and approve hours" }, - { id: "op.13", name: "View Staff Performance", description: "Access ratings and reviews" }, - ] - }, - { - id: "reports", - name: "Reports", - icon: BarChart3, - color: "text-indigo-600", - permissions: [ - { id: "op.14", name: "View Enterprise Dashboards", description: "Access my enterprise analytics" }, - { id: "op.15", name: "Export Reports", description: "Download reports" }, + { id: "workforce.view", name: "View Workforce", description: "See staff across enterprise" }, + { id: "workforce.assign", name: "Assign Staff", description: "Schedule staff for events" }, + { id: "workforce.timesheets", name: "Approve Timesheets", description: "Review and approve hours" }, + { id: "workforce.performance", name: "Staff Performance", description: "Access ratings and reviews" }, ] } ], @@ -174,37 +239,34 @@ const ROLE_PERMISSIONS = { sector: [ { id: "events", - name: "Event Management", + name: "Location Events", icon: Calendar, - color: "text-blue-600", permissions: [ - { id: "sec.1", name: "View My Sector Events", description: "See events at my location" }, - { id: "sec.2", name: "Create Event Requests", description: "Request new events" }, - { id: "sec.3", name: "Edit My Events", description: "Modify event details" }, - { id: "sec.4", name: "View Event Costs", description: "See event billing information" }, + { id: "events.viewMy", name: "View My Events", description: "See events at my location" }, + { id: "events.request", name: "Request Events", description: "Submit new event requests" }, + { id: "events.editMy", name: "Edit My Events", description: "Modify my event details" }, + { id: "events.costs", name: "View Costs", description: "See event billing information" }, ] }, { - id: "workforce", + id: "staff", name: "Staff Management", icon: Users, - color: "text-emerald-600", permissions: [ - { id: "sec.5", name: "View My Location Staff", description: "See staff at my sector" }, - { id: "sec.6", name: "Schedule Staff", description: "Assign staff to shifts" }, - { id: "sec.7", name: "Approve Timesheets", description: "Review hours worked" }, - { id: "sec.8", name: "Rate Staff Performance", description: "Provide performance feedback" }, + { id: "staff.view", name: "View Staff", description: "See staff at my sector" }, + { id: "staff.schedule", name: "Schedule Staff", description: "Assign staff to shifts" }, + { id: "staff.timesheets", name: "Approve Timesheets", description: "Review hours worked" }, + { id: "staff.rate", name: "Rate Performance", description: "Provide staff feedback" }, ] }, { id: "vendors", - name: "Vendor Relations", + name: "Vendor Access", icon: Package, - color: "text-purple-600", permissions: [ - { id: "sec.9", name: "View Approved Vendors", description: "See vendors available to my sector" }, - { id: "sec.10", name: "View Vendor Rates", description: "Access rate cards" }, - { id: "sec.11", name: "Request Vendor Services", description: "Submit staffing requests" }, + { id: "vendors.viewApproved", name: "View Vendors", description: "See approved vendors" }, + { id: "vendors.rates", name: "View Rates", description: "Access rate cards" }, + { id: "vendors.request", name: "Request Services", description: "Submit staffing requests" }, ] } ], @@ -212,48 +274,34 @@ const ROLE_PERMISSIONS = { client: [ { id: "orders", - name: "Order Management", + name: "My Orders", icon: Calendar, - color: "text-blue-600", permissions: [ - { id: "client.1", name: "View My Orders", description: "See my event orders" }, - { id: "client.2", name: "Create New Orders", description: "Request staffing for events" }, - { id: "client.3", name: "Edit My Orders", description: "Modify order details before confirmation" }, - { id: "client.4", name: "Cancel Orders", description: "Cancel pending or confirmed orders" }, - { id: "client.5", name: "View Order Status", description: "Track order fulfillment" }, + { id: "orders.view", name: "View Orders", description: "See my event orders" }, + { id: "orders.create", name: "Create Orders", description: "Request staffing for events" }, + { id: "orders.edit", name: "Edit Orders", description: "Modify orders before confirmation" }, + { id: "orders.cancel", name: "Cancel Orders", description: "Cancel pending orders" }, + { id: "orders.status", name: "Track Status", description: "Monitor order fulfillment" }, ] }, { id: "vendors", name: "Vendor Selection", icon: Package, - color: "text-purple-600", permissions: [ - { id: "client.6", name: "View Available Vendors", description: "See vendors I can work with" }, - { id: "client.7", name: "View Vendor Rates", description: "See pricing for services" }, - { id: "client.8", name: "Request Specific Vendors", description: "Prefer specific vendors for orders" }, - ] - }, - { - id: "workforce", - name: "Staff Review", - icon: Users, - color: "text-emerald-600", - permissions: [ - { id: "client.9", name: "View Assigned Staff", description: "See who's working my events" }, - { id: "client.10", name: "Rate Staff Performance", description: "Provide feedback on staff" }, - { id: "client.11", name: "Request Staff Changes", description: "Request staff replacements" }, + { id: "vendors.browse", name: "Browse Vendors", description: "View available vendors" }, + { id: "vendors.rates", name: "View Rates", description: "See service pricing" }, + { id: "vendors.prefer", name: "Preferred Vendors", description: "Request specific vendors" }, ] }, { id: "billing", - name: "Billing & Invoices", + name: "Billing", icon: DollarSign, - color: "text-green-600", permissions: [ - { id: "client.12", name: "View My Invoices", description: "Access invoices for my orders" }, - { id: "client.13", name: "Download Invoices", description: "Export invoice PDFs" }, - { id: "client.14", name: "View Spend Analytics", description: "See my spending trends" }, + { id: "billing.invoices", name: "View Invoices", description: "Access my invoices" }, + { id: "billing.download", name: "Download Invoices", description: "Export invoice PDFs" }, + { id: "billing.analytics", name: "Spend Analytics", description: "View spending trends" }, ] } ], @@ -263,59 +311,43 @@ const ROLE_PERMISSIONS = { id: "orders", name: "Order Fulfillment", icon: Calendar, - color: "text-blue-600", permissions: [ - { id: "vendor.1", name: "View My Orders", description: "See orders assigned to me" }, - { id: "vendor.2", name: "Accept/Decline Orders", description: "Respond to order requests" }, - { id: "vendor.3", name: "Update Order Status", description: "Mark orders as in progress/completed" }, - { id: "vendor.4", name: "View Order Details", description: "Access order requirements" }, + { id: "orders.viewAssigned", name: "View Orders", description: "See assigned orders" }, + { id: "orders.respond", name: "Accept/Decline", description: "Respond to order requests" }, + { id: "orders.update", name: "Update Status", description: "Mark order progress" }, + { id: "orders.details", name: "Order Details", description: "Access requirements" }, ] }, { id: "workforce", name: "My Workforce", icon: Users, - color: "text-emerald-600", permissions: [ - { id: "vendor.5", name: "View My Staff", description: "See my workforce members" }, - { id: "vendor.6", name: "Add New Staff", description: "Onboard new workers" }, - { id: "vendor.7", name: "Edit Staff Details", description: "Update staff information" }, - { id: "vendor.8", name: "Assign Staff to Orders", description: "Schedule staff for orders" }, - { id: "vendor.9", name: "Manage Staff Compliance", description: "Track certifications and background checks" }, - { id: "vendor.10", name: "View Staff Performance", description: "See ratings and feedback" }, + { id: "workforce.view", name: "View Staff", description: "See my workforce" }, + { id: "workforce.add", name: "Add Staff", description: "Onboard new workers" }, + { id: "workforce.edit", name: "Edit Staff", description: "Update staff info" }, + { id: "workforce.assign", name: "Assign Staff", description: "Schedule for orders" }, + { id: "workforce.compliance", name: "Manage Compliance", description: "Track certifications" }, ] }, { id: "rates", - name: "Rate Management", + name: "My Rates", icon: DollarSign, - color: "text-green-600", permissions: [ - { id: "vendor.11", name: "View My Rate Cards", description: "See my approved rates" }, - { id: "vendor.12", name: "Submit Rate Proposals", description: "Propose new rates" }, - { id: "vendor.13", name: "View Rate History", description: "Track rate changes" }, + { id: "rates.viewMy", name: "View Rates", description: "See my approved rates" }, + { id: "rates.propose", name: "Propose Rates", description: "Submit rate proposals" }, + { id: "rates.history", name: "Rate History", description: "Track rate changes" }, ] }, { - id: "performance", - name: "Performance & Analytics", - icon: BarChart3, - color: "text-indigo-600", - permissions: [ - { id: "vendor.14", name: "View My Scorecard", description: "See my performance metrics" }, - { id: "vendor.15", name: "View Fill Rate", description: "Track order fulfillment rate" }, - { id: "vendor.16", name: "View Revenue Analytics", description: "See my earnings trends" }, - ] - }, - { - id: "billing", - name: "Invoices & Payments", + id: "invoices", + name: "Invoicing", icon: FileText, - color: "text-cyan-600", permissions: [ - { id: "vendor.17", name: "View My Invoices", description: "Access invoices I've issued" }, - { id: "vendor.18", name: "Create Invoices", description: "Generate invoices for completed work" }, - { id: "vendor.19", name: "Track Payments", description: "Monitor payment status" }, + { id: "invoices.view", name: "View Invoices", description: "Access my invoices" }, + { id: "invoices.create", name: "Create Invoices", description: "Generate invoices" }, + { id: "invoices.track", name: "Track Payments", description: "Monitor payment status" }, ] } ], @@ -325,103 +357,57 @@ const ROLE_PERMISSIONS = { id: "shifts", name: "My Shifts", icon: Calendar, - color: "text-blue-600", permissions: [ - { id: "work.1", name: "View My Schedule", description: "See my upcoming shifts" }, - { id: "work.2", name: "Clock In/Out", description: "Record shift start and end times" }, - { id: "work.3", name: "Request Time Off", description: "Submit time off requests" }, - { id: "work.4", name: "View Shift History", description: "See past shifts worked" }, + { id: "shifts.view", name: "View Schedule", description: "See upcoming shifts" }, + { id: "shifts.clock", name: "Clock In/Out", description: "Record shift times" }, + { id: "shifts.timeoff", name: "Request Time Off", description: "Submit time off requests" }, + { id: "shifts.history", name: "Shift History", description: "See past shifts" }, ] }, { id: "profile", name: "My Profile", icon: Users, - color: "text-emerald-600", permissions: [ - { id: "work.5", name: "View My Profile", description: "See my worker profile" }, - { id: "work.6", name: "Edit Contact Info", description: "Update phone/email" }, - { id: "work.7", name: "Update Availability", description: "Set my available days/times" }, - { id: "work.8", name: "Upload Certifications", description: "Add certificates and licenses" }, + { id: "profile.view", name: "View Profile", description: "See my worker profile" }, + { id: "profile.edit", name: "Edit Contact", description: "Update phone/email" }, + { id: "profile.availability", name: "Update Availability", description: "Set available times" }, + { id: "profile.certs", name: "Upload Certs", description: "Add certifications" }, ] }, { id: "earnings", - name: "Earnings & Payments", + name: "Earnings", icon: DollarSign, - color: "text-green-600", permissions: [ - { id: "work.9", name: "View My Earnings", description: "See my pay and hours" }, - { id: "work.10", name: "View Timesheets", description: "Access my timesheet records" }, - { id: "work.11", name: "View Payment History", description: "See past payments" }, - { id: "work.12", name: "Download Pay Stubs", description: "Export payment records" }, - ] - }, - { - id: "performance", - name: "My Performance", - icon: BarChart3, - color: "text-indigo-600", - permissions: [ - { id: "work.13", name: "View My Ratings", description: "See feedback from clients" }, - { id: "work.14", name: "View Performance Stats", description: "See my reliability metrics" }, - { id: "work.15", name: "View Badges/Achievements", description: "See earned badges" }, + { id: "earnings.view", name: "View Earnings", description: "See pay and hours" }, + { id: "earnings.timesheets", name: "View Timesheets", description: "Access timesheet records" }, + { id: "earnings.history", name: "Payment History", description: "See past payments" }, + { id: "earnings.download", name: "Download Stubs", description: "Export pay stubs" }, ] } ] }; -const ROLE_TEMPLATES = { - admin: { - name: "Administrator", - description: "Full system access", - color: "bg-red-100 text-red-700", - defaultPermissions: "all" - }, - procurement: { - name: "Procurement Manager", - description: "Vendor and rate management", - color: "bg-purple-100 text-purple-700", - defaultPermissions: ["proc.1", "proc.2", "proc.3", "proc.4", "proc.5", "proc.6", "proc.7", "proc.8", "proc.9", "proc.10", "proc.12", "proc.13", "proc.15", "proc.16"] - }, - operator: { - name: "Operator", - description: "Enterprise management", - color: "bg-blue-100 text-blue-700", - defaultPermissions: ["op.1", "op.2", "op.3", "op.5", "op.6", "op.7", "op.8", "op.10", "op.11", "op.12", "op.14"] - }, - sector: { - name: "Sector Manager", - description: "Location-specific management", - color: "bg-cyan-100 text-cyan-700", - defaultPermissions: ["sec.1", "sec.2", "sec.3", "sec.4", "sec.5", "sec.6", "sec.7", "sec.9", "sec.10"] - }, - client: { - name: "Client", - description: "Order creation and viewing", - color: "bg-green-100 text-green-700", - defaultPermissions: ["client.1", "client.2", "client.3", "client.5", "client.6", "client.7", "client.9", "client.12"] - }, - vendor: { - name: "Vendor Partner", - description: "Vendor-specific access", - color: "bg-amber-100 text-amber-700", - defaultPermissions: ["vendor.1", "vendor.2", "vendor.3", "vendor.5", "vendor.6", "vendor.7", "vendor.8", "vendor.11", "vendor.14", "vendor.17", "vendor.18"] - }, - workforce: { - name: "Workforce Member", - description: "Basic access for staff", - color: "bg-slate-100 text-slate-700", - defaultPermissions: ["work.1", "work.2", "work.4", "work.5", "work.6", "work.9", "work.10", "work.13"] - } -}; +// Default position templates +const DEFAULT_POSITION_TEMPLATES = [ + { id: "manager", position: "Manager", layer: "operator", description: "Full operational access", permissions: ["events.view", "events.create", "events.edit", "events.approve", "sectors.view", "workforce.view", "workforce.assign"] }, + { id: "supervisor", position: "Supervisor", layer: "sector", description: "Location-level management", permissions: ["events.viewMy", "events.request", "staff.view", "staff.schedule", "staff.timesheets"] }, + { id: "coordinator", position: "Coordinator", layer: "client", description: "Order coordination", permissions: ["orders.view", "orders.create", "orders.status", "vendors.browse"] }, + { id: "team_lead", position: "Team Lead", layer: "vendor", description: "Workforce team management", permissions: ["orders.viewAssigned", "workforce.view", "workforce.assign", "rates.viewMy"] }, + { id: "staff", position: "Staff Member", layer: "workforce", description: "Basic shift access", permissions: ["shifts.view", "shifts.clock", "profile.view", "earnings.view"] }, +]; export default function Permissions() { - const [selectedRole, setSelectedRole] = useState("operator"); + const [activeTab, setActiveTab] = useState("layers"); + const [selectedLayer, setSelectedLayer] = useState("operator"); const [searchTerm, setSearchTerm] = useState(""); - const [expandedCategories, setExpandedCategories] = useState({}); + const [expandedModules, setExpandedModules] = useState({}); const [permissions, setPermissions] = useState({}); - const [overrides, setOverrides] = useState({}); + const [positionTemplates, setPositionTemplates] = useState(DEFAULT_POSITION_TEMPLATES); + const [showAddPosition, setShowAddPosition] = useState(false); + const [newPosition, setNewPosition] = useState({ position: "", description: "" }); + const [editingPosition, setEditingPosition] = useState(null); const { toast } = useToast(); const { data: user } = useQuery({ @@ -431,247 +417,561 @@ export default function Permissions() { const userRole = user?.user_role || user?.role || "admin"; - // Get role-specific permissions - const roleCategories = ROLE_PERMISSIONS[selectedRole] || []; + // Non-admin users can only see their own layer + const effectiveLayer = userRole === "admin" ? selectedLayer : userRole; + const selectedLayerConfig = LAYER_HIERARCHY.find(l => l.id === effectiveLayer); + const modules = PERMISSION_MODULES[effectiveLayer] || []; - // Initialize permissions based on role template + // Filter layer hierarchy for non-admin users + const visibleLayers = userRole === "admin" ? LAYER_HIERARCHY : LAYER_HIERARCHY.filter(l => l.id === userRole); + + // Initialize permissions with all enabled for demo React.useEffect(() => { - const template = ROLE_TEMPLATES[selectedRole]; - if (template) { - const newPermissions = {}; - if (template.defaultPermissions === "all") { - // Admin gets all permissions - roleCategories.forEach(category => { - category.permissions.forEach(perm => { - newPermissions[perm.id] = true; - }); - }); - } else { - // Other roles get specific permissions - template.defaultPermissions.forEach(permId => { - newPermissions[permId] = true; - }); - } - setPermissions(newPermissions); - setOverrides({}); - } - }, [selectedRole]); - - const toggleCategory = (categoryId) => { - setExpandedCategories(prev => ({ - ...prev, - [categoryId]: !prev[categoryId] - })); - }; - - const handlePermissionChange = (permId, value) => { - setPermissions(prev => ({ - ...prev, - [permId]: value - })); - setOverrides(prev => ({ - ...prev, - [permId]: true - })); - }; - - const handleInherit = (permId) => { - const template = ROLE_TEMPLATES[selectedRole]; - const shouldBeEnabled = template.defaultPermissions === "all" || template.defaultPermissions.includes(permId); - setPermissions(prev => ({ - ...prev, - [permId]: shouldBeEnabled - })); - setOverrides(prev => { - const newOverrides = { ...prev }; - delete newOverrides[permId]; - return newOverrides; + const newPermissions = {}; + modules.forEach(module => { + module.permissions.forEach(perm => { + newPermissions[perm.id] = true; + }); }); + setPermissions(newPermissions); + // Expand all modules by default + const expanded = {}; + modules.forEach(m => expanded[m.id] = true); + setExpandedModules(expanded); + }, [selectedLayer]); + + const toggleModule = (moduleId) => { + setExpandedModules(prev => ({ + ...prev, + [moduleId]: !prev[moduleId] + })); + }; + + const togglePermission = (permId) => { + setPermissions(prev => ({ + ...prev, + [permId]: !prev[permId] + })); + }; + + const toggleAllInModule = (module, value) => { + const newPerms = { ...permissions }; + module.permissions.forEach(p => { + newPerms[p.id] = value; + }); + setPermissions(newPerms); }; const handleSave = () => { toast({ title: "Permissions Saved", - description: `Permissions for ${ROLE_TEMPLATES[selectedRole].name} role have been updated successfully.`, + description: `${selectedLayerConfig.name} layer permissions updated successfully.`, }); }; - const filteredCategories = roleCategories.map(category => ({ - ...category, - permissions: category.permissions.filter(perm => + const handleAddPosition = () => { + if (!newPosition.position) return; + const id = newPosition.position.toLowerCase().replace(/\s+/g, '_'); + setPositionTemplates([...positionTemplates, { ...newPosition, id, layer: effectiveLayer, permissions: [] }]); + setNewPosition({ position: "", description: "" }); + setShowAddPosition(false); + toast({ title: "Position Added", description: `${newPosition.position} template created.` }); + }; + + const handleDeletePosition = (id) => { + setPositionTemplates(positionTemplates.filter(p => p.id !== id)); + toast({ title: "Position Deleted", description: "Position template removed." }); + }; + + const handleEditPositionPermissions = (position) => { + setEditingPosition(position); + setSelectedLayer(position.layer); + // Load position's permissions + const newPerms = {}; + position.permissions.forEach(p => newPerms[p] = true); + setPermissions(newPerms); + }; + + const handleSavePositionPermissions = () => { + if (!editingPosition) return; + const enabledPerms = Object.entries(permissions).filter(([_, v]) => v).map(([k]) => k); + setPositionTemplates(positionTemplates.map(p => + p.id === editingPosition.id ? { ...p, permissions: enabledPerms } : p + )); + setEditingPosition(null); + toast({ title: "Position Updated", description: `${editingPosition.position} permissions saved.` }); + }; + + const filteredModules = modules.map(module => ({ + ...module, + permissions: module.permissions.filter(perm => perm.name.toLowerCase().includes(searchTerm.toLowerCase()) || perm.description.toLowerCase().includes(searchTerm.toLowerCase()) ) - })).filter(category => category.permissions.length > 0); + })).filter(module => module.permissions.length > 0); + + const enabledCount = Object.values(permissions).filter(Boolean).length; + const totalCount = modules.reduce((sum, m) => sum + m.permissions.length, 0); + - // Only admins can access this page - if (userRole !== "admin") { - return ( -
- -

Access Denied

-

Only administrators can manage permissions.

-
- ); - } return ( -
-
- + +
+
+ - {/* Role Selector */} - - - Select Role to Configure -

Permissions shown below are contextual to the selected role

-
- -
- {Object.entries(ROLE_TEMPLATES).map(([roleKey, role]) => ( - - ))} + {/* Tab Navigation */} +
+ + +
+ + {activeTab === "positions" && !editingPosition && ( + <> + {/* Position Templates */} + + +
+
+
+ +
+
+ Position Templates +

Define default permissions for job positions

+
+
+ +
+
+ +
+ {positionTemplates.map((template) => { + const layerConfig = LAYER_HIERARCHY.find(l => l.id === template.layer); + const layerModules = PERMISSION_MODULES[template.layer] || []; + + // Get permission names for display + const permissionNames = template.permissions.map(permId => { + for (const module of layerModules) { + const found = module.permissions.find(p => p.id === permId); + if (found) return found.name; + } + return permId; + }); + + return ( +
+
+
+

{template.position}

+ + {layerConfig?.name || template.layer} + +
+
+ + +
+
+

{template.description}

+
+ {layerModules.map((module) => { + const ModuleIcon = module.icon; + const moduleEnabledCount = module.permissions.filter(p => template.permissions.includes(p.id)).length; + const allModuleEnabled = moduleEnabledCount === module.permissions.length; + + return ( +
+ {/* Module Header */} +
+
+
+ +
+
+

{module.name}

+

{moduleEnabledCount} of {module.permissions.length} enabled

+
+
+ +
+ + {/* Permissions List */} +
+ {module.permissions.map((perm) => { + const isEnabled = template.permissions.includes(perm.id); + return ( +
{ + const newPerms = isEnabled + ? template.permissions.filter(p => p !== perm.id) + : [...template.permissions, perm.id]; + setPositionTemplates(positionTemplates.map(p => + p.id === template.id ? { ...p, permissions: newPerms } : p + )); + toast({ title: isEnabled ? "Permission Disabled" : "Permission Enabled", description: `${perm.name} ${isEnabled ? 'removed from' : 'added to'} ${template.position}` }); + }} + > +
+
+ +
+
+

{perm.name}

+

{perm.description}

+
+
+
+
+
+
+ ); + })} +
+
+ ); + })} +
+
+ ); + })} +
+ + + + {/* Add Position Dialog */} + + + + Add Position Template + +
+
+ + setNewPosition({ ...newPosition, position: e.target.value })} + placeholder="e.g., Regional Manager" + className="mt-1" + /> +
+ +
+ + setNewPosition({ ...newPosition, description: e.target.value })} + placeholder="Brief description of access level" + className="mt-1" + /> +
+
+ + + + +
+
+ + )} + + {editingPosition && ( +
+
+ +
+

Editing: {editingPosition.position}

+

Configure permissions for this position template

+
+
+
+ + +
- - + )} - {/* Search */} - - + {(activeTab === "layers" || editingPosition) && ( + <> + {/* Layer Hierarchy Visual */} + + +
+ +
+ KROW Ecosystem Layers +

Select a layer to configure its permissions

+
+
+
+ + {/* Horizontal Layer Flow */} +
+ {visibleLayers.map((layer, index) => { + const Icon = layer.icon; + const isSelected = effectiveLayer === layer.id; + + return ( + + + {index < visibleLayers.length - 1 && ( +
+ )} +
+ ); + })} +
+ + {/* Selected Layer Info */} + {selectedLayerConfig && ( +
+
+
+
+ +
+
+

+ {selectedLayerConfig.name} Layer +

+

{selectedLayerConfig.description}

+
+
+
+

{enabledCount}/{totalCount}

+

enabled

+
+
+
+ )} +
+
+ + {/* Search */} +
- + setSearchTerm(e.target.value)} - className="pl-10 border-slate-300" + className="pl-10 h-10 bg-white border-slate-300 shadow-sm" />
- - +
- {/* Permission Categories */} -
- {filteredCategories.map((category) => { - const Icon = category.icon; - const isExpanded = expandedCategories[category.id] !== false; // Default to expanded + {/* Permission Modules */} +
+ {filteredModules.map((module) => { + const Icon = module.icon; + const isExpanded = expandedModules[module.id] !== false; + const moduleEnabled = module.permissions.filter(p => permissions[p.id]).length; + const moduleTotal = module.permissions.length; + const allEnabled = moduleEnabled === moduleTotal; - return ( - - toggleCategory(category.id)} - > -
-
- {isExpanded ? ( - - ) : ( - - )} - - {category.name} - - {category.permissions.filter(p => permissions[p.id]).length}/{category.permissions.length} - + return ( + + toggleModule(module.id)} + > +
+
+ {isExpanded ? ( + + ) : ( + + )} +
+ +
+
+ {module.name} +

+ {moduleEnabled} of {moduleTotal} enabled +

+
+
+
e.stopPropagation()}> + + + + + Toggle all permissions in this module + +
-
- + - {isExpanded && ( - -
- {category.permissions.map((perm) => { - const isOverridden = overrides[perm.id]; + {isExpanded && ( + + {module.permissions.map((perm) => { const isEnabled = permissions[perm.id]; return (
- {perm.id} -
- {perm.name} - - - - - -

{perm.description}

-
-
+
+ {isEnabled ? ( + + ) : ( + + )} +
+
+
+ + {perm.name} + + {perm.critical && ( + + + + + Critical permission - use with caution + + )} +
+

{perm.description}

-
- - - handlePermissionChange(perm.id, checked)} - className="border-slate-300 data-[state=checked]:bg-[#0A39DF] data-[state=checked]:border-[#0A39DF]" - /> -
+ togglePermission(perm.id)} + className="data-[state=checked]:bg-green-600" + />
); })} -
-
- )} - - ); - })} -
- - {/* Save Button */} -
-
- {Object.keys(overrides).length} permission{Object.keys(overrides).length !== 1 ? 's' : ''} overridden + + )} + + ); + })}
- + + {/* Save Footer */} + {!editingPosition && ( +
+
+
+
+ +
+
+

{selectedLayerConfig.name} Layer

+

+ {enabledCount} enabled • {totalCount - enabledCount} disabled +

+
+
+
+ + +
+
+
+ )} + + )}
-
+ ); } \ No newline at end of file diff --git a/frontend-web/src/pages/Schedule.jsx b/frontend-web/src/pages/Schedule.jsx new file mode 100644 index 00000000..a1f65bf9 --- /dev/null +++ b/frontend-web/src/pages/Schedule.jsx @@ -0,0 +1,252 @@ +import React, { useState } from "react"; +import { base44 } from "@/api/base44Client"; +import { useQuery } from "@tanstack/react-query"; +import { useNavigate } from "react-router-dom"; +import { createPageUrl } from "@/utils"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { ChevronLeft, ChevronRight, Plus, Clock, DollarSign, Calendar as CalendarIcon } from "lucide-react"; +import { format, startOfWeek, addDays, isSameDay, addWeeks, subWeeks, isToday, parseISO } from "date-fns"; + +const safeParseDate = (dateString) => { + if (!dateString) return null; + try { + if (typeof dateString === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(dateString)) { + const [year, month, day] = dateString.split('-').map(Number); + return new Date(year, month - 1, day); + } + return parseISO(dateString); + } catch { + return null; + } +}; + +export default function Schedule() { + const navigate = useNavigate(); + const [currentWeek, setCurrentWeek] = useState(startOfWeek(new Date(), { weekStartsOn: 0 })); + + const { data: events = [] } = useQuery({ + queryKey: ['events'], + queryFn: () => base44.entities.Event.list('-date'), + initialData: [], + }); + + const weekDays = Array.from({ length: 7 }, (_, i) => addDays(currentWeek, i)); + + const getEventsForDay = (date) => { + return events.filter(event => { + const eventDate = safeParseDate(event.date); + return eventDate && isSameDay(eventDate, date); + }); + }; + + const calculateWeekMetrics = () => { + const weekEvents = events.filter(event => { + const eventDate = safeParseDate(event.date); + if (!eventDate) return false; + return weekDays.some(day => isSameDay(eventDate, day)); + }); + + const totalHours = weekEvents.reduce((sum, event) => { + const hours = event.shifts?.reduce((shiftSum, shift) => { + return shiftSum + (shift.roles?.reduce((roleSum, role) => roleSum + (role.hours || 0), 0) || 0); + }, 0) || 0; + return sum + hours; + }, 0); + + const totalCost = weekEvents.reduce((sum, event) => sum + (event.total || 0), 0); + const totalShifts = weekEvents.reduce((sum, event) => sum + (event.shifts?.length || 0), 0); + + return { totalHours, totalCost, totalShifts }; + }; + + const metrics = calculateWeekMetrics(); + + const goToPreviousWeek = () => setCurrentWeek(subWeeks(currentWeek, 1)); + const goToNextWeek = () => setCurrentWeek(addWeeks(currentWeek, 1)); + const goToToday = () => setCurrentWeek(startOfWeek(new Date(), { weekStartsOn: 0 })); + + return ( +
+
+ {/* Header */} +
+
+

Schedule

+

Plan and manage staff shifts

+
+ +
+ + {/* Metrics Cards */} +
+ + +
+
+

Week Total Hours

+

{metrics.totalHours.toFixed(1)}

+
+ +
+
+
+ + + +
+
+

Week Labor Cost

+

${metrics.totalCost.toLocaleString()}

+
+ +
+
+
+ + + +
+
+

Total Shifts

+

{metrics.totalShifts}

+
+ +
+
+
+
+ + {/* Week Navigation */} + + +
+ +
+

Week of

+

{format(currentWeek, 'MMM d, yyyy')}

+
+ + +
+
+
+ + {/* Weekly Calendar */} +
+ {weekDays.map((day, index) => { + const dayEvents = getEventsForDay(day); + const isTodayDay = isToday(day); + + return ( + + + {/* Day Header */} +
+

+ {format(day, 'EEE')} +

+

+ {format(day, 'd')} +

+

+ {format(day, 'MMM')} +

+
+ + {/* Add Shift Button */} + + + {/* Events List */} +
+ {dayEvents.length === 0 ? ( +

+ No shifts +

+ ) : ( + dayEvents.map((event) => { + const firstShift = event.shifts?.[0]; + const firstRole = firstShift?.roles?.[0]; + const firstStaff = event.assigned_staff?.[0]; + + return ( +
navigate(createPageUrl(`EventDetail?id=${event.id}`))} + className={`p-3 rounded cursor-pointer transition-all ${ + isTodayDay + ? 'bg-white/20 hover:bg-white/30 border border-white/40' + : 'bg-white hover:bg-slate-50 border border-slate-200 shadow-sm' + }`} + > + {/* Status Badges */} +
+ {firstRole?.role && ( + + {firstRole.role} + + )} + + {event.status || 'scheduled'} + +
+ + {/* Staff Member */} + {firstStaff && ( +

+ 👤 + {firstStaff.staff_name} +

+ )} + + {/* Time */} + {firstRole && (firstRole.start_time || firstRole.end_time) && ( +

+ + {firstRole.start_time || '00:00'} - {firstRole.end_time || '00:00'} +

+ )} + + {/* Cost */} + {event.total > 0 && ( +

+ ${event.total.toFixed(2)} +

+ )} +
+ ); + }) + )} +
+
+
+ ); + })} +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend-web/src/pages/StaffAvailability.jsx b/frontend-web/src/pages/StaffAvailability.jsx new file mode 100644 index 00000000..d4e7da39 --- /dev/null +++ b/frontend-web/src/pages/StaffAvailability.jsx @@ -0,0 +1,469 @@ +import React, { useState, useMemo } from "react"; +import { base44 } from "@/api/base44Client"; +import { useQuery } 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 { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { Users, Calendar, Clock, TrendingUp, TrendingDown, AlertCircle, CheckCircle, XCircle, Search, Filter, List, LayoutGrid, ChevronLeft, ChevronRight } from "lucide-react"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { format } from "date-fns"; + +export default function StaffAvailability() { + const [searchTerm, setSearchTerm] = useState(""); + const [filterStatus, setFilterStatus] = useState("all"); + const [filterUtilization, setFilterUtilization] = useState("all"); + const [viewMode, setViewMode] = useState("cards"); + const [currentPage, setCurrentPage] = useState(1); + const [itemsPerPage, setItemsPerPage] = useState(50); + const [sortBy, setSortBy] = useState("need_work_index"); + + const { data: allStaff = [] } = useQuery({ + queryKey: ['staff-availability-all'], + queryFn: () => base44.entities.Staff.list(), + initialData: [], + }); + + const { data: availabilityData = [] } = useQuery({ + queryKey: ['worker-availability'], + queryFn: () => base44.entities.WorkerAvailability.list(), + initialData: [], + }); + + const { data: events = [] } = useQuery({ + queryKey: ['events-for-availability'], + queryFn: () => base44.entities.Event.list(), + initialData: [], + }); + + // Calculate metrics + const metrics = useMemo(() => { + const needsWork = availabilityData.filter(w => w.need_work_index >= 60).length; + const fullyBooked = availabilityData.filter(w => w.utilization_percentage >= 90).length; + const hasUtilization = availabilityData.filter(w => w.utilization_percentage > 0 && w.utilization_percentage < 90).length; + const onTimeOff = availabilityData.filter(w => w.availability_status === 'BLOCKED').length; + + return { needsWork, fullyBooked, hasUtilization, onTimeOff }; + }, [availabilityData]); + + // Filter and search logic + const filteredAvailability = useMemo(() => { + let filtered = availabilityData; + + // Search + if (searchTerm) { + filtered = filtered.filter(a => + a.staff_name?.toLowerCase().includes(searchTerm.toLowerCase()) + ); + } + + // Status filter + if (filterStatus !== "all") { + filtered = filtered.filter(a => a.availability_status === filterStatus); + } + + // Utilization filter + if (filterUtilization === "underutilized") { + filtered = filtered.filter(a => a.utilization_percentage < 50); + } else if (filterUtilization === "optimal") { + filtered = filtered.filter(a => a.utilization_percentage >= 50 && a.utilization_percentage < 100); + } else if (filterUtilization === "full") { + filtered = filtered.filter(a => a.utilization_percentage >= 100); + } + + // Sort + if (sortBy === "need_work_index") { + filtered.sort((a, b) => (b.need_work_index || 0) - (a.need_work_index || 0)); + } else if (sortBy === "utilization") { + filtered.sort((a, b) => (a.utilization_percentage || 0) - (b.utilization_percentage || 0)); + } else if (sortBy === "name") { + filtered.sort((a, b) => (a.staff_name || "").localeCompare(b.staff_name || "")); + } else if (sortBy === "availability_score") { + filtered.sort((a, b) => (b.predicted_availability_score || 0) - (a.predicted_availability_score || 0)); + } + + return filtered; + }, [availabilityData, searchTerm, filterStatus, filterUtilization, sortBy]); + + // Pagination + const totalPages = Math.ceil(filteredAvailability.length / itemsPerPage); + const paginatedData = filteredAvailability.slice( + (currentPage - 1) * itemsPerPage, + currentPage * itemsPerPage + ); + + React.useEffect(() => { + setCurrentPage(1); + }, [searchTerm, filterStatus, filterUtilization, sortBy]); + + const getUtilizationColor = (percentage) => { + if (percentage === 0) return "text-slate-400"; + if (percentage < 50) return "text-red-600"; + if (percentage < 80) return "text-amber-600"; + return "text-green-600"; + }; + + const getStatusBadge = (worker) => { + const statusConfig = { + 'CONFIRMED_AVAILABLE': { bg: 'bg-green-100', text: 'text-green-800', label: 'Available' }, + 'UNKNOWN': { bg: 'bg-gray-100', text: 'text-gray-800', label: 'Unknown' }, + 'BLOCKED': { bg: 'bg-red-100', text: 'text-red-800', label: 'Unavailable' }, + }; + const config = statusConfig[worker.availability_status] || statusConfig['UNKNOWN']; + return {config.label}; + }; + + return ( +
+
+ + {/* Header */} +
+
+

Staff Availability

+

+ Showing {filteredAvailability.length} of {availabilityData.length} workers +

+
+
+ + +
+
+ + {/* Metrics Cards */} +
+ + +
+
+

Needs Work

+

{metrics.needsWork}

+

Available workers

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

Fully Booked

+

{metrics.fullyBooked}

+

At capacity

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

Active

+

{metrics.hasUtilization}

+

Working now

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

On Time Off

+

{metrics.onTimeOff}

+

Unavailable

+
+
+ +
+
+
+
+
+ + {/* Search and Filters */} + + +
+
+ + setSearchTerm(e.target.value)} + className="pl-10 h-10 border border-slate-300 focus:border-[#0A39DF]" + /> +
+ + + +
+
+
+ + {/* Main Content - Table or Cards View */} + {viewMode === "table" ? ( + + + + + + Name + Status + Hours + Utilization + Hours Gap + Acceptance + Last Shift + + + + {paginatedData.length === 0 ? ( + + + No workers found + + + ) : ( + paginatedData.map((worker) => ( + + +
+ + + {worker.staff_name?.charAt(0) || "?"} + + + {worker.staff_name} +
+
+ + {getStatusBadge(worker)} + + + + {worker.scheduled_hours_this_period}h + + / {worker.desired_hours_this_period}h + + +
+ + {Math.round(worker.utilization_percentage)}% + +
+
+
+
+ + + {worker.scheduled_hours_this_period < worker.desired_hours_this_period ? ( + + Needs {worker.desired_hours_this_period - worker.scheduled_hours_this_period}h + + ) : ( + + Fully booked + + )} + + + {worker.acceptance_rate || 0}% + + + {worker.last_shift_date ? format(new Date(worker.last_shift_date), 'MMM d') : '-'} + + + )) + )} + +
+
+
+ ) : ( +
+ {paginatedData.map((worker) => { + const staff = allStaff.find(s => s.id === worker.staff_id); + + return ( + + +
+ + + {worker.staff_name?.charAt(0)?.toUpperCase()} + + +
+

{worker.staff_name}

+

{staff?.position || 'Staff'}

+ +
+ {getStatusBadge(worker)} + {worker.scheduled_hours_this_period < worker.desired_hours_this_period && ( + + Needs {worker.desired_hours_this_period - worker.scheduled_hours_this_period}h + + )} + {worker.scheduled_hours_this_period >= worker.desired_hours_this_period && ( + + Fully booked + + )} +
+ +
+
+
+ Weekly Hours + + {worker.scheduled_hours_this_period}h / {worker.desired_hours_this_period}h + +
+
+
+
+
+ + {worker.last_shift_date && ( +
+ + Last shift: {format(new Date(worker.last_shift_date), 'MMM d')} +
+ )} +
+
+
+ + + ); + })} +
+ )} + + {/* Pagination */} + + +
+
+

+ Showing {((currentPage - 1) * itemsPerPage) + 1} to {Math.min(currentPage * itemsPerPage, filteredAvailability.length)} of {filteredAvailability.length} workers +

+ +
+
+ + + Page {currentPage} of {totalPages} + + +
+
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/frontend-web/src/pages/TeamDetails.jsx b/frontend-web/src/pages/TeamDetails.jsx index 4b683349..ae77337a 100644 --- a/frontend-web/src/pages/TeamDetails.jsx +++ b/frontend-web/src/pages/TeamDetails.jsx @@ -647,7 +647,12 @@ export default function TeamDetails() {
-

{hub.hub_name}

+
+

{hub.hub_name}

+ + {members.filter(m => m.hub === hub.hub_name).length} members + +
{hub.manager_name && (

Manager: {hub.manager_name}

)} diff --git a/frontend-web/src/pages/Teams.jsx b/frontend-web/src/pages/Teams.jsx index a7310228..acd931e7 100644 --- a/frontend-web/src/pages/Teams.jsx +++ b/frontend-web/src/pages/Teams.jsx @@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Users, Plus, Search, Building2, MapPin, UserCheck, Mail, Edit, Loader2, Trash2, UserX, LayoutGrid, List as ListIcon, RefreshCw, Send, Filter, Star, UserPlus } from "lucide-react"; import PageHeader from "@/components/common/PageHeader"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; @@ -232,8 +233,8 @@ export default function Teams() { const uniqueDepartments = [...new Set([...teamDepartments, ...memberDepartments])]; // Separate active and deactivated members - const activeMembers = teamMembers.filter(m => m.is_active); - const deactivatedMembers = teamMembers.filter(m => !m.is_active); + const activeMembers = teamMembers.filter(m => m.is_active !== false); + const deactivatedMembers = teamMembers.filter(m => m.is_active === false); const createTestInviteMutation = useMutation({ mutationFn: async () => { @@ -289,15 +290,30 @@ export default function Teams() { throw new Error("Unable to identify who is sending the invite. Please try logging out and back in."); } - // Create hub if it doesn't exist - if (data.hub && !teamHubs.find(h => h.hub_name === data.hub)) { + // Create hub if it doesn't exist, or update hub with new department + const existingHub = teamHubs.find(h => h.hub_name === data.hub); + + if (data.hub && !existingHub) { + // Create new hub with department await base44.entities.TeamHub.create({ team_id: userTeam.id, hub_name: data.hub, address: "", - is_active: true + is_active: true, + departments: data.department ? [{ department_name: data.department, cost_center: "" }] : [] }); queryClient.invalidateQueries({ queryKey: ['team-hubs-main', userTeam?.id] }); + } else if (existingHub && data.department) { + // Add department to existing hub if it doesn't exist + const hubDepartments = existingHub.departments || []; + const departmentExists = hubDepartments.some(d => d.department_name === data.department); + + if (!departmentExists) { + await base44.entities.TeamHub.update(existingHub.id, { + departments: [...hubDepartments, { department_name: data.department, cost_center: "" }] + }); + queryClient.invalidateQueries({ queryKey: ['team-hubs-main', userTeam?.id] }); + } } const inviteCode = `TEAM-${Math.floor(10000 + Math.random() * 90000)}`; @@ -855,11 +871,19 @@ export default function Teams() { {member.email}
)} - {member.department && ( - - {member.department} - - )} +
+ {member.department && ( + + {member.department} + + )} + {member.hub && ( + + + {member.hub} + + )} +
@@ -1053,51 +1077,71 @@ export default function Teams() { )} {viewMode === "list" && filteredMembers(activeMembers).length > 0 && ( -
- {filteredMembers(activeMembers).map((member) => ( -
-
-
- {member.member_name?.split(' ').map(n => n[0]).join('') || '?'} -
-
-

{member.member_name}

-

{member.role} {member.title && `• ${member.title}`}

-

- {member.phone && `${member.phone} • `}{member.email} -

- {member.department && ( - - {member.department} - - )} -
-
-
- - -
-
- ))} +
+ + + + + + + + + + + + + + + {filteredMembers(activeMembers).map((member, index) => { + const memberHub = teamHubs.find(h => h.hub_name === member.hub); + return ( + + + + + + + + + + + ); + })} + +
#NameTitleRoleDepartmentHubHub AddressActions
{index + 1} +
+
+ {member.member_name?.split(' ').map(n => n[0]).join('') || '?'} +
+
+

{member.member_name}

+

{member.email}

+
+
+
{member.title || '-'} + + {member.role} + + {member.department || 'No Department'}{member.hub || 'No Hub'}{memberHub?.address || 'No Address'} +
+ + +
+
)} @@ -1134,54 +1178,74 @@ export default function Teams() { )} {viewMode === "list" && filteredMembers(deactivatedMembers).length > 0 && ( -
- {filteredMembers(deactivatedMembers).map((member) => ( -
-
-
- {member.member_name?.split(' ').map(n => n[0]).join('') || '?'} -
- -
-
-
-

{member.member_name}

-

{member.role} {member.title && `• ${member.title}`}

-

- {member.phone && `${member.phone} • `}{member.email} -

- {member.department && ( - - {member.department} - - )} -
-
-
- - -
-
- ))} +
+ + + + + + + + + + + + + + + {filteredMembers(deactivatedMembers).map((member, index) => { + const memberHub = teamHubs.find(h => h.hub_name === member.hub); + return ( + + + + + + + + + + + ); + })} + +
#NameTitleRoleDepartmentHubHub AddressActions
{index + 1} +
+
+ {member.member_name?.split(' ').map(n => n[0]).join('') || '?'} +
+ +
+
+
+

{member.member_name}

+

{member.email}

+
+
+
{member.title || '-'} + + {member.role} + + {member.department || 'No Department'}{member.hub || 'No Hub'}{memberHub?.address || 'No Address'} +
+ + +
+
)} @@ -1258,124 +1322,271 @@ export default function Teams() { {/* Hubs Tab */}
-
-
-

- - Team Locations & Hubs -

-

Manage your physical locations and departments

-
- -
- {teamHubs.length > 0 ? ( -
- {teamHubs.map((hub) => ( - -
-
-
-
+ viewMode === "grid" ? ( +
+ {teamHubs.map((hub) => ( +
+ {/* Hub Header */} +
+
+
+
-
-

{hub.hub_name}

+
+

{hub.hub_name}

+ {hub.address && ( +
+ +

{hub.address}

+
+ )} {hub.manager_name && ( -
- - {hub.manager_name} +
+
+ + {hub.manager_name} +
+ {hub.manager_email && ( + + + {hub.manager_email} + + )}
)}
- - {hub.departments?.length || 0} Depts - +
+ +
+
+ + {/* Quick Stats */} +
+
+
+ +
+
+

Team Members

+

{activeMembers.filter(m => m.hub === hub.hub_name).length}

+
+
+
+
+ +
+
+

Departments

+

{hub.departments?.length || 0}

+
+
- - - {hub.address && ( -
-
- -

{hub.address}

-
-
- )} - - {hub.manager_email && ( - - )} - - {hub.departments && hub.departments.length > 0 ? ( -
-

Departments

-
- {hub.departments.map((dept, idx) => ( -
-
-
-

{dept.department_name}

- {dept.cost_center && ( -

CC: {dept.cost_center}

- )} -
- {dept.manager_name && ( - - {dept.manager_name} - - )} -
-
- ))} -
-
- ) : ( -
-

No departments yet

-
- )} - -
+ + {/* Departments Section */} +
+
+

+ + Departments +

-
- - - ))} -
+ + {hub.departments && hub.departments.length > 0 ? ( +
+ {hub.departments.map((dept, idx) => ( +
+
+
+
+
+ +
+
+
{dept.department_name}
+ {dept.cost_center && ( +

Cost Center: {dept.cost_center}

+ )} +
+
+ {dept.manager_name && ( + + {dept.manager_name} + + )} +
+ + {/* Team members in this department */} + {activeMembers.filter(m => m.hub === hub.hub_name && m.department === dept.department_name).length > 0 && ( +
+

+ Team ({activeMembers.filter(m => m.hub === hub.hub_name && m.department === dept.department_name).length}) +

+
+ {activeMembers.filter(m => m.hub === hub.hub_name && m.department === dept.department_name).map((member) => ( +
handleEditMember(member)}> +
+ {member.member_name?.split(' ').map(n => n[0]).join('') || '?'} +
+
+

{member.member_name}

+

{member.title || member.role}

+
+
+ ))} +
+
+ )} +
+
+ ))} +
+ ) : ( +
+ +

No departments yet

+ +
+ )} +
+
+ ))} + + {/* Add New Hub Button */} + +
+ ) : ( +
+ + + + Hub Name + Address + Manager + Departments + Members + Actions + + + + {teamHubs.map((hub) => ( + + +
+
+ +
+ {hub.hub_name} +
+
+ +
+ + {hub.address || '—'} +
+
+ +
+

{hub.manager_name || '—'}

+ {hub.manager_email && ( +

{hub.manager_email}

+ )} +
+
+ + + {hub.departments?.length || 0} depts + + + + + {activeMembers.filter(m => m.hub === hub.hub_name).length} members + + + +
+ + +
+
+
+ ))} +
+
+ +
+ ) ) : (
@@ -1396,65 +1607,146 @@ export default function Teams() { {/* Favorites Tab */} -
-
-
- +
+ {/* Header Section */} +
+
+
+

+
+ +
+ Preferred Staff +

+

Your go-to professionals for high-priority assignments

+
+
+
+

{userTeam?.favorite_staff_count || 0}

+

Favorites

+
+ +
+
+ + {/* Search */} +
+ setFavoriteSearch(e.target.value)} - className="pl-10" + className="pl-12 h-12 bg-white border-2 border-amber-200 focus:border-amber-400 text-base" />
-
{userTeam?.favorite_staff && userTeam.favorite_staff.length > 0 ? ( -
- {userTeam.favorite_staff.filter(f => - !favoriteSearch || - f.staff_name?.toLowerCase().includes(favoriteSearch.toLowerCase()) || - f.position?.toLowerCase().includes(favoriteSearch.toLowerCase()) - ).map((fav) => ( - - -
-
- - - {fav.staff_name?.charAt(0)} - - -
-

{fav.staff_name}

-

{fav.position}

+ viewMode === "grid" ? ( +
+ {userTeam.favorite_staff.filter(f => + !favoriteSearch || + f.staff_name?.toLowerCase().includes(favoriteSearch.toLowerCase()) || + f.position?.toLowerCase().includes(favoriteSearch.toLowerCase()) + ).map((fav) => ( +
+
+
+
+
+
+ {fav.staff_name?.charAt(0)} +
+
+ +
+
+
+

{fav.staff_name}

+

{fav.position}

+

+ Added {new Date(fav.added_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })} +

+
- + +
+ +
- - - - ))} -
+
+ ))} +
+ ) : ( + + + + Staff Member + Position + Added Date + Actions + + + + {userTeam.favorite_staff.filter(f => + !favoriteSearch || + f.staff_name?.toLowerCase().includes(favoriteSearch.toLowerCase()) || + f.position?.toLowerCase().includes(favoriteSearch.toLowerCase()) + ).map((fav) => ( + + +
+
+ {fav.staff_name?.charAt(0)} +
+ {fav.staff_name} + +
+
+ {fav.position} + + + {new Date(fav.added_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })} + + + + + +
+ ))} +
+
+ ) ) : ( -
- -

No Favorite Staff

-

Mark staff as favorites to see them here

-
@@ -1464,62 +1756,151 @@ export default function Teams() { {/* Blocked Staff Tab */} -
-
-
- +
+ {/* Header Section */} +
+
+
+

+
+ +
+ Blocked Staff +

+

Staff members excluded from future assignments

+
+
+
+

{userTeam?.blocked_staff_count || 0}

+

Blocked

+
+ +
+
+ + {/* Search */} +
+ setBlockedSearch(e.target.value)} - className="pl-10" + className="pl-12 h-12 bg-white border-2 border-red-200 focus:border-red-400 text-base" />
-
{userTeam?.blocked_staff && userTeam.blocked_staff.length > 0 ? ( -
- {userTeam.blocked_staff.filter(b => - !blockedSearch || - b.staff_name?.toLowerCase().includes(blockedSearch.toLowerCase()) - ).map((blocked) => ( - - -
-
- - - {blocked.staff_name?.charAt(0)} - - -
-

{blocked.staff_name}

-

Reason: {blocked.reason || 'No reason provided'}

-

Blocked {new Date(blocked.blocked_date).toLocaleDateString()}

+ viewMode === "grid" ? ( +
+ {userTeam.blocked_staff.filter(b => + !blockedSearch || + b.staff_name?.toLowerCase().includes(blockedSearch.toLowerCase()) + ).map((blocked) => ( +
+
+
+
+
+
+ {blocked.staff_name?.charAt(0)} +
+
+ +
+
+
+

{blocked.staff_name}

+
+

Block Reason

+

{blocked.reason || 'No reason provided'}

+
+
+
+
+ Blocked on {new Date(blocked.blocked_date).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })} +
+
+
+
-
- - - ))} -
+
+ ))} +
+ ) : ( + + + + Staff Member + Reason + Blocked Date + Actions + + + + {userTeam.blocked_staff.filter(b => + !blockedSearch || + b.staff_name?.toLowerCase().includes(blockedSearch.toLowerCase()) + ).map((blocked) => ( + + +
+
+ {blocked.staff_name?.charAt(0)} +
+ {blocked.staff_name} + +
+
+ + {blocked.reason || 'No reason provided'} + + + + {new Date(blocked.blocked_date).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })} + + + + + +
+ ))} +
+
+ ) ) : ( -
- -

No Blocked Staff

-

Blocked staff will appear here

+
+
+ +
+

No Blocked Staff

+

+ All staff members are currently eligible for assignments +

+

+ Block staff members who should not be assigned to your events +

)}
@@ -1992,10 +2373,20 @@ export default function Teams() { await base44.entities.TeamHub.update(selectedHubForDept.id, { departments: updatedDepartments }); + + // Also add department to team's global department list + const teamDepartments = userTeam?.departments || []; + if (!teamDepartments.includes(newHubDepartment.department_name)) { + await base44.entities.Team.update(userTeam.id, { + departments: [...teamDepartments, newHubDepartment.department_name] + }); + queryClient.invalidateQueries({ queryKey: ['user-team', user?.id, userRole] }); + } + queryClient.invalidateQueries({ queryKey: ['team-hubs-main', userTeam?.id] }); setShowAddHubDepartmentDialog(false); setNewHubDepartment({ department_name: "", cost_center: "" }); - toast({ title: "✅ Department Added", description: "Department created successfully" }); + toast({ title: "✅ Department Added", description: `Department added to ${selectedHubForDept.hub_name}` }); }} className="bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white" disabled={!newHubDepartment.department_name} diff --git a/frontend-web/src/pages/Tutorials.jsx b/frontend-web/src/pages/Tutorials.jsx new file mode 100644 index 00000000..3eb76e5b --- /dev/null +++ b/frontend-web/src/pages/Tutorials.jsx @@ -0,0 +1,469 @@ +import React, { useState } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Play, Search, Calendar, Users, FileText, UserPlus, Building2, + DollarSign, MessageSquare, Award, TrendingUp, MapPin, + Briefcase, Package, CheckSquare, Headphones, Mail +} from "lucide-react"; +import PageHeader from "@/components/common/PageHeader"; + +export default function Tutorials() { + const [searchTerm, setSearchTerm] = useState(""); + const [selectedCategory, setSelectedCategory] = useState("all"); + const [playingVideo, setPlayingVideo] = useState(null); + + const tutorials = [ + { + id: 1, + title: "How to Create an Event Order", + description: "Learn how to create a new event booking with staff requirements, shifts, and scheduling", + category: "Events", + duration: "3:45", + icon: Calendar, + videoUrl: "https://www.youtube.com/embed/dQw4w9WgXcQ", + steps: [ + "Navigate to Events page", + "Click 'Create Event' button", + "Fill in event details (name, date, location)", + "Add shift requirements and roles", + "Set headcount for each position", + "Review and submit" + ] + }, + { + id: 2, + title: "Inviting Team Members", + description: "Step-by-step guide to invite new members to your team and assign them to hubs", + category: "Team Management", + duration: "2:30", + icon: UserPlus, + videoUrl: "https://www.youtube.com/embed/dQw4w9WgXcQ", + steps: [ + "Go to Teams page", + "Click 'Invite Member' button", + "Enter member's name and email", + "Select role and department", + "Choose hub location", + "Send invitation email" + ] + }, + { + id: 3, + title: "Creating and Managing Hubs", + description: "How to create location hubs and organize departments within them", + category: "Team Management", + duration: "4:15", + icon: MapPin, + videoUrl: "https://www.youtube.com/embed/dQw4w9WgXcQ", + steps: [ + "Navigate to Teams → Hubs tab", + "Click 'Create Hub' button", + "Enter hub name (e.g., BVG300)", + "Add location address", + "Assign hub manager", + "Add departments with cost centers" + ] + }, + { + id: 4, + title: "Staff Assignment & Scheduling", + description: "Assign staff to events, manage schedules, and handle conflicts", + category: "Staff Management", + duration: "5:20", + icon: Users, + videoUrl: "https://www.youtube.com/embed/dQw4w9WgXcQ", + steps: [ + "Open event details", + "Click 'Assign Staff' button", + "Filter staff by role and rating", + "Select staff members", + "Review conflict warnings", + "Confirm assignments" + ] + }, + { + id: 5, + title: "Creating and Sending Invoices", + description: "Generate invoices from events and send them to clients", + category: "Invoicing", + duration: "3:50", + icon: FileText, + videoUrl: "https://www.youtube.com/embed/dQw4w9WgXcQ", + steps: [ + "Go to Invoices page", + "Click 'Create Invoice'", + "Select event or create manually", + "Review line items and totals", + "Set payment terms", + "Send to client via email" + ] + }, + { + id: 6, + title: "Vendor Onboarding Process", + description: "Complete guide to onboarding new vendors with compliance documents", + category: "Vendor Management", + duration: "6:10", + icon: Package, + videoUrl: "https://www.youtube.com/embed/dQw4w9WgXcQ", + steps: [ + "Navigate to Vendors", + "Click 'Add Vendor'", + "Enter vendor details and contacts", + "Upload W9 and COI documents", + "Set coverage regions and roles", + "Submit for approval" + ] + }, + { + id: 7, + title: "Managing Vendor Rates", + description: "Set up and manage vendor pricing, markups, and client rates", + category: "Vendor Management", + duration: "4:30", + icon: DollarSign, + videoUrl: "https://www.youtube.com/embed/dQw4w9WgXcQ", + steps: [ + "Go to Vendor Rates page", + "Click 'Add New Rate'", + "Select category and role", + "Enter employee wage", + "Set markup and vendor fee %", + "Review client rate and save" + ] + }, + { + id: 8, + title: "Staff Onboarding Tutorial", + description: "Onboard new staff members with all required information and documents", + category: "Staff Management", + duration: "5:00", + icon: Users, + videoUrl: "https://www.youtube.com/embed/dQw4w9WgXcQ", + steps: [ + "Navigate to Onboarding page", + "Enter staff personal details", + "Add employment information", + "Upload certifications", + "Set availability and skills", + "Complete profile setup" + ] + }, + { + id: 9, + title: "Using the Messaging System", + description: "Communicate with team members, vendors, and clients through built-in messaging", + category: "Communication", + duration: "2:45", + icon: MessageSquare, + videoUrl: "https://www.youtube.com/embed/dQw4w9WgXcQ", + steps: [ + "Go to Messages page", + "Start new conversation", + "Select participants", + "Type and send messages", + "Attach files if needed", + "Archive old conversations" + ] + }, + { + id: 10, + title: "Managing Certifications", + description: "Track and manage employee certifications and compliance documents", + category: "Compliance", + duration: "3:20", + icon: Award, + videoUrl: "https://www.youtube.com/embed/dQw4w9WgXcQ", + steps: [ + "Navigate to Certifications", + "Click 'Add Certification'", + "Select employee and cert type", + "Enter issue and expiry dates", + "Upload certificate document", + "Submit for validation" + ] + }, + { + id: 11, + title: "Enterprise & Sector Setup", + description: "Set up enterprise organizations and manage multiple sectors", + category: "Enterprise", + duration: "5:40", + icon: Building2, + videoUrl: "https://www.youtube.com/embed/dQw4w9WgXcQ", + steps: [ + "Go to Enterprise Management", + "Click 'Add Enterprise'", + "Enter enterprise details", + "Add brand family members", + "Create sectors under enterprise", + "Link partners to sectors" + ] + }, + { + id: 12, + title: "Partner & Client Management", + description: "Add partners, manage sites, and configure client relationships", + category: "Partners", + duration: "4:00", + icon: Briefcase, + videoUrl: "https://www.youtube.com/embed/dQw4w9WgXcQ", + steps: [ + "Navigate to Partners", + "Click 'Add Partner'", + "Enter partner information", + "Add multiple sites", + "Configure allowed vendors", + "Set payment terms" + ] + }, + { + id: 13, + title: "Generating Reports & Analytics", + description: "Create custom reports and analyze workforce performance data", + category: "Reports", + duration: "4:25", + icon: TrendingUp, + videoUrl: "https://www.youtube.com/embed/dQw4w9WgXcQ", + steps: [ + "Go to Reports page", + "Select report type", + "Choose date range", + "Apply filters (vendor, client, etc.)", + "Generate report", + "Export to PDF or Excel" + ] + }, + { + id: 14, + title: "Task Board & Project Management", + description: "Use the task board to track work items and collaborate with your team", + category: "Productivity", + duration: "3:10", + icon: CheckSquare, + videoUrl: "https://www.youtube.com/embed/dQw4w9WgXcQ", + steps: [ + "Navigate to Task Board", + "Create new task", + "Assign to team members", + "Set due dates and priority", + "Move tasks between columns", + "Mark tasks as complete" + ] + }, + { + id: 15, + title: "Role-Based Permissions", + description: "Configure user roles and permissions across the platform", + category: "Administration", + duration: "3:55", + icon: Users, + videoUrl: "https://www.youtube.com/embed/dQw4w9WgXcQ", + steps: [ + "Go to Permissions page", + "Select user role", + "Configure access levels", + "Set entity permissions", + "Enable/disable features", + "Save permission settings" + ] + } + ]; + + const categories = ["all", ...new Set(tutorials.map(t => t.category))]; + + const filteredTutorials = tutorials.filter(tutorial => { + const matchesSearch = !searchTerm || + tutorial.title.toLowerCase().includes(searchTerm.toLowerCase()) || + tutorial.description.toLowerCase().includes(searchTerm.toLowerCase()); + + const matchesCategory = selectedCategory === "all" || tutorial.category === selectedCategory; + + return matchesSearch && matchesCategory; + }); + + return ( +
+
+ + + {/* Search and Filters */} +
+
+ + setSearchTerm(e.target.value)} + className="pl-10 h-12 border-slate-300 focus:border-[#0A39DF]" + /> +
+ +
+ {categories.map(category => ( + + ))} +
+
+ + {/* Tutorials Grid */} +
+ {filteredTutorials.map((tutorial) => ( + + +
+
+
+ +
+
+ + {tutorial.title} + +

{tutorial.description}

+
+
+ + {tutorial.duration} + +
+
+ + + {/* Video Player */} + {playingVideo === tutorial.id ? ( +
+