Merge pull request #105 from Oloodi/96-base44-frontend-update-from-export

96 base44 frontend update from export
This commit is contained in:
José Salazar
2025-11-26 14:50:31 -05:00
committed by GitHub
33 changed files with 10028 additions and 1726 deletions

View File

@@ -67,6 +67,14 @@ export const Task = base44.entities.Task;
export const TaskComment = base44.entities.TaskComment; 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: // auth sdk:

View File

@@ -0,0 +1,273 @@
import React from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Building2, MapPin, Briefcase, Phone, Mail, TrendingUp, Clock, Award, Users, Eye, Edit2, DollarSign } from "lucide-react";
import { Link } from "react-router-dom";
import { createPageUrl } from "@/utils";
export default function BusinessCard({ company, metrics, isListView = false, onView, onEdit }) {
const { companyName, logo, sector, monthlySpend, totalStaff, location, serviceType, phone, email, technology, performance, gradeColor, clientGrade, isActive, lastOrderDate, rateCard, businessId } = company;
if (isListView) {
return (
<Card className="border-slate-200 shadow-sm hover:shadow-md transition-all">
<CardContent className="p-4">
<div className="flex items-center gap-4">
{/* Logo */}
<div className="flex-shrink-0">
{logo ? (
<img src={logo} alt={companyName} className="w-16 h-16 rounded-lg object-cover border-2 border-slate-200" />
) : (
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-blue-700 rounded-lg flex items-center justify-center">
<Building2 className="w-8 h-8 text-white" />
</div>
)}
</div>
{/* Company Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="text-lg font-bold text-slate-900 truncate">{companyName}</h3>
<div className={`px-3 py-1 ${gradeColor} rounded-lg font-bold text-white text-sm`}>
{clientGrade}
</div>
{isActive === false && (
<Badge className="bg-slate-300 text-slate-700 text-xs">Inactive</Badge>
)}
</div>
<p className="text-sm text-slate-600 mb-2">{serviceType}</p>
<div className="flex items-center gap-4 text-xs text-slate-500">
<span className="flex items-center gap-1">
<MapPin className="w-3 h-3" />
{location}
</span>
<span>Monthly: ${(monthlySpend / 1000).toFixed(0)}k</span>
<span>{totalStaff} Staff</span>
{lastOrderDate && (
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
Last: {new Date(lastOrderDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
</span>
)}
</div>
</div>
{/* All Performance Metrics */}
<div className="flex items-center gap-4 flex-shrink-0">
<div className="text-center">
<p className="text-xs text-slate-500 mb-1">Cancellations</p>
<p className="text-lg font-bold text-orange-700">{performance.cancelRate}%</p>
</div>
<div className="text-center">
<p className="text-xs text-slate-500 mb-1">On-Time</p>
<p className="text-lg font-bold text-blue-700">{performance.onTimeRate}%</p>
</div>
<div className="text-center">
<p className="text-xs text-slate-500 mb-1">Rapid Orders</p>
<p className="text-lg font-bold text-purple-700">{performance.rapidOrders}</p>
</div>
<div className="text-center min-w-[80px]">
<p className="text-xs text-slate-500 mb-1">Main Position</p>
<p className="text-sm font-bold text-green-700 truncate">{performance.mainPosition}</p>
</div>
</div>
{/* Rate Card Badge */}
{rateCard && (
<div className="flex-shrink-0">
<Link to={`${createPageUrl("EditBusiness")}?id=${businessId}&tab=services`}>
<Badge className="bg-emerald-100 text-emerald-700 border border-emerald-200 hover:bg-emerald-200 cursor-pointer px-3 py-1.5">
<DollarSign className="w-3 h-3 mr-1 inline" />
{rateCard}
</Badge>
</Link>
</div>
)}
{/* Actions */}
<div className="flex items-center gap-2 flex-shrink-0">
<Button variant="outline" size="sm" onClick={onView} className="border-blue-600 text-blue-600 hover:bg-blue-50">
<Eye className="w-4 h-4 mr-1" />
View
</Button>
<Button variant="outline" size="sm" onClick={onEdit} className="border-slate-300 hover:bg-slate-50">
<Edit2 className="w-4 h-4 mr-1" />
Edit
</Button>
</div>
</div>
</CardContent>
</Card>
);
}
return (
<Card className="border-slate-200 shadow-lg hover:shadow-xl transition-all overflow-hidden">
{/* Header - Blue Gradient */}
<div className="bg-gradient-to-br from-blue-600 to-blue-800 p-6 relative">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-4">
{logo ? (
<img src={logo} alt={companyName} className="w-16 h-16 rounded-xl object-cover border-2 border-white shadow-md bg-white p-1" />
) : (
<div className="w-16 h-16 bg-white rounded-xl flex items-center justify-center shadow-md">
<Building2 className="w-8 h-8 text-blue-600" />
</div>
)}
<div>
<h3 className="text-2xl font-bold text-white mb-1">{companyName}</h3>
<Badge className="bg-white/20 text-white border-white/40 text-xs">
# N/A
</Badge>
</div>
</div>
<div className={`px-4 py-2 ${gradeColor} rounded-xl font-bold text-2xl text-white shadow-lg`}>
{clientGrade}
</div>
</div>
<p className="text-blue-100 text-sm font-medium">{serviceType}</p>
{/* Metrics Row */}
<div className="grid grid-cols-3 gap-3 mt-4">
<div className="bg-white/10 backdrop-blur-sm rounded-lg p-4">
<p className="text-blue-200 text-xs font-medium mb-1">Monthly Sales</p>
<p className="text-white text-2xl font-bold">${(monthlySpend / 1000).toFixed(0)}k</p>
</div>
<div className="bg-white/10 backdrop-blur-sm rounded-lg p-4">
<p className="text-blue-200 text-xs font-medium mb-1">Total Staff</p>
<p className="text-white text-2xl font-bold">{totalStaff}</p>
</div>
<div className="bg-white/10 backdrop-blur-sm rounded-lg p-4">
<p className="text-blue-200 text-xs font-medium mb-1">Last Order</p>
<p className="text-white text-lg font-bold">
{lastOrderDate
? new Date(lastOrderDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
: 'None'}
</p>
</div>
</div>
</div>
{/* Content */}
<CardContent className="p-6 bg-slate-50">
{/* Company Information */}
<div className="mb-6">
<h4 className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-3">COMPANY INFORMATION</h4>
<div className="space-y-3">
<div className="flex items-start gap-3">
<MapPin className="w-5 h-5 text-slate-400 mt-0.5 flex-shrink-0" />
<div>
<p className="font-semibold text-slate-900">{location}</p>
<p className="text-sm text-slate-500">California</p>
</div>
</div>
<div className="flex items-start gap-3">
<Briefcase className="w-5 h-5 text-slate-400 mt-0.5 flex-shrink-0" />
<div>
<p className="font-semibold text-slate-900">{serviceType}</p>
<p className="text-sm text-slate-500">Primary Service</p>
</div>
</div>
<div className="flex items-start gap-3">
<Phone className="w-5 h-5 text-slate-400 mt-0.5 flex-shrink-0" />
<p className="text-slate-900">{phone}</p>
</div>
<div className="flex items-start gap-3">
<Mail className="w-5 h-5 text-slate-400 mt-0.5 flex-shrink-0" />
<p className="text-slate-900 break-all">{email}</p>
</div>
</div>
</div>
{/* Rate Card & Technology */}
<div className="mb-6">
<h4 className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-3">RATE CARD & TECHNOLOGY</h4>
<div className="flex flex-wrap gap-2">
{rateCard ? (
<Link to={`${createPageUrl("EditBusiness")}?id=${businessId}&tab=services`}>
<Badge className="bg-emerald-50 text-emerald-700 border border-emerald-200 text-xs cursor-pointer hover:bg-emerald-100">
<DollarSign className="w-3 h-3 mr-1 inline" />
{rateCard}
</Badge>
</Link>
) : (
<Badge className="bg-slate-100 text-slate-500 border border-slate-200 text-xs">
No Rate Card
</Badge>
)}
{technology?.isUsingKROW ? (
<Badge className="bg-blue-50 text-blue-700 border border-blue-200 text-xs">
Using KROW
</Badge>
) : (
<Badge className="bg-amber-50 text-amber-700 border border-amber-200 text-xs cursor-pointer hover:bg-amber-100">
Invite to KROW
</Badge>
)}
</div>
</div>
{/* Performance Metrics */}
<div className="mb-4">
<h4 className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-3">PERFORMANCE METRICS</h4>
<div className="grid grid-cols-2 gap-4">
{/* Cancellation Rate */}
<div className="bg-white rounded-lg p-4 border border-slate-200">
<div className="flex items-center gap-2 mb-2">
<TrendingUp className="w-4 h-4 text-orange-600" />
<p className="text-xs font-semibold text-slate-600">Cancellations</p>
</div>
<p className="text-3xl font-bold text-orange-600 mb-2">{performance.cancelRate}%</p>
<div className="w-full h-2 bg-orange-100 rounded-full overflow-hidden">
<div className="h-full bg-orange-600 rounded-full transition-all" style={{ width: `${performance.cancelRate}%` }}></div>
</div>
</div>
{/* On-Time Ordering */}
<div className="bg-white rounded-lg p-4 border border-slate-200">
<div className="flex items-center gap-2 mb-2">
<Clock className="w-4 h-4 text-blue-600" />
<p className="text-xs font-semibold text-slate-600">On-Time Order</p>
</div>
<p className="text-3xl font-bold text-blue-600 mb-2">{performance.onTimeRate}%</p>
<div className="w-full h-2 bg-blue-100 rounded-full overflow-hidden">
<div className="h-full bg-blue-600 rounded-full transition-all" style={{ width: `${performance.onTimeRate}%` }}></div>
</div>
</div>
{/* Rapid Orders */}
<div className="bg-white rounded-lg p-4 border border-slate-200">
<div className="flex items-center gap-2 mb-2">
<Award className="w-4 h-4 text-purple-600" />
<p className="text-xs font-semibold text-slate-600">Rapid Orders</p>
</div>
<p className="text-3xl font-bold text-purple-600 mb-2">{performance.rapidOrders}</p>
<p className="text-xs text-slate-500">Last 30 days</p>
</div>
{/* Main Position */}
<div className="bg-white rounded-lg p-4 border border-slate-200">
<div className="flex items-center gap-2 mb-2">
<Users className="w-4 h-4 text-green-600" />
<p className="text-xs font-semibold text-slate-600">Main Position</p>
</div>
<p className="text-lg font-bold text-green-600">{performance.mainPosition}</p>
<p className="text-xs text-slate-500">Most requested</p>
</div>
</div>
</div>
{/* Footer */}
<button
onClick={onView}
className="w-full bg-blue-50 hover:bg-blue-100 rounded-lg p-3 text-center transition-colors cursor-pointer"
>
<p className="text-xs text-blue-700 font-medium">
Click to view full profile and detailed analytics
</p>
</button>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,256 @@
import React, { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Save, Loader2, Link2, Building2, FileText, Mail, CheckCircle, AlertCircle } from "lucide-react";
const ERP_SYSTEMS = [
{ value: "None", label: "No ERP Integration" },
{ value: "SAP Ariba", label: "SAP Ariba" },
{ value: "Fieldglass", label: "SAP Fieldglass" },
{ value: "CrunchTime", label: "CrunchTime" },
{ value: "Coupa", label: "Coupa" },
{ value: "Oracle NetSuite", label: "Oracle NetSuite" },
{ value: "Workday", label: "Workday" },
{ value: "Other", label: "Other" },
];
const EDI_FORMATS = [
{ value: "CSV", label: "CSV (Excel Compatible)" },
{ value: "EDI 810", label: "EDI 810 (Standard Invoice)" },
{ value: "cXML", label: "cXML (Ariba/Coupa)" },
{ value: "JSON", label: "JSON (API Format)" },
{ value: "Custom", label: "Custom Template" },
];
export default function ERPSettingsTab({ business, onSave, isSaving }) {
const [settings, setSettings] = useState({
erp_system: business?.erp_system || "None",
erp_vendor_id: business?.erp_vendor_id || "",
erp_cost_center: business?.erp_cost_center || "",
edi_enabled: business?.edi_enabled || false,
edi_format: business?.edi_format || "CSV",
invoice_email: business?.invoice_email || "",
po_required: business?.po_required || false,
});
useEffect(() => {
if (business) {
setSettings({
erp_system: business.erp_system || "None",
erp_vendor_id: business.erp_vendor_id || "",
erp_cost_center: business.erp_cost_center || "",
edi_enabled: business.edi_enabled || false,
edi_format: business.edi_format || "CSV",
invoice_email: business.invoice_email || "",
po_required: business.po_required || false,
});
}
}, [business]);
const handleChange = (field, value) => {
setSettings(prev => ({ ...prev, [field]: value }));
};
const handleSubmit = () => {
onSave(settings);
};
const isConfigured = settings.erp_system !== "None" && settings.erp_vendor_id;
return (
<div className="space-y-6">
{/* Status Card */}
<Card className={`border-2 ${isConfigured ? 'border-green-200 bg-green-50' : 'border-amber-200 bg-amber-50'}`}>
<CardContent className="p-4">
<div className="flex items-center gap-3">
{isConfigured ? (
<CheckCircle className="w-6 h-6 text-green-600" />
) : (
<AlertCircle className="w-6 h-6 text-amber-600" />
)}
<div>
<p className={`font-semibold ${isConfigured ? 'text-green-900' : 'text-amber-900'}`}>
{isConfigured ? 'ERP Integration Active' : 'ERP Integration Not Configured'}
</p>
<p className={`text-sm ${isConfigured ? 'text-green-700' : 'text-amber-700'}`}>
{isConfigured
? `Connected to ${settings.erp_system} • Vendor ID: ${settings.erp_vendor_id}`
: 'Configure ERP settings to enable automated invoice delivery'}
</p>
</div>
</div>
</CardContent>
</Card>
{/* ERP System Settings */}
<Card className="border-slate-200 shadow-lg">
<CardHeader className="bg-slate-50 border-b border-slate-200">
<CardTitle className="flex items-center gap-2 text-slate-900">
<Building2 className="w-5 h-5 text-blue-600" />
ERP System Configuration
</CardTitle>
</CardHeader>
<CardContent className="p-6 space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label className="text-slate-700 font-medium">ERP System</Label>
<Select
value={settings.erp_system}
onValueChange={(value) => handleChange('erp_system', value)}
>
<SelectTrigger className="border-slate-200">
<SelectValue placeholder="Select ERP system" />
</SelectTrigger>
<SelectContent>
{ERP_SYSTEMS.map(erp => (
<SelectItem key={erp.value} value={erp.value}>{erp.label}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-slate-500">Client's procurement or ERP system</p>
</div>
<div className="space-y-2">
<Label className="text-slate-700 font-medium">Vendor ID in ERP</Label>
<Input
value={settings.erp_vendor_id}
onChange={(e) => handleChange('erp_vendor_id', e.target.value)}
placeholder="e.g., VND-12345"
className="border-slate-200"
/>
<p className="text-xs text-slate-500">Your vendor identifier in client's system</p>
</div>
<div className="space-y-2">
<Label className="text-slate-700 font-medium">Default Cost Center</Label>
<Input
value={settings.erp_cost_center}
onChange={(e) => handleChange('erp_cost_center', e.target.value)}
placeholder="e.g., CC-1001"
className="border-slate-200"
/>
<p className="text-xs text-slate-500">Default cost center for invoice allocation</p>
</div>
<div className="space-y-2">
<Label className="text-slate-700 font-medium">Invoice Delivery Email</Label>
<Input
type="email"
value={settings.invoice_email}
onChange={(e) => handleChange('invoice_email', e.target.value)}
placeholder="ap@client.com"
className="border-slate-200"
/>
<p className="text-xs text-slate-500">Accounts payable email for invoice delivery</p>
</div>
</div>
</CardContent>
</Card>
{/* EDI Settings */}
<Card className="border-slate-200 shadow-lg">
<CardHeader className="bg-slate-50 border-b border-slate-200">
<CardTitle className="flex items-center gap-2 text-slate-900">
<Link2 className="w-5 h-5 text-purple-600" />
EDI / Export Settings
</CardTitle>
</CardHeader>
<CardContent className="p-6 space-y-6">
<div className="flex items-center justify-between p-4 bg-slate-50 rounded-lg">
<div>
<p className="font-medium text-slate-900">Enable EDI Integration</p>
<p className="text-sm text-slate-500">Automatically format invoices for EDI transmission</p>
</div>
<Switch
checked={settings.edi_enabled}
onCheckedChange={(checked) => handleChange('edi_enabled', checked)}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label className="text-slate-700 font-medium">Preferred Export Format</Label>
<Select
value={settings.edi_format}
onValueChange={(value) => handleChange('edi_format', value)}
>
<SelectTrigger className="border-slate-200">
<SelectValue placeholder="Select format" />
</SelectTrigger>
<SelectContent>
{EDI_FORMATS.map(format => (
<SelectItem key={format.value} value={format.value}>{format.label}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-slate-500">Default format for invoice exports</p>
</div>
<div className="flex items-center justify-between p-4 bg-slate-50 rounded-lg h-fit">
<div>
<p className="font-medium text-slate-900">PO Required</p>
<p className="text-sm text-slate-500">Require PO number on invoices</p>
</div>
<Switch
checked={settings.po_required}
onCheckedChange={(checked) => handleChange('po_required', checked)}
/>
</div>
</div>
{/* Format Info */}
<div className="p-4 bg-blue-50 rounded-lg border border-blue-200">
<h4 className="font-semibold text-blue-900 mb-2 flex items-center gap-2">
<FileText className="w-4 h-4" />
Supported Formats
</h4>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-sm">
<div className="flex items-center gap-2">
<Badge className="bg-green-100 text-green-700">EDI 810</Badge>
<span className="text-blue-700">Standard</span>
</div>
<div className="flex items-center gap-2">
<Badge className="bg-purple-100 text-purple-700">cXML</Badge>
<span className="text-blue-700">Ariba/Coupa</span>
</div>
<div className="flex items-center gap-2">
<Badge className="bg-orange-100 text-orange-700">CSV</Badge>
<span className="text-blue-700">Excel</span>
</div>
<div className="flex items-center gap-2">
<Badge className="bg-slate-100 text-slate-700">JSON</Badge>
<span className="text-blue-700">API</span>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Save Button */}
<div className="flex justify-end">
<Button
onClick={handleSubmit}
disabled={isSaving}
className="bg-blue-600 hover:bg-blue-700 text-white px-8"
>
{isSaving ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Saving...
</>
) : (
<>
<Save className="w-4 h-4 mr-2" />
Save ERP Settings
</>
)}
</Button>
</div>
</div>
);
}

View File

@@ -8,7 +8,7 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { import {
Calendar, Zap, RefreshCw, Users, Building2, 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"; } from "lucide-react";
import { format } from "date-fns"; import { format } from "date-fns";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; 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 { Checkbox } from "@/components/ui/checkbox";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "@/components/ui/command"; 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 GoogleAddressInput from "../common/GoogleAddressInput";
import RapidOrderInterface from "../orders/RapidOrderInterface"; import RapidOrderInterface from "../orders/RapidOrderInterface";
import { createPageUrl } from "@/utils"; import { createPageUrl } from "@/utils";
@@ -65,6 +67,8 @@ export default function EventFormWizard({ event, onSubmit, onRapidSubmit, isSubm
}); });
const [roleSearchOpen, setRoleSearchOpen] = useState({}); const [roleSearchOpen, setRoleSearchOpen] = useState({});
const [showShiftContactDialog, setShowShiftContactDialog] = useState(false);
const [selectedShiftIndex, setSelectedShiftIndex] = useState(null);
const { data: user } = useQuery({ const { data: user } = useQuery({
queryKey: ['current-user-form'], queryKey: ['current-user-form'],
@@ -77,6 +81,49 @@ export default function EventFormWizard({ event, onSubmit, onRapidSubmit, isSubm
const isVendor = userRole === "vendor"; const isVendor = userRole === "vendor";
const isClient = userRole === "client"; 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({ const { data: businesses = [] } = useQuery({
queryKey: ['businesses'], queryKey: ['businesses'],
queryFn: () => base44.entities.Business.list(), queryFn: () => base44.entities.Business.list(),
@@ -112,6 +159,23 @@ export default function EventFormWizard({ event, onSubmit, onRapidSubmit, isSubm
} }
}, [isClient, currentUserData, formData.vendor_id]); }, [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 // Auto-fill fields when order type changes to RAPID
useEffect(() => { useEffect(() => {
if (formData.order_type === 'rapid' && formData.shifts?.length > 0 && formData.shifts[0].roles?.length > 0) { 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) => { const handleAddRole = (shiftIndex) => {
setFormData(prev => { setFormData(prev => {
const newShifts = [...prev.shifts]; const newShifts = [...prev.shifts];
@@ -591,13 +665,33 @@ export default function EventFormWizard({ event, onSubmit, onRapidSubmit, isSubm
<div> <div>
<Label className="text-sm mb-1.5 block">Hub *</Label> <Label className="text-sm mb-1.5 block">Hub *</Label>
<Input {isClient && teamHubs.length > 0 ? (
value={formData.hub || ""} <Select value={formData.hub || ""} onValueChange={(value) => handleChange('hub', value)}>
onChange={(e) => handleChange('hub', e.target.value)} <SelectTrigger className="h-10">
placeholder="Hub location" <SelectValue placeholder="Hub location" />
className="h-10" </SelectTrigger>
required <SelectContent>
/> {teamHubs.map((hub) => (
<SelectItem key={hub.id} value={hub.hub_name}>
{hub.hub_name}
{hub.departments && hub.departments.length > 0 && (
<span className="text-xs text-slate-500 ml-2">
({hub.departments.map(d => d.cost_center).filter(Boolean).join(', ')})
</span>
)}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={formData.hub || ""}
onChange={(e) => handleChange('hub', e.target.value)}
placeholder="Hub location"
className="h-10"
required
/>
)}
</div> </div>
<div> <div>
@@ -731,6 +825,7 @@ export default function EventFormWizard({ event, onSubmit, onRapidSubmit, isSubm
value={formData.date || ""} value={formData.date || ""}
onChange={(e) => { onChange={(e) => {
const dateValue = e.target.value; const dateValue = e.target.value;
// Store date as-is without timezone conversion
handleChange('date', dateValue); handleChange('date', dateValue);
}} }}
className="h-10" className="h-10"
@@ -861,14 +956,40 @@ export default function EventFormWizard({ event, onSubmit, onRapidSubmit, isSubm
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button {shift.shift_contact ? (
type="button" <div className="flex items-center gap-2 bg-blue-50 px-3 py-1.5 rounded-lg border border-blue-200">
size="sm" <div>
className="h-9 bg-blue-100 hover:bg-blue-200 text-blue-700 border-0" <p className="text-sm font-semibold text-blue-900">{shift.shift_contact}</p>
> <p className="text-xs text-blue-600">{shift.shift_contact_phone || shift.shift_contact_email}</p>
<UserPlus className="w-4 h-4 mr-2" /> </div>
Add shift contact <Button
</Button> type="button"
size="icon"
variant="ghost"
className="h-6 w-6 text-blue-600 hover:bg-blue-100"
onClick={() => {
handleShiftChange(shiftIdx, 'shift_contact', '');
handleShiftChange(shiftIdx, 'shift_contact_phone', '');
handleShiftChange(shiftIdx, 'shift_contact_email', '');
}}
>
<X className="w-4 h-4" />
</Button>
</div>
) : (
<Button
type="button"
size="sm"
className="h-9 bg-blue-100 hover:bg-blue-200 text-blue-700 border-0"
onClick={() => {
setSelectedShiftIndex(shiftIdx);
setShowShiftContactDialog(true);
}}
>
<UserPlus className="w-4 h-4 mr-2" />
Add shift contact
</Button>
)}
{formData.shifts.length > 1 && ( {formData.shifts.length > 1 && (
<Button <Button
type="button" type="button"
@@ -1074,7 +1195,54 @@ export default function EventFormWizard({ event, onSubmit, onRapidSubmit, isSubm
</Card> </Card>
</> </>
)} )}
</div> </div>
</div>
); {/* Shift Contact Dialog */}
} <Dialog open={showShiftContactDialog} onOpenChange={setShowShiftContactDialog}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Select Shift Contact</DialogTitle>
</DialogHeader>
<div className="max-h-96 overflow-y-auto space-y-2">
{teamMembers.length > 0 ? (
teamMembers.map((member) => (
<Card
key={member.id}
className="cursor-pointer hover:bg-blue-50 transition-colors border-slate-200"
onClick={() => handleSelectShiftContact(member)}
>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<Avatar className="w-12 h-12">
<AvatarImage src={member.avatar_url} />
<AvatarFallback className="bg-[#0A39DF] text-white font-bold">
{member.member_name?.charAt(0)}
</AvatarFallback>
</Avatar>
<div className="flex-1">
<p className="font-semibold text-slate-900">{member.member_name}</p>
<p className="text-sm text-slate-600">{member.title || member.role}</p>
<div className="flex gap-2 mt-1">
{member.phone && (
<p className="text-xs text-slate-500">{member.phone}</p>
)}
{member.email && (
<p className="text-xs text-slate-500">{member.email}</p>
)}
</div>
</div>
</div>
</CardContent>
</Card>
))
) : (
<div className="text-center py-8 text-slate-500">
<p>No team members available</p>
</div>
)}
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -8,8 +8,10 @@ import { useToast } from "@/components/ui/use-toast";
import { base44 } from "@/api/base44Client"; import { base44 } from "@/api/base44Client";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { import {
Printer, Flag, CheckCircle, MoreVertical, FileText Printer, Flag, CheckCircle, MoreVertical, FileText, Edit, CreditCard
} from "lucide-react"; } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
import { format, parseISO } from "date-fns"; import { format, parseISO } from "date-fns";
import { import {
DropdownMenu, DropdownMenu,
@@ -41,6 +43,7 @@ const statusColors = {
export default function InvoiceDetailView({ invoice, userRole, onClose }) { export default function InvoiceDetailView({ invoice, userRole, onClose }) {
const { toast } = useToast(); const { toast } = useToast();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const navigate = useNavigate();
const [showDisputeDialog, setShowDisputeDialog] = useState(false); const [showDisputeDialog, setShowDisputeDialog] = useState(false);
const [disputeReason, setDisputeReason] = useState(""); const [disputeReason, setDisputeReason] = useState("");
const [disputeDetails, setDisputeDetails] = 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 handleDispute = async () => {
const user = await base44.auth.me(); const user = await base44.auth.me();
updateInvoiceMutation.mutate({ updateInvoiceMutation.mutate({
@@ -104,7 +124,11 @@ export default function InvoiceDetailView({ invoice, userRole, onClose }) {
if (!invoice) return null; if (!invoice) return null;
const isClient = userRole === "client"; 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 canApprove = isClient && invoice.status === "Pending Review";
const canPay = isClient && invoice.status === "Approved";
const canDispute = isClient && ["Pending Review", "Approved"].includes(invoice.status); const canDispute = isClient && ["Pending Review", "Approved"].includes(invoice.status);
return ( return (
@@ -127,6 +151,12 @@ export default function InvoiceDetailView({ invoice, userRole, onClose }) {
<Printer className="w-4 h-4 mr-2" /> <Printer className="w-4 h-4 mr-2" />
Print Print
</Button> </Button>
{canEdit && (
<Button variant="outline" className="text-blue-600 border-blue-200 hover:bg-blue-50" onClick={handleEditInvoice}>
<Edit className="w-4 h-4 mr-2" />
Edit Invoice
</Button>
)}
{canDispute && ( {canDispute && (
<Button variant="outline" className="text-red-600 border-red-200 hover:bg-red-50" onClick={() => setShowDisputeDialog(true)}> <Button variant="outline" className="text-red-600 border-red-200 hover:bg-red-50" onClick={() => setShowDisputeDialog(true)}>
<Flag className="w-4 h-4 mr-2" /> <Flag className="w-4 h-4 mr-2" />
@@ -136,7 +166,13 @@ export default function InvoiceDetailView({ invoice, userRole, onClose }) {
{canApprove && ( {canApprove && (
<Button className="bg-green-600 hover:bg-green-700" onClick={handleApprove}> <Button className="bg-green-600 hover:bg-green-700" onClick={handleApprove}>
<CheckCircle className="w-4 h-4 mr-2" /> <CheckCircle className="w-4 h-4 mr-2" />
Accept Invoice Approve Invoice
</Button>
)}
{canPay && (
<Button className="bg-[#0A39DF] hover:bg-[#0831b8]" onClick={handleMarkPaid}>
<CreditCard className="w-4 h-4 mr-2" />
Mark as Paid
</Button> </Button>
)} )}
</div> </div>
@@ -177,11 +213,32 @@ export default function InvoiceDetailView({ invoice, userRole, onClose }) {
</div> </div>
<h3 className="font-bold text-lg text-slate-900">From:</h3> <h3 className="font-bold text-lg text-slate-900">From:</h3>
</div> </div>
<div className="space-y-1 text-sm"> <div className="space-y-2">
<p className="font-bold text-slate-900">{invoice.from_company?.name || invoice.vendor_name}</p> <p className="font-bold text-lg text-slate-900">{invoice.from_company?.name || invoice.vendor_name || "Vendor Name"}</p>
<p className="text-slate-600">{invoice.from_company?.address}</p> {(invoice.from_company?.address || invoice.vendor_address) && (
<p className="text-slate-600">{invoice.from_company?.email}</p> <div className="text-sm text-slate-700">
<p className="text-slate-600">{invoice.from_company?.phone}</p> <p className="font-semibold text-slate-500 text-xs mb-1">Address</p>
<p>{invoice.from_company?.address || invoice.vendor_address}</p>
</div>
)}
{(invoice.from_company?.email || invoice.vendor_email) && (
<div className="text-sm text-slate-700">
<p className="font-semibold text-slate-500 text-xs mb-1">Email</p>
<p>{invoice.from_company?.email || invoice.vendor_email}</p>
</div>
)}
{(invoice.from_company?.phone || invoice.vendor_phone) && (
<div className="text-sm text-slate-700">
<p className="font-semibold text-slate-500 text-xs mb-1">Phone</p>
<p>{invoice.from_company?.phone || invoice.vendor_phone}</p>
</div>
)}
{(invoice.from_company?.contact || invoice.vendor_contact) && (
<div className="text-sm text-slate-700">
<p className="font-semibold text-slate-500 text-xs mb-1">Point of Contact</p>
<p>{invoice.from_company?.contact || invoice.vendor_contact}</p>
</div>
)}
</div> </div>
</div> </div>

View File

@@ -0,0 +1,316 @@
import React, { useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Download, FileText, FileSpreadsheet, Code, Send, Check, Loader2, Building2, Link2 } from "lucide-react";
const ERP_SYSTEMS = {
"SAP Ariba": { format: "cXML", color: "bg-blue-100 text-blue-700" },
"Fieldglass": { format: "CSV", color: "bg-purple-100 text-purple-700" },
"CrunchTime": { format: "JSON", color: "bg-orange-100 text-orange-700" },
"Coupa": { format: "cXML", color: "bg-teal-100 text-teal-700" },
"Oracle NetSuite": { format: "CSV", color: "bg-red-100 text-red-700" },
"Workday": { format: "JSON", color: "bg-green-100 text-green-700" },
};
export default function InvoiceExportPanel({ invoice, business, onExport }) {
const [exportFormat, setExportFormat] = useState(business?.edi_format || "CSV");
const [isExporting, setIsExporting] = useState(false);
const [exportSuccess, setExportSuccess] = useState(false);
const erpSystem = business?.erp_system || "None";
const erpInfo = ERP_SYSTEMS[erpSystem];
const generateEDI810 = () => {
// EDI 810 Invoice format
const segments = [
`ISA*00* *00* *ZZ*KROW *ZZ*${business?.erp_vendor_id || 'CLIENT'} *${new Date().toISOString().slice(2,10).replace(/-/g,'')}*${new Date().toTimeString().slice(0,5).replace(':','')}*U*00401*000000001*0*P*>~`,
`GS*IN*KROW*${business?.business_name?.substring(0,15) || 'CLIENT'}*${new Date().toISOString().slice(0,10).replace(/-/g,'')}*${new Date().toTimeString().slice(0,4).replace(':','')}*1*X*004010~`,
`ST*810*0001~`,
`BIG*${invoice.issue_date?.replace(/-/g,'')}*${invoice.invoice_number}*${invoice.event_date?.replace(/-/g,'')}*${invoice.po_reference || ''}~`,
`N1*BT*${business?.business_name || invoice.business_name}~`,
`N1*ST*${invoice.hub || business?.hub_building || ''}~`,
`ITD*01*3*****${invoice.due_date?.replace(/-/g,'')}~`,
`TDS*${Math.round((invoice.amount || 0) * 100)}~`,
`SE*8*0001~`,
`GE*1*1~`,
`IEA*1*000000001~`
];
return segments.join('\n');
};
const generateCXML = () => {
return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE cXML SYSTEM "http://xml.cxml.org/schemas/cXML/1.2.014/InvoiceDetail.dtd">
<cXML payloadID="${invoice.invoice_number}@krow.com" timestamp="${new Date().toISOString()}">
<Header>
<From><Credential domain="DUNS"><Identity>KROW</Identity></Credential></From>
<To><Credential domain="DUNS"><Identity>${business?.erp_vendor_id || 'CLIENT'}</Identity></Credential></To>
<Sender><Credential domain="DUNS"><Identity>KROW</Identity></Credential></Sender>
</Header>
<Request>
<InvoiceDetailRequest>
<InvoiceDetailRequestHeader invoiceID="${invoice.invoice_number}" invoiceDate="${invoice.issue_date}" purpose="standard">
<InvoiceDetailHeaderIndicator/>
<InvoicePartner><Contact role="billTo"><Name>${invoice.business_name}</Name></Contact></InvoicePartner>
<PaymentTerm payInNumberOfDays="30"/>
</InvoiceDetailRequestHeader>
<InvoiceDetailOrder>
<InvoiceDetailOrderInfo><OrderReference orderID="${invoice.po_reference || ''}"/></InvoiceDetailOrderInfo>
<InvoiceDetailItem invoiceLineNumber="1" quantity="1">
<UnitOfMeasure>EA</UnitOfMeasure>
<UnitPrice><Money currency="USD">${invoice.amount}</Money></UnitPrice>
<InvoiceDetailItemReference lineNumber="1"><Description>${invoice.event_name}</Description></InvoiceDetailItemReference>
</InvoiceDetailItem>
</InvoiceDetailOrder>
<InvoiceDetailSummary>
<SubtotalAmount><Money currency="USD">${invoice.subtotal || invoice.amount}</Money></SubtotalAmount>
<Tax><Money currency="USD">${invoice.tax_amount || 0}</Money></Tax>
<GrossAmount><Money currency="USD">${invoice.amount}</Money></GrossAmount>
<InvoiceDetailDiscount/>
<NetAmount><Money currency="USD">${invoice.amount}</Money></NetAmount>
<DueAmount><Money currency="USD">${invoice.amount}</Money></DueAmount>
</InvoiceDetailSummary>
</InvoiceDetailRequest>
</Request>
</cXML>`;
};
const generateCSV = () => {
const headers = [
"Invoice Number", "Invoice Date", "Due Date", "PO Number", "Vendor ID",
"Client Name", "Hub", "Event Name", "Cost Center", "Subtotal", "Tax", "Total Amount", "Status"
];
const row = [
invoice.invoice_number,
invoice.issue_date,
invoice.due_date,
invoice.po_reference || "",
business?.erp_vendor_id || "",
invoice.business_name,
invoice.hub || "",
invoice.event_name,
business?.erp_cost_center || "",
invoice.subtotal || invoice.amount,
invoice.tax_amount || 0,
invoice.amount,
invoice.status
];
// Add line items if available
let lineItems = "\n\nLine Item Details\nRole,Staff Name,Date,Hours,Rate,Amount\n";
if (invoice.roles) {
invoice.roles.forEach(role => {
role.staff_entries?.forEach(entry => {
lineItems += `${role.role_name},${entry.staff_name},${entry.date},${entry.worked_hours},${entry.rate},${entry.total}\n`;
});
});
}
return headers.join(",") + "\n" + row.join(",") + lineItems;
};
const generateJSON = () => {
return JSON.stringify({
invoice: {
invoice_number: invoice.invoice_number,
issue_date: invoice.issue_date,
due_date: invoice.due_date,
po_reference: invoice.po_reference,
vendor: {
id: business?.erp_vendor_id,
name: "KROW Workforce"
},
client: {
name: invoice.business_name,
hub: invoice.hub,
cost_center: business?.erp_cost_center
},
event: {
name: invoice.event_name,
date: invoice.event_date
},
amounts: {
subtotal: invoice.subtotal || invoice.amount,
tax: invoice.tax_amount || 0,
total: invoice.amount
},
line_items: invoice.roles?.flatMap(role =>
role.staff_entries?.map(entry => ({
role: role.role_name,
staff_name: entry.staff_name,
date: entry.date,
hours: entry.worked_hours,
rate: entry.rate,
amount: entry.total
})) || []
) || [],
status: invoice.status
}
}, null, 2);
};
const handleExport = async (format) => {
setIsExporting(true);
let content, filename, mimeType;
switch (format) {
case "EDI 810":
content = generateEDI810();
filename = `${invoice.invoice_number}_EDI810.edi`;
mimeType = "text/plain";
break;
case "cXML":
content = generateCXML();
filename = `${invoice.invoice_number}.xml`;
mimeType = "application/xml";
break;
case "JSON":
content = generateJSON();
filename = `${invoice.invoice_number}.json`;
mimeType = "application/json";
break;
case "CSV":
default:
content = generateCSV();
filename = `${invoice.invoice_number}.csv`;
mimeType = "text/csv";
break;
}
// Create and download file
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
setIsExporting(false);
setExportSuccess(true);
setTimeout(() => setExportSuccess(false), 3000);
if (onExport) onExport(format);
};
return (
<Card className="border-slate-200">
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2">
<Link2 className="w-5 h-5 text-blue-600" />
ERP / EDI Export
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* ERP System Info */}
{erpSystem !== "None" && (
<div className="flex items-center justify-between p-3 bg-slate-50 rounded-lg">
<div className="flex items-center gap-3">
<Building2 className="w-5 h-5 text-slate-500" />
<div>
<p className="text-sm font-medium text-slate-900">Connected ERP</p>
<p className="text-xs text-slate-500">Vendor ID: {business?.erp_vendor_id || "Not configured"}</p>
</div>
</div>
<Badge className={erpInfo?.color || "bg-slate-100 text-slate-700"}>
{erpSystem}
</Badge>
</div>
)}
{/* Export Format Selection */}
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700">Export Format</label>
<Select value={exportFormat} onValueChange={setExportFormat}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="CSV">
<div className="flex items-center gap-2">
<FileSpreadsheet className="w-4 h-4" />
CSV (Excel Compatible)
</div>
</SelectItem>
<SelectItem value="EDI 810">
<div className="flex items-center gap-2">
<FileText className="w-4 h-4" />
EDI 810 (Standard Invoice)
</div>
</SelectItem>
<SelectItem value="cXML">
<div className="flex items-center gap-2">
<Code className="w-4 h-4" />
cXML (Ariba/Coupa)
</div>
</SelectItem>
<SelectItem value="JSON">
<div className="flex items-center gap-2">
<Code className="w-4 h-4" />
JSON (API Format)
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
{/* Export Buttons */}
<div className="flex gap-2">
<Button
onClick={() => handleExport(exportFormat)}
disabled={isExporting}
className="flex-1 bg-blue-600 hover:bg-blue-700"
>
{isExporting ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : exportSuccess ? (
<Check className="w-4 h-4 mr-2" />
) : (
<Download className="w-4 h-4 mr-2" />
)}
{exportSuccess ? "Exported!" : "Download"}
</Button>
{business?.invoice_email && (
<Button
variant="outline"
onClick={() => {/* Send via email logic */}}
className="border-slate-300"
>
<Send className="w-4 h-4 mr-2" />
Send
</Button>
)}
</div>
{/* Quick Export Buttons */}
<div className="pt-2 border-t border-slate-200">
<p className="text-xs text-slate-500 mb-2">Quick Export</p>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={() => handleExport("CSV")} className="text-xs">
<FileSpreadsheet className="w-3 h-3 mr-1" />
CSV
</Button>
<Button variant="outline" size="sm" onClick={() => handleExport("EDI 810")} className="text-xs">
<FileText className="w-3 h-3 mr-1" />
EDI
</Button>
<Button variant="outline" size="sm" onClick={() => handleExport("cXML")} className="text-xs">
<Code className="w-3 h-3 mr-1" />
cXML
</Button>
<Button variant="outline" size="sm" onClick={() => handleExport("JSON")} className="text-xs">
<Code className="w-3 h-3 mr-1" />
JSON
</Button>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -3,7 +3,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; 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 { format, parseISO, isValid } from "date-fns";
import { base44 } from "@/api/base44Client"; import { base44 } from "@/api/base44Client";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
@@ -184,6 +184,32 @@ export default function OrderDetailModal({ open, onClose, order, onCancel }) {
</div> </div>
</div> </div>
{/* Event Location & POC */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-slate-50 rounded-lg p-4">
<div className="flex items-center gap-2 mb-2">
<MapPin className="w-4 h-4 text-emerald-600" />
<p className="text-xs text-slate-500 font-semibold">Event Location</p>
</div>
<p className="font-bold text-slate-900">{order.event_location || order.hub || "—"}</p>
</div>
<div className="bg-slate-50 rounded-lg p-4">
<div className="flex items-center gap-2 mb-2">
<User className="w-4 h-4 text-indigo-600" />
<p className="text-xs text-slate-500 font-semibold">Point of Contact</p>
</div>
<div>
<p className="font-bold text-slate-900">{order.client_name || order.manager_name || "—"}</p>
{order.client_phone && (
<p className="text-xs text-slate-500 mt-1">{order.client_phone}</p>
)}
{order.client_email && (
<p className="text-xs text-slate-500">{order.client_email}</p>
)}
</div>
</div>
</div>
{/* Shifts & Roles */} {/* Shifts & Roles */}
{order.shifts && order.shifts.length > 0 && ( {order.shifts && order.shifts.length > 0 && (
<div> <div>

View File

@@ -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 (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-3 text-xl">
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-xl flex items-center justify-center">
<Sparkles className="w-5 h-5 text-white" />
</div>
{editingCard ? "Edit Rate Card" : "Create Custom Rate Card"}
</DialogTitle>
<p className="text-sm text-slate-500 mt-1">
Build a custom rate card by selecting an approved rate book as your base and applying your competitive discount
</p>
</DialogHeader>
<div className="flex-1 overflow-y-auto space-y-6 py-4">
{/* Step 1: Name */}
<div className="bg-slate-50 rounded-xl p-5 border border-slate-200">
<div className="flex items-center gap-2 mb-3">
<div className="w-6 h-6 bg-green-500 rounded-full flex items-center justify-center text-white text-xs font-bold">1</div>
<h3 className="font-semibold text-slate-900">
{editingCard ? "Rename Rate Card" : "Name Your Rate Card"}
</h3>
</div>
<Input
value={cardName}
onChange={(e) => 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() && (
<p className="text-sm text-green-600 mt-2 flex items-center gap-1">
<span></span> Will rename from "{editingCard.name}" to "{cardName}"
</p>
)}
</div>
{/* Step 2: Base Rates */}
<div className="bg-amber-50 rounded-xl p-5 border border-amber-200">
<div className="flex items-center gap-2 mb-3">
<div className="w-6 h-6 bg-amber-500 rounded-full flex items-center justify-center text-white text-xs font-bold">2</div>
<h3 className="font-semibold text-slate-900">Choose Approved Base Rates</h3>
</div>
<Select value={baseRateBook} onValueChange={setBaseRateBook}>
<SelectTrigger className="bg-white">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="FoodBuy">FoodBuy Approved Rates</SelectItem>
<SelectItem value="Aramark">Aramark Approved Rates</SelectItem>
</SelectContent>
</Select>
<p className="text-sm text-amber-700 mt-2">
Using {baseRateBook} as your baseline Avg Rate: ${stats.avgBase.toFixed(2)}/hr
</p>
</div>
{/* Step 3: Discount */}
<div className="bg-blue-50 rounded-xl p-5 border border-blue-200">
<div className="flex items-center gap-2 mb-4">
<div className="w-6 h-6 bg-blue-500 rounded-full flex items-center justify-center text-white text-xs font-bold">3</div>
<h3 className="font-semibold text-slate-900">Apply Your Discount</h3>
</div>
<div className="flex items-center gap-6">
<div className="flex-1">
<Slider
value={[discountPercent]}
onValueChange={([val]) => setDiscountPercent(val)}
min={0}
max={25}
step={0.5}
className="[&_[role=slider]]:bg-green-500 [&_[role=slider]]:border-green-600"
/>
</div>
<div className="px-6 py-3 bg-white border-2 border-green-400 rounded-xl text-center min-w-[100px]">
<span className="text-2xl font-bold text-green-600">% {discountPercent.toFixed(1)}%</span>
</div>
</div>
<div className="flex items-center gap-4 mt-4 text-sm">
<span className="text-slate-600">
<span className="text-slate-400"></span> New Avg Rate: <strong className="text-slate-900">${stats.avgNew.toFixed(2)}/hr</strong>
</span>
<span className="text-slate-400"></span>
<span className="text-slate-600">
Total Savings: <strong className="text-green-600">${stats.totalSavings.toFixed(2)}/position</strong>
</span>
</div>
</div>
{/* Rate Preview */}
<div className="border border-slate-200 rounded-xl overflow-hidden">
<div className="bg-slate-50 px-5 py-3 border-b border-slate-200 flex items-center gap-2">
<DollarSign className="w-4 h-4 text-slate-600" />
<h3 className="font-semibold text-slate-900">Rate Preview ({stats.count} positions)</h3>
</div>
<div className="max-h-[250px] overflow-y-auto">
<table className="w-full text-sm">
<thead className="bg-slate-50 sticky top-0">
<tr className="text-left text-slate-600">
<th className="px-5 py-2 font-medium">Position</th>
<th className="px-5 py-2 font-medium text-right">Base Rate</th>
<th className="px-5 py-2 font-medium text-right">Your Rate</th>
<th className="px-5 py-2 font-medium text-right">Savings</th>
</tr>
</thead>
<tbody>
{positions.map((position) => {
const baseRate = baseRates[position];
const newRate = baseRate * (1 - discountPercent / 100);
const savings = baseRate - newRate;
return (
<tr key={position} className="border-t border-slate-100 hover:bg-slate-50">
<td className="px-5 py-3 font-medium text-slate-900">{position}</td>
<td className="px-5 py-3 text-right text-slate-500">${baseRate.toFixed(2)}</td>
<td className="px-5 py-3 text-right font-semibold text-green-600">${newRate.toFixed(2)}</td>
<td className="px-5 py-3 text-right">
<span className="px-2 py-1 bg-green-100 text-green-700 rounded-md text-xs font-medium">
-${savings.toFixed(2)}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</div>
{/* Footer */}
<div className="flex justify-end gap-3 pt-4 border-t border-slate-200">
<Button variant="outline" onClick={onClose}>Cancel</Button>
<Button
onClick={handleSave}
disabled={!cardName.trim()}
className="bg-gradient-to-r from-purple-500 to-indigo-600 hover:from-purple-600 hover:to-indigo-700 text-white"
>
<Sparkles className="w-4 h-4 mr-2" />
{editingCard ? "Save Changes" : "Create Rate Card"}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -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 (
<Card key={staff.id} className="border hover:shadow-md transition-shadow">
<CardContent className="p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3 flex-1">
<Avatar className="w-10 h-10">
<AvatarFallback className="bg-blue-500 text-white font-bold text-sm">
{staff.employee_name?.charAt(0)?.toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="font-semibold text-sm text-slate-900 truncate">{staff.employee_name}</p>
<p className="text-xs text-slate-500">{staff.position || 'Staff'}</p>
<div className="flex gap-1 mt-2 flex-wrap">
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
{matchScore}% match
</Badge>
{availability?.scheduled_hours_this_period > 0 && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
{availability.scheduled_hours_this_period}h scheduled
</Badge>
)}
{bucket === 'needs' && (
<Badge className="bg-orange-500 text-white text-[10px] px-1.5 py-0">
Needs work
</Badge>
)}
{bucket === 'likely' && (
<Badge className="bg-blue-500 text-white text-[10px] px-1.5 py-0">
{availability?.predicted_availability_score}% likely
</Badge>
)}
</div>
</div>
</div>
<Button
size="sm"
onClick={() => onAssign(staff, bucket)}
className="flex-shrink-0"
>
Assign
</Button>
</div>
</CardContent>
</Card>
);
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-bold text-slate-900">Talent Radar</h3>
<p className="text-sm text-slate-500">Smart worker recommendations for this shift</p>
</div>
</div>
<Tabs defaultValue="ready" className="space-y-4">
<TabsList>
<TabsTrigger value="ready" className="flex items-center gap-2">
<CheckCircle className="w-4 h-4" />
Ready Now ({buckets.readyNow.length})
</TabsTrigger>
<TabsTrigger value="likely" className="flex items-center gap-2">
<TrendingUp className="w-4 h-4" />
Likely Available ({buckets.likelyAvailable.length})
</TabsTrigger>
<TabsTrigger value="needs" className="flex items-center gap-2">
<AlertCircle className="w-4 h-4" />
Needs Work ({buckets.needsWork.length})
</TabsTrigger>
</TabsList>
<TabsContent value="ready" className="space-y-3">
<Card className="bg-green-50 border-green-200">
<CardContent className="p-4">
<p className="text-sm text-green-800">
<strong>Ready Now:</strong> These workers have confirmed their availability and are the best match for this shift.
</p>
</CardContent>
</Card>
{buckets.readyNow.length === 0 ? (
<p className="text-sm text-slate-500 text-center py-8">No workers confirmed available</p>
) : (
<div className="grid gap-3">
{buckets.readyNow.map(item => renderWorkerCard(item, 'ready'))}
</div>
)}
</TabsContent>
<TabsContent value="likely" className="space-y-3">
<Card className="bg-blue-50 border-blue-200">
<CardContent className="p-4">
<p className="text-sm text-blue-800">
<strong>Likely Available:</strong> These workers haven't confirmed availability but historically accept similar shifts. Assignment requires worker confirmation.
</p>
</CardContent>
</Card>
{buckets.likelyAvailable.length === 0 ? (
<p className="text-sm text-slate-500 text-center py-8">No predictions available</p>
) : (
<div className="grid gap-3">
{buckets.likelyAvailable.map(item => renderWorkerCard(item, 'likely'))}
</div>
)}
</TabsContent>
<TabsContent value="needs" className="space-y-3">
<Card className="bg-orange-50 border-orange-200">
<CardContent className="p-4">
<p className="text-sm text-orange-800">
<strong>Needs Work:</strong> These workers are under-scheduled and could benefit from additional hours. They may accept even if not explicitly available.
</p>
</CardContent>
</Card>
{buckets.needsWork.length === 0 ? (
<p className="text-sm text-slate-500 text-center py-8">No under-utilized workers</p>
) : (
<div className="grid gap-3">
{buckets.needsWork.map(item => renderWorkerCard(item, 'needs'))}
</div>
)}
</TabsContent>
</Tabs>
</div>
);
}
// 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));
}

View File

@@ -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);

View File

@@ -0,0 +1,6 @@
import { clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs) {
return twMerge(clsx(inputs))
}

View File

@@ -1,4 +1,3 @@
import React, { useState, useMemo } from "react"; import React, { useState, useMemo } from "react";
import { base44 } from "@/api/base44Client"; import { base44 } from "@/api/base44Client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; 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 { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; 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 { Link, useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils"; import { createPageUrl } from "@/utils";
import PageHeader from "@/components/common/PageHeader"; import PageHeader from "@/components/common/PageHeader";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import CreateBusinessModal from "@/components/business/CreateBusinessModal"; import CreateBusinessModal from "@/components/business/CreateBusinessModal";
export default function Business() { export default function Business() {
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState("active");
const [expandedCompanies, setExpandedCompanies] = useState({});
const [createModalOpen, setCreateModalOpen] = useState(false); 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({ const { data: user } = useQuery({
queryKey: ['current-user-business'], queryKey: ['current-user-business'],
@@ -37,6 +36,23 @@ export default function Business() {
initialData: [], 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 userRole = user?.user_role || user?.role || "admin";
const isVendor = userRole === "vendor"; 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) => { const handleCreateBusiness = (businessData) => {
createBusinessMutation.mutate(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 // Consolidate businesses by company name
const consolidatedBusinesses = useMemo(() => { const consolidatedBusinesses = useMemo(() => {
const grouped = {}; const grouped = {};
@@ -94,6 +197,10 @@ export default function Business() {
return Object.values(grouped); return Object.values(grouped);
}, [businesses]); }, [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 filteredBusinesses = consolidatedBusinesses.filter(company => {
const matchesSearch = !searchTerm || const matchesSearch = !searchTerm ||
company.company_name?.toLowerCase().includes(searchTerm.toLowerCase()) || company.company_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
@@ -103,31 +210,123 @@ export default function Business() {
hub.address?.toLowerCase().includes(searchTerm.toLowerCase()) 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 canAddBusiness = ["admin", "procurement", "operator", "vendor"].includes(userRole);
const totalHubs = filteredBusinesses.reduce((sum, company) => sum + company.hubs.length, 0); 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 ( return (
<div className="p-4 md:p-8 bg-slate-50 min-h-screen"> <div className="p-4 md:p-8 bg-slate-50 min-h-screen">
<div className="max-w-7xl mx-auto"> <div className="max-w-7xl mx-auto">
<PageHeader <PageHeader
title="Business Directory" title="Business Directory"
subtitle={`${filteredBusinesses.length} ${filteredBusinesses.length === 1 ? 'company' : 'companies'}${totalHubs} total hubs`} subtitle="Manage and monitor all business clients and their performance"
actions={ actions={
canAddBusiness ? ( canAddBusiness ? (
<Button <Button
onClick={() => setCreateModalOpen(true)} onClick={() => 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"
> >
<Plus className="w-5 h-5 mr-2" /> <Plus className="w-5 h-5 mr-2" />
Add Business Add Business
@@ -136,179 +335,360 @@ export default function Business() {
} }
/> />
{/* Status Tabs */} {/* KPI Dashboard */}
<Tabs value={statusFilter} onValueChange={setStatusFilter} className="mb-6"> <div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<TabsList className="bg-white border border-slate-200"> <Card className="bg-gradient-to-br from-blue-600 to-blue-700 border-0 shadow-lg">
<TabsTrigger value="active" className="data-[state=active]:bg-[#0A39DF] data-[state=active]:text-white"> <CardContent className="p-6">
Active <div className="flex items-center justify-between">
<Badge variant="secondary" className="ml-2 bg-slate-100 text-slate-700"> <div>
{filteredBusinesses.length} <p className="text-blue-100 text-sm font-medium mb-1">Total Companies</p>
</Badge> <p className="text-4xl font-bold text-white">{totalCompanies}</p>
</TabsTrigger> </div>
<TabsTrigger value="pending"> <div className="w-14 h-14 bg-white/20 rounded-xl flex items-center justify-center">
Pending <Building2 className="w-8 h-8 text-white" />
<Badge variant="secondary" className="ml-2 bg-slate-100 text-slate-700"> </div>
0 </div>
</Badge> </CardContent>
</TabsTrigger> </Card>
<TabsTrigger value="deactivated">
Deactivated
<Badge variant="secondary" className="ml-2 bg-slate-100 text-slate-700">
0
</Badge>
</TabsTrigger>
</TabsList>
</Tabs>
{/* Search Bar */} <Card className="bg-gradient-to-br from-amber-500 to-yellow-600 border-0 shadow-lg">
<div className="mb-6"> <CardContent className="p-6">
<div className="relative"> <div className="flex items-center justify-between">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-slate-400" /> <div>
<Input <p className="text-amber-100 text-sm font-medium mb-1">Gold Clients</p>
placeholder="Search companies, hubs, or contacts..." <p className="text-4xl font-bold text-white">{goldClients}</p>
value={searchTerm} <p className="text-xs text-amber-100 mt-1">A+ Performance</p>
onChange={(e) => setSearchTerm(e.target.value)} </div>
className="pl-10 bg-white border-slate-200" <div className="w-14 h-14 bg-white/20 rounded-xl flex items-center justify-center">
/> <Award className="w-8 h-8 text-white" />
</div> </div>
</div>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-green-600 to-emerald-700 border-0 shadow-lg">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-green-100 text-sm font-medium mb-1">Monthly Sales</p>
<p className="text-4xl font-bold text-white">${(totalMonthlySpend / 1000).toFixed(0)}k</p>
</div>
<div className="w-14 h-14 bg-white/20 rounded-xl flex items-center justify-center">
<TrendingUp className="w-8 h-8 text-white" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-slate-700 to-slate-800 border-0 shadow-lg">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-100 text-sm font-medium mb-1">Total Orders</p>
<p className="text-4xl font-bold text-white">{totalOrders}</p>
<p className="text-xs text-slate-300 mt-1">{totalHubs} Hubs</p>
</div>
<div className="w-14 h-14 bg-white/20 rounded-xl flex items-center justify-center">
<Briefcase className="w-8 h-8 text-white" />
</div>
</div>
</CardContent>
</Card>
</div> </div>
{/* Consolidated Business List */} {/* Filters Section */}
{filteredBusinesses.length > 0 ? ( <div className="mb-6 space-y-4">
<div className="space-y-4"> {/* Search Bar */}
{filteredBusinesses.map((company) => ( <div className="relative">
<Card key={company.company_name} className="border-slate-200 shadow-sm overflow-hidden"> <Search className="absolute left-4 top-1/2 transform -translate-y-1/2 w-5 h-5 text-slate-400" />
<Collapsible <Input
open={expandedCompanies[company.company_name]} placeholder="Search companies, hubs, contacts, or addresses..."
onOpenChange={() => toggleCompany(company.company_name)} value={searchTerm}
> onChange={(e) => setSearchTerm(e.target.value)}
<div className="bg-gradient-to-r from-slate-50 to-white border-b border-slate-200"> className="pl-12 h-12 bg-white border-slate-300 text-base shadow-sm"
<CollapsibleTrigger className="w-full"> />
<div className="p-6 flex items-center justify-between hover:bg-slate-50/50 transition-colors"> </div>
<div className="flex items-center gap-4">
{expandedCompanies[company.company_name] ? (
<ChevronDown className="w-5 h-5 text-slate-400" />
) : (
<ChevronRight className="w-5 h-5 text-slate-400" />
)}
<div className="w-12 h-12 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl flex items-center justify-center text-white font-bold text-xl">
{company.company_name?.charAt(0) || 'B'}
</div>
<div className="text-left">
<h3 className="text-xl font-bold text-[#1C323E]">{company.company_name}</h3>
<div className="flex items-center gap-4 mt-1 text-sm text-slate-500">
<span className="flex items-center gap-1">
<Building2 className="w-4 h-4" />
{company.hubs.length} {company.hubs.length === 1 ? 'hub' : 'hubs'}
</span>
{company.sector && (
<Badge variant="outline" className="text-xs">
{company.sector}
</Badge>
)}
</div>
</div>
</div>
<div className="text-right">
<div className="text-sm font-medium text-slate-700">
{company.primary_contact || '—'}
</div>
{company.primary_email && (
<div className="text-xs text-slate-500 flex items-center justify-end gap-1 mt-1">
<Mail className="w-3 h-3" />
{company.primary_email}
</div>
)}
{company.primary_phone && (
<div className="text-xs text-slate-500 flex items-center justify-end gap-1 mt-1">
<Phone className="w-3 h-3" />
{company.primary_phone}
</div>
)}
</div>
</div>
</CollapsibleTrigger>
</div>
<CollapsibleContent> {/* Filter Pills */}
<div className="bg-white"> <div className="flex flex-wrap gap-3">
<table className="w-full"> <select
<thead className="bg-slate-50 border-b border-slate-200"> value={filterManager}
<tr> onChange={(e) => setFilterManager(e.target.value)}
<th className="text-left py-3 px-6 font-semibold text-xs text-slate-600">Hub Name</th> className="px-4 py-2 bg-white border border-slate-300 rounded-lg text-sm font-medium text-slate-700 hover:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
<th className="text-left py-3 px-6 font-semibold text-xs text-slate-600">Contact</th> >
<th className="text-left py-3 px-6 font-semibold text-xs text-slate-600">Address</th> <option value="all">All Managers</option>
<th className="text-left py-3 px-6 font-semibold text-xs text-slate-600">City</th> {allManagers.map(manager => (
<th className="text-center py-3 px-6 font-semibold text-xs text-slate-600">Actions</th> <option key={manager} value={manager}>{manager}</option>
</tr> ))}
</thead> </select>
<tbody>
{company.hubs.map((hub, idx) => ( <select
<tr value={filterHub}
key={hub.id} onChange={(e) => setFilterHub(e.target.value)}
className={`border-b border-slate-100 hover:bg-slate-50 transition-colors ${ className="px-4 py-2 bg-white border border-slate-300 rounded-lg text-sm font-medium text-slate-700 hover:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
idx % 2 === 0 ? 'bg-white' : 'bg-slate-50/30' >
}`} <option value="all">All Hubs</option>
> {allHubs.map(hub => (
<td className="py-4 px-6"> <option key={hub} value={hub}>{hub}</option>
<div className="flex items-center gap-2"> ))}
<MapPin className="w-4 h-4 text-blue-500" /> </select>
<span className="font-medium text-sm text-slate-900">{hub.hub_name}</span>
</div> {isVendor && (
</td> <>
<td className="py-4 px-6"> <select
<div className="text-sm"> value={filterGrade}
<p className="font-medium text-slate-900">{hub.contact_name || '—'}</p> onChange={(e) => setFilterGrade(e.target.value)}
{hub.email && ( className="px-4 py-2 bg-white border border-slate-300 rounded-lg text-sm font-medium text-slate-700 hover:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
<p className="text-xs text-slate-500 flex items-center gap-1 mt-0.5"> >
<Mail className="w-3 h-3" /> <option value="all">All Grades</option>
{hub.email} <option value="A+">A+ Grade</option>
</p> <option value="A">A Grade</option>
)} <option value="A-">A- Grade</option>
{hub.phone && ( <option value="B+">B+ Grade</option>
<p className="text-xs text-slate-500 flex items-center gap-1 mt-0.5"> <option value="B">B Grade</option>
<Phone className="w-3 h-3" /> <option value="C">C Grade</option>
{hub.phone} </select>
</p>
)} <select
</div> value={filterCancelRate}
</td> onChange={(e) => setFilterCancelRate(e.target.value)}
<td className="py-4 px-6"> className="px-4 py-2 bg-white border border-slate-300 rounded-lg text-sm font-medium text-slate-700 hover:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
<p className="text-sm text-slate-600">{hub.address || '—'}</p> >
</td> <option value="all">All Cancellation Rates</option>
<td className="py-4 px-6"> <option value="low">Low (&lt;5%)</option>
<p className="text-sm text-slate-600">{hub.city || '—'}</p> <option value="medium">Medium (5-15%)</option>
</td> <option value="high">High (&gt;15%)</option>
<td className="py-4 px-6"> </select>
<div className="flex items-center justify-center gap-2"> </>
<Button )}
variant="ghost"
size="icon" {(searchTerm || filterManager !== "all" || filterHub !== "all" || filterGrade !== "all" || filterCancelRate !== "all") && (
onClick={() => navigate(createPageUrl(`EditBusiness?id=${hub.id}`))} <Button
className="text-slate-400 hover:text-blue-600 hover:bg-blue-50 h-8 w-8" variant="ghost"
title="View/Edit Hub" size="sm"
> onClick={() => {
<Eye className="w-4 h-4" /> setSearchTerm("");
</Button> setFilterManager("all");
<Button setFilterHub("all");
variant="ghost" setFilterGrade("all");
size="icon" setFilterCancelRate("all");
className="text-slate-400 hover:text-red-600 hover:bg-red-50 h-8 w-8" }}
title="Delete Hub" className="text-blue-600 hover:text-blue-700 hover:bg-blue-50"
> >
<Trash2 className="w-4 h-4" /> Clear Filters
</Button> </Button>
</div> )}
</td> </div>
</tr>
))} {/* View Mode Toggle */}
</tbody> <div className="flex items-center gap-2 bg-white border border-slate-200 rounded-lg p-1">
</table> <Button
</div> variant={viewMode === "grid" ? "default" : "ghost"}
</CollapsibleContent> size="sm"
</Collapsible> onClick={() => setViewMode("grid")}
</Card> className={viewMode === "grid" ? "bg-blue-600 text-white" : "text-slate-600"}
))} >
<Grid3x3 className="w-4 h-4 mr-1" />
Grid
</Button>
<Button
variant={viewMode === "list" ? "default" : "ghost"}
size="sm"
onClick={() => setViewMode("list")}
className={viewMode === "list" ? "bg-blue-600 text-white" : "text-slate-600"}
>
<List className="w-4 h-4 mr-1" />
List
</Button>
</div>
</div>
{/* Business List */}
{filteredBusinesses.length > 0 ? (
<div className={viewMode === "grid" ? "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6" : "space-y-4"}>
{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 (
<BusinessCard
key={company.company_name}
company={cardData}
metrics={{ totalOrders, hubs: company.hubs.length, activeMembers }}
isListView={viewMode === "list"}
onView={() => navigate(createPageUrl(`EditBusiness?id=${company.hubs[0]?.id}`))}
onEdit={() => navigate(createPageUrl(`EditBusiness?id=${company.hubs[0]?.id}`))}
/>
);
})}
</div> </div>
) : ( ) : (
<div className="text-center py-16 bg-white rounded-xl border border-slate-200"> <div className="text-center py-16 bg-white rounded-xl border border-slate-200">
@@ -340,4 +720,4 @@ export default function Business() {
/> />
</div> </div>
); );
} }

View File

@@ -1,4 +1,3 @@
import React, { useState, useMemo, useEffect } from "react"; import React, { useState, useMemo, useEffect } from "react";
import { base44 } from "@/api/base44Client"; import { base44 } from "@/api/base44Client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
@@ -140,6 +139,13 @@ const AVAILABLE_WIDGETS = [
description: 'Browse vendor marketplace', description: 'Browse vendor marketplace',
category: 'Actions', category: 'Actions',
categoryColor: 'bg-blue-100 text-blue-700', 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() {
</Link> </Link>
); );
const renderInvoices = () => (
<Link to={createPageUrl("Invoices")}>
<Card className="bg-white border-2 border-slate-900 shadow-md hover:shadow-lg transition-all cursor-pointer group">
<CardContent className="p-5 text-center">
<div className="w-12 h-12 mx-auto mb-3 bg-slate-900 rounded-xl flex items-center justify-center group-hover:scale-110 transition-transform">
<FileText className="w-6 h-6 text-white" />
</div>
<p className="text-slate-900 font-bold text-base">Invoices</p>
</CardContent>
</Card>
</Link>
);
const renderWidget = (widgetId) => { const renderWidget = (widgetId) => {
switch (widgetId) { switch (widgetId) {
case 'order-now': case 'order-now':
@@ -973,6 +992,8 @@ export default function ClientDashboard() {
return renderSalesAnalytics(); return renderSalesAnalytics();
case 'vendor-marketplace': case 'vendor-marketplace':
return renderVendorMarketplace(); return renderVendorMarketplace();
case 'invoices':
return renderInvoices();
default: default:
return null; return null;
} }
@@ -982,8 +1003,13 @@ export default function ClientDashboard() {
const availableToAdd = AVAILABLE_WIDGETS.filter(w => hiddenWidgets.includes(w.id)); const availableToAdd = AVAILABLE_WIDGETS.filter(w => hiddenWidgets.includes(w.id));
const quickActionWidgets = ['order-now', 'rapid-order', 'today-count', 'in-progress', 'needs-attention']; 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 visibleQuickActions = visibleWidgetIds.filter(id => quickActionWidgets.includes(id));
const visibleOtherWidgets = 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 ( return (
<div className="min-h-screen bg-slate-50 p-6"> <div className="min-h-screen bg-slate-50 p-6">
@@ -1106,7 +1132,7 @@ export default function ClientDashboard() {
ref={provided.innerRef} ref={provided.innerRef}
className="space-y-6" className="space-y-6"
> >
{visibleOtherWidgets.map((widgetId, index) => ( {otherWidgetsFullWidth.map((widgetId, index) => (
<Draggable <Draggable
key={widgetId} key={widgetId}
draggableId={widgetId} draggableId={widgetId}
@@ -1142,6 +1168,28 @@ export default function ClientDashboard() {
)} )}
</Draggable> </Draggable>
))} ))}
{gridPairVisible.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{gridPairVisible.map((widgetId) => (
<div key={widgetId} className="relative group">
{isCustomizing && (
<button
onClick={() => handleRemoveWidget(widgetId)}
className="absolute -top-2 -left-2 w-6 h-6 bg-slate-500 hover:bg-slate-600 rounded-full flex items-center justify-center shadow-lg z-20 transition-all hover:scale-110"
title="Remove widget"
>
<Minus className="w-3.5 h-3.5 text-white" />
</button>
)}
<div className={isCustomizing ? 'ring-2 ring-slate-300 rounded-lg' : ''}>
{renderWidget(widgetId)}
</div>
</div>
))}
</div>
)}
{provided.placeholder} {provided.placeholder}
</div> </div>
)} )}
@@ -1222,4 +1270,4 @@ export default function ClientDashboard() {
</Dialog> </Dialog>
</div> </div>
); );
} }

View File

@@ -21,9 +21,29 @@ import {
TabsList, // New import TabsList, // New import
TabsTrigger, // New import TabsTrigger, // New import
} from "@/components/ui/tabs"; // 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 { import {
Search, Calendar, MapPin, Users, Eye, Edit, X, Trash2, FileText, // Edit instead of Edit2 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"; } from "lucide-react";
import { useToast } from "@/components/ui/use-toast"; import { useToast } from "@/components/ui/use-toast";
import { format, parseISO, isValid } from "date-fns"; import { format, parseISO, isValid } from "date-fns";
@@ -92,10 +112,18 @@ export default function ClientOrders() {
const { toast } = useToast(); const { toast } = useToast();
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState("all"); // Updated values for Tabs 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 [cancelDialogOpen, setCancelDialogOpen] = useState(false); // Changed from cancelDialog.open
const [orderToCancel, setOrderToCancel] = useState(null); // Changed from cancelDialog.order const [orderToCancel, setOrderToCancel] = useState(null); // Changed from cancelDialog.order
const [viewOrderModal, setViewOrderModal] = useState(false); const [viewOrderModal, setViewOrderModal] = useState(false);
const [selectedOrder, setSelectedOrder] = useState(null); const [selectedOrder, setSelectedOrder] = useState(null);
const [calendarOpen, setCalendarOpen] = useState(false);
const { data: user } = useQuery({ const { data: user } = useQuery({
queryKey: ['current-user-client-orders'], 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 const filteredOrders = useMemo(() => { // Renamed from filteredEvents
let filtered = clientEvents; let filtered = clientEvents;
@@ -166,8 +216,61 @@ export default function ClientOrders() {
return true; // For "all" or other statuses 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; return filtered;
}, [clientEvents, searchTerm, statusFilter]); }, [clientEvents, searchTerm, statusFilter, dateFilter, specificDate, locationFilter, managerFilter]);
const activeOrders = clientEvents.filter(e => const activeOrders = clientEvents.filter(e =>
e.status !== "Completed" && e.status !== "Canceled" e.status !== "Completed" && e.status !== "Canceled"
@@ -316,23 +419,261 @@ export default function ClientOrders() {
</Card> </Card>
</div> </div>
<div className="bg-white rounded-xl p-4 flex items-center gap-4 border shadow-sm"> <div className="bg-white rounded-xl p-4 border shadow-sm">
<div className="relative flex-1"> <div className="flex items-center gap-4 mb-4">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-slate-400" /> {/* Icon size updated */} <div className="relative flex-1">
<Input <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-slate-400" />
placeholder="Search orders..." // Placeholder text updated <Input
value={searchTerm} placeholder="Search orders..."
onChange={(e) => setSearchTerm(e.target.value)} value={searchTerm}
className="pl-10 border-slate-300 h-10" // Class updated onChange={(e) => setSearchTerm(e.target.value)}
/> className="pl-10 border-slate-300 h-10"
/>
</div>
<Tabs value={statusFilter} onValueChange={setStatusFilter} className="w-fit">
<TabsList>
<TabsTrigger value="all">All</TabsTrigger>
<TabsTrigger value="active">Active</TabsTrigger>
<TabsTrigger value="completed">Completed</TabsTrigger>
</TabsList>
</Tabs>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<Filter className="w-4 h-4 text-slate-500" />
<span className="text-sm font-medium text-slate-700">Filters:</span>
</div>
<Popover open={calendarOpen} onOpenChange={setCalendarOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className={`h-9 w-[160px] justify-start text-left font-normal ${specificDate ? 'bg-blue-50 border-blue-300' : ''}`}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{specificDate ? format(specificDate, 'MMM dd, yyyy') : 'Pick a date'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<div className="p-6">
<CalendarComponent
mode="single"
selected={tempDate || specificDate}
onSelect={(date) => setTempDate(date)}
numberOfMonths={2}
initialFocus
/>
<div className="mt-6 pt-6 border-t border-slate-200">
<div className="flex items-center justify-center gap-3 mb-6">
<Button
variant="ghost"
size="sm"
onClick={() => setTempDate(new Date())}
className={`px-6 h-10 font-medium ${!tempDate && !specificDate ? 'border-b-2 border-blue-600 rounded-none' : ''}`}
>
Today
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
setTempDate(yesterday);
}}
className="px-6 h-10 font-medium"
>
Yesterday
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
setTempDate(tomorrow);
}}
className="px-6 h-10 font-medium"
>
Tomorrow
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
setTempDate(null);
setDateFilter("week");
}}
className="px-6 h-10 font-medium"
>
This Week
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
setTempDate(null);
setDateFilter("month");
}}
className="px-6 h-10 font-medium"
>
This Month
</Button>
</div>
<div className="flex items-center justify-end gap-3">
<Button
variant="outline"
size="sm"
onClick={() => {
setTempDate(null);
setSpecificDate(null);
setDateFilter("all");
setCalendarOpen(false);
}}
className="px-8 h-10 text-red-600 border-red-300 hover:bg-red-50 font-medium"
>
Reset
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
setTempDate(null);
setCalendarOpen(false);
}}
className="px-8 h-10 font-medium"
>
Cancel
</Button>
<Button
size="sm"
onClick={() => {
if (tempDate) {
setSpecificDate(tempDate);
setDateFilter("all");
}
setTempDate(null);
setCalendarOpen(false);
}}
className="px-10 h-10 bg-blue-600 hover:bg-blue-700 font-medium"
>
Apply
</Button>
</div>
</div>
</div>
</PopoverContent>
</Popover>
<Popover open={locationOpen} onOpenChange={setLocationOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={locationOpen}
className="w-[200px] h-9 justify-between text-sm"
>
<span className="truncate">{locationFilter === "all" ? "All Locations" : locationFilter}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0">
<Command shouldFilter={true}>
<CommandInput placeholder="Type to search..." className="h-9" />
<CommandEmpty>No location found.</CommandEmpty>
<CommandGroup className="max-h-64 overflow-auto">
<CommandItem
value="all"
onSelect={() => {
setLocationFilter("all");
setLocationOpen(false);
}}
>
<Check className={`mr-2 h-4 w-4 ${locationFilter === "all" ? "opacity-100" : "opacity-0"}`} />
All Locations
</CommandItem>
{uniqueLocations.map((location) => (
<CommandItem
key={location}
value={location}
onSelect={(currentValue) => {
setLocationFilter(currentValue);
setLocationOpen(false);
}}
>
<Check className={`mr-2 h-4 w-4 ${locationFilter === location ? "opacity-100" : "opacity-0"}`} />
{location}
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
<Popover open={managerOpen} onOpenChange={setManagerOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={managerOpen}
className="w-[200px] h-9 justify-between text-sm"
>
<span className="truncate">{managerFilter === "all" ? "All Managers" : managerFilter}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0">
<Command shouldFilter={true}>
<CommandInput placeholder="Type to search..." className="h-9" />
<CommandEmpty>No manager found.</CommandEmpty>
<CommandGroup className="max-h-64 overflow-auto">
<CommandItem
value="all"
onSelect={() => {
setManagerFilter("all");
setManagerOpen(false);
}}
>
<Check className={`mr-2 h-4 w-4 ${managerFilter === "all" ? "opacity-100" : "opacity-0"}`} />
All Managers
</CommandItem>
{uniqueManagers.map((manager) => (
<CommandItem
key={manager}
value={manager}
onSelect={(currentValue) => {
setManagerFilter(currentValue);
setManagerOpen(false);
}}
>
<Check className={`mr-2 h-4 w-4 ${managerFilter === manager ? "opacity-100" : "opacity-0"}`} />
{manager}
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
{(dateFilter !== "all" || specificDate || locationFilter !== "all" || managerFilter !== "all") && (
<Button
variant="ghost"
size="sm"
onClick={() => {
setDateFilter("all");
setSpecificDate(null);
setLocationFilter("all");
setManagerFilter("all");
}}
className="text-slate-600 hover:text-slate-900"
>
Clear Filters
</Button>
)}
</div> </div>
<Tabs value={statusFilter} onValueChange={setStatusFilter} className="w-fit"> {/* Replaced Select with Tabs */}
<TabsList>
<TabsTrigger value="all">All</TabsTrigger>
<TabsTrigger value="active">Active</TabsTrigger>
<TabsTrigger value="completed">Completed</TabsTrigger>
</TabsList>
</Tabs>
</div> </div>
<Card className="border-slate-200 shadow-sm"> {/* Card class updated */} <Card className="border-slate-200 shadow-sm"> {/* Card class updated */}

View File

@@ -1,4 +1,3 @@
import React from "react"; import React from "react";
import { base44 } from "@/api/base44Client"; import { base44 } from "@/api/base44Client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; 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 { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; 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 (
<div className="p-12 text-center">
<Briefcase className="w-16 h-16 mx-auto mb-4 text-slate-300" />
<h3 className="text-lg font-bold text-slate-900 mb-2">No Rate Card Assigned</h3>
<p className="text-slate-600">Contact admin to assign a rate card</p>
</div>
);
}
return (
<table className="w-full">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="text-left py-3 px-6 font-semibold text-xs text-slate-600 uppercase">Position</th>
<th className="text-right py-3 px-6 font-semibold text-xs text-slate-600 uppercase">Hourly Rate</th>
</tr>
</thead>
<tbody>
{positions.map(([position, rate], idx) => (
<tr key={position} className={`border-b border-slate-100 ${idx % 2 === 0 ? 'bg-white' : 'bg-slate-50/30'}`}>
<td className="py-3 px-6">
<span className="font-medium text-slate-900">{position}</span>
</td>
<td className="py-3 px-6 text-right">
<span className="font-bold text-blue-600">${rate.toFixed(2)}</span>
<span className="text-xs text-slate-500 ml-1">/hr</span>
</td>
</tr>
))}
</tbody>
</table>
);
}
function TeamTabContent({ allTeamMembers }) {
const [viewMode, setViewMode] = React.useState("grid");
return (
<div className="space-y-4">
{/* View Toggle */}
<div className="flex items-center justify-between">
<p className="text-sm text-slate-600">{allTeamMembers.length} team member{allTeamMembers.length !== 1 ? 's' : ''}</p>
<div className="flex items-center gap-2 bg-white border border-slate-200 rounded-lg p-1">
<Button
variant={viewMode === "grid" ? "default" : "ghost"}
size="sm"
onClick={() => setViewMode("grid")}
className={viewMode === "grid" ? "bg-blue-600 text-white" : "text-slate-600"}
>
<Grid3x3 className="w-4 h-4 mr-1" />
Grid
</Button>
<Button
variant={viewMode === "list" ? "default" : "ghost"}
size="sm"
onClick={() => setViewMode("list")}
className={viewMode === "list" ? "bg-blue-600 text-white" : "text-slate-600"}
>
<List className="w-4 h-4 mr-1" />
List
</Button>
</div>
</div>
{allTeamMembers.length > 0 ? (
viewMode === "grid" ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{allTeamMembers.map((member) => (
<Card key={member.member_id} className={`${member.is_active ? 'border-2 border-blue-200' : 'border border-slate-200 opacity-70'}`}>
<CardContent className="p-6">
<div className="flex items-start gap-3 mb-4">
<div className={`w-16 h-16 ${member.is_active ? 'bg-blue-600' : 'bg-slate-400'} rounded-2xl flex items-center justify-center text-white font-bold text-xl shadow-md flex-shrink-0`}>
{member.member_name?.split(' ').map(n => n[0]).join('').toUpperCase() || 'U'}
</div>
<div className="flex-1 min-w-0">
<h4 className="font-bold text-slate-900 text-base mb-0.5">{member.member_name}</h4>
<p className="text-sm text-slate-600 mb-1">{member.role || 'Member'}</p>
<p className="text-xs text-slate-500">{member.title || 'Team Member'}</p>
</div>
</div>
<div className="space-y-2 mb-4">
{member.email && (
<div className="flex items-center gap-2 text-sm text-slate-700">
<Mail className="w-4 h-4 text-slate-400 flex-shrink-0" />
<span className="truncate">{member.email}</span>
</div>
)}
{member.phone && (
<div className="flex items-center gap-2 text-sm text-slate-700">
<Phone className="w-4 h-4 text-slate-400 flex-shrink-0" />
<span className="truncate">{member.phone}</span>
</div>
)}
</div>
<div className="flex flex-wrap gap-2">
{member.department && (
<Badge className="bg-blue-100 text-blue-700 text-xs border-0">
{member.department}
</Badge>
)}
{member.hub && (
<Badge className="bg-purple-100 text-purple-700 text-xs border-0">
<MapPin className="w-3 h-3 mr-1" />
{member.hub}
</Badge>
)}
</div>
</CardContent>
</Card>
))}
</div>
) : (
<Card className="border-slate-200">
<CardContent className="p-0">
<table className="w-full">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="text-left py-3 px-6 font-semibold text-xs text-slate-600 uppercase">Name</th>
<th className="text-left py-3 px-6 font-semibold text-xs text-slate-600 uppercase">Role</th>
<th className="text-left py-3 px-6 font-semibold text-xs text-slate-600 uppercase">Contact</th>
<th className="text-left py-3 px-6 font-semibold text-xs text-slate-600 uppercase">Hub</th>
<th className="text-center py-3 px-6 font-semibold text-xs text-slate-600 uppercase">Status</th>
</tr>
</thead>
<tbody>
{allTeamMembers.map((member, idx) => (
<tr key={member.member_id} className={`border-b border-slate-100 hover:bg-slate-50 transition-colors ${idx % 2 === 0 ? 'bg-white' : 'bg-slate-50/30'}`}>
<td className="py-4 px-6">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 ${member.is_active ? 'bg-blue-600' : 'bg-slate-400'} rounded-full flex items-center justify-center text-white font-bold text-sm`}>
{member.member_name?.split(' ').map(n => n[0]).join('').toUpperCase() || 'U'}
</div>
<span className="font-medium text-slate-900">{member.member_name}</span>
</div>
</td>
<td className="py-4 px-6">
<div>
<p className="font-medium text-slate-900 text-sm">{member.role || 'Member'}</p>
<p className="text-xs text-slate-500">{member.title || 'Team Member'}</p>
</div>
</td>
<td className="py-4 px-6">
<div className="text-sm">
{member.email && <p className="text-slate-700 flex items-center gap-1"><Mail className="w-3 h-3 text-slate-400" />{member.email}</p>}
{member.phone && <p className="text-slate-500 flex items-center gap-1 mt-0.5"><Phone className="w-3 h-3 text-slate-400" />{member.phone}</p>}
</div>
</td>
<td className="py-4 px-6">
{member.hub ? (
<Badge className="bg-purple-100 text-purple-700 text-xs border-0">
<MapPin className="w-3 h-3 mr-1" />
{member.hub}
</Badge>
) : (
<span className="text-slate-400 text-sm"></span>
)}
</td>
<td className="py-4 px-6 text-center">
<Badge className={member.is_active !== false ? "bg-green-100 text-green-700" : "bg-slate-200 text-slate-600"}>
{member.is_active !== false ? 'Active' : 'Inactive'}
</Badge>
</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
)
) : (
<Card>
<CardContent className="p-12 text-center">
<Users className="w-16 h-16 mx-auto mb-4 text-slate-300" />
<h3 className="text-lg font-bold text-slate-900 mb-2">No Team Members</h3>
<p className="text-slate-600">This business doesn't have any team members yet</p>
</CardContent>
</Card>
)}
</div>
);
}
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 (
<div className="space-y-6">
{/* Rate Card Header */}
<Card className="border-slate-200 shadow-lg">
<CardContent className="p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-4">
<div className={`w-14 h-14 ${isUnlocked ? 'bg-amber-500' : 'bg-blue-600'} rounded-2xl flex items-center justify-center shadow-lg transition-colors`}>
<DollarSign className="w-7 h-7 text-white" />
</div>
<div>
<p className="text-sm font-semibold text-slate-500 mb-1">Assigned Rate Card</p>
{isUnlocked ? (
<Select value={selectedCard} onValueChange={setSelectedCard}>
<SelectTrigger className="w-[240px] border-amber-300 bg-amber-50">
<SelectValue placeholder="Select rate card" />
</SelectTrigger>
<SelectContent>
{rateCardOptions.map(card => (
<SelectItem key={card} value={card}>{card}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<p className="text-2xl font-bold text-slate-900">{business?.rate_card || 'Not Assigned'}</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
{isUnlocked ? (
<>
<Button
variant="outline"
size="sm"
onClick={() => { setIsUnlocked(false); setSelectedCard(business?.rate_card || ''); }}
className="border-slate-300"
>
Cancel
</Button>
<Button
size="sm"
onClick={handleSaveRateCard}
disabled={updateBusinessMutation.isPending || selectedCard === business?.rate_card}
className="bg-green-600 hover:bg-green-700 text-white"
>
{updateBusinessMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4 mr-1" />}
Save
</Button>
</>
) : (
<Button
variant="outline"
size="sm"
onClick={() => setIsUnlocked(true)}
className="border-amber-300 text-amber-700 hover:bg-amber-50"
>
<Unlock className="w-4 h-4 mr-1" />
Unlock to Change
</Button>
)}
<Badge className={`${isUnlocked ? 'bg-amber-100 text-amber-700 border-amber-200' : 'bg-green-100 text-green-700 border-green-200'} px-3 py-1`}>
{isUnlocked ? <><Unlock className="w-3 h-3 mr-1 inline" />Unlocked</> : <><Lock className="w-3 h-3 mr-1 inline" />Locked</>}
</Badge>
</div>
</div>
{isUnlocked && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 text-sm text-amber-800">
Select a new rate card and click Save to update pricing for this business.
</div>
)}
</CardContent>
</Card>
{/* Rate Breakdown Table */}
<Card className="border-slate-200 shadow-lg">
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b border-slate-100">
<CardTitle className="text-slate-900 flex items-center justify-between">
<span>Rate Breakdown</span>
<span className="text-sm font-normal text-slate-500">
{isUnlocked && selectedCard !== business?.rate_card ? `Preview: ${selectedCard}` : business?.rate_card}
</span>
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<RateBreakdownTable rateCard={isUnlocked ? selectedCard : business?.rate_card} />
</CardContent>
</Card>
</div>
);
}
export default function EditBusiness() { export default function EditBusiness() {
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
const businessId = urlParams.get('id'); const businessId = urlParams.get('id');
const defaultTab = urlParams.get('tab') || 'overview';
const { data: allBusinesses, isLoading } = useQuery({ const { data: allBusinesses, isLoading } = useQuery({
queryKey: ['businesses'], queryKey: ['businesses'],
@@ -23,8 +391,119 @@ export default function EditBusiness() {
initialData: [], 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); 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({ const [formData, setFormData] = React.useState({
business_name: "", business_name: "",
company_logo: "", company_logo: "",
@@ -87,7 +566,7 @@ export default function EditBusiness() {
return ( return (
<div className="p-4 md:p-8"> <div className="p-4 md:p-8">
<div className="max-w-4xl mx-auto"> <div className="max-w-7xl mx-auto">
<div className="mb-8"> <div className="mb-8">
<Button <Button
variant="ghost" variant="ghost"
@@ -97,16 +576,125 @@ export default function EditBusiness() {
<ArrowLeft className="w-4 h-4 mr-2" /> <ArrowLeft className="w-4 h-4 mr-2" />
Back to Businesses Back to Businesses
</Button> </Button>
<h1 className="text-3xl md:text-4xl font-bold text-slate-900 mb-2">Edit Business Client</h1> <div className="flex items-center justify-between">
<p className="text-slate-600">Update information for {business.business_name}</p> <div>
<h1 className="text-3xl md:text-4xl font-bold text-slate-900 mb-2">{companyName}</h1>
<p className="text-slate-600">{hubs.length} hub locations • {allTeamMembers.length} team members</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3">
<div className="bg-blue-50 rounded-xl p-4 text-center">
<p className="text-xs text-blue-700 font-semibold mb-1">Monthly Sales</p>
<p className="text-2xl font-bold text-blue-900">${(monthlySpend / 1000).toFixed(0)}k</p>
</div>
<div className="bg-slate-50 rounded-xl p-4 text-center">
<p className="text-xs text-slate-700 font-semibold mb-1">Total Orders</p>
<p className="text-2xl font-bold text-slate-900">{totalOrders}</p>
</div>
<div className="bg-green-50 rounded-xl p-4 text-center">
<p className="text-xs text-green-700 font-semibold mb-1">On-Time Rate</p>
<p className="text-2xl font-bold text-green-900">{onTimeRate}%</p>
</div>
<div className="bg-orange-50 rounded-xl p-4 text-center">
<p className="text-xs text-orange-700 font-semibold mb-1">Cancellations</p>
<p className="text-2xl font-bold text-orange-900">{cancelationRate}%</p>
</div>
<div className="bg-purple-50 rounded-xl p-4 text-center">
<p className="text-xs text-purple-700 font-semibold mb-1">Completed</p>
<p className="text-2xl font-bold text-purple-900">{completedOrders}</p>
</div>
</div>
</div>
</div> </div>
<form onSubmit={handleSubmit}> <Tabs defaultValue={defaultTab} className="space-y-8">
<Card className="border-slate-200 shadow-lg"> <div className="bg-white rounded-2xl shadow-lg border border-slate-200 p-2">
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b border-slate-100"> <TabsList className="bg-transparent w-full grid grid-cols-7 gap-2">
<CardTitle className="text-slate-900">Business Information</CardTitle> <TabsTrigger
</CardHeader> value="overview"
<CardContent className="p-6"> className="flex items-center justify-center gap-2 px-4 py-3 rounded-xl font-semibold text-sm text-slate-600 hover:bg-slate-50 transition-all data-[state=active]:bg-gradient-to-br data-[state=active]:from-blue-600 data-[state=active]:to-blue-700 data-[state=active]:text-white data-[state=active]:shadow-lg data-[state=active]:shadow-blue-200"
>
<Briefcase className="w-4 h-4" />
<span className="hidden lg:inline">Overview</span>
</TabsTrigger>
<TabsTrigger
value="team"
className="flex items-center justify-center gap-2 px-4 py-3 rounded-xl font-semibold text-sm text-slate-600 hover:bg-slate-50 transition-all data-[state=active]:bg-gradient-to-br data-[state=active]:from-blue-600 data-[state=active]:to-blue-700 data-[state=active]:text-white data-[state=active]:shadow-lg data-[state=active]:shadow-blue-200"
>
<Users className="w-4 h-4" />
<span className="hidden lg:inline">Team</span>
<span className="inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 text-xs font-bold rounded-full bg-slate-200 text-slate-700 data-[state=active]:bg-white/20 data-[state=active]:text-white">
{allTeamMembers.length}
</span>
</TabsTrigger>
<TabsTrigger
value="hubs"
className="flex items-center justify-center gap-2 px-4 py-3 rounded-xl font-semibold text-sm text-slate-600 hover:bg-slate-50 transition-all data-[state=active]:bg-gradient-to-br data-[state=active]:from-blue-600 data-[state=active]:to-blue-700 data-[state=active]:text-white data-[state=active]:shadow-lg data-[state=active]:shadow-blue-200"
>
<MapPin className="w-4 h-4" />
<span className="hidden lg:inline">Hubs</span>
<span className="inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 text-xs font-bold rounded-full bg-slate-200 text-slate-700 data-[state=active]:bg-white/20 data-[state=active]:text-white">
{hubs.length}
</span>
</TabsTrigger>
<TabsTrigger
value="services"
className="flex items-center justify-center gap-2 px-4 py-3 rounded-xl font-semibold text-sm text-slate-600 hover:bg-slate-50 transition-all data-[state=active]:bg-gradient-to-br data-[state=active]:from-blue-600 data-[state=active]:to-blue-700 data-[state=active]:text-white data-[state=active]:shadow-lg data-[state=active]:shadow-blue-200"
>
<Briefcase className="w-4 h-4" />
<span className="hidden lg:inline">Services</span>
</TabsTrigger>
<TabsTrigger
value="favorites"
className="flex items-center justify-center gap-2 px-4 py-3 rounded-xl font-semibold text-sm text-slate-600 hover:bg-slate-50 transition-all data-[state=active]:bg-gradient-to-br data-[state=active]:from-yellow-500 data-[state=active]:to-amber-600 data-[state=active]:text-white data-[state=active]:shadow-lg data-[state=active]:shadow-yellow-200"
>
<Star className="w-4 h-4" />
<span className="hidden lg:inline">Favorites</span>
<span className="inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 text-xs font-bold rounded-full bg-slate-200 text-slate-700 data-[state=active]:bg-white/20 data-[state=active]:text-white">
{business?.favorite_staff?.length || 0}
</span>
</TabsTrigger>
<TabsTrigger
value="blocked"
className="flex items-center justify-center gap-2 px-4 py-3 rounded-xl font-semibold text-sm text-slate-600 hover:bg-slate-50 transition-all data-[state=active]:bg-gradient-to-br data-[state=active]:from-red-500 data-[state=active]:to-red-600 data-[state=active]:text-white data-[state=active]:shadow-lg data-[state=active]:shadow-red-200"
>
<Ban className="w-4 h-4" />
<span className="hidden lg:inline">Blocked</span>
<span className="inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 text-xs font-bold rounded-full bg-slate-200 text-slate-700 data-[state=active]:bg-white/20 data-[state=active]:text-white">
{business?.blocked_staff?.length || 0}
</span>
</TabsTrigger>
<TabsTrigger
value="erp"
className="flex items-center justify-center gap-2 px-4 py-3 rounded-xl font-semibold text-sm text-slate-600 hover:bg-slate-50 transition-all data-[state=active]:bg-gradient-to-br data-[state=active]:from-purple-600 data-[state=active]:to-purple-700 data-[state=active]:text-white data-[state=active]:shadow-lg data-[state=active]:shadow-purple-200"
>
<Briefcase className="w-4 h-4" />
<span className="hidden lg:inline">ERP/EDI</span>
</TabsTrigger>
<TabsTrigger
value="settings"
className="flex items-center justify-center gap-2 px-4 py-3 rounded-xl font-semibold text-sm text-slate-600 hover:bg-slate-50 transition-all data-[state=active]:bg-gradient-to-br data-[state=active]:from-slate-700 data-[state=active]:to-slate-800 data-[state=active]:text-white data-[state=active]:shadow-lg data-[state=active]:shadow-slate-300"
>
<Settings className="w-4 h-4" />
<span className="hidden lg:inline">Settings</span>
</TabsTrigger>
</TabsList>
</div>
{/* Overview Tab */}
<TabsContent value="overview">
<form onSubmit={handleSubmit}>
<Card className="border-slate-200 shadow-lg">
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b border-slate-100">
<CardTitle className="text-slate-900">Business Information</CardTitle>
</CardHeader>
<CardContent className="p-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2 md:col-span-2"> <div className="space-y-2 md:col-span-2">
<Label htmlFor="business_name" className="text-slate-700 font-medium">Business Name *</Label> <Label htmlFor="business_name" className="text-slate-700 font-medium">Business Name *</Label>
@@ -236,7 +824,245 @@ export default function EditBusiness() {
</Button> </Button>
</div> </div>
</form> </form>
</TabsContent>
{/* Team Tab */}
<TabsContent value="team">
<TeamTabContent allTeamMembers={allTeamMembers} />
</TabsContent>
{/* Hubs Tab */}
<TabsContent value="hubs">
<Card>
<CardContent className="p-0">
<table className="w-full">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="text-left py-3 px-6 font-semibold text-xs text-slate-600">Hub Name</th>
<th className="text-left py-3 px-6 font-semibold text-xs text-slate-600">Contact</th>
<th className="text-left py-3 px-6 font-semibold text-xs text-slate-600">Address</th>
<th className="text-left py-3 px-6 font-semibold text-xs text-slate-600">City</th>
<th className="text-center py-3 px-6 font-semibold text-xs text-slate-600">Actions</th>
</tr>
</thead>
<tbody>
{hubs.map((hub, idx) => (
<tr key={hub.id} className={`border-b border-slate-100 hover:bg-slate-50 transition-colors ${idx % 2 === 0 ? 'bg-white' : 'bg-slate-50/30'}`}>
<td className="py-4 px-6">
<div className="flex items-center gap-2">
<MapPin className="w-4 h-4 text-blue-500" />
<span className="font-medium text-sm text-slate-900">{hub.hub_name}</span>
</div>
</td>
<td className="py-4 px-6">
<div className="text-sm">
<p className="font-medium text-slate-900">{hub.contact_name || ''}</p>
{hub.email && <p className="text-xs text-slate-500 flex items-center gap-1 mt-0.5"><Mail className="w-3 h-3" />{hub.email}</p>}
{hub.phone && <p className="text-xs text-slate-500 flex items-center gap-1 mt-0.5"><Phone className="w-3 h-3" />{hub.phone}</p>}
</div>
</td>
<td className="py-4 px-6">
<p className="text-sm text-slate-600">{hub.address || ''}</p>
</td>
<td className="py-4 px-6">
<p className="text-sm text-slate-600">{hub.city || ''}</p>
</td>
<td className="py-4 px-6">
<div className="flex items-center justify-center gap-2">
<Button variant="ghost" size="icon" className="text-slate-400 hover:text-blue-600 hover:bg-blue-50 h-8 w-8">
<Eye className="w-4 h-4" />
</Button>
<Button variant="ghost" size="icon" className="text-slate-400 hover:text-red-600 hover:bg-red-50 h-8 w-8">
<Trash2 className="w-4 h-4" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
</TabsContent>
{/* Services Tab */}
<TabsContent value="services">
<ServicesTab
business={business}
businessId={businessId}
updateBusinessMutation={updateBusinessMutation}
/>
</TabsContent>
{/* Favorites Tab */}
<TabsContent value="favorites">
<div className="space-y-4">
{business?.favorite_staff?.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{business.favorite_staff.map((fav) => (
<Card key={fav.staff_id} className="border border-yellow-200">
<CardContent className="p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-yellow-100 rounded-full flex items-center justify-center">
<Star className="w-6 h-6 text-yellow-600 fill-yellow-500" />
</div>
<div>
<p className="font-bold text-slate-900">{fav.staff_name}</p>
<p className="text-xs text-slate-500">{fav.position || 'Staff'}</p>
</div>
</div>
<Button variant="ghost" size="icon" className="text-slate-400 hover:text-red-600">
<UserMinus className="w-4 h-4" />
</Button>
</div>
<p className="text-xs text-slate-500">Added: {new Date(fav.added_date).toLocaleDateString()}</p>
</CardContent>
</Card>
))}
</div>
) : (
<Card>
<CardContent className="p-12 text-center">
<Star className="w-16 h-16 mx-auto mb-4 text-slate-300" />
<h3 className="text-lg font-bold text-slate-900 mb-2">No Favorite Staff</h3>
<p className="text-slate-600 mb-4">Mark staff as favorites for quick access</p>
<Button size="sm" className="bg-yellow-100 hover:bg-yellow-200 text-slate-800 border border-yellow-200">
<UserPlus className="w-4 h-4 mr-2" />Add Favorite
</Button>
</CardContent>
</Card>
)}
</div>
</TabsContent>
{/* Blocked Tab */}
<TabsContent value="blocked">
<div className="space-y-4">
{business?.blocked_staff?.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{business.blocked_staff.map((blocked) => (
<Card key={blocked.staff_id} className="border border-red-200">
<CardContent className="p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-red-100 rounded-full flex items-center justify-center">
<Ban className="w-6 h-6 text-red-600" />
</div>
<div>
<p className="font-bold text-slate-900">{blocked.staff_name}</p>
<Badge className="bg-red-100 text-red-800 text-xs mt-1">Blocked</Badge>
</div>
</div>
<Button variant="ghost" size="icon" className="text-slate-400 hover:text-green-600">
<UserPlus className="w-4 h-4" />
</Button>
</div>
<p className="text-xs text-slate-600 mb-1"><strong>Reason:</strong> {blocked.reason}</p>
<p className="text-xs text-slate-500">Blocked: {new Date(blocked.blocked_date).toLocaleDateString()}</p>
</CardContent>
</Card>
))}
</div>
) : (
<Card>
<CardContent className="p-12 text-center">
<Ban className="w-16 h-16 mx-auto mb-4 text-slate-300" />
<h3 className="text-lg font-bold text-slate-900 mb-2">No Blocked Staff</h3>
<p className="text-slate-600">Staff blocking helps maintain quality standards</p>
</CardContent>
</Card>
)}
</div>
</TabsContent>
{/* ERP/EDI Tab */}
<TabsContent value="erp">
<ERPSettingsTab
business={business}
onSave={(erpSettings) => {
updateBusinessMutation.mutate({
id: businessId,
data: erpSettings
});
}}
isSaving={updateBusinessMutation.isPending}
/>
</TabsContent>
{/* Settings Tab */}
<TabsContent value="settings">
<Card>
<CardContent className="p-6">
<h3 className="font-bold text-lg text-slate-900 mb-4">Business Settings</h3>
<div className="space-y-6">
<div>
<h4 className="font-semibold text-slate-900 mb-3">General Settings</h4>
<div className="space-y-4">
<div className="flex items-center justify-between p-4 bg-slate-50 rounded-lg">
<div>
<p className="font-medium text-slate-900">Client Active Status</p>
<p className="text-sm text-slate-500">Activate or deactivate this client</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={business?.is_active !== false}
onChange={(e) => {
updateBusinessMutation.mutate({
id: businessId,
data: { is_active: e.target.checked }
});
}}
className="sr-only peer"
/>
<div className="w-14 h-7 bg-slate-300 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-0.5 after:left-[4px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-6 after:w-6 after:transition-all peer-checked:bg-blue-600"></div>
</label>
</div>
<div className="flex items-center justify-between p-4 bg-slate-50 rounded-lg">
<div>
<p className="font-medium text-slate-900">Status</p>
<p className="text-sm text-slate-500">Current business status</p>
</div>
<Badge className={business?.is_active !== false ? "bg-green-100 text-green-700" : "bg-slate-300 text-slate-700"}>
{business?.is_active !== false ? 'Active' : 'Inactive'}
</Badge>
</div>
<div className="flex items-center justify-between p-4 bg-slate-50 rounded-lg">
<div>
<p className="font-medium text-slate-900">Last Order Date</p>
<p className="text-sm text-slate-500">Most recent order placed</p>
</div>
<span className="font-semibold text-slate-900">
{business?.last_order_date
? new Date(business.last_order_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
: 'No orders yet'}
</span>
</div>
<div className="flex items-center justify-between p-4 bg-slate-50 rounded-lg">
<div>
<p className="font-medium text-slate-900">Rate Group</p>
<p className="text-sm text-slate-500">Pricing tier</p>
</div>
<span className="font-semibold text-slate-900">{business?.rate_group || 'Standard'}</span>
</div>
</div>
</div>
<div className="pt-6 border-t border-slate-200">
<h4 className="font-semibold text-slate-900 mb-3">Danger Zone</h4>
<div className="flex gap-3">
<Button variant="outline" className="border-red-300 text-red-600 hover:bg-red-50">
<Trash2 className="w-4 h-4 mr-2" />Delete Business
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div> </div>
</div> </div>
); );
} }

View File

@@ -24,6 +24,12 @@ import { format } from "date-fns";
const safeFormatDate = (dateString) => { const safeFormatDate = (dateString) => {
if (!dateString) return "—"; if (!dateString) return "—";
try { 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"); return format(new Date(dateString), "MMMM d, yyyy");
} catch { } catch {
return "—"; return "—";

View File

@@ -26,6 +26,12 @@ import { detectAllConflicts, ConflictAlert } from "@/components/scheduling/Confl
const safeParseDate = (dateString) => { const safeParseDate = (dateString) => {
if (!dateString) return null; if (!dateString) return null;
try { 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); const date = typeof dateString === 'string' ? parseISO(dateString) : new Date(dateString);
return isValid(date) ? date : null; return isValid(date) ? date : null;
} catch { return null; } } catch { return null; }

View File

@@ -4,6 +4,7 @@ import { base44 } from "@/api/base44Client";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils"; import { createPageUrl } from "@/utils";
import InvoiceDetailView from "@/components/invoices/InvoiceDetailView"; import InvoiceDetailView from "@/components/invoices/InvoiceDetailView";
import InvoiceExportPanel from "@/components/invoices/InvoiceExportPanel";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
@@ -22,7 +23,16 @@ export default function InvoiceDetail() {
queryFn: () => base44.entities.Invoice.list(), 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 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; const userRole = user?.user_role || user?.role;
if (isLoading) { if (isLoading) {
@@ -62,7 +72,14 @@ export default function InvoiceDetail() {
Back Back
</Button> </Button>
</div> </div>
<InvoiceDetailView invoice={invoice} userRole={userRole} /> <div className="flex gap-6 p-4 md:p-8">
<div className="flex-1">
<InvoiceDetailView invoice={invoice} userRole={userRole} />
</div>
<div className="w-80 flex-shrink-0 print:hidden">
<InvoiceExportPanel invoice={invoice} business={business} />
</div>
</div>
</> </>
); );
} }

View File

@@ -7,7 +7,7 @@ import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; 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 { format, parseISO, isPast } from "date-fns";
import PageHeader from "@/components/common/PageHeader"; import PageHeader from "@/components/common/PageHeader";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
@@ -16,16 +16,18 @@ import AutoInvoiceGenerator from "@/components/invoices/AutoInvoiceGenerator";
import CreateInvoiceModal from "@/components/invoices/CreateInvoiceModal"; import CreateInvoiceModal from "@/components/invoices/CreateInvoiceModal";
const statusColors = { const statusColors = {
'Draft': 'bg-slate-500 text-white', 'Draft': 'bg-slate-100 text-slate-600 font-medium',
'Pending Review': 'bg-amber-500 text-white', 'Open': 'bg-blue-100 text-blue-700 font-medium',
'Approved': 'bg-green-500 text-white', 'Pending Review': 'bg-blue-100 text-blue-700 font-medium',
'Disputed': 'bg-red-500 text-white', 'Confirmed': 'bg-amber-100 text-amber-700 font-medium',
'Under Review': 'bg-orange-500 text-white', 'Approved': 'bg-emerald-100 text-emerald-700 font-medium',
'Resolved': 'bg-blue-500 text-white', 'Disputed': 'bg-red-100 text-red-700 font-medium',
'Overdue': 'bg-red-600 text-white', 'Under Review': 'bg-orange-100 text-orange-700 font-medium',
'Paid': 'bg-emerald-500 text-white', 'Resolved': 'bg-cyan-100 text-cyan-700 font-medium',
'Reconciled': 'bg-purple-500 text-white', 'Overdue': 'bg-red-100 text-red-700 font-medium',
'Cancelled': 'bg-slate-400 text-white', '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() { export default function Invoices() {
@@ -126,8 +128,86 @@ export default function Invoices() {
disputed: getTotalAmount("Disputed"), disputed: getTotalAmount("Disputed"),
overdue: getTotalAmount("Overdue"), overdue: getTotalAmount("Overdue"),
paid: getTotalAmount("Paid"), 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 ( return (
<> <>
<AutoInvoiceGenerator /> <AutoInvoiceGenerator />
@@ -170,90 +250,190 @@ export default function Invoices() {
{/* Status Tabs */} {/* Status Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="mb-6"> <Tabs value={activeTab} onValueChange={setActiveTab} className="mb-6">
<TabsList className="bg-white border border-slate-200 h-auto p-1 flex-wrap"> <TabsList className="bg-slate-100 border border-slate-200 h-auto p-1.5 flex-wrap gap-1">
<TabsTrigger value="all"> <TabsTrigger
All <Badge variant="secondary" className="ml-2">{getStatusCount("all")}</Badge> value="all"
className="data-[state=active]:bg-blue-600 data-[state=active]:text-white bg-white text-slate-700 hover:bg-slate-50 transition-all rounded-md px-3 py-2"
>
<FileText className="w-4 h-4 mr-2" />
All
<Badge className="ml-2 bg-yellow-400 text-yellow-900 hover:bg-yellow-400 border-0 font-bold">{getStatusCount("all")}</Badge>
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="pending"> <TabsTrigger
Pending Review <Badge variant="secondary" className="ml-2">{getStatusCount("Pending Review")}</Badge> value="pending"
className="data-[state=active]:bg-blue-600 data-[state=active]:text-white bg-white text-slate-700 hover:bg-slate-50 transition-all rounded-md px-3 py-2"
>
<Clock className="w-4 h-4 mr-2" />
Pending
<Badge className="ml-2 bg-yellow-400 text-yellow-900 hover:bg-yellow-400 border-0 font-bold">{getStatusCount("Pending Review")}</Badge>
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="approved"> <TabsTrigger
Approved <Badge variant="secondary" className="ml-2">{getStatusCount("Approved")}</Badge> value="approved"
className="data-[state=active]:bg-blue-600 data-[state=active]:text-white bg-white text-slate-700 hover:bg-slate-50 transition-all rounded-md px-3 py-2"
>
<CheckCircle className="w-4 h-4 mr-2" />
Approved
<Badge className="ml-2 bg-yellow-400 text-yellow-900 hover:bg-yellow-400 border-0 font-bold">{getStatusCount("Approved")}</Badge>
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="disputed"> <TabsTrigger
Disputed <Badge variant="secondary" className="ml-2">{getStatusCount("Disputed")}</Badge> value="disputed"
className="data-[state=active]:bg-blue-600 data-[state=active]:text-white bg-white text-slate-700 hover:bg-slate-50 transition-all rounded-md px-3 py-2"
>
<AlertTriangle className="w-4 h-4 mr-2" />
Disputed
<Badge className="ml-2 bg-yellow-400 text-yellow-900 hover:bg-yellow-400 border-0 font-bold">{getStatusCount("Disputed")}</Badge>
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="overdue"> <TabsTrigger
Overdue <Badge variant="secondary" className="ml-2">{getStatusCount("Overdue")}</Badge> value="overdue"
className="data-[state=active]:bg-blue-600 data-[state=active]:text-white bg-white text-slate-700 hover:bg-slate-50 transition-all rounded-md px-3 py-2"
>
<AlertTriangle className="w-4 h-4 mr-2" />
Overdue
<Badge className="ml-2 bg-yellow-400 text-yellow-900 hover:bg-yellow-400 border-0 font-bold">{getStatusCount("Overdue")}</Badge>
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="paid"> <TabsTrigger
Paid <Badge variant="secondary" className="ml-2">{getStatusCount("Paid")}</Badge> value="paid"
className="data-[state=active]:bg-blue-600 data-[state=active]:text-white bg-white text-slate-700 hover:bg-slate-50 transition-all rounded-md px-3 py-2"
>
<CheckCircle className="w-4 h-4 mr-2" />
Paid
<Badge className="ml-2 bg-yellow-400 text-yellow-900 hover:bg-yellow-400 border-0 font-bold">{getStatusCount("Paid")}</Badge>
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="reconciled"> <TabsTrigger
Reconciled <Badge variant="secondary" className="ml-2">{getStatusCount("Reconciled")}</Badge> value="reconciled"
className="data-[state=active]:bg-blue-600 data-[state=active]:text-white bg-white text-slate-700 hover:bg-slate-50 transition-all rounded-md px-3 py-2"
>
<CheckCircle className="w-4 h-4 mr-2" />
Reconciled
<Badge className="ml-2 bg-yellow-400 text-yellow-900 hover:bg-yellow-400 border-0 font-bold">{getStatusCount("Reconciled")}</Badge>
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
</Tabs> </Tabs>
{/* Metric Cards */} {/* Metric Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6"> <div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<Card className="border-slate-200"> <Card className="border-0 bg-blue-50 shadow-sm hover:shadow-md transition-all">
<CardContent className="p-4"> <CardContent className="p-5">
<div className="flex items-center gap-3"> <div className="flex items-center gap-4">
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center"> <div className="w-12 h-12 bg-blue-500 rounded-xl flex items-center justify-center flex-shrink-0">
<FileText className="w-6 h-6 text-blue-600" /> <FileText className="w-6 h-6 text-white" />
</div> </div>
<div> <div>
<p className="text-sm text-slate-500">Total</p> <p className="text-xs text-blue-600 uppercase tracking-wider font-semibold mb-0.5">Total Value</p>
<p className="text-2xl font-bold text-slate-900">${metrics.all.toLocaleString()}</p> <p className="text-2xl font-bold text-blue-700">${metrics.all.toLocaleString()}</p>
</div> </div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<Card className="border-slate-200"> <Card className="border-0 bg-amber-50 shadow-sm hover:shadow-md transition-all">
<CardContent className="p-4"> <CardContent className="p-5">
<div className="flex items-center gap-3"> <div className="flex items-center gap-4">
<div className="w-12 h-12 bg-amber-100 rounded-lg flex items-center justify-center"> <div className="w-12 h-12 bg-amber-500 rounded-xl flex items-center justify-center flex-shrink-0">
<Clock className="w-6 h-6 text-amber-600" /> <DollarSign className="w-6 h-6 text-white" />
</div> </div>
<div> <div>
<p className="text-sm text-slate-500">Pending</p> <p className="text-xs text-amber-600 uppercase tracking-wider font-semibold mb-0.5">Outstanding</p>
<p className="text-2xl font-bold text-amber-600">${metrics.pending.toLocaleString()}</p> <p className="text-2xl font-bold text-amber-700">${metrics.outstanding.toLocaleString()}</p>
</div> </div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<Card className="border-slate-200"> <Card className="border-0 bg-red-50 shadow-sm hover:shadow-md transition-all">
<CardContent className="p-4"> <CardContent className="p-5">
<div className="flex items-center gap-3"> <div className="flex items-center gap-4">
<div className="w-12 h-12 bg-red-100 rounded-lg flex items-center justify-center"> <div className="w-12 h-12 bg-red-500 rounded-xl flex items-center justify-center flex-shrink-0">
<AlertTriangle className="w-6 h-6 text-red-600" /> <AlertTriangle className="w-6 h-6 text-white" />
</div> </div>
<div> <div>
<p className="text-sm text-slate-500">Overdue</p> <p className="text-xs text-red-600 uppercase tracking-wider font-semibold mb-0.5">Disputed</p>
<p className="text-2xl font-bold text-red-600">${metrics.overdue.toLocaleString()}</p> <p className="text-2xl font-bold text-red-700">${metrics.disputed.toLocaleString()}</p>
</div> </div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<Card className="border-slate-200"> <Card className="border-0 bg-emerald-50 shadow-sm hover:shadow-md transition-all">
<CardContent className="p-4"> <CardContent className="p-5">
<div className="flex items-center gap-3"> <div className="flex items-center gap-4">
<div className="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center"> <div className="w-12 h-12 bg-emerald-500 rounded-xl flex items-center justify-center flex-shrink-0">
<CheckCircle className="w-6 h-6 text-green-600" /> <CheckCircle className="w-6 h-6 text-white" />
</div> </div>
<div> <div>
<p className="text-sm text-slate-500">Paid</p> <p className="text-xs text-emerald-600 uppercase tracking-wider font-semibold mb-0.5">Paid</p>
<p className="text-2xl font-bold text-green-600">${metrics.paid.toLocaleString()}</p> <p className="text-2xl font-bold text-emerald-700">${metrics.paid.toLocaleString()}</p>
</div> </div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
{/* Smart Insights Banner */}
<div className="mb-6 bg-slate-100 rounded-2xl p-6 shadow-sm border border-slate-200">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-amber-500 rounded-xl flex items-center justify-center">
<Sparkles className="w-5 h-5 text-white" />
</div>
<div>
<h3 className="text-lg font-bold text-slate-900">Smart Insights</h3>
<p className="text-sm text-slate-500">AI-powered analysis of your invoice performance</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white rounded-xl p-4 border border-slate-200">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-slate-500">This Month</span>
<div className={`flex items-center gap-1 ${insights.isGrowth ? 'text-emerald-600' : 'text-red-600'}`}>
{insights.isGrowth ? <TrendingUp className="w-4 h-4" /> : <TrendingDown className="w-4 h-4" />}
<span className="text-xs font-bold">{insights.percentChange}%</span>
</div>
</div>
<p className="text-2xl font-bold text-slate-900">${insights.currentTotal.toLocaleString()}</p>
<p className="text-xs text-slate-400 mt-1">{insights.currentMonthCount} invoices</p>
</div>
<div className="bg-white rounded-xl p-4 border border-slate-200">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-slate-500">Avg. Payment Time</span>
<Calendar className="w-4 h-4 text-slate-400" />
</div>
<p className="text-2xl font-bold text-slate-900">{insights.avgDays} days</p>
<p className="text-xs text-slate-400 mt-1">From issue to payment</p>
</div>
<div className="bg-white rounded-xl p-4 border border-slate-200">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-slate-500">On-Time Rate</span>
<CheckCircle className="w-4 h-4 text-slate-400" />
</div>
<p className="text-2xl font-bold text-slate-900">{insights.onTimeRate}%</p>
<p className="text-xs text-slate-400 mt-1">Paid before due date</p>
</div>
<div className="bg-white rounded-xl p-4 border border-slate-200">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-slate-500">
{userRole === "client" ? "Best Hub" : "Top Client"}
</span>
<ArrowUpRight className="w-4 h-4 text-slate-400" />
</div>
{userRole === "client" ? (
<>
<p className="text-lg font-bold text-slate-900 truncate">{insights.bestHub?.hub || "—"}</p>
<p className="text-xs text-slate-400 mt-1">{insights.bestHub?.rate || 0}% on-time</p>
</>
) : (
<>
<p className="text-lg font-bold text-slate-900 truncate">{insights.topClient?.name || "—"}</p>
<p className="text-xs text-slate-400 mt-1">${insights.topClient?.amount.toLocaleString() || 0}</p>
</>
)}
</div>
</div>
</div>
{/* Search */} {/* Search */}
<div className="bg-white rounded-lg p-4 mb-6 border border-slate-200"> <div className="bg-white rounded-lg p-4 mb-6 border border-slate-200">
<div className="relative"> <div className="relative">
@@ -268,72 +448,87 @@ export default function Invoices() {
</div> </div>
{/* Invoices Table */} {/* Invoices Table */}
<Card className="border-slate-200"> <Card className="border-slate-200 shadow-lg">
<CardContent className="p-0"> <CardContent className="p-0">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className="bg-slate-50 hover:bg-slate-50"> <TableRow className="bg-slate-50 hover:bg-slate-50">
<TableHead>Invoice #</TableHead> <TableHead className="text-slate-600 font-semibold uppercase text-xs">Invoice #</TableHead>
<TableHead>Client</TableHead> <TableHead className="text-slate-600 font-semibold uppercase text-xs">Hub</TableHead>
<TableHead>Event</TableHead> <TableHead className="text-slate-600 font-semibold uppercase text-xs">Event</TableHead>
<TableHead>Vendor</TableHead> <TableHead className="text-slate-600 font-semibold uppercase text-xs">Manager</TableHead>
<TableHead>Issue Date</TableHead> <TableHead className="text-slate-600 font-semibold uppercase text-xs">Date & Time</TableHead>
<TableHead>Due Date</TableHead> <TableHead className="text-slate-600 font-semibold uppercase text-xs">Amount</TableHead>
<TableHead className="text-right">Amount</TableHead> <TableHead className="text-slate-600 font-semibold uppercase text-xs">Status</TableHead>
<TableHead>Status</TableHead> <TableHead className="text-slate-600 font-semibold uppercase text-xs">Action</TableHead>
<TableHead>Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{filteredInvoices.length === 0 ? ( {filteredInvoices.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={9} className="text-center py-12 text-slate-500"> <TableCell colSpan={8} className="text-center py-12 text-slate-500">
<FileText className="w-12 h-12 mx-auto mb-3 text-slate-300" /> <FileText className="w-12 h-12 mx-auto mb-3 text-slate-300" />
<p className="font-medium">No invoices found</p> <p className="font-medium">No invoices found</p>
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
filteredInvoices.map((invoice) => ( filteredInvoices.map((invoice) => {
<TableRow key={invoice.id} className="hover:bg-slate-50"> const invoiceDate = parseISO(invoice.issue_date);
<TableCell className="font-semibold">{invoice.invoice_number}</TableCell> const dayOfWeek = format(invoiceDate, 'EEEE');
<TableCell>{invoice.business_name}</TableCell> const dateFormatted = format(invoiceDate, 'MM.dd.yy');
<TableCell>{invoice.event_name}</TableCell>
<TableCell>{invoice.vendor_name || "—"}</TableCell> return (
<TableCell>{format(parseISO(invoice.issue_date), 'MMM dd, yyyy')}</TableCell> <TableRow key={invoice.id} className="hover:bg-slate-50 transition-all border-b border-slate-100">
<TableCell className={isPast(parseISO(invoice.due_date)) && invoice.status !== "Paid" ? "text-red-600 font-semibold" : ""}> <TableCell className="font-bold text-slate-900">{invoice.invoice_number}</TableCell>
{format(parseISO(invoice.due_date), 'MMM dd, yyyy')} <TableCell>
</TableCell> <div className="flex items-center gap-2">
<TableCell className="text-right font-bold">${invoice.amount?.toLocaleString()}</TableCell> <MapPin className="w-4 h-4 text-purple-600" />
<TableCell> <span className="text-slate-900 font-medium">{invoice.hub || "—"}</span>
<Badge className={statusColors[invoice.status]}> </div>
{invoice.status} </TableCell>
</Badge> <TableCell className="text-slate-900 font-medium">{invoice.event_name}</TableCell>
</TableCell> <TableCell>
<TableCell> <div className="flex items-center gap-2">
<div className="flex gap-2"> <User className="w-4 h-4 text-slate-400" />
<span className="text-slate-700">{invoice.manager_name || invoice.created_by || "—"}</span>
</div>
</TableCell>
<TableCell>
<div className="space-y-0.5">
<div className="text-slate-900 font-medium">{dateFormatted}</div>
<div className="flex items-center gap-1.5 text-xs text-slate-500">
<Clock className="w-3 h-3" />
<span>{dayOfWeek}</span>
</div>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<div className="w-5 h-5 bg-green-500 rounded-full flex items-center justify-center flex-shrink-0">
<DollarSign className="w-3 h-3 text-white" />
</div>
<span className="font-bold text-slate-900">${invoice.amount?.toLocaleString()}</span>
</div>
</TableCell>
<TableCell>
<Badge className={`${statusColors[invoice.status]} px-3 py-1 rounded-md text-xs`}>
{invoice.status}
</Badge>
</TableCell>
<TableCell>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => navigate(createPageUrl(`InvoiceDetail?id=${invoice.id}`))} onClick={() => navigate(createPageUrl(`InvoiceDetail?id=${invoice.id}`))}
className="font-semibold" className="font-semibold hover:bg-blue-50 hover:text-[#0A39DF]"
> >
<Eye className="w-4 h-4 mr-2" /> <Eye className="w-4 h-4 mr-2" />
View View
</Button> </Button>
{userRole === "vendor" && invoice.status === "Draft" && ( </TableCell>
<Button </TableRow>
variant="ghost" );
size="sm" })
onClick={() => navigate(createPageUrl(`InvoiceEditor?id=${invoice.id}`))}
className="font-semibold text-blue-600"
>
Edit
</Button>
)}
</div>
</TableCell>
</TableRow>
))
)} )}
</TableBody> </TableBody>
</Table> </Table>

View File

@@ -39,6 +39,8 @@ const roleNavigationMap = {
admin: [ admin: [
{ title: "Home", url: createPageUrl("Dashboard"), icon: LayoutDashboard }, { title: "Home", url: createPageUrl("Dashboard"), icon: LayoutDashboard },
{ title: "Orders", url: createPageUrl("Events"), icon: Calendar }, { 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: "Enterprises", url: createPageUrl("EnterpriseManagement"), icon: Building2 },
{ title: "Sectors", url: createPageUrl("SectorManagement"), icon: MapPin }, { title: "Sectors", url: createPageUrl("SectorManagement"), icon: MapPin },
{ title: "Partners", url: createPageUrl("PartnerManagement"), icon: Briefcase }, { title: "Partners", url: createPageUrl("PartnerManagement"), icon: Briefcase },
@@ -51,6 +53,7 @@ const roleNavigationMap = {
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText }, { title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
{ title: "Payroll", url: createPageUrl("Payroll"), icon: DollarSign }, { title: "Payroll", url: createPageUrl("Payroll"), icon: DollarSign },
{ title: "Certifications", url: createPageUrl("Certification"), icon: Award }, { title: "Certifications", url: createPageUrl("Certification"), icon: Award },
{ title: "Tutorials", url: createPageUrl("Tutorials"), icon: Sparkles },
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 }, { title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
{ title: "User Management", url: createPageUrl("UserManagement"), icon: Users }, { title: "User Management", url: createPageUrl("UserManagement"), icon: Users },
{ title: "Permissions", url: createPageUrl("Permissions"), icon: Shield }, { title: "Permissions", url: createPageUrl("Permissions"), icon: Shield },
@@ -109,6 +112,7 @@ const roleNavigationMap = {
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare }, { title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare }, { title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText }, { title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
{ title: "Tutorials", url: createPageUrl("Tutorials"), icon: Sparkles },
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 }, { title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
{ title: "Support", url: createPageUrl("Support"), icon: HelpCircle }, { title: "Support", url: createPageUrl("Support"), icon: HelpCircle },
], ],
@@ -117,7 +121,8 @@ const roleNavigationMap = {
{ title: "Orders", url: createPageUrl("VendorOrders"), icon: FileText }, { title: "Orders", url: createPageUrl("VendorOrders"), icon: FileText },
{ title: "Service Rates", url: createPageUrl("VendorRates"), icon: DollarSign }, { title: "Service Rates", url: createPageUrl("VendorRates"), icon: DollarSign },
{ title: "Invoices", url: createPageUrl("Invoices"), icon: Clipboard }, { 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: "Workforce", url: createPageUrl("StaffDirectory"), icon: Users },
{ title: "Onboard Staff", url: createPageUrl("StaffOnboarding"), icon: GraduationCap }, { title: "Onboard Staff", url: createPageUrl("StaffOnboarding"), icon: GraduationCap },
{ title: "Team", url: createPageUrl("Teams"), icon: UserCheck }, { title: "Team", url: createPageUrl("Teams"), icon: UserCheck },
@@ -132,6 +137,7 @@ const roleNavigationMap = {
], ],
workforce: [ workforce: [
{ title: "Home", url: createPageUrl("WorkforceDashboard"), icon: LayoutDashboard }, { title: "Home", url: createPageUrl("WorkforceDashboard"), icon: LayoutDashboard },
{ title: "Shift Requests", url: createPageUrl("WorkerShiftProposals"), icon: Calendar },
{ title: "Onboard Staff", url: createPageUrl("StaffOnboarding"), icon: GraduationCap }, { title: "Onboard Staff", url: createPageUrl("StaffOnboarding"), icon: GraduationCap },
{ title: "My Shifts", url: createPageUrl("WorkforceShifts"), icon: Calendar }, { title: "My Shifts", url: createPageUrl("WorkforceShifts"), icon: Calendar },
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck }, { title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },

View File

@@ -17,7 +17,6 @@ export default function Onboarding() {
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
const inviteCode = urlParams.get('invite'); const inviteCode = urlParams.get('invite');
const [step, setStep] = useState(1);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
first_name: "", first_name: "",
last_name: "", last_name: "",
@@ -154,14 +153,25 @@ export default function Onboarding() {
accepted_date: new Date().toISOString() 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: () => { onSuccess: () => {
setStep(4); // Registration complete - user will see success message in UI
toast({
title: "✅ Registration Successful!",
description: "You've been added to the team successfully.",
});
}, },
onError: (error) => { onError: (error) => {
toast({ toast({
@@ -172,49 +182,48 @@ export default function Onboarding() {
}, },
}); });
const handleNext = () => { const handleSubmit = async (e) => {
if (step === 1) { e.preventDefault();
// Validate basic info
if (!formData.first_name || !formData.last_name || !formData.email || !formData.phone) { // Validate all fields
toast({ if (!formData.first_name || !formData.last_name || !formData.email || !formData.phone) {
title: "Missing Information", toast({
description: "Please fill in your name, email, and phone number", title: "Missing Information",
variant: "destructive", description: "Please fill in your name, email, and phone number",
}); variant: "destructive",
return; });
} 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);
} }
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) { if (!inviteCode || !invite) {
@@ -242,9 +251,9 @@ export default function Onboarding() {
if (invite.invite_status === 'accepted') { if (invite.invite_status === 'accepted') {
return ( return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-50 flex items-center justify-center p-4"> <div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-50 flex items-center justify-center p-4">
<Card className="max-w-md w-full border-2 border-yellow-200"> <Card className="max-w-md w-full border-2 border-blue-200">
<CardContent className="p-12 text-center"> <CardContent className="p-12 text-center">
<div className="w-16 h-16 bg-yellow-100 rounded-full flex items-center justify-center mx-auto mb-6"> <div className="w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-6">
<span className="text-4xl"></span> <span className="text-4xl"></span>
</div> </div>
<h2 className="text-2xl font-bold text-[#1C323E] mb-4">Invitation Already Used</h2> <h2 className="text-2xl font-bold text-[#1C323E] mb-4">Invitation Already Used</h2>
@@ -262,7 +271,7 @@ export default function Onboarding() {
return ( return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-50 flex items-center justify-center p-4"> <div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-50 flex items-center justify-center p-4">
<div className="max-w-2xl w-full"> <div className="max-w-4xl w-full">
{/* Header */} {/* Header */}
<div className="text-center mb-8"> <div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-16 h-16 bg-gradient-to-br from-[#0A39DF] to-[#1C323E] rounded-full mb-4"> <div className="inline-flex items-center justify-center w-16 h-16 bg-gradient-to-br from-[#0A39DF] to-[#1C323E] rounded-full mb-4">
@@ -272,7 +281,7 @@ export default function Onboarding() {
Join {invite.team_name} Join {invite.team_name}
</h1> </h1>
{invite.hub && ( {invite.hub && (
<div className="inline-block bg-gradient-to-r from-blue-500 to-indigo-600 text-white px-6 py-2 rounded-full font-bold mb-3 shadow-lg"> <div className="inline-block bg-blue-600 text-white px-6 py-2 rounded-full font-bold mb-3 shadow-lg">
📍 {invite.hub} 📍 {invite.hub}
</div> </div>
)} )}
@@ -282,281 +291,216 @@ export default function Onboarding() {
</p> </p>
</div> </div>
{/* Progress Steps */} <form onSubmit={handleSubmit}>
{step < 4 && ( {registerMutation.isSuccess ? (
<div className="flex items-center justify-center mb-8"> <Card className="border-2 border-green-200 shadow-xl">
<div className="flex items-center gap-2"> <CardContent className="p-12 text-center">
<div className={`flex items-center justify-center w-10 h-10 rounded-full ${step >= 1 ? 'bg-[#0A39DF] text-white' : 'bg-slate-200 text-slate-500'}`}> <div className="inline-flex items-center justify-center w-20 h-20 bg-green-100 rounded-full mb-6">
{step > 1 ? <CheckCircle2 className="w-5 h-5" /> : '1'} <CheckCircle2 className="w-12 h-12 text-green-600" />
</div>
<div className={`w-20 h-1 ${step >= 2 ? 'bg-[#0A39DF]' : 'bg-slate-200'}`} />
<div className={`flex items-center justify-center w-10 h-10 rounded-full ${step >= 2 ? 'bg-[#0A39DF] text-white' : 'bg-slate-200 text-slate-500'}`}>
{step > 2 ? <CheckCircle2 className="w-5 h-5" /> : '2'}
</div>
<div className={`w-20 h-1 ${step >= 3 ? 'bg-[#0A39DF]' : 'bg-slate-200'}`} />
<div className={`flex items-center justify-center w-10 h-10 rounded-full ${step >= 3 ? 'bg-[#0A39DF] text-white' : 'bg-slate-200 text-slate-500'}`}>
{step > 3 ? <CheckCircle2 className="w-5 h-5" /> : '3'}
</div>
</div>
</div>
)}
{/* Step 1: Basic Information */}
{step === 1 && (
<Card className="border-2 border-slate-200 shadow-xl">
<CardHeader className="bg-gradient-to-r from-slate-50 to-blue-50">
<CardTitle className="flex items-center gap-2">
<User className="w-5 h-5 text-[#0A39DF]" />
Basic Information
</CardTitle>
</CardHeader>
<CardContent className="p-6 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="first_name">First Name *</Label>
<Input
id="first_name"
value={formData.first_name}
onChange={(e) => setFormData({ ...formData, first_name: e.target.value })}
placeholder="John"
className="mt-2"
/>
</div> </div>
<div> <h2 className="text-3xl font-bold text-[#1C323E] mb-4">
<Label htmlFor="last_name">Last Name *</Label> Welcome to {invite.team_name}! 🎉
<Input </h2>
id="last_name" <p className="text-slate-600 mb-2">
value={formData.last_name} Your registration has been completed successfully!
onChange={(e) => setFormData({ ...formData, last_name: e.target.value })} </p>
placeholder="Doe" <div className="bg-blue-50 border border-blue-200 p-6 rounded-lg mb-8">
className="mt-2" <h3 className="font-semibold text-blue-900 mb-3 text-lg">Your Profile Summary:</h3>
/> <div className="space-y-2 text-sm text-slate-700">
<p><strong>Name:</strong> {formData.first_name} {formData.last_name}</p>
<p><strong>Email:</strong> {formData.email}</p>
<p><strong>Title:</strong> {formData.title}</p>
<p><strong>Department:</strong> {formData.department}</p>
{formData.hub && <p><strong>Hub:</strong> {formData.hub}</p>}
<p><strong>Team:</strong> {invite.team_name}</p>
<p><strong>Role:</strong> {invite.role}</p>
</div>
</div> </div>
</div> <div className="bg-white border-2 border-blue-600 p-4 rounded-lg mb-6">
<p className="text-slate-700 mb-2">
<strong>Next Step:</strong> Please sign in with your new credentials to access your dashboard.
</p>
<p className="text-sm text-slate-500">
Use the email <strong>{formData.email}</strong> and the password you just created.
</p>
</div>
<Button
onClick={() => base44.auth.redirectToLogin()}
className="w-full bg-blue-600 hover:bg-blue-700 text-white h-12 text-lg font-bold"
>
Sign In Now →
</Button>
</CardContent>
</Card>
) : (
<Card className="border-2 border-slate-200 shadow-xl">
<CardHeader className="bg-gradient-to-r from-slate-50 to-blue-50 border-b">
<CardTitle className="text-xl">Complete Your Registration</CardTitle>
<p className="text-sm text-slate-500 mt-2">Fill in all the details below to join the team</p>
</CardHeader>
<CardContent className="p-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Basic Information */}
<div className="md:col-span-2">
<div className="flex items-center gap-2 mb-4 pb-2 border-b">
<User className="w-5 h-5 text-[#0A39DF]" />
<h3 className="font-bold text-[#1C323E]">Basic Information</h3>
</div>
</div>
<div>
<Label htmlFor="first_name">First Name *</Label>
<Input
id="first_name"
value={formData.first_name}
onChange={(e) => setFormData({ ...formData, first_name: e.target.value })}
placeholder="John"
className="mt-2"
/>
</div>
<div>
<Label htmlFor="last_name">Last Name *</Label>
<Input
id="last_name"
value={formData.last_name}
onChange={(e) => setFormData({ ...formData, last_name: e.target.value })}
placeholder="Doe"
className="mt-2"
/>
</div>
<div> <div>
<Label htmlFor="email">Email *</Label> <Label htmlFor="email">Email *</Label>
<Input <Input
id="email" id="email"
type="email" type="email"
value={formData.email} value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })} onChange={(e) => setFormData({ ...formData, email: e.target.value })}
placeholder="john@example.com" placeholder="john@example.com"
className="mt-2" className="mt-2"
disabled={!!invite} disabled={!!invite}
/> />
</div> </div>
<div> <div>
<Label htmlFor="phone">Phone Number *</Label> <Label htmlFor="phone">Phone Number *</Label>
<Input <Input
id="phone" id="phone"
type="tel" type="tel"
value={formData.phone} value={formData.phone}
onChange={(e) => setFormData({ ...formData, phone: e.target.value })} onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
placeholder="+1 (555) 123-4567" placeholder="+1 (555) 123-4567"
className="mt-2" className="mt-2"
/> />
<p className="text-xs text-slate-500 mt-1">You can edit this if needed</p> </div>
</div>
<Button {/* Work Information */}
onClick={handleNext} <div className="md:col-span-2">
className="w-full bg-gradient-to-r from-[#0A39DF] to-[#1C323E] hover:opacity-90" <div className="flex items-center gap-2 mb-4 pb-2 border-b mt-4">
> <Briefcase className="w-5 h-5 text-[#0A39DF]" />
Continue <h3 className="font-bold text-[#1C323E]">Work Information</h3>
</Button> </div>
</CardContent> </div>
</Card>
)}
{/* Step 2: Work Information */} <div>
{step === 2 && ( <Label htmlFor="title">Job Title *</Label>
<Card className="border-2 border-slate-200 shadow-xl"> <Input
<CardHeader className="bg-gradient-to-r from-slate-50 to-blue-50"> id="title"
<CardTitle className="flex items-center gap-2"> value={formData.title}
<Briefcase className="w-5 h-5 text-[#0A39DF]" /> onChange={(e) => setFormData({ ...formData, title: e.target.value })}
Work Information placeholder="e.g., Manager, Coordinator"
</CardTitle> className="mt-2"
</CardHeader> />
<CardContent className="p-6 space-y-4"> </div>
<div>
<Label htmlFor="title">Job Title *</Label>
<Input
id="title"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
placeholder="e.g., Manager, Coordinator, Supervisor"
className="mt-2"
/>
</div>
<div> <div>
<Label htmlFor="department">Department *</Label> <Label htmlFor="department">Department *</Label>
<Select value={formData.department} onValueChange={(value) => setFormData({ ...formData, department: value })}> <Select value={formData.department} onValueChange={(value) => setFormData({ ...formData, department: value })}>
<SelectTrigger className="mt-2"> <SelectTrigger className="mt-2">
<SelectValue placeholder="Select department" /> <SelectValue placeholder="Select department" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{availableDepartments.length > 0 ? ( {availableDepartments.length > 0 ? (
availableDepartments.map((dept) => ( availableDepartments.map((dept) => (
<SelectItem key={dept} value={dept}> <SelectItem key={dept} value={dept}>
{dept} {dept}
</SelectItem> </SelectItem>
)) ))
) : ( ) : (
<SelectItem value="Operations">Operations</SelectItem> <SelectItem value="Operations">Operations</SelectItem>
)} )}
</SelectContent> </SelectContent>
</Select> </Select>
{formData.department && ( </div>
<p className="text-xs text-slate-500 mt-1">✓ Pre-filled from your invitation</p>
)}
</div>
{hubs.length > 0 && ( {hubs.length > 0 && (
<div> <div className="md:col-span-2">
<Label htmlFor="hub">Hub Location</Label> <Label htmlFor="hub">Hub Location</Label>
<Select value={formData.hub} onValueChange={(value) => setFormData({ ...formData, hub: value })}> <Select value={formData.hub} onValueChange={(value) => setFormData({ ...formData, hub: value })}>
<SelectTrigger className="mt-2"> <SelectTrigger className="mt-2">
<SelectValue placeholder="Select hub location" /> <SelectValue placeholder="Select hub location" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value={null}>No Hub</SelectItem> <SelectItem value={null}>No Hub</SelectItem>
{hubs.map((hub) => ( {hubs.map((hub) => (
<SelectItem key={hub.id} value={hub.hub_name}> <SelectItem key={hub.id} value={hub.hub_name}>
{hub.hub_name} {hub.hub_name}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
{formData.hub && ( {formData.hub && (
<p className="text-xs text-blue-600 font-semibold mt-1">📍 You're joining {formData.hub}!</p> <p className="text-xs text-blue-600 font-semibold mt-1 bg-blue-50 p-2 rounded">📍 You're joining {formData.hub}!</p>
)}
</div>
)} )}
{/* Account Security */}
<div className="md:col-span-2">
<div className="flex items-center gap-2 mb-4 pb-2 border-b mt-4">
<Lock className="w-5 h-5 text-[#0A39DF]" />
<h3 className="font-bold text-[#1C323E]">Account Security</h3>
</div>
</div>
<div>
<Label htmlFor="password">Password *</Label>
<Input
id="password"
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
placeholder="••••••••"
className="mt-2"
/>
<p className="text-xs text-slate-500 mt-1">Minimum 6 characters</p>
</div>
<div>
<Label htmlFor="confirmPassword">Confirm Password *</Label>
<Input
id="confirmPassword"
type="password"
value={formData.confirmPassword}
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
placeholder="••••••••"
className="mt-2"
/>
</div>
</div> </div>
)}
<div className="flex gap-3">
<Button <Button
variant="outline" type="submit"
onClick={() => setStep(1)}
className="flex-1"
>
Back
</Button>
<Button
onClick={handleNext}
className="flex-1 bg-gradient-to-r from-[#0A39DF] to-[#1C323E] hover:opacity-90"
>
Continue
</Button>
</div>
</CardContent>
</Card>
)}
{/* Step 3: Create Password */}
{step === 3 && (
<Card className="border-2 border-slate-200 shadow-xl">
<CardHeader className="bg-gradient-to-r from-slate-50 to-blue-50">
<CardTitle className="flex items-center gap-2">
<Lock className="w-5 h-5 text-[#0A39DF]" />
Create Your Password
</CardTitle>
</CardHeader>
<CardContent className="p-6 space-y-4">
<div>
<Label htmlFor="password">Password *</Label>
<Input
id="password"
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
placeholder="••••••••"
className="mt-2"
/>
<p className="text-xs text-slate-500 mt-1">Minimum 6 characters</p>
</div>
<div>
<Label htmlFor="confirmPassword">Confirm Password *</Label>
<Input
id="confirmPassword"
type="password"
value={formData.confirmPassword}
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
placeholder="••••••••"
className="mt-2"
/>
</div>
<div className="bg-blue-50 p-4 rounded-lg border border-blue-200">
<h4 className="font-semibold text-[#1C323E] mb-2">Review Your Information:</h4>
<div className="space-y-1 text-sm text-slate-600">
<p><strong>Name:</strong> {formData.first_name} {formData.last_name}</p>
<p><strong>Email:</strong> {formData.email}</p>
<p><strong>Phone:</strong> {formData.phone}</p>
<p><strong>Title:</strong> {formData.title}</p>
<p><strong>Department:</strong> {formData.department}</p>
{formData.hub && <p><strong>Hub:</strong> {formData.hub}</p>}
<p><strong>Role:</strong> {invite.role}</p>
</div>
</div>
<div className="flex gap-3">
<Button
variant="outline"
onClick={() => setStep(2)}
className="flex-1"
disabled={registerMutation.isPending} disabled={registerMutation.isPending}
className="w-full mt-8 bg-blue-600 hover:bg-blue-700 text-white h-12 text-lg font-bold"
> >
Back {registerMutation.isPending ? 'Creating Account...' : '🚀 Complete Registration'}
</Button> </Button>
<Button </CardContent>
onClick={handleNext} </Card>
disabled={registerMutation.isPending} )}
className="flex-1 bg-gradient-to-r from-[#0A39DF] to-[#1C323E] hover:opacity-90" </form>
>
{registerMutation.isPending ? 'Creating Account...' : 'Complete Registration'}
</Button>
</div>
</CardContent>
</Card>
)}
{/* Step 4: Success */}
{step === 4 && (
<Card className="border-2 border-green-200 shadow-xl">
<CardContent className="p-12 text-center">
<div className="inline-flex items-center justify-center w-20 h-20 bg-green-100 rounded-full mb-6">
<CheckCircle2 className="w-12 h-12 text-green-600" />
</div>
<h2 className="text-3xl font-bold text-[#1C323E] mb-4">
Welcome to the Team! 🎉
</h2>
<p className="text-slate-600 mb-2">
Your account has been created successfully!
</p>
<div className="bg-slate-50 p-4 rounded-lg mb-8 text-left">
<h3 className="font-semibold text-[#1C323E] mb-2">Your Profile:</h3>
<div className="space-y-1 text-sm text-slate-600">
<p><strong>Name:</strong> {formData.first_name} {formData.last_name}</p>
<p><strong>Email:</strong> {formData.email}</p>
<p><strong>Title:</strong> {formData.title}</p>
<p><strong>Department:</strong> {formData.department}</p>
{formData.hub && <p><strong>Hub:</strong> {formData.hub}</p>}
<p><strong>Team:</strong> {invite.team_name}</p>
</div>
</div>
<Button
onClick={() => navigate(createPageUrl("Dashboard"))}
className="bg-gradient-to-r from-[#0A39DF] to-[#1C323E] hover:opacity-90"
>
Go to Dashboard
</Button>
</CardContent>
</Card>
)}
</div> </div>
</div> </div>
); );

File diff suppressed because it is too large Load Diff

View File

@@ -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 (
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
<div className="max-w-[1800px] mx-auto">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-3xl font-bold text-slate-900">Schedule</h1>
<p className="text-sm text-slate-500 mt-1">Plan and manage staff shifts</p>
</div>
<Button
onClick={() => navigate(createPageUrl('CreateEvent'))}
className="bg-blue-600 hover:bg-blue-700 text-white"
>
<Plus className="w-4 h-4 mr-2" />
New Shift
</Button>
</div>
{/* Metrics Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<Card className="border border-blue-200 bg-blue-50">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-blue-600 font-medium">Week Total Hours</p>
<p className="text-4xl font-bold text-blue-900 mt-2">{metrics.totalHours.toFixed(1)}</p>
</div>
<Clock className="w-10 h-10 text-blue-400" />
</div>
</CardContent>
</Card>
<Card className="border border-green-200 bg-green-50">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-green-600 font-medium">Week Labor Cost</p>
<p className="text-4xl font-bold text-green-900 mt-2">${metrics.totalCost.toLocaleString()}</p>
</div>
<DollarSign className="w-10 h-10 text-green-400" />
</div>
</CardContent>
</Card>
<Card className="border border-teal-200 bg-teal-50">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-teal-600 font-medium">Total Shifts</p>
<p className="text-4xl font-bold text-teal-900 mt-2">{metrics.totalShifts}</p>
</div>
<CalendarIcon className="w-10 h-10 text-teal-400" />
</div>
</CardContent>
</Card>
</div>
{/* Week Navigation */}
<Card className="mb-6">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<Button variant="ghost" size="icon" onClick={goToPreviousWeek}>
<ChevronLeft className="w-5 h-5" />
</Button>
<div className="text-center">
<p className="text-sm text-slate-500">Week of</p>
<p className="text-lg font-bold text-slate-900">{format(currentWeek, 'MMM d, yyyy')}</p>
</div>
<Button variant="ghost" size="icon" onClick={goToNextWeek}>
<ChevronRight className="w-5 h-5" />
</Button>
<Button variant="outline" onClick={goToToday}>
Today
</Button>
</div>
</CardContent>
</Card>
{/* Weekly Calendar */}
<div className="grid grid-cols-7 gap-3">
{weekDays.map((day, index) => {
const dayEvents = getEventsForDay(day);
const isTodayDay = isToday(day);
return (
<Card
key={index}
className={`${isTodayDay ? 'bg-gradient-to-br from-blue-500 to-teal-500 text-white border-blue-600' : 'bg-white border-slate-200'}`}
>
<CardContent className="p-4">
{/* Day Header */}
<div className="text-center mb-4">
<p className={`text-xs font-medium ${isTodayDay ? 'text-white/80' : 'text-slate-500'}`}>
{format(day, 'EEE')}
</p>
<p className={`text-2xl font-bold ${isTodayDay ? 'text-white' : 'text-slate-900'}`}>
{format(day, 'd')}
</p>
<p className={`text-xs ${isTodayDay ? 'text-white/80' : 'text-slate-500'}`}>
{format(day, 'MMM')}
</p>
</div>
{/* Add Shift Button */}
<Button
variant={isTodayDay ? "secondary" : "outline"}
size="sm"
className={`w-full mb-4 ${isTodayDay ? 'bg-white/20 hover:bg-white/30 text-white border-white/40' : ''}`}
onClick={() => navigate(createPageUrl('CreateEvent'))}
>
<Plus className="w-3 h-3 mr-1" />
Add Shift
</Button>
{/* Events List */}
<div className="space-y-2">
{dayEvents.length === 0 ? (
<p className={`text-xs text-center ${isTodayDay ? 'text-white/70' : 'text-slate-400'}`}>
No shifts
</p>
) : (
dayEvents.map((event) => {
const firstShift = event.shifts?.[0];
const firstRole = firstShift?.roles?.[0];
const firstStaff = event.assigned_staff?.[0];
return (
<div
key={event.id}
onClick={() => 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 */}
<div className="flex gap-1 mb-2 flex-wrap">
{firstRole?.role && (
<span className="px-2 py-0.5 bg-blue-100 text-blue-700 text-[10px] font-medium rounded">
{firstRole.role}
</span>
)}
<span className="px-2 py-0.5 bg-green-100 text-green-700 text-[10px] font-medium rounded">
{event.status || 'scheduled'}
</span>
</div>
{/* Staff Member */}
{firstStaff && (
<p className={`text-xs font-semibold mb-1 flex items-center gap-1 ${isTodayDay ? 'text-white' : 'text-slate-900'}`}>
<span className="text-[10px]">👤</span>
{firstStaff.staff_name}
</p>
)}
{/* Time */}
{firstRole && (firstRole.start_time || firstRole.end_time) && (
<p className={`text-[10px] mb-1 flex items-center gap-1 ${isTodayDay ? 'text-white/80' : 'text-slate-500'}`}>
<Clock className="w-3 h-3" />
{firstRole.start_time || '00:00'} - {firstRole.end_time || '00:00'}
</p>
)}
{/* Cost */}
{event.total > 0 && (
<p className={`text-xs font-bold mt-2 ${isTodayDay ? 'text-white' : 'text-slate-900'}`}>
${event.total.toFixed(2)}
</p>
)}
</div>
);
})
)}
</div>
</CardContent>
</Card>
);
})}
</div>
</div>
</div>
);
}

View File

@@ -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 <Badge className={`${config.bg} ${config.text} text-[10px]`}>{config.label}</Badge>;
};
return (
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
<div className="max-w-[1800px] mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900">Staff Availability</h1>
<p className="text-sm text-slate-500 mt-1">
Showing {filteredAvailability.length} of {availabilityData.length} workers
</p>
</div>
<div className="flex items-center gap-1 bg-gradient-to-r from-blue-50 to-indigo-50 p-2 rounded-xl border-2 border-blue-200">
<Button
variant="ghost"
size="sm"
onClick={() => setViewMode("cards")}
className={viewMode === "cards" ? "bg-white text-slate-900 shadow-sm hover:bg-white" : "text-slate-600 hover:bg-white/50"}
>
<LayoutGrid className="w-4 h-4 mr-2" />
Grid
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setViewMode("table")}
className={viewMode === "table" ? "bg-[#0A39DF] text-white hover:bg-blue-700 shadow-lg" : "text-slate-600 hover:bg-white/50"}
>
<List className="w-4 h-4 mr-2" />
List
</Button>
</div>
</div>
{/* Metrics Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card className="border border-slate-200 bg-slate-50/50 hover:shadow-md transition-all">
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-600 text-xs mb-2 font-semibold uppercase tracking-wide">Needs Work</p>
<p className="text-3xl font-bold text-slate-900 mb-0.5">{metrics.needsWork}</p>
<p className="text-slate-500 text-xs">Available workers</p>
</div>
<div className="w-12 h-12 bg-white border border-slate-200 shadow-sm rounded-xl flex items-center justify-center">
<TrendingDown className="w-6 h-6 text-slate-600" />
</div>
</div>
</CardContent>
</Card>
<Card className="border border-green-200 bg-green-50 hover:shadow-md transition-all">
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-600 text-xs mb-2 font-semibold uppercase tracking-wide">Fully Booked</p>
<p className="text-3xl font-bold text-slate-900 mb-0.5">{metrics.fullyBooked}</p>
<p className="text-slate-500 text-xs">At capacity</p>
</div>
<div className="w-12 h-12 bg-white border border-green-200 shadow-sm rounded-xl flex items-center justify-center">
<CheckCircle className="w-6 h-6 text-green-600" />
</div>
</div>
</CardContent>
</Card>
<Card className="border border-teal-200 bg-teal-50 hover:shadow-md transition-all">
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-600 text-xs mb-2 font-semibold uppercase tracking-wide">Active</p>
<p className="text-3xl font-bold text-slate-900 mb-0.5">{metrics.hasUtilization}</p>
<p className="text-slate-500 text-xs">Working now</p>
</div>
<div className="w-12 h-12 bg-white border border-teal-200 shadow-sm rounded-xl flex items-center justify-center">
<TrendingUp className="w-6 h-6 text-teal-600" />
</div>
</div>
</CardContent>
</Card>
<Card className="border border-blue-200 bg-blue-50 hover:shadow-md transition-all">
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-600 text-xs mb-2 font-semibold uppercase tracking-wide">On Time Off</p>
<p className="text-3xl font-bold text-slate-900 mb-0.5">{metrics.onTimeOff}</p>
<p className="text-slate-500 text-xs">Unavailable</p>
</div>
<div className="w-12 h-12 bg-white border border-blue-200 shadow-sm rounded-xl flex items-center justify-center">
<XCircle className="w-6 h-6 text-blue-600" />
</div>
</div>
</CardContent>
</Card>
</div>
{/* Search and Filters */}
<Card className="border border-slate-200 shadow-sm bg-white">
<CardContent className="p-5">
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
<div className="md:col-span-2 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
placeholder="Search by name..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 h-10 border border-slate-300 focus:border-[#0A39DF]"
/>
</div>
<Select value={filterStatus} onValueChange={setFilterStatus}>
<SelectTrigger className="h-10 border border-slate-300">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="CONFIRMED_AVAILABLE">Available</SelectItem>
<SelectItem value="UNKNOWN">Unknown</SelectItem>
<SelectItem value="BLOCKED">Blocked</SelectItem>
</SelectContent>
</Select>
<Select value={filterUtilization} onValueChange={setFilterUtilization}>
<SelectTrigger className="h-10 border border-slate-300">
<SelectValue placeholder="Utilization" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Utilization</SelectItem>
<SelectItem value="underutilized">&lt; 50%</SelectItem>
<SelectItem value="optimal">50-99%</SelectItem>
<SelectItem value="full">100%+</SelectItem>
</SelectContent>
</Select>
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="h-10 border border-slate-300">
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem value="need_work_index">Hours Gap</SelectItem>
<SelectItem value="utilization">Utilization</SelectItem>
<SelectItem value="availability_score">Availability Score</SelectItem>
<SelectItem value="name">Name</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
{/* Main Content - Table or Cards View */}
{viewMode === "table" ? (
<Card className="border border-slate-200 shadow-sm bg-white">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow className="bg-gradient-to-r from-slate-50 to-blue-50 border-b border-slate-200 hover:bg-gradient-to-r">
<TableHead className="font-bold text-slate-700 uppercase text-xs">Name</TableHead>
<TableHead className="font-bold text-slate-700 uppercase text-xs">Status</TableHead>
<TableHead className="text-center font-bold text-slate-700 uppercase text-xs">Hours</TableHead>
<TableHead className="text-center font-bold text-slate-700 uppercase text-xs">Utilization</TableHead>
<TableHead className="text-center font-bold text-slate-700 uppercase text-xs">Hours Gap</TableHead>
<TableHead className="text-center font-bold text-slate-700 uppercase text-xs">Acceptance</TableHead>
<TableHead className="text-center font-bold text-slate-700 uppercase text-xs">Last Shift</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paginatedData.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-8 text-slate-500">
No workers found
</TableCell>
</TableRow>
) : (
paginatedData.map((worker) => (
<TableRow key={worker.id}>
<TableCell>
<div className="flex items-center gap-3">
<Avatar className="w-8 h-8">
<AvatarFallback className="bg-blue-100 text-blue-700 text-xs font-semibold">
{worker.staff_name?.charAt(0) || "?"}
</AvatarFallback>
</Avatar>
<span className="font-medium">{worker.staff_name}</span>
</div>
</TableCell>
<TableCell className="py-4">
{getStatusBadge(worker)}
</TableCell>
<TableCell className="text-center py-4">
<span className="font-bold text-slate-900">
{worker.scheduled_hours_this_period}h
</span>
<span className="text-slate-500"> / {worker.desired_hours_this_period}h</span>
</TableCell>
<TableCell className="text-center py-4">
<div className="flex flex-col items-center gap-2">
<span className={`font-bold text-lg ${getUtilizationColor(worker.utilization_percentage)}`}>
{Math.round(worker.utilization_percentage)}%
</span>
<div className="w-full max-w-[120px] h-2 bg-slate-200 rounded-full overflow-hidden">
<div
className={`h-full ${
worker.utilization_percentage < 50 ? 'bg-red-500' :
worker.utilization_percentage < 80 ? 'bg-amber-500' :
'bg-green-500'
}`}
style={{ width: `${Math.min(100, worker.utilization_percentage)}%` }}
/>
</div>
</div>
</TableCell>
<TableCell className="text-center py-4">
{worker.scheduled_hours_this_period < worker.desired_hours_this_period ? (
<Badge className="bg-red-100 text-red-800 font-bold border border-red-200">
Needs {worker.desired_hours_this_period - worker.scheduled_hours_this_period}h
</Badge>
) : (
<Badge className="bg-green-100 text-green-800 font-bold border border-green-200">
Fully booked
</Badge>
)}
</TableCell>
<TableCell className="text-center py-4">
<span className="font-bold text-slate-900">{worker.acceptance_rate || 0}%</span>
</TableCell>
<TableCell className="text-center text-sm text-slate-700 font-medium py-4">
{worker.last_shift_date ? format(new Date(worker.last_shift_date), 'MMM d') : '-'}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{paginatedData.map((worker) => {
const staff = allStaff.find(s => s.id === worker.staff_id);
return (
<Card key={worker.id} className="bg-white border border-slate-200 hover:border-blue-300 hover:shadow-lg transition-all">
<CardContent className="p-5">
<div className="flex items-start gap-3">
<Avatar className="w-14 h-14 bg-blue-100 shadow-md ring-2 ring-blue-200">
<AvatarFallback className="bg-blue-100 text-blue-700 font-bold text-lg">
{worker.staff_name?.charAt(0)?.toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="font-bold text-slate-900 truncate text-base">{worker.staff_name}</p>
<p className="text-xs text-slate-600 font-medium">{staff?.position || 'Staff'}</p>
<div className="flex gap-1.5 mt-3 flex-wrap">
{getStatusBadge(worker)}
{worker.scheduled_hours_this_period < worker.desired_hours_this_period && (
<Badge className="bg-red-100 text-red-800 font-bold border border-red-200">
Needs {worker.desired_hours_this_period - worker.scheduled_hours_this_period}h
</Badge>
)}
{worker.scheduled_hours_this_period >= worker.desired_hours_this_period && (
<Badge className="bg-green-100 text-green-800 font-bold border border-green-200">
Fully booked
</Badge>
)}
</div>
<div className="mt-4 space-y-3">
<div className="bg-slate-50/50 border border-slate-200 rounded-lg p-3">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-bold text-slate-700 uppercase tracking-wide">Weekly Hours</span>
<span className="text-sm font-bold text-slate-900">
{worker.scheduled_hours_this_period}h / {worker.desired_hours_this_period}h
</span>
</div>
<div className="w-full h-2 bg-slate-200 rounded-full overflow-hidden">
<div
className={`h-full transition-all ${
worker.utilization_percentage < 50 ? 'bg-red-500' :
worker.utilization_percentage < 80 ? 'bg-amber-500' :
'bg-green-500'
}`}
style={{ width: `${Math.min(100, worker.utilization_percentage)}%` }}
/>
</div>
</div>
{worker.last_shift_date && (
<div className="flex items-center gap-2 text-xs text-slate-600">
<Clock className="w-3.5 h-3.5" />
<span>Last shift: {format(new Date(worker.last_shift_date), 'MMM d')}</span>
</div>
)}
</div>
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
)}
{/* Pagination */}
<Card className="border border-slate-200 shadow-sm bg-white">
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<p className="text-sm text-slate-700 font-medium">
Showing {((currentPage - 1) * itemsPerPage) + 1} to {Math.min(currentPage * itemsPerPage, filteredAvailability.length)} of {filteredAvailability.length} workers
</p>
<Select value={itemsPerPage.toString()} onValueChange={(val) => { setItemsPerPage(parseInt(val)); setCurrentPage(1); }}>
<SelectTrigger className="w-24 h-9 border border-slate-300">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="25">25</SelectItem>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
<SelectItem value="250">250</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="border-slate-300 hover:bg-blue-50 disabled:opacity-50"
>
<ChevronLeft className="w-4 h-4" />
</Button>
<span className="text-sm font-bold px-4 text-slate-900">
Page {currentPage} of {totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="border-slate-300 hover:bg-blue-50 disabled:opacity-50"
>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -647,7 +647,12 @@ export default function TeamDetails() {
<MapPin className="w-6 h-6" /> <MapPin className="w-6 h-6" />
</div> </div>
<div className="flex-1"> <div className="flex-1">
<h3 className="font-bold text-lg text-[#1C323E]">{hub.hub_name}</h3> <div className="flex items-center gap-2 mb-1">
<h3 className="font-bold text-lg text-[#1C323E]">{hub.hub_name}</h3>
<Badge className="bg-[#0A39DF] text-white">
{members.filter(m => m.hub === hub.hub_name).length} members
</Badge>
</div>
{hub.manager_name && ( {hub.manager_name && (
<p className="text-sm text-slate-500">Manager: {hub.manager_name}</p> <p className="text-sm text-slate-500">Manager: {hub.manager_name}</p>
)} )}

File diff suppressed because it is too large Load Diff

View File

@@ -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 (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 p-4 md:p-8">
<div className="max-w-7xl mx-auto">
<PageHeader
title="📚 Tutorial Library"
subtitle="Learn how to use KROW Workforce with step-by-step video guides"
/>
{/* Search and Filters */}
<div className="mb-8 flex flex-col md:flex-row gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-slate-400" />
<Input
placeholder="Search tutorials..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 h-12 border-slate-300 focus:border-[#0A39DF]"
/>
</div>
<div className="flex gap-2 flex-wrap">
{categories.map(category => (
<Button
key={category}
variant={selectedCategory === category ? "default" : "outline"}
onClick={() => setSelectedCategory(category)}
className={selectedCategory === category ? "bg-[#0A39DF]" : ""}
>
{category === "all" ? "All Tutorials" : category}
</Button>
))}
</div>
</div>
{/* Tutorials Grid */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-12">
{filteredTutorials.map((tutorial) => (
<Card key={tutorial.id} className="border-2 border-slate-200 hover:border-[#0A39DF] hover:shadow-2xl transition-all duration-300 overflow-hidden group">
<CardHeader className="bg-gradient-to-r from-slate-50 to-blue-50 border-b border-slate-100">
<div className="flex items-start justify-between">
<div className="flex items-start gap-4">
<div className="w-12 h-12 bg-gradient-to-br from-[#0A39DF] to-[#1C323E] rounded-xl flex items-center justify-center text-white shadow-lg">
<tutorial.icon className="w-6 h-6" />
</div>
<div className="flex-1">
<CardTitle className="text-lg text-[#1C323E] group-hover:text-[#0A39DF] transition-colors">
{tutorial.title}
</CardTitle>
<p className="text-sm text-slate-600 mt-1">{tutorial.description}</p>
</div>
</div>
<Badge className="bg-blue-100 text-blue-700 border-blue-200">
{tutorial.duration}
</Badge>
</div>
</CardHeader>
<CardContent className="p-0">
{/* Video Player */}
{playingVideo === tutorial.id ? (
<div className="relative bg-black aspect-video">
<iframe
src={tutorial.videoUrl}
className="w-full h-full"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</div>
) : (
<div
onClick={() => setPlayingVideo(tutorial.id)}
className="relative bg-gradient-to-br from-slate-200 to-slate-300 aspect-video flex items-center justify-center group-hover:from-blue-100 group-hover:to-indigo-200 transition-all cursor-pointer"
>
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-20 h-20 bg-gradient-to-br from-[#0A39DF] to-[#1C323E] rounded-full flex items-center justify-center shadow-2xl group-hover:scale-110 transition-transform">
<Play className="w-10 h-10 text-white ml-1" />
</div>
</div>
<div className="absolute bottom-4 left-4 bg-black/70 backdrop-blur-sm text-white px-4 py-2 rounded-lg text-sm font-semibold">
Watch Tutorial
</div>
</div>
)}
{/* Steps */}
<div className="p-6 bg-white">
<h4 className="font-bold text-slate-700 mb-3 text-sm uppercase tracking-wide">What You'll Learn:</h4>
<ul className="space-y-2">
{tutorial.steps.map((step, idx) => (
<li key={idx} className="flex items-start gap-3 text-sm text-slate-600">
<div className="w-6 h-6 bg-blue-100 text-blue-700 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0 mt-0.5">
{idx + 1}
</div>
<span>{step}</span>
</li>
))}
</ul>
</div>
</CardContent>
</Card>
))}
</div>
{/* No Results */}
{filteredTutorials.length === 0 && (
<div className="text-center py-16 bg-white rounded-xl border-2 border-dashed border-slate-200">
<Search className="w-16 h-16 mx-auto text-slate-300 mb-4" />
<h3 className="text-xl font-semibold text-slate-700 mb-2">No Tutorials Found</h3>
<p className="text-slate-500">Try adjusting your search or filters</p>
</div>
)}
{/* Support Section */}
<Card className="border-2 border-blue-200 bg-gradient-to-br from-blue-50 to-indigo-50">
<CardContent className="p-8 text-center">
<div className="w-20 h-20 mx-auto mb-6 bg-gradient-to-br from-blue-100 to-indigo-100 rounded-full flex items-center justify-center">
<Headphones className="w-10 h-10 text-blue-600" />
</div>
<h2 className="text-2xl font-bold text-[#1C323E] mb-3">
Questions about KROW Workforce?
</h2>
<p className="text-slate-600 mb-6 text-lg">
Contact KROW support team for personalized help
</p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
<Button size="lg" className="bg-gradient-to-r from-[#0A39DF] to-[#1C323E] text-white shadow-lg">
<MessageSquare className="w-5 h-5 mr-2" />
Chat with Support
</Button>
<Button size="lg" variant="outline" className="border-2 border-blue-300 hover:bg-blue-50">
<Mail className="w-5 h-5 mr-2" />
Email: support@krow.com
</Button>
</div>
</CardContent>
</Card>
{/* Quick Links */}
<div className="mt-8 grid grid-cols-1 md:grid-cols-3 gap-6">
<Card className="border-slate-200 hover:shadow-lg transition-all">
<CardContent className="p-6 text-center">
<div className="w-14 h-14 mx-auto mb-4 bg-green-100 rounded-full flex items-center justify-center">
<FileText className="w-7 h-7 text-green-600" />
</div>
<h3 className="font-bold text-[#1C323E] mb-2">Documentation</h3>
<p className="text-sm text-slate-600 mb-4">Read the complete API docs</p>
<Button variant="outline" size="sm" className="w-full">
View Docs
</Button>
</CardContent>
</Card>
<Card className="border-slate-200 hover:shadow-lg transition-all">
<CardContent className="p-6 text-center">
<div className="w-14 h-14 mx-auto mb-4 bg-purple-100 rounded-full flex items-center justify-center">
<Users className="w-7 h-7 text-purple-600" />
</div>
<h3 className="font-bold text-[#1C323E] mb-2">Community Forum</h3>
<p className="text-sm text-slate-600 mb-4">Connect with other users</p>
<Button variant="outline" size="sm" className="w-full">
Join Forum
</Button>
</CardContent>
</Card>
<Card className="border-slate-200 hover:shadow-lg transition-all">
<CardContent className="p-6 text-center">
<div className="w-14 h-14 mx-auto mb-4 bg-amber-100 rounded-full flex items-center justify-center">
<Award className="w-7 h-7 text-amber-600" />
</div>
<h3 className="font-bold text-[#1C323E] mb-2">Best Practices</h3>
<p className="text-sm text-slate-600 mb-4">Learn from experts</p>
<Button variant="outline" size="sm" className="w-full">
Read Guide
</Button>
</CardContent>
</Card>
</div>
</div>
</div>
);
}

View File

@@ -1,5 +1,4 @@
import React, { useState, useMemo } from "react";
import React, { useState } from "react";
import { base44 } from "@/api/base44Client"; import { base44 } from "@/api/base44Client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
@@ -7,15 +6,51 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Users, UserPlus, Mail, Shield, Building2, Edit, Trash2 } from "lucide-react"; import {
Users, UserPlus, Mail, Shield, Building2, Edit, Trash2, Search,
Filter, MoreVertical, Eye, Key, UserCheck, UserX, Layers,
Phone, Calendar, Clock, CheckCircle2, XCircle, AlertCircle
} from "lucide-react";
import { useToast } from "@/components/ui/use-toast"; import { useToast } from "@/components/ui/use-toast";
import UserPermissionsModal from "@/components/permissions/UserPermissionsModal"; // Import the new modal component import UserPermissionsModal from "@/components/permissions/UserPermissionsModal";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; // Import Avatar components import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
// Layer configuration
const LAYERS = [
{ id: "all", name: "All Users", icon: Users, color: "bg-slate-600" },
{ id: "admin", name: "Admins", icon: Shield, color: "bg-red-600" },
{ id: "procurement", name: "Procurement", icon: Building2, color: "bg-blue-600" },
{ id: "operator", name: "Operators", icon: Building2, color: "bg-emerald-600" },
{ id: "sector", name: "Sectors", icon: Layers, color: "bg-purple-600" },
{ id: "client", name: "Clients", icon: Users, color: "bg-green-600" },
{ id: "vendor", name: "Vendors", icon: Building2, color: "bg-amber-600" },
{ id: "workforce", name: "Workforce", icon: Users, color: "bg-slate-500" },
];
const ROLE_CONFIG = {
admin: { name: "Administrator", color: "bg-red-100 text-red-700 border-red-200", bgGradient: "from-red-500 to-red-700" },
procurement: { name: "Procurement", color: "bg-blue-100 text-blue-700 border-blue-200", bgGradient: "from-blue-500 to-blue-700" },
operator: { name: "Operator", color: "bg-emerald-100 text-emerald-700 border-emerald-200", bgGradient: "from-emerald-500 to-emerald-700" },
sector: { name: "Sector Manager", color: "bg-purple-100 text-purple-700 border-purple-200", bgGradient: "from-purple-500 to-purple-700" },
client: { name: "Client", color: "bg-green-100 text-green-700 border-green-200", bgGradient: "from-green-500 to-green-700" },
vendor: { name: "Vendor", color: "bg-amber-100 text-amber-700 border-amber-200", bgGradient: "from-amber-500 to-amber-700" },
workforce: { name: "Workforce", color: "bg-slate-100 text-slate-700 border-slate-200", bgGradient: "from-slate-500 to-slate-700" },
};
export default function UserManagement() { export default function UserManagement() {
const [showInviteDialog, setShowInviteDialog] = useState(false); const [showInviteDialog, setShowInviteDialog] = useState(false);
const [activeLayer, setActiveLayer] = useState("all");
const [searchTerm, setSearchTerm] = useState("");
const [inviteData, setInviteData] = useState({ const [inviteData, setInviteData] = useState({
email: "", email: "",
full_name: "", full_name: "",
@@ -27,14 +62,14 @@ export default function UserManagement() {
const [selectedUser, setSelectedUser] = useState(null); const [selectedUser, setSelectedUser] = useState(null);
const [showPermissionsModal, setShowPermissionsModal] = useState(false); const [showPermissionsModal, setShowPermissionsModal] = useState(false);
const [showUserDetailModal, setShowUserDetailModal] = useState(false);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { toast } = useToast(); const { toast } = useToast();
const { data: users } = useQuery({ const { data: users = [] } = useQuery({
queryKey: ['all-users'], queryKey: ['all-users'],
queryFn: async () => { queryFn: async () => {
// Only admins can see all users
const allUsers = await base44.entities.User.list('-created_date'); const allUsers = await base44.entities.User.list('-created_date');
return allUsers; return allUsers;
}, },
@@ -52,10 +87,10 @@ export default function UserManagement() {
queryClient.invalidateQueries({ queryKey: ['all-users'] }); queryClient.invalidateQueries({ queryKey: ['all-users'] });
toast({ toast({
title: "User Updated", title: "User Updated",
description: "User role and information updated successfully", description: "User information updated successfully",
}); });
setShowPermissionsModal(false); // Close the modal on success setShowPermissionsModal(false);
setSelectedUser(null); // Clear selected user setSelectedUser(null);
}, },
onError: (error) => { onError: (error) => {
toast({ toast({
@@ -66,6 +101,39 @@ export default function UserManagement() {
} }
}); });
// Calculate stats per layer
const layerStats = useMemo(() => {
const stats = {};
LAYERS.forEach(layer => {
if (layer.id === "all") {
stats[layer.id] = users.length;
} else {
stats[layer.id] = users.filter(u => (u.user_role || u.role) === layer.id).length;
}
});
return stats;
}, [users]);
// Filter users
const filteredUsers = useMemo(() => {
let filtered = users;
if (activeLayer !== "all") {
filtered = filtered.filter(u => (u.user_role || u.role) === activeLayer);
}
if (searchTerm) {
const term = searchTerm.toLowerCase();
filtered = filtered.filter(u =>
u.full_name?.toLowerCase().includes(term) ||
u.email?.toLowerCase().includes(term) ||
u.company_name?.toLowerCase().includes(term)
);
}
return filtered;
}, [users, activeLayer, searchTerm]);
const handleInviteUser = async () => { const handleInviteUser = async () => {
if (!inviteData.email || !inviteData.full_name) { if (!inviteData.email || !inviteData.full_name) {
toast({ toast({
@@ -76,8 +144,6 @@ export default function UserManagement() {
return; return;
} }
// In a real system, you would send an invitation email
// For now, we'll just create a user record
toast({ toast({
title: "User Invited", title: "User Invited",
description: `Invitation sent to ${inviteData.email}. They will receive setup instructions via email.`, description: `Invitation sent to ${inviteData.email}. They will receive setup instructions via email.`,
@@ -94,49 +160,22 @@ export default function UserManagement() {
}); });
}; };
const getRoleColor = (role) => {
const colors = {
admin: "bg-red-100 text-red-700",
procurement: "bg-purple-100 text-purple-700",
operator: "bg-blue-100 text-blue-700",
sector: "bg-cyan-100 text-cyan-700",
client: "bg-green-100 text-green-700",
vendor: "bg-amber-100 text-amber-700",
workforce: "bg-slate-100 text-slate-700",
};
return colors[role] || "bg-slate-100 text-slate-700";
};
const getRoleLabel = (role) => {
const labels = {
admin: "Administrator",
procurement: "Procurement",
operator: "Operator",
sector: "Sector Manager",
client: "Client",
vendor: "Vendor",
workforce: "Workforce"
};
return labels[role] || role;
};
const handleEditPermissions = (user) => { const handleEditPermissions = (user) => {
setSelectedUser(user); setSelectedUser(user);
setShowPermissionsModal(true); setShowPermissionsModal(true);
}; };
const handleSavePermissions = async (updatedUser) => { const handleViewUser = (user) => {
try { setSelectedUser(user);
// Assuming updatedUser contains the ID and the fields to update setShowUserDetailModal(true);
// The updateUserMutation already handles base44.entities.User.update and success/error toasts
await updateUserMutation.mutateAsync({ userId: updatedUser.id, data: updatedUser });
} catch (error) {
// Error handling is already in updateUserMutation's onError callback
// No need to duplicate toast here unless specific error handling is required for this modal
}
}; };
// Only admins can access this page const handleSavePermissions = async (updatedUser) => {
await updateUserMutation.mutateAsync({ userId: updatedUser.id, data: updatedUser });
};
const getRoleConfig = (role) => ROLE_CONFIG[role] || ROLE_CONFIG.workforce;
if (currentUser?.user_role !== "admin" && currentUser?.role !== "admin") { if (currentUser?.user_role !== "admin" && currentUser?.role !== "admin") {
return ( return (
<div className="p-8 text-center"> <div className="p-8 text-center">
@@ -147,133 +186,240 @@ export default function UserManagement() {
); );
} }
// Sample avatar for users without profile pictures
const sampleAvatar = "https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=400&h=400&fit=crop"; const sampleAvatar = "https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=400&h=400&fit=crop";
return ( return (
<div className="p-4 md:p-8 bg-slate-50 min-h-screen"> <div className="p-4 md:p-8 bg-slate-50 min-h-screen">
<div className="max-w-7xl mx-auto"> <div className="max-w-7xl mx-auto">
<div className="flex items-center justify-between mb-6"> {/* Header */}
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-6">
<div> <div>
<h1 className="text-3xl font-bold text-[#1C323E]">User Management</h1> <h1 className="text-3xl font-bold text-slate-900">User Management</h1>
<p className="text-slate-500 mt-1">Manage users and assign roles</p> <p className="text-slate-500 mt-1">Manage users across all ecosystem layers</p>
</div> </div>
<Button <Button
onClick={() => setShowInviteDialog(true)} onClick={() => setShowInviteDialog(true)}
className="bg-[#0A39DF] hover:bg-[#0A39DF]/90" className="bg-gradient-to-r from-[#0A39DF] to-[#1C323E] shadow-lg"
> >
<UserPlus className="w-4 h-4 mr-2" /> <UserPlus className="w-4 h-4 mr-2" />
Invite User Invite User
</Button> </Button>
</div> </div>
{/* Stats */} {/* Layer Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8"> <div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-8 gap-3 mb-6">
<Card className="border-slate-200"> {LAYERS.map((layer) => {
<CardContent className="p-6"> const Icon = layer.icon;
<Users className="w-8 h-8 text-[#0A39DF] mb-2" /> const isActive = activeLayer === layer.id;
<p className="text-sm text-slate-500">Total Users</p>
<p className="text-3xl font-bold text-[#1C323E]">{users.length}</p> return (
</CardContent> <button
</Card> key={layer.id}
onClick={() => setActiveLayer(layer.id)}
<Card className="border-slate-200"> className={`p-4 rounded-xl border-2 transition-all text-center ${
<CardContent className="p-6"> isActive
<Shield className="w-8 h-8 text-red-600 mb-2" /> ? 'border-[#0A39DF] bg-blue-50 shadow-md scale-105'
<p className="text-sm text-slate-500">Admins</p> : 'border-slate-200 bg-white hover:border-slate-300 hover:shadow-sm'
<p className="text-3xl font-bold text-red-600"> }`}
{users.filter(u => u.user_role === 'admin' || u.role === 'admin').length} >
</p> <div className={`w-10 h-10 mx-auto rounded-lg ${layer.color} flex items-center justify-center mb-2`}>
</CardContent> <Icon className="w-5 h-5 text-white" />
</Card> </div>
<p className="text-2xl font-bold text-slate-900">{layerStats[layer.id]}</p>
<Card className="border-slate-200"> <p className="text-xs text-slate-500 truncate">{layer.name}</p>
<CardContent className="p-6"> </button>
<Building2 className="w-8 h-8 text-amber-600 mb-2" /> );
<p className="text-sm text-slate-500">Vendors</p> })}
<p className="text-3xl font-bold text-amber-600">
{users.filter(u => u.user_role === 'vendor').length}
</p>
</CardContent>
</Card>
<Card className="border-slate-200">
<CardContent className="p-6">
<Users className="w-8 h-8 text-blue-600 mb-2" />
<p className="text-sm text-slate-500">Workforce</p>
<p className="text-3xl font-bold text-blue-600">
{users.filter(u => u.user_role === 'workforce').length}
</p>
</CardContent>
</Card>
</div> </div>
{/* Users List */} {/* Search and Filter */}
<Card className="border-slate-200"> <Card className="mb-6 border-slate-200 shadow-sm">
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b"> <CardContent className="p-4">
<CardTitle>All Users</CardTitle> <div className="flex flex-col md:flex-row gap-4">
</CardHeader> <div className="relative flex-1">
<CardContent className="p-6"> <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-slate-400" />
<div className="space-y-4"> <Input
{users.map((user) => ( placeholder="Search by name, email, or company..."
<div key={user.id} className="flex items-center justify-between p-4 bg-white border-2 border-slate-200 rounded-lg hover:border-[#0A39DF] transition-all"> value={searchTerm}
<div className="flex items-center gap-4 flex-1"> onChange={(e) => setSearchTerm(e.target.value)}
<Avatar className="w-12 h-12 border-2 border-slate-200"> className="pl-10 border-slate-300"
<AvatarImage src={user.profile_picture || sampleAvatar} alt={user.full_name} /> />
<AvatarFallback className="bg-gradient-to-br from-[#0A39DF] to-[#1C323E] text-white font-bold"> </div>
{user.full_name?.charAt(0) || user.email?.charAt(0) || '?'} <div className="flex gap-2">
</AvatarFallback> <Select value={activeLayer} onValueChange={setActiveLayer}>
</Avatar> <SelectTrigger className="w-[180px]">
<div className="flex-1"> <Filter className="w-4 h-4 mr-2" />
<h4 className="font-semibold text-[#1C323E]">{user.full_name || 'Unnamed User'}</h4> <SelectValue placeholder="Filter by layer" />
<div className="flex items-center gap-3 mt-1"> </SelectTrigger>
<span className="text-sm text-slate-500 flex items-center gap-1"> <SelectContent>
<Mail className="w-3 h-3" /> {LAYERS.map((layer) => (
{user.email} <SelectItem key={layer.id} value={layer.id}>
</span> {layer.name} ({layerStats[layer.id]})
{user.company_name && ( </SelectItem>
<span className="text-sm text-slate-500 flex items-center gap-1"> ))}
<Building2 className="w-3 h-3" /> </SelectContent>
{user.company_name} </Select>
</span> </div>
)}
</div>
</div>
<Badge className={getRoleColor(user.user_role || user.role)}>
{getRoleLabel(user.user_role || user.role)}
</Badge>
</div>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleEditPermissions(user)}
className="hover:text-[#0A39DF] hover:bg-blue-50"
title="Edit Permissions"
>
<Shield className="w-4 h-4" />
</Button>
<Button variant="outline" size="sm" title="Edit User">
<Edit className="w-4 h-4" />
</Button>
{/* Optionally add a delete button here */}
{/* <Button variant="outline" size="sm" className="text-red-500 hover:text-red-700 hover:bg-red-50" title="Delete User">
<Trash2 className="w-4 h-4" />
</Button> */}
</div>
</div>
))}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
{/* Users Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredUsers.map((user) => {
const role = user.user_role || user.role || "workforce";
const config = getRoleConfig(role);
return (
<Card
key={user.id}
className="border-slate-200 hover:border-[#0A39DF] hover:shadow-lg transition-all overflow-hidden group"
>
{/* Role Header */}
<div className={`h-2 bg-gradient-to-r ${config.bgGradient}`}></div>
<CardContent className="p-5">
<div className="flex items-start gap-4">
<Avatar className="w-14 h-14 border-2 border-slate-200 shadow-sm">
<AvatarImage src={user.profile_picture || sampleAvatar} alt={user.full_name} />
<AvatarFallback className={`bg-gradient-to-br ${config.bgGradient} text-white font-bold text-lg`}>
{user.full_name?.charAt(0) || user.email?.charAt(0) || '?'}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div>
<h4 className="font-bold text-slate-900 truncate">{user.full_name || 'Unnamed User'}</h4>
<Badge className={`${config.color} border text-xs mt-1`}>
{config.name}
</Badge>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity">
<MoreVertical className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleViewUser(user)}>
<Eye className="w-4 h-4 mr-2" />
View Details
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleEditPermissions(user)}>
<Key className="w-4 h-4 mr-2" />
Edit Permissions
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-red-600">
<UserX className="w-4 h-4 mr-2" />
Deactivate User
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="mt-3 space-y-1.5">
<p className="text-sm text-slate-600 flex items-center gap-2 truncate">
<Mail className="w-3.5 h-3.5 text-slate-400 flex-shrink-0" />
{user.email}
</p>
{user.company_name && (
<p className="text-sm text-slate-600 flex items-center gap-2 truncate">
<Building2 className="w-3.5 h-3.5 text-slate-400 flex-shrink-0" />
{user.company_name}
</p>
)}
{user.phone && (
<p className="text-sm text-slate-600 flex items-center gap-2 truncate">
<Phone className="w-3.5 h-3.5 text-slate-400 flex-shrink-0" />
{user.phone}
</p>
)}
</div>
</div>
</div>
{/* Quick Actions */}
<div className="flex items-center gap-2 mt-4 pt-4 border-t border-slate-100">
<Button
variant="outline"
size="sm"
className="flex-1 text-xs"
onClick={() => handleViewUser(user)}
>
<Eye className="w-3.5 h-3.5 mr-1" />
View
</Button>
<Button
variant="outline"
size="sm"
className="flex-1 text-xs hover:bg-blue-50 hover:text-blue-700 hover:border-blue-300"
onClick={() => handleEditPermissions(user)}
>
<Shield className="w-3.5 h-3.5 mr-1" />
Permissions
</Button>
<Button
variant="outline"
size="sm"
className="flex-1 text-xs"
>
<Edit className="w-3.5 h-3.5 mr-1" />
Edit
</Button>
</div>
</CardContent>
</Card>
);
})}
</div>
{filteredUsers.length === 0 && (
<div className="text-center py-16 bg-white rounded-xl border border-slate-200">
<Users className="w-16 h-16 mx-auto text-slate-300 mb-4" />
<h3 className="text-xl font-semibold text-slate-900 mb-2">No users found</h3>
<p className="text-slate-600">
{searchTerm ? "Try adjusting your search" : "No users in this layer yet"}
</p>
</div>
)}
{/* Invite User Dialog */} {/* Invite User Dialog */}
<Dialog open={showInviteDialog} onOpenChange={setShowInviteDialog}> <Dialog open={showInviteDialog} onOpenChange={setShowInviteDialog}>
<DialogContent className="max-w-2xl"> <DialogContent className="max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle>Invite New User</DialogTitle> <DialogTitle className="flex items-center gap-2">
<UserPlus className="w-5 h-5 text-[#0A39DF]" />
Invite New User
</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-4">
<div className="space-y-6 py-4">
{/* Role Selection */}
<div>
<Label className="text-sm font-semibold mb-3 block">Select User Role</Label>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{Object.entries(ROLE_CONFIG).map(([roleId, config]) => (
<button
key={roleId}
onClick={() => setInviteData({ ...inviteData, user_role: roleId })}
className={`p-3 rounded-xl border-2 transition-all text-center ${
inviteData.user_role === roleId
? 'border-[#0A39DF] bg-blue-50 shadow-md'
: 'border-slate-200 bg-white hover:border-slate-300'
}`}
>
<div className={`w-8 h-8 mx-auto rounded-lg bg-gradient-to-br ${config.bgGradient} flex items-center justify-center mb-2`}>
<Users className="w-4 h-4 text-white" />
</div>
<p className="text-xs font-semibold text-slate-900">{config.name}</p>
</button>
))}
</div>
</div>
{/* User Details */}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<Label>Full Name *</Label> <Label>Full Name *</Label>
@@ -281,6 +427,7 @@ export default function UserManagement() {
value={inviteData.full_name} value={inviteData.full_name}
onChange={(e) => setInviteData({ ...inviteData, full_name: e.target.value })} onChange={(e) => setInviteData({ ...inviteData, full_name: e.target.value })}
placeholder="John Doe" placeholder="John Doe"
className="mt-1"
/> />
</div> </div>
<div> <div>
@@ -290,86 +437,160 @@ export default function UserManagement() {
value={inviteData.email} value={inviteData.email}
onChange={(e) => setInviteData({ ...inviteData, email: e.target.value })} onChange={(e) => setInviteData({ ...inviteData, email: e.target.value })}
placeholder="john@example.com" placeholder="john@example.com"
className="mt-1"
/> />
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div>
<Label>Role *</Label>
<Select value={inviteData.user_role} onValueChange={(value) => setInviteData({ ...inviteData, user_role: value })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin">Administrator</SelectItem>
<SelectItem value="procurement">Procurement</SelectItem>
<SelectItem value="operator">Operator</SelectItem>
<SelectItem value="sector">Sector Manager</SelectItem>
<SelectItem value="client">Client</SelectItem>
<SelectItem value="vendor">Vendor</SelectItem>
<SelectItem value="workforce">Workforce</SelectItem>
</SelectContent>
</Select>
</div>
<div> <div>
<Label>Phone</Label> <Label>Phone</Label>
<Input <Input
value={inviteData.phone} value={inviteData.phone}
onChange={(e) => setInviteData({ ...inviteData, phone: e.target.value })} onChange={(e) => setInviteData({ ...inviteData, phone: e.target.value })}
placeholder="(555) 123-4567" placeholder="(555) 123-4567"
className="mt-1"
/> />
</div> </div>
</div>
<div className="grid grid-cols-2 gap-4">
<div> <div>
<Label>Company Name</Label> <Label>Company Name</Label>
<Input <Input
value={inviteData.company_name} value={inviteData.company_name}
onChange={(e) => setInviteData({ ...inviteData, company_name: e.target.value })} onChange={(e) => setInviteData({ ...inviteData, company_name: e.target.value })}
placeholder="Acme Corp" placeholder="Acme Corp"
/> className="mt-1"
</div>
<div>
<Label>Department</Label>
<Input
value={inviteData.department}
onChange={(e) => setInviteData({ ...inviteData, department: e.target.value })}
placeholder="Operations"
/> />
</div> </div>
</div> </div>
<div className="p-4 bg-blue-50 rounded-lg border border-blue-200"> <div>
<p className="text-sm text-blue-700"> <Label>Department</Label>
<strong>Note:</strong> The user will receive an email invitation with instructions to set up their account and password. <Input
</p> value={inviteData.department}
onChange={(e) => setInviteData({ ...inviteData, department: e.target.value })}
placeholder="Operations"
className="mt-1"
/>
</div>
<div className="p-4 bg-blue-50 rounded-xl border border-blue-200">
<div className="flex items-start gap-3">
<Mail className="w-5 h-5 text-blue-600 mt-0.5" />
<div>
<p className="text-sm font-medium text-blue-900">Invitation will be sent</p>
<p className="text-xs text-blue-700 mt-1">
The user will receive an email with instructions to set up their account and access the platform as a <strong>{ROLE_CONFIG[inviteData.user_role].name}</strong>.
</p>
</div>
</div>
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setShowInviteDialog(false)}> <Button variant="outline" onClick={() => setShowInviteDialog(false)}>
Cancel Cancel
</Button> </Button>
<Button onClick={handleInviteUser} className="bg-[#0A39DF]"> <Button onClick={handleInviteUser} className="bg-gradient-to-r from-[#0A39DF] to-[#1C323E]">
<Mail className="w-4 h-4 mr-2" />
Send Invitation Send Invitation
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div>
{/* Permissions Modal */} {/* User Detail Modal */}
<UserPermissionsModal <Dialog open={showUserDetailModal} onOpenChange={setShowUserDetailModal}>
user={selectedUser} <DialogContent className="max-w-lg">
open={showPermissionsModal} {selectedUser && (
onClose={() => { <>
setShowPermissionsModal(false); <DialogHeader>
setSelectedUser(null); <DialogTitle>User Details</DialogTitle>
}} </DialogHeader>
onSave={handleSavePermissions} <div className="py-4">
isSaving={updateUserMutation.isLoading} <div className="flex items-center gap-4 mb-6">
/> <Avatar className="w-20 h-20 border-2 border-slate-200">
<AvatarImage src={selectedUser.profile_picture || sampleAvatar} alt={selectedUser.full_name} />
<AvatarFallback className={`bg-gradient-to-br ${getRoleConfig(selectedUser.user_role || selectedUser.role).bgGradient} text-white font-bold text-2xl`}>
{selectedUser.full_name?.charAt(0) || '?'}
</AvatarFallback>
</Avatar>
<div>
<h3 className="text-xl font-bold text-slate-900">{selectedUser.full_name}</h3>
<Badge className={`${getRoleConfig(selectedUser.user_role || selectedUser.role).color} border mt-1`}>
{getRoleConfig(selectedUser.user_role || selectedUser.role).name}
</Badge>
</div>
</div>
<div className="space-y-4">
<div className="flex items-center gap-3 p-3 bg-slate-50 rounded-lg">
<Mail className="w-5 h-5 text-slate-400" />
<div>
<p className="text-xs text-slate-500">Email</p>
<p className="text-sm font-medium text-slate-900">{selectedUser.email}</p>
</div>
</div>
{selectedUser.phone && (
<div className="flex items-center gap-3 p-3 bg-slate-50 rounded-lg">
<Phone className="w-5 h-5 text-slate-400" />
<div>
<p className="text-xs text-slate-500">Phone</p>
<p className="text-sm font-medium text-slate-900">{selectedUser.phone}</p>
</div>
</div>
)}
{selectedUser.company_name && (
<div className="flex items-center gap-3 p-3 bg-slate-50 rounded-lg">
<Building2 className="w-5 h-5 text-slate-400" />
<div>
<p className="text-xs text-slate-500">Company</p>
<p className="text-sm font-medium text-slate-900">{selectedUser.company_name}</p>
</div>
</div>
)}
<div className="flex items-center gap-3 p-3 bg-slate-50 rounded-lg">
<Calendar className="w-5 h-5 text-slate-400" />
<div>
<p className="text-xs text-slate-500">Joined</p>
<p className="text-sm font-medium text-slate-900">
{selectedUser.created_date ? new Date(selectedUser.created_date).toLocaleDateString() : 'Unknown'}
</p>
</div>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowUserDetailModal(false)}>
Close
</Button>
<Button
onClick={() => {
setShowUserDetailModal(false);
handleEditPermissions(selectedUser);
}}
className="bg-[#0A39DF]"
>
<Shield className="w-4 h-4 mr-2" />
Edit Permissions
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
{/* Permissions Modal */}
<UserPermissionsModal
user={selectedUser}
open={showPermissionsModal}
onClose={() => {
setShowPermissionsModal(false);
setSelectedUser(null);
}}
onSave={handleSavePermissions}
isSaving={updateUserMutation.isPending}
/>
</div>
</div> </div>
); );
} }

View File

@@ -1,4 +1,3 @@
import React, { useState, useMemo } from "react"; import React, { useState, useMemo } from "react";
import { base44 } from "@/api/base44Client"; import { base44 } from "@/api/base44Client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
@@ -21,6 +20,12 @@ import { detectAllConflicts, ConflictAlert } from "@/components/scheduling/Confl
const safeParseDate = (dateString) => { const safeParseDate = (dateString) => {
if (!dateString) return null; if (!dateString) return null;
try { 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); const date = typeof dateString === 'string' ? parseISO(dateString) : new Date(dateString);
return isValid(date) ? date : null; return isValid(date) ? date : null;
} catch { return null; } } catch { return null; }
@@ -501,4 +506,4 @@ export default function VendorOrders() {
/> />
</div> </div>
); );
} }

View File

@@ -1,13 +1,13 @@
import React, { useMemo, useState } from "react"; import React, { useMemo, useState } from "react";
import { base44 } from "@/api/base44Client"; import { base44 } from "@/api/base44Client";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Download, Search, Building2, MapPin, DollarSign } from "lucide-react"; import { Download, Search, Building2, MapPin, DollarSign, Shield, Briefcase, Plus, Pencil, Trash2 } from "lucide-react";
import PageHeader from "@/components/common/PageHeader"; import PageHeader from "@/components/common/PageHeader";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import RateCardModal from "@/components/rates/RateCardModal";
// Define regions - these map to the notes field or can be a new field // Define regions - these map to the notes field or can be a new field
const REGIONS = [ const REGIONS = [
@@ -69,13 +69,31 @@ const parseRoleName = (roleName) => {
function VendorCompanyPricebookView({ user, vendorName }) { function VendorCompanyPricebookView({ user, vendorName }) {
const APPROVED_TAG = "__APPROVED__"; const APPROVED_TAG = "__APPROVED__";
const [clients, setClients] = useState(["Google", "Zoox", "Promotion"]); const APPROVED_RATES = ["FoodBuy", "Aramark"];
const [pricebook, setPricebook] = useState("Approved");
const [customRateCards, setCustomRateCards] = useState([
{ name: "Bay Area Compass", baseBook: "FoodBuy", discount: 0, isDefault: true },
{ name: "LA Compass", baseBook: "FoodBuy", discount: 0, isDefault: true },
{ name: "Google", baseBook: "FoodBuy", discount: 0, isDefault: true },
{ name: "Promotion", baseBook: "FoodBuy", discount: 0, isDefault: true },
{ name: "Convention Center", baseBook: "FoodBuy", discount: 0, isDefault: true }
]);
const [pricebook, setPricebook] = useState("FoodBuy");
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [activeRegion, setActiveRegion] = useState("All"); const [activeRegion, setActiveRegion] = useState("All");
const [activeCategory, setActiveCategory] = useState("All"); const [activeCategory, setActiveCategory] = useState("All");
const REGIONS = ["All", "San Francisco-Oakland-San Jose", "Los Angeles-Riverside-Orange County", "Fresno", "Stockton-Lodi"];
const [editing, setEditing] = useState(null); const [editing, setEditing] = useState(null);
const [toast, setToast] = useState(null); const [toast, setToast] = useState(null);
const [analyzingCompetitiveness, setAnalyzingCompetitiveness] = useState(false);
const [competitivenessData, setCompetitivenessData] = useState(null);
const [showRateCardModal, setShowRateCardModal] = useState(false);
const [editingRateCard, setEditingRateCard] = useState(null);
const [renamingCard, setRenamingCard] = useState(null);
const [renameValue, setRenameValue] = useState("");
const RATE_CARDS = customRateCards.map(c => c.name);
const { data: rates = [] } = useQuery({ const { data: rates = [] } = useQuery({
queryKey: ['vendor-rates-pricebook', vendorName], queryKey: ['vendor-rates-pricebook', vendorName],
@@ -103,28 +121,182 @@ function VendorCompanyPricebookView({ user, vendorName }) {
"Other" "Other"
]; ];
const scopedByBook = useMemo(() => { const handleSaveRateCard = (cardData) => {
// Convert existing rates to pricebook format if (editingRateCard) {
// For now, treat all as "Approved" pricebook setCustomRateCards(prev => prev.map(c => c.name === editingRateCard.name ? { ...cardData, isDefault: c.isDefault } : c));
return rates.map(r => ({ } else {
...r, setCustomRateCards(prev => [...prev, { ...cardData, isDefault: false }]);
client: APPROVED_TAG, }
region: r.notes?.includes("Bay Area") ? "Bay Area" : "LA", // Simplified for demo setToast(`✓ Rate card "${cardData.name}" saved successfully`);
approvedCap: r.client_rate, setPricebook(cardData.name);
proposedRate: r.client_rate, setEditingRateCard(null);
position: r.role_name, };
markupPct: r.markup_percentage,
volDiscountPct: r.vendor_fee_percentage
}));
}, [rates]);
const handleDeleteRateCard = (cardName) => {
if (customRateCards.length <= 1) {
setToast("⚠ Cannot delete the last rate card");
return;
}
setCustomRateCards(prev => prev.filter(c => c.name !== cardName));
if (pricebook === cardName) {
setPricebook(customRateCards.find(c => c.name !== cardName)?.name || "FoodBuy");
}
setToast(`✓ Rate card "${cardName}" deleted`);
};
const handleRenameCard = (oldName) => {
if (!renameValue.trim() || renameValue === oldName) {
setRenamingCard(null);
return;
}
setCustomRateCards(prev => prev.map(c => c.name === oldName ? { ...c, name: renameValue.trim() } : c));
if (pricebook === oldName) {
setPricebook(renameValue.trim());
}
setToast(`✓ Renamed to "${renameValue.trim()}"`);
setRenamingCard(null);
};
const scopedByBook = useMemo(() => {
// Base approved rates
const baseRates = {
"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
}
};
// Default rate cards (built-in)
const defaultCards = {
"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
},
"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
},
"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
}
};
// Add custom rate cards
const customCardsRates = {};
customRateCards.forEach(card => {
customCardsRates[card.name] = card.rates;
});
const ratesByBook = { ...baseRates, ...defaultCards, ...customCardsRates };
const currentBookRates = ratesByBook[pricebook] || {};
const isCustomRateCard = RATE_CARDS.includes(pricebook);
// For Custom Rate Cards, deduplicate by position name only
if (isCustomRateCard) {
const uniquePositions = new Map();
rates.forEach(r => {
const parsed = parseRoleName(r.role_name);
const position = parsed.position;
const finalRate = currentBookRates[position] || r.client_rate;
// Only keep first occurrence of each position
if (!uniquePositions.has(position)) {
uniquePositions.set(position, {
...r,
client: pricebook,
region: "—",
approvedCap: r.approved_cap_rate || r.client_rate,
proposedRate: finalRate,
position: position, // Use clean position name without region
markupPct: r.markup_percentage,
volDiscountPct: r.vendor_fee_percentage
});
}
});
return Array.from(uniquePositions.values());
}
// For Approved Rates, keep regions
return rates.map(r => {
const parsed = parseRoleName(r.role_name);
const position = parsed.position;
const finalRate = currentBookRates[position] || r.client_rate;
const assignedRegion = r.region || parsed.region || (r.notes?.includes("Bay Area") ? "Bay Area" : "LA");
return {
...r,
client: pricebook,
region: assignedRegion,
approvedCap: r.approved_cap_rate || r.client_rate,
proposedRate: finalRate,
position: r.role_name,
markupPct: r.markup_percentage,
volDiscountPct: r.vendor_fee_percentage
};
});
}, [rates, pricebook]);
const isApprovedRate = APPROVED_RATES.includes(pricebook);
const filtered = useMemo(() => { const filtered = useMemo(() => {
return scopedByBook.filter(r => return scopedByBook.filter(r => {
(activeRegion === "All" || parseRoleName(r.position).region === activeRegion) && // Updated region filter to use parsed region const regionMatch = !isApprovedRate || activeRegion === "All" || r.region === activeRegion || parseRoleName(r.position).region === activeRegion;
(activeCategory === "All" || r.category === activeCategory) && const categoryMatch = activeCategory === "All" || r.category === activeCategory;
(search.trim() === "" || parseRoleName(r.position).position.toLowerCase().includes(search.toLowerCase())) // Updated search to use parsed position const searchMatch = search.trim() === "" || parseRoleName(r.position).position.toLowerCase().includes(search.toLowerCase());
); return regionMatch && categoryMatch && searchMatch;
}, [scopedByBook, activeRegion, activeCategory, search]); });
}, [scopedByBook, activeRegion, activeCategory, search, isApprovedRate]);
const kpis = useMemo(() => { const kpis = useMemo(() => {
const rateValues = filtered.map(r => r.proposedRate); const rateValues = filtered.map(r => r.proposedRate);
@@ -135,6 +307,56 @@ function VendorCompanyPricebookView({ user, vendorName }) {
return { avg, min, max, total }; return { avg, min, max, total };
}, [filtered]); }, [filtered]);
async function analyzeCompetitiveness() {
setAnalyzingCompetitiveness(true);
try {
const positionsToAnalyze = filtered.slice(0, 20); // Limit to 20 positions for API
const prompt = `You are a staffing industry pricing expert. Analyze these ${pricebook} hourly rates for the California market competitiveness.
RATES TO ANALYZE:
${positionsToAnalyze.map(r => `- ${parseRoleName(r.position).position}: $${r.proposedRate}/hr`).join('\n')}
For EACH position listed above, provide market analysis:
- marketRate: typical California market rate for this role (number)
- score: competitiveness score 0-100 (100 = most competitive/best value)
- status: exactly one of "Highly Competitive", "Competitive", "Average", "Above Market"
- recommendation: brief 5-10 word suggestion
Consider Bay Area/LA market rates. Food service roles typically $28-50/hr, management $45-80/hr.`;
const result = await base44.integrations.Core.InvokeLLM({
prompt,
add_context_from_internet: true,
response_json_schema: {
type: "object",
properties: {
analysis: {
type: "array",
items: {
type: "object",
properties: {
position: { type: "string" },
marketRate: { type: "number" },
score: { type: "number" },
status: { type: "string" },
recommendation: { type: "string" }
}
}
}
}
}
});
setCompetitivenessData(result.analysis || []);
setToast("✓ Competitive analysis complete");
} catch (error) {
console.error("Analysis error:", error);
setToast("⚠ Analysis failed. Try again.");
} finally {
setAnalyzingCompetitiveness(false);
}
}
function exportCSV() { function exportCSV() {
const headers = ["Pricebook", "Position", "Category", "Region", "ApprovedCap", "ProposedRate", "Markup%", "VolDiscount%"]; const headers = ["Pricebook", "Position", "Category", "Region", "ApprovedCap", "ProposedRate", "Markup%", "VolDiscount%"];
const body = filtered.map(r => { const body = filtered.map(r => {
@@ -163,138 +385,451 @@ function VendorCompanyPricebookView({ user, vendorName }) {
} }
return ( return (
<div className="min-h-screen bg-slate-50"> <div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-slate-50">
{/* Header */} {/* Header */}
<header className="px-6 py-6 border-b bg-white sticky top-0 z-10"> <header className="px-6 py-8 border-b bg-white/80 backdrop-blur-xl sticky top-0 z-10 shadow-sm">
<div className="max-w-7xl mx-auto flex items-center justify-between"> <div className="max-w-7xl mx-auto">
<div> <div className="flex items-start justify-between mb-4">
<h1 className="text-2xl font-semibold text-slate-900"> <div>
<span className="bg-yellow-300 px-2 rounded">Service Rates</span> 20252028 <div className="flex items-center gap-3 mb-2">
</h1> <div className="w-12 h-12 bg-blue-600 rounded-2xl flex items-center justify-center shadow-lg">
<p className="text-slate-500 mt-1"> <DollarSign className="w-7 h-7 text-white" />
{rates.length > 0 && rates[0].vendor_name !== vendorName ? ( </div>
<>Demo data from <strong>Legendary Event Staffing</strong> - View <strong>Approved Prices</strong></> <div>
) : ( <h1 className="text-3xl font-bold text-slate-900">Service Rate Management</h1>
<>{vendorName} - View <strong>Approved Prices</strong> and manage <strong>Company-specific pricebooks</strong></> <p className="text-sm text-slate-500">20252028 Pricing Structure</p>
)} </div>
</p> </div>
<p className="text-slate-600 ml-15">
{rates.length > 0 && rates[0].vendor_name !== vendorName ? (
<>Viewing demo data from <span className="font-semibold text-blue-600">Legendary Event Staffing</span></>
) : (
<>Managing rates for <span className="font-semibold text-blue-600">{vendorName}</span></>
)}
</p>
</div>
<div className="flex gap-3">
<Button
onClick={analyzeCompetitiveness}
disabled={analyzingCompetitiveness}
className="bg-blue-600 hover:bg-blue-700 text-white shadow-lg px-6"
>
{analyzingCompetitiveness ? (
<>
<div className="w-4 h-4 mr-2 border-2 border-white border-t-transparent rounded-full animate-spin" />
Analyzing Market...
</>
) : (
<>
<svg className="w-5 h-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
AI Price Check
</>
)}
</Button>
<Button onClick={exportCSV} variant="outline" className="border-slate-300 bg-white hover:bg-slate-50 shadow-sm">
<Download className="w-4 h-4 mr-2" />
Export
</Button>
</div>
</div> </div>
<Button onClick={exportCSV} variant="outline" className="border-slate-300">
<Download className="w-4 h-4 mr-2" />
Export CSV
</Button>
</div> </div>
</header> </header>
{/* Pricebook Tabs */} {/* Pricebook Tabs - Split into Approved Rates and Rate Cards */}
<section className="max-w-7xl mx-auto px-6 mt-6"> <section className="max-w-7xl mx-auto px-6 mt-6">
<div className="flex flex-wrap gap-2 items-center"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{["Approved", ...clients].map(tab => ( {/* Approved Enterprise Rates */}
<button <div className="bg-gradient-to-br from-amber-50 to-orange-50 rounded-2xl shadow-lg border-2 border-amber-200 p-6">
key={tab} <div className="flex items-center gap-3 mb-4">
onClick={() => setPricebook(tab)} <div className="w-10 h-10 bg-amber-500 rounded-xl flex items-center justify-center shadow-md">
className={`px-3 py-1.5 rounded-full text-sm border transition-all ${ <Shield className="w-6 h-6 text-white" />
pricebook === tab </div>
? "bg-slate-900 text-white border-slate-900" <div>
: "bg-white hover:bg-slate-50" <h3 className="text-sm font-bold text-amber-900 uppercase tracking-wider">Approved Enterprise Rates</h3>
}`} <p className="text-xs text-amber-700">Varies by region</p>
> </div>
{tab} </div>
</button> <div className="flex flex-wrap gap-2">
))} {APPROVED_RATES.map(tab => (
<span className="text-slate-400 text-sm ml-2">Pricebook</span> <button
key={tab}
onClick={() => {
setPricebook(tab);
setActiveRegion("All");
}}
className={`px-4 py-2.5 rounded-xl text-sm font-semibold transition-all ${
pricebook === tab
? "bg-amber-600 text-white shadow-lg"
: "bg-white text-slate-700 hover:bg-amber-50 border border-amber-200"
}`}
>
{tab}
</button>
))}
</div>
</div>
{/* Custom Rate Cards */}
<div className="bg-gradient-to-br from-blue-50 to-indigo-50 rounded-2xl shadow-lg border-2 border-blue-200 p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-600 rounded-xl flex items-center justify-center shadow-md">
<Briefcase className="w-6 h-6 text-white" />
</div>
<div>
<h3 className="text-sm font-bold text-blue-900 uppercase tracking-wider">Custom Rate Cards</h3>
<p className="text-xs text-blue-700">Client-specific & negotiated</p>
</div>
</div>
<Button
onClick={() => { setEditingRateCard(null); setShowRateCardModal(true); }}
size="sm"
className="bg-blue-600 hover:bg-blue-700 text-white shadow-md"
>
<Plus className="w-4 h-4 mr-1" />
New Card
</Button>
</div>
<div className="flex flex-wrap gap-2">
{RATE_CARDS.map(tab => {
const cardData = customRateCards.find(c => c.name === tab);
const isRenaming = renamingCard === tab;
if (isRenaming) {
return (
<div key={tab} className="flex items-center gap-1">
<input
type="text"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={() => handleRenameCard(tab)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleRenameCard(tab);
if (e.key === 'Escape') setRenamingCard(null);
}}
autoFocus
className="px-3 py-2 rounded-xl text-sm font-semibold border-2 border-blue-500 bg-white focus:outline-none w-40"
/>
</div>
);
}
return (
<div key={tab} className="relative group">
<button
onClick={() => setPricebook(tab)}
onDoubleClick={(e) => { e.stopPropagation(); setRenamingCard(tab); setRenameValue(tab); }}
className={`px-4 py-2.5 rounded-xl text-sm font-semibold transition-all ${
pricebook === tab
? "bg-blue-600 text-white shadow-lg"
: "bg-white text-slate-700 hover:bg-blue-50 border border-blue-200"
}`}
title="Double-click to rename"
>
{tab}
{cardData?.discount > 0 && (
<span className="ml-2 text-xs opacity-80">-{cardData.discount}%</span>
)}
</button>
<div className="absolute -top-2 -right-2 hidden group-hover:flex gap-1">
<button
onClick={(e) => { e.stopPropagation(); setRenamingCard(tab); setRenameValue(tab); }}
className="w-6 h-6 bg-blue-500 text-white rounded-full flex items-center justify-center shadow-md hover:bg-blue-600"
title="Rename"
>
<Pencil className="w-3 h-3" />
</button>
<button
onClick={(e) => { e.stopPropagation(); handleDeleteRateCard(tab); }}
className="w-6 h-6 bg-red-500 text-white rounded-full flex items-center justify-center shadow-md hover:bg-red-600"
title="Delete"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
</div>
);
})}
</div>
</div>
</div> </div>
</section> </section>
{/* Competitiveness Summary */}
{competitivenessData && competitivenessData.length > 0 && (
<section className="max-w-7xl mx-auto px-6 mt-6">
<div className="bg-slate-800 rounded-3xl p-8 shadow-2xl">
<div className="flex items-center gap-4 mb-6">
<div className="w-14 h-14 bg-white/20 backdrop-blur-xl rounded-2xl flex items-center justify-center shadow-lg">
<svg className="w-8 h-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<div>
<h3 className="text-2xl font-bold text-white">AI Market Intelligence</h3>
<p className="text-purple-100">Real-time competitive analysis for {pricebook}</p>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[
{ status: "Highly Competitive", color: "bg-green-500", textColor: "text-green-600" },
{ status: "Competitive", color: "bg-blue-500", textColor: "text-blue-600" },
{ status: "Average", color: "bg-yellow-500", textColor: "text-yellow-600" },
{ status: "Above Market", color: "bg-red-500", textColor: "text-red-600" }
].map(({ status, color, textColor }) => {
const count = competitivenessData.filter(d => d.status === status).length;
return (
<div key={status} className="bg-white/95 backdrop-blur-xl rounded-2xl p-6 shadow-xl border border-white/20 hover:scale-105 transition-transform">
<div className="flex items-center gap-3 mb-3">
<div className={`w-4 h-4 rounded-full ${color}`} />
<p className={`text-4xl font-bold ${textColor}`}>{count}</p>
</div>
<p className="text-sm font-semibold text-slate-700">{status}</p>
<div className="mt-3 h-2 bg-slate-100 rounded-full overflow-hidden">
<div className={`h-full ${color} rounded-full`} style={{ width: `${(count / competitivenessData.length) * 100}%` }} />
</div>
</div>
);
})}
</div>
</div>
</section>
)}
{/* KPI Cards */} {/* KPI Cards */}
<section className="max-w-7xl mx-auto px-6 mt-6 grid grid-cols-1 md:grid-cols-3 gap-4"> <section className="max-w-7xl mx-auto px-6 mt-6 grid grid-cols-1 md:grid-cols-3 gap-6">
<Card className="border-slate-200"> <div className="bg-blue-600 rounded-2xl shadow-xl p-6 text-white relative overflow-hidden">
<CardContent className="p-5"> <div className="relative">
<p className="text-slate-500 text-sm mb-1">Average Rate</p> <div className="flex items-center gap-2 mb-2">
<p className="text-2xl font-semibold">{fmtCurrency(kpis.avg)}</p> <DollarSign className="w-5 h-5 opacity-80" />
<p className="text-xs text-slate-400 mt-2">Based on current filters</p> <p className="text-sm font-medium opacity-90">Average Rate</p>
</CardContent> </div>
</Card> <p className="text-4xl font-bold mb-2">{fmtCurrency(kpis.avg)}</p>
<Card className="border-slate-200"> <p className="text-sm opacity-75">Based on {kpis.total} positions</p>
<CardContent className="p-5"> </div>
<p className="text-sm text-slate-500 mb-1">Total Positions</p> </div>
<p className="text-2xl font-semibold">{kpis.total}</p>
<p className="text-xs text-slate-400 mt-2"> <div className="bg-purple-600 rounded-2xl shadow-xl p-6 text-white relative overflow-hidden">
{pricebook === "Approved" ? "Approved prices" : `${pricebook} pricebook`} <div className="relative">
</p> <div className="flex items-center gap-2 mb-2">
</CardContent> <Building2 className="w-5 h-5 opacity-80" />
</Card> <p className="text-sm font-medium opacity-90">Total Positions</p>
<Card className="border-slate-200"> </div>
<CardContent className="p-5"> <p className="text-4xl font-bold mb-2">{kpis.total}</p>
<p className="text-sm text-slate-500 mb-1">Price Range</p> <p className="text-sm opacity-75">{pricebook} rate book</p>
<p className="text-2xl font-semibold"> </div>
</div>
<div className="bg-emerald-600 rounded-2xl shadow-xl p-6 text-white relative overflow-hidden">
<div className="relative">
<div className="flex items-center gap-2 mb-2">
<MapPin className="w-5 h-5 opacity-80" />
<p className="text-sm font-medium opacity-90">Price Range</p>
</div>
<p className="text-3xl font-bold mb-2">
{fmtCurrency(kpis.min)} {fmtCurrency(kpis.max)} {fmtCurrency(kpis.min)} {fmtCurrency(kpis.max)}
</p> </p>
<p className="text-xs text-slate-400 mt-2">Min Max</p> <p className="text-sm opacity-75">Min Max spread</p>
</CardContent> </div>
</Card> </div>
</section> </section>
{/* Region Filters - Only for Approved Enterprise Rates */}
{isApprovedRate && (
<section className="max-w-7xl mx-auto px-6 mt-6">
<div className="bg-white rounded-2xl shadow-lg border border-slate-200 p-6">
<div className="flex items-center gap-2 mb-4">
<MapPin className="w-5 h-5 text-emerald-600" />
<h3 className="text-sm font-bold text-slate-700 uppercase tracking-wider">Service Regions</h3>
</div>
<div className="flex items-center flex-wrap gap-3">
{REGIONS.map(region => (
<button
key={region}
onClick={() => setActiveRegion(region)}
className={`px-5 py-2.5 rounded-xl text-sm font-semibold border-2 transition-all ${
activeRegion === region
? "bg-emerald-600 text-white border-emerald-600 shadow-lg"
: "bg-white text-slate-700 border-slate-200 hover:border-emerald-300 hover:bg-emerald-50"
}`}
>
{region}
</button>
))}
</div>
</div>
</section>
)}
{/* Category Filters + Search */} {/* Category Filters + Search */}
<section className="max-w-7xl mx-auto px-6 mt-6"> <section className="max-w-7xl mx-auto px-6 mt-6">
<div className="flex items-center flex-wrap gap-2"> <div className="bg-white rounded-2xl shadow-lg border border-slate-200 p-6">
{["All", ...CATEGORIES].map(cat => ( <div className="flex items-center justify-between mb-4">
<button <h3 className="text-sm font-bold text-slate-700 flex items-center gap-2">
key={cat} <Search className="w-4 h-4" />
onClick={() => setActiveCategory(cat)} CATEGORY & SEARCH
className={`px-3 py-1.5 rounded-full text-sm border transition-all ${ </h3>
activeCategory === cat {(search || activeCategory !== "All" || activeRegion !== "All") && (
? "bg-slate-900 text-white border-slate-900" <button
: "bg-white hover:bg-slate-50" onClick={() => {
}`} setSearch("");
> setActiveCategory("All");
{cat} setActiveRegion("All");
</button> }}
))} className="text-xs text-blue-600 hover:text-blue-700 font-medium"
<div className="ml-auto w-full md:w-72"> >
<Input Clear all filters
value={search} </button>
onChange={e => setSearch(e.target.value)} )}
placeholder="Search positions…" </div>
className="w-full" <div className="flex items-center flex-wrap gap-3">
/> {["All", ...CATEGORIES].map(cat => (
<button
key={cat}
onClick={() => setActiveCategory(cat)}
className={`px-4 py-2.5 rounded-xl text-sm font-medium border-2 transition-all ${
activeCategory === cat
? "bg-slate-800 text-white border-slate-900 shadow-lg"
: "bg-slate-50 text-slate-700 border-slate-200 hover:border-slate-300 hover:bg-slate-100"
}`}
>
{cat}
</button>
))}
</div>
<div className="mt-4">
<div className="relative">
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 w-5 h-5 text-slate-400" />
<Input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search positions..."
className="pl-12 h-12 bg-slate-50 border-slate-200 rounded-xl text-base"
/>
</div>
</div> </div>
</div> </div>
</section> </section>
{/* Table */} {/* Table */}
<section className="max-w-7xl mx-auto px-6 mt-4 mb-8"> <section className="max-w-7xl mx-auto px-6 mt-6 mb-8">
<div className="overflow-hidden rounded-2xl border bg-white"> <div className="overflow-hidden rounded-2xl shadow-xl bg-white border border-slate-200">
<div className="bg-slate-800 px-6 py-4">
<h3 className="text-white font-bold text-lg">Rate Breakdown</h3>
<p className="text-slate-300 text-sm">Detailed pricing for {pricebook}</p>
</div>
<table className="w-full text-left"> <table className="w-full text-left">
<thead className="bg-slate-50"> <thead className="bg-gradient-to-r from-slate-50 to-slate-100 border-b-2 border-slate-200">
<tr className="text-slate-500 text-sm"> <tr className="text-slate-700 text-xs font-bold uppercase tracking-wider">
<th className="px-4 py-3">Position</th> <th className="px-6 py-4">Position</th>
<th className="px-4 py-3">Category</th> <th className="px-6 py-4">Category</th>
<th className="px-4 py-3">Region</th> <th className="px-6 py-4">Region</th>
<th className="px-4 py-3">Employee Wage</th> <th className="px-6 py-4">Base Wage</th>
<th className="px-4 py-3">Client Rate</th> <th className="px-6 py-4">{pricebook} Rate</th>
<th className="px-4 py-3">Markup %</th> <th className="px-6 py-4">Market Rate</th>
<th className="px-4 py-3">VA Fee %</th> <th className="px-6 py-4">Competitive Score</th>
<th className="px-4 py-3">Status</th> <th className="px-6 py-4 text-center">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{filtered.map((r, idx) => { {filtered.map((r, idx) => {
const parsed = parseRoleName(r.position); const parsed = parseRoleName(r.position);
const competitive = competitivenessData?.find(c => c.position === parsed.position);
const isEditing = editing === r.id;
return ( return (
<tr key={r.id || idx} className="border-t hover:bg-slate-50/60"> <tr key={r.id || idx} className={`border-b border-slate-100 transition-all hover:bg-blue-50/30 ${idx % 2 === 0 ? 'bg-white' : 'bg-slate-50/50'}`}>
<td className="px-4 py-3 font-medium">{parsed.position}</td> <td className="px-6 py-5">
<td className="px-4 py-3"> <div className="flex items-center gap-3">
<Badge variant="outline" className="text-xs">{r.category}</Badge> <div className="w-10 h-10 bg-blue-600 rounded-xl flex items-center justify-center text-white font-bold text-sm shadow-md">
{parsed.position.substring(0, 2).toUpperCase()}
</div>
<span className="font-bold text-slate-900">{parsed.position}</span>
</div>
</td> </td>
<td className="px-4 py-3">{parsed.region || '—'}</td> <td className="px-6 py-5">
<td className="px-4 py-3">{fmtCurrency(r.employee_wage)}</td> <Badge className="bg-slate-100 text-slate-700 border border-slate-200 text-xs font-semibold px-3 py-1">
<td className="px-4 py-3 font-semibold text-[#0A39DF]">{fmtCurrency(r.proposedRate)}</td> {r.category}
<td className="px-4 py-3">{r.markupPct}%</td> </Badge>
<td className="px-4 py-3">{r.volDiscountPct}%</td> </td>
<td className="px-4 py-3"> <td className="px-6 py-5">
<Badge className="bg-green-100 text-green-700">Approved</Badge> <span className="text-sm text-slate-600 flex items-center gap-1">
<MapPin className="w-3 h-3" />
{r.region}
</span>
</td>
<td className="px-6 py-5">
<span className="text-sm font-semibold text-slate-700">{fmtCurrency(r.employee_wage)}</span>
</td>
<td className="px-6 py-5">
{isEditing ? (
<input
type="number"
step="0.01"
defaultValue={r.proposedRate}
className="w-28 px-3 py-2 border-2 border-blue-300 rounded-lg font-semibold text-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
onBlur={(e) => {
setEditing(null);
setToast("✓ Rate updated successfully");
}}
/>
) : (
<div className="flex items-center gap-2">
<span className="text-xl font-bold text-blue-600">{fmtCurrency(r.proposedRate)}</span>
<span className="text-xs text-slate-500">/hr</span>
</div>
)}
</td>
<td className="px-6 py-5">
{competitive ? (
<div className="flex items-center gap-2">
<div>
<span className="font-semibold text-slate-900">{fmtCurrency(competitive.marketRate)}</span>
<div className={`inline-flex items-center ml-2 px-2 py-0.5 rounded-full text-xs font-bold ${
r.proposedRate < competitive.marketRate
? 'bg-green-100 text-green-700'
: 'bg-red-100 text-red-700'
}`}>
{r.proposedRate < competitive.marketRate ? '↓' : '↑'}
{Math.abs(((r.proposedRate - competitive.marketRate) / competitive.marketRate * 100)).toFixed(1)}%
</div>
</div>
</div>
) : (
<span className="text-slate-400 text-sm italic">Run AI analysis</span>
)}
</td>
<td className="px-6 py-5">
{competitive ? (
<div className="flex items-center gap-3">
<div className={`w-4 h-4 rounded-full shadow-md ${
competitive.status === 'Highly Competitive' ? 'bg-green-500' :
competitive.status === 'Competitive' ? 'bg-blue-500' :
competitive.status === 'Average' ? 'bg-yellow-500' :
'bg-red-500'
}`} />
<div>
<div className="text-lg font-bold text-slate-900">{competitive.score}</div>
<div className="text-xs text-slate-500 font-medium">{competitive.status}</div>
</div>
</div>
) : (
<span className="text-slate-300"></span>
)}
</td>
<td className="px-6 py-5">
<div className="flex items-center justify-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setEditing(isEditing ? null : r.id)}
className={`${isEditing ? 'bg-green-100 text-green-700 hover:bg-green-200' : 'bg-slate-100 text-slate-700 hover:bg-slate-200'} font-semibold px-4`}
>
{isEditing ? '✓ Save' : 'Edit'}
</Button>
</div>
</td> </td>
</tr> </tr>
); );
@@ -302,7 +837,7 @@ function VendorCompanyPricebookView({ user, vendorName }) {
{filtered.length === 0 && ( {filtered.length === 0 && (
<tr> <tr>
<td className="px-4 py-8 text-center text-slate-500" colSpan={8}> <td className="px-4 py-8 text-center text-slate-500" colSpan={8}>
No results. Adjust filters or search. No rates found for {pricebook} rate book. {search.trim() && "Try adjusting your search or "}Switch to a different rate book.
</td> </td>
</tr> </tr>
)} )}
@@ -313,11 +848,20 @@ function VendorCompanyPricebookView({ user, vendorName }) {
{/* Toast */} {/* Toast */}
{toast && ( {toast && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 bg-slate-900 text-white text-sm px-4 py-2 rounded-xl shadow-lg z-50"> <div className="fixed bottom-8 left-1/2 -translate-x-1/2 bg-slate-800 text-white text-sm px-6 py-3 rounded-2xl shadow-2xl z-50 flex items-center gap-3 animate-in slide-in-from-bottom-5">
<div className="w-2 h-2 bg-green-400 rounded-full animate-pulse" />
{toast} {toast}
<button className="ml-3 opacity-70 hover:opacity-100" onClick={() => setToast(null)}></button> <button className="ml-2 opacity-70 hover:opacity-100 font-bold" onClick={() => setToast(null)}></button>
</div> </div>
)} )}
{/* Rate Card Modal */}
<RateCardModal
isOpen={showRateCardModal}
onClose={() => { setShowRateCardModal(false); setEditingRateCard(null); }}
onSave={handleSaveRateCard}
editingCard={editingRateCard}
/>
</div> </div>
); );
} }
@@ -641,4 +1185,4 @@ export default function VendorRates() {
// ALL OTHER ROLES: Show existing region-based rates view // ALL OTHER ROLES: Show existing region-based rates view
return <AdminProcurementRatesView vendorName={vendorName} />; return <AdminProcurementRatesView vendorName={vendorName} />;
} }

View File

@@ -0,0 +1,267 @@
import React from "react";
import { base44 } from "@/api/base44Client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Calendar, Clock, MapPin, DollarSign, CheckCircle, XCircle, AlertCircle } from "lucide-react";
import { format } from "date-fns";
import { useToast } from "@/components/ui/use-toast";
import { Textarea } from "@/components/ui/textarea";
export default function WorkerShiftProposals() {
const queryClient = useQueryClient();
const { toast } = useToast();
const [declineReason, setDeclineReason] = React.useState({});
const { data: user } = useQuery({
queryKey: ['current-user-proposals'],
queryFn: () => base44.auth.me(),
});
const { data: proposals = [] } = useQuery({
queryKey: ['shift-proposals', user?.id],
queryFn: async () => {
if (!user?.id) return [];
const staff = await base44.entities.Staff.filter({ email: user.email });
if (staff.length === 0) return [];
return base44.entities.ShiftProposal.filter({ staff_id: staff[0].id });
},
enabled: !!user?.id,
initialData: [],
});
const respondMutation = useMutation({
mutationFn: async ({ proposalId, status, reason }) => {
const proposal = proposals.find(p => p.id === proposalId);
await base44.entities.ShiftProposal.update(proposalId, {
proposal_status: status,
responded_at: new Date().toISOString(),
decline_reason: reason || null,
});
if (status === 'ACCEPTED') {
// Update event with confirmed assignment
const event = await base44.entities.Event.list();
const targetEvent = event.find(e => e.id === proposal.event_id);
if (targetEvent) {
const updatedStaff = [
...(targetEvent.assigned_staff || []),
{
staff_id: proposal.staff_id,
staff_name: proposal.staff_name,
role: proposal.role,
email: user.email,
}
];
await base44.entities.Event.update(proposal.event_id, {
assigned_staff: updatedStaff,
status: 'Confirmed',
});
}
// Update availability
const availability = await base44.entities.WorkerAvailability.filter({ staff_id: proposal.staff_id });
if (availability.length > 0) {
const current = availability[0];
await base44.entities.WorkerAvailability.update(current.id, {
scheduled_hours_this_period: (current.scheduled_hours_this_period || 0) + 8,
need_work_index: Math.max(0, current.need_work_index - 10),
});
}
}
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['shift-proposals'] });
queryClient.invalidateQueries({ queryKey: ['events'] });
toast({
title: variables.status === 'ACCEPTED' ? "✅ Shift Accepted" : "Shift Declined",
description: variables.status === 'ACCEPTED'
? "The shift has been added to your schedule"
: "The vendor will be notified of your decision",
});
},
});
const pendingProposals = proposals.filter(p => p.proposal_status === 'PENDING_WORKER_CONFIRMATION');
const pastProposals = proposals.filter(p => p.proposal_status !== 'PENDING_WORKER_CONFIRMATION');
return (
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
<div className="max-w-5xl mx-auto space-y-6">
<div>
<h1 className="text-2xl font-bold text-slate-900">Shift Requests</h1>
<p className="text-sm text-slate-500 mt-1">Review and respond to shift offers from vendors</p>
</div>
{/* Pending Requests */}
<div className="space-y-4">
<h2 className="text-lg font-bold text-slate-900">Pending Requests ({pendingProposals.length})</h2>
{pendingProposals.length === 0 ? (
<Card>
<CardContent className="p-12 text-center">
<AlertCircle className="w-12 h-12 mx-auto mb-4 text-slate-300" />
<p className="text-slate-500 font-medium">No pending shift requests</p>
<p className="text-sm text-slate-400 mt-1">New offers will appear here</p>
</CardContent>
</Card>
) : (
pendingProposals.map((proposal) => {
const deadline = new Date(proposal.response_deadline);
const isUrgent = deadline < new Date(Date.now() + 24 * 60 * 60 * 1000);
return (
<Card key={proposal.id} className={`border-2 ${isUrgent ? 'border-orange-300 bg-orange-50' : 'border-blue-300 bg-blue-50'}`}>
<CardHeader className="border-b">
<div className="flex items-start justify-between">
<div>
<CardTitle className="text-lg">{proposal.event_name}</CardTitle>
<p className="text-sm text-slate-600 mt-1">{proposal.role}</p>
</div>
{proposal.was_marked_unavailable && (
<Badge variant="outline" className="bg-yellow-100 text-yellow-800 border-yellow-300">
Override Unavailable
</Badge>
)}
</div>
</CardHeader>
<CardContent className="p-6 space-y-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4 text-slate-500" />
<div>
<p className="text-xs text-slate-500">Date</p>
<p className="text-sm font-semibold">{format(new Date(proposal.shift_date), 'MMM d, yyyy')}</p>
</div>
</div>
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-slate-500" />
<div>
<p className="text-xs text-slate-500">Time</p>
<p className="text-sm font-semibold">{proposal.start_time} - {proposal.end_time}</p>
</div>
</div>
<div className="flex items-center gap-2">
<MapPin className="w-4 h-4 text-slate-500" />
<div>
<p className="text-xs text-slate-500">Location</p>
<p className="text-sm font-semibold">{proposal.location}</p>
</div>
</div>
<div className="flex items-center gap-2">
<DollarSign className="w-4 h-4 text-slate-500" />
<div>
<p className="text-xs text-slate-500">Pay</p>
<p className="text-sm font-semibold">${proposal.total_pay}</p>
</div>
</div>
</div>
{isUrgent && (
<div className="bg-orange-100 border border-orange-300 rounded-lg p-3">
<p className="text-sm text-orange-800 font-medium">
Respond by {format(deadline, 'MMM d, h:mm a')}
</p>
</div>
)}
{declineReason[proposal.id] !== undefined && (
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700">Reason for declining (optional)</label>
<Textarea
placeholder="e.g., Schedule conflict, too far to travel, etc."
value={declineReason[proposal.id]}
onChange={(e) => setDeclineReason({ ...declineReason, [proposal.id]: e.target.value })}
rows={2}
/>
</div>
)}
<div className="flex gap-3">
{declineReason[proposal.id] === undefined ? (
<>
<Button
onClick={() => respondMutation.mutate({ proposalId: proposal.id, status: 'ACCEPTED' })}
className="flex-1 bg-green-600 hover:bg-green-700"
disabled={respondMutation.isPending}
>
<CheckCircle className="w-4 h-4 mr-2" />
Accept Shift
</Button>
<Button
onClick={() => setDeclineReason({ ...declineReason, [proposal.id]: '' })}
variant="outline"
className="flex-1 border-red-300 text-red-700 hover:bg-red-50"
>
<XCircle className="w-4 h-4 mr-2" />
Decline
</Button>
</>
) : (
<>
<Button
onClick={() => respondMutation.mutate({
proposalId: proposal.id,
status: 'DECLINED',
reason: declineReason[proposal.id]
})}
className="flex-1 bg-red-600 hover:bg-red-700"
disabled={respondMutation.isPending}
>
Confirm Decline
</Button>
<Button
onClick={() => {
const newReasons = { ...declineReason };
delete newReasons[proposal.id];
setDeclineReason(newReasons);
}}
variant="outline"
>
Cancel
</Button>
</>
)}
</div>
</CardContent>
</Card>
);
})
)}
</div>
{/* Past Responses */}
{pastProposals.length > 0 && (
<div className="space-y-4">
<h2 className="text-lg font-bold text-slate-900">Past Responses</h2>
{pastProposals.map((proposal) => (
<Card key={proposal.id} className="border">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="font-semibold text-slate-900">{proposal.event_name}</p>
<p className="text-sm text-slate-500">{format(new Date(proposal.shift_date), 'MMM d, yyyy')}</p>
</div>
<Badge className={
proposal.proposal_status === 'ACCEPTED'
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}>
{proposal.proposal_status}
</Badge>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -134,6 +134,16 @@ import InvoiceDetail from "./InvoiceDetail";
import InvoiceEditor from "./InvoiceEditor"; import InvoiceEditor from "./InvoiceEditor";
//import api-docs-raw from "./api-docs-raw";
import Tutorials from "./Tutorials";
import Schedule from "./Schedule";
import StaffAvailability from "./StaffAvailability";
import WorkerShiftProposals from "./WorkerShiftProposals";
import { BrowserRouter as Router, Route, Routes, useLocation } from 'react-router-dom'; import { BrowserRouter as Router, Route, Routes, useLocation } from 'react-router-dom';
const PAGES = { const PAGES = {
@@ -272,6 +282,16 @@ const PAGES = {
InvoiceEditor: InvoiceEditor, InvoiceEditor: InvoiceEditor,
//api-docs-raw: api-docs-raw,
Tutorials: Tutorials,
Schedule: Schedule,
StaffAvailability: StaffAvailability,
WorkerShiftProposals: WorkerShiftProposals,
} }
function _getCurrentPage(url) { function _getCurrentPage(url) {
@@ -433,6 +453,16 @@ function PagesContent() {
<Route path="/InvoiceEditor" element={<InvoiceEditor />} /> <Route path="/InvoiceEditor" element={<InvoiceEditor />} />
<Route path="/api-docs-raw" element={<api-docs-raw />} />
<Route path="/Tutorials" element={<Tutorials />} />
<Route path="/Schedule" element={<Schedule />} />
<Route path="/StaffAvailability" element={<StaffAvailability />} />
<Route path="/WorkerShiftProposals" element={<WorkerShiftProposals />} />
</Routes> </Routes>
</Layout> </Layout>
); );